From a38ca3a08ac70154df718229d8c49555630a6bee Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Wed, 28 Oct 2026 21:05:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E7=9A=84=20var=20fallback=20=E5=8D=95=E4=BD=8D=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/clean-moles-open.md | 6 ++ apps/website/src/guide/recipes.md | 4 +- apps/website/src/zh-CN/guide/recipes.md | 3 +- packages/postcss-pxtrans/README.md | 8 ++ packages/postcss-pxtrans/README.zh-CN.md | 7 ++ packages/postcss-pxtrans/src/index.ts | 18 +++- packages/postcss-pxtrans/src/types.ts | 2 + .../postcss-pxtrans/test-d/index.test-d.ts | 4 + .../test/var-fallbacks.test.ts | 64 ++++++++++++ packages/postcss-rule-unit-converter/API.md | 7 +- .../postcss-rule-unit-converter/API.zh-CN.md | 6 ++ .../postcss-rule-unit-converter/MIGRATION.md | 8 +- .../MIGRATION.zh-CN.md | 8 +- .../postcss-rule-unit-converter/README.md | 9 +- .../README.zh-CN.md | 5 +- .../src/defaults.ts | 1 + .../postcss-rule-unit-converter/src/plugin.ts | 3 +- .../src/tokenize.ts | 99 ++++++++++++++++++- .../postcss-rule-unit-converter/src/types.ts | 2 + .../test-d/index.test-d.ts | 4 + .../test/var-fallbacks.test.ts | 88 +++++++++++++++++ scripts/check-cjs-plugins.mjs | 15 +++ 22 files changed, 350 insertions(+), 21 deletions(-) create mode 100644 .changeset/clean-moles-open.md create mode 100644 packages/postcss-pxtrans/test/var-fallbacks.test.ts create mode 100644 packages/postcss-rule-unit-converter/test/var-fallbacks.test.ts diff --git a/.changeset/clean-moles-open.md b/.changeset/clean-moles-open.md new file mode 100644 index 0000000..ceb8816 --- /dev/null +++ b/.changeset/clean-moles-open.md @@ -0,0 +1,6 @@ +--- +"postcss-rule-unit-converter": minor +"postcss-pxtrans": minor +--- + +新增可选的 CSS 变量 fallback 单位转换,保护变量名、字符串与 URL,并保持默认行为和 pxtrans 平台转换兼容性。 diff --git a/apps/website/src/guide/recipes.md b/apps/website/src/guide/recipes.md index a243ce4..d103605 100644 --- a/apps/website/src/guide/recipes.md +++ b/apps/website/src/guide/recipes.md @@ -20,7 +20,9 @@ Use `propList` for property-level control, `selectorBlackList` for selectors, an `postcss-rule-unit-converter` uses CSS tokens by default: signed/scientific values and complete decoded units are supported, while strings, URLs, identifiers, and -the whole `var()` fallback remain untouched. An explicit `unitRegex` replaces +the whole `var()` fallback remain untouched. Set `transformVarFallbacks: true` to +convert fallback dimensions recursively while preserving variable names, strings, +and URLs. An explicit `unitRegex` overrides the fallback option and replaces that protection. The first matching rule wins even when it returns `undefined`, and conversions do not chain in one run; repeated processing may convert again. See the [API reference](/plugins/postcss-rule-unit-converter/api#matching-boundaries). diff --git a/apps/website/src/zh-CN/guide/recipes.md b/apps/website/src/zh-CN/guide/recipes.md index e5e4863..9654139 100644 --- a/apps/website/src/zh-CN/guide/recipes.md +++ b/apps/website/src/zh-CN/guide/recipes.md @@ -20,7 +20,8 @@ Tailwind 输出 `rem` 时,将 `postcss-rem-to-responsive-pixel` 放在 Tailwin `postcss-rule-unit-converter` 默认使用 CSS token,支持带符号数值、科学计数法和 完整解码后的单位,保护字符串、URL、标识符及整个 `var()` fallback。 -显式 `unitRegex` 会替代这些保护。第一条命中规则即为最终选择,即使它返回 +设置 `transformVarFallbacks: true` 可递归转换 fallback,同时继续保护变量名、 +字符串和 URL。显式 `unitRegex` 会替代这些保护和 fallback 选项。第一条命中规则即为最终选择,即使它返回 `undefined`;单轮不连锁转换,重复处理则可能继续转换。详见 [API 参考](/zh-CN/plugins/postcss-rule-unit-converter/api#匹配边界)。 diff --git a/packages/postcss-pxtrans/README.md b/packages/postcss-pxtrans/README.md index d6a1f3d..bb2efbc 100644 --- a/packages/postcss-pxtrans/README.md +++ b/packages/postcss-pxtrans/README.md @@ -182,11 +182,19 @@ RN eject: - `replace`: `boolean`, when `false` it appends a new declaration - `mediaQuery`: `boolean`, convert px in `@media` - `minPixelValue`: `number`, values lower than this are not converted (Harmony still converts to `ch`) +- `transformVarFallbacks`: `boolean`, default `false`; convert fallback dimensions using CSS tokens while keeping variable names, strings, and URLs intact - `onePxTransform`: `boolean`, whether to convert `1px` (Harmony still converts `PX` to `ch`) - `exclude`: `Array | (filePath?: string) => boolean`, skip the whole file when matched > Note: internally this plugin reuses utilities from `postcss-plugin-shared` for prop matching and unit regex building. +With `transformVarFallbacks: true`, `var(--size48px, 48px)` becomes +`var(--size48px, 96rpx)` for `designWidth: 375`. This mode preserves platform unit +case rules and unsigned `rootValue(input, m, value)` callback arguments while +handling complete signed/scientific values. Negative zero may normalize to zero. +The default `false` retains the legacy regex path. This option does not change +`postcss-rem-to-responsive-pixel`. + ### Choosing Between `exclude`, `selectorBlackList`, and `propList` - `exclude`: skip the whole file before any declaration is processed diff --git a/packages/postcss-pxtrans/README.zh-CN.md b/packages/postcss-pxtrans/README.zh-CN.md index d88b414..f1112d4 100644 --- a/packages/postcss-pxtrans/README.zh-CN.md +++ b/packages/postcss-pxtrans/README.zh-CN.md @@ -182,11 +182,18 @@ RN eject: - `replace`: `boolean`,`false` 时追加新声明 - `mediaQuery`: `boolean`,是否转换 `@media` 中的 px - `minPixelValue`: `number`,小于该值时不转换(Harmony 仍会转 `ch`) +- `transformVarFallbacks`:`boolean`,默认 `false`;使用 CSS token 转换 fallback,保护变量名、字符串和 URL - `onePxTransform`: `boolean`,是否转换 1px(Harmony 下 `PX` 仍转 `ch`) - `exclude`:`Array | (filePath?: string) => boolean`,命中直接跳过整文件 > 说明:插件内部复用了 `postcss-plugin-shared` 的通用能力(propList 匹配、单位正则构建等)。 +开启 `transformVarFallbacks: true` 后,`designWidth: 375` 会把 +`var(--size48px, 48px)` 转成 `var(--size48px, 96rpx)`。单位大小写与平台规则、 +`rootValue(input, m, value)` 的无符号回调参数保持原约定,同时完整处理带符号数字 +和科学计数法;负零可能规范化为零。默认 `false` 保留旧正则路径。此选项不改变 +`postcss-rem-to-responsive-pixel`。 + ### `exclude`、`selectorBlackList`、`propList` 怎么选 - `exclude`:文件级跳过,命中后整个文件都不处理 diff --git a/packages/postcss-pxtrans/src/index.ts b/packages/postcss-pxtrans/src/index.ts index 1c1ab28..44945f8 100644 --- a/packages/postcss-pxtrans/src/index.ts +++ b/packages/postcss-pxtrans/src/index.ts @@ -25,6 +25,7 @@ const defaults = { replace: true, mediaQuery: false, minPixelValue: 0, + transformVarFallbacks: false, } const deviceRatio: NonNullable = { @@ -288,6 +289,7 @@ function plugin(userOptions: PxTransformOptions = {}) { const preserveUnit = unConvertTargetUnit ?? targetUnit const unitPrecision = opts.unitPrecision ?? defaults.unitPrecision const minPixelValue = opts.minPixelValue ?? defaults.minPixelValue + const transformVarFallbacks = opts.transformVarFallbacks ?? defaults.transformVarFallbacks const isBlacklisted = hasSelectorBlackList ? createSelectorBlacklistMatcher(opts.selectorBlackList as readonly (string | RegExp)[], { cache: true }) : undefined @@ -296,6 +298,10 @@ function plugin(userOptions: PxTransformOptions = {}) { { from: unit => unit === 'px' || unit === 'rpx', transform(value, context) { + // tokenizer 解码单位用于规则匹配,平台原有大小写和 rpx 范围仍按原始拼写判断。 + if (transformVarFallbacks && !transUnits.includes(context.rawUnit)) { + return undefined + } if (context.prop && !satisfyPropList(context.prop)) { return undefined } @@ -318,7 +324,7 @@ function plugin(userOptions: PxTransformOptions = {}) { } } - if (!onePxTransform && value === 1) { + if (!onePxTransform && Math.abs(value) === 1) { return isHarmony ? { value, @@ -327,7 +333,7 @@ function plugin(userOptions: PxTransformOptions = {}) { : undefined } - if (value < minPixelValue) { + if (Math.abs(value) < minPixelValue) { return isHarmony ? { value, @@ -336,8 +342,11 @@ function plugin(userOptions: PxTransformOptions = {}) { : undefined } + // 正负号参与数值运算,用户回调继续接收旧正则提供的无符号匹配参数。 + const match = transformVarFallbacks ? context.match.replace(/^[+-]/, '') : context.match + const rawValue = transformVarFallbacks ? context.rawValue.replace(/^[+-]/, '') : context.rawValue return { - value: value / getRootValue(context.input)(context.input, context.match, context.rawValue), + value: value / getRootValue(context.input)(context.input, match, rawValue), unit: targetUnit, } }, @@ -353,7 +362,8 @@ function plugin(userOptions: PxTransformOptions = {}) { replace: opts.replace ?? defaults.replace, rules, unitPrecision, - unitRegex: pxRegex(transUnits), + transformVarFallbacks, + unitRegex: transformVarFallbacks ? undefined : pxRegex(transUnits), }) as Plugin return { diff --git a/packages/postcss-pxtrans/src/types.ts b/packages/postcss-pxtrans/src/types.ts index 5d39a0d..5c47690 100644 --- a/packages/postcss-pxtrans/src/types.ts +++ b/packages/postcss-pxtrans/src/types.ts @@ -74,6 +74,8 @@ export interface PxTransformDeviceRatio { * } */ export interface PxTransformOptions { + /** 转换 var() fallback 内的完整长度 token;默认 false,保留既有正则行为。 */ + transformVarFallbacks?: boolean /** * Target platform. * diff --git a/packages/postcss-pxtrans/test-d/index.test-d.ts b/packages/postcss-pxtrans/test-d/index.test-d.ts index 6187bbc..d6f1a1e 100644 --- a/packages/postcss-pxtrans/test-d/index.test-d.ts +++ b/packages/postcss-pxtrans/test-d/index.test-d.ts @@ -16,3 +16,7 @@ const options: PxTransformOptions = { expectAssignable(options) pxtrans(options) expectError(pxtrans({ platform: 'web' })) + +pxtrans({ transformVarFallbacks: true }) +pxtrans({ transformVarFallbacks: false }) +expectError(pxtrans({ transformVarFallbacks: 'true' })) diff --git a/packages/postcss-pxtrans/test/var-fallbacks.test.ts b/packages/postcss-pxtrans/test/var-fallbacks.test.ts new file mode 100644 index 0000000..11f7b0e --- /dev/null +++ b/packages/postcss-pxtrans/test/var-fallbacks.test.ts @@ -0,0 +1,64 @@ +import type { Input } from 'postcss' +import { transform } from './utils' + +describe('pxtrans 可选的 var fallback 转换', () => { + const enabled = { transformVarFallbacks: true } + + it('默认不改变既有行为,开启后按设计稿尺寸转换 fallback', () => { + const source = '.a{width:var(--nutui-48px,48px);height:48px}' + expect(transform(source)).toBe('.a{width:var(--nutui-48px,48px);height:48rpx}') + expect(transform(source, enabled)).toBe('.a{width:var(--nutui-48px,48rpx);height:48rpx}') + expect(transform(source, { ...enabled, designWidth: 375 })) + .toBe('.a{width:var(--nutui-48px,96rpx);height:96rpx}') + expect(transform(source, { ...enabled, transformVarFallbacks: false })).toBe(transform(source)) + }) + + it('保留原始单位大小写、rpx 平台范围、完整 token 边界和符号', () => { + const source = '.a{margin:var(--x,-2px +.5px 1e2px 2Px 2PX 2pX 2rpx item2px 2pxfoo)}' + expect(transform(source, { ...enabled, designWidth: 375 })) + .toBe('.a{margin:var(--x,-4rpx +1rpx 200rpx 2Px 2PX 2pX 2rpx item2px 2pxfoo)}') + expect(transform('.a{width:var(--x,2rpx)}', { ...enabled, platform: 'h5', rootValue: 2 })) + .toBe('.a{width:var(--x,1rem)}') + }) + + it('保持 rootValue 回调的无符号匹配参数和真实 Input', () => { + const rootValue = vi.fn((_input: Input, _match: string, _value: string) => 2) + expect(transform('.a{margin:var(--x,-2px +.5px)}', { ...enabled, rootValue })) + .toBe('.a{margin:var(--x,-1rpx +0.25rpx)}') + expect(rootValue.mock.calls.map(call => call.slice(1))).toEqual([['2px', '2'], ['.5px', '.5']]) + expect(rootValue.mock.calls[0]?.[0]).toMatchObject({ file: expect.stringContaining('input.css') }) + }) + + it('使用绝对值判断负数阈值和一像素保留,并保持舍入精度', () => { + expect(transform('.a{margin:var(--x,-1px -2px -.5px 2px)}', { ...enabled, designWidth: 375, onePxTransform: false, minPixelValue: 2 })) + .toBe('.a{margin:var(--x,-1px -4rpx -.5px 4rpx)}') + expect(transform('.a{margin:var(--x,-1.005px .00001px -0px)}', { ...enabled, rootValue: 1, unitPrecision: 2 })) + .toBe('.a{margin:var(--x,-1.01rpx 0rpx 0rpx)}') + }) + + it('复用属性、选择器、文件排除和禁用指令', () => { + const source = '.a{width:var(--x,2px);height:var(--x,2px)}.skip{width:var(--x,2px)}' + expect(transform(source, { ...enabled, propList: ['width'], selectorBlackList: ['.skip'] })) + .toBe('.a{width:var(--x,2rpx);height:var(--x,2px)}.skip{width:var(--x,2px)}') + expect(transform(source, { ...enabled, exclude: () => true })).toBe(source) + expect(transform(`/* postcss-pxtrans disable */${source}`, enabled)) + .toBe(`/* postcss-pxtrans disable */${source}`) + }) + + it('保留 Harmony 特殊单位及黑名单语义', () => { + expect(transform('.a{margin:var(--x,-1px -2px 2PX)}', { ...enabled, platform: 'harmony', designWidth: 375, onePxTransform: false })) + .toBe('.a{margin:var(--x,-1ch -4px 2ch)}') + expect(transform('.a{margin:var(--x,-2px 2PX)}', { ...enabled, platform: 'harmony', selectorBlackList: ['.a'] })) + .toBe('.a{margin:var(--x,-2ch 2ch)}') + }) + + it('只生成一份 replace:false 结果,并保持 mediaQuery 开关', () => { + const source = '@media (width:var(--x,2px)){.a{width:var(--x,3px)}}' + expect(transform(source, { ...enabled, replace: false, mediaQuery: true })) + .toBe('@media (width:var(--x,2rpx)){.a{width:var(--x,3px);width:var(--x,3rpx)}}') + expect(transform('.a{width:var(--x,8px)}', { ...enabled, platform: 'rn', rootValue: 2, replace: false })) + .toBe('.a{width:var(--x,8px);width:var(--x,4px)}') + expect(transform(source, enabled)) + .toBe('@media (width:var(--x,2px)){.a{width:var(--x,3rpx)}}') + }) +}) diff --git a/packages/postcss-rule-unit-converter/API.md b/packages/postcss-rule-unit-converter/API.md index 33541c5..c90fece 100644 --- a/packages/postcss-rule-unit-converter/API.md +++ b/packages/postcss-rule-unit-converter/API.md @@ -22,6 +22,7 @@ type PostcssUnitConverter = PluginCreator interface UserDefinedOptions { rules?: readonly ConversionRule[] unitRegex?: RegExp + transformVarFallbacks?: boolean unitPrecision?: number minValue?: number keepZeroUnit?: boolean @@ -36,6 +37,7 @@ interface UserDefinedOptions { - `rules`: ordered conversion rules. First match wins. - `unitRegex`: opt out of default CSS tokenization and use a custom regex instead. +- `transformVarFallbacks`: convert dimensions after the first direct comma in `var()` (default `false`). Ignored when `unitRegex` is supplied. - `unitPrecision`: decimal precision for generated values. - `minValue`: global minimum absolute source magnitude to convert (default `0`). Negative lengths remain eligible. - `keepZeroUnit`: keep `0px`/`0rem` style output instead of collapsing to bare `0`. @@ -151,7 +153,10 @@ scientific notation, unit case, and CSS escapes follow CSS token boundaries. Strings, comments, and URL contents remain untouched, including escaped quotes and uppercase `URL()`. The whole `var()` function is skipped at every nesting depth, including its fallback; lengths in other functions such as `calc()` and -`min()` are processed normally. +`min()` are processed normally. With `transformVarFallbacks: true`, fallback +dimensions are converted recursively while the variable name, strings, and URLs +remain intact. This does not resolve variables or select their fallback. An +unclosed `var()` stays unchanged and does not call conversion callbacks. An explicit `unitRegex` replaces tokenization entirely, including those protections. The expression must expose the numeric value as capture group 1 and the unit as diff --git a/packages/postcss-rule-unit-converter/API.zh-CN.md b/packages/postcss-rule-unit-converter/API.zh-CN.md index f58bee5..f241efe 100644 --- a/packages/postcss-rule-unit-converter/API.zh-CN.md +++ b/packages/postcss-rule-unit-converter/API.zh-CN.md @@ -22,6 +22,7 @@ type PostcssUnitConverter = PluginCreator interface UserDefinedOptions { rules?: readonly ConversionRule[] unitRegex?: RegExp + transformVarFallbacks?: boolean unitPrecision?: number minValue?: number keepZeroUnit?: boolean @@ -36,6 +37,7 @@ interface UserDefinedOptions { - `rules`:有序转换规则,先命中的先生效。 - `unitRegex`:关闭默认 CSS tokenizer,改用自定义正则匹配。 +- `transformVarFallbacks`:转换 `var()` 直属第一个逗号之后的 fallback,默认 `false`;显式 `unitRegex` 优先,此时该选项不参与处理。 - `unitPrecision`:输出值的小数精度。 - `minValue`:全局源值绝对值阈值,默认 `0`;负长度也可转换。 - `keepZeroUnit`:保留 `0px`、`0rem` 这类带单位的零值输出,而不是折叠成裸 `0`。 @@ -147,6 +149,10 @@ CSS 转义都按 CSS token 边界处理。字符串、注释与 URL 内容保持 转义引号和大写 `URL()`。默认完整跳过任意嵌套深度的 `var()` 及其 fallback; `calc()`、`min()` 等其他函数中的长度正常转换。 +开启 `transformVarFallbacks: true` 后递归转换 fallback 中的长度,仍保留变量名、 +字符串和 URL;不解析变量、不选择 fallback。未闭合的 `var()` 保持原文, +不会触发其中的转换回调。 + 显式 `unitRegex` 会完全替代 tokenizer 及上述保护。正则必须把数字放在第 1 个 捕获组、单位放在第 2 个捕获组;缺少 `g` 标志时会自动补上,确保每个匹配项都会 处理。捕获到的单位会转成小写用于规则匹配;转义、带符号数字、科学计数法、 diff --git a/packages/postcss-rule-unit-converter/MIGRATION.md b/packages/postcss-rule-unit-converter/MIGRATION.md index 3d6e7ef..716a4cf 100644 --- a/packages/postcss-rule-unit-converter/MIGRATION.md +++ b/packages/postcss-rule-unit-converter/MIGRATION.md @@ -13,9 +13,11 @@ The rule engine uses CSS tokenization by default. It treats signed/scientific numbers as complete values, matches case-insensitive decoded units, and protects strings, URLs, identifiers, and the entire `var()` fallback at any depth. Callbacks receive signed values, and `minValue` compares absolute magnitude. -If your old configuration relied on regex substring matches or converting inside -`var()`, review [matching boundaries](./API.md#matching-boundaries) and explicitly -configure `unitRegex` only when that behavior is intended. +To convert fallback lengths safely, set `transformVarFallbacks: true`. Variable +names, strings, and URLs remain protected; variables are not resolved. If your old +configuration relied on regex substring matches, review +[matching boundaries](./API.md#matching-boundaries) and use `unitRegex` only when +that behavior is intended; it overrides the fallback option too. ## When To Migrate diff --git a/packages/postcss-rule-unit-converter/MIGRATION.zh-CN.md b/packages/postcss-rule-unit-converter/MIGRATION.zh-CN.md index 9afce10..7816ddf 100644 --- a/packages/postcss-rule-unit-converter/MIGRATION.zh-CN.md +++ b/packages/postcss-rule-unit-converter/MIGRATION.zh-CN.md @@ -11,9 +11,11 @@ 规则引擎默认使用 CSS tokenizer,完整解析带符号数字与科学计数法,解码单位并按 小写匹配,保护字符串、URL、标识符及任意深度的整个 `var()` fallback。 -回调收到带符号数值,`minValue` 按绝对值比较。旧配置若依赖正则子串匹配或 -转换 `var()` 中的内容,请查看 [匹配边界](./API.zh-CN.md#匹配边界),仅在确实 -需要时显式配置 `unitRegex`。 +回调收到带符号数值,`minValue` 按绝对值比较。 +需要转换 `var()` fallback 时,优先设置 `transformVarFallbacks: true`,仍保护变量名、 +字符串和 URL,不解析变量。依赖正则子串匹配时,请查看 +[匹配边界](./API.zh-CN.md#匹配边界),仅在确实需要时显式配置 `unitRegex`; +自定义正则仍完整覆盖 fallback 选项。 ## 什么时候该迁移 diff --git a/packages/postcss-rule-unit-converter/README.md b/packages/postcss-rule-unit-converter/README.md index fbd1262..bf43d1b 100644 --- a/packages/postcss-rule-unit-converter/README.md +++ b/packages/postcss-rule-unit-converter/README.md @@ -255,7 +255,8 @@ const result = await postcss([ Advanced matching options: -- `unitRegex`: replace default tokenization with your own matching regex, for example to opt into matching inside `var(...)` +- `transformVarFallbacks`: opt into converting `var()` fallback dimensions while protecting the variable name, strings, and URLs (default `false`) +- `unitRegex`: replace default tokenization with your own matching regex; this remains authoritative even with `transformVarFallbacks: true` - `keepZeroUnit`: keep `0px`, `0rem`, or custom zero outputs instead of collapsing them to bare `0` ## Matching And Processing Boundaries @@ -266,7 +267,9 @@ Signed numbers and scientific notation are parsed as complete values. A rule for `rem` does not match the unit `remfoo` in `2remfoo` or the identifier `foo2rem`. Strings and URLs remain untouched, including escaped quotes and uppercase `URL()`. The entire `var()` function is skipped at every nesting depth; other functions -such as `calc()` and `min()` can contain convertible dimensions. +such as `calc()` and `min()` can contain convertible dimensions. Set +`transformVarFallbacks: true` to convert fallback dimensions at any depth without +resolving the variable or choosing a fallback. An unclosed `var()` stays unchanged. Callbacks receive the signed value: `-2rem` passes `-2`, and `1e3rem` passes `1000`. Global and per-rule `minValue` compare the absolute source magnitude, so @@ -292,7 +295,7 @@ property, value, and `!important`, preserving declarations with different priori - For `presets.viewportPresetGroup()`, choose `viewportUnit: 'vw'` or `viewportUnit: 'vh'` explicitly when you want one axis. - Prefer `definePreset(...)` and `definePresetGroup(...)` when authoring reusable presets in your own package or app config. - Use `RuleContext.rawUnit`, `RuleContext.rawValue`, and `RuleContext.match` when your transform depends on original casing or exact matched text. -- Use `unitRegex` when you need non-default matching behavior such as not skipping `var(...)` fallbacks. +- Use `transformVarFallbacks: true` to convert fallback lengths safely; reserve `unitRegex` for custom matching semantics. - Use `keepZeroUnit` when downstream tooling expects `0` to keep its explicit unit. - If you want more scenario-based examples, see the cookbook. diff --git a/packages/postcss-rule-unit-converter/README.zh-CN.md b/packages/postcss-rule-unit-converter/README.zh-CN.md index e983d3c..6dbfa17 100644 --- a/packages/postcss-rule-unit-converter/README.zh-CN.md +++ b/packages/postcss-rule-unit-converter/README.zh-CN.md @@ -231,7 +231,7 @@ const result = await postcss([ - 使用 `presets.viewportPresetGroup()` 时,建议显式指定 `viewportUnit: 'vw'` 或 `viewportUnit: 'vh'`。 - 在业务里沉淀可复用 preset 时,优先使用 `definePreset(...)` 和 `definePresetGroup(...)`。 - `RuleContext.fromUnit` 是归一化为小写的源单位(默认 tokenizer 还会解码转义);`rawUnit` 保留原始大小写与转义;`rawValue` 是 `Number(...)` 前带符号的数字文本;`match` 是完整匹配片段。 -- 如果需要主动处理 `var()` 中的内容,可以使用自定义 `unitRegex`,并自行保证匹配边界。 +- 开启 `transformVarFallbacks: true` 可安全转换 `var()` fallback 内的长度,默认 `false`;只有需要完全自定义匹配时才使用 `unitRegex`。 - 如果你想直接抄场景配置,优先看 cookbook。 ## 匹配与处理边界 @@ -241,6 +241,9 @@ const result = await postcss([ 按完整数值解析。`rem` 规则不会误改 `2remfoo` 的 `remfoo` 单位,也不会误改 `foo2rem` 这样的标识符。字符串和 URL 保持原样,包括转义引号和大写 `URL()`。 默认完整跳过任意嵌套深度的 `var()`;`calc()`、`min()` 等其他函数里的长度仍可转换。 +开启 `transformVarFallbacks: true` 后转换直属第一个逗号之后的 fallback,保护变量名、 +字符串和 URL;不解析变量、不选择 fallback,未闭合的 `var()` 保持原文。显式 +`unitRegex` 始终完整覆盖 tokenizer,此时 fallback 选项不参与处理。 回调收到真实带符号数值:`-2rem` 传入 `-2`,`1e3rem` 传入 `1000`。 全局与单规则的 `minValue` 都按源值绝对值比较,例如 `minValue: 2` 会转换 diff --git a/packages/postcss-rule-unit-converter/src/defaults.ts b/packages/postcss-rule-unit-converter/src/defaults.ts index d50c94c..b88c4e7 100644 --- a/packages/postcss-rule-unit-converter/src/defaults.ts +++ b/packages/postcss-rule-unit-converter/src/defaults.ts @@ -5,6 +5,7 @@ export type ResolvedOptions = Required export const defaultOptions: ResolvedOptions = { rules: [] as Array, unitRegex: undefined, + transformVarFallbacks: false, unitPrecision: 5, minValue: 0, keepZeroUnit: false, diff --git a/packages/postcss-rule-unit-converter/src/plugin.ts b/packages/postcss-rule-unit-converter/src/plugin.ts index cae1ae9..c8d594e 100644 --- a/packages/postcss-rule-unit-converter/src/plugin.ts +++ b/packages/postcss-rule-unit-converter/src/plugin.ts @@ -280,6 +280,7 @@ const plugin: PostcssUnitConverter = (options: UserDefinedOptions = {}) => { const { rules, unitRegex: customUnitRegex, + transformVarFallbacks, unitPrecision, minValue, keepZeroUnit, @@ -358,7 +359,7 @@ const plugin: PostcssUnitConverter = (options: UserDefinedOptions = {}) => { } } else { - walkOptions.createValueTransformer = context => createValueTransformer(createConverter(context)) + walkOptions.createValueTransformer = context => createValueTransformer(createConverter(context), transformVarFallbacks) } walkAndReplaceValues(walkOptions) }, diff --git a/packages/postcss-rule-unit-converter/src/tokenize.ts b/packages/postcss-rule-unit-converter/src/tokenize.ts index 82d9420..df3ffdc 100644 --- a/packages/postcss-rule-unit-converter/src/tokenize.ts +++ b/packages/postcss-rule-unit-converter/src/tokenize.ts @@ -7,7 +7,94 @@ const NUMBER_PREFIX = /^[+-]?(?:\d*\.\d+|\d+)(?:e[+-]?\d+)?/i type ConvertDimension = (match: string, rawValue: string, rawUnit: string, decodedUnit: string) => string -export function createValueTransformer(convert: ConvertDimension) { +interface BlockFrame { + closing: TokenType + start: number + isVar: boolean + protected: boolean +} + +interface DimensionCandidate { + start: number + end: number + raw: string + rawValue: string + rawUnit: string + decodedUnit: string +} + +function createFallbackValueTransformer(convert: ConvertDimension) { + return (value: string) => { + const stream = tokenizer({ css: value }) + const blocks: BlockFrame[] = [] + const candidates: DimensionCandidate[] = [] + let protectedDepth = 0 + + while (true) { + const token = stream.nextToken() + const type = token[0] + if (type === TokenType.EOF) { + break + } + + if (type === TokenType.Function) { + const name = token[4].value.toLowerCase() + const protectedBlock = name === 'var' || name === 'url' + blocks.push({ closing: TokenType.CloseParen, start: token[2], isVar: name === 'var', protected: protectedBlock }) + if (protectedBlock) { + protectedDepth += 1 + } + } + else if (type === TokenType.OpenParen || type === TokenType.OpenSquare || type === TokenType.OpenCurly) { + const closing = type === TokenType.OpenParen + ? TokenType.CloseParen + : type === TokenType.OpenSquare ? TokenType.CloseSquare : TokenType.CloseCurly + blocks.push({ closing, start: token[2], isVar: false, protected: false }) + } + else if (type === TokenType.Comma) { + const block = blocks.at(-1) + // 只允许直属逗号结束变量名;嵌套函数内部的逗号不属于当前 var。 + if (block?.isVar && block.protected) { + block.protected = false + protectedDepth -= 1 + } + } + else if (type === blocks.at(-1)?.closing) { + const block = blocks.pop()! + if (block.protected) { + protectedDepth -= 1 + } + } + else if (protectedDepth === 0 && (type === TokenType.Dimension || type === TokenType.Percentage)) { + const rawValue = token[1].match(NUMBER_PREFIX)![0] + candidates.push({ + start: token[2], + end: token[3] + 1, + raw: token[1], + rawValue, + rawUnit: token[1].slice(rawValue.length), + decodedUnit: type === TokenType.Dimension ? token[4].unit : '%', + }) + } + } + + // 未闭合 var 连同内部已闭合子函数保持原文,也不调用可能有副作用的转换回调。 + const unclosedVarStart = blocks.find(block => block.isVar)?.start ?? value.length + let output = '' + let cursor = 0 + for (const candidate of candidates) { + if (candidate.start >= unclosedVarStart) { + break + } + output += value.slice(cursor, candidate.start) + output += convert(candidate.raw, candidate.rawValue, candidate.rawUnit, candidate.decodedUnit) + cursor = candidate.end + } + return output + value.slice(cursor) + } +} + +function createProtectedValueTransformer(convert: ConvertDimension) { return (value: string) => { const stream = tokenizer({ css: value }) const skippedBlocks: TokenType[] = [] @@ -39,8 +126,8 @@ export function createValueTransformer(convert: ConvertDimension) { if (type === TokenType.Function) { const name = token[4].value.toLowerCase() - // Unquoted URLs are atomic URL/BadURL tokens; quoted URLs are function - // tokens. Escaped and mixed-case spellings have already been decoded. + // 无引号 URL 由原子 token 表示;带引号 URL 使用函数 token。 + // tokenizer 已解码转义与混合大小写函数名。 if (name === 'var' || name === 'url') { skippedBlocks.push(TokenType.CloseParen) } @@ -58,3 +145,9 @@ export function createValueTransformer(convert: ConvertDimension) { } } } + +export function createValueTransformer(convert: ConvertDimension, transformVarFallbacks = false) { + return transformVarFallbacks + ? createFallbackValueTransformer(convert) + : createProtectedValueTransformer(convert) +} diff --git a/packages/postcss-rule-unit-converter/src/types.ts b/packages/postcss-rule-unit-converter/src/types.ts index ebfab86..a4196b2 100644 --- a/packages/postcss-rule-unit-converter/src/types.ts +++ b/packages/postcss-rule-unit-converter/src/types.ts @@ -105,6 +105,8 @@ export interface UserDefinedOptions { * The expression controls its own token boundaries and protected functions. */ unitRegex?: RegExp | undefined + /** 转换 var() 逗号之后的 fallback;默认 false,显式 unitRegex 仍完整覆盖此策略。 */ + transformVarFallbacks?: boolean unitPrecision?: number /** Minimum absolute magnitude of the original value. Defaults to 0. */ minValue?: number diff --git a/packages/postcss-rule-unit-converter/test-d/index.test-d.ts b/packages/postcss-rule-unit-converter/test-d/index.test-d.ts index d426e9d..49f105f 100644 --- a/packages/postcss-rule-unit-converter/test-d/index.test-d.ts +++ b/packages/postcss-rule-unit-converter/test-d/index.test-d.ts @@ -76,3 +76,7 @@ expectType(noOptions()) expectError(noOptions(2)) expectError(definePreset(() => presets.remToPx())) expectError(definePresetGroup(() => [presets.remToPx()])) + +plugin({ transformVarFallbacks: true }) +plugin({ transformVarFallbacks: false }) +expectError(plugin({ transformVarFallbacks: 'true' })) diff --git a/packages/postcss-rule-unit-converter/test/var-fallbacks.test.ts b/packages/postcss-rule-unit-converter/test/var-fallbacks.test.ts new file mode 100644 index 0000000..a02714a --- /dev/null +++ b/packages/postcss-rule-unit-converter/test/var-fallbacks.test.ts @@ -0,0 +1,88 @@ +import postcss from 'postcss' +import unitConverter from '../src/index' + +function convert(value: string, extra = {}) { + const root = postcss.parse('.a{width:initial}', { from: 'input.css' }) + root.walkDecls((decl) => { + decl.value = value + }) + return postcss(unitConverter({ + rules: [{ from: 'px', to: 'rpx', factor: 2 }], + transformVarFallbacks: true, + ...extra, + })).process(root, { from: 'input.css' }).css +} + +describe('可选的 var fallback 单位转换', () => { + it('默认保留整个 var,并允许显式开启或关闭', () => { + const source = '.a{width:var(--size48px,48px);height:48px}' + const rules = [{ from: 'px', to: 'rpx', factor: 2 }] + expect(postcss(unitConverter({ rules })).process(source, { from: 'input.css' }).css) + .toBe('.a{width:var(--size48px,48px);height:96rpx}') + expect(convert('var(--size48px,48px)')).toBe('.a{width:var(--size48px,96rpx)}') + expect(convert('var(--size48px,48px)', { transformVarFallbacks: false })) + .toBe('.a{width:var(--size48px,48px)}') + }) + + it('转换任意深度的 fallback,同时保留变量引用和普通函数', () => { + expect(convert('var(--size,calc(24px + var(--other,min(12px,max(4px,2px)))))')) + .toBe('.a{width:var(--size,calc(48rpx + var(--other,min(24rpx,max(8rpx,4rpx)))))}') + const depth = 600 + const value = `${'var(--x,'.repeat(depth)}2px${')'.repeat(depth)}` + expect(convert(value)).toBe(`.a{width:${'var(--x,'.repeat(depth)}4rpx${')'.repeat(depth)}}`) + }) + + it('识别大小写和转义函数名,但不改写首参数', () => { + expect(convert(String.raw`VAR(--size48px,2px) v\61 r(--other,3px)`)) + .toBe(String.raw`.a{width:VAR(--size48px,4rpx) v\61 r(--other,6rpx)}`) + expect(convert('var(48px,24px) var(calc(8px,4px),2px)')) + .toBe('.a{width:var(48px,48rpx) var(calc(8px,4px),4rpx)}') + expect(convert('var(--size) var(--size,)')).toBe('.a{width:var(--size) var(--size,)}') + }) + + it('保护字符串、注释、URL 和非 dimension token', () => { + const value = String.raw`var(--size48px,"48px" '24px' url(12px.png) URL("8px.png") u\72l(4px.png) /* 2px */ foo48px 1pxfoo 3px)` + expect(convert(value)).toBe(`.a{width:${value.slice(0, -4)}6rpx)}`) + }) + + it('只保留未闭合 var 原文,不执行其中的转换回调', () => { + const transform = vi.fn(value => value * 2) + const options = { rules: [{ from: 'px', to: 'rpx', transform }] } + expect(convert('1px var(--x,calc(2px + var(--y,3px))', options)) + .toBe('.a{width:2rpx var(--x,calc(2px + var(--y,3px))}') + expect(transform).toHaveBeenCalledTimes(1) + expect(convert('var(--x,[) 2px] ) 3px')).toBe('.a{width:var(--x,[) 4rpx] ) 6rpx}') + }) + + it('对 fallback 复用完整有符号数值和科学计数 token 规则', () => { + expect(convert('var(--x,-2px +.5px 1e2px -.005px)')) + .toBe('.a{width:var(--x,-4rpx +1rpx 200rpx -0.01rpx)}') + expect(convert('var(--x,2px)', { rules: [{ from: 'px', to: 'rpx', factor: 2 }, { from: 'rpx', to: 'px', factor: 3 }] })) + .toBe('.a{width:var(--x,4rpx)}') + }) + + it('显式 unitRegex 始终完整覆盖 tokenizer 和 fallback 策略', () => { + expect(convert('var(--x,2px) item3px', { unitRegex: /(\d+)(px)/g })) + .toBe('.a{width:var(--x,4rpx) item6rpx}') + expect(convert('var(--x,2px)', { unitRegex: /var\([^)]+\)|(\d+)(px)/g })) + .toBe('.a{width:var(--x,2px)}') + }) + + it('对过滤、mediaQuery 和 replace:false 复用同一遍历', () => { + const processor = postcss(unitConverter({ + rules: [{ from: 'px', to: 'rpx', factor: 2 }], + transformVarFallbacks: true, + propList: ['width'], + selectorBlackList: ['.skip'], + mediaQuery: true, + replace: false, + })) + const source = '@media (width:var(--w,2px)){.a{width:var(--w,3px);height:var(--h,4px)}.skip{width:var(--w,5px)}}' + const expected = '@media (width:var(--w,4rpx)){.a{width:var(--w,3px);width:var(--w,6rpx);height:var(--h,4px)}.skip{width:var(--w,5px)}}' + expect(processor.process(source, { from: 'input.css' }).css).toBe(expected) + expect(processor.process(source, { from: 'second.css' }).css).toBe(expected) + expect(convert('var(--x,2px)', { rules: [{ from: 'px', to: 'px', factor: 2 }], replace: false })) + .toBe('.a{width:var(--x,2px);width:var(--x,4px)}') + expect(convert('var(--x,2px)', { exclude: ['input.css'] })).toBe('.a{width:var(--x,2px)}') + }) +}) diff --git a/scripts/check-cjs-plugins.mjs b/scripts/check-cjs-plugins.mjs index 4c4082e..c5c8f25 100644 --- a/scripts/check-cjs-plugins.mjs +++ b/scripts/check-cjs-plugins.mjs @@ -18,6 +18,21 @@ const packageNames = [ ] const cases = [ + { + name: 'postcss-pxtrans', + create: plugin => plugin({ designWidth: 375, transformVarFallbacks: true }), + input: '.nut{height:var(--nutui-48px,48px);width:48px}', + output: '.nut{height:var(--nutui-48px,96rpx);width:96rpx}', + }, + { + name: 'postcss-rule-unit-converter', + create: plugin => plugin({ + rules: [{ from: 'px', to: 'rpx', factor: 2 }], + transformVarFallbacks: true, + }), + input: '.nut{height:var(--nutui-48px,48px);width:48px}', + output: '.nut{height:var(--nutui-48px,96rpx);width:96rpx}', + }, { name: 'postcss-pxtrans', create: plugin => plugin(),