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/fluffy-bears-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"postcss-plugin-shared": minor
---

Add whole-value transforms to the shared walker and preserve important fallback declarations.
5 changes: 5 additions & 0 deletions .changeset/mean-snakes-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"postcss-rule-unit-converter": patch
---

Parse complete CSS unit tokens safely, preserve signed and scientific values, and consistently skip var() contents.
13 changes: 13 additions & 0 deletions apps/website/src/guide/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,16 @@ The focused packages remain useful when their old API is part of your public bui
## Protecting declarations

Use `propList` for property-level control, `selectorBlackList` for selectors, and `exclude` for file-level decisions. The shared matcher behavior is documented in `postcss-plugin-shared` and is consistent across the conversion packages.

## Rule-engine matching boundaries

`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
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).

Its `exclude` predicate and callback input refer to the root CSS entry. After
imports are expanded, inspect `context.decl?.source?.input.file` in a transform
for per-source decisions; `exclude` does not skip individual imported files.
12 changes: 12 additions & 0 deletions apps/website/src/zh-CN/guide/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ Tailwind 输出 `rem` 时,将 `postcss-rem-to-responsive-pixel` 放在 Tailwin
## 保护声明

用 `propList` 控制属性,用 `selectorBlackList` 控制选择器,用 `exclude` 控制文件。`postcss-plugin-shared` 中的 matcher 规则在各个转换包之间保持一致。

## 规则引擎的匹配边界

`postcss-rule-unit-converter` 默认使用 CSS token,支持带符号数值、科学计数法和
完整解码后的单位,保护字符串、URL、标识符及整个 `var()` fallback。
显式 `unitRegex` 会替代这些保护。第一条命中规则即为最终选择,即使它返回
`undefined`;单轮不连锁转换,重复处理则可能继续转换。详见
[API 参考](/zh-CN/plugins/postcss-rule-unit-converter/api#匹配边界)。

它的 `exclude` predicate 与回调 input 指向根 CSS 入口。展开 import 后,需要
按原文件处理时,在 transform 中读取 `context.decl?.source?.input.file`;
`exclude` 不会单独跳过被导入的文件。
40 changes: 35 additions & 5 deletions packages/postcss-plugin-shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Entry: `packages/postcss-plugin-shared/src/index.ts`
- `createAdvancedPropListMatcher`: advanced prop matcher (wildcards + negation) for `string[]`
- `createExcludeMatcher`: builds an exclude matcher from `exclude` (array or function)
- `createSelectorBlacklistMatcher`: selector blacklist matcher with optional cache
- `declarationExists`: checks whether a rule/decls already contains the same `prop/value` to avoid duplicates
- `declarationExists`: checks whether a rule/decls already contains the same `prop/value`, optionally also importance, to avoid duplicates
- `walkAndReplaceValues`: shared walker to replace declaration values and media params

## API
Expand Down Expand Up @@ -221,23 +221,53 @@ isExcluded('/a/node_modules/x.css') // true
isExcluded('/a/src/vendor.css') // true
```

### `declarationExists(decls, prop, value)`
### `declarationExists(decls, prop, value, important?)`

Checks whether a rule already contains the same declaration (commonly used to avoid duplicates when `replace: false` and `cloneAfter` is used).
Checks whether a rule already contains the same property and value (commonly used to avoid duplicates when `replace: false` and `cloneAfter` is used). Omit `important` to match either priority, preserving the existing three-argument behavior. Pass `true` or `false` to match that exact normalized `!important` flag.

### `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.
`skipDuplicate` defaults to `true` and only controls insertion when `replace: false`: it prevents adding an existing property/value/importance combination while retaining the source declaration. A normal declaration does not suppress an important fallback, or vice versa. Set `skipDuplicate: false` to insert the converted declaration even when that combination already exists.

Choose one value-conversion strategy:

```ts
interface WalkAndReplaceOptions {
// Optional context for source-less programmatic ASTs:
input?: Input
// Existing regex strategy:
unitRegex?: RegExp
createReplacer?: (context: ReplaceContext) => (m: string, $1?: string) => string
// Whole-value strategy; takes precedence when provided:
createValueTransformer?: (context: ReplaceContext) => (value: string) => string
// Other walker options are unchanged.
}
```

`createValueTransformer` bypasses regex matching and its precheck completely. It
receives each eligible declaration value, and media parameters when `mediaQuery`
is enabled. Filters, context, replacement/fallback handling, and deduplication are
shared by both strategies. Without a whole-value transformer, both `unitRegex` and
`createReplacer` are required; an incomplete strategy throws `TypeError`.

`context.input`, `context.filePath`, and `exclude` use the root CSS entry. A
transformer can inspect `context.decl?.source?.input.file` to identify an imported
declaration's original file.

For a programmatic root without `root.source.input`, pass an optional `input` to
provide callback and exclusion context. The real root input takes precedence.
The walker does not attach the supplied input to any AST source or create source
map metadata; without either input it leaves the root unchanged.

`decls` only needs a `.some(...)` method that iterates PostCSS `ChildNode`s (usually a `Rule`).

```ts
import { declarationExists } from 'postcss-plugin-shared'

// inside PostCSS visitor
if (!declarationExists(rule, decl.prop, nextValue)) {
if (!declarationExists(rule, decl.prop, nextValue, Boolean(decl.important))) {
decl.cloneAfter({ value: nextValue })
}
```
Expand Down
35 changes: 31 additions & 4 deletions packages/postcss-plugin-shared/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pnpm add postcss-plugin-shared
- `createAdvancedPropListMatcher`:支持高级 `propList` 语法的属性匹配器(string[],通配符 + 取反)
- `createExcludeMatcher`:根据 exclude 生成文件路径排除函数(数组或函数)
- `createSelectorBlacklistMatcher`:选择器黑名单匹配器(支持缓存)
- `declarationExists`:用于检测某 rule/decls 中是否已存在相同 `prop/value`,避免重复注入
- `declarationExists`:用于检测某 rule/decls 中是否已存在相同 `prop/value`,可同时比较优先级,避免重复注入
- `walkAndReplaceValues`:通用遍历替换逻辑(decl 值与 media params)

## API 说明
Expand Down Expand Up @@ -163,12 +163,39 @@ const re = createUnitRegex({ units: ['px', 'rpx'], ignoreCase: true })

生成一个 `(filepath?: string) => boolean` 的排除函数。

### `declarationExists(decls, prop, value)`
### `declarationExists(decls, prop, value, important?)`

用于避免在同一个规则中生成重复声明(常用于 `replace: false` 时 `cloneAfter` 的去重判断)。
检查同一规则中是否已有相同属性和值(常用于 `replace: false` 时 `cloneAfter` 的去重判断)。省略 `important` 时匹配任意优先级,保持原有三参数行为;显式传入 `true` 或 `false` 时,严格匹配归一化后的 `!important` 标志。

### `walkAndReplaceValues(options)`

默认 `replace: true` 会原位转换每条命中的声明,即使已存在相同属性和转换后值。声明顺序和 `!important` 保持不变;此函数不删除重复声明。

`skipDuplicate` 默认为 `true`,仅控制 `replace: false` 时的插入行为:保留原声明,已有相同属性和值时不再插入。设置 `skipDuplicate: false` 后,即使已存在相同属性和值,仍插入转换后的声明。
`skipDuplicate` 默认为 `true`,仅控制 `replace: false` 时的插入行为:保留原声明,已有相同属性、值和优先级时不再插入。普通声明不会阻止重要声明的 fallback,反之亦然。设置 `skipDuplicate: false` 后,即使存在相同组合,仍插入转换后的声明。

可选择两种值转换策略:

```ts
interface WalkAndReplaceOptions {
// 为没有源码信息的手工 AST 提供可选上下文:
input?: Input
// 原有正则策略:
unitRegex?: RegExp
createReplacer?: (context: ReplaceContext) => (m: string, $1?: string) => string
// 整值转换策略;提供时优先使用:
createValueTransformer?: (context: ReplaceContext) => (value: string) => string
// 其他 walker 选项保持不变。
}
```

`createValueTransformer` 完全绕过正则匹配及其预检查,接收每条符合条件的声明值;
开启 `mediaQuery` 后,也接收媒体查询参数。两种策略共享过滤、上下文、原位替换、
fallback 和去重逻辑。未提供整值转换器时,必须同时提供 `unitRegex` 和
`createReplacer`;不完整的策略会抛出 `TypeError`。

`context.input`、`context.filePath` 和 `exclude` 使用根 CSS 入口。转换器可通过
`context.decl?.source?.input.file` 获取导入声明的原文件。

手工创建的根节点没有 `root.source.input` 时,可传入 `input` 作为回调和排除
规则的上下文;真实根输入始终优先。walker 不会将备用输入附加到任何 AST
源码或创建 sourcemap 信息。两种输入都没有时,根节点保持原样。
12 changes: 11 additions & 1 deletion packages/postcss-plugin-shared/src/numbers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ export function toFixed(number: number, precision: number) {
const multiplier = 10 ** precision
const sign = Math.sign(number)
const absNumber = Math.abs(number)
const rounded = Math.round((absNumber + Number.EPSILON) * multiplier) / multiplier
// A fixed EPSILON can overwhelm very small lengths. Use a relative
// correction below that magnitude, preserving ordinary decimal rounding.
const correction = absNumber < Number.EPSILON ? Number.EPSILON * absNumber : Number.EPSILON
const correctedNumber = absNumber + correction
const scaledNumber = correctedNumber * multiplier
// Scaling large finite lengths can overflow even though no fractional
// digits remain to round at the requested precision.
if (Number.isFinite(number) && !Number.isFinite(scaledNumber)) {
return number
}
const rounded = Math.round(scaledNumber) / multiplier
return sign * rounded
}
73 changes: 48 additions & 25 deletions packages/postcss-plugin-shared/src/postcss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {

/**
* Check whether a declaration with the same property and value already exists.
* When supplied, `important` also requires the same importance flag.
*
* @example
* if (declarationExists(rule, 'margin', '16px')) return
Expand All @@ -22,6 +23,7 @@ export function declarationExists(
decls: { some: (cb: (node: ChildNode) => boolean) => boolean },
prop: string,
value: string,
important?: boolean,
) {
const nodes = decls as unknown as { length?: number, [index: number]: ChildNode }
const length = nodes.length
Expand All @@ -34,7 +36,8 @@ export function declarationExists(
}

const decl = node as Declaration
if (decl.prop === prop && decl.value === value) {
if (decl.prop === prop && decl.value === value
&& (important === undefined || Boolean(decl.important) === important)) {
return true
}
}
Expand All @@ -48,6 +51,7 @@ export function declarationExists(
}
const decl = node as Declaration
return decl.prop === prop && decl.value === value
&& (important === undefined || Boolean(decl.important) === important)
})
}

Expand Down Expand Up @@ -88,15 +92,21 @@ export interface ReplaceContext {
*/
export interface WalkAndReplaceOptions {
root: Root
unitRegex: RegExp
/** Context for source-less ASTs. Existing root.source.input takes precedence. */
input?: Input
/** Required with createReplacer unless createValueTransformer is supplied. */
unitRegex?: RegExp
propList: readonly (string | RegExp)[]
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
/** Required with unitRegex unless createValueTransformer is supplied. */
createReplacer?: (context: ReplaceContext) => (m: string, $1?: string) => string
/** Transforms the complete value, taking precedence over the regex strategy. */
createValueTransformer?: (context: ReplaceContext) => (value: string) => string
shouldProcessDecl?: (decl: Declaration) => boolean
shouldProcessAtRule?: (atRule: AtRule) => boolean
}
Expand All @@ -109,8 +119,8 @@ interface DeclarationIndex {
counts: Map<string, number>
}

function declarationKey(prop: string, value: string) {
return `${prop}\0${value}`
function declarationKey(prop: string, value: string, important: boolean | undefined) {
return `${prop}\0${value}\0${important ? '1' : '0'}`
}

function createDeclarationIndex(container: DeclarationContainer): DeclarationIndex {
Expand All @@ -119,19 +129,19 @@ function createDeclarationIndex(container: DeclarationContainer): DeclarationInd
if (node.type !== 'decl') {
continue
}
const key = declarationKey(node.prop, node.value)
const key = declarationKey(node.prop, node.value, node.important)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return { counts }
}

function incrementDeclaration(index: DeclarationIndex, prop: string, value: string) {
const key = declarationKey(prop, value)
function incrementDeclaration(index: DeclarationIndex, prop: string, value: string, important: boolean | undefined) {
const key = declarationKey(prop, value, important)
index.counts.set(key, (index.counts.get(key) ?? 0) + 1)
}

function decrementDeclaration(index: DeclarationIndex, prop: string, value: string) {
const key = declarationKey(prop, value)
function decrementDeclaration(index: DeclarationIndex, prop: string, value: string, important: boolean | undefined) {
const key = declarationKey(prop, value, important)
const count = index.counts.get(key) ?? 0
if (count <= 1) {
index.counts.delete(key)
Expand All @@ -141,8 +151,8 @@ function decrementDeclaration(index: DeclarationIndex, prop: string, value: stri
}
}

function hasDeclaration(index: DeclarationIndex, prop: string, value: string) {
return (index.counts.get(declarationKey(prop, value)) ?? 0) > 0
function hasDeclaration(index: DeclarationIndex, prop: string, value: string, important: boolean | undefined) {
return (index.counts.get(declarationKey(prop, value, important)) ?? 0) > 0
}

function createGlobalRegex(regex: RegExp) {
Expand Down Expand Up @@ -177,12 +187,29 @@ export function walkAndReplaceValues(options: WalkAndReplaceOptions) {
skipDuplicate = true,
mediaQuery = false,
createReplacer,
createValueTransformer,
shouldProcessDecl,
shouldProcessAtRule,
} = options

let hasCandidate: (value: string) => boolean
let transformValue: (value: string, context: ReplaceContext) => string
if (createValueTransformer) {
hasCandidate = () => true
transformValue = (value, context) => createValueTransformer(context)(value)
}
else {
if (!unitRegex || !createReplacer) {
throw new TypeError('walkAndReplaceValues requires createValueTransformer or both unitRegex and createReplacer')
}
const replacementRegex = createGlobalRegex(unitRegex)
const unitTestRegex = new RegExp(unitRegex.source, unitRegex.flags.replace(/[gy]/g, ''))
hasCandidate = value => unitTestRegex.test(value)
transformValue = (value, context) => value.replace(replacementRegex, createReplacer(context))
}

const source = root.source
const input = source?.input
const input = source?.input ?? options.input
if (!input) {
return
}
Expand All @@ -194,8 +221,6 @@ export function walkAndReplaceValues(options: WalkAndReplaceOptions) {
}

const satisfyPropList = createPropListMatcher(propList)
const replacementRegex = createGlobalRegex(unitRegex)
const unitTestRegex = new RegExp(unitRegex.source, unitRegex.flags.replace(/[gy]/g, ''))
const hasSelectorBlackList = selectorBlackList.length > 0
const isBlacklisted = hasSelectorBlackList
? createSelectorBlacklistMatcher(selectorBlackList, { cache: true })
Expand Down Expand Up @@ -243,7 +268,7 @@ export function walkAndReplaceValues(options: WalkAndReplaceOptions) {
if (shouldProcessDecl && !shouldProcessDecl(decl)) {
return
}
if (!unitTestRegex.test(decl.value)) {
if (!hasCandidate(decl.value)) {
return
}

Expand All @@ -265,28 +290,27 @@ export function walkAndReplaceValues(options: WalkAndReplaceOptions) {
prop: decl.prop,
...(atRule ? { atRule } : {}),
}
const replacer = createReplacer(context)
const nextValue = decl.value.replace(replacementRegex, replacer)
const nextValue = transformValue(decl.value, context)
if (nextValue === decl.value) {
return
}

const index = getDeclarationIndex(parent ?? root)
if (!replace && skipDuplicate && index && hasDeclaration(index, decl.prop, nextValue)) {
if (!replace && skipDuplicate && index && hasDeclaration(index, decl.prop, nextValue, decl.important)) {
return
}

if (replace) {
if (index) {
decrementDeclaration(index, decl.prop, decl.value)
incrementDeclaration(index, decl.prop, nextValue)
decrementDeclaration(index, decl.prop, decl.value, decl.important)
incrementDeclaration(index, decl.prop, nextValue, decl.important)
}
decl.value = nextValue
return
}

if (index) {
incrementDeclaration(index, decl.prop, nextValue)
incrementDeclaration(index, decl.prop, nextValue, decl.important)
}
const clone = decl.cloneAfter({ value: nextValue })
generatedDeclarations.add(clone)
Expand All @@ -295,15 +319,14 @@ export function walkAndReplaceValues(options: WalkAndReplaceOptions) {

if (mediaQuery && node.type === 'atrule') {
const atRule = node as AtRule
if (!shouldProcess(atRule) || !unitTestRegex.test(atRule.params)) {
if (!shouldProcess(atRule) || !hasCandidate(atRule.params)) {
return
}
const context: ReplaceContext = {
...baseContext,
atRule,
}
const replacer = createReplacer(context)
const nextParams = atRule.params.replace(replacementRegex, replacer)
const nextParams = transformValue(atRule.params, context)
if (nextParams !== atRule.params) {
atRule.params = nextParams
}
Expand Down
Loading
Loading