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
5 changes: 5 additions & 0 deletions .changeset/sour-poets-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"postcss-plugin-shared": patch
---

修复替换模式因已有目标值而跳过单位转换的问题,保留声明顺序与优先级;重复检测仅阻止 replace:false 时插入已有结果。
6 changes: 6 additions & 0 deletions packages/postcss-plugin-shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/postcss-plugin-shared/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 后,即使已存在相同属性和值,仍插入转换后的声明。
3 changes: 2 additions & 1 deletion packages/postcss-plugin-shared/src/postcss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion packages/postcss-plugin-shared/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
})

Expand Down
52 changes: 52 additions & 0 deletions packages/postcss-plugin-shared/test/replace-duplicates.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
6 changes: 3 additions & 3 deletions packages/postcss-pxtrans/test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down Expand Up @@ -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)
})
})
9 changes: 5 additions & 4 deletions packages/postcss-rem-to-responsive-pixel/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
5 changes: 3 additions & 2 deletions packages/postcss-rem-to-viewport/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
8 changes: 8 additions & 0 deletions scripts/check-cjs-plugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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`)
}

Expand Down
Loading