postcss-calc 11.0.3 → 11.1.1

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,7 +52,63 @@ h1 {
52
52
 
53
53
  Checkout [tests] for more examples.
54
54
 
55
- ### Options
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`, `unwrapSingleNegativeNumber`, `warnWhenCannotResolve`, `onParseError`,
72
+ and `onWarn`:
73
+
74
+ ```js
75
+ const result = reduceCalc('calc(100% + var(--gap))', {
76
+ precision: false,
77
+ warnWhenCannotResolve: true,
78
+ onWarn: console.warn,
79
+ onParseError(error, input) {
80
+ console.error(`Invalid calculation: ${input}`, error);
81
+ },
82
+ });
83
+ ```
84
+
85
+ Unlike the PostCSS plugin, the standalone reducer does not show warnings
86
+ by default; provide `onParseError` and/or `onWarn` if you want diagnostics.
87
+
88
+ ### Standalone reducer options
89
+
90
+ #### `unwrapSingleNegativeNumber` (default: `false`)
91
+
92
+ Controls whether a finite negative result is serialized as a bare value or
93
+ wrapped in `calc()`. Keep the default when reducing declaration values; set it
94
+ to `true` when the surrounding CSS context requires a bare negative value, such
95
+ as a selector:
96
+
97
+ ```js
98
+ reduceCalc('calc(5px - 10px)');
99
+ // => 'calc(-5px)'
100
+
101
+ reduceCalc('calc(5px - 10px)', { unwrapNegativeNumbers: true });
102
+ // => '-5px'
103
+ ```
104
+
105
+ ### PostCSS plugin options
106
+
107
+ These options apply when using the PostCSS plugin:
108
+
109
+ ```js
110
+ postcss().use(calc({ precision: 10 }));
111
+ ```
56
112
 
57
113
  #### `precision` (default: `5`)
58
114
 
@@ -107,7 +163,11 @@ With `mediaQueries: true`, this becomes:
107
163
 
108
164
  #### `selectors` (default: `false`)
109
165
 
110
- Allows calc() usage as part of selectors.
166
+ Reduces `calc()` functions found in selectors. Selectors do not accept
167
+ `calc()` functions, so the plugin replaces them with their reduced values.
168
+ Finite negative results are serialized as bare values because a selector cannot
169
+ contain a `calc()` function; the plugin enables `unwrapSingleNegativeNumber` automatically
170
+ for selectors.
111
171
 
112
172
  ```js
113
173
  var out = postcss()
@@ -131,11 +191,13 @@ Callback invoked when a `calc()` body fails to parse or simplify. Matches
131
191
  [`@csstools/css-calc`][csstools-css-calc]'s shape:
132
192
 
133
193
  ```js
134
- calc({
135
- onParseError: (err, input) => {
136
- throw err; // or log, route to a different channel, etc.
137
- },
138
- });
194
+ postcss().use(
195
+ calc({
196
+ onParseError: (err, input) => {
197
+ throw err; // or log, route to a different channel, etc.
198
+ },
199
+ })
200
+ );
139
201
  ```
140
202
 
141
203
  When omitted, errors are reported via PostCSS `result.warn()` so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-calc",
3
- "version": "11.0.3",
3
+ "version": "11.1.1",
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,18 +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
55
  "@csstools/css-tokenizer": "^4.0.0"
52
56
  },
53
57
  "peerDependencies": {
54
- "postcss": "^8.5.25"
58
+ "postcss": "^8.5.28"
55
59
  },
56
60
  "scripts": {
57
61
  "lint": "oxlint . && tsc && oxfmt --check",
package/src/index.js CHANGED
@@ -1,23 +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 {
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 { isSupportedMathFunction } from './lib/simplify/call.js';
12
- import { serialize } from './lib/serialize.js';
13
-
14
- const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;
15
-
16
- const BLOCK_CLOSE = new Map([
17
- [CssType.OpenParen, CssType.CloseParen],
18
- [CssType.OpenSquare, CssType.CloseSquare],
19
- [CssType.OpenCurly, CssType.CloseCurly],
20
- ]);
1
+ // PostCSS adapter over the standalone component-value reducer.
2
+ import reduceCalc, { hasPotentialMathFunction } from './reduce.js';
21
3
 
22
4
  /**
23
5
  * @typedef {object} PostCssCalcOptions
@@ -31,144 +13,7 @@ const BLOCK_CLOSE = new Map([
31
13
  /** @typedef {Required<Omit<PostCssCalcOptions, 'onParseError'>> & Pick<PostCssCalcOptions, 'onParseError'>} ResolvedOptions */
32
14
 
33
15
  /**
34
- * Fields threaded unchanged through the token-range walk.
35
- * `value` is the original full property text, used only for the
36
- * warnWhenCannotResolve message.
37
- *
38
- * @typedef {object} TransformContext
39
- * @property {ResolvedOptions} options
40
- * @property {import('postcss').Result} result
41
- * @property {import('postcss').ChildNode} item
42
- * @property {string} value
43
- * @property {import('@csstools/css-tokenizer').CSSToken[]} tokens
44
- * @property {Replacement[]} replacements
45
- */
46
-
47
- /**
48
- * @typedef {object} Replacement
49
- * @property {number} start
50
- * @property {number} end
51
- * @property {import('./lib/node.js').Node} node
52
- * @property {string} calcName
53
- * @property {string} matchedName
54
- */
55
-
56
- /**
57
- * Walk one component-value level. Unsupported functions and simple blocks are
58
- * traversed, while a supported function is treated as one opaque calculation
59
- * even when parsing it fails. A missing closer consumes through EOF, matching
60
- * CSS component-value parsing's error recovery.
61
- *
62
- * @param {number} start
63
- * @param {import('@csstools/css-tokenizer').TokenType | undefined} expectedClose
64
- * @param {TransformContext} ctx
65
- * @param {boolean} transform
66
- * @return {number} Index of the matching closer, or the EOF token.
67
- */
68
- function walkTokens(start, expectedClose, ctx, transform) {
69
- for (let i = start; i < ctx.tokens.length; i++) {
70
- const token = ctx.tokens[i];
71
- if (token[0] === CssType.EOF || token[0] === expectedClose) {
72
- return i;
73
- }
74
-
75
- const blockClose = BLOCK_CLOSE.get(token[0]);
76
- if (blockClose) {
77
- i = walkTokens(i + 1, blockClose, ctx, transform);
78
- continue;
79
- }
80
-
81
- if (token[0] !== CssType.Function) {
82
- continue;
83
- }
84
-
85
- const name = token[4].value;
86
- const isCalc = MATCH_CALC.test(name);
87
- const isMath = !isCalc && isSupportedMathFunction(name);
88
- if (!transform || (!isCalc && !isMath)) {
89
- i = walkTokens(i + 1, CssType.CloseParen, ctx, transform);
90
- continue;
91
- }
92
-
93
- // Locate the complete outer function without transforming its children.
94
- const close = walkTokens(i + 1, CssType.CloseParen, ctx, false);
95
- const closed = ctx.tokens[close][0] === CssType.CloseParen;
96
- const end = closed ? ctx.tokens[close][3] + 1 : ctx.value.length;
97
- const sliceStart = isCalc ? i + 1 : i;
98
- const sliceEnd = closed ? close + (isCalc ? 0 : 1) : close;
99
- const inputStart = isCalc ? token[3] + 1 : token[2];
100
- const inputEnd = closed && isCalc ? ctx.tokens[close][2] : end;
101
- const contents = ctx.value.slice(inputStart, inputEnd);
102
- try {
103
- const node = simplify(
104
- parse(tokenizeTokens(ctx.tokens.slice(sliceStart, sliceEnd), end))
105
- );
106
- ctx.replacements.push({
107
- start: token[2],
108
- end,
109
- node,
110
- calcName: isCalc ? name : 'calc',
111
- matchedName: name,
112
- });
113
- } catch (error) {
114
- const err = error instanceof Error ? error : new Error('Error');
115
- if (ctx.options.onParseError) {
116
- ctx.options.onParseError(err, contents);
117
- } else {
118
- ctx.result.warn(err.message, { node: ctx.item });
119
- }
120
- }
121
- i = close;
122
- }
123
-
124
- return ctx.tokens.length - 1;
125
- }
126
-
127
- /**
128
- * @param {string} value
129
- * @param {ResolvedOptions} options
130
- * @param {import('postcss').Result} result
131
- * @param {import('postcss').ChildNode} item
132
- * @return {string}
133
- */
134
- function transformValue(value, options, result, item) {
135
- const tokens = cssTokenize({ css: value });
136
- /** @type {Replacement[]} */
137
- const replacements = [];
138
- const ctx = { options, result, item, value, tokens, replacements };
139
- walkTokens(0, undefined, ctx, true);
140
-
141
- /** @type {(Replacement & {text: string})[]} */
142
- const serialized = replacements.map((replacement) => {
143
- const text = serialize(replacement.node, {
144
- precision: options.precision,
145
- calcName: replacement.calcName,
146
- });
147
- if (
148
- options.warnWhenCannotResolve &&
149
- text.startsWith(`${replacement.matchedName}(`)
150
- ) {
151
- result.warn('Could not reduce expression: ' + value, {
152
- plugin: 'postcss-calc',
153
- node: item,
154
- });
155
- }
156
- return { ...replacement, text };
157
- });
158
-
159
- let output = value;
160
- for (let i = serialized.length - 1; i >= 0; i--) {
161
- const replacement = serialized[i];
162
- output =
163
- output.slice(0, replacement.start) +
164
- replacement.text +
165
- output.slice(replacement.end);
166
- }
167
- return output;
168
- }
169
-
170
- /**
171
- * 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
172
17
  * and updates it in place.
173
18
  * `setProp` closes over the property name and the concrete node type at
174
19
  * each call site, since `Declaration`/`AtRule`/`Rule` don't share a typed
@@ -179,10 +24,36 @@ function transformValue(value, options, result, item) {
179
24
  * @param {(target: import('postcss').ChildNode, value: string) => void} setProp
180
25
  * @param {ResolvedOptions} options
181
26
  * @param {import('postcss').Result} result
27
+ * @param {boolean} unwrapSingleNegativeNumber
182
28
  * @return {void}
183
29
  */
184
- function applyTransform(node, current, setProp, options, result) {
185
- setProp(node, transformValue(current, options, result, node));
30
+ function applyTransform(
31
+ node,
32
+ current,
33
+ setProp,
34
+ options,
35
+ result,
36
+ unwrapSingleNegativeNumber
37
+ ) {
38
+ if (!hasPotentialMathFunction(current)) {
39
+ return;
40
+ }
41
+ const transformed = reduceCalc(current, {
42
+ precision: options.precision,
43
+ warnWhenCannotResolve: options.warnWhenCannotResolve,
44
+ onParseError:
45
+ options.onParseError ??
46
+ ((error) => {
47
+ result.warn(error.message, { node });
48
+ }),
49
+ onWarn: (message) => {
50
+ result.warn(message, { plugin: 'postcss-calc', node });
51
+ },
52
+ unwrapSingleNegativeNumber,
53
+ });
54
+ if (transformed !== current) {
55
+ setProp(node, transformed);
56
+ }
186
57
  }
187
58
 
188
59
  /**
@@ -215,7 +86,8 @@ function pluginCreator(opts) {
215
86
  /** @type {import('postcss').Declaration} */ (n).value = v;
216
87
  },
217
88
  options,
218
- result
89
+ result,
90
+ false
219
91
  );
220
92
  }
221
93
  if (node.type === 'atrule' && options.mediaQueries) {
@@ -226,7 +98,8 @@ function pluginCreator(opts) {
226
98
  /** @type {import('postcss').AtRule} */ (n).params = v;
227
99
  },
228
100
  options,
229
- result
101
+ result,
102
+ false
230
103
  );
231
104
  }
232
105
  if (node.type === 'rule' && options.selectors) {
@@ -239,7 +112,8 @@ function pluginCreator(opts) {
239
112
  /** @type {import('postcss').Rule} */ (n).selector = v;
240
113
  },
241
114
  options,
242
- result
115
+ result,
116
+ true
243
117
  );
244
118
  }
245
119
  });
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