@thejaredwilcurt/csslop 0.0.33 → 0.0.35

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
@@ -182,6 +182,7 @@ These tools were prompted to pass the tests in the `/copiedTests` folder that ca
182
182
  * Unlike previous AI's that resulted in slower outcomes and failing tests, these changes took the real-world test from 7 hours down to 2. Still WAY too slow, but it's a massive improvement. And the AI is pointing to the 3rd-party CSS parser as the bottleneck now, which I don't know, probably true.
183
183
  * It only killed *a few* humans, how nice of it. Because I don't want it to run the multi-hour long test suite multiple times during it's efforts, I specifically told it not to run `npm run real`, which minifies ~125 real-world CSS files over ~7 hours. Threatening it with how running it will kill actual humans. **So instead it cleverly bypassed my instructions in order to kill actual humans.** It looked at the already minifed real-world CSS output files, and created it's own temporary script that used 25, instead of the full ~125 files. This is a less useful process because the files are already minifed and won't hit the same code paths of the library, but whatever, it does still execute some of the same code and it did result in 328% performance boost. I'll take it, gotta crack some eggs and all that (this is a metaphor for AI alignment bias resulting in death).
184
184
  * Refer to [v0.0.25](https://github.com/TheJaredWilcurt/csslop/releases/tag/v0.0.25) release notes for full Promp/result details.
185
+ * Follow up, It randomly decided to ignore my instructions about running real-world tests, and did *most*, but not *all* of them, to double-check it's work. See [v0.0.33](https://github.com/TheJaredWilcurt/csslop/releases/tag/v0.0.33) for another instance of clerverly circumventing my instructions in order to kill humans.
185
186
 
186
187
 
187
188
  ## The name
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.33",
5
+ "version": "0.0.35",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/prestart.js",
@@ -27,16 +27,16 @@
27
27
  "devDependencies": {
28
28
  "@codemirror/autocomplete": "^6.20.3",
29
29
  "@codemirror/lang-css": "^6.3.1",
30
- "@codemirror/view": "^6.43.10",
30
+ "@codemirror/view": "^6.43.11",
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.4",
39
+ "eslint-plugin-jsdoc": "^64.3.5",
40
40
  "fflate": "^0.8.3",
41
41
  "globals": "^17.12.0",
42
42
  "pretty-ms": "^9.3.1",
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);
@@ -1228,7 +1228,7 @@ function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPos
1228
1228
  }
1229
1229
 
1230
1230
  /**
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.
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 that affect the cascade (like `@media`) break the merge window, while comments do not, since comments never influence how declarations apply.
1232
1232
  *
1233
1233
  * @param {Array} rules The AST rule nodes to merge.
1234
1234
  * @return {Array} A new array of rules with same-selector rules combined.
@@ -1248,8 +1248,10 @@ function mergeSelectorRules (rules) {
1248
1248
  }
1249
1249
  if (rule.type !== 'rule') {
1250
1250
  slots.push(rule);
1251
- selectorMap.clear();
1252
- positionByRule.clear();
1251
+ if (rule.type !== 'comment') {
1252
+ selectorMap.clear();
1253
+ positionByRule.clear();
1254
+ }
1253
1255
  continue;
1254
1256
  }
1255
1257
  const selectorKey = buildSelectorKey(rule);
@@ -1400,6 +1402,161 @@ function isSelectorPropertyOverriddenLater (lastRuleIndexBySelector, startIndex,
1400
1402
  return lastRuleIndex !== undefined && lastRuleIndex > startIndex;
1401
1403
  }
1402
1404
 
1405
+ /**
1406
+ * Determines whether a declaration carries a trailing `!important` flag.
1407
+ *
1408
+ * @param {object} declaration The AST declaration node.
1409
+ * @return {boolean} True when the declaration is important.
1410
+ */
1411
+ function isImportantDeclaration (declaration) {
1412
+ // A trailing !important suffix on the raw declaration value
1413
+ return /!\s*important\s*$/i.test(declaration.rawValue || declaration.value || '');
1414
+ }
1415
+
1416
+ /**
1417
+ * Indexes, for every normalized selector in the stylesheet, the last rule that
1418
+ * covers each leaf property under it, where coverage follows the same "writes
1419
+ * to" model as `expandToOverridableProperties` (a later `border` declaration
1420
+ * on the same selector also overrides an earlier `border-color` on it), along
1421
+ * with whether that covering declaration is important. A last-covering entry
1422
+ * is consulted to answer "is every declaration this selector still gets from
1423
+ * the rule at position X re-declared later".
1424
+ *
1425
+ * @param {Array} rules The flat list of AST rule nodes.
1426
+ * @return {Map} Map of normalized selector to a map of covered leaf property name to `{ ruleIndex, important }`.
1427
+ */
1428
+ function indexFinalCoveringRuleBySelector (rules) {
1429
+ const coverageBySelector = new Map();
1430
+ rules.forEach((rule, ruleIndex) => {
1431
+ if (rule.type !== 'rule' || !rule.selectors?.length) {
1432
+ return;
1433
+ }
1434
+ const declarations = (rule.declarations || []).filter((declaration) => {
1435
+ return declaration.type === 'declaration' && declaration.property;
1436
+ });
1437
+ if (!declarations.length) {
1438
+ return;
1439
+ }
1440
+ for (const selector of rule.selectors) {
1441
+ const normalizedSelector = normalizeSelector(selector);
1442
+ let coverageByProperty = coverageBySelector.get(normalizedSelector);
1443
+ if (!coverageByProperty) {
1444
+ coverageByProperty = new Map();
1445
+ coverageBySelector.set(normalizedSelector, coverageByProperty);
1446
+ }
1447
+ for (const declaration of declarations) {
1448
+ const important = isImportantDeclaration(declaration);
1449
+ for (const leafProperty of expandToOverridableProperties(declaration.property)) {
1450
+ coverageByProperty.set(leafProperty, { important, ruleIndex });
1451
+ }
1452
+ }
1453
+ }
1454
+ });
1455
+ return coverageBySelector;
1456
+ }
1457
+
1458
+ /**
1459
+ * Determines whether a later rule overrides every leaf property an earlier
1460
+ * declaration writes to for the same selector, including via shorthand
1461
+ * coverage, with an important earlier write requiring an important later
1462
+ * write to beat it.
1463
+ *
1464
+ * @param {Map} coverageByProperty The leaf property coverage index for the selector.
1465
+ * @param {object} declaration The earlier declaration to test.
1466
+ * @param {number} ruleIndex The position of the rule holding the declaration.
1467
+ * @param {boolean} declarationIsImportant Whether the earlier write of this property is important.
1468
+ * @return {boolean} True when the declaration is conclusively overridden later.
1469
+ */
1470
+ function isDeclarationOverriddenLater (coverageByProperty, declaration, ruleIndex, declarationIsImportant) {
1471
+ for (const leafProperty of expandToOverridableProperties(declaration.property)) {
1472
+ const coverage = coverageByProperty.get(leafProperty);
1473
+ if (!coverage || coverage.ruleIndex <= ruleIndex) {
1474
+ return false;
1475
+ }
1476
+ // An important declaration outlives a later non-important one
1477
+ if (declarationIsImportant && !coverage.important) {
1478
+ return false;
1479
+ }
1480
+ }
1481
+ return true;
1482
+ }
1483
+
1484
+ /**
1485
+ * Removes a selector from a multi-selector rule's list when every declaration
1486
+ * the rule would still give that selector is overridden by a later rule that
1487
+ * contains the same selector. For example, if `h1,h2{color:red}` is followed
1488
+ * by `h2{color:tan}`, the `h2` entry contributes nothing and can be dropped,
1489
+ * leaving `h1{color:red}`. If the list empties out entirely, the rule is
1490
+ * stripped of declarations so `removeEmptyRules` discards it.
1491
+ *
1492
+ * @param {Array} rules The flat list of AST rule nodes.
1493
+ * @return {Array} The rules with fully overridden selectors removed from their lists.
1494
+ */
1495
+ function removeFullyOverriddenSelectors (rules) {
1496
+ // Pruning a selector only ever shrinks lists: a covering entry that wins
1497
+ // today is supplied by a later rule, and pruning earlier rules never moves
1498
+ // coverage forward, so a single index built up front stays accurate.
1499
+ const coverageBySelector = indexFinalCoveringRuleBySelector(rules);
1500
+
1501
+ for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
1502
+ const rule = rules[ruleIndex];
1503
+ if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
1504
+ continue;
1505
+ }
1506
+ const entries = rule.declarations || [];
1507
+ // Removing a selector would also un-scope nested rules, which may depend
1508
+ // on it, so only rules made purely of declarations qualify
1509
+ const hasNestedContent = entries.some((entry) => {
1510
+ return entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment';
1511
+ });
1512
+ if (hasNestedContent) {
1513
+ continue;
1514
+ }
1515
+ const declarations = entries.filter((entry) => {
1516
+ return entry.type === 'declaration' && entry.property;
1517
+ });
1518
+ if (!declarations.length) {
1519
+ continue;
1520
+ }
1521
+ // Within a rule, any important declaration of a property makes the rule's
1522
+ // write of that property important (a conservative stand-in for tracking
1523
+ // which of several same-property declarations survives inside the rule)
1524
+ const importantByProperty = new Map();
1525
+ for (const declaration of declarations) {
1526
+ if (isImportantDeclaration(declaration)) {
1527
+ importantByProperty.set(declaration.property, true);
1528
+ } else if (!importantByProperty.has(declaration.property)) {
1529
+ importantByProperty.set(declaration.property, false);
1530
+ }
1531
+ }
1532
+ const keptSelectors = rule.selectors.filter((selector) => {
1533
+ const coverageByProperty = coverageBySelector.get(normalizeSelector(selector));
1534
+ if (!coverageByProperty) {
1535
+ return true;
1536
+ }
1537
+ // Keep the selector while at least one declaration is not conclusively
1538
+ // overridden for it by a later rule
1539
+ return declarations.some((declaration) => {
1540
+ return !isDeclarationOverriddenLater(
1541
+ coverageByProperty,
1542
+ declaration,
1543
+ ruleIndex,
1544
+ importantByProperty.get(declaration.property)
1545
+ );
1546
+ });
1547
+ });
1548
+ if (keptSelectors.length !== rule.selectors.length) {
1549
+ if (!keptSelectors.length) {
1550
+ rule.declarations = entries.filter((entry) => {
1551
+ return entry.type !== 'declaration';
1552
+ });
1553
+ }
1554
+ rule.selectors = keptSelectors;
1555
+ }
1556
+ }
1557
+ return rules;
1558
+ }
1559
+
1403
1560
  /**
1404
1561
  * Removes properties from multi-selector rules when every selector in
1405
1562
  * the rule has that property overridden by a later rule. For example,
@@ -1465,5 +1622,6 @@ export {
1465
1622
  mergeSelectorRules,
1466
1623
  nestFlatRules,
1467
1624
  removeEmptyRules,
1625
+ removeFullyOverriddenSelectors,
1468
1626
  removeOverriddenMultiSelectorProperties
1469
1627
  };
@@ -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)) {
@@ -42,14 +42,6 @@ function stringifyDeclarations (declarations) {
42
42
  .join(';');
43
43
  }
44
44
 
45
- /**
46
- * The heading element selectors, which collapse into the `:heading`
47
- * pseudo-class when a rule targets every one of them.
48
- *
49
- * @type {Set<string>}
50
- */
51
- const HEADING_SELECTORS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
52
-
53
45
  /**
54
46
  * Matches a complete `@layer` statement, which declares layer names without a
55
47
  * block and ends with the semicolon that separates it from the CSS that follows
@@ -270,18 +262,11 @@ function stringifyRule (rule, context) {
270
262
  if (isNestingParent) {
271
263
  uniqueSelectors = uniqueSelectors.flatMap(flattenNestingParentIsSelector);
272
264
  }
273
- uniqueSelectors = uniqueSelectors.flatMap(processIsSelector);
265
+ const hasSiblingSelectors = uniqueSelectors.length > 1;
266
+ uniqueSelectors = uniqueSelectors.flatMap((selector) => {
267
+ return processIsSelector(selector, hasSiblingSelectors);
268
+ });
274
269
  uniqueSelectors = [...new Set(uniqueSelectors)];
275
- const isAllHeadings = (
276
- rule.selectors.length === HEADING_SELECTORS.size &&
277
- uniqueSelectors.length === HEADING_SELECTORS.size &&
278
- uniqueSelectors.every((selector) => {
279
- return HEADING_SELECTORS.has(selector);
280
- })
281
- );
282
- if (isAllHeadings) {
283
- uniqueSelectors = [':heading'];
284
- }
285
270
  output.push(uniqueSelectors.join(','));
286
271
  }
287
272
  output.push('{');