@thejaredwilcurt/csslop 0.0.39 → 0.0.40

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/package.json CHANGED
@@ -2,15 +2,15 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.39",
5
+ "version": "0.0.40",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
- "prestart": "node ./scripts/prestart.js",
8
+ "prestart": "node ./scripts/pre/start.js",
9
9
  "start": "vite",
10
10
  "preview": "vite preview",
11
- "prebuild": "node ./scripts/prebuild.js",
11
+ "prebuild": "node ./scripts/pre/build.js",
12
12
  "build": "vite build",
13
- "postbuild": "node ./scripts/postbuild.js",
13
+ "postbuild": "node ./scripts/post/build.js",
14
14
  "copy": "node ./scripts/copyTests.js",
15
15
  "test": "node ./tests/index.test.js",
16
16
  "real": "node ./tests/realworld.test.js",
@@ -28,17 +28,17 @@
28
28
  "devDependencies": {
29
29
  "@codemirror/autocomplete": "^6.20.3",
30
30
  "@codemirror/lang-css": "^6.3.1",
31
- "@codemirror/view": "^6.43.12",
31
+ "@codemirror/view": "^6.43.13",
32
32
  "@eslint/js": "^10.0.1",
33
33
  "@node-projects/css-parser": "^5.2.0",
34
34
  "@stylistic/eslint-plugin": "^5.10.0",
35
35
  "codemirror": "^6.0.2",
36
- "eslint": "^10.10.0",
36
+ "eslint": "^10.11.0",
37
37
  "eslint-config-tjw-base": "^5.0.0",
38
38
  "eslint-config-tjw-import-x": "^1.0.1",
39
39
  "eslint-config-tjw-jsdoc": "^2.0.1",
40
40
  "eslint-plugin-import-x": "^4.17.0",
41
- "eslint-plugin-jsdoc": "^64.5.2",
41
+ "eslint-plugin-jsdoc": "^64.5.4",
42
42
  "fflate": "^0.8.3",
43
43
  "globals": "^17.12.0",
44
44
  "pretty-ms": "^9.3.1",
@@ -60,11 +60,11 @@
60
60
  "devEngines": {
61
61
  "runtime": {
62
62
  "name": "node",
63
- "version": "26.9.0"
63
+ "version": "26.10.0"
64
64
  },
65
65
  "packageManager": {
66
66
  "name": "npm",
67
- "version": "12.0.2"
67
+ "version": "12.1.0"
68
68
  }
69
69
  }
70
70
  }
@@ -2,6 +2,9 @@
2
2
  * @file Custom property value whitespace and comment processing for CSS minification.
3
3
  */
4
4
 
5
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
6
+ import { formatCompactNumber } from '../value/shared.js';
7
+
5
8
  /**
6
9
  * Removes spaces after commas only inside parenthesized groups (function
7
10
  * calls like `var()`, `calc()`), leaving top-level comma spacing intact.
@@ -34,15 +37,89 @@ function removeSpacesAfterCommasInsideFunctions (value) {
34
37
  }
35
38
 
36
39
  /**
37
- * Strips leading zeros from decimal numbers in a custom property value
38
- * (e.g. `0.5` becomes `.5`, `-0.02em` becomes `-.02em`).
40
+ * The token types whose text begins with a number, and so may hold digits that
41
+ * are written out longer than they need to be.
42
+ *
43
+ * @type {Set<string>}
44
+ */
45
+ const NUMERIC_TOKEN_TYPES = new Set(['number', 'dimension', 'percentage']);
46
+
47
+ /**
48
+ * Splits a numeric token into the decimal number it states and the unit
49
+ * written on it. A number carrying a scientific exponent does not match, since
50
+ * its digits cannot be restated without reading the exponent along with them,
51
+ * and neither does a whole number, which has no zeros to shed.
52
+ *
53
+ * @type {RegExp}
54
+ */
55
+ const DECIMAL_NUMBER_TOKEN = /^([+-]?(?:\d+\.\d+|\.\d+))([a-zA-Z%]*)$/;
56
+
57
+ /**
58
+ * Restates a numeric token as the shortest way of writing the same number,
59
+ * dropping the zeros that lead the decimal point and the ones that trail the
60
+ * digits behind it (`-0.020em` becomes `-.02em`). A token that is not a plain
61
+ * decimal, or whose number is only expressible in scientific notation, is left
62
+ * exactly as it was written.
63
+ *
64
+ * @param {string} tokenText The text of a number, dimension, or percentage token.
65
+ * @return {string} The shortest way of writing that same token.
66
+ */
67
+ function shortenNumericToken (tokenText) {
68
+ const decimalNumber = tokenText.match(DECIMAL_NUMBER_TOKEN);
69
+ if (!decimalNumber) {
70
+ return tokenText;
71
+ }
72
+ const [, number, unit] = decimalNumber;
73
+ const shortened = formatCompactNumber(number);
74
+ // A number too small or too large to write out plainly comes back in
75
+ // scientific notation, which reads as a different token once a unit follows
76
+ if (shortened.includes('e')) {
77
+ return tokenText;
78
+ }
79
+ return shortened + unit;
80
+ }
81
+
82
+ /**
83
+ * Reports whether two token lists say the same thing, which is what tells a
84
+ * rewritten value apart from one whose tokens have run together. Shortening
85
+ * the numbers of `1.0.0` would leave `10`, a single number where two stood.
86
+ *
87
+ * @param {Array} tokens The tokens the value was read as.
88
+ * @param {Array} rewrittenTokens The tokens the rewritten value reads as.
89
+ * @return {boolean} Whether the rewrite left every token boundary in place.
90
+ */
91
+ function hasSameTokenBoundaries (tokens, rewrittenTokens) {
92
+ if (tokens.length !== rewrittenTokens.length) {
93
+ return false;
94
+ }
95
+ return tokens.every((token, index) => {
96
+ return token.type === rewrittenTokens[index].type;
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Writes every number in a custom property value the shortest way it can be
102
+ * written. A custom property holds an arbitrary token sequence, so the value
103
+ * is walked token by token: only the tokens that state a number are rewritten,
104
+ * leaving strings, urls, and hash tokens exactly as the author wrote them.
39
105
  *
40
106
  * @param {string} value The custom property value string.
41
- * @return {string} The value with leading zeros removed from decimals.
107
+ * @return {string} The value with its numbers shortened.
42
108
  */
43
- function stripLeadingZerosFromDecimals (value) {
44
- // Match a boundary (start, whitespace, comma, open-paren), optional sign, then leading zeros before a decimal
45
- return value.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3');
109
+ function shortenNumbers (value) {
110
+ const tokens = tokenizeCssValue(value);
111
+ const rewritten = tokens
112
+ .map((token) => {
113
+ if (!NUMERIC_TOKEN_TYPES.has(token.type)) {
114
+ return token.text;
115
+ }
116
+ return shortenNumericToken(token.text);
117
+ })
118
+ .join('');
119
+ if (!hasSameTokenBoundaries(tokens, tokenizeCssValue(rewritten))) {
120
+ return value;
121
+ }
122
+ return rewritten;
46
123
  }
47
124
 
48
125
  /**
@@ -69,7 +146,7 @@ function processCustomPropertyComments (value) {
69
146
  * Collapses whitespace in a custom property value while preserving
70
147
  * token boundaries. Each whitespace sequence is reduced to a single
71
148
  * space, spaces after commas inside function calls are removed, and
72
- * leading zeros on decimal numbers are stripped.
149
+ * every number is written the shortest way it can be.
73
150
  *
74
151
  * @param {string} value The raw custom property value string.
75
152
  * @return {string} The minified custom property value.
@@ -79,8 +156,8 @@ function collapseCustomPropertyWhitespace (value) {
79
156
  let collapsed = value.replace(/\s+/g, ' ');
80
157
  // Remove spaces after commas only inside function calls (e.g. var(--bar, 1.5) → var(--bar,1.5))
81
158
  collapsed = removeSpacesAfterCommasInsideFunctions(collapsed);
82
- // Strip leading zeros from decimals (e.g. 0.5 → .5, -0.02em → -.02em)
83
- collapsed = stripLeadingZerosFromDecimals(collapsed);
159
+ // Shorten decimals (e.g. 0.5 → .5, -0.020em → -.02em)
160
+ collapsed = shortenNumbers(collapsed);
84
161
  return collapsed;
85
162
  }
86
163
  export {
@@ -1351,6 +1351,74 @@ const MERGE_AT_LATER_RULE = 'later';
1351
1351
  */
1352
1352
  const MERGE_AT_EARLIER_RULE = 'earlier';
1353
1353
 
1354
+ /**
1355
+ * Reports whether an entry of a rule's body is a declaration whose standing in
1356
+ * the cascade can be read off the property it writes. Anything else a body may
1357
+ * hold, such as a nested rule, is never shadowed by a declaration beside it.
1358
+ *
1359
+ * @param {object} entry An entry of a rule's declaration list.
1360
+ * @return {boolean} True when the entry is a property declaration.
1361
+ */
1362
+ function isPropertyDeclaration (entry) {
1363
+ return entry.type === 'declaration' && Boolean(entry.property);
1364
+ }
1365
+
1366
+ /**
1367
+ * Finds which declaration decides each leaf property once a list of
1368
+ * declarations is read as one rule's body. Within a rule, an important
1369
+ * declaration beats a non-important one, and between two of equal weight the
1370
+ * last one written wins.
1371
+ *
1372
+ * @param {Array} declarations The declarations of the combined rule, in the order they would be written.
1373
+ * @return {Map} Map of leaf property name to the declaration that decides it.
1374
+ */
1375
+ function findDecidingDeclarations (declarations) {
1376
+ const decidingDeclarations = new Map();
1377
+ const importantDeclarations = new Set();
1378
+ for (const declaration of declarations) {
1379
+ if (!isPropertyDeclaration(declaration)) {
1380
+ continue;
1381
+ }
1382
+ const isImportant = isImportantDeclaration(declaration);
1383
+ if (isImportant) {
1384
+ importantDeclarations.add(declaration);
1385
+ }
1386
+ for (const leafProperty of expandToOverridableProperties(declaration.property)) {
1387
+ const decidingDeclaration = decidingDeclarations.get(leafProperty);
1388
+ const outranksIt = (
1389
+ !decidingDeclaration ||
1390
+ isImportant ||
1391
+ !importantDeclarations.has(decidingDeclaration)
1392
+ );
1393
+ if (outranksIt) {
1394
+ decidingDeclarations.set(leafProperty, declaration);
1395
+ }
1396
+ }
1397
+ }
1398
+ return decidingDeclarations;
1399
+ }
1400
+
1401
+ /**
1402
+ * Reports whether anything a rule brings to a merge still decides a property
1403
+ * of the combined rule. A declaration that loses every property it writes to
1404
+ * another declaration of the same rule is dropped from the merged rule, so
1405
+ * where the merged rule sits cannot change how it reads.
1406
+ *
1407
+ * @param {object} rule The rule contributing the entries.
1408
+ * @param {Map} decidingDeclarations The deciding declaration of each leaf property of the merged rule.
1409
+ * @return {boolean} True when the rule still decides something after the merge.
1410
+ */
1411
+ function decidesAnythingAfterMerge (rule, decidingDeclarations) {
1412
+ return (rule.declarations || []).some((entry) => {
1413
+ if (!isPropertyDeclaration(entry)) {
1414
+ return entry.type !== 'whitespace' && entry.type !== 'comment';
1415
+ }
1416
+ return [...expandToOverridableProperties(entry.property)].some((leafProperty) => {
1417
+ return decidingDeclarations.get(leafProperty) === entry;
1418
+ });
1419
+ });
1420
+ }
1421
+
1354
1422
  /**
1355
1423
  * Decides where two rules with the same selector can be combined. Merging them
1356
1424
  * always makes one set of declarations cross whatever separates the two rules,
@@ -1358,6 +1426,12 @@ const MERGE_AT_EARLIER_RULE = 'earlier';
1358
1426
  * the merged rule goes wherever the set that moved keeps its old standing.
1359
1427
  * Nothing separates adjacent rules, so those always merge.
1360
1428
  *
1429
+ * The declarations one rule loses to the other are the exception: they are
1430
+ * dropped from the merged rule, so the merged rule belongs wherever the
1431
+ * declarations that outlive the merge already were, and nothing has to cross
1432
+ * anything at all. That is what lets `.a{color:red!important}` swallow a later
1433
+ * `.a{color:tan}` across a rule that also writes `color`.
1434
+ *
1361
1435
  * @param {object} overrideIndex The index of what each position writes to.
1362
1436
  * @param {object} earlierRule The first of the two rules with this selector.
1363
1437
  * @param {object} laterRule The second of the two rules with this selector.
@@ -1367,7 +1441,20 @@ const MERGE_AT_EARLIER_RULE = 'earlier';
1367
1441
  */
1368
1442
  function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPosition, laterPosition) {
1369
1443
  const rulesAreAdjacent = earlierPosition === laterPosition - 1;
1370
- if (rulesAreAdjacent || !overrideIndex.blocksRelocation(earlierRule, earlierPosition)) {
1444
+ if (rulesAreAdjacent) {
1445
+ return MERGE_AT_LATER_RULE;
1446
+ }
1447
+ const decidingDeclarations = findDecidingDeclarations([
1448
+ ...(earlierRule.declarations || []),
1449
+ ...(laterRule.declarations || [])
1450
+ ]);
1451
+ if (!decidesAnythingAfterMerge(laterRule, decidingDeclarations)) {
1452
+ return MERGE_AT_EARLIER_RULE;
1453
+ }
1454
+ if (!decidesAnythingAfterMerge(earlierRule, decidingDeclarations)) {
1455
+ return MERGE_AT_LATER_RULE;
1456
+ }
1457
+ if (!overrideIndex.blocksRelocation(earlierRule, earlierPosition)) {
1371
1458
  return MERGE_AT_LATER_RULE;
1372
1459
  }
1373
1460
  if (!overrideIndex.blocksRelocation(laterRule, earlierPosition)) {
package/src/value/math.js CHANGED
@@ -8,7 +8,8 @@ import { findMatchingParenthesis } from '../parser/source-search.js';
8
8
 
9
9
  import {
10
10
  convertAbsoluteLengthToPx,
11
- formatDimension,
11
+ formatResolvedDimension,
12
+ formatResolvedNumber,
12
13
  roundCompactNumber
13
14
  } from './shared.js';
14
15
 
@@ -245,6 +246,158 @@ function convertReciprocalMultiplicationToDivision (expression) {
245
246
  });
246
247
  }
247
248
 
249
+ /**
250
+ * Matches a whole expression that divides a percentage by a plain number,
251
+ * capturing the percentage and the divisor it is shared out between.
252
+ *
253
+ * @type {RegExp}
254
+ */
255
+ const PERCENTAGE_DIVISION = /^([+-]?(?:\d+|\d*\.\d+))%\s*\/\s*([+-]?(?:\d+|\d*\.\d+))$/;
256
+
257
+ /**
258
+ * Picks between what a calculation resolved to and the calculation itself,
259
+ * keeping whichever takes fewer characters to write. A tie goes to the
260
+ * resolved value, which states the same thing without the arithmetic.
261
+ *
262
+ * @param {string} resolved The value the calculation resolves to.
263
+ * @param {string} calculation The calculation, written as compactly as it can be.
264
+ * @return {string} The shorter of the two.
265
+ */
266
+ function shorterOfResolutionAndCalculation (resolved, calculation) {
267
+ if (resolved.length <= calculation.length) {
268
+ return resolved;
269
+ }
270
+ return calculation;
271
+ }
272
+
273
+ /**
274
+ * Resolves a percentage divided by a number into the single percentage it
275
+ * equals, which is how `calc(100%/16)` comes to be written as `6.25%`. The
276
+ * division stays as it was written when the result is no shorter, and when
277
+ * the divisor is zero, since that resolves to nothing a stylesheet can state.
278
+ *
279
+ * @param {string} expression The contents of a `calc()`, with its whitespace collapsed.
280
+ * @return {string|null} The shorter of the resolved percentage and the division, or null when the expression is not one.
281
+ */
282
+ function resolvePercentageDivision (expression) {
283
+ const division = expression.match(PERCENTAGE_DIVISION);
284
+ if (!division) {
285
+ return null;
286
+ }
287
+ const [, percentage, divisor] = division;
288
+ const calculation = 'calc(' + percentage + '%/' + divisor + ')';
289
+ if (Number(divisor) === 0) {
290
+ return calculation;
291
+ }
292
+ const resolved = formatResolvedNumber(Number(percentage) / Number(divisor)) + '%';
293
+ return shorterOfResolutionAndCalculation(resolved, calculation);
294
+ }
295
+
296
+ /**
297
+ * The properties whose values every CSS specification defines as
298
+ * non-negative, so that a math function declared on one of them has its
299
+ * result clamped rather than read as written.
300
+ *
301
+ * @type {Set<string>}
302
+ */
303
+ const NON_NEGATIVE_PROPERTIES = new Set([
304
+ 'animation-duration',
305
+ 'animation-iteration-count',
306
+ 'background-size',
307
+ 'block-size',
308
+ 'border-block-end-width',
309
+ 'border-block-start-width',
310
+ 'border-block-width',
311
+ 'border-bottom-left-radius',
312
+ 'border-bottom-right-radius',
313
+ 'border-bottom-width',
314
+ 'border-end-end-radius',
315
+ 'border-end-start-radius',
316
+ 'border-image-outset',
317
+ 'border-image-width',
318
+ 'border-inline-end-width',
319
+ 'border-inline-start-width',
320
+ 'border-inline-width',
321
+ 'border-left-width',
322
+ 'border-radius',
323
+ 'border-right-width',
324
+ 'border-start-end-radius',
325
+ 'border-start-start-radius',
326
+ 'border-top-left-radius',
327
+ 'border-top-right-radius',
328
+ 'border-top-width',
329
+ 'border-width',
330
+ 'column-count',
331
+ 'column-gap',
332
+ 'column-rule-width',
333
+ 'column-width',
334
+ 'flex-basis',
335
+ 'flex-grow',
336
+ 'flex-shrink',
337
+ 'font-size',
338
+ 'gap',
339
+ 'height',
340
+ 'inline-size',
341
+ 'line-height',
342
+ 'mask-size',
343
+ 'max-block-size',
344
+ 'max-height',
345
+ 'max-inline-size',
346
+ 'max-width',
347
+ 'min-block-size',
348
+ 'min-height',
349
+ 'min-inline-size',
350
+ 'min-width',
351
+ 'outline-width',
352
+ 'padding',
353
+ 'padding-block',
354
+ 'padding-block-end',
355
+ 'padding-block-start',
356
+ 'padding-bottom',
357
+ 'padding-inline',
358
+ 'padding-inline-end',
359
+ 'padding-inline-start',
360
+ 'padding-left',
361
+ 'padding-right',
362
+ 'padding-top',
363
+ 'perspective',
364
+ 'row-gap',
365
+ 'shape-margin',
366
+ 'stroke-width',
367
+ 'tab-size',
368
+ 'transition-duration',
369
+ 'width'
370
+ ]);
371
+
372
+ /**
373
+ * Matches a value written as a single negative number, with or without the
374
+ * unit or percent sign that follows its digits.
375
+ *
376
+ * @type {RegExp}
377
+ */
378
+ const NEGATIVE_NUMBER_VALUE = /^-(?:\d+|\d*\.\d+)(?:[a-z]+|%)?$/i;
379
+
380
+ /**
381
+ * Clamps what a math function resolved to into the range its property
382
+ * accepts. A negative length written out directly makes the declaration
383
+ * invalid and the CSS engine throws it away, but a math function is allowed
384
+ * to resolve to one: its result is clamped to the nearest value the property
385
+ * does take, which on a property that only accepts non-negative values is
386
+ * zero, as the range checking section of the CSS Values specification
387
+ * describes: https://drafts.csswg.org/css-values/#calc-range.
388
+ *
389
+ * @param {string} resolvedValue The value the math function resolved to.
390
+ * @param {string} property The property the math function is declared on.
391
+ * @return {string} The resolved value, held inside the property's range.
392
+ */
393
+ function clampResolvedValueToPropertyRange (resolvedValue, property) {
394
+ const acceptsNegativeValues = !NON_NEGATIVE_PROPERTIES.has(String(property).toLowerCase());
395
+ if (acceptsNegativeValues || !NEGATIVE_NUMBER_VALUE.test(resolvedValue)) {
396
+ return resolvedValue;
397
+ }
398
+ return '0';
399
+ }
400
+
248
401
  /**
249
402
  * The units a folded calc() expression leads with, in the order they are
250
403
  * written. Every other unit follows them alphabetically.
@@ -276,15 +429,15 @@ function tryFoldCalcExpression (expression) {
276
429
  expr = expr.replace(/\(([^()]+)\)/g, '$1');
277
430
  // Fold: <number> * <number><unit> → computed result in same unit
278
431
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))\s*\*\s*(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q|%)/gi, (match, a, b, unit) => {
279
- return formatDimension(parseFloat(a) * parseFloat(b), unit);
432
+ return formatResolvedDimension(parseFloat(a) * parseFloat(b), unit);
280
433
  });
281
434
  // Fold: <number><unit> * <number> → computed result in same unit
282
435
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q|%)\s*\*\s*(-?(?:\d*\.\d+|\d+))/gi, (match, a, unit, b) => {
283
- return formatDimension(parseFloat(a) * parseFloat(b), unit);
436
+ return formatResolvedDimension(parseFloat(a) * parseFloat(b), unit);
284
437
  });
285
438
  // Fold: <number><unit> / <number> → computed result in same unit
286
439
  expr = expr.replace(/(-?(?:\d*\.\d+|\d+))(px|pt|pc|in|cm|mm|q)\s*\/\s*(-?(?:\d*\.\d+|\d+))/gi, (match, value, unit, divisor) => {
287
- return formatDimension(parseFloat(value) / parseFloat(divisor), unit);
440
+ return formatResolvedDimension(parseFloat(value) / parseFloat(divisor), unit);
288
441
  });
289
442
  // Collapse zero-with-unit terms (e.g. 0px, 0%) to plain 0 or remove them
290
443
  expr = expr.replace(/(^|[+-])\s*0(?:px|pt|pc|in|cm|mm|q|%)\b/g, (match, sign) => {
@@ -361,26 +514,100 @@ function tryFoldCalcExpression (expression) {
361
514
 
362
515
  if (outputTerms.length === 1) {
363
516
  const { unit, value } = outputTerms[0];
364
- if (unit) {
365
- return roundCompactNumber(value) + unit;
366
- }
367
- return roundCompactNumber(value);
517
+ return formatResolvedDimension(value, unit);
368
518
  }
369
519
 
370
520
  const [first, ...rest] = outputTerms;
371
- let result = roundCompactNumber(first.value) + first.unit;
521
+ let result = formatResolvedDimension(first.value, first.unit);
372
522
  for (const term of rest) {
373
523
  const sign = term.value < 0 ? '-' : '+';
374
- result += ' ' + sign + ' ' + roundCompactNumber(Math.abs(term.value)) + term.unit;
524
+ result += ' ' + sign + ' ' + formatResolvedDimension(Math.abs(term.value), term.unit);
375
525
  }
376
526
  return 'calc(' + result + ')';
377
527
  }
378
528
 
529
+ /**
530
+ * Matches a whole expression that scales a percentage by a plain number,
531
+ * written either way around. Both operands are already as short as they can
532
+ * be, so the expression is only ever squeezed, never resolved.
533
+ *
534
+ * @type {RegExp}
535
+ */
536
+ const PERCENTAGE_MULTIPLICATION = /^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/;
537
+
538
+ /**
539
+ * Matches a value that is a single percentage, which is all a calculation
540
+ * leaves behind once every term of it has resolved into one.
541
+ *
542
+ * @type {RegExp}
543
+ */
544
+ const RESOLVED_PERCENTAGE = /^-?(?:\d+|\d*\.\d+)%$/;
545
+
546
+ /**
547
+ * Chooses how a calculation that shares a percentage out resolves. The
548
+ * library states the result to the full precision a float carries, which is
549
+ * both longer than the calculation it replaces and finer than a stylesheet
550
+ * has any use for, so the result is rounded into the budget a resolved number
551
+ * is written within and kept only when it is the shorter of the two.
552
+ *
553
+ * @param {string} simplified The result the calculation library returned.
554
+ * @param {string} compactInner The contents of the `calc()`, with its whitespace collapsed.
555
+ * @param {string} match The whole `calc()` as it was written.
556
+ * @return {string} How the resolved calculation is written.
557
+ */
558
+ function chooseResolvedPercentage (simplified, compactInner, match) {
559
+ // A division sign written after a percentage, which is what the resolved percentage was shared out by
560
+ const dividesAPercentage = RESOLVED_PERCENTAGE.test(simplified) && /%\s*\//.test(match);
561
+ if (!dividesAPercentage) {
562
+ return simplified;
563
+ }
564
+ // Remove whitespace around division operator
565
+ const calculation = 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
566
+ const resolved = formatResolvedNumber(parseFloat(simplified)) + '%';
567
+ return shorterOfResolutionAndCalculation(resolved, calculation);
568
+ }
569
+
570
+ /**
571
+ * Resolves the contents of a `calc()` into the shortest way of writing the
572
+ * same value, which is either the value the arithmetic works out to or the
573
+ * calculation itself with the whitespace squeezed out of it.
574
+ *
575
+ * @param {string} match The whole `calc()` as it was written.
576
+ * @param {string} compactInner The contents of the `calc()`, with its whitespace collapsed.
577
+ * @return {string} The shortest way of writing what the calculation states.
578
+ */
579
+ function resolveCalcExpression (match, compactInner) {
580
+ if (PERCENTAGE_MULTIPLICATION.test(compactInner)) {
581
+ // Remove whitespace around multiplication/division operators
582
+ return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
583
+ }
584
+
585
+ const dividedPercentage = resolvePercentageDivision(compactInner);
586
+ if (dividedPercentage) {
587
+ return dividedPercentage;
588
+ }
589
+
590
+ const folded = tryFoldCalcExpression(compactInner);
591
+ if (folded) {
592
+ return folded;
593
+ }
594
+
595
+ try {
596
+ const simplified = calc(match);
597
+ if (typeof simplified !== 'string') {
598
+ return match;
599
+ }
600
+ return chooseResolvedPercentage(simplified, compactInner, match);
601
+ } catch {
602
+ return match;
603
+ }
604
+ }
605
+
379
606
  /**
380
607
  * Simplifies calc(), min(), and max() expressions within a CSS value string using the `@csstools`/css-calc library, falling back to the original value on failure.
381
608
  *
382
609
  * @param {string} value The CSS value string containing math functions to simplify.
383
- * @param {string} property The CSS property name, used for context-aware simplification.
610
+ * @param {string} property The CSS property name, whose range every resolved result is clamped into.
384
611
  * @param {string} originalValue The original unmodified value to fall back to if simplification produces an invalid result.
385
612
  * @return {string} The value with math functions simplified where possible.
386
613
  */
@@ -404,7 +631,10 @@ function normalizeMathFunctions (value, property, originalValue = '') {
404
631
  result = result.replace(/\b(min|max)\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match) => {
405
632
  try {
406
633
  const simplified = calc(match);
407
- return typeof simplified === 'string' ? simplified : match;
634
+ if (typeof simplified !== 'string') {
635
+ return match;
636
+ }
637
+ return clampResolvedValueToPropertyRange(simplified, property);
408
638
  } catch {
409
639
  return match;
410
640
  }
@@ -414,35 +644,8 @@ function normalizeMathFunctions (value, property, originalValue = '') {
414
644
  result = result.replace(/calc\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
415
645
  // Collapse whitespace inside calc expression
416
646
  const compactInner = inner.replace(/\s+/g, ' ').trim();
417
- // Preserve percent-times-number expressions (e.g. 50%*2 or 2*50%) — just strip inner spaces
418
- if (/^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/i.test(compactInner)) {
419
- // Remove whitespace around multiplication/division operators
420
- return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
421
- }
422
- // Preserve percent/number division expressions (e.g. 100%/3) — just strip inner spaces
423
- if (/^\d+(?:\.\d+)?%\s*\/\s*\d+(?:\.\d+)?$/i.test(compactInner)) {
424
- // Remove whitespace around division operator
425
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
426
- }
427
-
428
- const folded = tryFoldCalcExpression(compactInner);
429
- if (folded) {
430
- return folded;
431
- }
432
-
433
- try {
434
- const simplified = calc(match);
435
- if (typeof simplified !== 'string') {
436
- return match;
437
- }
438
- // If simplified to a bare percentage but original had division, preserve the calc form
439
- if (/^-?(?:\d+|\d*\.\d+)%$/.test(simplified) && /%\s*\//.test(match)) {
440
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
441
- }
442
- return simplified;
443
- } catch {
444
- return match;
445
- }
647
+ const resolved = resolveCalcExpression(match, compactInner);
648
+ return clampResolvedValueToPropertyRange(resolved, property);
446
649
  }));
447
650
 
448
651
  // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
@@ -456,9 +659,9 @@ function normalizeMathFunctions (value, property, originalValue = '') {
456
659
  }
457
660
  }
458
661
 
459
- // Round results with excessive decimal places (4+ digits after the decimal)
662
+ // Round a result whose decimal places run past the budget a resolved number is written within
460
663
  result = result.replace(/(-?(?:\d+|\d*\.\d+)\.\d{4,})([a-z%]+)/gi, (match, number, unit) => {
461
- return roundCompactNumber(number) + unit;
664
+ return formatResolvedNumber(number) + unit;
462
665
  });
463
666
  return result;
464
667
  }
@@ -466,18 +669,14 @@ function normalizeMathFunctions (value, property, originalValue = '') {
466
669
  /**
467
670
  * Simplifies a standalone calc() value by flattening nested calc expressions, converting absolute length units to pixels, and folding constant terms.
468
671
  *
469
- * @param {string} value The CSS value string that may be a standalone calc() expression.
672
+ * @param {string} value The CSS value string, already known to be a single `calc()`.
470
673
  * @return {string} The simplified value, or the original value if simplification is not applicable.
471
674
  */
472
- function simplifyStandaloneCalc (value) {
473
- // Check if value starts with calc( and ends with )
474
- if (!/^calc\(/i.test(value) || !value.endsWith(')')) {
475
- return value;
476
- }
675
+ function foldStandaloneCalc (value) {
477
676
  let inner = value.slice(5, -1).trim();
478
677
 
479
678
  // Preserve percent-times-number expressions, only stripping whitespace around operators
480
- if (/^(?:-?(?:\d*\.\d+|\d+)%\s*\*\s*-?(?:\d*\.\d+|\d+)|-?(?:\d*\.\d+|\d+)\s*\*\s*-?(?:\d*\.\d+|\d+)%)$/i.test(inner.replace(/\s+/g, ' ').trim())) {
679
+ if (PERCENTAGE_MULTIPLICATION.test(inner.replace(/\s+/g, ' ').trim())) {
481
680
  return 'calc(' + inner.replace(/\s*([*/])\s*/g, '$1') + ')';
482
681
  }
483
682
 
@@ -516,15 +715,31 @@ function simplifyStandaloneCalc (value) {
516
715
 
517
716
  // Collapse whitespace and check for percent-division expressions
518
717
  const compactInner = inner.replace(/\s+/g, ' ').trim();
519
- // Preserve percent/number division, just strip whitespace around the operator
520
- if (/^\d+(?:\.\d+)?%\s*\/\s*\d+(?:\.\d+)?$/i.test(compactInner)) {
521
- return 'calc(' + compactInner.replace(/\s*\/\s*/g, '/') + ')';
718
+ const dividedPercentage = resolvePercentageDivision(compactInner);
719
+ if (dividedPercentage) {
720
+ return dividedPercentage;
522
721
  }
523
722
 
524
723
  // Default: strip whitespace around multiplication/division operators
525
724
  return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
526
725
  }
527
726
 
727
+ /**
728
+ * Simplifies a value that is a single `calc()`, holding whatever it resolves
729
+ * to inside the range the property it is declared on accepts.
730
+ *
731
+ * @param {string} value The CSS value string that may be a standalone calc() expression.
732
+ * @param {string} property The CSS property the value is declared on.
733
+ * @return {string} The simplified value, or the original value if simplification is not applicable.
734
+ */
735
+ function simplifyStandaloneCalc (value, property = '') {
736
+ // Check if value starts with calc( and ends with )
737
+ if (!/^calc\(/i.test(value) || !value.endsWith(')')) {
738
+ return value;
739
+ }
740
+ return clampResolvedValueToPropertyRange(foldStandaloneCalc(value), property);
741
+ }
742
+
528
743
  export {
529
744
  normalizeMathFunctions,
530
745
  simplifyStandaloneCalc
@@ -37,6 +37,7 @@ import {
37
37
  import {
38
38
  collapseShorthandParts,
39
39
  convertAbsoluteLengthToPx,
40
+ formatResolvedNumber,
40
41
  hasSubstitutedParts,
41
42
  normalizeScaleComponent,
42
43
  parseAlphaString,
@@ -1376,7 +1377,7 @@ function computeMinifiedValue (declaration) {
1376
1377
  // Remove whitespace around * and / operators (safe outside calc context)
1377
1378
  val = val.replace(/\s*([*/])\s*/g, '$1');
1378
1379
  val = normalizeMathFunctions(val, declaration.property, declaration.value || '');
1379
- val = simplifyStandaloneCalc(val);
1380
+ val = simplifyStandaloneCalc(val, declaration.property);
1380
1381
  // Simplify calc() expressions containing zero-percent additive terms
1381
1382
  val = val.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
1382
1383
  // Collapse whitespace inside calc expression
@@ -1400,10 +1401,11 @@ function computeMinifiedValue (declaration) {
1400
1401
  }
1401
1402
  val = val.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
1402
1403
 
1403
- // If value is a standalone number with optional unit, round it compactly
1404
+ // If value is a standalone number with optional unit, round it into the
1405
+ // character budget a number is written within
1404
1406
  if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1405
1407
  const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1406
- val = roundCompactNumber(rawNumber, 4) + rawUnit;
1408
+ val = formatResolvedNumber(rawNumber) + rawUnit;
1407
1409
  }
1408
1410
 
1409
1411
  val = lowercaseHexColors(val);
@@ -47,8 +47,10 @@ function roundCompactNumber (value, precision = 3) {
47
47
  if (!Number.isFinite(number)) {
48
48
  return String(value);
49
49
  }
50
- // Strip trailing zeros and trailing decimal point from the fixed-precision string
51
- let result = number.toFixed(precision).replace(/0+$/, '').replace(/\.$/, '');
50
+ // Strip the zeros trailing the fraction, then the decimal point they leave
51
+ // behind. Only zeros written after a decimal point are droppable, so the
52
+ // match has to start at one: the zeros of a whole number are digits.
53
+ let result = number.toFixed(precision).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
52
54
  if (result.startsWith('0.')) {
53
55
  result = result.slice(1);
54
56
  }
@@ -58,6 +60,44 @@ function roundCompactNumber (value, precision = 3) {
58
60
  return result;
59
61
  }
60
62
 
63
+ /**
64
+ * The most characters the result of a resolved calculation is written with,
65
+ * counting the digits and the decimal point but not the sign. A calculation is
66
+ * only replaced by its result to save characters, and a result that repeats
67
+ * can always be written out longer, so its digits are cut off where the
68
+ * accuracy they add stops paying for the characters they cost.
69
+ *
70
+ * @type {number}
71
+ */
72
+ const MAXIMUM_RESOLVED_NUMBER_CHARACTERS = 7;
73
+
74
+ /**
75
+ * Formats what a calculation resolved to as the number a stylesheet states it
76
+ * with: rounded to however many decimal places are left over once the whole
77
+ * part and the decimal point have taken their share of the character budget a
78
+ * resolved number is written within.
79
+ *
80
+ * @param {number|string} value The number the calculation resolved to.
81
+ * @param {number} maximumCharacters The most characters the written number may take.
82
+ * @return {string} The number as it is written into the stylesheet.
83
+ */
84
+ function formatResolvedNumber (value, maximumCharacters = MAXIMUM_RESOLVED_NUMBER_CHARACTERS) {
85
+ const number = typeof value === 'number' ? value : parseFloat(value);
86
+ if (!Number.isFinite(number)) {
87
+ return String(value);
88
+ }
89
+ const wholePart = Math.floor(Math.abs(number));
90
+ // A magnitude below one is written without its leading zero, so nothing of
91
+ // its whole part is spent from the budget
92
+ let wholeDigitCount = 0;
93
+ if (wholePart !== 0) {
94
+ wholeDigitCount = String(wholePart).length;
95
+ }
96
+ // The decimal point itself takes one of the characters the digits could have
97
+ const decimalPlaces = Math.max(maximumCharacters - wholeDigitCount - 1, 0);
98
+ return roundCompactNumber(number, decimalPlaces);
99
+ }
100
+
61
101
  /**
62
102
  * Converts an absolute CSS length value (pt, pc, in, cm, mm, q) to its pixel equivalent using standard conversion factors.
63
103
  *
@@ -84,15 +124,16 @@ function convertAbsoluteLengthToPx (value, unit) {
84
124
  }
85
125
 
86
126
  /**
87
- * Formats a numeric value with its CSS unit as a compact string, rounding to the specified precision.
127
+ * Formats what a calculation resolved to as the dimension a stylesheet states
128
+ * it with, rounding the number into the character budget a resolved number is
129
+ * written within.
88
130
  *
89
- * @param {number} value The numeric dimension value.
90
- * @param {string} unit The CSS unit suffix (e.g. "px", "%").
91
- * @param {number} precision The number of decimal places to keep.
92
- * @return {string} The formatted dimension string.
131
+ * @param {number} value The numeric result of the calculation.
132
+ * @param {string} unit The CSS unit suffix the result carries (e.g. "px", "%").
133
+ * @return {string} The dimension as it is written into the stylesheet.
93
134
  */
94
- function formatDimension (value, unit, precision = 3) {
95
- return roundCompactNumber(value, precision) + unit;
135
+ function formatResolvedDimension (value, unit) {
136
+ return formatResolvedNumber(value) + unit;
96
137
  }
97
138
 
98
139
  /**
@@ -197,7 +238,8 @@ export {
197
238
  collapseShorthandParts,
198
239
  convertAbsoluteLengthToPx,
199
240
  formatCompactNumber,
200
- formatDimension,
241
+ formatResolvedDimension,
242
+ formatResolvedNumber,
201
243
  hasSubstitutedParts,
202
244
  normalizeScaleComponent,
203
245
  parseAlphaString,