@thejaredwilcurt/csslop 0.0.44 → 0.0.46

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
@@ -296,7 +296,7 @@ Two different cases:
296
296
  1. `git add -A && git commit -m "Updated tests"`
297
297
  1. Then run `npm t` to see if any tests fail
298
298
  1. If they fail, give an AI this prompt:
299
- * **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Any test fails related to idempotency should be handled last. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. When all done, run the `beep` command to alert me you finished.
299
+ * **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Any test fails related to idempotency should be handled last. DO NOT touch anything related to real-world tests (including `npm run idem` and `npm run real`). Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. When all done, run the `beep` command to alert me you finished.
300
300
  1. Verify only code in the `src` folder was modified
301
301
  1. Verify `npm t` passes with a 100% score
302
302
  1. Run `npm run lint`, if anything fails, have the AI fix it.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.44",
5
+ "version": "0.0.46",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/pre/start.js",
@@ -23,9 +23,9 @@
23
23
  "proto": "proto pin node latest --resolve --tool-native && proto pin npm latest --resolve --tool-native && git status"
24
24
  },
25
25
  "devDependencies": {
26
- "@codemirror/autocomplete": "^6.20.3",
26
+ "@codemirror/autocomplete": "^6.20.4",
27
27
  "@codemirror/lang-css": "^6.3.1",
28
- "@codemirror/view": "^6.43.14",
28
+ "@codemirror/view": "^6.43.15",
29
29
  "@eslint/js": "^10.0.1",
30
30
  "@node-projects/css-parser": "^5.2.0",
31
31
  "@stylistic/eslint-plugin": "^5.10.0",
@@ -35,7 +35,7 @@
35
35
  "eslint-config-tjw-import-x": "^1.0.1",
36
36
  "eslint-config-tjw-jsdoc": "^2.0.1",
37
37
  "eslint-plugin-import-x": "^4.17.0",
38
- "eslint-plugin-jsdoc": "^65.3.11",
38
+ "eslint-plugin-jsdoc": "^65.4.0",
39
39
  "fflate": "^0.8.3",
40
40
  "globals": "^17.13.0",
41
41
  "pretty-ms": "^10.0.0",
@@ -5,7 +5,10 @@
5
5
  import { splitTopLevelComponents } from '../parser/source-search.js';
6
6
  import { minifyValue } from '../value/minify.js';
7
7
  import { namedColors } from '../value/named-colors.js';
8
- import { collapseShorthandParts } from '../value/shared.js';
8
+ import {
9
+ collapseShorthandParts,
10
+ hasSubstitutedParts
11
+ } from '../value/shared.js';
9
12
 
10
13
  import {
11
14
  BORDER_EDGE_PROPERTIES,
@@ -107,16 +110,14 @@ function findLastDeclarationIndex (declarations, property) {
107
110
  }
108
111
 
109
112
  /**
110
- * Rewrites a `border-width` + `border-style` + `border-color` trio whose color
111
- * differs per edge into a `border` shorthand built from the first color, followed
112
- * by a `border-color` override that restores the per-edge colors. The rewrite is
113
- * only applied when the resulting pair is shorter than the three longhands.
113
+ * Rewrites a border trio into a shorthand and overrides for components that
114
+ * differ by edge. Only shorter rewrites with uniform importance are used.
114
115
  *
115
116
  * @param {Array} declarations The declarations of a single rule.
116
117
  * @param {object} context The minification context with the stylesheet's reset properties.
117
118
  * @return {Array} The declarations, with the trio rewritten when it is shorter.
118
119
  */
119
- function collapseBorderTrioWithPerEdgeColor (declarations, context) {
120
+ function collapseBorderTrioWithPerEdgeOverrides (declarations, context) {
120
121
  // The `border` shorthand also resets `border-image`, so the trio stays as it
121
122
  // is when another rule of the stylesheet relies on that value.
122
123
  if (resetsPropertyDeclaredElsewhere('border', collectDeclaredProperties(declarations), context)) {
@@ -132,6 +133,18 @@ function collapseBorderTrioWithPerEdgeColor (declarations, context) {
132
133
  if (!hasWholeTrio) {
133
134
  return declarations;
134
135
  }
136
+ const hasCompetingBorderWrites = declarations.some((declaration) => {
137
+ const property = declaration.property || '';
138
+ if (BORDER_TRIO_PROPERTIES.includes(property)) {
139
+ return declarations.filter((entry) => {
140
+ return entry.property === property;
141
+ }).length !== 1;
142
+ }
143
+ return property === 'border' || (property.startsWith('border-') && !property.startsWith('border-image'));
144
+ });
145
+ if (hasCompetingBorderWrites) {
146
+ return declarations;
147
+ }
135
148
 
136
149
  const trioValues = trioIndexes.map((index) => {
137
150
  return minifyValue(declarations[index]);
@@ -150,28 +163,38 @@ function collapseBorderTrioWithPerEdgeColor (declarations, context) {
150
163
  const [width, style, color] = trioValues.map((value) => {
151
164
  return value.replace('!important', '').trim();
152
165
  });
153
- const colorComponents = splitTopLevelComponents(color);
154
- const isSingleComponent = (value) => {
155
- return splitTopLevelComponents(value).length === 1;
156
- };
157
- const canBuildShorthand = (
158
- isSingleComponent(width) &&
159
- isSingleComponent(style) &&
160
- colorComponents.length > 1
161
- );
162
- if (!canBuildShorthand) {
166
+ const componentsByProperty = [width, style, color].map(splitTopLevelComponents);
167
+ const hasPerEdgeComponents = componentsByProperty.some((components) => {
168
+ return components.length > 1;
169
+ });
170
+ const allComponents = componentsByProperty.flat();
171
+ if (!hasPerEdgeComponents || hasSubstitutedParts(allComponents)) {
163
172
  return declarations;
164
173
  }
165
- const usesCssWideKeyword = [width, style, ...colorComponents].some((value) => {
174
+ const usesCssWideKeyword = allComponents.some((value) => {
166
175
  return CSS_WIDE_KEYWORDS.has(value.toLowerCase());
167
176
  });
168
177
  if (usesCssWideKeyword) {
169
178
  return declarations;
170
179
  }
171
180
 
172
- const borderValue = buildBorderShorthandValue(width, style, colorComponents[0]) + importantSuffix;
173
- const colorValue = color + importantSuffix;
174
- const rewrittenLength = ('border:' + borderValue + ';border-color:' + colorValue).length;
181
+ const [widthComponents, styleComponents, colorComponents] = componentsByProperty;
182
+ const borderValue = buildBorderShorthandValue(
183
+ widthComponents.length === 1 ? width : '',
184
+ styleComponents.length === 1 ? style : '',
185
+ colorComponents[0]
186
+ ) + importantSuffix;
187
+ const overrides = BORDER_TRIO_PROPERTIES.flatMap((property, index) => {
188
+ if (componentsByProperty[index].length === 1) {
189
+ return [];
190
+ }
191
+ return [{
192
+ property,
193
+ value: trioValues[index]
194
+ }];
195
+ });
196
+ const replacement = [{ property: 'border', value: borderValue }, ...overrides];
197
+ const rewrittenLength = measureDeclarations(replacement);
175
198
  const longhandLength = (
176
199
  'border-width:' + trioValues[0] +
177
200
  ';border-style:' + trioValues[1] +
@@ -185,16 +208,7 @@ function collapseBorderTrioWithPerEdgeColor (declarations, context) {
185
208
  const insertionIndex = Math.min(...trioIndexes);
186
209
  return declarations.flatMap((declaration, index) => {
187
210
  if (index === insertionIndex) {
188
- return [
189
- {
190
- property: 'border',
191
- value: borderValue
192
- },
193
- {
194
- property: 'border-color',
195
- value: colorValue
196
- }
197
- ];
211
+ return replacement;
198
212
  }
199
213
  if (replacedIndexes.has(index)) {
200
214
  return [];
@@ -216,8 +230,8 @@ function classifyBorderEdgeComponent (component) {
216
230
  if (BORDER_STYLE_KEYWORDS.has(keyword)) {
217
231
  return 'border-style';
218
232
  }
219
- // Match a number with an optional unit, which is how a length is written
220
- const isLength = /^[+-]?(?:\d+|\d*\.\d+)[a-z]*$/i.test(component);
233
+ // Match a number, optional exponent, and optional length unit.
234
+ const isLength = /^[+-]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?[a-z]*$/i.test(component);
221
235
  if (BORDER_WIDTH_KEYWORDS.has(keyword) || isLength || LENGTH_FUNCTION_PATTERN.test(component)) {
222
236
  return 'border-width';
223
237
  }
@@ -411,6 +425,6 @@ function splitBorderEdgesIntoTrio (declarations) {
411
425
  }
412
426
 
413
427
  export {
414
- collapseBorderTrioWithPerEdgeColor,
428
+ collapseBorderTrioWithPerEdgeOverrides,
415
429
  splitBorderEdgesIntoTrio
416
430
  };
@@ -7,7 +7,7 @@ import { hasInvalidQuotesCount } from '../value/quotes.js';
7
7
 
8
8
  import { absorbBackgroundLonghandsIntoShorthand } from './background.js';
9
9
  import {
10
- collapseBorderTrioWithPerEdgeColor,
10
+ collapseBorderTrioWithPerEdgeOverrides,
11
11
  splitBorderEdgesIntoTrio
12
12
  } from './border.js';
13
13
  import {
@@ -449,7 +449,7 @@ function processDeclarations (declarations, context) {
449
449
  result = foldLonghandOverridesIntoShorthands(result, context);
450
450
  result = hoistCssWideKeywordsIntoShorthands(result, context);
451
451
  result = splitBorderEdgesIntoTrio(result);
452
- result = collapseBorderTrioWithPerEdgeColor(result, context);
452
+ result = collapseBorderTrioWithPerEdgeOverrides(result, context);
453
453
 
454
454
  return orderDeclarations(result);
455
455
  }
package/src/index.js CHANGED
@@ -23,24 +23,13 @@ import {
23
23
  import { preprocessDeclarationBlocks } from './preprocess.js';
24
24
  import { combineLanguageSelectors } from './rules/lang.js';
25
25
  import { normalizeRuleSelectors } from './rules/normalize.js';
26
- import {
27
- deduplicateKeyframes,
28
- expandPureNestedRules,
29
- factorCommonParents,
30
- mergeByDeclarations,
31
- mergeIdenticalNestedRules,
32
- mergeLayerRules,
33
- mergeMediaRules,
34
- mergeSelectorRules,
35
- nestFlatRules,
36
- removeEmptyRules,
37
- removeFullyOverriddenSelectors,
38
- removeOverriddenMultiSelectorProperties
39
- } from './rules/optimize.js';
26
+ import { optimizeRuleList } from './rules/optimize.js';
27
+ import { combineLegacyAnySelectors } from './rules/selectors.js';
40
28
  import {
41
29
  removeRedundantLayerStatementSemicolon,
42
30
  stringifyRule
43
31
  } from './rules/stringify.js';
32
+ import { removeInvalidSelectorRules } from './rules/validate.js';
44
33
  import {
45
34
  clearMinifiedValueCache,
46
35
  minifyValue
@@ -64,7 +53,7 @@ function deduplicateSelectorList (selectorList) {
64
53
  seen.add(selector);
65
54
  return true;
66
55
  });
67
- return combineLanguageSelectors(unique).join(',');
56
+ return combineLanguageSelectors(combineLegacyAnySelectors(unique)).join(',');
68
57
  }
69
58
 
70
59
  /**
@@ -199,6 +188,7 @@ export const minifyCSS = function (input) {
199
188
  const context = createMinifyContext();
200
189
 
201
190
  if (ast?.stylesheet?.rules) {
191
+ ast.stylesheet.rules = removeInvalidSelectorRules(ast.stylesheet.rules);
202
192
  // Every later pass reads selectors, so they are written the one canonical
203
193
  // way before any of them runs.
204
194
  normalizeRuleSelectors(ast.stylesheet.rules);
@@ -227,24 +217,7 @@ export const minifyCSS = function (input) {
227
217
  );
228
218
  ast.stylesheet.rules = filterRedundantCharsets(ast.stylesheet.rules);
229
219
 
230
- ast.stylesheet.rules = expandPureNestedRules(ast.stylesheet.rules);
231
- ast.stylesheet.rules = mergeLayerRules(ast.stylesheet.rules, mergeSelectorRules);
232
- ast.stylesheet.rules = mergeMediaRules(ast.stylesheet.rules, mergeSelectorRules);
233
- ast.stylesheet.rules = deduplicateKeyframes(ast.stylesheet.rules);
234
-
235
- const mergedRules = mergeSelectorRules(ast.stylesheet.rules);
236
- const overrideCleanedRules = removeOverriddenMultiSelectorProperties(mergedRules);
237
- const selectorPrunedRules = removeFullyOverriddenSelectors(overrideCleanedRules);
238
- const preCleanedRules = removeEmptyRules(selectorPrunedRules);
239
- const declarationMergedRules = mergeByDeclarations(preCleanedRules);
240
- const nestedRules = nestFlatRules(declarationMergedRules);
241
- const nonEmptyRules = removeEmptyRules(nestedRules);
242
- // Nesting is what reveals that two rules style the same thing: until their
243
- // descendants have moved inside them, the rules only share a declaration.
244
- const nestingMergedRules = mergeByDeclarations(nonEmptyRules);
245
- const factoredRules = factorCommonParents(nestingMergedRules);
246
- const nestedFinalRules = nestFlatRules(factoredRules);
247
- const finalRules = mergeIdenticalNestedRules(nestedFinalRules);
220
+ const finalRules = optimizeRuleList(ast.stylesheet.rules);
248
221
 
249
222
  for (const rule of finalRules) {
250
223
  output.push(stringifyRule(rule, context));
@@ -162,8 +162,8 @@ function startsUrlToken (source, index) {
162
162
 
163
163
  /**
164
164
  * Finds the first of several delimiter characters that is written at the top
165
- * level of the text, meaning it is not escaped, quoted, or nested inside a
166
- * group.
165
+ * level of the text, meaning it is not escaped, quoted, inside a comment, or
166
+ * nested inside a group.
167
167
  *
168
168
  * @param {string} source The raw CSS text being scanned.
169
169
  * @param {string} delimiters The delimiter characters to look for.
@@ -174,6 +174,11 @@ function findTopLevelDelimiter (source, delimiters, startIndex) {
174
174
  let index = startIndex;
175
175
  while (index < source.length) {
176
176
  const character = source[index];
177
+ if (character === '/' && source[index + 1] === '*') {
178
+ const closingIndex = source.indexOf('*/', index + 2);
179
+ index = closingIndex === -1 ? source.length : closingIndex + 2;
180
+ continue;
181
+ }
177
182
  if (character === '\\') {
178
183
  index += 2;
179
184
  continue;
@@ -11,12 +11,17 @@ import {
11
11
  findMatchingParenthesis,
12
12
  splitTopLevelCommaList
13
13
  } from '../parser/source-search.js';
14
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
14
15
  import { escapeRegexString } from '../utilities.js';
15
16
 
16
17
  import {
17
18
  normalizeLayerNames,
18
19
  normalizeMedia
19
20
  } from './normalize.js';
21
+ import {
22
+ containsPseudoElement,
23
+ isUniversallyRecognizableSelector
24
+ } from './selectors.js';
20
25
 
21
26
  /**
22
27
  * Reports whether an entry of a rule's body is itself a nested rule rather
@@ -44,13 +49,67 @@ function expandNestedRuleBodies (rules) {
44
49
  if (rule.type !== 'rule' || !(rule.declarations || []).some(isNestedRule)) {
45
50
  return rule;
46
51
  }
52
+ const declarations = expandPureNestedRules(rule.declarations);
53
+ const canFlattenSelf = selectorsShareSpecificity(rule.selectors) && !rule.selectors.some(containsPseudoElement);
54
+ let encounteredNestedContent = false;
55
+ const flattened = declarations.flatMap((entry) => {
56
+ const isSelfRule = entry.type === 'rule' && entry.selectors?.length === 1 && entry.selectors[0].trim() === '&';
57
+ if (canFlattenSelf && isSelfRule && !encounteredNestedContent) {
58
+ return entry.declarations || [];
59
+ }
60
+ if (entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment') {
61
+ encounteredNestedContent = true;
62
+ }
63
+ if (entry.type === 'rule' && rule.selectors.length === 1) {
64
+ entry.selectors = entry.selectors.map((selector) => {
65
+ return shortenRepeatedNestingSelector(rule.selectors[0], selector);
66
+ });
67
+ }
68
+ return [entry];
69
+ });
47
70
  return {
48
71
  ...rule,
49
- declarations: expandPureNestedRules(rule.declarations)
72
+ declarations: flattened
50
73
  };
51
74
  });
52
75
  }
53
76
 
77
+ /**
78
+ * Replaces repetitions of a simple class or ID beside a leading nesting
79
+ * selector. Token comparisons keep longer names and escaped text intact.
80
+ *
81
+ * @param {string} parentSelector The sole parent selector.
82
+ * @param {string} childSelector The nested selector.
83
+ * @return {string} The shorter equivalent selector.
84
+ */
85
+ function shortenRepeatedNestingSelector (parentSelector, childSelector) {
86
+ const parentTokens = tokenizeCssValue(parentSelector);
87
+ const isClass = parentTokens.length === 2 && parentTokens[0].text === '.' && parentTokens[1].type === 'ident';
88
+ const isIdentifier = parentTokens.length === 1 && parentTokens[0].type === 'hash';
89
+ if (!isClass && !isIdentifier) {
90
+ return childSelector;
91
+ }
92
+ const childTokens = tokenizeCssValue(childSelector);
93
+ if (childTokens[0]?.text !== '&') {
94
+ return childSelector;
95
+ }
96
+ let index = 1;
97
+ let result = '&';
98
+ while (index < childTokens.length) {
99
+ const matchesParent = parentTokens.every((token, position) => {
100
+ return childTokens[index + position]?.text === token.text;
101
+ });
102
+ if (!matchesParent) {
103
+ break;
104
+ }
105
+ result += '&';
106
+ index += parentTokens.length;
107
+ }
108
+ return result + childTokens.slice(index).map((token) => {
109
+ return token.text;
110
+ }).join('');
111
+ }
112
+
54
113
  /**
55
114
  * Reports whether a nested child selector still means the same thing once it
56
115
  * is written out in full under its parent.
@@ -203,6 +262,9 @@ function createNestedSelectorMatcher (parentSelector) {
203
262
 
204
263
  return (childSelector) => {
205
264
  const child = childSelector.trim();
265
+ if (child === parent) {
266
+ return '&';
267
+ }
206
268
  if (child.startsWith(parent + ':') || child.startsWith(parent + '::')) {
207
269
  return '&' + child.slice(parent.length);
208
270
  }
@@ -437,6 +499,26 @@ function factorCommonParents (rules) {
437
499
  let index = 0;
438
500
  while (index < rules.length) {
439
501
  const rule = rules[index];
502
+ if (rule.type === 'rule' && rule.selectors?.length > 1) {
503
+ const candidateParent = extractLeadingCompound(rule.selectors[0]) || rule.selectors[0];
504
+ if (candidateParent) {
505
+ const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
506
+ const childSelectors = rule.selectors.map(matchNestedSelector);
507
+ const allMatch = childSelectors.every((selector) => {
508
+ return selector !== null;
509
+ });
510
+ const wrapperLength = candidateParent.length + 2 + childSelectors.join(',').length;
511
+ if (allMatch && !containsPseudoElement(candidateParent) && wrapperLength < rule.selectors.join(',').length) {
512
+ result.push({
513
+ type: 'rule',
514
+ selectors: [candidateParent],
515
+ declarations: [{ ...rule, selectors: childSelectors }]
516
+ });
517
+ index++;
518
+ continue;
519
+ }
520
+ }
521
+ }
440
522
  // Only single-selector style rules can act as a factoring candidate.
441
523
  if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
442
524
  result.push(rule);
@@ -491,6 +573,42 @@ function factorCommonParents (rules) {
491
573
  return result;
492
574
  }
493
575
 
576
+ /**
577
+ * Shares a descendant prefix through `:is()` when a nesting wrapper did not
578
+ * save enough space to factor the parent out of the selector list.
579
+ *
580
+ * @param {object} rule The style rule whose selector list may be shortened.
581
+ */
582
+ function factorSelectorBranches (rule) {
583
+ if (rule.type !== 'rule' || !rule.selectors?.length) {
584
+ return;
585
+ }
586
+ for (const parent of [...rule.selectors]) {
587
+ const prefix = parent + ' ';
588
+ const branches = rule.selectors.filter((selector) => {
589
+ return selector.startsWith(prefix);
590
+ });
591
+ const children = branches.map((selector) => {
592
+ return selector.slice(prefix.length);
593
+ });
594
+ if (children.length < 2 || !children.every(isUniversallyRecognizableSelector) || !selectorsShareSpecificity(children)) {
595
+ continue;
596
+ }
597
+ const grouped = prefix + ':is(' + children.sort().join(',') + ')';
598
+ if (grouped.length >= branches.join(',').length) {
599
+ continue;
600
+ }
601
+ const branchSet = new Set(branches);
602
+ const firstIndex = rule.selectors.indexOf(branches[0]);
603
+ rule.selectors = rule.selectors.flatMap((selector, index) => {
604
+ if (index === firstIndex) {
605
+ return [grouped];
606
+ }
607
+ return branchSet.has(selector) ? [] : [selector];
608
+ });
609
+ }
610
+ }
611
+
494
612
  /**
495
613
  * Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
496
614
  *
@@ -1469,7 +1587,7 @@ function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPos
1469
1587
  * @param {Array} rules The AST rule nodes to merge.
1470
1588
  * @return {Array} A new array of rules with same-selector rules combined.
1471
1589
  */
1472
- function mergeSelectorRules (rules) {
1590
+ function mergeSelectorRulePass (rules) {
1473
1591
  // A rule merged at the later of the two positions moves to the end of the
1474
1592
  // output. Its old slot is emptied instead of spliced out so that every
1475
1593
  // recorded position stays valid, and the position map locates that slot
@@ -1524,6 +1642,24 @@ function mergeSelectorRules (rules) {
1524
1642
  });
1525
1643
  }
1526
1644
 
1645
+ /**
1646
+ * Reconsiders blocked pairs after merges relocate the rules that blocked them.
1647
+ * Each productive pass removes a rule, so this reaches a fixed point without
1648
+ * rerunning parsing or unrelated optimizations.
1649
+ *
1650
+ * @param {Array} rules The sibling rules to merge.
1651
+ * @return {Array} Rules with all currently safe same-selector merges.
1652
+ */
1653
+ function mergeSelectorRules (rules) {
1654
+ let previousRules = rules;
1655
+ let mergedRules = mergeSelectorRulePass(previousRules);
1656
+ while (mergedRules.length < previousRules.length) {
1657
+ previousRules = mergedRules;
1658
+ mergedRules = mergeSelectorRulePass(previousRules);
1659
+ }
1660
+ return mergedRules;
1661
+ }
1662
+
1527
1663
  /**
1528
1664
  * Merges `@layer` blocks with the same name by combining their child rules, deduplicates layer statements, and merges selector rules within each block.
1529
1665
  *
@@ -1847,6 +1983,37 @@ function removeOverriddenMultiSelectorProperties (rules) {
1847
1983
  return rules;
1848
1984
  }
1849
1985
 
1986
+ /**
1987
+ * Applies the structural optimization pipeline to a stylesheet or grouping
1988
+ * block, so merging at-rules does not leave their children unoptimized.
1989
+ *
1990
+ * @param {Array} rules The sibling AST entries.
1991
+ * @return {Array} The optimized sibling entries.
1992
+ */
1993
+ function optimizeRuleList (rules) {
1994
+ let result = expandPureNestedRules(rules);
1995
+ result = mergeLayerRules(result, mergeSelectorRules);
1996
+ result = mergeMediaRules(result, mergeSelectorRules);
1997
+ result = deduplicateKeyframes(result);
1998
+ for (const rule of result) {
1999
+ if (rule.rules) {
2000
+ rule.rules = optimizeRuleList(rule.rules);
2001
+ }
2002
+ }
2003
+ result = mergeSelectorRules(result);
2004
+ result = removeOverriddenMultiSelectorProperties(result);
2005
+ result = removeFullyOverriddenSelectors(result);
2006
+ result = removeEmptyRules(result);
2007
+ result = mergeByDeclarations(result);
2008
+ result = nestFlatRules(result);
2009
+ result = removeEmptyRules(result);
2010
+ result = mergeByDeclarations(result);
2011
+ result = factorCommonParents(result);
2012
+ result = nestFlatRules(result);
2013
+ result.forEach(factorSelectorBranches);
2014
+ return mergeIdenticalNestedRules(result);
2015
+ }
2016
+
1850
2017
  export {
1851
2018
  deduplicateKeyframes,
1852
2019
  expandPureNestedRules,
@@ -1857,6 +2024,7 @@ export {
1857
2024
  mergeMediaRules,
1858
2025
  mergeSelectorRules,
1859
2026
  nestFlatRules,
2027
+ optimizeRuleList,
1860
2028
  removeEmptyRules,
1861
2029
  removeFullyOverriddenSelectors,
1862
2030
  removeOverriddenMultiSelectorProperties
@@ -719,9 +719,50 @@ function flattenNestingParentIsSelector (selector) {
719
719
  return parts;
720
720
  }
721
721
 
722
+ /**
723
+ * Replaces matching legacy any-functions only when multiple vendor variants
724
+ * supply the same selector. A lone vendor-specific selector stays scoped to
725
+ * the browsers that understand it.
726
+ *
727
+ * @param {Array} selectors Selectors with identical declarations.
728
+ * @return {Array} The selectors with redundant legacy forms combined.
729
+ */
730
+ function combineLegacyAnySelectors (selectors) {
731
+ const legacyFunctions = [':-moz-any(', ':-webkit-any(', ':matches('];
732
+ const groups = new Map();
733
+ const entries = selectors.map((selector) => {
734
+ let canonical = selector;
735
+ const variants = new Set();
736
+ for (const functionCall of legacyFunctions) {
737
+ let position = findNextFunctionCallOutsideStrings(canonical, functionCall, 0);
738
+ while (position !== -1) {
739
+ variants.add(functionCall);
740
+ canonical = canonical.slice(0, position) + ':is(' + canonical.slice(position + functionCall.length);
741
+ position = findNextFunctionCallOutsideStrings(canonical, functionCall, position + 4);
742
+ }
743
+ }
744
+ let group = groups.get(canonical);
745
+ if (!group) {
746
+ group = new Set();
747
+ groups.set(canonical, group);
748
+ }
749
+ const variant = variants.size === 1 ? [...variants][0] : null;
750
+ if (variant) {
751
+ group.add(variant);
752
+ }
753
+ return { selector, canonical, variant };
754
+ });
755
+ return [...new Set(entries.map((entry) => {
756
+ return entry.variant && groups.get(entry.canonical).size > 1 ? entry.canonical : entry.selector;
757
+ }))];
758
+ }
759
+
722
760
  export {
761
+ combineLegacyAnySelectors,
762
+ containsPseudoElement,
723
763
  findNextFunctionCallOutsideStrings,
724
764
  flattenNestingParentIsSelector,
765
+ isUniversallyRecognizableSelector,
725
766
  mergeAdjacentWherePseudoClasses,
726
767
  processIsSelector
727
768
  };
@@ -3,8 +3,15 @@
3
3
  */
4
4
 
5
5
  import { processDeclarations } from '../declarations/process.js';
6
- import { splitTopLevelCommaList } from '../parser/source-search.js';
6
+ import {
7
+ skipParenthesizedGroup,
8
+ skipQuotedString,
9
+ splitTopLevelCommaList,
10
+ startsUrlToken
11
+ } from '../parser/source-search.js';
12
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
7
13
  import { minifyValueForOutput } from '../value/minify.js';
14
+ import { elideRedundantSeparators } from '../value/tokens.js';
8
15
 
9
16
  import {
10
17
  collapseCustomPropertyWhitespace,
@@ -24,6 +31,7 @@ import {
24
31
  } from './normalize.js';
25
32
  import { resolvePropertyDescriptors } from './property.js';
26
33
  import {
34
+ combineLegacyAnySelectors,
27
35
  flattenNestingParentIsSelector,
28
36
  mergeAdjacentWherePseudoClasses,
29
37
  processIsSelector
@@ -110,6 +118,67 @@ function minifyFunctionPrelude (prelude) {
110
118
  return functionName + '(' + minifiedParameters.join(',') + ')';
111
119
  }
112
120
 
121
+ /**
122
+ * Removes comments between namespace tokens while leaving strings, URLs, and
123
+ * escaped characters intact.
124
+ *
125
+ * @param {string} prelude The namespace prelude as written.
126
+ * @return {string} The prelude with comments replaced by separators.
127
+ */
128
+ function removeNamespaceComments (prelude) {
129
+ let result = '';
130
+ let index = 0;
131
+ while (index < prelude.length) {
132
+ if (prelude.startsWith('/*', index)) {
133
+ const closingIndex = prelude.indexOf('*/', index + 2);
134
+ index = closingIndex === -1 ? prelude.length : closingIndex + 2;
135
+ result += ' ';
136
+ continue;
137
+ }
138
+ let end = index + 1;
139
+ if (prelude[index] === '"' || prelude[index] === '\'') {
140
+ end = skipQuotedString(prelude, index);
141
+ } else if (prelude[index] === '\\') {
142
+ end = Math.min(index + 2, prelude.length);
143
+ } else if (startsUrlToken(prelude, index)) {
144
+ end = skipParenthesizedGroup(prelude, index + 3);
145
+ }
146
+ result += prelude.slice(index, end);
147
+ index = end;
148
+ }
149
+ return result;
150
+ }
151
+
152
+ /**
153
+ * Unwraps quoted namespace URLs and removes token separators without changing
154
+ * whitespace or escapes inside the namespace name's string.
155
+ *
156
+ * @param {string} prelude The optional namespace prefix and its URL.
157
+ * @return {string} The minified namespace statement.
158
+ */
159
+ function stringifyNamespace (prelude) {
160
+ const tokens = tokenizeCssValue(removeNamespaceComments(prelude)).filter((token) => {
161
+ return token.type !== 'whitespace';
162
+ });
163
+ const parts = ['@namespace'];
164
+ for (let index = 0; index < tokens.length; index++) {
165
+ const token = tokens[index];
166
+ const isQuotedUrl = (
167
+ token.type === 'function' &&
168
+ token.text.toLowerCase() === 'url(' &&
169
+ tokens[index + 1]?.type === 'string' &&
170
+ tokens[index + 2]?.text === ')'
171
+ );
172
+ if (isQuotedUrl) {
173
+ parts.push(tokens[index + 1].text);
174
+ index += 2;
175
+ } else {
176
+ parts.push(token.text);
177
+ }
178
+ }
179
+ return elideRedundantSeparators(parts.join(' ')) + ';';
180
+ }
181
+
113
182
  /**
114
183
  * Stringifies a generic `at-rule` AST node into minified CSS, with specialized
115
184
  * prelude minification for `@function` rules and generic whitespace handling
@@ -257,6 +326,7 @@ function stringifyRule (rule, context) {
257
326
  minified = minifyLanguageSelector(minified);
258
327
  return minified;
259
328
  });
329
+ uniqueSelectors = combineLegacyAnySelectors(uniqueSelectors);
260
330
  // When this rule is a nesting parent, its whole selector list is treated
261
331
  // as :is() for the children's specificity, so a top-level :is() can be
262
332
  // safely lifted into the list without altering specificity.
@@ -464,6 +534,10 @@ function stringifyRule (rule, context) {
464
534
  return '@charset ' + rule.charset + ';';
465
535
  }
466
536
 
537
+ if (rule.type === 'namespace') {
538
+ return stringifyNamespace(rule.namespace);
539
+ }
540
+
467
541
  if (rule.type === 'import') {
468
542
  let importStatement = rule.import;
469
543
  // Unwrap url() with a quoted string to just the quoted string
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @file Rejects malformed selector syntax before rules can be merged.
3
+ */
4
+
5
+ import {
6
+ findMatchingBracket,
7
+ findMatchingParenthesis,
8
+ splitTopLevelCommaList
9
+ } from '../parser/source-search.js';
10
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
11
+
12
+ /**
13
+ * Reads an attribute name, optional matcher, value, and case flag.
14
+ *
15
+ * @param {string} attribute The contents of the brackets.
16
+ * @return {boolean} Whether the attribute has a valid token sequence.
17
+ */
18
+ function isValidAttribute (attribute) {
19
+ const tokens = tokenizeCssValue(attribute).filter((token) => {
20
+ return token.type !== 'whitespace';
21
+ });
22
+ let index = 0;
23
+ if ((tokens[0]?.type === 'ident' || tokens[0]?.text === '*') && tokens[1]?.text === '|') {
24
+ index = 2;
25
+ } else if (tokens[0]?.text === '|') {
26
+ index = 1;
27
+ }
28
+ if (tokens[index]?.type !== 'ident') {
29
+ return false;
30
+ }
31
+ index++;
32
+ if (index === tokens.length) {
33
+ return true;
34
+ }
35
+ if (['~', '|', '^', '$', '*'].includes(tokens[index]?.text)) {
36
+ index++;
37
+ }
38
+ if (tokens[index]?.text !== '=') {
39
+ return false;
40
+ }
41
+ index++;
42
+ if (tokens[index]?.type !== 'ident' && tokens[index]?.type !== 'string') {
43
+ return false;
44
+ }
45
+ index++;
46
+ if (tokens[index]?.type === 'ident' && ['i', 's'].includes(tokens[index].text.toLowerCase())) {
47
+ index++;
48
+ }
49
+ return index === tokens.length;
50
+ }
51
+
52
+ /**
53
+ * Checks that each language range is a single identifier or quoted string.
54
+ *
55
+ * @param {string} argumentsText The language function's contents.
56
+ * @return {boolean} Whether every argument has valid syntax.
57
+ */
58
+ function isValidLanguageList (argumentsText) {
59
+ const argumentsList = splitTopLevelCommaList(argumentsText);
60
+ return argumentsList.length > 0 && argumentsList.every((argument) => {
61
+ const tokens = tokenizeCssValue(argument.trim());
62
+ return tokens.length === 1 && (tokens[0].type === 'ident' || tokens[0].type === 'string');
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Detects invalid tokens without interpreting data inside strings, escapes,
68
+ * or forgiving selector lists as syntax of the surrounding selector.
69
+ *
70
+ * @param {string} selector A single selector.
71
+ * @return {boolean} Whether its checked syntax is valid.
72
+ */
73
+ function isValidSelector (selector) {
74
+ const tokens = tokenizeCssValue(selector);
75
+ let offset = 0;
76
+ for (let index = 0; index < tokens.length; index++) {
77
+ const token = tokens[index];
78
+ if (token.text === '[' || token.type === 'function') {
79
+ const isAttribute = token.text === '[';
80
+ const openingIndex = isAttribute ? offset : offset + token.text.length - 1;
81
+ const closingIndex = isAttribute ?
82
+ findMatchingBracket(selector, openingIndex) :
83
+ findMatchingParenthesis(selector, openingIndex);
84
+ if (closingIndex === -1) {
85
+ return false;
86
+ }
87
+ const inner = selector.slice(openingIndex + 1, closingIndex);
88
+ const functionName = token.text.slice(0, -1).toLowerCase();
89
+ if (isAttribute && !isValidAttribute(inner)) {
90
+ return false;
91
+ }
92
+ if (functionName === 'lang' && !isValidLanguageList(inner)) {
93
+ return false;
94
+ }
95
+ if (functionName === 'not' || functionName === 'has') {
96
+ if (!splitTopLevelCommaList(inner).every(isValidSelector)) {
97
+ return false;
98
+ }
99
+ }
100
+ while (offset < closingIndex) {
101
+ offset += tokens[index].text.length;
102
+ index++;
103
+ }
104
+ offset += tokens[index].text.length;
105
+ continue;
106
+ }
107
+ if (token.text === '|' && tokens[index + 1]?.text === '|') {
108
+ return false;
109
+ }
110
+ if (token.text === ':') {
111
+ let followingIndex = index + 1;
112
+ if (tokens[followingIndex]?.text === ':') {
113
+ followingIndex++;
114
+ }
115
+ const following = tokens[followingIndex];
116
+ if (following?.type !== 'ident' && following?.type !== 'function') {
117
+ return false;
118
+ }
119
+ }
120
+ offset += token.text.length;
121
+ }
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * Removes an entire unforgiving selector list if any branch is malformed.
127
+ *
128
+ * @param {Array} rules Stylesheet or nested body entries.
129
+ * @return {Array} Entries that cannot poison a valid rule during merging.
130
+ */
131
+ function removeInvalidSelectorRules (rules) {
132
+ return rules.filter((rule) => {
133
+ return !rule.selectors || rule.selectors.every(isValidSelector);
134
+ }).map((rule) => {
135
+ if (rule.rules) {
136
+ rule.rules = removeInvalidSelectorRules(rule.rules);
137
+ }
138
+ if (rule.declarations) {
139
+ rule.declarations = removeInvalidSelectorRules(rule.declarations);
140
+ }
141
+ return rule;
142
+ });
143
+ }
144
+
145
+ export { removeInvalidSelectorRules };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @file Token-aware font shorthand minification.
3
+ */
4
+
5
+ import { CSS_WIDE_KEYWORDS } from '../declarations/config.js';
6
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
7
+
8
+ /**
9
+ * Keywords that would change a quoted family into a generic family.
10
+ *
11
+ * @type {Set<string>}
12
+ */
13
+ const GENERIC_FAMILY_NAMES = new Set([
14
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
15
+ 'math', 'emoji', 'fangsong', 'ui-serif', 'ui-sans-serif', 'ui-monospace',
16
+ 'ui-rounded'
17
+ ]);
18
+
19
+ /**
20
+ * Removes family quotes only when their contents form ordinary identifiers.
21
+ *
22
+ * @param {string} text A family string token.
23
+ * @return {string} The shortest safe spelling.
24
+ */
25
+ function shortenFamilyString (text) {
26
+ const inner = text.slice(1, -1);
27
+ if (inner.includes('\\') || inner.trim() !== inner) {
28
+ return text;
29
+ }
30
+ const tokens = tokenizeCssValue(inner);
31
+ const isIdentifierList = tokens.length && tokens.every((token) => {
32
+ if (token.type === 'whitespace') {
33
+ return true;
34
+ }
35
+ const keyword = token.text.toLowerCase();
36
+ return token.type === 'ident' && !CSS_WIDE_KEYWORDS.has(keyword) && keyword !== 'default';
37
+ });
38
+ if (!isIdentifierList || GENERIC_FAMILY_NAMES.has(inner.toLowerCase())) {
39
+ return text;
40
+ }
41
+ return inner;
42
+ }
43
+
44
+ /**
45
+ * Locates a size before rewriting optional components. A substituted or
46
+ * calculated prefix is left intact because its component boundaries may not
47
+ * be knowable until the browser resolves it.
48
+ *
49
+ * @param {Array} tokens The font tokens.
50
+ * @return {number} The size index, or -1 if the prefix is ambiguous.
51
+ */
52
+ function findFontSizeIndex (tokens) {
53
+ for (let index = 0; index < tokens.length; index++) {
54
+ const token = tokens[index];
55
+ if (token.type === 'function') {
56
+ return -1;
57
+ }
58
+ // An oblique font style may state an angle before the font size.
59
+ const isAngle = token.type === 'dimension' && /(?:deg|grad|rad|turn)$/i.test(token.text);
60
+ if (token.type === 'dimension' && !isAngle) {
61
+ return index;
62
+ }
63
+ // Match absolute and relative size keywords, not family-name substrings.
64
+ const isSizeKeyword = token.type === 'ident' &&
65
+ /^(?:xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|smaller|larger)$/i.test(token.text);
66
+ if (isSizeKeyword || token.type === 'percentage' || (token.type === 'number' && Number(token.text) === 0)) {
67
+ return index;
68
+ }
69
+ }
70
+ return -1;
71
+ }
72
+
73
+ /**
74
+ * Normalizes optional font components before the size, leaving the family and
75
+ * line height in their respective grammar positions.
76
+ *
77
+ * @param {string} value The font shorthand.
78
+ * @return {string} The minified shorthand.
79
+ */
80
+ function minifyFontShorthand (value) {
81
+ const tokens = tokenizeCssValue(value);
82
+ const sizeIndex = findFontSizeIndex(tokens);
83
+ if (sizeIndex === -1) {
84
+ return value;
85
+ }
86
+ return tokens.map((token, index) => {
87
+ if (index < sizeIndex) {
88
+ if (token.type === 'ident') {
89
+ const keyword = token.text.toLowerCase();
90
+ if (keyword === 'normal') {
91
+ return '';
92
+ }
93
+ if (keyword === 'bold') {
94
+ return '700';
95
+ }
96
+ } else if (token.type === 'number' && token.text === '400') {
97
+ return '';
98
+ }
99
+ }
100
+ if (index > sizeIndex && token.type === 'string') {
101
+ const family = shortenFamilyString(token.text);
102
+ if (family !== token.text && tokens[index - 1]?.type !== 'whitespace' && tokens[index - 1]?.text !== ',') {
103
+ return ' ' + family;
104
+ }
105
+ return family;
106
+ }
107
+ return token.text;
108
+ }).join('').trim();
109
+ }
110
+
111
+ export { minifyFontShorthand };
@@ -22,6 +22,7 @@ import {
22
22
  rgbaToHex,
23
23
  shortestColor
24
24
  } from './colors.js';
25
+ import { minifyFontShorthand } from './font.js';
25
26
  import { minifyGradients } from './gradients.js';
26
27
  import { simplifyEquivalentLightDarkFunctions } from './light-dark.js';
27
28
  import {
@@ -45,7 +46,10 @@ import {
45
46
  roundCompactNumber
46
47
  } from './shared.js';
47
48
  import { minifySvgDataUri } from './svg-data-uri.js';
48
- import { elideRedundantSeparators } from './tokens.js';
49
+ import {
50
+ elideRedundantSeparators,
51
+ shortenDimensionNumbers
52
+ } from './tokens.js';
49
53
  import { minifyTransformValue } from './transforms.js';
50
54
  import { optimizeUnicodeRange } from './unicode-range.js';
51
55
 
@@ -480,7 +484,7 @@ function normalizeWhitespaceAndQuotes (val, property) {
480
484
  });
481
485
 
482
486
  // Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
483
- if (property !== 'content' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
487
+ if (property !== 'content' && property !== 'font' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
484
488
  // Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
485
489
  val = val.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
486
490
  // Keep quotes around CSS generic font-family keywords — unquoted they mean something different
@@ -988,6 +992,11 @@ const LENGTHY_INITIAL_KEYWORDS = new Map([
988
992
  ['background-clip', 'border-box'],
989
993
  ['background-origin', 'padding-box'],
990
994
  ['border-collapse', 'separate'],
995
+ ['border-color', 'currentcolor'],
996
+ ['border-top-color', 'currentcolor'],
997
+ ['border-right-color', 'currentcolor'],
998
+ ['border-bottom-color', 'currentcolor'],
999
+ ['border-left-color', 'currentcolor'],
991
1000
  ['box-sizing', 'content-box'],
992
1001
  ['mask-clip', 'border-box'],
993
1002
  ['mask-origin', 'border-box'],
@@ -1115,7 +1124,7 @@ const BORDER_STYLE_KEYWORD_SOURCE = 'solid|dashed|dotted|double|groove|ridge|ins
1115
1124
  *
1116
1125
  * @type {string}
1117
1126
  */
1118
- const BORDER_WIDTH_SOURCE = 'thin|medium|thick|[+-]?(?:\\d+|\\d*\\.\\d+)[a-z]*|(?:calc|min|max|clamp)\\([^()]*\\)';
1127
+ const BORDER_WIDTH_SOURCE = 'thin|medium|thick|[+-]?(?:\\d+|\\d*\\.\\d+)(?:e[+-]?\\d+)?[a-z]*|(?:calc|min|max|clamp)\\([^()]*\\)';
1119
1128
 
1120
1129
  /**
1121
1130
  * Matches a `border` value that states its line style in front of its line
@@ -1408,10 +1417,12 @@ function applyPropertyOptimizations (val, property, urlTokens) {
1408
1417
  return rgbaToHex(r, r, r, parseAlphaString(alphaStr));
1409
1418
  });
1410
1419
 
1411
- // Shorten all color tokens (second pass after property-specific color evaluations)
1412
- val = replaceOutsideStringsAndUrls(val, (segment) => {
1413
- return shortenColorValues(segment);
1414
- });
1420
+ // Font families may contain color names, but never color values.
1421
+ if (property !== 'font' && property !== 'font-family') {
1422
+ val = replaceOutsideStringsAndUrls(val, (segment) => {
1423
+ return shortenColorValues(segment);
1424
+ });
1425
+ }
1415
1426
 
1416
1427
  if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
1417
1428
  // Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
@@ -1420,17 +1431,7 @@ function applyPropertyOptimizations (val, property, urlTokens) {
1420
1431
  val = restoreSpaceBeforeMathOperators(val);
1421
1432
 
1422
1433
  if (property === 'font') {
1423
- // Split font shorthand on whitespace
1424
- const parts = val.split(/\s+/);
1425
- // Find the font-size part: contains a digit and a recognized CSS length/percentage unit
1426
- const sizeIndex = parts.findIndex((part) => {
1427
- return /\d/.test(part) && /(?:px|em|rem|%|pt|pc|vw|vh|vmin|vmax|ch|ex|cm|mm|in|lh|rlh)/i.test(part);
1428
- });
1429
- if (sizeIndex > 0) {
1430
- val = [...parts.slice(0, sizeIndex).filter((part) => {
1431
- return part !== 'normal' && part !== '400';
1432
- }), ...parts.slice(sizeIndex)].join(' ');
1433
- }
1434
+ val = minifyFontShorthand(val);
1434
1435
  }
1435
1436
 
1436
1437
  if (property === 'background' && val !== 'none') {
@@ -1577,18 +1578,22 @@ function computeMinifiedValue (declaration) {
1577
1578
  // character budget a number is written within
1578
1579
  if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1579
1580
  const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1580
- val = formatResolvedNumber(rawNumber) + rawUnit;
1581
+ const scientificValue = isCustomProperty(declaration.property) ? val : shortenDimensionNumbers(val);
1582
+ const roundedNumber = formatResolvedNumber(rawNumber);
1583
+ const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
1584
+ // Preserve tiny dimensions whose significant digits would otherwise be
1585
+ // rounded away before their shorter exponent form can be considered.
1586
+ val = losesPrecision && scientificValue !== val ? scientificValue : roundedNumber + rawUnit;
1581
1587
  }
1582
1588
 
1583
1589
  val = lowercaseHexColors(val);
1584
1590
 
1585
- // Convert color functions to hex equivalents
1586
- val = convertColorsToHex(val);
1587
-
1588
- // Shorten all color tokens (hex and named) to their shortest representation
1589
- val = replaceOutsideStringsAndUrls(val, (segment) => {
1590
- return shortenColorValues(segment);
1591
- });
1591
+ if (declaration.property !== 'font' && declaration.property !== 'font-family') {
1592
+ val = convertColorsToHex(val);
1593
+ val = replaceOutsideStringsAndUrls(val, (segment) => {
1594
+ return shortenColorValues(segment);
1595
+ });
1596
+ }
1592
1597
 
1593
1598
  // Collapse light-dark() when both normalized branches are identical
1594
1599
  val = simplifyEquivalentLightDarkFunctions(val);
@@ -1627,6 +1632,7 @@ function computeMinifiedValue (declaration) {
1627
1632
  !isCustomProperty(declaration.property)
1628
1633
  );
1629
1634
  if (elidesRedundantSeparators) {
1635
+ val = shortenDimensionNumbers(val);
1630
1636
  // A property that punctuates its own components already had the whitespace
1631
1637
  // after its closing parentheses taken out, and only the separators its
1632
1638
  // grammar still needs put back, so those are the ones left alone here.
@@ -141,4 +141,41 @@ function elideRedundantSeparators (value, elidesAfterSelfEndingTokens = true) {
141
141
  }).join('');
142
142
  }
143
143
 
144
- export { elideRedundantSeparators };
144
+ /**
145
+ * Shortens decimal dimension and percentage tokens without changing their
146
+ * digits. Unitless numbers stay untouched because integer grammars reject
147
+ * scientific notation.
148
+ *
149
+ * @param {string} value The CSS value.
150
+ * @return {string} The value with shorter numeric spellings.
151
+ */
152
+ function shortenDimensionNumbers (value) {
153
+ return tokenizeCssValue(value).map((token) => {
154
+ if (token.type !== 'dimension' && token.type !== 'percentage') {
155
+ return token.text;
156
+ }
157
+ // Capture a decimal coefficient and its unit, excluding existing exponents.
158
+ const match = token.text.match(/^([+-]?)(\d*\.\d+|\d+)([%a-z]+)$/i);
159
+ if (!match) {
160
+ return token.text;
161
+ }
162
+ const [, sign, coefficient, unit] = match;
163
+ const decimalIndex = coefficient.indexOf('.');
164
+ const fractionalDigits = decimalIndex === -1 ? 0 : coefficient.length - decimalIndex - 1;
165
+ const digits = coefficient.replace('.', '');
166
+ // Leading zeros carry no magnitude; trailing zeros move into the exponent.
167
+ const significantDigits = digits.replace(/^0+/, '').replace(/0+$/, '');
168
+ if (!significantDigits) {
169
+ return token.text;
170
+ }
171
+ const trailingZeros = digits.length - digits.replace(/0+$/, '').length;
172
+ const exponent = trailingZeros - fractionalDigits;
173
+ const scientific = sign + significantDigits + 'e' + exponent + unit;
174
+ return scientific.length < token.text.length ? scientific : token.text;
175
+ }).join('');
176
+ }
177
+
178
+ export {
179
+ elideRedundantSeparators,
180
+ shortenDimensionNumbers
181
+ };