@thejaredwilcurt/csslop 0.0.34 → 0.0.36

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
@@ -290,7 +290,7 @@ Two different cases:
290
290
  1. `git add -A && git commit -m "Updated tests"`
291
291
  1. Then run `npm t` to see if any tests fail
292
292
  1. If they fail, give an AI this prompt:
293
- * **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. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)! When all done, run the `beep` command to alert me you finished.
293
+ * **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. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)! When all done, run the `beep` command to alert me you finished.
294
294
  1. Verify only code in the `src` folder was modified
295
295
  1. Verify `npm t` passes with a 100% score
296
296
  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.34",
5
+ "version": "0.0.36",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -31,12 +31,12 @@
31
31
  "@eslint/js": "^10.0.1",
32
32
  "@stylistic/eslint-plugin": "^5.10.0",
33
33
  "codemirror": "^6.0.2",
34
- "eslint": "^10.9.1",
34
+ "eslint": "^10.10.0",
35
35
  "eslint-config-tjw-base": "^5.0.0",
36
36
  "eslint-config-tjw-import-x": "^1.0.1",
37
37
  "eslint-config-tjw-jsdoc": "^2.0.1",
38
38
  "eslint-plugin-import-x": "^4.17.0",
39
- "eslint-plugin-jsdoc": "^64.3.5",
39
+ "eslint-plugin-jsdoc": "^64.3.6",
40
40
  "fflate": "^0.8.3",
41
41
  "globals": "^17.12.0",
42
42
  "pretty-ms": "^9.3.1",
@@ -199,93 +199,6 @@ function getOverridesOf (shorthandName) {
199
199
  return OVERRIDES_BY_SHORTHAND.get(shorthandName) || NO_PROPERTIES;
200
200
  }
201
201
 
202
- /**
203
- * The words a longhand adds to its shorthand's name to say which side, axis,
204
- * corner, or alignment dimension of the box that longhand applies to, such as
205
- * the `top` of `padding-top` or the `row` of `row-gap`.
206
- *
207
- * @type {Set<string>}
208
- */
209
- const BOX_PART_WORDS = new Set([
210
- 'align',
211
- 'block',
212
- 'bottom',
213
- 'column',
214
- 'end',
215
- 'inline',
216
- 'justify',
217
- 'left',
218
- 'right',
219
- 'row',
220
- 'start',
221
- 'top',
222
- 'x',
223
- 'y'
224
- ]);
225
-
226
- /**
227
- * Removes each of the shorthand's own words from a longhand's words, leaving
228
- * only the words the longhand adds to name the part of the box it covers. Each
229
- * shared word is removed once, so the `border` and the `radius` of
230
- * `border-radius` leave `top` and `left` behind in `border-top-left-radius`.
231
- *
232
- * @param {Array} longhandWords The hyphen-separated words of the longhand's name.
233
- * @param {Array} shorthandWords The hyphen-separated words of the shorthand's name.
234
- * @return {Array} The words the longhand adds on top of the shorthand's.
235
- */
236
- function subtractSharedWords (longhandWords, shorthandWords) {
237
- const remainingWords = [...longhandWords];
238
- for (const shorthandWord of shorthandWords) {
239
- const wordIndex = remainingWords.indexOf(shorthandWord);
240
- if (wordIndex !== -1) {
241
- remainingWords.splice(wordIndex, 1);
242
- }
243
- }
244
- return remainingWords;
245
- }
246
-
247
- /**
248
- * Whether each shorthand takes a positional list of components, computed on
249
- * first use, since the shorthand tables never change.
250
- *
251
- * @type {Map<string, boolean>}
252
- */
253
- const positionalComponentsByShorthand = new Map();
254
-
255
- /**
256
- * Reports whether a shorthand's value is a positional list of same-typed
257
- * components rather than an unordered set of components that its grammar tells
258
- * apart by type. A shorthand is positional when its longhands are the very same
259
- * property repeated for each part of the box, as `padding` repeats a length for
260
- * each side and `gap` repeats one for each axis. Nothing but the order the
261
- * components are written in says which part of the box each one lands on, so
262
- * the whitespace between them delimits the list. A shorthand such as `border`
263
- * or `font`, whose longhands each hold a different kind of value, is not
264
- * positional: its grammar reads each component by type, in any order.
265
- *
266
- * @param {string} shorthandName The CSS property name to test.
267
- * @return {boolean} Whether the shorthand's components are positional.
268
- */
269
- function hasPositionalComponents (shorthandName) {
270
- const cachedAnswer = positionalComponentsByShorthand.get(shorthandName);
271
- if (cachedAnswer !== undefined) {
272
- return cachedAnswer;
273
- }
274
- const longhands = shorthandMap[shorthandName];
275
- let isPositional = false;
276
- if (Array.isArray(longhands)) {
277
- const shorthandWords = shorthandName.split('-');
278
- isPositional = longhands.every((longhand) => {
279
- const addedWords = subtractSharedWords(longhand.split('-'), shorthandWords);
280
- return Boolean(addedWords.length) && addedWords.every((word) => {
281
- return BOX_PART_WORDS.has(word);
282
- });
283
- });
284
- }
285
- positionalComponentsByShorthand.set(shorthandName, isPositional);
286
- return isPositional;
287
- }
288
-
289
202
  /**
290
203
  * The leaf longhands each property ultimately sets, computed on first use. The
291
204
  * shorthand tables never change, so a property always expands the same way.
@@ -329,7 +242,6 @@ export {
329
242
  expandToLeafProperties,
330
243
  getLonghandsOf,
331
244
  getOverridesOf,
332
- hasPositionalComponents,
333
245
  shorthandMap,
334
246
  shorthandOverrideMap,
335
247
  UNIFORM_VALUE_SHORTHANDS
package/src/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  mergeSelectorRules,
37
37
  nestFlatRules,
38
38
  removeEmptyRules,
39
+ removeFullyOverriddenSelectors,
39
40
  removeOverriddenMultiSelectorProperties
40
41
  } from './rules/optimize.js';
41
42
  import {
@@ -263,7 +264,8 @@ export const minifyCSS = function (input) {
263
264
 
264
265
  const mergedRules = mergeSelectorRules(ast.stylesheet.rules);
265
266
  const overrideCleanedRules = removeOverriddenMultiSelectorProperties(mergedRules);
266
- const preCleanedRules = removeEmptyRules(overrideCleanedRules);
267
+ const selectorPrunedRules = removeFullyOverriddenSelectors(overrideCleanedRules);
268
+ const preCleanedRules = removeEmptyRules(selectorPrunedRules);
267
269
  const declarationMergedRules = mergeByDeclarations(preCleanedRules);
268
270
  const nestedRules = nestFlatRules(declarationMergedRules);
269
271
  const nonEmptyRules = removeEmptyRules(nestedRules);
@@ -68,6 +68,15 @@ function expandPureNestedRules (rules) {
68
68
  canExpand = false;
69
69
  break;
70
70
  }
71
+ // A child written as a comma-separated selector list, such as `.b,.c`,
72
+ // repeats the parent in front of every item when expanded (`.a .b,.a .c`),
73
+ // while the nested form states the parent once. Expanding is always
74
+ // longer, and leaving the rule nested keeps one pass from expanding what
75
+ // the next pass would then keep, so the output stays idempotent.
76
+ if (nestedRule.selectors.length > 1) {
77
+ canExpand = false;
78
+ break;
79
+ }
71
80
  const combinedSelectors = [];
72
81
  for (const parentSelector of rule.selectors) {
73
82
  for (const childSelector of nestedRule.selectors) {
@@ -1228,7 +1237,7 @@ function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPos
1228
1237
  }
1229
1238
 
1230
1239
  /**
1231
- * Merges rules with identical normalized selectors by combining their declarations, as long as `chooseMergePlacement` finds a spot for the combined rule that the cascade reads the same way. Non-rule entries (like `@media`) break the merge window.
1240
+ * Merges rules with identical normalized selectors by combining their declarations, as long as `chooseMergePlacement` finds a spot for the combined rule that the cascade reads the same way. Non-rule entries that affect the cascade (like `@media`) break the merge window, while comments do not, since comments never influence how declarations apply.
1232
1241
  *
1233
1242
  * @param {Array} rules The AST rule nodes to merge.
1234
1243
  * @return {Array} A new array of rules with same-selector rules combined.
@@ -1248,8 +1257,10 @@ function mergeSelectorRules (rules) {
1248
1257
  }
1249
1258
  if (rule.type !== 'rule') {
1250
1259
  slots.push(rule);
1251
- selectorMap.clear();
1252
- positionByRule.clear();
1260
+ if (rule.type !== 'comment') {
1261
+ selectorMap.clear();
1262
+ positionByRule.clear();
1263
+ }
1253
1264
  continue;
1254
1265
  }
1255
1266
  const selectorKey = buildSelectorKey(rule);
@@ -1400,6 +1411,161 @@ function isSelectorPropertyOverriddenLater (lastRuleIndexBySelector, startIndex,
1400
1411
  return lastRuleIndex !== undefined && lastRuleIndex > startIndex;
1401
1412
  }
1402
1413
 
1414
+ /**
1415
+ * Determines whether a declaration carries a trailing `!important` flag.
1416
+ *
1417
+ * @param {object} declaration The AST declaration node.
1418
+ * @return {boolean} True when the declaration is important.
1419
+ */
1420
+ function isImportantDeclaration (declaration) {
1421
+ // A trailing !important suffix on the raw declaration value
1422
+ return /!\s*important\s*$/i.test(declaration.rawValue || declaration.value || '');
1423
+ }
1424
+
1425
+ /**
1426
+ * Indexes, for every normalized selector in the stylesheet, the last rule that
1427
+ * covers each leaf property under it, where coverage follows the same "writes
1428
+ * to" model as `expandToOverridableProperties` (a later `border` declaration
1429
+ * on the same selector also overrides an earlier `border-color` on it), along
1430
+ * with whether that covering declaration is important. A last-covering entry
1431
+ * is consulted to answer "is every declaration this selector still gets from
1432
+ * the rule at position X re-declared later".
1433
+ *
1434
+ * @param {Array} rules The flat list of AST rule nodes.
1435
+ * @return {Map} Map of normalized selector to a map of covered leaf property name to `{ ruleIndex, important }`.
1436
+ */
1437
+ function indexFinalCoveringRuleBySelector (rules) {
1438
+ const coverageBySelector = new Map();
1439
+ rules.forEach((rule, ruleIndex) => {
1440
+ if (rule.type !== 'rule' || !rule.selectors?.length) {
1441
+ return;
1442
+ }
1443
+ const declarations = (rule.declarations || []).filter((declaration) => {
1444
+ return declaration.type === 'declaration' && declaration.property;
1445
+ });
1446
+ if (!declarations.length) {
1447
+ return;
1448
+ }
1449
+ for (const selector of rule.selectors) {
1450
+ const normalizedSelector = normalizeSelector(selector);
1451
+ let coverageByProperty = coverageBySelector.get(normalizedSelector);
1452
+ if (!coverageByProperty) {
1453
+ coverageByProperty = new Map();
1454
+ coverageBySelector.set(normalizedSelector, coverageByProperty);
1455
+ }
1456
+ for (const declaration of declarations) {
1457
+ const important = isImportantDeclaration(declaration);
1458
+ for (const leafProperty of expandToOverridableProperties(declaration.property)) {
1459
+ coverageByProperty.set(leafProperty, { important, ruleIndex });
1460
+ }
1461
+ }
1462
+ }
1463
+ });
1464
+ return coverageBySelector;
1465
+ }
1466
+
1467
+ /**
1468
+ * Determines whether a later rule overrides every leaf property an earlier
1469
+ * declaration writes to for the same selector, including via shorthand
1470
+ * coverage, with an important earlier write requiring an important later
1471
+ * write to beat it.
1472
+ *
1473
+ * @param {Map} coverageByProperty The leaf property coverage index for the selector.
1474
+ * @param {object} declaration The earlier declaration to test.
1475
+ * @param {number} ruleIndex The position of the rule holding the declaration.
1476
+ * @param {boolean} declarationIsImportant Whether the earlier write of this property is important.
1477
+ * @return {boolean} True when the declaration is conclusively overridden later.
1478
+ */
1479
+ function isDeclarationOverriddenLater (coverageByProperty, declaration, ruleIndex, declarationIsImportant) {
1480
+ for (const leafProperty of expandToOverridableProperties(declaration.property)) {
1481
+ const coverage = coverageByProperty.get(leafProperty);
1482
+ if (!coverage || coverage.ruleIndex <= ruleIndex) {
1483
+ return false;
1484
+ }
1485
+ // An important declaration outlives a later non-important one
1486
+ if (declarationIsImportant && !coverage.important) {
1487
+ return false;
1488
+ }
1489
+ }
1490
+ return true;
1491
+ }
1492
+
1493
+ /**
1494
+ * Removes a selector from a multi-selector rule's list when every declaration
1495
+ * the rule would still give that selector is overridden by a later rule that
1496
+ * contains the same selector. For example, if `h1,h2{color:red}` is followed
1497
+ * by `h2{color:tan}`, the `h2` entry contributes nothing and can be dropped,
1498
+ * leaving `h1{color:red}`. If the list empties out entirely, the rule is
1499
+ * stripped of declarations so `removeEmptyRules` discards it.
1500
+ *
1501
+ * @param {Array} rules The flat list of AST rule nodes.
1502
+ * @return {Array} The rules with fully overridden selectors removed from their lists.
1503
+ */
1504
+ function removeFullyOverriddenSelectors (rules) {
1505
+ // Pruning a selector only ever shrinks lists: a covering entry that wins
1506
+ // today is supplied by a later rule, and pruning earlier rules never moves
1507
+ // coverage forward, so a single index built up front stays accurate.
1508
+ const coverageBySelector = indexFinalCoveringRuleBySelector(rules);
1509
+
1510
+ for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
1511
+ const rule = rules[ruleIndex];
1512
+ if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
1513
+ continue;
1514
+ }
1515
+ const entries = rule.declarations || [];
1516
+ // Removing a selector would also un-scope nested rules, which may depend
1517
+ // on it, so only rules made purely of declarations qualify
1518
+ const hasNestedContent = entries.some((entry) => {
1519
+ return entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment';
1520
+ });
1521
+ if (hasNestedContent) {
1522
+ continue;
1523
+ }
1524
+ const declarations = entries.filter((entry) => {
1525
+ return entry.type === 'declaration' && entry.property;
1526
+ });
1527
+ if (!declarations.length) {
1528
+ continue;
1529
+ }
1530
+ // Within a rule, any important declaration of a property makes the rule's
1531
+ // write of that property important (a conservative stand-in for tracking
1532
+ // which of several same-property declarations survives inside the rule)
1533
+ const importantByProperty = new Map();
1534
+ for (const declaration of declarations) {
1535
+ if (isImportantDeclaration(declaration)) {
1536
+ importantByProperty.set(declaration.property, true);
1537
+ } else if (!importantByProperty.has(declaration.property)) {
1538
+ importantByProperty.set(declaration.property, false);
1539
+ }
1540
+ }
1541
+ const keptSelectors = rule.selectors.filter((selector) => {
1542
+ const coverageByProperty = coverageBySelector.get(normalizeSelector(selector));
1543
+ if (!coverageByProperty) {
1544
+ return true;
1545
+ }
1546
+ // Keep the selector while at least one declaration is not conclusively
1547
+ // overridden for it by a later rule
1548
+ return declarations.some((declaration) => {
1549
+ return !isDeclarationOverriddenLater(
1550
+ coverageByProperty,
1551
+ declaration,
1552
+ ruleIndex,
1553
+ importantByProperty.get(declaration.property)
1554
+ );
1555
+ });
1556
+ });
1557
+ if (keptSelectors.length !== rule.selectors.length) {
1558
+ if (!keptSelectors.length) {
1559
+ rule.declarations = entries.filter((entry) => {
1560
+ return entry.type !== 'declaration';
1561
+ });
1562
+ }
1563
+ rule.selectors = keptSelectors;
1564
+ }
1565
+ }
1566
+ return rules;
1567
+ }
1568
+
1403
1569
  /**
1404
1570
  * Removes properties from multi-selector rules when every selector in
1405
1571
  * the rule has that property overridden by a later rule. For example,
@@ -1465,5 +1631,6 @@ export {
1465
1631
  mergeSelectorRules,
1466
1632
  nestFlatRules,
1467
1633
  removeEmptyRules,
1634
+ removeFullyOverriddenSelectors,
1468
1635
  removeOverriddenMultiSelectorProperties
1469
1636
  };
@@ -30,9 +30,60 @@ function splitParametersByComma (parameterString) {
30
30
  return parameters;
31
31
  }
32
32
 
33
+ /**
34
+ * Advances past a quoted string in a selector, honoring backslash escapes so a
35
+ * quoted close-quote does not end the skip early.
36
+ *
37
+ * @param {string} text The selector string being scanned.
38
+ * @param {number} quoteIndex The index of the opening quote character.
39
+ * @return {number} The index right after the closing quote, or the end of the string if the quote never closes.
40
+ */
41
+ function skipQuotedText (text, quoteIndex) {
42
+ const quote = text[quoteIndex];
43
+ let index = quoteIndex + 1;
44
+ while (index < text.length) {
45
+ if (text[index] === '\\') {
46
+ index++;
47
+ } else if (text[index] === quote) {
48
+ return index + 1;
49
+ }
50
+ index++;
51
+ }
52
+ return text.length;
53
+ }
54
+
55
+ /**
56
+ * Advances past an attribute selector `[...]` in a selector, skipping quoted
57
+ * values and escaped characters so brackets inside them are not miscounted.
58
+ *
59
+ * @param {string} text The selector string being scanned.
60
+ * @param {number} openIndex The index of the `[` that opens the attribute selector.
61
+ * @return {number} The index right after the closing `]`, or the end of the string if it never closes.
62
+ */
63
+ function skipAttributeSelector (text, openIndex) {
64
+ let index = openIndex + 1;
65
+ while (index < text.length) {
66
+ const character = text[index];
67
+ if (character === '"' || character === '\'') {
68
+ index = skipQuotedText(text, index);
69
+ continue;
70
+ }
71
+ if (character === '\\') {
72
+ index += 2;
73
+ continue;
74
+ }
75
+ if (character === ']') {
76
+ return index + 1;
77
+ }
78
+ index++;
79
+ }
80
+ return text.length;
81
+ }
82
+
33
83
  /**
34
84
  * Finds the index of the closing parenthesis that matches the opening
35
- * parenthesis at the given position in the string.
85
+ * parenthesis at the given position in the string, skipping over quoted
86
+ * strings and attribute selectors so parentheses inside them are ignored.
36
87
  *
37
88
  * @param {string} text The string to search within.
38
89
  * @param {number} openIndex The index of the opening parenthesis.
@@ -40,15 +91,60 @@ function splitParametersByComma (parameterString) {
40
91
  */
41
92
  function findMatchingCloseParenthesis (text, openIndex) {
42
93
  let depth = 0;
43
- for (let index = openIndex; index < text.length; index++) {
44
- if (text[index] === '(') {
94
+ let index = openIndex;
95
+ while (index < text.length) {
96
+ const character = text[index];
97
+ if (character === '"' || character === '\'') {
98
+ index = skipQuotedText(text, index);
99
+ continue;
100
+ }
101
+ if (character === '[') {
102
+ index = skipAttributeSelector(text, index);
103
+ continue;
104
+ }
105
+ if (character === '(') {
45
106
  depth++;
46
- } else if (text[index] === ')') {
107
+ } else if (character === ')') {
47
108
  depth--;
48
109
  if (depth === 0) {
49
110
  return index;
50
111
  }
51
112
  }
113
+ index++;
114
+ }
115
+ return -1;
116
+ }
117
+
118
+ /**
119
+ * Finds the next occurrence of a pseudo-class function token (e.g. `:is(`)
120
+ * that sits at the top level of a selector, outside any quoted string or
121
+ * attribute selector.
122
+ *
123
+ * @param {string} text The selector string to scan.
124
+ * @param {string} functionCall The function token to find, including its opening parenthesis.
125
+ * @param {number} start The index to start scanning from.
126
+ * @return {number} The index of the next top-level occurrence, or -1 if none remains.
127
+ */
128
+ function findNextFunctionCallOutsideStrings (text, functionCall, start) {
129
+ let index = start;
130
+ while (index < text.length) {
131
+ const character = text[index];
132
+ if (character === '"' || character === '\'') {
133
+ index = skipQuotedText(text, index);
134
+ continue;
135
+ }
136
+ if (character === '[') {
137
+ index = skipAttributeSelector(text, index);
138
+ continue;
139
+ }
140
+ if (character === '\\') {
141
+ index += 2;
142
+ continue;
143
+ }
144
+ if (text.startsWith(functionCall, index)) {
145
+ return index;
146
+ }
147
+ index++;
52
148
  }
53
149
  return -1;
54
150
  }
@@ -174,42 +270,445 @@ function mergeAdjacentWherePseudoClasses (selector) {
174
270
  }
175
271
 
176
272
  /**
177
- * Matches a compound selector built exclusively from long-established simple
178
- * selectors: an optional type or universal selector, followed by any number of
179
- * id and class selectors. Anything else (pseudo-classes, pseudo-elements,
180
- * attribute matchers, combinators, descendant sequences) is excluded, because
181
- * those may be unrecognized by a browser and `:is()` forgiving parsing is what
182
- * keeps the remaining selectors in the rule alive.
273
+ * Pseudo-classes that every browser has recognized for many years. Wrapping
274
+ * one of these in `:is()` cannot protect a rule from a browser that would not
275
+ * know how to parse it, because every browser does. Keeping the list to
276
+ * long-established pseudo-classes means newer or vendor-specific ones continue
277
+ * to be treated as potentially unknown.
278
+ *
279
+ * @type {Set<string>}
280
+ */
281
+ const WELL_KNOWN_PSEUDO_CLASSES = new Set([
282
+ 'active',
283
+ 'any-link',
284
+ 'checked',
285
+ 'default',
286
+ 'dir',
287
+ 'disabled',
288
+ 'empty',
289
+ 'enabled',
290
+ 'first-child',
291
+ 'first-of-type',
292
+ 'focus',
293
+ 'focus-visible',
294
+ 'focus-within',
295
+ 'fullscreen',
296
+ 'hover',
297
+ 'in-range',
298
+ 'indeterminate',
299
+ 'invalid',
300
+ 'lang',
301
+ 'last-child',
302
+ 'last-of-type',
303
+ 'link',
304
+ 'not',
305
+ 'nth-child',
306
+ 'nth-last-child',
307
+ 'nth-last-of-type',
308
+ 'nth-of-type',
309
+ 'only-child',
310
+ 'only-of-type',
311
+ 'optional',
312
+ 'out-of-range',
313
+ 'placeholder-shown',
314
+ 'read-only',
315
+ 'read-write',
316
+ 'required',
317
+ 'root',
318
+ 'scope',
319
+ 'target',
320
+ 'valid',
321
+ 'visited'
322
+ ]);
323
+
324
+ /**
325
+ * The legacy pseudo-elements that browsers accept with a single colon. They
326
+ * are tracked because pseudo-elements inside `:is()` never match (the spec
327
+ * forbids them), so unwrapping such an `:is()` would resurrect a dead rule.
328
+ *
329
+ * @type {Set<string>}
330
+ */
331
+ const LEGACY_PSEUDO_ELEMENT_NAMES = new Set([
332
+ 'after',
333
+ 'before',
334
+ 'first-letter',
335
+ 'first-line'
336
+ ]);
337
+
338
+ /**
339
+ * Functional pseudo-classes whose argument is a selector list, such as
340
+ * `:not(.a)`. Their specificity comes from their argument rather than the
341
+ * pseudo-class itself, so a simple token count cannot describe them.
342
+ *
343
+ * @type {Set<string>}
344
+ */
345
+ const SELECTOR_ARGUMENT_PSEUDO_CLASSES = new Set([
346
+ 'is',
347
+ 'where',
348
+ 'has',
349
+ 'not',
350
+ 'matches',
351
+ '-webkit-any',
352
+ '-moz-any'
353
+ ]);
354
+
355
+ /**
356
+ * Matches a single character allowed inside a selector identifier (letters,
357
+ * digits, hyphens, underscores).
183
358
  *
184
359
  * @type {RegExp}
185
360
  */
186
- const BROWSER_SAFE_COMPOUND_SELECTOR = /^(?:\*|[a-zA-Z][a-zA-Z0-9_-]*)?(?:[#.][a-zA-Z_-][a-zA-Z0-9_-]*)*$/;
361
+ const IDENTIFIER_CHARACTER = /[a-zA-Z0-9_-]/;
187
362
 
188
363
  /**
189
- * Matches every id or class selector within a compound selector, used to count
190
- * each one's specificity contribution.
364
+ * Matches a single ASCII letter, which is how a type selector name begins.
191
365
  *
192
366
  * @type {RegExp}
193
367
  */
194
- const ID_OR_CLASS_SELECTOR = /[#.][a-zA-Z_-][a-zA-Z0-9_-]*/g;
368
+ const TYPE_SELECTOR_START = /[a-zA-Z]/;
369
+
370
+ /**
371
+ * Matches a single character that may start a pseudo-class or pseudo-element
372
+ * name (a letter, or a hyphen for vendor-prefixed names).
373
+ *
374
+ * @type {RegExp}
375
+ */
376
+ const PSEUDO_NAME_CHARACTER = /[a-zA-Z-]/;
377
+
378
+ /**
379
+ * Reads a pseudo-class or pseudo-element name starting at the given index and
380
+ * returns the index right after the final name character.
381
+ *
382
+ * @param {string} selector The selector string being scanned.
383
+ * @param {number} start The index of the name's first character.
384
+ * @return {number} The index immediately after the pseudo name.
385
+ */
386
+ function readPseudoNameEnd (selector, start) {
387
+ let index = start;
388
+ while (index < selector.length && PSEUDO_NAME_CHARACTER.test(selector[index])) {
389
+ index++;
390
+ }
391
+ return index;
392
+ }
393
+
394
+ /**
395
+ * Result of scanning one compound selector: how many simple selectors of each
396
+ * specificity tier it holds and whether a browser could fail to recognize any
397
+ * of them.
398
+ *
399
+ * @typedef {object} CompoundSelectorSummary
400
+ * @property {number} identifierCount Number of id selectors.
401
+ * @property {number} classLevelCount Number of class, attribute, and pseudo-class selectors.
402
+ * @property {number} typeLevelCount Number of type selectors and pseudo-elements.
403
+ * @property {boolean} hasPseudoElement True when a pseudo-element is present.
404
+ * @property {boolean} recognizable True when every simple selector is universally recognized.
405
+ */
406
+
407
+ /**
408
+ * Scans a compound selector (one without combinators) and summarizes its
409
+ * simple selectors for specificity comparison and recognizability checks.
410
+ * Returns null when the string is not a plain compound selector, since
411
+ * something else (a combinator, a stray character) was found inside.
412
+ *
413
+ * @param {string} selector The compound selector string to summarize.
414
+ * @return {CompoundSelectorSummary|null} The summary, or null for non-compound input.
415
+ */
416
+ function summarizeCompoundSelector (selector) {
417
+ if (!selector) {
418
+ return null;
419
+ }
420
+ const summary = {
421
+ identifierCount: 0,
422
+ classLevelCount: 0,
423
+ typeLevelCount: 0,
424
+ hasPseudoElement: false,
425
+ recognizable: true
426
+ };
427
+ let index = 0;
428
+ let tokenCount = 0;
429
+ while (index < selector.length) {
430
+ const character = selector[index];
431
+ if (tokenCount === 0 && (character === '*' || TYPE_SELECTOR_START.test(character))) {
432
+ // The first simple selector of a compound may be a type (`div`) or universal (`*`) selector
433
+ if (character !== '*') {
434
+ let end = index + 1;
435
+ while (end < selector.length && IDENTIFIER_CHARACTER.test(selector[end])) {
436
+ end++;
437
+ }
438
+ index = end;
439
+ summary.typeLevelCount++;
440
+ } else {
441
+ index++;
442
+ }
443
+ tokenCount++;
444
+ continue;
445
+ }
446
+ if (character === '#' || character === '.') {
447
+ // Id and class selectors are an identifier prefixed by '#' or '.'
448
+ const nameStart = index + 1;
449
+ if (nameStart >= selector.length || !IDENTIFIER_CHARACTER.test(selector[nameStart])) {
450
+ return null;
451
+ }
452
+ let end = nameStart + 1;
453
+ while (end < selector.length && IDENTIFIER_CHARACTER.test(selector[end])) {
454
+ end++;
455
+ }
456
+ index = end;
457
+ if (character === '#') {
458
+ summary.identifierCount++;
459
+ } else {
460
+ summary.classLevelCount++;
461
+ }
462
+ } else if (character === '[') {
463
+ // Attribute selector: well-formed input has a closing bracket
464
+ const closeBracketIndex = selector.indexOf(']', index + 1);
465
+ if (closeBracketIndex === -1) {
466
+ return null;
467
+ }
468
+ index = closeBracketIndex + 1;
469
+ summary.classLevelCount++;
470
+ } else if (character === ':') {
471
+ let nameStart = index + 1;
472
+ const isDoubleColon = selector[nameStart] === ':';
473
+ if (isDoubleColon) {
474
+ nameStart++;
475
+ }
476
+ const nameEnd = readPseudoNameEnd(selector, nameStart);
477
+ if (nameEnd === nameStart) {
478
+ return null;
479
+ }
480
+ const pseudoName = selector.slice(nameStart, nameEnd).toLowerCase();
481
+ index = nameEnd;
482
+ if (selector[index] === '(') {
483
+ // Functional pseudo-class: skip past its balanced argument parentheses
484
+ const closeParenthesisIndex = findMatchingCloseParenthesis(selector, index);
485
+ if (closeParenthesisIndex === -1) {
486
+ return null;
487
+ }
488
+ index = closeParenthesisIndex + 1;
489
+ }
490
+ if (isDoubleColon || LEGACY_PSEUDO_ELEMENT_NAMES.has(pseudoName)) {
491
+ summary.hasPseudoElement = true;
492
+ summary.recognizable = false;
493
+ summary.typeLevelCount++;
494
+ } else if (SELECTOR_ARGUMENT_PSEUDO_CLASSES.has(pseudoName)) {
495
+ // Selector-argument pseudo-classes take their specificity from their
496
+ // argument, so simple token counting cannot price them, and their
497
+ // argument may itself contain something unknown to older browsers.
498
+ summary.recognizable = false;
499
+ summary.classLevelCount++;
500
+ } else {
501
+ summary.classLevelCount++;
502
+ if (!WELL_KNOWN_PSEUDO_CLASSES.has(pseudoName)) {
503
+ summary.recognizable = false;
504
+ }
505
+ }
506
+ } else {
507
+ return null;
508
+ }
509
+ tokenCount++;
510
+ }
511
+ if (tokenCount === 0) {
512
+ return null;
513
+ }
514
+ return summary;
515
+ }
516
+
517
+ /**
518
+ * Splits a complex selector into the compound segments between its top-level
519
+ * combinators, treating anything inside parentheses or attribute brackets as
520
+ * part of the current segment.
521
+ *
522
+ * @param {string} selector The selector string to split.
523
+ * @return {Array} The compound selector segments, combinators excluded.
524
+ */
525
+ function splitCombinatorSegments (selector) {
526
+ const segments = [];
527
+ let currentSegment = '';
528
+ let depth = 0;
529
+ for (const character of selector) {
530
+ if (character === '(' || character === '[') {
531
+ depth++;
532
+ } else if (character === ')' || character === ']') {
533
+ depth--;
534
+ }
535
+ // A combinator at the top level ends the current compound segment;
536
+ // whitespace runs collapse into a single boundary
537
+ if (depth === 0 && (character === '>' || character === '+' || character === '~' || /\s/.test(character))) {
538
+ if (currentSegment) {
539
+ segments.push(currentSegment);
540
+ currentSegment = '';
541
+ }
542
+ continue;
543
+ }
544
+ currentSegment += character;
545
+ }
546
+ if (currentSegment) {
547
+ segments.push(currentSegment);
548
+ }
549
+ return segments;
550
+ }
551
+
552
+ /**
553
+ * Determines whether a selector is built entirely from simple selectors every
554
+ * browser recognizes. Combinators are fine; only the compound segments between
555
+ * them decide. Unknown pseudo-classes and pseudo-elements fail the check,
556
+ * because a browser that cannot parse a selector discards its whole rule,
557
+ * while `:is()` forgiving parsing would keep the rest of the rule alive.
558
+ *
559
+ * @param {string} selector A minified CSS selector string.
560
+ * @return {boolean} True when the selector is universally recognizable.
561
+ */
562
+ function isUniversallyRecognizableSelector (selector) {
563
+ const segments = splitCombinatorSegments(selector);
564
+ if (!segments.length) {
565
+ return false;
566
+ }
567
+ return segments.every((segment) => {
568
+ const summary = summarizeCompoundSelector(segment);
569
+ return summary !== null && summary.recognizable && !summary.hasPseudoElement;
570
+ });
571
+ }
572
+
573
+ /**
574
+ * Determines whether a selector contains a pseudo-element anywhere (double
575
+ * colon, or a legacy single-colon pseudo-element name). Pseudo-elements are
576
+ * invalid inside `:is()`, so an `:is()` holding one never matches, and
577
+ * unwrapping it would wrongly bring the selector to life.
578
+ *
579
+ * @param {string} selector The selector string to inspect.
580
+ * @return {boolean} True when a pseudo-element token is present.
581
+ */
582
+ function containsPseudoElement (selector) {
583
+ let index = 0;
584
+ while (index < selector.length) {
585
+ if (selector[index] !== ':') {
586
+ index++;
587
+ continue;
588
+ }
589
+ if (selector[index + 1] === ':') {
590
+ return true;
591
+ }
592
+ const nameEnd = readPseudoNameEnd(selector, index + 1);
593
+ const pseudoName = selector.slice(index + 1, nameEnd).toLowerCase();
594
+ if (LEGACY_PSEUDO_ELEMENT_NAMES.has(pseudoName)) {
595
+ return true;
596
+ }
597
+ index = nameEnd > index + 1 ? nameEnd : index + 1;
598
+ }
599
+ return false;
600
+ }
601
+
602
+ /**
603
+ * Determines whether a single selector contains a top-level combinator, which
604
+ * would make it a complex selector rather than a single compound selector.
605
+ *
606
+ * @param {string} selector The selector string to inspect.
607
+ * @return {boolean} True when a top-level combinator is present.
608
+ */
609
+ function hasTopLevelCombinator (selector) {
610
+ let depth = 0;
611
+ for (const character of selector) {
612
+ if (character === '(' || character === '[') {
613
+ depth++;
614
+ } else if (character === ')' || character === ']') {
615
+ depth--;
616
+ } else if (depth === 0 && (character === '>' || character === '+' || character === '~' || /\s/.test(character))) {
617
+ // A combinator or whitespace boundary at the top level joins two compounds
618
+ return true;
619
+ }
620
+ }
621
+ return false;
622
+ }
623
+
624
+ /**
625
+ * The characters that can start a simple selector mid-compound (id, class,
626
+ * attribute selector, pseudo-class) or the nesting selector. Type and
627
+ * universal selectors are excluded on purpose: they may only open a compound,
628
+ * so `div:is(a)` cannot flatten to `diva`.
629
+ *
630
+ * @type {Set<string>}
631
+ */
632
+ const SIMPLE_SELECTOR_STARTS = new Set(['#', '.', '[', ':', '&']);
195
633
 
196
634
  /**
197
- * Computes the specificity of a compound selector known to consist only of
198
- * type, universal, id, and class selectors, as an "ids,classes,types" key.
635
+ * Determines whether a character sits between two selector parts, meaning an
636
+ * `:is()` next to it is its own compound (or its own item in an argument
637
+ * list) rather than fused with neighboring simple selectors.
199
638
  *
200
- * @param {string} compoundSelector A browser-safe compound selector.
201
- * @return {string} The specificity key for equality comparison.
639
+ * @param {string} character The character to classify.
640
+ * @return {boolean} True when the character is a selector part boundary.
202
641
  */
203
- function getSimpleCompoundSpecificityKey (compoundSelector) {
204
- const idsAndClasses = compoundSelector.match(ID_OR_CLASS_SELECTOR) || [];
205
- const identifierCount = idsAndClasses.filter((selector) => {
206
- return selector.startsWith('#');
207
- }).length;
208
- const classCount = idsAndClasses.length - identifierCount;
209
- // Whatever precedes the first id/class is the type or universal selector, if any
210
- const typePortion = compoundSelector.split(/[#.]/)[0];
211
- const typeCount = typePortion && typePortion !== '*' ? 1 : 0;
212
- return identifierCount + ',' + classCount + ',' + typeCount;
642
+ function isSelectorPartBoundary (character) {
643
+ // Combinators, whitespace, parentheses, and comma all delimit selector parts
644
+ return /\s/.test(character) || character === '>' || character === '+' || character === '~' || character === '(' || character === ')' || character === ',';
645
+ }
646
+
647
+ /**
648
+ * Unwraps every single-argument `:is()` found within a selector, since such an
649
+ * `:is()` adds neither specificity (a one-argument `:is()` takes its
650
+ * argument's) nor matching behavior of its own. The unwrap is refused when it
651
+ * could change what a browser applies: an `:is()` fused to neighboring
652
+ * compound parts cannot release a complex argument (`div:is(a b)` cannot
653
+ * become `div a b`), and an argument a browser might not recognize must stay
654
+ * inside `:is()` whenever sibling selectors rely on its forgiving parsing.
655
+ *
656
+ * @param {string} selector A minified CSS selector string.
657
+ * @param {boolean} hasSiblingSelectors True when the rule's selector list holds other selectors besides this one.
658
+ * @return {string} The selector with redundant `:is()` wrappers removed.
659
+ */
660
+ function unwrapSingleArgumentIsFunctions (selector, hasSiblingSelectors) {
661
+ let result = selector;
662
+ let position = 0;
663
+ while (position < result.length) {
664
+ const isIndex = findNextFunctionCallOutsideStrings(result, ':is(', position);
665
+ if (isIndex === -1) {
666
+ break;
667
+ }
668
+ const openParenthesisIndex = isIndex + 3;
669
+ const closeParenthesisIndex = findMatchingCloseParenthesis(result, openParenthesisIndex);
670
+ if (closeParenthesisIndex === -1) {
671
+ break;
672
+ }
673
+ const content = result.slice(openParenthesisIndex + 1, closeParenthesisIndex);
674
+ const innerSelector = content.trim();
675
+ const parts = splitParametersByComma(content);
676
+ if (parts.length !== 1 || !innerSelector) {
677
+ position = closeParenthesisIndex + 1;
678
+ continue;
679
+ }
680
+ const characterBefore = isIndex > 0 ? result[isIndex - 1] : '';
681
+ const characterAfter = closeParenthesisIndex + 1 < result.length ? result[closeParenthesisIndex + 1] : '';
682
+ const fusedOnLeft = characterBefore !== '' && !isSelectorPartBoundary(characterBefore);
683
+ const fusedOnRight = characterAfter !== '' && !isSelectorPartBoundary(characterAfter);
684
+ if ((fusedOnLeft || fusedOnRight) && hasTopLevelCombinator(innerSelector)) {
685
+ position = closeParenthesisIndex + 1;
686
+ continue;
687
+ }
688
+ // A compound selector's simple selectors must not fuse into one another:
689
+ // dropped `:is()` text must still start with something that can continue a
690
+ // compound (`div:is(.a)` → `div.a`) and end before something that can
691
+ // continue one (`:is(.a):hover` → `.a:hover`), or tokens merge wrongly
692
+ // (`div:is(a)` cannot become `diva`)
693
+ if (fusedOnLeft && !SIMPLE_SELECTOR_STARTS.has(innerSelector[0])) {
694
+ position = closeParenthesisIndex + 1;
695
+ continue;
696
+ }
697
+ if (fusedOnRight && !SIMPLE_SELECTOR_STARTS.has(characterAfter)) {
698
+ position = closeParenthesisIndex + 1;
699
+ continue;
700
+ }
701
+ const canUnwrap = hasSiblingSelectors ?
702
+ isUniversallyRecognizableSelector(innerSelector) :
703
+ !containsPseudoElement(innerSelector);
704
+ if (!canUnwrap) {
705
+ position = closeParenthesisIndex + 1;
706
+ continue;
707
+ }
708
+ result = result.slice(0, isIndex) + innerSelector + result.slice(closeParenthesisIndex + 1);
709
+ // Stay at isIndex so a nested :is() exposed by this unwrap is considered next
710
+ }
711
+ return result;
213
712
  }
214
713
 
215
714
  /**
@@ -226,32 +725,45 @@ function canDecomposeIsSelector (parts) {
226
725
  if (parts.length < 2) {
227
726
  return false;
228
727
  }
229
- const allBrowserSafe = parts.every((part) => {
230
- return part !== '' && BROWSER_SAFE_COMPOUND_SELECTOR.test(part);
231
- });
232
- if (!allBrowserSafe) {
233
- return false;
728
+ const summaries = [];
729
+ for (const part of parts) {
730
+ const trimmedPart = part.trim();
731
+ if (!trimmedPart) {
732
+ return false;
733
+ }
734
+ const summary = summarizeCompoundSelector(trimmedPart);
735
+ if (!summary || !summary.recognizable || summary.hasPseudoElement) {
736
+ return false;
737
+ }
738
+ summaries.push(summary);
234
739
  }
235
- const specificityKeys = parts.map((part) => {
236
- return getSimpleCompoundSpecificityKey(part);
237
- });
238
- return specificityKeys.every((key) => {
239
- return key === specificityKeys[0];
740
+ const firstSummary = summaries[0];
741
+ return summaries.every((summary) => {
742
+ return (
743
+ summary.identifierCount === firstSummary.identifierCount &&
744
+ summary.classLevelCount === firstSummary.classLevelCount &&
745
+ summary.typeLevelCount === firstSummary.typeLevelCount
746
+ );
240
747
  });
241
748
  }
242
749
 
243
750
  /**
244
- * Processes a bare `:is()` selector by merging `:link`+`:visited` into `:any-link`,
245
- * de-duplicating, sorting alphabetically, and decomposing into individual selectors
246
- * when the remaining parts are browser-safe and share one level of specificity.
751
+ * Processes a selector by unwrapping redundant single-argument `:is()`
752
+ * functions within it, then for bare `:is()` selectors (where `:is()` is the
753
+ * entire selector) merging `:link`+`:visited` into `:any-link`,
754
+ * de-duplicating, sorting alphabetically, and decomposing into individual
755
+ * selectors when the remaining parts are browser-safe and share one level of
756
+ * specificity.
247
757
  *
248
- * @param {string} selector A minified CSS selector string.
249
- * @return {Array} An array of one or more processed selector strings.
758
+ * @param {string} selector A minified CSS selector string.
759
+ * @param {boolean} hasSiblingSelectors True when the rule's selector list holds other selectors besides this one.
760
+ * @return {Array} An array of one or more processed selector strings.
250
761
  */
251
- function processIsSelector (selector) {
762
+ function processIsSelector (selector, hasSiblingSelectors) {
252
763
  // Replace :is(:link,:visited) and :is(:visited,:link) with :any-link
253
764
  selector = selector.replace(/:is\(:link,:visited\)/g, ':any-link');
254
765
  selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
766
+ selector = unwrapSingleArgumentIsFunctions(selector, hasSiblingSelectors);
255
767
  // Only process bare :is() selectors (where :is() is the entire selector)
256
768
  if (!selector.startsWith(':is(')) {
257
769
  return [selector];
@@ -307,7 +819,11 @@ function processIsSelector (selector) {
307
819
  parts.sort();
308
820
  // Unwrap :is() with a single selector
309
821
  if (parts.length === 1) {
310
- return parts;
822
+ const onlyPart = parts[0].trim();
823
+ const canUnwrap = hasSiblingSelectors ?
824
+ isUniversallyRecognizableSelector(onlyPart) :
825
+ !containsPseudoElement(onlyPart);
826
+ return canUnwrap ? [onlyPart] : [':is(' + parts[0] + ')'];
311
827
  }
312
828
  // Drop the :is() wrapper when the parts are equivalent as a plain selector list
313
829
  if (canDecomposeIsSelector(parts)) {
@@ -262,7 +262,10 @@ function stringifyRule (rule, context) {
262
262
  if (isNestingParent) {
263
263
  uniqueSelectors = uniqueSelectors.flatMap(flattenNestingParentIsSelector);
264
264
  }
265
- uniqueSelectors = uniqueSelectors.flatMap(processIsSelector);
265
+ const hasSiblingSelectors = uniqueSelectors.length > 1;
266
+ uniqueSelectors = uniqueSelectors.flatMap((selector) => {
267
+ return processIsSelector(selector, hasSiblingSelectors);
268
+ });
266
269
  uniqueSelectors = [...new Set(uniqueSelectors)];
267
270
  output.push(uniqueSelectors.join(','));
268
271
  }
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import { isUnicodeCharset } from '../context.js';
6
- import { hasPositionalComponents } from '../declarations/config.js';
7
6
  import { resolveUnicodeEscape } from '../utilities.js';
8
7
 
9
8
  import { evaluateColorMix } from './color-mix.js';
@@ -261,8 +260,12 @@ function restoreSpaceBeforeMathOperators (value) {
261
260
  function formatUrlPath (path) {
262
261
  // Parentheses and quote characters are invalid inside an unquoted url() token
263
262
  const hasQuoteForcingCharacters = /[()"']/.test(path);
264
- // Count spaces so escaping them can be compared against keeping the quotes
265
- const spaceCount = (path.match(/ /g) || []).length;
263
+ // An escaped space is already the shortest representation of a space, so it
264
+ // is counted apart from unescaped ones to keep from being escaped twice
265
+ const escapedSpaceCount = (path.match(/\\ /g) || []).length;
266
+ // A space only needs escaping when no escaping backslash precedes it
267
+ const unescapedSpaceCount = (path.match(/(?<!\\) /g) || []).length;
268
+ const spaceCount = escapedSpaceCount + unescapedSpaceCount;
266
269
 
267
270
  if (hasQuoteForcingCharacters || spaceCount >= 2) {
268
271
  // Escape any embedded double quotes so the double-quoted wrapper stays valid
@@ -270,8 +273,12 @@ function formatUrlPath (path) {
270
273
  }
271
274
 
272
275
  if (spaceCount === 1) {
273
- // A lone space is one byte shorter to escape than to wrap the value in quotes
274
- return path.replace(/ /g, '\\ ');
276
+ // A lone space is one byte shorter to escape than to wrap the value in
277
+ // quotes, and one that arrived already escaped stays exactly as it is
278
+ if (!unescapedSpaceCount) {
279
+ return path;
280
+ }
281
+ return path.replace(/(?<!\\) /g, '\\ ');
275
282
  }
276
283
 
277
284
  return path;
@@ -976,12 +983,11 @@ function reorderBorderWidthBeforeStyle (value) {
976
983
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
977
984
  * background, display, scale, border-radius, shorthand collapsing, etc.).
978
985
  *
979
- * @param {string} val The CSS value string after generic minification.
980
- * @param {string} property The CSS property name.
981
- * @param {boolean} allowsSeparatorElision Whether redundant separator whitespace may be removed.
982
- * @return {string} The value with property-specific optimizations applied.
986
+ * @param {string} val The CSS value string after generic minification.
987
+ * @param {string} property The CSS property name.
988
+ * @return {string} The value with property-specific optimizations applied.
983
989
  */
984
- function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
990
+ function applyPropertyOptimizations (val, property) {
985
991
  if (property === 'font-weight' && isUnicodeCharset()) {
986
992
  // Replace font-weight keyword "bold" with its numeric equivalent
987
993
  val = val.replace(/\bbold\b/gi, '700');
@@ -1023,8 +1029,11 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
1023
1029
  val = val.replace(/\s+0px/g, ' ');
1024
1030
  // Remove leading zero-pixel value
1025
1031
  val = val.replace(/^0px\s*/, '');
1026
- // Remove trailing zero
1027
- val = val.replace(/\s+0$/, '');
1032
+ // Remove an explicit zero basis: a trailing zero is only a basis when grow
1033
+ // and shrink precede it, and the shorthand already reads an unwritten basis
1034
+ // as 0. In `0 0` the trailing zero is the shrink, which the shorthand
1035
+ // defaults to 1 instead, so dropping it would size the element differently.
1036
+ val = val.replace(/^(\S+\s+\S+)\s+0$/, '$1');
1028
1037
  // Remove standalone zero-pixel value
1029
1038
  val = val.replace(/^0px$/, '');
1030
1039
  val = val.trim();
@@ -1141,7 +1150,7 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
1141
1150
 
1142
1151
  // Shorten all color tokens (second pass after property-specific color evaluations)
1143
1152
  val = replaceOutsideStringsAndUrls(val, (segment) => {
1144
- return shortenColorValues(segment, allowsSeparatorElision);
1153
+ return shortenColorValues(segment);
1145
1154
  });
1146
1155
 
1147
1156
  if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
@@ -1243,21 +1252,6 @@ function applyPropertyOptimizations (val, property, allowsSeparatorElision) {
1243
1252
  return val;
1244
1253
  }
1245
1254
 
1246
- /**
1247
- * Reports whether a declaration holds a shorthand that was assembled by joining
1248
- * already-minified longhand values with a separator, and whose grammar reads
1249
- * those components by their position in the list. Nothing but that separator
1250
- * says where one component ends and the next begins, so it is kept even where
1251
- * the two components happen to be tokens that would survive being written
1252
- * together.
1253
- *
1254
- * @param {object} declaration The CSS declaration object with property and value fields.
1255
- * @return {boolean} Whether the assembled components keep their separators.
1256
- */
1257
- function keepsAssembledComponentSeparators (declaration) {
1258
- return Boolean(declaration.isAssembledShorthand) && hasPositionalComponents(declaration.property);
1259
- }
1260
-
1261
1255
  /**
1262
1256
  * Minifies a CSS declaration's value by applying color conversion, math simplification, shorthand compression, gradient optimization, and other property-specific optimizations.
1263
1257
  *
@@ -1275,7 +1269,6 @@ function computeMinifiedValue (declaration) {
1275
1269
  return 'none';
1276
1270
  }
1277
1271
  let val = declaration.value;
1278
- const allowsSeparatorElision = !keepsAssembledComponentSeparators(declaration);
1279
1272
 
1280
1273
  if (typeof val === 'string') {
1281
1274
  val = val.trim();
@@ -1323,19 +1316,16 @@ function computeMinifiedValue (declaration) {
1323
1316
  // Convert color functions to hex equivalents
1324
1317
  val = convertColorsToHex(val);
1325
1318
 
1326
- // Shorten all color tokens (hex and named) to their shortest representation.
1327
- // A value that keeps the whitespace between its components saves nothing by
1328
- // switching to a spelling of the same length, so its colors keep the
1329
- // spelling they were written with.
1319
+ // Shorten all color tokens (hex and named) to their shortest representation
1330
1320
  val = replaceOutsideStringsAndUrls(val, (segment) => {
1331
- return shortenColorValues(segment, allowsSeparatorElision);
1321
+ return shortenColorValues(segment);
1332
1322
  });
1333
1323
 
1334
1324
  // Collapse light-dark() when both normalized branches are identical
1335
1325
  val = simplifyEquivalentLightDarkFunctions(val);
1336
1326
 
1337
1327
  // Property-specific optimizations
1338
- val = applyPropertyOptimizations(val, declaration.property, allowsSeparatorElision);
1328
+ val = applyPropertyOptimizations(val, declaration.property);
1339
1329
 
1340
1330
  // Minify relative color syntax (identity resolution and whitespace collapsing)
1341
1331
  val = minifyRelativeColorSyntax(val);
@@ -1356,7 +1346,6 @@ function computeMinifiedValue (declaration) {
1356
1346
  // so the ones that turned out to be redundant are only dropped at the end.
1357
1347
  const elidesRedundantSeparators = (
1358
1348
  typeof val === 'string' &&
1359
- allowsSeparatorElision &&
1360
1349
  !isCustomProperty(declaration.property)
1361
1350
  );
1362
1351
  if (elidesRedundantSeparators) {