postcss-calc 11.0.2 → 11.1.0

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/README.md CHANGED
@@ -13,7 +13,7 @@ statement is left as is, to fallback to the [W3C calc() implementation].
13
13
  npm install postcss-calc
14
14
  ```
15
15
 
16
- ## Usage
16
+ ## PostCSS usage
17
17
 
18
18
  ```js
19
19
  // dependencies
@@ -52,6 +52,38 @@ h1 {
52
52
 
53
53
  Checkout [tests] for more examples.
54
54
 
55
+ ## Use the reducer without PostCSS
56
+
57
+ For a single CSS component-value string, import the dedicated reducer entry
58
+ point. It reduces `calc()` and the supported CSS math functions it finds while
59
+ leaving all other text untouched.
60
+
61
+ ```js
62
+ import reduceCalc from 'postcss-calc/reduce';
63
+
64
+ reduceCalc('calc(1in + 10px)');
65
+ // => '1.10417in'
66
+
67
+ reduceCalc('min(50px, calc(2 * 40px))');
68
+ // => '50px'
69
+ ```
70
+
71
+ It accepts `precision`, `warnWhenCannotResolve`, `onParseError`, and `onWarn`:
72
+
73
+ ```js
74
+ const result = reduceCalc('calc(100% + var(--gap))', {
75
+ precision: false,
76
+ warnWhenCannotResolve: true,
77
+ onWarn: console.warn,
78
+ onParseError(error, input) {
79
+ console.error(`Invalid calculation: ${input}`, error);
80
+ },
81
+ });
82
+ ```
83
+
84
+ Unlike the PostCSS plugin, the standalone reducer does not show warnings
85
+ by default; provide `onParseError` and/or `onWarn` if you want diagnostics.
86
+
55
87
  ### Options
56
88
 
57
89
  #### `precision` (default: `5`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-calc",
3
- "version": "11.0.2",
3
+ "version": "11.1.0",
4
4
  "type": "module",
5
5
  "description": "PostCSS plugin to reduce calc()",
6
6
  "keywords": [
@@ -19,6 +19,10 @@
19
19
  ".": {
20
20
  "types": "./types/index.d.ts",
21
21
  "default": "./src/index.js"
22
+ },
23
+ "./reduce": {
24
+ "types": "./types/reduce.d.ts",
25
+ "default": "./src/reduce.js"
22
26
  }
23
27
  },
24
28
  "files": [
@@ -40,19 +44,18 @@
40
44
  "devDependencies": {
41
45
  "@csstools/css-calc": "^3.3.0",
42
46
  "@rmenke/css-tokenizer-tests": "^1.2.0",
43
- "@types/node": "^26.4.0",
47
+ "@types/node": "^26.4.1",
44
48
  "fast-check": "^4.9.0",
45
- "oxfmt": "^0.65.0",
46
- "oxlint": "^1.80.0",
47
- "postcss": "^8.5.26",
49
+ "oxfmt": "^0.66.0",
50
+ "oxlint": "^1.81.0",
51
+ "postcss": "^8.5.28",
48
52
  "typescript": "~7.0.2"
49
53
  },
50
54
  "dependencies": {
51
- "@csstools/css-parser-algorithms": "^4.0.0",
52
55
  "@csstools/css-tokenizer": "^4.0.0"
53
56
  },
54
57
  "peerDependencies": {
55
- "postcss": "^8.5.25"
58
+ "postcss": "^8.5.28"
56
59
  },
57
60
  "scripts": {
58
61
  "lint": "oxlint . && tsc && oxfmt --check",
package/src/index.js CHANGED
@@ -1,26 +1,5 @@
1
- // PostCSS adapter. Walks declaration values (and optionally @rule params
2
- // and selectors), feeds calc() bodies through tokenize → parse → simplify
3
- // → serialize, and writes the result back.
4
- import { tokenize as cssTokenize } from '@csstools/css-tokenizer';
5
- import {
6
- isFunctionNode,
7
- isSimpleBlockNode,
8
- parseListOfComponentValues,
9
- } from '@csstools/css-parser-algorithms';
10
- import { tokenize } from './lib/tokenizer.js';
11
- import { parse } from './lib/parser.js';
12
- import { simplify } from './lib/simplify.js';
13
- import { isSupportedMathFunction } from './lib/simplify/call.js';
14
- import { serialize } from './lib/serialize.js';
15
-
16
- // The outer walk is deliberately forgiving: it only needs to locate calc()/
17
- // math-function boundaries in otherwise arbitrary (and possibly non-standard)
18
- // CSS values, so parse errors from the outer tokenizer/parser are swallowed.
19
- // Genuine syntax problems inside a matched call are
20
- // caught below via our own tokenize/parse/simplify pipeline.
21
- const NOOP_PARSE_ERROR = { onParseError: () => {} };
22
-
23
- const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;
1
+ // PostCSS adapter over the standalone component-value reducer.
2
+ import reduceCalc, { hasPotentialMathFunction } from './reduce.js';
24
3
 
25
4
  /**
26
5
  * @typedef {object} PostCssCalcOptions
@@ -34,99 +13,7 @@ const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;
34
13
  /** @typedef {Required<Omit<PostCssCalcOptions, 'onParseError'>> & Pick<PostCssCalcOptions, 'onParseError'>} ResolvedOptions */
35
14
 
36
15
  /**
37
- * Fields threaded unchanged through the recursive `transformList` walk.
38
- * `value` is the original full property text, used only for the
39
- * warnWhenCannotResolve message.
40
- *
41
- * @typedef {object} TransformContext
42
- * @property {ResolvedOptions} options
43
- * @property {import('postcss').Result} result
44
- * @property {import('postcss').ChildNode} item
45
- * @property {string} value
46
- */
47
-
48
- /**
49
- * Walks a list of component values in place, replacing matched calc()/math
50
- * function nodes with their simplified form. Unlike the library's generic
51
- * `walk` helper, this recurses manually so a matched node's own (stale,
52
- * pre-simplification) children are never independently re-visited.
53
- *
54
- * @param {import('@csstools/css-parser-algorithms').ComponentValue[]} list
55
- * @param {TransformContext} ctx
56
- * @return {void}
57
- */
58
- function transformList(list, ctx) {
59
- for (let i = 0; i < list.length; i++) {
60
- const node = list[i];
61
- if (!isFunctionNode(node)) {
62
- if (isSimpleBlockNode(node)) {
63
- transformList(node.value, ctx);
64
- }
65
- continue;
66
- }
67
-
68
- const name = node.getName();
69
- const isCalc = MATCH_CALC.test(name);
70
- const isMath = !isCalc && isSupportedMathFunction(name);
71
- if (!isCalc && !isMath) {
72
- transformList(node.value, ctx);
73
- continue;
74
- }
75
-
76
- // calc(): feed the body. Bare math: feed the whole call.
77
- const inner = node.value.map((child) => child.toString()).join('');
78
- const contents = isCalc ? inner : `${name}(${inner})`;
79
- try {
80
- const simplified = simplify(parse(tokenize(contents)));
81
- const str = serialize(simplified, {
82
- precision: ctx.options.precision,
83
- calcName: isCalc ? name : 'calc', // preserve vendor prefix on calc()
84
- });
85
-
86
- if (ctx.options.warnWhenCannotResolve && str.startsWith(`${name}(`)) {
87
- ctx.result.warn('Could not reduce expression: ' + ctx.value, {
88
- plugin: 'postcss-calc',
89
- node: ctx.item,
90
- });
91
- }
92
-
93
- const replacement = parseListOfComponentValues(
94
- cssTokenize({ css: str }),
95
- NOOP_PARSE_ERROR
96
- );
97
- list.splice(i, 1, ...replacement);
98
- i += replacement.length - 1;
99
- } catch (error) {
100
- const err = error instanceof Error ? error : new Error('Error');
101
- if (ctx.options.onParseError) {
102
- ctx.options.onParseError(err, contents);
103
- } else {
104
- ctx.result.warn(err.message, { node: ctx.item });
105
- }
106
- }
107
- }
108
- }
109
-
110
- /**
111
- * @param {string} value
112
- * @param {ResolvedOptions} options
113
- * @param {import('postcss').Result} result
114
- * @param {import('postcss').ChildNode} item
115
- * @return {string}
116
- */
117
- function transformValue(value, options, result, item) {
118
- const componentValues = parseListOfComponentValues(
119
- cssTokenize({ css: value }),
120
- NOOP_PARSE_ERROR
121
- );
122
-
123
- transformList(componentValues, { options, result, item, value });
124
-
125
- return componentValues.map((node) => node.toString()).join('');
126
- }
127
-
128
- /**
129
- * Runs `transformValue` over one text property of a decl/atrule/rule node
16
+ * Runs `reduceCalc` over one text property of a decl/atrule/rule node
130
17
  * and updates it in place.
131
18
  * `setProp` closes over the property name and the concrete node type at
132
19
  * each call site, since `Declaration`/`AtRule`/`Rule` don't share a typed
@@ -140,7 +27,24 @@ function transformValue(value, options, result, item) {
140
27
  * @return {void}
141
28
  */
142
29
  function applyTransform(node, current, setProp, options, result) {
143
- setProp(node, transformValue(current, options, result, node));
30
+ if (!hasPotentialMathFunction(current)) {
31
+ return;
32
+ }
33
+ const transformed = reduceCalc(current, {
34
+ precision: options.precision,
35
+ warnWhenCannotResolve: options.warnWhenCannotResolve,
36
+ onParseError:
37
+ options.onParseError ??
38
+ ((error) => {
39
+ result.warn(error.message, { node });
40
+ }),
41
+ onWarn: (message) => {
42
+ result.warn(message, { plugin: 'postcss-calc', node });
43
+ },
44
+ });
45
+ if (transformed !== current) {
46
+ setProp(node, transformed);
47
+ }
144
48
  }
145
49
 
146
50
  /**
package/src/lib/node.js CHANGED
@@ -71,7 +71,7 @@ function mkSum(rawTerms) {
71
71
  pushSumTerm(flat, t);
72
72
  }
73
73
  if (flat.length === 0) {
74
- return { type: 'Num', value: 0 };
74
+ return num(0);
75
75
  }
76
76
  if (flat.length === 1 && flat[0].sign === 1) {
77
77
  return flat[0].node;
@@ -101,10 +101,10 @@ function pushSumTerm(out, term) {
101
101
  // canonical-form rule downstream code relies on.
102
102
  if (sign === -1) {
103
103
  if (node.type === 'Num') {
104
- node = { type: 'Num', value: -node.value };
104
+ node = num(-node.value);
105
105
  sign = 1;
106
106
  } else if (node.type === 'Dim') {
107
- node = { type: 'Dim', value: -node.value, unit: node.unit };
107
+ node = dim(-node.value, node.unit);
108
108
  sign = 1;
109
109
  }
110
110
  }
@@ -128,7 +128,7 @@ function mkProduct(rawFactors) {
128
128
  pushProductFactor(flat, f);
129
129
  }
130
130
  if (flat.length === 0) {
131
- return { type: 'Num', value: 1 };
131
+ return num(1);
132
132
  }
133
133
  if (flat.length === 1 && flat[0].exponent === 1) {
134
134
  return flat[0].node;
@@ -184,7 +184,7 @@ function negate(node) {
184
184
  }
185
185
  // Opaque (Ident, Call, Product): wrap as a single negative-sign term —
186
186
  // the only case where sign=-1 remains on a SumTerm.
187
- return { type: 'Sum', terms: [{ sign: -1, node }] };
187
+ return mkSum([{ sign: -1, node }]);
188
188
  }
189
189
 
190
190
  export { num, dim, ident, call, mkSum, mkProduct, negate };
package/src/lib/parser.js CHANGED
@@ -10,15 +10,6 @@ import { mkSum, mkProduct, negate, num, dim, ident, call } from './node.js';
10
10
  * @typedef {(p: Parser, token: Token) => Node} PrefixParselet
11
11
  */
12
12
 
13
- /**
14
- * @param {Token} t
15
- * @param {string} value
16
- * @return {boolean}
17
- */
18
- function isPunct(t, value) {
19
- return t.type === 'punct' && t.value === value;
20
- }
21
-
22
13
  /**
23
14
  * §10.9 — case-insensitive except for NaN.
24
15
  * @param {string} name
@@ -81,6 +72,39 @@ class Parser {
81
72
  return t;
82
73
  }
83
74
 
75
+ /**
76
+ * @param {string} value
77
+ * @param {string} [value2]
78
+ * @return {boolean}
79
+ */
80
+ isPunct(value, value2) {
81
+ const t = this.peek();
82
+ return (
83
+ t.type === 'punct' &&
84
+ (t.value === value || (value2 !== undefined && t.value === value2))
85
+ );
86
+ }
87
+
88
+ /**
89
+ * @param {string} value
90
+ * @return {boolean}
91
+ */
92
+ matchPunct(value) {
93
+ if (this.isPunct(value)) {
94
+ this.next();
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * @param {string} value
102
+ * @return {Token}
103
+ */
104
+ expectPunct(value) {
105
+ return this.expect('punct', value);
106
+ }
107
+
84
108
  /**
85
109
  * @param {number} [minBp]
86
110
  * @return {Node}
@@ -111,14 +135,7 @@ class Parser {
111
135
  sign: /** @type {1 | -1} */ (token.value === '+' ? 1 : -1),
112
136
  node: this.parseExpr(ADD_BP + 1),
113
137
  });
114
- const next = this.peek();
115
- if (
116
- next.type !== 'punct' ||
117
- (next.value !== '+' && next.value !== '-')
118
- ) {
119
- break;
120
- }
121
- } while (ADD_BP >= minBp);
138
+ } while (this.isPunct('+', '-'));
122
139
  left = mkSum(terms);
123
140
  continue;
124
141
  }
@@ -132,14 +149,7 @@ class Parser {
132
149
  exponent: /** @type {1 | -1} */ (token.value === '*' ? 1 : -1),
133
150
  node: this.parseExpr(MUL_BP + 1),
134
151
  });
135
- const next = this.peek();
136
- if (
137
- next.type !== 'punct' ||
138
- (next.value !== '*' && next.value !== '/')
139
- ) {
140
- break;
141
- }
142
- } while (MUL_BP >= minBp);
152
+ } while (this.isPunct('*', '/'));
143
153
  left = mkProduct(factors);
144
154
  continue;
145
155
  }
@@ -255,22 +265,19 @@ const PREFIX = {
255
265
  ),
256
266
 
257
267
  ident: (p, t) => {
258
- const nxt = p.peek();
259
- if (nxt.type === 'punct' && nxt.value === '(') {
260
- p.next();
268
+ if (p.matchPunct('(')) {
261
269
  if (OPAQUE_ARG_FUNCTIONS.has(t.value.toLowerCase())) {
262
270
  return parseOpaqueCall(p, t.value);
263
271
  }
264
272
  /** @type {Node[]} */
265
273
  const args = [];
266
- if (!isPunct(p.peek(), ')')) {
274
+ if (!p.isPunct(')')) {
267
275
  args.push(p.parseExpr(0));
268
- while (isPunct(p.peek(), ',')) {
269
- p.next();
276
+ while (p.matchPunct(',')) {
270
277
  args.push(p.parseExpr(0));
271
278
  }
272
279
  }
273
- p.expect('punct', ')');
280
+ p.expectPunct(')');
274
281
  return call(t.value, args);
275
282
  }
276
283
  const kw = foldCalcKeyword(t.value);
@@ -282,7 +289,7 @@ const PREFIX = {
282
289
 
283
290
  '(': (p) => {
284
291
  const e = p.parseExpr(0);
285
- p.expect('punct', ')');
292
+ p.expectPunct(')');
286
293
  return e.type === 'Sum' ? { ...e, grouped: true } : e;
287
294
  },
288
295
 
@@ -3,6 +3,8 @@
3
3
  // arithmetic operator. A Sum inside a Product is the only place parens
4
4
  // are ever required on valid canonical input.
5
5
 
6
+ import { num, dim } from './node.js';
7
+
6
8
  /**
7
9
  * @typedef {import('./node.js').Node} Node
8
10
  * @typedef {import('./node.js').Sum} Sum
@@ -105,14 +107,11 @@ function serialize(node, opts = {}) {
105
107
  node.terms.length > 1 &&
106
108
  displaySign(node.terms[0]).sign === -1
107
109
  ) {
108
- const body = /** @type {Sum} */ ({
109
- type: 'Sum',
110
- terms: node.terms.map((t) => ({
111
- sign: /** @type {1 | -1} */ (-t.sign),
112
- node: t.node,
113
- })),
114
- });
115
- return `${calcName}(-(${serializeExpr(body, prec)}))`;
110
+ const invertedTerms = node.terms.map((t) => ({
111
+ sign: /** @type {1 | -1} */ (-t.sign),
112
+ node: t.node,
113
+ }));
114
+ return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`;
116
115
  }
117
116
 
118
117
  if (
@@ -182,27 +181,27 @@ function displaySign(term) {
182
181
  if (node.type === 'Num' && Number.isFinite(node.value) && node.value < 0) {
183
182
  return {
184
183
  sign: /** @type {1 | -1} */ (-sign),
185
- magnitude: { type: 'Num', value: -node.value },
184
+ magnitude: num(-node.value),
186
185
  };
187
186
  }
188
187
  if (node.type === 'Dim' && Number.isFinite(node.value) && node.value < 0) {
189
188
  return {
190
189
  sign: /** @type {1 | -1} */ (-sign),
191
- magnitude: { type: 'Dim', value: -node.value, unit: node.unit },
190
+ magnitude: dim(-node.value, node.unit),
192
191
  };
193
192
  }
194
193
  return { sign, magnitude: node };
195
194
  }
196
195
 
197
196
  /**
198
- * @param {Sum} sum
197
+ * @param {import('./node.js').SumTerm[]} terms
199
198
  * @param {number | false} prec
200
199
  * @return {string}
201
200
  */
202
- function serializeSum(sum, prec) {
201
+ function serializeSumTerms(terms, prec) {
203
202
  let out = '';
204
- for (const [i, t] of sum.terms.entries()) {
205
- const { sign, magnitude } = displaySign(t);
203
+ for (let i = 0; i < terms.length; i++) {
204
+ const { sign, magnitude } = displaySign(terms[i]);
206
205
  if (i === 0) {
207
206
  if (magnitude.type === 'Sum' && magnitude.grouped) {
208
207
  const body = `(${serializeExpr(magnitude, prec)})`;
@@ -225,6 +224,15 @@ function serializeSum(sum, prec) {
225
224
  return out;
226
225
  }
227
226
 
227
+ /**
228
+ * @param {Sum} sum
229
+ * @param {number | false} prec
230
+ * @return {string}
231
+ */
232
+ function serializeSum(sum, prec) {
233
+ return serializeSumTerms(sum.terms, prec);
234
+ }
235
+
228
236
  /**
229
237
  * Fold a leading negation into a finite leading Num if there is one
230
238
  * (`-(0.5 * x)` → `-0.5 * x`); else use `-(…)` for Sum/Product or `-x`.
@@ -249,11 +257,8 @@ function serializeLeadingNeg(node, prec) {
249
257
  const negatedFactors =
250
258
  negatedValue === 1
251
259
  ? rest
252
- : [
253
- { exponent: 1, node: { type: 'Num', value: negatedValue } },
254
- ...rest,
255
- ];
256
- return serializeProduct({ type: 'Product', factors: negatedFactors }, prec);
260
+ : [{ exponent: 1, node: num(negatedValue) }, ...rest];
261
+ return serializeFactors(negatedFactors, prec);
257
262
  }
258
263
  const body = serializeExpr(node, prec);
259
264
  return node.type === 'Sum' || node.type === 'Product'
@@ -262,13 +267,14 @@ function serializeLeadingNeg(node, prec) {
262
267
  }
263
268
 
264
269
  /**
265
- * @param {Product} product
270
+ * @param {ProductFactor[]} factors
266
271
  * @param {number | false} prec
267
272
  * @return {string}
268
273
  */
269
- function serializeProduct(product, prec) {
274
+ function serializeFactors(factors, prec) {
270
275
  let out = '';
271
- for (const [i, f] of product.factors.entries()) {
276
+ for (let i = 0; i < factors.length; i++) {
277
+ const f = factors[i];
272
278
  let body = serializeExpr(f.node, prec);
273
279
  // A Sum factor needs parens: `a * (b + c)`. Flat canonical form means
274
280
  // this is the only place parens are required.
@@ -285,4 +291,13 @@ function serializeProduct(product, prec) {
285
291
  return out;
286
292
  }
287
293
 
294
+ /**
295
+ * @param {Product} product
296
+ * @param {number | false} prec
297
+ * @return {string}
298
+ */
299
+ function serializeProduct(product, prec) {
300
+ return serializeFactors(product.factors, prec);
301
+ }
302
+
288
303
  export { serialize };
@@ -21,7 +21,7 @@ import { convert } from '../convertUnits.js';
21
21
  * @return {UnitBucket[]}
22
22
  */
23
23
  function mergeConvertibleBuckets(buckets) {
24
- const ordered = [...buckets].sort((a, b) => a.order - b.order);
24
+ const ordered = buckets.sort((a, b) => a.order - b.order);
25
25
  /** @type {Set<string>} */ const merged = new Set();
26
26
  /** @type {UnitBucket[]} */ const out = [];
27
27
  for (const b of ordered) {
@@ -26,7 +26,7 @@ import { call } from '../node.js';
26
26
  // Bare CSS math functions with implemented simplification semantics, keyed
27
27
  // by lowercase name. calc() and its vendor-prefixed forms are handled
28
28
  // separately as wrappers in simplifyCall. This map is the single source of
29
- // truth for both dispatch and `isSupportedMathFunction`.
29
+ // truth for dispatch, `isSupportedMathFunction`, and `QUICK_MATH_TEST`.
30
30
  /** @type {Map<string, MathSimplifier>} */
31
31
  const MATH_SIMPLIFIERS = new Map([
32
32
  ['min', simplifyMinMax],
@@ -51,6 +51,29 @@ const MATH_SIMPLIFIERS = new Map([
51
51
  ['exp', (_name, args) => simplifyExp(args)],
52
52
  ]);
53
53
 
54
+ const mathFnNames = [...MATH_SIMPLIFIERS.keys()].sort(
55
+ (a, b) => b.length - a.length
56
+ );
57
+
58
+ const QUICK_MATH_TEST = new RegExp(
59
+ `(?:-(?:webkit|moz)-)?(?:calc|${mathFnNames.join('|')})\\(`,
60
+ 'i'
61
+ );
62
+
63
+ /**
64
+ * Fast check to determine whether a CSS component value could contain
65
+ * a supported calculation or math function call (or an escape sequence
66
+ * that could decode to one).
67
+ *
68
+ * @param {string} value
69
+ * @return {boolean}
70
+ */
71
+ function hasPotentialMathFunction(value) {
72
+ return (
73
+ value.includes('(') && (QUICK_MATH_TEST.test(value) || value.includes('\\'))
74
+ );
75
+ }
76
+
54
77
  /**
55
78
  * Whether a bare CSS math function has an implemented simplifier.
56
79
  *
@@ -91,4 +114,9 @@ function simplifyCall(node, simplify) {
91
114
  return call(node.name, args);
92
115
  }
93
116
 
94
- export { isSupportedMathFunction, simplifyCall };
117
+ export {
118
+ isSupportedMathFunction,
119
+ simplifyCall,
120
+ hasPotentialMathFunction,
121
+ QUICK_MATH_TEST,
122
+ };
@@ -1,5 +1,6 @@
1
- import { num, dim, call } from '../node.js';
2
- import { foldConstArgs } from './fold.js';
1
+ import { call } from '../node.js';
2
+ import { foldConstArgs, foldResult } from './fold.js';
3
+ import { simplifyMinMax } from './min-max.js';
3
4
 
4
5
  /** @typedef {import('../node.js').Node} Node */
5
6
 
@@ -9,16 +10,37 @@ import { foldConstArgs } from './fold.js';
9
10
  */
10
11
  function simplifyClamp(args) {
11
12
  if (args.length === 3) {
13
+ const minNone = isNone(args[0]);
14
+ const maxNone = isNone(args[2]);
15
+
16
+ if (minNone && maxNone) {
17
+ return args[1];
18
+ }
19
+ if (minNone) {
20
+ return simplifyMinMax('min', [args[1], args[2]]);
21
+ }
22
+ if (maxNone) {
23
+ return simplifyMinMax('max', [args[0], args[1]]);
24
+ }
25
+
12
26
  const fold = foldConstArgs(args);
13
27
  if (fold !== null) {
14
28
  const [lo, v, hi] = /** @type {[number, number, number]} */ (fold.values);
15
29
  // Spec §10.8: clamp(MIN, VAL, MAX) = max(MIN, min(VAL, MAX)). The
16
30
  // outer max(MIN, …) means MIN wins when MIN > MAX — not MAX.
17
31
  const clamped = Math.max(lo, Math.min(v, hi));
18
- return fold.unit === '' ? num(clamped) : dim(clamped, fold.unit);
32
+ return foldResult(fold, clamped);
19
33
  }
20
34
  }
21
35
  return call('clamp', args);
22
36
  }
23
37
 
38
+ /**
39
+ * @param {Node} node
40
+ * @return {boolean}
41
+ */
42
+ function isNone(node) {
43
+ return node.type === 'Ident' && node.name.toLowerCase() === 'none';
44
+ }
45
+
24
46
  export { simplifyClamp };
@@ -1,8 +1,21 @@
1
+ import { num, dim } from '../node.js';
1
2
  import { baseOf, convert } from '../convertUnits.js';
2
3
 
3
4
  /** @typedef {import('../node.js').Node} Node */
5
+ /** @typedef {import('../node.js').Num} Num */
6
+ /** @typedef {import('../node.js').Dim} Dim */
4
7
  /** @typedef {import('../convertUnits.js').BaseType} BaseType */
5
8
 
9
+ /**
10
+ * Construct a Num or Dim node from a folded result.
11
+ * @param {{ unit: string }} fold
12
+ * @param {number} value
13
+ * @return {Num | Dim}
14
+ */
15
+ function foldResult(fold, value) {
16
+ return fold.unit === '' ? num(value) : dim(value, fold.unit);
17
+ }
18
+
6
19
  /**
7
20
  * @param {Node[]} args
8
21
  * @return {{ values: number[], unit: string } | null}
@@ -62,4 +75,4 @@ function foldDimArgs(args, unit, base) {
62
75
  return { values, unit };
63
76
  }
64
77
 
65
- export { foldConstArgs };
78
+ export { foldConstArgs, foldResult };
@@ -1,7 +1,7 @@
1
1
  // §10.5 — hypot. Empty args return null from foldConstArgs naturally.
2
2
 
3
- import { num, dim, call } from '../node.js';
4
- import { foldConstArgs } from './fold.js';
3
+ import { call } from '../node.js';
4
+ import { foldConstArgs, foldResult } from './fold.js';
5
5
 
6
6
  /** @typedef {import('../node.js').Node} Node */
7
7
 
@@ -16,7 +16,7 @@ function simplifyHypot(args) {
16
16
  }
17
17
  const sumSq = fold.values.reduce((acc, v) => acc + v * v, 0);
18
18
  const result = Math.sqrt(sumSq);
19
- return fold.unit === '' ? num(result) : dim(result, fold.unit);
19
+ return foldResult(fold, result);
20
20
  }
21
21
 
22
22
  export { simplifyHypot };
@@ -1,5 +1,5 @@
1
- import { num, dim, call } from '../node.js';
2
- import { foldConstArgs } from './fold.js';
1
+ import { call } from '../node.js';
2
+ import { foldConstArgs, foldResult } from './fold.js';
3
3
 
4
4
  /** @typedef {import('../node.js').Node} Node */
5
5
 
@@ -13,7 +13,7 @@ function simplifyMinMax(name, args) {
13
13
  if (fold !== null) {
14
14
  const fn = name.toLowerCase() === 'min' ? Math.min : Math.max;
15
15
  const value = fn(...fold.values);
16
- return fold.unit === '' ? num(value) : dim(value, fold.unit);
16
+ return foldResult(fold, value);
17
17
  }
18
18
  return call(name, args);
19
19
  }
@@ -1,5 +1,5 @@
1
- import { num, dim, call } from '../node.js';
2
- import { foldConstArgs } from './fold.js';
1
+ import { num, call } from '../node.js';
2
+ import { foldConstArgs, foldResult } from './fold.js';
3
3
 
4
4
  /** @typedef {import('../node.js').Node} Node */
5
5
 
@@ -23,7 +23,7 @@ function simplifyModRem(name, args) {
23
23
  if (Number.isNaN(result)) {
24
24
  return num(Number.NaN);
25
25
  }
26
- return fold.unit === '' ? num(result) : dim(result, fold.unit);
26
+ return foldResult(fold, result);
27
27
  }
28
28
 
29
29
  /**
@@ -1,5 +1,5 @@
1
- import { num, dim, ident, call } from '../node.js';
2
- import { foldConstArgs } from './fold.js';
1
+ import { num, ident, call } from '../node.js';
2
+ import { foldConstArgs, foldResult } from './fold.js';
3
3
 
4
4
  /** @typedef {import('../node.js').Node} Node */
5
5
 
@@ -58,14 +58,14 @@ function simplifyRound(args) {
58
58
  } else {
59
59
  result = a < 0 || Object.is(a, -0) ? -0 : 0;
60
60
  }
61
- return fold.unit === '' ? num(result) : dim(result, fold.unit);
61
+ return foldResult(fold, result);
62
62
  }
63
63
 
64
64
  const result = applyRound(strategy, a, b);
65
65
  if (Number.isNaN(result)) {
66
66
  return num(Number.NaN);
67
67
  }
68
- return fold.unit === '' ? num(result) : dim(result, fold.unit);
68
+ return foldResult(fold, result);
69
69
  }
70
70
 
71
71
  /**
@@ -23,49 +23,74 @@ const NUMERIC_RAW = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?/;
23
23
  * @return {Token[]}
24
24
  */
25
25
  function tokenize(input) {
26
- /** @type {Token[]} */
27
- const tokens = [];
28
- let ws = true;
26
+ return tokenizeTokens(tokenizeCss({ css: input }), input.length);
27
+ }
29
28
 
30
- // CSS absorbs leading signs (`-5px` is one token); the parser expects
31
- // punct sign + unsigned numeric, so split them back out.
32
- /**
33
- * @param {string} raw
34
- * @param {string | undefined} unit
35
- * @param {number} pos
36
- * @return {void}
37
- */
38
- function pushNumeric(raw, unit, pos) {
39
- let value = /** @type {RegExpExecArray} */ (NUMERIC_RAW.exec(raw))[0];
40
- const sign = value[0];
41
- if (sign === '+' || sign === '-') {
42
- tokens.push({ type: 'punct', value: sign, pos, ws });
43
- value = value.slice(1);
44
- pos += 1;
45
- ws = false;
46
- }
47
- if (unit === undefined) {
48
- tokens.push({ type: 'number', value, pos, ws });
49
- } else {
50
- tokens.push({ type: 'dimension', value, unit, pos, ws });
51
- }
29
+ /**
30
+ * CSS absorbs leading signs (`-5px` is one token); the parser expects
31
+ * punct sign + unsigned numeric, so split them back out.
32
+ * @param {Token[]} tokens
33
+ * @param {string} raw
34
+ * @param {string | undefined} unit
35
+ * @param {number} pos
36
+ * @param {boolean} ws
37
+ * @return {void}
38
+ */
39
+ function pushNumeric(tokens, raw, unit, pos, ws) {
40
+ let value = /** @type {RegExpExecArray} */ (NUMERIC_RAW.exec(raw))[0];
41
+ const sign = value[0];
42
+ if (sign === '+' || sign === '-') {
43
+ tokens.push({ type: 'punct', value: sign, pos, ws });
44
+ value = value.slice(1);
45
+ pos += 1;
52
46
  ws = false;
53
47
  }
48
+ if (unit === undefined) {
49
+ tokens.push({ type: 'number', value, pos, ws });
50
+ } else {
51
+ tokens.push({ type: 'dimension', value, unit, pos, ws });
52
+ }
53
+ }
54
54
 
55
- for (const t of tokenizeCss({ css: input })) {
55
+ /**
56
+ * Convert a slice of an existing CSS token stream into the token subset used
57
+ * by the calculation parser. Token positions remain relative to the original
58
+ * source text, which keeps parse errors useful to adapter callers.
59
+ *
60
+ * @param {import('@csstools/css-tokenizer').CSSToken[]} cssTokens
61
+ * @param {number} eofPosition
62
+ * @param {number} [start]
63
+ * @param {number} [end]
64
+ * @return {Token[]}
65
+ */
66
+ function tokenizeTokens(
67
+ cssTokens,
68
+ eofPosition,
69
+ start = 0,
70
+ end = cssTokens.length
71
+ ) {
72
+ /** @type {Token[]} */
73
+ const tokens = [];
74
+ let ws = true;
75
+
76
+ for (let i = start; i < end; i++) {
77
+ const t = cssTokens[i];
56
78
  switch (t[0]) {
57
79
  case CssType.Whitespace:
58
80
  case CssType.Comment:
59
81
  ws = true;
60
82
  continue;
61
83
  case CssType.Number:
62
- pushNumeric(t[1], undefined, t[2]);
84
+ pushNumeric(tokens, t[1], undefined, t[2], ws);
85
+ ws = false;
63
86
  continue;
64
87
  case CssType.Dimension:
65
- pushNumeric(t[1], t[4].unit, t[2]);
88
+ pushNumeric(tokens, t[1], t[4].unit, t[2], ws);
89
+ ws = false;
66
90
  continue;
67
91
  case CssType.Percentage:
68
- pushNumeric(t[1], '%', t[2]);
92
+ pushNumeric(tokens, t[1], '%', t[2], ws);
93
+ ws = false;
69
94
  continue;
70
95
  case CssType.Ident:
71
96
  tokens.push({ type: 'ident', value: t[4].value, pos: t[2], ws });
@@ -97,8 +122,7 @@ function tokenize(input) {
97
122
  tokens.push({ type: 'punct', value: t[4].value, pos: t[2], ws });
98
123
  break;
99
124
  case CssType.EOF:
100
- tokens.push({ type: 'eof', value: '', pos: input.length, ws });
101
- break;
125
+ continue;
102
126
  default:
103
127
  throw new Error(
104
128
  `Unexpected character "${t[1][0] ?? ''}" at position ${t[2]}`
@@ -107,7 +131,9 @@ function tokenize(input) {
107
131
  ws = false;
108
132
  }
109
133
 
134
+ tokens.push({ type: 'eof', value: '', pos: eofPosition, ws });
135
+
110
136
  return tokens;
111
137
  }
112
138
 
113
- export { tokenize };
139
+ export { tokenize, tokenizeTokens };
package/src/reduce.js ADDED
@@ -0,0 +1,165 @@
1
+ // CSS component-value reducer. This module deliberately has no PostCSS
2
+ // dependency so it can also be used for individual declaration values,
3
+ // at-rule parameters, or selector text.
4
+ import {
5
+ tokenize as cssTokenize,
6
+ TokenType as CssType,
7
+ } from '@csstools/css-tokenizer';
8
+ import { tokenizeTokens } from './lib/tokenizer.js';
9
+ import { parse } from './lib/parser.js';
10
+ import { simplify } from './lib/simplify.js';
11
+ import {
12
+ isSupportedMathFunction,
13
+ hasPotentialMathFunction,
14
+ QUICK_MATH_TEST,
15
+ } from './lib/simplify/call.js';
16
+ import { serialize } from './lib/serialize.js';
17
+
18
+ const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;
19
+
20
+ const BLOCK_CLOSE = new Map([
21
+ [CssType.OpenParen, CssType.CloseParen],
22
+ [CssType.OpenSquare, CssType.CloseSquare],
23
+ [CssType.OpenCurly, CssType.CloseCurly],
24
+ ]);
25
+
26
+ /**
27
+ * @typedef {object} ReduceCalcOptions
28
+ * @property {number | false} [precision]
29
+ * @property {boolean} [warnWhenCannotResolve]
30
+ * @property {(error: Error, input: string) => void} [onParseError] Invoked when parse/simplify throws.
31
+ * @property {(message: string) => void} [onWarn] Invoked when `warnWhenCannotResolve` is set and an expression cannot be reduced to a single value.
32
+ */
33
+
34
+ /** @typedef {Required<Omit<ReduceCalcOptions, 'onParseError' | 'onWarn'>> & Pick<ReduceCalcOptions, 'onParseError' | 'onWarn'>} ResolvedReduceCalcOptions */
35
+
36
+ /**
37
+ * Fields threaded unchanged through the token-range walk.
38
+ * `value` is the original full property text, used only for the
39
+ * warnWhenCannotResolve message.
40
+ *
41
+ * @typedef {object} TransformContext
42
+ * @property {ResolvedReduceCalcOptions} options
43
+ * @property {string} value
44
+ * @property {import('@csstools/css-tokenizer').CSSToken[]} tokens
45
+ * @property {Replacement[]} replacements
46
+ */
47
+
48
+ /**
49
+ * @typedef {object} Replacement
50
+ * @property {number} start
51
+ * @property {number} end
52
+ * @property {import('./lib/node.js').Node} node
53
+ * @property {string} calcName
54
+ * @property {string} matchedName
55
+ */
56
+
57
+ /**
58
+ * Walk one component-value level. Unsupported functions and simple blocks are
59
+ * traversed, while a supported function is treated as one opaque calculation
60
+ * even when parsing it fails. A missing closer consumes through EOF, matching
61
+ * CSS component-value parsing's error recovery.
62
+ *
63
+ * @param {number} start
64
+ * @param {import('@csstools/css-tokenizer').TokenType | undefined} expectedClose
65
+ * @param {TransformContext} ctx
66
+ * @param {boolean} transform
67
+ * @return {number} Index of the matching closer, or the EOF token.
68
+ */
69
+ function walkTokens(start, expectedClose, ctx, transform) {
70
+ for (let i = start; i < ctx.tokens.length; i++) {
71
+ const token = ctx.tokens[i];
72
+ if (token[0] === CssType.EOF || token[0] === expectedClose) return i;
73
+
74
+ const blockClose = BLOCK_CLOSE.get(token[0]);
75
+ if (blockClose) {
76
+ i = walkTokens(i + 1, blockClose, ctx, transform);
77
+ continue;
78
+ }
79
+
80
+ if (token[0] !== CssType.Function) continue;
81
+
82
+ const name = token[4].value;
83
+ const isCalc = MATCH_CALC.test(name);
84
+ const isMath = !isCalc && isSupportedMathFunction(name);
85
+ if (!transform || (!isCalc && !isMath)) {
86
+ i = walkTokens(i + 1, CssType.CloseParen, ctx, transform);
87
+ continue;
88
+ }
89
+
90
+ // Locate the complete outer function without transforming its children.
91
+ const close = walkTokens(i + 1, CssType.CloseParen, ctx, false);
92
+ const closed = ctx.tokens[close][0] === CssType.CloseParen;
93
+ const end = closed ? ctx.tokens[close][3] + 1 : ctx.value.length;
94
+ const sliceStart = isCalc ? i + 1 : i;
95
+ const sliceEnd = closed ? close + (isCalc ? 0 : 1) : close;
96
+ const inputStart = isCalc ? token[3] + 1 : token[2];
97
+ const inputEnd = closed && isCalc ? ctx.tokens[close][2] : end;
98
+ const contents = ctx.value.slice(inputStart, inputEnd);
99
+ try {
100
+ const node = simplify(
101
+ parse(tokenizeTokens(ctx.tokens, end, sliceStart, sliceEnd))
102
+ );
103
+ ctx.replacements.push({
104
+ start: token[2],
105
+ end,
106
+ node,
107
+ calcName: isCalc ? name : 'calc',
108
+ matchedName: name,
109
+ });
110
+ } catch (error) {
111
+ const err = error instanceof Error ? error : new Error('Error');
112
+ ctx.options.onParseError?.(err, contents);
113
+ }
114
+ i = close;
115
+ }
116
+
117
+ return ctx.tokens.length - 1;
118
+ }
119
+
120
+ /**
121
+ * Simplify every supported CSS math function in a component-value string.
122
+ * Text outside those functions is preserved byte-for-byte.
123
+ *
124
+ * @param {string} value
125
+ * @param {ReduceCalcOptions} [opts]
126
+ * @return {string}
127
+ */
128
+ function reduceCalc(value, opts) {
129
+ if (!hasPotentialMathFunction(value)) {
130
+ return value;
131
+ }
132
+
133
+ /** @type {ResolvedReduceCalcOptions} */
134
+ const options = { precision: 5, warnWhenCannotResolve: false, ...opts };
135
+ const tokens = cssTokenize({ css: value });
136
+ /** @type {Replacement[]} */
137
+ const replacements = [];
138
+ walkTokens(0, undefined, { options, value, tokens, replacements }, true);
139
+
140
+ if (replacements.length === 0) {
141
+ return value;
142
+ }
143
+
144
+ let output = '';
145
+ let lastIndex = 0;
146
+ for (const replacement of replacements) {
147
+ const text = serialize(replacement.node, {
148
+ precision: options.precision,
149
+ calcName: replacement.calcName,
150
+ });
151
+ if (
152
+ options.warnWhenCannotResolve &&
153
+ text.startsWith(`${replacement.matchedName}(`)
154
+ ) {
155
+ options.onWarn?.('Could not reduce expression: ' + value);
156
+ }
157
+ output += value.slice(lastIndex, replacement.start) + text;
158
+ lastIndex = replacement.end;
159
+ }
160
+ output += value.slice(lastIndex);
161
+ return output;
162
+ }
163
+
164
+ export { QUICK_MATH_TEST, hasPotentialMathFunction };
165
+ export default reduceCalc;
package/types/index.d.ts CHANGED
@@ -9,12 +9,6 @@ export type PostCssCalcOptions = {
9
9
  onParseError?: (error: Error, input: string) => void;
10
10
  };
11
11
  export type ResolvedOptions = Required<Omit<PostCssCalcOptions, 'onParseError'>> & Pick<PostCssCalcOptions, 'onParseError'>;
12
- export type TransformContext = {
13
- options: ResolvedOptions;
14
- result: import('postcss').Result;
15
- item: import('postcss').ChildNode;
16
- value: string;
17
- };
18
12
  /**
19
13
  * @param {PostCssCalcOptions} [opts]
20
14
  * @return {import('postcss').Plugin}
@@ -21,6 +21,22 @@ declare class Parser {
21
21
  * @return {Token}
22
22
  */
23
23
  expect(type: TokenType, value?: string): Token;
24
+ /**
25
+ * @param {string} value
26
+ * @param {string} [value2]
27
+ * @return {boolean}
28
+ */
29
+ isPunct(value: string, value2?: string): boolean;
30
+ /**
31
+ * @param {string} value
32
+ * @return {boolean}
33
+ */
34
+ matchPunct(value: string): boolean;
35
+ /**
36
+ * @param {string} value
37
+ * @return {Token}
38
+ */
39
+ expectPunct(value: string): Token;
24
40
  /**
25
41
  * @param {number} [minBp]
26
42
  * @return {Node}
@@ -1,6 +1,16 @@
1
1
  export type Node = import('../node.js').Node;
2
2
  export type SimplifyFn = import('../simplify.js').SimplifyFn;
3
3
  export type MathSimplifier = (name: string, args: Node[]) => Node;
4
+ declare const QUICK_MATH_TEST: RegExp;
5
+ /**
6
+ * Fast check to determine whether a CSS component value could contain
7
+ * a supported calculation or math function call (or an escape sequence
8
+ * that could decode to one).
9
+ *
10
+ * @param {string} value
11
+ * @return {boolean}
12
+ */
13
+ declare function hasPotentialMathFunction(value: string): boolean;
4
14
  /**
5
15
  * Whether a bare CSS math function has an implemented simplifier.
6
16
  *
@@ -16,4 +26,4 @@ declare function isSupportedMathFunction(name: string): boolean;
16
26
  declare function simplifyCall(node: Extract<Node, {
17
27
  type: 'Call';
18
28
  }>, simplify: SimplifyFn): Node;
19
- export { isSupportedMathFunction, simplifyCall };
29
+ export { isSupportedMathFunction, simplifyCall, hasPotentialMathFunction, QUICK_MATH_TEST, };
@@ -1,7 +1,20 @@
1
1
  export type Node = import('../node.js').Node;
2
+ export type Num = import('../node.js').Num;
3
+ export type Dim = import('../node.js').Dim;
2
4
  export type BaseType = import('../convertUnits.js').BaseType;
3
5
  /** @typedef {import('../node.js').Node} Node */
6
+ /** @typedef {import('../node.js').Num} Num */
7
+ /** @typedef {import('../node.js').Dim} Dim */
4
8
  /** @typedef {import('../convertUnits.js').BaseType} BaseType */
9
+ /**
10
+ * Construct a Num or Dim node from a folded result.
11
+ * @param {{ unit: string }} fold
12
+ * @param {number} value
13
+ * @return {Num | Dim}
14
+ */
15
+ declare function foldResult(fold: {
16
+ unit: string;
17
+ }, value: number): Num | Dim;
5
18
  /**
6
19
  * @param {Node[]} args
7
20
  * @return {{ values: number[], unit: string } | null}
@@ -10,4 +23,4 @@ declare function foldConstArgs(args: Node[]): {
10
23
  values: number[];
11
24
  unit: string;
12
25
  } | null;
13
- export { foldConstArgs };
26
+ export { foldConstArgs, foldResult };
@@ -17,4 +17,16 @@ export type Token = {
17
17
  * @return {Token[]}
18
18
  */
19
19
  declare function tokenize(input: string): Token[];
20
- export { tokenize };
20
+ /**
21
+ * Convert a slice of an existing CSS token stream into the token subset used
22
+ * by the calculation parser. Token positions remain relative to the original
23
+ * source text, which keeps parse errors useful to adapter callers.
24
+ *
25
+ * @param {import('@csstools/css-tokenizer').CSSToken[]} cssTokens
26
+ * @param {number} eofPosition
27
+ * @param {number} [start]
28
+ * @param {number} [end]
29
+ * @return {Token[]}
30
+ */
31
+ declare function tokenizeTokens(cssTokens: import('@csstools/css-tokenizer').CSSToken[], eofPosition: number, start?: number, end?: number): Token[];
32
+ export { tokenize, tokenizeTokens };
@@ -0,0 +1,38 @@
1
+ import { hasPotentialMathFunction, QUICK_MATH_TEST } from './lib/simplify/call.js';
2
+ export type ReduceCalcOptions = {
3
+ precision?: number | false;
4
+ warnWhenCannotResolve?: boolean;
5
+ /**
6
+ * Invoked when parse/simplify throws.
7
+ */
8
+ onParseError?: (error: Error, input: string) => void;
9
+ /**
10
+ * Invoked when `warnWhenCannotResolve` is set and an expression cannot be reduced to a single value.
11
+ */
12
+ onWarn?: (message: string) => void;
13
+ };
14
+ export type ResolvedReduceCalcOptions = Required<Omit<ReduceCalcOptions, 'onParseError' | 'onWarn'>> & Pick<ReduceCalcOptions, 'onParseError' | 'onWarn'>;
15
+ export type TransformContext = {
16
+ options: ResolvedReduceCalcOptions;
17
+ value: string;
18
+ tokens: import('@csstools/css-tokenizer').CSSToken[];
19
+ replacements: Replacement[];
20
+ };
21
+ export type Replacement = {
22
+ start: number;
23
+ end: number;
24
+ node: import('./lib/node.js').Node;
25
+ calcName: string;
26
+ matchedName: string;
27
+ };
28
+ /**
29
+ * Simplify every supported CSS math function in a component-value string.
30
+ * Text outside those functions is preserved byte-for-byte.
31
+ *
32
+ * @param {string} value
33
+ * @param {ReduceCalcOptions} [opts]
34
+ * @return {string}
35
+ */
36
+ declare function reduceCalc(value: string, opts?: ReduceCalcOptions): string;
37
+ export { QUICK_MATH_TEST, hasPotentialMathFunction };
38
+ export default reduceCalc;
@@ -1,23 +0,0 @@
1
- export type BaseType =
2
- | 'length'
3
- | 'angle'
4
- | 'time'
5
- | 'frequency'
6
- | 'resolution'
7
- | 'flex'
8
- | 'percentage';
9
- /**
10
- * @param {string} unit
11
- * @return {BaseType | null}
12
- */
13
- export function baseOf(unit: string): BaseType | null;
14
- /**
15
- * Convert a value within a single conversion family. Returns null when
16
- * either unit is missing from the table (em/rem/vw need runtime context)
17
- * or when the units belong to different base types.
18
- * @param {number} value
19
- * @param {string} from
20
- * @param {string} to
21
- * @return {number | null}
22
- */
23
- export function convert(value: number, from: string, to: string): number | null;