From adc01890f129c53204b76c4ce033ed5c5bd31326 Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Mon, 28 Sep 2026 01:32:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(shared):=20=E4=BF=AE=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=9B=AE=E6=A0=87=E5=80=BC=E9=98=BB=E6=AD=A2=E5=8E=9F?= =?UTF-8?q?=E4=BD=8D=E5=8D=95=E4=BD=8D=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/sour-poets-kiss.md | 5 ++ packages/postcss-plugin-shared/README.md | 6 +++ .../postcss-plugin-shared/README.zh-CN.md | 6 +++ packages/postcss-plugin-shared/src/postcss.ts | 3 +- .../postcss-plugin-shared/test/index.test.ts | 3 +- .../test/replace-duplicates.test.ts | 52 +++++++++++++++++++ packages/postcss-pxtrans/test/basic.test.ts | 6 +-- .../test/index.test.ts | 9 ++-- .../test/replace-duplicates.test.ts | 21 ++++++++ .../test/index.test.ts | 5 +- scripts/check-cjs-plugins.mjs | 8 +++ 11 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 .changeset/sour-poets-kiss.md create mode 100644 packages/postcss-plugin-shared/test/replace-duplicates.test.ts create mode 100644 packages/postcss-rem-to-responsive-pixel/test/replace-duplicates.test.ts diff --git a/.changeset/sour-poets-kiss.md b/.changeset/sour-poets-kiss.md new file mode 100644 index 0000000..3235b09 --- /dev/null +++ b/.changeset/sour-poets-kiss.md @@ -0,0 +1,5 @@ +--- +"postcss-plugin-shared": patch +--- + +修复替换模式因已有目标值而跳过单位转换的问题,保留声明顺序与优先级;重复检测仅阻止 replace:false 时插入已有结果。 diff --git a/packages/postcss-plugin-shared/README.md b/packages/postcss-plugin-shared/README.md index 8e10063..a80e02b 100644 --- a/packages/postcss-plugin-shared/README.md +++ b/packages/postcss-plugin-shared/README.md @@ -225,6 +225,12 @@ isExcluded('/a/src/vendor.css') // true Checks whether a rule already contains the same declaration (commonly used to avoid duplicates when `replace: false` and `cloneAfter` is used). +### `walkAndReplaceValues(options)` + +With the default `replace: true`, every matched declaration is converted in place, even if the same property and converted value already exist. Declaration order and `!important` are preserved; this function does not remove duplicate declarations. + +`skipDuplicate` defaults to `true` and only controls insertion when `replace: false`: it prevents adding an existing property/value pair while retaining the source declaration. Set `skipDuplicate: false` to insert the converted declaration even when that pair already exists. + `decls` only needs a `.some(...)` method that iterates PostCSS `ChildNode`s (usually a `Rule`). ```ts diff --git a/packages/postcss-plugin-shared/README.zh-CN.md b/packages/postcss-plugin-shared/README.zh-CN.md index 05d07f7..ed6bced 100644 --- a/packages/postcss-plugin-shared/README.zh-CN.md +++ b/packages/postcss-plugin-shared/README.zh-CN.md @@ -166,3 +166,9 @@ const re = createUnitRegex({ units: ['px', 'rpx'], ignoreCase: true }) ### `declarationExists(decls, prop, value)` 用于避免在同一个规则中生成重复声明(常用于 `replace: false` 时 `cloneAfter` 的去重判断)。 + +### `walkAndReplaceValues(options)` + +默认 `replace: true` 会原位转换每条命中的声明,即使已存在相同属性和转换后值。声明顺序和 `!important` 保持不变;此函数不删除重复声明。 + +`skipDuplicate` 默认为 `true`,仅控制 `replace: false` 时的插入行为:保留原声明,已有相同属性和值时不再插入。设置 `skipDuplicate: false` 后,即使已存在相同属性和值,仍插入转换后的声明。 diff --git a/packages/postcss-plugin-shared/src/postcss.ts b/packages/postcss-plugin-shared/src/postcss.ts index 189ee48..785b720 100644 --- a/packages/postcss-plugin-shared/src/postcss.ts +++ b/packages/postcss-plugin-shared/src/postcss.ts @@ -93,6 +93,7 @@ export interface WalkAndReplaceOptions { selectorBlackList?: readonly (string | RegExp)[] exclude?: readonly (string | RegExp)[] | ((filePath: string) => boolean) replace?: boolean + /** 仅在 replace:false 时跳过已存在的转换结果;替换模式始终更新当前声明。 */ skipDuplicate?: boolean mediaQuery?: boolean createReplacer: (context: ReplaceContext) => (m: string, $1?: string) => string @@ -271,7 +272,7 @@ export function walkAndReplaceValues(options: WalkAndReplaceOptions) { } const index = getDeclarationIndex(parent ?? root) - if (skipDuplicate && index && hasDeclaration(index, decl.prop, nextValue)) { + if (!replace && skipDuplicate && index && hasDeclaration(index, decl.prop, nextValue)) { return } diff --git a/packages/postcss-plugin-shared/test/index.test.ts b/packages/postcss-plugin-shared/test/index.test.ts index f279097..57ea871 100644 --- a/packages/postcss-plugin-shared/test/index.test.ts +++ b/packages/postcss-plugin-shared/test/index.test.ts @@ -468,12 +468,13 @@ describe('postcss-plugin-shared', () => { expect(root.toString()).toBe('.rule{width:2px}') }) - it('skips duplicate declarations when enabled', () => { + it('skips duplicate insertions when replace is false', () => { const root = postcss.parse('.rule{width:1rem;width:2px}') walkAndReplaceValues({ root, unitRegex, propList: ['*'], + replace: false, createReplacer: () => (_m, value) => `${Number(value) * 2}px`, }) diff --git a/packages/postcss-plugin-shared/test/replace-duplicates.test.ts b/packages/postcss-plugin-shared/test/replace-duplicates.test.ts new file mode 100644 index 0000000..e2b8bc1 --- /dev/null +++ b/packages/postcss-plugin-shared/test/replace-duplicates.test.ts @@ -0,0 +1,52 @@ +import postcss from 'postcss' +import { remRegex, walkAndReplaceValues } from '../src/index' + +describe('replacement with existing converted declarations', () => { + for (const prop of ['width', '--spacing']) { + for (const replace of [undefined, true]) { + for (const skipDuplicate of [undefined, true, false]) { + it.each([ + ['8rpx;.25rem', '8rpx;8rpx'], + ['.25rem;8rpx', '8rpx;8rpx'], + ['8rpx;2rem;.25rem', '8rpx;64rpx;8rpx'], + ['.25rem;.25rem', '8rpx;8rpx'], + ['8rpx;.25rem!important', '8rpx;8rpx!important'], + ['8rpx!important;.25rem', '8rpx!important;8rpx'], + ])(`${prop}, replace=${replace}, skipDuplicate=${skipDuplicate}: %s → %s`, (input, expected) => { + const css = (values: string) => `.rule{${values.split(';').map(value => `${prop}:${value}`).join(';')}}` + const root = postcss.parse(css(input)) + const rule = root.first as postcss.Rule + const declarations = [...rule.nodes] + walkAndReplaceValues({ + root, + unitRegex: remRegex, + propList: ['*'], + ...(replace === undefined ? {} : { replace }), + ...(skipDuplicate === undefined ? {} : { skipDuplicate }), + createReplacer: () => (match, value) => value === undefined ? match : `${Number(value) * 32}rpx`, + }) + + expect(root.toString()).toBe(css(expected)) + expect(rule.nodes).toHaveLength(declarations.length) + rule.nodes.forEach((node, index) => expect(node).toBe(declarations[index])) + }) + } + } + } + + it.each([ + [true, '.rule{width:8rpx;width:.25rem}'], + [false, '.rule{width:8rpx;width:.25rem;width:8rpx}'], + ])('retains insertion semantics with replace=false and skipDuplicate=%s', (skipDuplicate, expected) => { + const root = postcss.parse('.rule{width:8rpx;width:.25rem}') + walkAndReplaceValues({ + root, + unitRegex: remRegex, + propList: ['*'], + replace: false, + skipDuplicate, + createReplacer: () => (_match, value) => `${Number(value) * 32}rpx`, + }) + expect(root.toString()).toBe(expected) + }) +}) diff --git a/packages/postcss-pxtrans/test/basic.test.ts b/packages/postcss-pxtrans/test/basic.test.ts index c1a6a52..a60f84a 100644 --- a/packages/postcss-pxtrans/test/basic.test.ts +++ b/packages/postcss-pxtrans/test/basic.test.ts @@ -31,9 +31,9 @@ describe('pxTransform basics', () => { expect(processed).toBe(expected) }) - it('should not add properties that already exist', () => { + it('should not add properties that already exist when replace is false', () => { const expected = '.rule { font-size: 40px; font-size: 1rem; }' - const processed = transform(expected, { platform: 'h5', designWidth: 750 }) + const processed = transform(expected, { platform: 'h5', designWidth: 750, replace: false }) expect(processed).toBe(expected) }) @@ -218,7 +218,7 @@ describe('additional coverage', () => { it('should ignore non-decl nodes in declarationExists', () => { const css = '.rule{/*c*/font-size:16px;font-size:0.468rem;}' - const processed = transform(css, { platform: 'h5', designWidth: 640 }) + const processed = transform(css, { platform: 'h5', designWidth: 640, replace: false }) expect(processed).toBe(css) }) }) diff --git a/packages/postcss-rem-to-responsive-pixel/test/index.test.ts b/packages/postcss-rem-to-responsive-pixel/test/index.test.ts index e70b8be..567d602 100644 --- a/packages/postcss-rem-to-responsive-pixel/test/index.test.ts +++ b/packages/postcss-rem-to-responsive-pixel/test/index.test.ts @@ -56,16 +56,17 @@ describe('remToPx', () => { expect(processed).toBe(expected) }) - it('should not add properties that already exist', () => { + it('should not add properties that already exist when replace is false', () => { const expected = '.rule { font-size: 1rem; font-size: 16px; }' - const processed = postcss(remToPx()).process(expected).css + const processed = postcss(remToPx({ replace: false })).process(expected).css expect(processed).toBe(expected) }) it('should remain unitless if 0', () => { - const expected = '.rule { font-size: 0rem; font-size: 0; }' - const processed = postcss(remToPx()).process(expected).css + const input = '.rule { font-size: 0rem; font-size: 0; }' + const expected = '.rule { font-size: 0; font-size: 0; }' + const processed = postcss(remToPx()).process(input).css expect(processed).toBe(expected) }) diff --git a/packages/postcss-rem-to-responsive-pixel/test/replace-duplicates.test.ts b/packages/postcss-rem-to-responsive-pixel/test/replace-duplicates.test.ts new file mode 100644 index 0000000..d89a4ab --- /dev/null +++ b/packages/postcss-rem-to-responsive-pixel/test/replace-duplicates.test.ts @@ -0,0 +1,21 @@ +import postcss from 'postcss' +import remToPx from '../src/index' + +describe('existing rpx declarations', () => { + it.each([ + ['.x{--spacing:8rpx;--spacing:2rem;--spacing:.25rem}', '.x{--spacing:8rpx;--spacing:64rpx;--spacing:8rpx}'], + ['.x{width:.25rem;width:8rpx}', '.x{width:8rpx;width:8rpx}'], + ['.x{width:8rpx;width:.25rem!important}', '.x{width:8rpx;width:8rpx!important}'], + ])('replaces all source units in %s', async (input, expected) => { + const result = await postcss(remToPx({ rootValue: 32, transformUnit: 'rpx', propList: ['*'] })) + .process(input, { from: undefined }) + expect(result.css).toBe(expected) + }) + + it('preserves source declarations without adding duplicate fallbacks when replace=false', async () => { + const input = '.x{width:8rpx;width:.25rem}' + const result = await postcss(remToPx({ rootValue: 32, transformUnit: 'rpx', propList: ['*'], replace: false })) + .process(input, { from: undefined }) + expect(result.css).toBe(input) + }) +}) diff --git a/packages/postcss-rem-to-viewport/test/index.test.ts b/packages/postcss-rem-to-viewport/test/index.test.ts index 532c4d6..67b42e6 100644 --- a/packages/postcss-rem-to-viewport/test/index.test.ts +++ b/packages/postcss-rem-to-viewport/test/index.test.ts @@ -64,8 +64,9 @@ describe('remToVw', () => { }) it('should remain unitless if 0', () => { - const expected = '.rule { font-size: 0rem; font-size: 0; }' - const processed = postcss(remToVw()).process(expected).css + const input = '.rule { font-size: 0rem; font-size: 0; }' + const expected = '.rule { font-size: 0; font-size: 0; }' + const processed = postcss(remToVw()).process(input).css expect(processed).toBe(expected) }) diff --git a/scripts/check-cjs-plugins.mjs b/scripts/check-cjs-plugins.mjs index f742b25..02cdcf6 100644 --- a/scripts/check-cjs-plugins.mjs +++ b/scripts/check-cjs-plugins.mjs @@ -30,6 +30,12 @@ const cases = [ input: '.a{font-size:1rem}', output: '.a{font-size:16px}', }, + { + name: 'postcss-rem-to-responsive-pixel', + create: plugin => plugin({ rootValue: 32, transformUnit: 'rpx', propList: ['*'] }), + input: '.a{--spacing:8rpx;--spacing:2rem;--spacing:.25rem}', + output: '.a{--spacing:8rpx;--spacing:64rpx;--spacing:8rpx}', + }, { name: 'postcss-rem-to-viewport', create: plugin => plugin(), @@ -132,6 +138,8 @@ async function main() { const packageDir = join(fixture, 'node_modules', item.name) const esm = await import(pathToFileURL(join(packageDir, 'dist', 'index.mjs')).href) assert.equal(typeof esm.default, 'function', `${item.name} ESM default must be callable`) + const esmResult = await postcss([item.create(esm.default)]).process(item.input, { from: undefined }) + assert.equal(esmResult.css, item.output, `${item.name} ESM plugin output changed`) console.log(`${item.name} ESM: ok`) }