Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cn-wrap-runtime-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@weapp-tailwindcss/runtime': patch
'@weapp-tailwindcss/cn': patch
---

导出 `wrapRuntimeAggregator`,让 `@weapp-tailwindcss/cn` 复用有界 LRU 与按需 unescape/escape,并接入与 merge 相同的 rpx 长度归一化,使 `text-red` 与 `text-[80rpx]` 等颜色 + 长度类可以共存。
2 changes: 2 additions & 0 deletions packages-runtime/cn/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

该包提供 shadcn 风格的 `cn` 类名组合函数,必须保持 `clsx` 输入语义与小程序兼容的 Tailwind 合并行为。

默认经 `wrapRuntimeAggregator` 接入与 `@weapp-tailwindcss/merge` 相同的 `createRpxLengthTransform`(`text` / `border` / `bg` / `outline` / `ring`),使 `text-red` 与 `text-[80rpx]` 这类颜色 + rpx 长度可以共存。

变更类名组合或 rpx 处理时补充 Vitest 回归测试,并运行 `pnpm --filter @weapp-tailwindcss/cn test`、`tsd` 和 `build`。
2 changes: 1 addition & 1 deletion packages-runtime/cn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> English | [简体中文](./README.zh-CN.md)

基于 npm `cn` 引擎的面向小程序 Tailwind CSS 类名组合工具,兼容条件类名、嵌套数组、Tailwind 冲突合并和 `rpx` 任意值。
基于 npm `cn` 引擎的面向小程序 Tailwind CSS 类名组合工具,兼容条件类名、嵌套数组、Tailwind 冲突合并和 `rpx` 任意值。`text` / `border` / `bg` / `outline` / `ring` 的 rpx 长度会与颜色类共存,例如 `cn('text-red', 'text-[80rpx]')`。

```ts
import { cn } from '@weapp-tailwindcss/cn'
Expand Down
2 changes: 1 addition & 1 deletion packages-runtime/cn/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> [English](./README.md) | 简体中文

基于 npm `cn` 引擎的面向小程序 Tailwind CSS 类名组合工具,兼容条件类名、嵌套数组、Tailwind 冲突合并和 `rpx` 任意值。
基于 npm `cn` 引擎的面向小程序 Tailwind CSS 类名组合工具,兼容条件类名、嵌套数组、Tailwind 冲突合并和 `rpx` 任意值。`text` / `border` / `bg` / `outline` / `ring` 的 rpx 长度会与颜色类共存,例如 `cn('text-red', 'text-[80rpx]')`。

```ts
import { cn } from '@weapp-tailwindcss/cn'
Expand Down
27 changes: 17 additions & 10 deletions packages-runtime/cn/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import type { ClassValue } from '@weapp-tailwindcss/runtime'
import { clsx, resolveTransformers } from '@weapp-tailwindcss/runtime'
import { cn as mergeClasses } from 'cn'
import {
createRpxLengthTransform,
resolveTransformers,
wrapRuntimeAggregator,
} from '@weapp-tailwindcss/runtime'
import { twMerge } from 'cn'

const transformers = resolveTransformers()
const rpxTransform = createRpxLengthTransform()

/** 组合条件类名,并按 Tailwind 冲突规则保留最后一个类名。 */
export function cn(...inputs: ClassValue[]): string {
const value = clsx(inputs)
if (!value) {
return value
}
return transformers.escape(mergeClasses(transformers.unescape(value)))
}
/**
* 组合条件类名,并按 Tailwind 冲突规则保留最后一个类名。
* 经 runtime 聚合包装:有界 LRU、rpx 长度归一化、按需 unescape/escape。
*/
export const cn = wrapRuntimeAggregator(
twMerge,
transformers,
rpxTransform.prepareValue,
rpxTransform.restoreValue,
)

export type { ClassValue }
24 changes: 24 additions & 0 deletions packages-runtime/cn/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,31 @@ describe('cn', () => {
expect(cn('hover:p-2', 'hover:p-4')).toBe(escapeClassName('hover:p-4'))
})

it('把 text/border/bg/outline/ring 的 rpx 任意值当作长度,不与颜色互斥', () => {
expect(cn('text-red', 'text-[80rpx]')).toBe(escapeClassName('text-red text-[80rpx]'))
expect(cn('border-red-500', 'border-[10rpx]')).toBe(escapeClassName('border-red-500 border-[10rpx]'))
expect(cn('bg-red-500', 'bg-[6rpx]')).toBe(escapeClassName('bg-red-500 bg-[6rpx]'))
expect(cn('outline-red-500', 'outline-[4rpx]')).toBe(escapeClassName('outline-red-500 outline-[4rpx]'))
expect(cn('ring-red-500', 'ring-[12rpx]')).toBe(escapeClassName('ring-red-500 ring-[12rpx]'))
})

it('同一前缀多次 rpx 只保留最后一次长度,颜色仍在', () => {
expect(cn('text-red', 'text-[20rpx]', 'text-[5rpx]', 'text-[12rpx]')).toBe(
escapeClassName('text-red text-[12rpx]'),
)
expect(cn('hover:text-[24rpx]', 'hover:text-[12rpx]')).toBe(
escapeClassName('hover:text-[12rpx]'),
)
})

it('保留非 Tailwind 类名', () => {
expect(cn('custom-card', 'p-4', 'p-2')).toBe('custom-card p-2')
})

it('同一输入重复调用结果稳定', () => {
const first = cn('p-4', 'p-2', 'hover:p-4')
const second = cn('p-4', 'p-2', 'hover:p-4')
expect(first).toBe(second)
expect(first).toBe(escapeClassName('p-2 hover:p-4'))
})
})
27 changes: 22 additions & 5 deletions packages-runtime/runtime/src/create-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
TailwindMergeVersion,
Transformers,
} from './types'
import { MappingChars2String } from '@weapp-core/escape'
import { clsx } from 'clsx'
import { resolveTransformers } from './transformers'

Expand Down Expand Up @@ -40,6 +41,7 @@ interface CreateRuntimeFactoryOptions<
const CACHE_LIMIT = 256

const UNESCAPE_RE = /u[0-9a-f]{3,}/i
const ESCAPE_NEEDLES = Object.keys(MappingChars2String).filter(Boolean)

function hasWhitespace(value: string) {
return value.includes(' ')
Expand All @@ -54,6 +56,15 @@ function shouldUnescape(value: string) {
return value.includes('_') || UNESCAPE_RE.test(value)
}

function shouldEscape(value: string) {
for (const needle of ESCAPE_NEEDLES) {
if (value.includes(needle)) {
return true
}
}
return false
}

function transformTokens(value: string, transformFn: (token: string) => string): string {
if (!value) {
return value
Expand All @@ -64,7 +75,11 @@ function transformTokens(value: string, transformFn: (token: string) => string):
return value.split(/\s+/).filter(Boolean).map(transformFn).join(' ')
}

function wrapClassAggregator(
/**
* 为任意 class 聚合函数套上 unescape / 可选 prepare-restore / escape 与有界 LRU。
* 命中缓存时直接返回已转义结果,避免重复跑引擎。
*/
export function wrapRuntimeAggregator(
fn: TailwindMergeLibraryFn,
transformers: Transformers,
prepareValue?: (value: string) => string | TransformResult,
Expand Down Expand Up @@ -107,7 +122,9 @@ function wrapClassAggregator(
const merged = fn(preparedValue)
const restored = restoreValue ? restoreValue(merged, metadata) : merged

const escaped = transformTokens(restored, transformers.escape)
const escaped = shouldEscape(restored)
? transformTokens(restored, transformers.escape)
: restored

if (cache.size >= CACHE_LIMIT) {
const firstEntry = cache.keys().next()
Expand All @@ -129,7 +146,7 @@ function wrapFactory<TFactory extends TailwindMergeFactoryFn>(
): TailwindMergeFactory<TFactory> {
return (...args: Parameters<TFactory>) => {
const runtime = factory(...args)
return wrapClassAggregator(runtime, transformers, prepareValue, restoreValue)
return wrapRuntimeAggregator(runtime, transformers, prepareValue, restoreValue)
}
}

Expand All @@ -152,8 +169,8 @@ export function createRuntimeFactory<
return function createRuntime(createOptions?: CreateOptions) {
const transformers = resolveTransformers(createOptions)

const twMerge: TailwindMergeRuntime = wrapClassAggregator(twMergeImpl, transformers, prepareValue, restoreValue)
const twJoin: TailwindMergeRuntime = wrapClassAggregator(twJoinImpl, transformers, prepareValue, restoreValue)
const twMerge: TailwindMergeRuntime = wrapRuntimeAggregator(twMergeImpl, transformers, prepareValue, restoreValue)
const twJoin: TailwindMergeRuntime = wrapRuntimeAggregator(twJoinImpl, transformers, prepareValue, restoreValue)
const extendTailwindMerge = wrapFactory(extendFactory, transformers, prepareValue, restoreValue)
const createTailwindMerge = wrapFactory(createFactory, transformers, prepareValue, restoreValue)

Expand Down
2 changes: 1 addition & 1 deletion packages-runtime/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const weappTwIgnoreImpl = String.raw as typeof String.raw

export { clsxFn as clsx }
export const weappTwIgnore = weappTwIgnoreImpl
export { createRuntimeFactory } from './create-runtime'
export { createRuntimeFactory, wrapRuntimeAggregator } from './create-runtime'
export { createRpxLengthTransform } from './rpx-length'
export { identity, resolveTransformers } from './transformers'

Expand Down
7 changes: 7 additions & 0 deletions packages-runtime/runtime/test-d/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
identity,
resolveTransformers,
weappTwIgnore,
wrapRuntimeAggregator,
} from '..'

expectType<string>(clsx('text-sm', false))
Expand Down Expand Up @@ -39,3 +40,9 @@ expectType<string>(runtime.twMerge('text-sm', false as ClassValue))
expectType<string>(runtime.twJoin('text-sm', 'text-lg'))
expectType<string>(runtime.extendTailwindMerge()('text-sm'))
expectType<string>(runtime.createTailwindMerge()('text-sm'))

const wrapped = wrapRuntimeAggregator(
(value: string) => value,
resolveTransformers(),
)
expectType<string>(wrapped('text-sm', false as ClassValue))
67 changes: 67 additions & 0 deletions packages-runtime/runtime/test/wrap-runtime-aggregator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import { wrapRuntimeAggregator } from '@/create-runtime'

describe('wrapRuntimeAggregator', () => {
it('空输入直接返回空串且不进引擎', () => {
const inner = vi.fn((value: string) => value)
const escape = vi.fn((value: string) => value)
const unescape = vi.fn((value: string) => value)
const run = wrapRuntimeAggregator(inner, { escape, unescape })

expect(run('')).toBe('')
expect(run(false, undefined, null)).toBe('')
expect(inner).not.toHaveBeenCalled()
})

it('同一输入第二次命中缓存,不再调用引擎', () => {
const inner = vi.fn((value: string) => value)
const run = wrapRuntimeAggregator(inner, {
escape: value => value,
unescape: value => value,
})

expect(run('p-4', 'p-2')).toBe('p-4 p-2')
expect(run('p-4', 'p-2')).toBe('p-4 p-2')
expect(inner).toHaveBeenCalledTimes(1)
expect(inner).toHaveBeenCalledWith('p-4 p-2')
})

it('无特殊字符时跳过 escape', () => {
const escape = vi.fn((value: string) => `esc:${value}`)
const run = wrapRuntimeAggregator(value => value, {
escape,
unescape: value => value,
})

expect(run('flex items-center')).toBe('flex items-center')
expect(escape).not.toHaveBeenCalled()
})

it('含 modifier 或任意值时仍 escape', () => {
const escape = vi.fn((value: string) => `esc:${value}`)
const run = wrapRuntimeAggregator(value => value, {
escape,
unescape: value => value,
})

expect(run('hover:p-2')).toBe('esc:hover:p-2')
expect(escape).toHaveBeenCalledWith('hover:p-2')
expect(run('w-[10rpx]')).toBe('esc:w-[10rpx]')
})

it('已转义输入才会 unescape', () => {
const unescape = vi.fn((value: string) => value.replaceAll('_c', ':'))
const inner = vi.fn((value: string) => value)
const run = wrapRuntimeAggregator(inner, {
escape: value => value,
unescape,
})

run('flex')
expect(unescape).not.toHaveBeenCalled()

run('hover_cp-2')
expect(unescape).toHaveBeenCalledWith('hover_cp-2')
expect(inner).toHaveBeenCalledWith('hover:p-2')
})
})
Loading