@thejaredwilcurt/csslop 0.0.48 → 0.0.49

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.
@@ -12,7 +12,6 @@ import {
12
12
  splitTopLevelCommaList
13
13
  } from '../parser/source-search.js';
14
14
  import { tokenizeCssValue } from '../parser/tokenizer.js';
15
- import { escapeRegexString } from '../utilities.js';
16
15
 
17
16
  import {
18
17
  normalizeLayerNames,
@@ -247,35 +246,57 @@ function expandPureNestedRules (inputRules) {
247
246
  return result;
248
247
  }
249
248
 
249
+ /**
250
+ * Matches the part of a child selector that follows its parent when that part
251
+ * starts with an explicit combinator. Optional whitespace, then the combinator
252
+ * (`>`, `+`, or `~`) is captured, then optional whitespace, then the rest of
253
+ * the selector is captured.
254
+ *
255
+ * @type {RegExp}
256
+ */
257
+ const LEADING_COMBINATOR_PATTERN = /^\s*([>+~])\s*(.+)$/;
258
+
259
+ /**
260
+ * Expresses the part of a child selector that follows its parent as the
261
+ * selector it would be once nested inside that parent.
262
+ *
263
+ * @param {string} remainder The child selector with the parent prefix removed.
264
+ * @return {string|null} The nested selector, or null when the remainder cannot be nested.
265
+ */
266
+ function nestSelectorRemainder (remainder) {
267
+ if (!remainder) {
268
+ return '&';
269
+ }
270
+ if (remainder.startsWith(':')) {
271
+ return '&' + remainder;
272
+ }
273
+ if (remainder.startsWith(' ')) {
274
+ return remainder.slice(1);
275
+ }
276
+ const combinatorMatch = remainder.match(LEADING_COMBINATOR_PATTERN);
277
+ if (combinatorMatch) {
278
+ return combinatorMatch[1] + combinatorMatch[2];
279
+ }
280
+ return null;
281
+ }
282
+
250
283
  /**
251
284
  * Builds a reusable matcher that expresses child selectors as nested selectors
252
- * relative to one parent. The combinator pattern is compiled once per parent so
253
- * that testing many children against the same parent does not rebuild it.
285
+ * relative to one parent. Most children tested against a parent do not start
286
+ * with it at all, so that cheap prefix check runs before anything else.
254
287
  *
255
288
  * @param {string} parentSelector The parent selector string.
256
289
  * @return {function(string): (string|null)} A matcher returning the nested selector, or null when the child cannot be nested.
257
290
  */
258
291
  function createNestedSelectorMatcher (parentSelector) {
259
292
  const parent = parentSelector.trim();
260
- // Match a child selector that starts with the parent followed by a combinator (>, +, ~)
261
- const combinatorPattern = new RegExp('^' + escapeRegexString(parent) + '\\s*([>+~])\\s*(.+)$');
262
293
 
263
294
  return (childSelector) => {
264
295
  const child = childSelector.trim();
265
- if (child === parent) {
266
- return '&';
267
- }
268
- if (child.startsWith(parent + ':') || child.startsWith(parent + '::')) {
269
- return '&' + child.slice(parent.length);
270
- }
271
- if (child.startsWith(parent + ' ')) {
272
- return child.slice(parent.length + 1);
273
- }
274
- const combinatorMatch = child.match(combinatorPattern);
275
- if (combinatorMatch) {
276
- return combinatorMatch[1] + combinatorMatch[2];
296
+ if (!child.startsWith(parent)) {
297
+ return null;
277
298
  }
278
- return null;
299
+ return nestSelectorRemainder(child.slice(parent.length));
279
300
  };
280
301
  }
281
302
 
@@ -457,118 +478,249 @@ function nestFlatRules (rules) {
457
478
  }
458
479
 
459
480
  /**
460
- * Extracts the leading compound selector of a complex selector: everything up to
461
- * the first top-level combinator (descendant whitespace, `>`, `+`, or `~`).
462
- * Combinator characters nested inside `()` or `[]` (such as `:nth-child(2n+1)` or
463
- * `[a~=b]`) are ignored so only structural combinators split the selector.
481
+ * Splits a selector into the part before its final top-level combinator and
482
+ * the trailing compound that follows it, so selectors that only differ in
483
+ * that leading part can share the trailing compound through nesting.
464
484
  *
465
- * @param {string} selector The selector string to inspect.
466
- * @return {string|null} The leading compound selector, or null when the selector has no descendant part to factor out.
485
+ * @param {string} selector The selector string to split.
486
+ * @return {object|null} The `head`, `combinator` (`' '` for descendant), and `tail`, or null when the selector has no top-level combinator.
467
487
  */
468
- function extractLeadingCompound (selector) {
488
+ function splitSelectorTail (selector) {
469
489
  const trimmed = selector.trim();
470
490
  let bracketDepth = 0;
491
+ let parenthesisDepth = 0;
492
+ let combinatorStart = -1;
493
+ let combinator = '';
494
+ let tailStart = -1;
471
495
  for (let index = 0; index < trimmed.length; index++) {
472
496
  const character = trimmed[index];
473
- if (character === '(' || character === '[') {
497
+ if (character === '(') {
498
+ parenthesisDepth++;
499
+ } else if (character === ')') {
500
+ parenthesisDepth--;
501
+ } else if (character === '[') {
474
502
  bracketDepth++;
475
- } else if (character === ')' || character === ']') {
503
+ } else if (character === ']') {
476
504
  bracketDepth--;
477
- } else if (
478
- bracketDepth === 0 &&
479
- (character === ' ' || character === '>' || character === '+' || character === '~')
480
- ) {
481
- const compound = trimmed.slice(0, index).trim();
482
- return compound.length ? compound : null;
505
+ } else if (parenthesisDepth === 0 && bracketDepth === 0) {
506
+ if (character === '>' || character === '+' || character === '~') {
507
+ // Whitespace written after an explicit combinator belongs to it
508
+ let end = index + 1;
509
+ while (end < trimmed.length && isSelectorWhitespace(trimmed[end])) {
510
+ end++;
511
+ }
512
+ combinator = character;
513
+ combinatorStart = index;
514
+ tailStart = end;
515
+ index = end - 1;
516
+ } else if (isSelectorWhitespace(character)) {
517
+ // Whitespace adjacent to an explicit combinator belongs to that
518
+ // combinator, so only a self-standing run is a descendant combinator
519
+ const previous = trimmed[index - 1];
520
+ let end = index;
521
+ while (end < trimmed.length && isSelectorWhitespace(trimmed[end])) {
522
+ end++;
523
+ }
524
+ const next = trimmed[end];
525
+ if (
526
+ next &&
527
+ next !== '>' && next !== '+' && next !== '~' &&
528
+ previous !== '>' && previous !== '+' && previous !== '~'
529
+ ) {
530
+ combinator = ' ';
531
+ combinatorStart = index;
532
+ tailStart = end;
533
+ }
534
+ index = end - 1;
535
+ }
483
536
  }
484
537
  }
485
- return null;
538
+ if (!combinator || combinatorStart === 0) {
539
+ return null;
540
+ }
541
+ const head = trimmed.slice(0, combinatorStart).trim();
542
+ const tail = trimmed.slice(tailStart).trim();
543
+ if (!head || !tail) {
544
+ return null;
545
+ }
546
+ return {
547
+ combinator,
548
+ head,
549
+ tail
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Rewrites a multi-selector rule whose selectors only differ before their
555
+ * final combinator into a wrapper rule that nests the shared trailing
556
+ * compound once, such as `&:hover .x,&:focus .x` becoming
557
+ * `&:hover,&:focus{.x{...}}`. The heads join a comma list, which resolves
558
+ * like `:is()`, so they may only be grouped while they share one specificity.
559
+ *
560
+ * @param {object} rule The AST rule node whose selector list may be factored.
561
+ * @return {object} The rule, rewritten when factoring is valid and shorter.
562
+ */
563
+ function factorCommonTrailingCompound (rule) {
564
+ if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
565
+ return rule;
566
+ }
567
+ const splitSelectors = rule.selectors.map(splitSelectorTail);
568
+ if (splitSelectors.some((split) => {
569
+ return split === null;
570
+ })) {
571
+ return rule;
572
+ }
573
+ const first = splitSelectors[0];
574
+ const allShareTrailingCompound = splitSelectors.every((split) => {
575
+ return split.tail === first.tail && split.combinator === first.combinator;
576
+ });
577
+ if (!allShareTrailingCompound) {
578
+ return rule;
579
+ }
580
+ const heads = splitSelectors.map((split) => {
581
+ return split.head;
582
+ });
583
+ if (!selectorsShareSpecificity(heads)) {
584
+ return rule;
585
+ }
586
+ const nestedSelector = first.combinator === ' ' ? first.tail : first.combinator + first.tail;
587
+ const flatLength = rule.selectors.join(',').length;
588
+ const factoredLength = heads.join(',').length + nestedSelector.length + 2;
589
+ if (factoredLength >= flatLength) {
590
+ return rule;
591
+ }
592
+ return {
593
+ ...rule,
594
+ selectors: heads,
595
+ declarations: [{
596
+ type: 'rule',
597
+ selectors: [nestedSelector],
598
+ declarations: rule.declarations
599
+ }]
600
+ };
601
+ }
602
+
603
+ /**
604
+ * Collects every selector prefix of a rule's first selector that could serve
605
+ * as a shared nesting parent, including the full selector itself, which can
606
+ * host sibling pseudo-class variants such as `.a` of `.a,.a:hover`.
607
+ *
608
+ * @param {object} rule The AST rule node whose candidates are collected.
609
+ * @return {Set} The candidate parent selector strings.
610
+ */
611
+ function collectSharedParentCandidates (rule) {
612
+ const firstSelector = rule.selectors[0].trim();
613
+ const candidates = collectCandidateParentSelectors(firstSelector);
614
+ if (firstSelector) {
615
+ candidates.add(firstSelector);
616
+ }
617
+ return candidates;
618
+ }
619
+
620
+ /**
621
+ * Counts how many characters wrapping a run behind a shared parent selector
622
+ * saves: the parent is written once with its braces, and every wrapped
623
+ * selector shrinks to its nested form.
624
+ *
625
+ * @param {Array} run The rules of the run paired with their nested selectors.
626
+ * @param {string} parent The shared parent selector string.
627
+ * @return {number} The character savings of the wrapper, or a non-positive number when it costs more than it saves.
628
+ */
629
+ function measureWrapperSavings (run, parent) {
630
+ const charactersSaved = run.reduce((total, entry) => {
631
+ return total + entry.sibling.selectors.reduce((sum, selector, position) => {
632
+ return sum + selector.trim().length - entry.nestedSelectors[position].length;
633
+ }, 0);
634
+ }, 0);
635
+ // The wrapper writes the shared selector once plus its surrounding braces.
636
+ return charactersSaved - (parent.length + 2);
486
637
  }
487
638
 
488
639
  /**
489
- * Groups consecutive sibling rules that share a common leading compound selector
490
- * (such as `.foo` in `.foo .a`, `.foo .b`) into a synthesized parent rule with the
491
- * shared portion factored out, but only when nesting trims more characters from the
492
- * child selectors than the wrapper itself costs.
640
+ * Groups consecutive sibling rules that share a common leading selector
641
+ * portion (such as `.foo` in `.foo .a`, `.foo .b`, or `.menu .item` in
642
+ * `.menu .item:hover .x`) into a synthesized parent rule with the shared
643
+ * portion factored out. Every viable prefix of the first rule's selector is
644
+ * tried as the parent, and the grouping that saves the most characters wins,
645
+ * but only when it saves more than the wrapper itself costs. Rules with a
646
+ * comma-separated selector list can join a run when every one of their
647
+ * selectors nests under the parent.
493
648
  *
494
- * @param {Array} rules The flat AST rule nodes to factor.
495
- * @return {Array} A new array of rules with shared parent selectors factored into nesting wrappers.
649
+ * @param {Array} rules The flat AST rule nodes to factor.
650
+ * @param {boolean} allowTrailingCompound True when the rules are children of a factored wrapper, whose `&`-relative selectors may share a trailing compound that can be factored out in turn.
651
+ * @return {Array} A new array of rules with shared parent selectors factored into nesting wrappers.
496
652
  */
497
- function factorCommonParents (rules) {
653
+ function factorCommonParents (rules, allowTrailingCompound = false) {
498
654
  const result = [];
499
655
  let index = 0;
500
656
  while (index < rules.length) {
501
657
  const rule = rules[index];
502
- if (rule.type === 'rule' && rule.selectors?.length > 1) {
503
- const candidateParent = extractLeadingCompound(rule.selectors[0]) || rule.selectors[0];
504
- if (candidateParent) {
505
- const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
506
- const childSelectors = rule.selectors.map(matchNestedSelector);
507
- const allMatch = childSelectors.every((selector) => {
508
- return selector !== null;
509
- });
510
- const wrapperLength = candidateParent.length + 2 + childSelectors.join(',').length;
511
- if (allMatch && !containsPseudoElement(candidateParent) && wrapperLength < rule.selectors.join(',').length) {
512
- result.push({
513
- type: 'rule',
514
- selectors: [candidateParent],
515
- declarations: [{ ...rule, selectors: childSelectors }]
516
- });
517
- index++;
518
- continue;
519
- }
520
- }
521
- }
522
- // Only single-selector style rules can act as a factoring candidate.
523
- if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
524
- result.push(rule);
525
- index++;
526
- continue;
527
- }
528
- const candidateParent = extractLeadingCompound(rule.selectors[0]);
529
- if (candidateParent === null) {
658
+ if (rule.type !== 'rule' || !rule.selectors?.length) {
530
659
  result.push(rule);
531
660
  index++;
532
661
  continue;
533
662
  }
534
- // Collect the run of consecutive rules that can all nest under the candidate.
535
- const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
536
- const run = [];
537
- const nestedForms = [];
538
- let lookahead = index;
539
- while (lookahead < rules.length) {
540
- const sibling = rules[lookahead];
541
- if (sibling.type !== 'rule' || sibling.selectors?.length !== 1) {
542
- break;
663
+ let bestGrouping = null;
664
+ for (const candidateParent of collectSharedParentCandidates(rule)) {
665
+ if (!candidateParent || containsPseudoElement(candidateParent)) {
666
+ continue;
543
667
  }
544
- const nestedSelector = matchNestedSelector(sibling.selectors[0]);
545
- if (nestedSelector === null) {
546
- break;
668
+ const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
669
+ // Collect the run of consecutive rules whose selectors can all nest under the candidate.
670
+ const run = [];
671
+ let lookahead = index;
672
+ while (lookahead < rules.length) {
673
+ const sibling = rules[lookahead];
674
+ if (sibling.type !== 'rule' || !sibling.selectors?.length) {
675
+ break;
676
+ }
677
+ const nestedSelectors = sibling.selectors.map((selector) => {
678
+ return matchNestedSelector(selector.trim());
679
+ });
680
+ if (nestedSelectors.some((nestedSelector) => {
681
+ return nestedSelector === null;
682
+ })) {
683
+ break;
684
+ }
685
+ run.push({ sibling, nestedSelectors });
686
+ lookahead++;
687
+ }
688
+ const selectorCount = run.reduce((total, entry) => {
689
+ return total + entry.nestedSelectors.length;
690
+ }, 0);
691
+ // A lone single-selector rule has nothing to share its parent with.
692
+ if (run.length < 2 && selectorCount < 2) {
693
+ continue;
694
+ }
695
+ const savings = measureWrapperSavings(run, candidateParent);
696
+ if (savings > 0 && (!bestGrouping || savings > bestGrouping.savings)) {
697
+ bestGrouping = {
698
+ lookahead,
699
+ parent: candidateParent,
700
+ run,
701
+ savings
702
+ };
703
+ }
704
+ }
705
+ if (!bestGrouping) {
706
+ if (allowTrailingCompound) {
707
+ result.push(factorCommonTrailingCompound(rule));
708
+ } else {
709
+ result.push(rule);
547
710
  }
548
- run.push(sibling);
549
- nestedForms.push(nestedSelector);
550
- lookahead++;
551
- }
552
- // The wrapper writes the shared selector once plus its surrounding braces.
553
- const wrapperCost = candidateParent.length + 2;
554
- const charactersSaved = run.reduce((total, sibling, position) => {
555
- return total + sibling.selectors[0].trim().length - nestedForms[position].length;
556
- }, 0);
557
- if (run.length >= 2 && charactersSaved > wrapperCost) {
558
- const children = run.map((sibling, position) => {
559
- return { ...sibling, selectors: [nestedForms[position]] };
560
- });
561
- result.push({
562
- type: 'rule',
563
- selectors: [candidateParent],
564
- // Recurse so deeper shared prefixes among the children also factor out.
565
- declarations: factorCommonParents(children)
566
- });
567
- index = lookahead;
568
- } else {
569
- result.push(rule);
570
711
  index++;
712
+ continue;
571
713
  }
714
+ const children = bestGrouping.run.map((entry) => {
715
+ return { ...entry.sibling, selectors: entry.nestedSelectors };
716
+ });
717
+ result.push({
718
+ type: 'rule',
719
+ selectors: [bestGrouping.parent],
720
+ // Recurse so deeper shared prefixes among the children also factor out.
721
+ declarations: factorCommonParents(children, true)
722
+ });
723
+ index = bestGrouping.lookahead;
572
724
  }
573
725
  return result;
574
726
  }
@@ -629,7 +781,8 @@ function mergeMediaRules (rules, mergeSelectorRules) {
629
781
  result.push(rule);
630
782
  }
631
783
  } else {
632
- if (rule.type !== 'whitespace') {
784
+ // Clearing a map replaces its storage, so an already empty one is left alone
785
+ if (rule.type !== 'whitespace' && mediaMap.size) {
633
786
  mediaMap.clear();
634
787
  }
635
788
  result.push(rule);
@@ -878,12 +1031,40 @@ function mergeByDeclarations (rules) {
878
1031
  function skipSelectorName (text, start) {
879
1032
  let index = start;
880
1033
  // Advance over identifier characters (letters, digits, hyphen, underscore)
881
- while (index < text.length && (/[a-zA-Z0-9_-]/).test(text[index])) {
1034
+ while (index < text.length && isSelectorNameCharacter(text.charCodeAt(index))) {
882
1035
  index++;
883
1036
  }
884
1037
  return index;
885
1038
  }
886
1039
 
1040
+ /**
1041
+ * Reports whether a character code is an ASCII letter.
1042
+ *
1043
+ * @param {number} characterCode The UTF-16 code unit of the character.
1044
+ * @return {boolean} True for `a`-`z` and `A`-`Z`.
1045
+ */
1046
+ function isAsciiLetter (characterCode) {
1047
+ return (
1048
+ (characterCode >= 97 && characterCode <= 122) ||
1049
+ (characterCode >= 65 && characterCode <= 90)
1050
+ );
1051
+ }
1052
+
1053
+ /**
1054
+ * Reports whether a character code can appear in a selector name, which here
1055
+ * means an ASCII letter, digit, hyphen, or underscore. Comparing character
1056
+ * codes avoids running a regular expression once per character.
1057
+ *
1058
+ * @param {number} characterCode The UTF-16 code unit of the character.
1059
+ * @return {boolean} True when the character continues a name.
1060
+ */
1061
+ function isSelectorNameCharacter (characterCode) {
1062
+ const isDigit = characterCode >= 48 && characterCode <= 57;
1063
+ const isHyphen = characterCode === 45;
1064
+ const isUnderscore = characterCode === 95;
1065
+ return isAsciiLetter(characterCode) || isDigit || isHyphen || isUnderscore;
1066
+ }
1067
+
887
1068
  /**
888
1069
  * Compares two specificity tuples [ids, classes, types] lexicographically.
889
1070
  *
@@ -964,7 +1145,7 @@ function computeSpecificity (selector) {
964
1145
  } else if (character === '*' || character === '&') {
965
1146
  // Universal selector and nesting selector add no counted specificity here
966
1147
  index++;
967
- } else if ((/[a-zA-Z]/).test(character)) {
1148
+ } else if (isAsciiLetter(text.charCodeAt(index))) {
968
1149
  specificity[2]++;
969
1150
  index = skipSelectorName(text, index);
970
1151
  } else {
@@ -976,21 +1157,84 @@ function computeSpecificity (selector) {
976
1157
  }
977
1158
 
978
1159
  /**
979
- * Returns the maximum specificity tuple across a comma-separated selector list,
980
- * matching how a comma-separated group behaves as `:is()`.
1160
+ * Memoizes the strongest and weakest specificity of each selector list seen
1161
+ * during the current stylesheet. The same rules are measured again on every
1162
+ * merge pass, and a selector list always has the same specificity, so each
1163
+ * distinct list is only split and scanned once per stylesheet.
1164
+ *
1165
+ * @type {Map<string, {strongest: Array, weakest: Array}>}
1166
+ */
1167
+ const specificityRangeCache = new Map();
1168
+
1169
+ /**
1170
+ * Memoizes the normalized form of each selector seen during the current
1171
+ * stylesheet.
1172
+ *
1173
+ * @type {Map<string, string>}
1174
+ */
1175
+ const normalizedSelectorCache = new Map();
1176
+
1177
+ /**
1178
+ * Discards every memoized specificity and normalized selector. The minifier
1179
+ * calls this around each stylesheet, so the caches do not outlive the pass that
1180
+ * filled them.
1181
+ */
1182
+ function clearSelectorCaches () {
1183
+ specificityRangeCache.clear();
1184
+ normalizedSelectorCache.clear();
1185
+ }
1186
+
1187
+ /**
1188
+ * Scans every selector of a comma-separated list for the strongest and the
1189
+ * weakest specificity among them. The returned tuples are frozen because they
1190
+ * are shared by everything that asks about the same list.
981
1191
  *
982
1192
  * @param {string} selectorList The selector list string.
983
- * @return {Array} The maximum [ids, classes, types] tuple.
1193
+ * @return {object} The `strongest` and `weakest` [ids, classes, types] tuples.
984
1194
  */
985
- function maxSelectorSpecificity (selectorList) {
986
- let maximum = [0, 0, 0];
1195
+ function computeSpecificityRange (selectorList) {
1196
+ let strongest = [0, 0, 0];
1197
+ let weakest = null;
987
1198
  for (const selector of splitTopLevelCommaList(selectorList)) {
988
1199
  const specificity = computeSpecificity(selector);
989
- if (compareSpecificity(specificity, maximum) > 0) {
990
- maximum = specificity;
1200
+ if (compareSpecificity(specificity, strongest) > 0) {
1201
+ strongest = specificity;
1202
+ }
1203
+ if (!weakest || compareSpecificity(specificity, weakest) < 0) {
1204
+ weakest = specificity;
991
1205
  }
992
1206
  }
993
- return maximum;
1207
+ return {
1208
+ strongest: Object.freeze(strongest),
1209
+ weakest: Object.freeze(weakest || [0, 0, 0])
1210
+ };
1211
+ }
1212
+
1213
+ /**
1214
+ * Returns the strongest and weakest specificity of a selector list, measuring
1215
+ * it the first time it is asked about.
1216
+ *
1217
+ * @param {string} selectorList The selector list string.
1218
+ * @return {object} The `strongest` and `weakest` [ids, classes, types] tuples.
1219
+ */
1220
+ function getSpecificityRange (selectorList) {
1221
+ let range = specificityRangeCache.get(selectorList);
1222
+ if (!range) {
1223
+ range = computeSpecificityRange(selectorList);
1224
+ specificityRangeCache.set(selectorList, range);
1225
+ }
1226
+ return range;
1227
+ }
1228
+
1229
+ /**
1230
+ * Returns the maximum specificity tuple across a comma-separated selector list,
1231
+ * matching how a comma-separated group behaves as `:is()`.
1232
+ *
1233
+ * @param {string} selectorList The selector list string.
1234
+ * @return {Array} The maximum [ids, classes, types] tuple.
1235
+ */
1236
+ function maxSelectorSpecificity (selectorList) {
1237
+ return getSpecificityRange(selectorList).strongest;
994
1238
  }
995
1239
 
996
1240
  /**
@@ -1014,14 +1258,7 @@ function maximumRuleSpecificity (selectors) {
1014
1258
  * @return {Array} The [ids, classes, types] specificity tuple.
1015
1259
  */
1016
1260
  function minimumRuleSpecificity (selectors) {
1017
- let minimum = null;
1018
- for (const selector of splitTopLevelCommaList((selectors || []).join(','))) {
1019
- const specificity = computeSpecificity(selector);
1020
- if (!minimum || compareSpecificity(specificity, minimum) < 0) {
1021
- minimum = specificity;
1022
- }
1023
- }
1024
- return minimum || [0, 0, 0];
1261
+ return getSpecificityRange((selectors || []).join(',')).weakest;
1025
1262
  }
1026
1263
 
1027
1264
  /**
@@ -1300,11 +1537,11 @@ function absorbWriteProfile (profile, absorbedWrites) {
1300
1537
  * position grows, so the first entry past a queried position is also the
1301
1538
  * strongest one past it.
1302
1539
  *
1303
- * @return {object} An index exposing `recordRule` and `blocksRelocation`.
1540
+ * @param {Map} profileByRule The write profiles already built for rules, shared by every pass of one merge.
1541
+ * @return {object} An index exposing `recordRule` and `blocksRelocation`.
1304
1542
  */
1305
- function createOverrideIndex () {
1543
+ function createOverrideIndex (profileByRule) {
1306
1544
  const entriesByProperty = new Map();
1307
- const profileByRule = new Map();
1308
1545
 
1309
1546
  /**
1310
1547
  * Records that a property is written at a position with a given specificity.
@@ -1357,8 +1594,9 @@ function createOverrideIndex () {
1357
1594
 
1358
1595
  /**
1359
1596
  * Returns a rule's write profile, building it the first time it is needed.
1360
- * Rules only ever grow here, by absorbing another rule's declarations, so a
1361
- * profile stays valid for as long as the merge pass runs.
1597
+ * Rules only ever grow here, by absorbing another rule's declarations, and
1598
+ * `absorbRule` folds those into the profile, so a profile stays valid for
1599
+ * every pass of the merge.
1362
1600
  *
1363
1601
  * @param {object} rule The AST rule node.
1364
1602
  * @return {RuleWriteProfile} The rule's write profile.
@@ -1453,6 +1691,24 @@ function buildSelectorKey (rule) {
1453
1691
  .join(',');
1454
1692
  }
1455
1693
 
1694
+ /**
1695
+ * Returns a rule's selector key, building it the first time it is needed. A
1696
+ * merge only ever grows a rule's declarations, never its selectors, so a key
1697
+ * stays valid for every pass of the merge.
1698
+ *
1699
+ * @param {object} rule The AST rule node.
1700
+ * @param {Map} selectorKeyByRule The selector keys already built, shared by every pass of one merge.
1701
+ * @return {string} The normalized, order independent selector key.
1702
+ */
1703
+ function getSelectorKey (rule, selectorKeyByRule) {
1704
+ let selectorKey = selectorKeyByRule.get(rule);
1705
+ if (selectorKey === undefined) {
1706
+ selectorKey = buildSelectorKey(rule);
1707
+ selectorKeyByRule.set(rule, selectorKey);
1708
+ }
1709
+ return selectorKey;
1710
+ }
1711
+
1456
1712
  /**
1457
1713
  * Placement for a merged rule whose combined declarations belong where the
1458
1714
  * later of the two rules was, because the earlier declarations moved down.
@@ -1584,10 +1840,11 @@ function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPos
1584
1840
  /**
1585
1841
  * 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.
1586
1842
  *
1587
- * @param {Array} rules The AST rule nodes to merge.
1588
- * @return {Array} A new array of rules with same-selector rules combined.
1843
+ * @param {Array} rules The AST rule nodes to merge.
1844
+ * @param {object} mergeCaches The write profiles and selector keys shared by every pass of one merge.
1845
+ * @return {Array} A new array of rules with same-selector rules combined.
1589
1846
  */
1590
- function mergeSelectorRulePass (rules) {
1847
+ function mergeSelectorRulePass (rules, mergeCaches) {
1591
1848
  // A rule merged at the later of the two positions moves to the end of the
1592
1849
  // output. Its old slot is emptied instead of spliced out so that every
1593
1850
  // recorded position stays valid, and the position map locates that slot
@@ -1595,20 +1852,21 @@ function mergeSelectorRulePass (rules) {
1595
1852
  const slots = [];
1596
1853
  const positionByRule = new Map();
1597
1854
  const selectorMap = new Map();
1598
- const overrideIndex = createOverrideIndex();
1855
+ const overrideIndex = createOverrideIndex(mergeCaches.profileByRule);
1599
1856
  for (const rule of rules) {
1600
1857
  if (rule.type === 'whitespace') {
1601
1858
  continue;
1602
1859
  }
1603
1860
  if (rule.type !== 'rule') {
1604
1861
  slots.push(rule);
1605
- if (rule.type !== 'comment') {
1862
+ // Clearing a map replaces its storage, so already empty maps are left alone
1863
+ if (rule.type !== 'comment' && selectorMap.size) {
1606
1864
  selectorMap.clear();
1607
1865
  positionByRule.clear();
1608
1866
  }
1609
1867
  continue;
1610
1868
  }
1611
- const selectorKey = buildSelectorKey(rule);
1869
+ const selectorKey = getSelectorKey(rule, mergeCaches.selectorKeyByRule);
1612
1870
  const existingRule = selectorKey && selectorMap.get(selectorKey);
1613
1871
  if (existingRule) {
1614
1872
  const existingPosition = positionByRule.get(existingRule);
@@ -1651,11 +1909,15 @@ function mergeSelectorRulePass (rules) {
1651
1909
  * @return {Array} Rules with all currently safe same-selector merges.
1652
1910
  */
1653
1911
  function mergeSelectorRules (rules) {
1912
+ const mergeCaches = {
1913
+ profileByRule: new Map(),
1914
+ selectorKeyByRule: new Map()
1915
+ };
1654
1916
  let previousRules = rules;
1655
- let mergedRules = mergeSelectorRulePass(previousRules);
1917
+ let mergedRules = mergeSelectorRulePass(previousRules, mergeCaches);
1656
1918
  while (mergedRules.length < previousRules.length) {
1657
1919
  previousRules = mergedRules;
1658
- mergedRules = mergeSelectorRulePass(previousRules);
1920
+ mergedRules = mergeSelectorRulePass(previousRules, mergeCaches);
1659
1921
  }
1660
1922
  return mergedRules;
1661
1923
  }
@@ -1710,15 +1972,35 @@ function mergeLayerRules (rules, mergeSelectorRules) {
1710
1972
  * @param {string} selector The raw selector string.
1711
1973
  * @return {string} The normalized selector.
1712
1974
  */
1713
- function normalizeSelector (selector) {
1714
- return selector
1715
- .trim()
1716
- .replace(/\s+/g, ' ')
1975
+ function computeNormalizedSelector (selector) {
1976
+ // Collapse each whitespace run into a single space
1977
+ const normalized = selector.trim().replace(/\s+/g, ' ');
1978
+ if (!normalized.includes('::')) {
1979
+ return normalized;
1980
+ }
1981
+ return normalized
1717
1982
  // Convert double-colon ::before/::after to single-colon legacy form
1718
1983
  .replace(/::before\b/g, ':before')
1719
1984
  .replace(/::after\b/g, ':after');
1720
1985
  }
1721
1986
 
1987
+ /**
1988
+ * Normalizes a selector string for consistent comparison, reusing the result
1989
+ * when the same selector was already normalized during this stylesheet. Every
1990
+ * merge pass compares the same selectors again, so each is only rewritten once.
1991
+ *
1992
+ * @param {string} selector The raw selector string.
1993
+ * @return {string} The normalized selector.
1994
+ */
1995
+ function normalizeSelector (selector) {
1996
+ let normalized = normalizedSelectorCache.get(selector);
1997
+ if (normalized === undefined) {
1998
+ normalized = computeNormalizedSelector(selector);
1999
+ normalizedSelectorCache.set(selector, normalized);
2000
+ }
2001
+ return normalized;
2002
+ }
2003
+
1722
2004
  /**
1723
2005
  * Indexes, for every normalized selector in the stylesheet, the last rule that
1724
2006
  * declares each property under it. Comparing that index against a rule's own
@@ -2015,6 +2297,7 @@ function optimizeRuleList (rules) {
2015
2297
  }
2016
2298
 
2017
2299
  export {
2300
+ clearSelectorCaches,
2018
2301
  deduplicateKeyframes,
2019
2302
  expandPureNestedRules,
2020
2303
  factorCommonParents,