postcss-calc 11.2.1 → 11.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -4,8 +4,9 @@
4
4
  [![Support Chat][git-img]][git-url]
5
5
 
6
6
  [PostCSS Calc] lets you reduce `calc()` references whenever it's possible.
7
- When multiple units are mixed together in the same expression, the `calc()`
8
- statement is left as is, to fallback to the [W3C calc() implementation].
7
+ When an expression mixes units that cannot be combined exactly (such as `px`
8
+ and `em`), or contains values only known at runtime (such as `var()`), the
9
+ unresolved part is left for the browser's [W3C calc() implementation].
9
10
 
10
11
  ## Installation
11
12
 
@@ -62,7 +63,7 @@ leaving all other text untouched.
62
63
  import reduceCalc from 'postcss-calc/reduce';
63
64
 
64
65
  reduceCalc('calc(1in + 10px)');
65
- // => 'calc(1.10417in)'
66
+ // => 'calc(106px)'
66
67
 
67
68
  reduceCalc('min(50px, calc(2 * 40px))');
68
69
  // => 'calc(50px)'
@@ -123,6 +124,28 @@ Allows you to define the precision for decimal numbers. Set it to `false` to
123
124
  disable rounding and preserve full IEEE-754 floating-point precision (emitting
124
125
  the shortest round-tripping decimal representation).
125
126
 
127
+ Values below 1 keep `precision` significant digits (`.0123456px` becomes
128
+ `.012346px`), and larger values keep `precision` decimals. Divisions and unit
129
+ conversions are folded only when the result is exact at the precision;
130
+ otherwise they stay symbolic and everything around them is still simplified:
131
+
132
+ ```css
133
+ .a {
134
+ width: calc(100% / 4);
135
+ } /* calc(25%) */
136
+ .b {
137
+ width: calc(100% / 3);
138
+ } /* calc(100% / 3), not 33.33333% */
139
+ .c {
140
+ width: calc(1cm + 1px);
141
+ } /* calc(1cm + 1px) */
142
+ .d {
143
+ width: calc(1px + 1pt);
144
+ } /* calc(1.75pt) */
145
+ ```
146
+
147
+ With `precision: false` nothing is rounded, so every division is folded.
148
+
126
149
  ```js
127
150
  var out = postcss()
128
151
  .use(calc({ precision: 10 }))
@@ -235,15 +258,10 @@ canonical-form decisions:
235
258
  requires the zero term because it carries the length-percentage type.
236
259
  - **Constant folding.** `calc(43 + pi)` now folds to `46.14159` (§10.7.1).
237
260
  Previously `pi` / `e` stayed symbolic.
238
- - **Reciprocal conversion.** `calc(var(--x) / 2)` becomes
239
- `calc(var(--x) * 0.5)`. The two are mathematically equivalent;
240
- previously the division shape was kept.
241
261
  - **Distributive multiplication.** `calc(0.5 * (100vw - 10px))` becomes
242
262
  `calc(50vw - 5px)`.
243
263
  - **Unit case normalization.** `2PX` becomes `2px` (CSS units are case-
244
264
  insensitive; lowercase is conventional).
245
- - **Calc unwrap (§10.6).** `calc(var(--foo))` becomes `var(--foo)` — a
246
- `calc()` containing a single value is replaced by that value.
247
265
  - **Spec-style spaced operators.** `2px*var(--x)` is serialized as
248
266
  `2px * var(--x)`. The tokenizer is unaffected; only output spacing
249
267
  differs.
@@ -265,13 +283,15 @@ To replace the value of CSS custom properties at build time, try [PostCSS Custom
265
283
  ## Contributing
266
284
 
267
285
  Work on a branch, install dev-dependencies, respect coding style & run tests
268
- before submitting a bug fix or a feature.
286
+ before submitting a bug fix or a feature. See [CONTRIBUTING.md](CONTRIBUTING.md)
287
+ for the full guidelines. The project uses [pnpm](https://pnpm.io/).
269
288
 
270
289
  ```bash
271
290
  git clone git@github.com:postcss/postcss-calc.git
272
291
  git checkout -b patch-1
273
- npm install
274
- npm test
292
+ pnpm install
293
+ pnpm test
294
+ pnpm lint
275
295
  ```
276
296
 
277
297
  The normal test run uses a deterministic structural sample of the harvested
@@ -282,11 +302,19 @@ when changing parsing/simplification behavior:
282
302
  pnpm test:corpus:full
283
303
  ```
284
304
 
285
- Profile parser chains with `pnpm benchmark:arithmetic-chains` or
286
- `pnpm benchmark:nested-fallbacks`; both use 20 fresh paired blocks by default
287
- and write ignored schema-v2 reports. Compare a saved report with
288
- `node scripts/compare-parser-benchmarks.js <report>`. Run the correctness-aware
289
- corpus benchmark with `pnpm benchmark:corpus`.
305
+ Performance changes to the parser, analyzer, simplifier, or serializer should be
306
+ checked with the benchmarks. `pnpm benchmark:arithmetic-chains` and
307
+ `pnpm benchmark:nested-fallbacks` compare the working tree against `HEAD` using
308
+ 20 fresh-process paired blocks by default and write git-ignored schema-v2
309
+ reports under `reports/benchmarks/`. Re-check a saved report with
310
+ `pnpm benchmark:reanalyze <report>`. `pnpm benchmark:corpus` compares the whole
311
+ pipeline with `@csstools/css-calc` on real-world expressions and is
312
+ report-only.
313
+
314
+ These benchmarks time only the parser (or, for the corpus, the whole reducer)
315
+ on one machine, and a `pass` means "no regression detected at the declared
316
+ margin", not "no change". Read [BENCHMARKS.md](BENCHMARKS.md) before
317
+ interpreting results.
290
318
 
291
319
  The PostCSS benchmark awaits `postcss().process(...)`, and that await already
292
320
  triggers result stringification. It therefore does not add a redundant
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-calc",
3
- "version": "11.2.1",
3
+ "version": "11.2.2",
4
4
  "type": "module",
5
5
  "description": "PostCSS plugin to reduce calc()",
6
6
  "keywords": [
@@ -38,20 +38,20 @@
38
38
  "devEngines": {
39
39
  "packageManager": {
40
40
  "name": "pnpm",
41
- "version": "12.4.2"
41
+ "version": "12.8.1"
42
42
  }
43
43
  },
44
44
  "devDependencies": {
45
- "@csstools/css-calc": "^3.4.0",
46
- "@types/node": "^26.6.1",
47
- "fast-check": "^4.10.1",
48
- "oxfmt": "^0.68.0",
49
- "oxlint": "^1.83.0",
45
+ "@csstools/css-calc": "^3.4.1",
46
+ "@types/node": "^26.6.3",
47
+ "fast-check": "^4.10.2",
48
+ "oxfmt": "^0.71.0",
49
+ "oxlint": "^1.86.0",
50
50
  "postcss": "^8.5.28",
51
51
  "typescript": "~7.0.2"
52
52
  },
53
53
  "dependencies": {
54
- "@csstools/css-tokenizer": "^4.0.1"
54
+ "@csstools/css-tokenizer": "^4.0.2"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "postcss": "^8.5.28"
@@ -59,13 +59,13 @@
59
59
  "scripts": {
60
60
  "lint": "oxlint . && tsc && oxfmt --check",
61
61
  "fmt": "oxfmt",
62
- "benchmark:arithmetic-chains": "node scripts/benchmark-arithmetic-chains.js",
63
- "benchmark:nested-fallbacks": "node scripts/benchmark-nested-fallbacks.js",
64
- "benchmark:corpus": "node scripts/benchmark.js",
65
- "benchmark:serialization": "node scripts/benchmark-serialization.js",
62
+ "benchmark:arithmetic-chains": "node scripts/benchmark/benchmark-parser.js arithmetic-chains",
63
+ "benchmark:nested-fallbacks": "node scripts/benchmark/benchmark-parser.js nested-fallbacks",
64
+ "benchmark:corpus": "node scripts/benchmark/benchmark-corpus.js",
65
+ "benchmark:serialization": "node scripts/benchmark/benchmark-serialization.js",
66
66
  "test:benchmark": "node --test 'test/unit/benchmark-*.test.js' test/unit/compare-parser-benchmarks.test.js test/unit/corpus-benchmark.test.js",
67
67
  "test:benchmark:simulation": "node test/benchmark/statistical-simulation.js",
68
- "benchmark:reanalyze": "node scripts/compare-parser-benchmarks.js",
68
+ "benchmark:reanalyze": "node scripts/benchmark/compare-parser-benchmarks.js",
69
69
  "test": "node --test --test-reporter=dot 'test/**/*.test.js' 'test/**/*.test.cjs'",
70
70
  "test:mutation:corpus": "node test/mutation/corpus-selection.js",
71
71
  "test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.js"
package/src/index.js CHANGED
@@ -65,12 +65,13 @@ function applyTransform(
65
65
  function pluginCreator(opts) {
66
66
  /** @type {ResolvedOptions} */
67
67
  const options = {
68
- precision: 5,
69
68
  warnWhenCannotResolve: false,
70
69
  mediaQueries: false,
71
70
  selectors: false,
72
71
  unwrapSingleValue: false,
73
72
  ...opts,
73
+ // An explicit `precision: undefined` keeps the default.
74
+ precision: opts?.precision ?? 5,
74
75
  };
75
76
 
76
77
  return {
@@ -50,9 +50,6 @@ function analyzeType(node, depth = 0) {
50
50
  case 'Num':
51
51
  return resolved(numberType);
52
52
  case 'Dim':
53
- // Percentages are contextual; their percent-ness is tracked so a
54
- // `% / %` product cancels to a number. Unknown units are opaque, while
55
- // known families can still reject px + seconds.
56
53
  return node.unit === '%'
57
54
  ? finish(percentageType, true, true)
58
55
  : resolved({ kind: 'dimension', base: baseOf(node.unit) });
@@ -83,9 +80,6 @@ function analyzeSum(node, depth) {
83
80
  if (isFailure(child.type)) {
84
81
  type = failureType;
85
82
  } else if (child.type.kind === 'unknown') {
86
- // A pure percentage sum stays percentage-typed so a surrounding
87
- // product can cancel `% / %`; any other opaque term must widen the
88
- // sum back to unknown.
89
83
  if (isPercentage(child.type)) hasPercentage = true;
90
84
  else hasUnknown = true;
91
85
  } else if (type === null) {
@@ -34,7 +34,7 @@ function compileCandidate(candidate, ctx) {
34
34
  if (!analysis.valid) {
35
35
  throw new Error('Invalid CSS calculation type');
36
36
  }
37
- const tree = simplify(parsed);
37
+ const tree = simplify(parsed, ctx.options.precision);
38
38
  const original =
39
39
  analysis.unresolved && !candidate.calculation
40
40
  ? ctx.value.slice(candidate.start, candidate.end)
package/src/lib/node.js CHANGED
@@ -8,7 +8,11 @@
8
8
  // - No ungrouped Sum directly contains another Sum (flattened on
9
9
  // construction). A grouped Sum is retained so a later negative sign
10
10
  // cannot be distributed across opaque terms.
11
- // - No Product directly contains another Product (flattened).
11
+ // - No Product directly contains another ungrouped Product (flattened). A
12
+ // grouped Product is retained: it is a parenthesized group around a
13
+ // substitution function (`var()`, `env()`, ...), whose tokens must not
14
+ // merge with the surrounding factors. A grouped Product may hold a single
15
+ // factor.
12
16
  // - A Sum/Product with one positive element collapses to that element.
13
17
  // - A Sum/Product with no elements collapses to Num(0) / Num(1).
14
18
  // - Positive zero-valued Nums are dropped from all-number sums. They are
@@ -28,7 +32,7 @@
28
32
  * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim.
29
33
  * @typedef {{type: 'Sum', terms: SumTerm[], grouped?: boolean}} Sum
30
34
  * @typedef {{exponent: 1 | -1, node: Node}} ProductFactor exponent +1 = numerator, -1 = denominator.
31
- * @typedef {{type: 'Product', factors: ProductFactor[]}} Product
35
+ * @typedef {{type: 'Product', factors: ProductFactor[], grouped?: boolean}} Product
32
36
  * @typedef {Num | Dim | Ident | Call | OpaqueCall | Sum | Product} Node
33
37
  */
34
38
 
@@ -185,7 +189,7 @@ function mkProduct(rawFactors) {
185
189
  */
186
190
  function pushProductFactor(out, f) {
187
191
  const n = f.node;
188
- if (n.type === 'Product') {
192
+ if (n.type === 'Product' && !n.grouped) {
189
193
  for (const inner of n.factors) {
190
194
  out.push({
191
195
  exponent: /** @type {1 | -1} */ (f.exponent * inner.exponent),
@@ -201,6 +205,48 @@ function pushProductFactor(out, f) {
201
205
  out.push(f);
202
206
  }
203
207
 
208
+ /**
209
+ * Mark a node as a source parenthesized group. A Sum or Product keeps its
210
+ * structure; any other node becomes a single-factor grouped Product.
211
+ * @param {Node} node
212
+ * @return {Node}
213
+ */
214
+ function mkGroup(node) {
215
+ if (node.type === 'Sum' || node.type === 'Product') {
216
+ return { ...node, grouped: true };
217
+ }
218
+ return {
219
+ type: 'Product',
220
+ factors: [{ exponent: 1, node }],
221
+ grouped: true,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Whether a node is a substitution function call (`var()`, `env()`, ...), or
227
+ * an ungrouped Product that directly contains one. Nested groups are not
228
+ * inspected: they already bound their own tokens.
229
+ * @param {Node} node
230
+ * @return {boolean}
231
+ */
232
+ function isSubstitutionNode(node) {
233
+ if (node.type === 'OpaqueCall') return true;
234
+ if (node.type === 'Product' && !node.grouped) {
235
+ return node.factors.some((factor) => isSubstitutionNode(factor.node));
236
+ }
237
+ return false;
238
+ }
239
+
240
+ /**
241
+ * Group a parenthesized expression when its tokens must stay bound: a sum, or
242
+ * a product around a substitution function.
243
+ * @param {Node} node
244
+ * @return {Node}
245
+ */
246
+ function groupSubstitution(node) {
247
+ return node.type === 'Sum' || isSubstitutionNode(node) ? mkGroup(node) : node;
248
+ }
249
+
204
250
  /**
205
251
  * Negate any node, preserving canonical form.
206
252
  * @param {Node} node
@@ -233,4 +279,15 @@ function negate(node) {
233
279
  return mkSum([{ sign: -1, node }]);
234
280
  }
235
281
 
236
- export { num, dim, ident, call, opaqueCall, mkSum, mkProduct, negate };
282
+ export {
283
+ num,
284
+ dim,
285
+ ident,
286
+ call,
287
+ opaqueCall,
288
+ mkSum,
289
+ mkProduct,
290
+ mkGroup,
291
+ groupSubstitution,
292
+ negate,
293
+ };
@@ -0,0 +1,151 @@
1
+ // Opaque function parsing: `var()`, unknown functions and their component trees.
2
+ import { TokenType as CssType } from '@csstools/css-tokenizer';
3
+ import { ident, opaqueCall } from '../node.js';
4
+ import {
5
+ isCalculationFunction,
6
+ isSupportedMathFunction,
7
+ } from '../functions.js';
8
+ import { assertDepth } from '../limits.js';
9
+ import { eofPositionAt, sourceSpelling } from './tokens.js';
10
+
11
+ /** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */
12
+ /** @typedef {import('../node.js').Node} Node */
13
+ /** @typedef {import('../node.js').OpaqueComponent} OpaqueComponent */
14
+ /** @typedef {import('./tokens.js').ParseInput} ParseInput */
15
+ /** @typedef {import('./tokens.js').Cursor} Cursor */
16
+ /** @typedef {import('./tokens.js').FunctionToken} FunctionToken */
17
+ /** @typedef {(input: ParseInput, start: number, end: number) => Node} ParseRange */
18
+
19
+ /** @param {OpaqueComponent[]} target @param {OpaqueComponent} part */
20
+ function pushComponent(target, part) {
21
+ if (typeof part === 'string' && typeof target.at(-1) === 'string')
22
+ target[target.length - 1] += part;
23
+ else target.push(part);
24
+ }
25
+
26
+ /** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @return {number} */
27
+ function closeOfCall(input, cursor, token, name) {
28
+ const close = input.index.closeOf(token.index, input.end);
29
+ if (close === -1)
30
+ throw new Error(
31
+ `Unclosed ${name}( at position ${eofPositionAt(input, cursor.index)}`
32
+ );
33
+ return close;
34
+ }
35
+
36
+ /** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */
37
+ function parseOpaqueCall(input, cursor, token, name, rawName, parseRange) {
38
+ const close = closeOfCall(input, cursor, token, name);
39
+ cursor.skipTo(close + 1);
40
+ return opaqueCall(
41
+ name,
42
+ componentTree(input, token.index + 1, close, parseRange, []),
43
+ sourceSpelling(rawName, name)
44
+ );
45
+ }
46
+
47
+ /** @param {CSSToken[]} tokens @param {number} start @param {number} end */
48
+ function rawTokens(tokens, start, end) {
49
+ let raw = '';
50
+ for (let i = start; i < end; i++) raw += tokens[i][1];
51
+ return raw;
52
+ }
53
+ /** @param {CSSToken[]} tokens @param {number} start @param {number} end */
54
+ function customProperty(tokens, start, end) {
55
+ /** @type {CSSToken | null} */
56
+ let found = null;
57
+ let foundIndex = -1;
58
+ for (let i = start; i < end; i++) {
59
+ const type = tokens[i][0];
60
+ if (type === CssType.Whitespace || type === CssType.Comment) continue;
61
+ if (found !== null) return null;
62
+ found = tokens[i];
63
+ foundIndex = i;
64
+ }
65
+ if (!found || found[0] !== CssType.Ident) return null;
66
+ const decoded = found[4].value;
67
+ return decoded.startsWith('--') && decoded !== '--'
68
+ ? { decoded, raw: found[1], index: foundIndex }
69
+ : null;
70
+ }
71
+ /**
72
+ * Append the component tree of `[start, end)` to `root` and return it.
73
+ * Callers seed `root` so leading components need no copy or spread.
74
+ * @param {ParseInput} input @param {number} start @param {number} end @param {ParseRange} parseRange @param {OpaqueComponent[]} root @return {OpaqueComponent[]}
75
+ */
76
+ function componentTree(input, start, end, parseRange, root) {
77
+ /** @type {OpaqueComponent[]} */ let tree = root;
78
+ /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}[]} */
79
+ const frames = [];
80
+ const { tokens } = input;
81
+ let i = start;
82
+ while (true) {
83
+ if (i >= end) {
84
+ if (frames.length === 0) break;
85
+ const frame =
86
+ /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}} */ (
87
+ frames.pop()
88
+ );
89
+ tree = frame.parent;
90
+ pushComponent(tree, frame.tree);
91
+ pushComponent(tree, tokens[frame.close][1]);
92
+ end = frame.end;
93
+ i = frame.close + 1;
94
+ continue;
95
+ }
96
+
97
+ const token = tokens[i];
98
+ const close = input.index.closeOf(i, end);
99
+ if (close === -1) {
100
+ pushComponent(tree, token[1]);
101
+ i++;
102
+ continue;
103
+ }
104
+
105
+ const isMathFunction =
106
+ token[0] === CssType.Function &&
107
+ (isCalculationFunction(token[4].value) ||
108
+ isSupportedMathFunction(token[4].value));
109
+ if (isMathFunction) {
110
+ try {
111
+ pushComponent(tree, parseRange(input, i, close + 1));
112
+ } catch {
113
+ pushComponent(tree, rawTokens(tokens, i, close + 1));
114
+ }
115
+ i = close + 1;
116
+ continue;
117
+ }
118
+
119
+ pushComponent(tree, token[1]);
120
+ assertDepth(frames.length + 1);
121
+ /** @type {OpaqueComponent[]} */
122
+ const child = [];
123
+ frames.push({ parent: tree, tree: child, close, end });
124
+ tree = child;
125
+ end = close;
126
+ i++;
127
+ }
128
+ return root;
129
+ }
130
+ /** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @param {ParseRange} parseRange @return {Node} */
131
+ function parseVar(input, cursor, token, name, rawName, parseRange) {
132
+ const { tokens } = input;
133
+ const start = token.index + 1;
134
+ const close = closeOfCall(input, cursor, token, name);
135
+ const comma = input.index.firstTopLevelComma(start, close);
136
+ const property = customProperty(tokens, start, comma === -1 ? close : comma);
137
+ if (!property)
138
+ throw new Error(
139
+ `Invalid custom property in ${name}() at position ${tokens[start]?.[2] ?? eofPositionAt(input, cursor.index)}`
140
+ );
141
+ cursor.skipTo(close + 1);
142
+ /** @type {OpaqueComponent[]} */
143
+ const components = [
144
+ ident(property.decoded, sourceSpelling(property.raw, property.decoded)),
145
+ ];
146
+ if (comma !== -1)
147
+ componentTree(input, property.index + 1, close, parseRange, components);
148
+ return opaqueCall(name, components, sourceSpelling(rawName, name));
149
+ }
150
+
151
+ export { parseOpaqueCall, parseVar };