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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.

## Unreleased

### Bug fixes

- `reduceCalc` and the plugin no longer print an error for a
value nested more than 1024 levels deep which is not a math function, for
example when `calc(` only appears inside `url()`, a string or a comment. The
output is unchanged.

## 11.2.3 (2026-10-10)

### Bug fixes
Expand Down
66 changes: 15 additions & 51 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,19 @@ import { hasPotentialMathFunction } from './lib/functions.js';
/**
* Runs `reduceCalc` over one text property of a decl/atrule/rule node
* and updates it in place.
* `setProp` closes over the property name and the concrete node type at
* each call site, since `Declaration`/`AtRule`/`Rule` don't share a typed
* "text property" name to index generically.
*
* @param {import('postcss').ChildNode} node
* @param {string} current
* @param {(target: import('postcss').ChildNode, value: string) => void} setProp
* @param {import('postcss').Declaration | import('postcss').AtRule | import('postcss').Rule} node
* @param {'value' | 'params' | 'selector'} prop
* @param {ResolvedOptions} options
* @param {import('postcss').Result} result
* @param {boolean} selectorContext
* @return {void}
*/
function applyTransform(
node,
current,
setProp,
options,
result,
selectorContext
) {
function applyTransform(node, prop, options, result) {
// `Declaration`/`AtRule`/`Rule` don't share a typed "text property" name.
const target = /** @type {Record<typeof prop, string>} */ (
/** @type {unknown} */ (node)
);
const current = target[prop];
if (!hasPotentialMathFunction(current)) {
return;
}
Expand All @@ -51,10 +44,10 @@ function applyTransform(
result.warn(message, { plugin: 'postcss-calc', node });
},
// Selectors cannot contain calc(), so they always unwrap resolved values.
unwrapSingleValue: selectorContext || options.unwrapSingleValue,
unwrapSingleValue: prop === 'selector' || options.unwrapSingleValue,
});
if (transformed !== current) {
setProp(node, transformed);
target[prop] = transformed;
}
}

Expand Down Expand Up @@ -83,42 +76,13 @@ function pluginCreator(opts) {
OnceExit(css, { result }) {
css.walk((node) => {
if (node.type === 'decl') {
applyTransform(
node,
node.value,
(n, v) => {
/** @type {import('postcss').Declaration} */ (n).value = v;
},
options,
result,
false
);
}
if (node.type === 'atrule' && options.mediaQueries) {
applyTransform(
node,
node.params,
(n, v) => {
/** @type {import('postcss').AtRule} */ (n).params = v;
},
options,
result,
false
);
}
if (node.type === 'rule' && options.selectors) {
applyTransform(node, 'value', options, result);
} else if (node.type === 'atrule' && options.mediaQueries) {
applyTransform(node, 'params', options, result);
} else if (node.type === 'rule' && options.selectors) {
// Reduces `:nth-child(calc(...))` via the function walk. calc() in a
// quoted attribute value is a literal match, so it's left untouched.
applyTransform(
node,
node.selector,
(n, v) => {
/** @type {import('postcss').Rule} */ (n).selector = v;
},
options,
result,
true
);
applyTransform(node, 'selector', options, result);
}
});
},
Expand Down
85 changes: 33 additions & 52 deletions src/lib/analyze.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,9 @@ import { assertDepth } from './limits.js';

/** @typedef {import('./node.js').Node} Node */
/** @typedef {import('./functions.js').CalculationType} CalculationType */
/** @typedef {Extract<CalculationType, {kind: 'dimension'}>} DimensionType */

/** @typedef {'number' | 'unknown' | {dimension: string | null}} AnalysisType */
/** @typedef {{type: AnalysisType, valid: boolean, unresolved: boolean}} Analysis */
/**
* @typedef {Object} ProductFactors
* @property {boolean} valid
* @property {boolean} structurallyValid
* @property {boolean} hasUnresolved
* @property {DimensionType | null} numerator
* @property {DimensionType | null} denominator
* @property {boolean} hasOpaqueNumerator
* @property {boolean} hasOpaqueDenominator
*/

/**
* Analyze the original complete tree and return its root summary. Analysis
* validates and classifies the tree; it is not a rewrite plan.
Expand Down Expand Up @@ -142,73 +130,66 @@ function analyzeProduct(node, depth) {
// Consume one such pair before judging the remaining unknowns so a
// surrounding sum does not mistake `% / %` for a length-compatible term.
const cancelled = Math.min(percentageNumerator, percentageDenominator);
const hasOpaqueNumerator = opaqueNumerator - cancelled > 0;
const hasOpaqueDenominator = opaqueDenominator - cancelled > 0;
return finishProduct({
valid,
structurallyValid,
return finishProduct(
valid && structurallyValid,
hasUnresolved,
numerator,
denominator,
hasOpaqueNumerator,
hasOpaqueDenominator,
});
opaqueNumerator - cancelled > 0,
opaqueDenominator - cancelled > 0
);
}

/**
* Classify a product once its factors are analyzed. Opaque factors can supply
* missing type information, but they cannot make an already-invalid
* combination of known dimensions valid.
* @param {ProductFactors} factors
* @param {boolean} valid
* @param {boolean} hasUnresolved
* @param {Extract<CalculationType, {kind: 'dimension'}> | null} numerator
* @param {Extract<CalculationType, {kind: 'dimension'}> | null} denominator
* @param {boolean} hasOpaqueNumerator
* @param {boolean} hasOpaqueDenominator
* @return {{type: CalculationType, valid: boolean, unresolved: boolean}}
*/
function finishProduct(factors) {
const { valid, structurallyValid, hasUnresolved } = factors;
if (!valid) return finish(failureType, false, hasUnresolved);
if (!structurallyValid) return finish(failureType, false, hasUnresolved);
const { numerator, denominator } = factors;
function finishProduct(
valid,
hasUnresolved,
numerator,
denominator,
hasOpaqueNumerator,
hasOpaqueDenominator
) {
if (
numerator !== null &&
denominator !== null &&
numerator.base !== denominator.base
!valid ||
(numerator !== null &&
denominator !== null &&
numerator.base !== denominator.base)
) {
return finish(failureType, false, hasUnresolved);
}
// An unresolved numerator multiplied by one known numerator dimension can
// only leave that dimension in place (when it resolves to a number) or make
// the product invalid. It can never make the product a bare number. Keep
// that known constraint so a surrounding sum can reject `1px * 1% + 1`.
const constrained = constrainedNumerator(factors);
if (constrained !== null) {
return finish(constrained, true, hasUnresolved);
if (
hasOpaqueNumerator &&
!hasOpaqueDenominator &&
numerator !== null &&
denominator === null
) {
return finish(numerator, true, hasUnresolved);
}
// Other opaque factors may supply type information that changes how the
// known dimensions combine once the known factors are structurally valid.
if (factors.hasOpaqueNumerator || factors.hasOpaqueDenominator) {
if (hasOpaqueNumerator || hasOpaqueDenominator) {
return finish(unknownType, true, hasUnresolved);
}
if (numerator !== null && denominator !== null) {
return finish(
numerator.base === denominator.base ? numberType : failureType,
valid && numerator.base === denominator.base,
hasUnresolved
);
return finish(numberType, true, hasUnresolved);
}
if (denominator !== null) return finish(failureType, false, hasUnresolved);
return finish(numerator ?? numberType, valid, hasUnresolved);
}

/**
* @param {ProductFactors} factors
* @return {DimensionType | null}
*/
function constrainedNumerator(factors) {
return factors.hasOpaqueNumerator &&
!factors.hasOpaqueDenominator &&
factors.numerator !== null &&
factors.denominator === null
? factors.numerator
: null;
return finish(numerator ?? numberType, true, hasUnresolved);
}

/** @param {Extract<Node, {type: 'Call'}>} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */
Expand Down
36 changes: 13 additions & 23 deletions src/lib/block-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,28 @@ const BLOCK_CLOSE = new Map([
]);

/**
* Read-only delimiter navigation for one native token stream range.
* Read-only delimiter navigation for one native token stream.
*/
class BlockIndex {
/** @type {CSSToken[]} */
#tokens;
/** @type {number} */
#rangeStart;
/** @type {number} */
#rangeEnd;
/** @type {Map<number, number>} */
#closes = new Map();
/** @type {number} */
#maxDepth = 0;

/**
* Build the delimiter index once for a token stream range. Matching remains
* Build the delimiter index once for a token stream. Matching remains
* LIFO: a mismatched closer is ignored and does not disturb the open stack.
*
* @param {CSSToken[]} tokens
* @param {number} start
* @param {number} end
*/
constructor(tokens, start, end) {
constructor(tokens) {
this.#tokens = tokens;
this.#rangeStart = Math.max(0, start);
this.#rangeEnd = Math.min(tokens.length, Math.max(this.#rangeStart, end));
/** @type {{index: number, close: import('@csstools/css-tokenizer').TokenType}[]} */
const stack = [];

for (let i = this.#rangeStart; i < this.#rangeEnd; i++) {
for (let i = 0; i < tokens.length; i++) {
const type = tokens[i][0];
const close = BLOCK_CLOSE.get(type);
if (close !== undefined) {
Expand Down Expand Up @@ -71,30 +63,30 @@ class BlockIndex {
/** @param {number | undefined} end @return {number} */
#boundedEnd(end) {
return Math.min(
this.#rangeEnd,
Math.max(this.#rangeStart, end ?? this.#rangeEnd)
this.#tokens.length,
Math.max(0, end ?? this.#tokens.length)
);
}

/** @param {number} openPosition @param {number} [endPosition] @return {number} */
closeOf(openPosition, endPosition) {
const close = this.#closes.get(openPosition) ?? -1;
const bound = this.#boundedEnd(endPosition);
return close >= this.#rangeStart && close < bound ? close : -1;
return close < bound ? close : -1;
}

/** @param {number} position @param {number} endPosition @return {number} */
nextComponent(position, endPosition) {
const bound = this.#boundedEnd(endPosition);
if (position < this.#rangeStart || position >= bound) return bound;
if (position < 0 || position >= bound) return bound;
const close = this.closeOf(position, bound);
return close === -1 ? position + 1 : close + 1;
}

/** @param {number} startPosition @param {number} endPosition @return {number} */
firstTopLevelComma(startPosition, endPosition) {
const bound = this.#boundedEnd(endPosition);
for (let i = Math.max(this.#rangeStart, startPosition); i < bound;) {
for (let i = Math.max(0, startPosition); i < bound;) {
if (this.#tokens[i][0] === CssType.Comma) return i;
i = this.nextComponent(i, bound);
}
Expand All @@ -103,16 +95,14 @@ class BlockIndex {
}

/**
* Build the delimiter index once for a token stream range. Matching remains
* LIFO: a mismatched closer is ignored and does not disturb the open stack.
* Build the delimiter index once for a token stream. Matching remains LIFO: a
* mismatched closer is ignored and does not disturb the open stack.
*
* @param {CSSToken[]} tokens
* @param {number} [start]
* @param {number} [end]
* @return {BlockIndex}
*/
function indexBlocks(tokens, start = 0, end = tokens.length) {
return new BlockIndex(tokens, start, end);
function indexBlocks(tokens) {
return new BlockIndex(tokens);
}

export { indexBlocks };
22 changes: 2 additions & 20 deletions src/lib/function-analyzers.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isClampKeyword } from './keywords.js';
import { ROUND_STRATEGIES } from './simplify/round.js';
import {
addTypes,
Expand Down Expand Up @@ -45,10 +46,7 @@ function analyzeSign(args) {

/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */
function analyzeRound(args, nodes) {
const strategy = nodes[0];
const hasStrategy =
strategy?.type === 'Ident' &&
ROUND_STRATEGIES.has(strategy.name.toLowerCase());
const hasStrategy = nodes.length > 0 && isRoundStrategy(nodes[0]);
const start = hasStrategy ? 1 : 0;
const valueCount = args.length - start;
if (valueCount === 1) {
Expand All @@ -72,25 +70,11 @@ function analyzeAtan2(args) {
return { kind: 'dimension', base: 'angle' };
}

/** @param {CalculationType[]} args @return {CalculationType} */
function analyzeCalc(args) {
return args.length === 1 ? args[0] : failureType;
}

/** @param {Node} node @return {boolean} */
function isRoundStrategy(node) {
return node.type === 'Ident' && ROUND_STRATEGIES.has(node.name.toLowerCase());
}

/** @param {Node} node @param {number} index @return {boolean} */
function isClampKeyword(node, index) {
return (
(index === 0 || index === 2) &&
node.type === 'Ident' &&
node.name.toLowerCase() === 'none'
);
}

/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */
function analyzeClamp(args, nodes) {
if (args.length !== 3) return failureType;
Expand All @@ -107,13 +91,11 @@ function analyzeClamp(args, nodes) {

export {
analyzeAtan2,
analyzeCalc,
analyzeClamp,
analyzeIdentity,
analyzeInverseTrig,
analyzeRound,
analyzeSign,
analyzeTrig,
isClampKeyword,
isRoundStrategy,
};
Loading
Loading