diff --git a/.changeset/cn-wrap-runtime-cache.md b/.changeset/cn-wrap-runtime-cache.md new file mode 100644 index 0000000000..9b144f578c --- /dev/null +++ b/.changeset/cn-wrap-runtime-cache.md @@ -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]` 等颜色 + 长度类可以共存。 diff --git a/packages-runtime/cn/AGENTS.md b/packages-runtime/cn/AGENTS.md index f39fda3acd..eab2f4d6f4 100644 --- a/packages-runtime/cn/AGENTS.md +++ b/packages-runtime/cn/AGENTS.md @@ -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`。 diff --git a/packages-runtime/cn/README.md b/packages-runtime/cn/README.md index a96d1e1b87..8141175aee 100644 --- a/packages-runtime/cn/README.md +++ b/packages-runtime/cn/README.md @@ -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' diff --git a/packages-runtime/cn/README.zh-CN.md b/packages-runtime/cn/README.zh-CN.md index 9b7dcc613a..daf043834e 100644 --- a/packages-runtime/cn/README.zh-CN.md +++ b/packages-runtime/cn/README.zh-CN.md @@ -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' diff --git a/packages-runtime/cn/src/index.ts b/packages-runtime/cn/src/index.ts index 9d7bede684..3225c1f18f 100644 --- a/packages-runtime/cn/src/index.ts +++ b/packages-runtime/cn/src/index.ts @@ -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 } diff --git a/packages-runtime/cn/test/index.test.ts b/packages-runtime/cn/test/index.test.ts index c1f22df9f7..664ca8b640 100644 --- a/packages-runtime/cn/test/index.test.ts +++ b/packages-runtime/cn/test/index.test.ts @@ -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')) + }) }) diff --git a/packages-runtime/runtime/src/create-runtime.ts b/packages-runtime/runtime/src/create-runtime.ts index 92519b4c32..62bc50ce17 100644 --- a/packages-runtime/runtime/src/create-runtime.ts +++ b/packages-runtime/runtime/src/create-runtime.ts @@ -6,6 +6,7 @@ import type { TailwindMergeVersion, Transformers, } from './types' +import { MappingChars2String } from '@weapp-core/escape' import { clsx } from 'clsx' import { resolveTransformers } from './transformers' @@ -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(' ') @@ -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 @@ -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, @@ -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() @@ -129,7 +146,7 @@ function wrapFactory( ): TailwindMergeFactory { return (...args: Parameters) => { const runtime = factory(...args) - return wrapClassAggregator(runtime, transformers, prepareValue, restoreValue) + return wrapRuntimeAggregator(runtime, transformers, prepareValue, restoreValue) } } @@ -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) diff --git a/packages-runtime/runtime/src/index.ts b/packages-runtime/runtime/src/index.ts index 99cff0bc7b..503247c28c 100644 --- a/packages-runtime/runtime/src/index.ts +++ b/packages-runtime/runtime/src/index.ts @@ -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' diff --git a/packages-runtime/runtime/test-d/index.test-d.ts b/packages-runtime/runtime/test-d/index.test-d.ts index 90b93a15e4..afcb9fd546 100644 --- a/packages-runtime/runtime/test-d/index.test-d.ts +++ b/packages-runtime/runtime/test-d/index.test-d.ts @@ -10,6 +10,7 @@ import { identity, resolveTransformers, weappTwIgnore, + wrapRuntimeAggregator, } from '..' expectType(clsx('text-sm', false)) @@ -39,3 +40,9 @@ expectType(runtime.twMerge('text-sm', false as ClassValue)) expectType(runtime.twJoin('text-sm', 'text-lg')) expectType(runtime.extendTailwindMerge()('text-sm')) expectType(runtime.createTailwindMerge()('text-sm')) + +const wrapped = wrapRuntimeAggregator( + (value: string) => value, + resolveTransformers(), +) +expectType(wrapped('text-sm', false as ClassValue)) diff --git a/packages-runtime/runtime/test/wrap-runtime-aggregator.test.ts b/packages-runtime/runtime/test/wrap-runtime-aggregator.test.ts new file mode 100644 index 0000000000..dbbb840177 --- /dev/null +++ b/packages-runtime/runtime/test/wrap-runtime-aggregator.test.ts @@ -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') + }) +})