@thejaredwilcurt/csslop 0.0.49 → 0.0.51

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.
Files changed (57) hide show
  1. package/README.md +0 -1
  2. package/package.json +2 -2
  3. package/src/declarations/border.js +1 -1
  4. package/src/declarations/process.js +155 -1
  5. package/src/index.js +7 -7
  6. package/src/rules/optimize/at-rules.js +113 -0
  7. package/src/rules/optimize/declaration-writes.js +70 -0
  8. package/src/rules/optimize/empty-rules.js +51 -0
  9. package/src/rules/optimize/expand-nesting.js +236 -0
  10. package/src/rules/optimize/factor-parents.js +343 -0
  11. package/src/rules/optimize/index.js +71 -0
  12. package/src/rules/optimize/merge-declarations.js +199 -0
  13. package/src/rules/optimize/merge-nested-rules.js +117 -0
  14. package/src/rules/optimize/merge-selectors.js +252 -0
  15. package/src/rules/optimize/nest-rules.js +202 -0
  16. package/src/rules/optimize/override-index.js +264 -0
  17. package/src/rules/optimize/prune-overridden.js +267 -0
  18. package/src/rules/optimize/selector-normalization.js +60 -0
  19. package/src/rules/optimize/specificity.js +280 -0
  20. package/src/{position-try.js → rules/position-try.js} +1 -1
  21. package/src/rules/stringify.js +13 -0
  22. package/src/utilities.js +14 -0
  23. package/src/value/border.js +85 -0
  24. package/src/value/{colors.js → color/conversion.js} +3 -2
  25. package/src/value/color/functions.js +463 -0
  26. package/src/value/{light-dark.js → color/light-dark.js} +1 -1
  27. package/src/value/{color-mix.js → color/mix.js} +3 -3
  28. package/src/value/{relative-colors.js → color/relative.js} +3 -3
  29. package/src/value/color/shorten.js +93 -0
  30. package/src/value/gradients.js +1 -1
  31. package/src/value/hyphenate-limit-chars.js +72 -0
  32. package/src/value/initial-keywords.js +128 -0
  33. package/src/value/lengths.js +55 -0
  34. package/src/value/math/evaluate.js +36 -0
  35. package/src/value/math/fold.js +315 -0
  36. package/src/value/math/format.js +230 -0
  37. package/src/value/math/functions.js +302 -0
  38. package/src/value/math/index.js +219 -0
  39. package/src/value/math/parser.js +301 -0
  40. package/src/value/math/property-ranges.js +174 -0
  41. package/src/value/math/rewrite.js +222 -0
  42. package/src/value/math/tokenizer.js +160 -0
  43. package/src/value/math/unit-totals.js +194 -0
  44. package/src/value/minify.js +127 -1669
  45. package/src/value/numbers.js +28 -0
  46. package/src/value/positions.js +152 -0
  47. package/src/value/property-optimizations.js +485 -0
  48. package/src/value/segments.js +74 -0
  49. package/src/value/{svg-data-uri.js → svg/data-uri.js} +1 -1
  50. package/src/value/{svg-markup.js → svg/markup.js} +1 -1
  51. package/src/value/time.js +75 -0
  52. package/src/value/urls.js +242 -0
  53. package/src/value/whitespace.js +88 -0
  54. package/src/rules/optimize.js +0 -2314
  55. package/src/value/math-expression.js +0 -1156
  56. package/src/value/math.js +0 -886
  57. /package/src/value/{named-colors.js → color/named.js} +0 -0
@@ -1,2314 +0,0 @@
1
- /**
2
- * @file Optimizes CSS rule structures by merging selectors, deduplicating keyframes, nesting flat rules, and consolidating `@media` and `@layer` blocks.
3
- */
4
-
5
- import {
6
- expandToLeafProperties,
7
- getOverridesOf
8
- } from '../declarations/config.js';
9
- import {
10
- findMatchingBracket,
11
- findMatchingParenthesis,
12
- splitTopLevelCommaList
13
- } from '../parser/source-search.js';
14
- import { tokenizeCssValue } from '../parser/tokenizer.js';
15
-
16
- import {
17
- normalizeLayerNames,
18
- normalizeMedia
19
- } from './normalize.js';
20
- import {
21
- containsPseudoElement,
22
- isUniversallyRecognizableSelector
23
- } from './selectors.js';
24
-
25
- /**
26
- * Reports whether an entry of a rule's body is itself a nested rule rather
27
- * than a plain declaration.
28
- *
29
- * @param {object} entry An entry of a rule's declaration list.
30
- * @return {boolean} True when the entry is a nested rule.
31
- */
32
- function isNestedRule (entry) {
33
- return entry.type === 'rule';
34
- }
35
-
36
- /**
37
- * Expands the rules nested inside each rule's body, so a chain of wrappers
38
- * collapses from the inside out and the outermost wrapper is then judged
39
- * against an already flattened child. A rule's body holds the same kinds of
40
- * entries a stylesheet does, so the body is expanded by the same rules, and
41
- * the plain declarations it also holds pass through untouched.
42
- *
43
- * @param {Array} rules The AST rule nodes whose bodies may hold nested rules.
44
- * @return {Array} The rules, each with its body expanded.
45
- */
46
- function expandNestedRuleBodies (rules) {
47
- return rules.map((rule) => {
48
- if (rule.type !== 'rule' || !(rule.declarations || []).some(isNestedRule)) {
49
- return rule;
50
- }
51
- const declarations = expandPureNestedRules(rule.declarations);
52
- const canFlattenSelf = selectorsShareSpecificity(rule.selectors) && !rule.selectors.some(containsPseudoElement);
53
- let encounteredNestedContent = false;
54
- const flattened = declarations.flatMap((entry) => {
55
- const isSelfRule = entry.type === 'rule' && entry.selectors?.length === 1 && entry.selectors[0].trim() === '&';
56
- if (canFlattenSelf && isSelfRule && !encounteredNestedContent) {
57
- return entry.declarations || [];
58
- }
59
- if (entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment') {
60
- encounteredNestedContent = true;
61
- }
62
- if (entry.type === 'rule' && rule.selectors.length === 1) {
63
- entry.selectors = entry.selectors.map((selector) => {
64
- return shortenRepeatedNestingSelector(rule.selectors[0], selector);
65
- });
66
- }
67
- return [entry];
68
- });
69
- return {
70
- ...rule,
71
- declarations: flattened
72
- };
73
- });
74
- }
75
-
76
- /**
77
- * Replaces repetitions of a simple class or ID beside a leading nesting
78
- * selector. Token comparisons keep longer names and escaped text intact.
79
- *
80
- * @param {string} parentSelector The sole parent selector.
81
- * @param {string} childSelector The nested selector.
82
- * @return {string} The shorter equivalent selector.
83
- */
84
- function shortenRepeatedNestingSelector (parentSelector, childSelector) {
85
- const parentTokens = tokenizeCssValue(parentSelector);
86
- const isClass = parentTokens.length === 2 && parentTokens[0].text === '.' && parentTokens[1].type === 'ident';
87
- const isIdentifier = parentTokens.length === 1 && parentTokens[0].type === 'hash';
88
- if (!isClass && !isIdentifier) {
89
- return childSelector;
90
- }
91
- const childTokens = tokenizeCssValue(childSelector);
92
- if (childTokens[0]?.text !== '&') {
93
- return childSelector;
94
- }
95
- let index = 1;
96
- let result = '&';
97
- while (index < childTokens.length) {
98
- const matchesParent = parentTokens.every((token, position) => {
99
- return childTokens[index + position]?.text === token.text;
100
- });
101
- if (!matchesParent) {
102
- break;
103
- }
104
- result += '&';
105
- index += parentTokens.length;
106
- }
107
- return result + childTokens.slice(index).map((token) => {
108
- return token.text;
109
- }).join('');
110
- }
111
-
112
- /**
113
- * Reports whether a nested child selector still means the same thing once it
114
- * is written out in full under its parent.
115
- *
116
- * @param {string} childSelector The selector of a rule nested inside another.
117
- * @return {boolean} True when the child can be written as a flat selector.
118
- */
119
- function canFlattenChildSelector (childSelector) {
120
- const child = childSelector.trim();
121
- if (!child) {
122
- return false;
123
- }
124
- // A `&` anywhere but the very start puts the parent where flattening cannot:
125
- // `.b &` means the parent follows the child rather than leading it
126
- return !child.slice(1).includes('&');
127
- }
128
-
129
- /**
130
- * Writes a nested child selector out in full, as the selector it stands for
131
- * when read through its parent.
132
- *
133
- * @param {string} parentSelector The selector of the rule the child is nested in.
134
- * @param {string} childSelector The selector of the nested child rule.
135
- * @return {string} The flat selector the nested pair stands for.
136
- */
137
- function combineNestedSelector (parentSelector, childSelector) {
138
- const parent = parentSelector.trim();
139
- const child = childSelector.trim();
140
- // A leading `&` names the parent itself, so it is what the parent replaces
141
- if (child.startsWith('&')) {
142
- return parent + child.slice(1);
143
- }
144
- return parent + ' ' + child;
145
- }
146
-
147
- /**
148
- * Expands rules that contain only nested sub-rules into flat rules with combined selectors, enabling further merging when the combined selectors already exist elsewhere.
149
- *
150
- * @param {Array} inputRules The AST rule nodes to process.
151
- * @return {Array} A new array of rules with pure-nested rules expanded.
152
- */
153
- function expandPureNestedRules (inputRules) {
154
- const rules = expandNestedRuleBodies(inputRules);
155
- const flatSelectors = new Set();
156
- for (const rule of rules) {
157
- if (rule.type !== 'rule' || !rule.selectors?.length) {
158
- continue;
159
- }
160
- const nonWhitespace = (rule.declarations || []).filter((declaration) => {
161
- return declaration.type !== 'whitespace';
162
- });
163
- const hasNonRuleDeclarations = nonWhitespace.some((declaration) => {
164
- return declaration.type !== 'rule';
165
- });
166
- if (hasNonRuleDeclarations) {
167
- rule.selectors.forEach((selector) => {
168
- // Normalize selector whitespace to single space for deduplication
169
- flatSelectors.add(selector.trim().replace(/\s+/g, ' '));
170
- });
171
- }
172
- }
173
-
174
- const result = [];
175
- for (const rule of rules) {
176
- if (rule.type !== 'rule' || !rule.selectors?.length) {
177
- result.push(rule);
178
- continue;
179
- }
180
- const nonWhitespace = (rule.declarations || []).filter((declaration) => {
181
- return declaration.type !== 'whitespace';
182
- });
183
- const isPureNested = nonWhitespace.length > 0 && nonWhitespace.every((declaration) => {
184
- return declaration.type === 'rule';
185
- });
186
- // A comma-separated parent selector list behaves like :is() for specificity,
187
- // so expanding it into descendant combinations (e.g. #a,.b → #a .c,.b .c)
188
- // would change specificity. Only single-selector parents can expand safely.
189
- if (!isPureNested || rule.selectors.length !== 1) {
190
- result.push(rule);
191
- continue;
192
- }
193
-
194
- let anyMatch = false;
195
- let expandedRules = [];
196
- let canExpand = true;
197
-
198
- for (const nestedRule of nonWhitespace) {
199
- if (!nestedRule.selectors?.length) {
200
- canExpand = false;
201
- break;
202
- }
203
- // A child written as a comma-separated selector list, such as `.b,.c`,
204
- // repeats the parent in front of every item when expanded (`.a .b,.a .c`),
205
- // while the nested form states the parent once. Expanding is always
206
- // longer, and leaving the rule nested keeps one pass from expanding what
207
- // the next pass would then keep, so the output stays idempotent.
208
- if (nestedRule.selectors.length > 1) {
209
- canExpand = false;
210
- break;
211
- }
212
- if (!nestedRule.selectors.every(canFlattenChildSelector)) {
213
- canExpand = false;
214
- break;
215
- }
216
- const combinedSelectors = [];
217
- for (const parentSelector of rule.selectors) {
218
- for (const childSelector of nestedRule.selectors) {
219
- const combinedSelector = combineNestedSelector(parentSelector, childSelector);
220
- combinedSelectors.push(combinedSelector);
221
- if (flatSelectors.has(combinedSelector)) {
222
- anyMatch = true;
223
- }
224
- }
225
- }
226
- expandedRules.push({ ...nestedRule, selectors: combinedSelectors });
227
- }
228
-
229
- const allChildSelectorStrings = expandedRules.map((expandedRule) => {
230
- return expandedRule.selectors.join(',');
231
- });
232
- const allSame = (
233
- allChildSelectorStrings.length > 0 &&
234
- allChildSelectorStrings.every((selectorString) => {
235
- return selectorString === allChildSelectorStrings[0];
236
- })
237
- );
238
- if (canExpand && (allSame || anyMatch)) {
239
- for (const expandedRule of expandedRules) {
240
- result.push(expandedRule);
241
- }
242
- } else {
243
- result.push(rule);
244
- }
245
- }
246
- return result;
247
- }
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
-
283
- /**
284
- * Builds a reusable matcher that expresses child selectors as nested selectors
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.
287
- *
288
- * @param {string} parentSelector The parent selector string.
289
- * @return {function(string): (string|null)} A matcher returning the nested selector, or null when the child cannot be nested.
290
- */
291
- function createNestedSelectorMatcher (parentSelector) {
292
- const parent = parentSelector.trim();
293
-
294
- return (childSelector) => {
295
- const child = childSelector.trim();
296
- if (!child.startsWith(parent)) {
297
- return null;
298
- }
299
- return nestSelectorRemainder(child.slice(parent.length));
300
- };
301
- }
302
-
303
- /**
304
- * Attempts to express a child selector as a nested selector relative to a parent, returning the nested form or null if nesting is not possible.
305
- *
306
- * @param {string} parentSel The parent selector string.
307
- * @param {string} childSel The child selector string to try nesting.
308
- * @return {string|null} The nested selector using & syntax, or null if the child cannot be nested under the parent.
309
- */
310
- function tryNestSelector (parentSel, childSel) {
311
- return createNestedSelectorMatcher(parentSel)(childSel);
312
- }
313
-
314
- /**
315
- * Determines whether a rule is effectively empty, containing no meaningful
316
- * CSS output after minification. A rule is effectively empty when it has
317
- * no declarations, or all of its entries are whitespace, non-important
318
- * comments, or recursively empty nested rules.
319
- *
320
- * @param {object} rule The AST rule node to evaluate.
321
- * @return {boolean} True if the rule produces no CSS output.
322
- */
323
- function isRuleEffectivelyEmpty (rule) {
324
- if (rule.type !== 'rule') {
325
- return false;
326
- }
327
- const nonWhitespaceEntries = (rule.declarations || []).filter((declaration) => {
328
- return declaration.type !== 'whitespace';
329
- });
330
- if (nonWhitespaceEntries.length === 0) {
331
- return true;
332
- }
333
- return nonWhitespaceEntries.every((entry) => {
334
- if (entry.type === 'comment') {
335
- return !entry.comment?.startsWith('!');
336
- }
337
- if (entry.type === 'rule') {
338
- return isRuleEffectivelyEmpty(entry);
339
- }
340
- return false;
341
- });
342
- }
343
-
344
- /**
345
- * Filters out effectively empty rules from the rules array, preventing
346
- * empty rules from being nested into parent rules during later
347
- * optimization passes and producing incorrect non-empty output.
348
- *
349
- * @param {Array} rules The AST rule nodes to filter.
350
- * @return {Array} A new array with effectively empty rules removed.
351
- */
352
- function removeEmptyRules (rules) {
353
- return rules.filter((rule) => {
354
- return !isRuleEffectivelyEmpty(rule);
355
- });
356
- }
357
-
358
- /**
359
- * Determines whether a character is CSS whitespace.
360
- *
361
- * @param {string} character A single character from a selector.
362
- * @return {boolean} Whether the character is whitespace.
363
- */
364
- function isSelectorWhitespace (character) {
365
- // Match a single whitespace character
366
- return (/\s/).test(character);
367
- }
368
-
369
- /**
370
- * Collects every prefix of a child selector that could act as its nesting
371
- * parent. A parent always ends immediately before a pseudo-class colon, a
372
- * descendant whitespace run, or the whitespace run leading into a combinator,
373
- * so only those few positions can start a nestable remainder. Enumerating them
374
- * turns "which earlier rule can host this one" into a set of key lookups
375
- * instead of a comparison against every preceding rule.
376
- *
377
- * @param {string} childSelector The trimmed child selector.
378
- * @return {Set} The candidate parent selector strings.
379
- */
380
- function collectCandidateParentSelectors (childSelector) {
381
- const candidates = new Set();
382
- for (let index = 1; index < childSelector.length; index++) {
383
- const character = childSelector[index];
384
- if (character === ':' || isSelectorWhitespace(character)) {
385
- candidates.add(childSelector.slice(0, index));
386
- continue;
387
- }
388
- if (character !== '>' && character !== '+' && character !== '~') {
389
- continue;
390
- }
391
- // A combinator may be separated from its parent by whitespace, which
392
- // belongs to neither side, so the parent ends where that run begins.
393
- let boundary = index;
394
- while (boundary > 0 && isSelectorWhitespace(childSelector[boundary - 1])) {
395
- boundary--;
396
- }
397
- if (boundary > 0) {
398
- candidates.add(childSelector.slice(0, boundary));
399
- }
400
- }
401
- return candidates;
402
- }
403
-
404
- /**
405
- * Finds the most recently emitted rule that can host a child selector as a
406
- * nested rule, matching the behaviour of scanning backwards through the emitted
407
- * rules but only visiting the handful of rules whose selector is a viable
408
- * parent prefix.
409
- *
410
- * @param {Array} emittedRules The rules emitted so far.
411
- * @param {Map} indexBySelector Map of each emitted single-selector rule's selector to its index.
412
- * @param {string} childSelector The trimmed child selector to nest.
413
- * @return {object|null} The `parentIndex` and `nestedSelector`, or null when nothing can host the child.
414
- */
415
- function findNestingParent (emittedRules, indexBySelector, childSelector) {
416
- const parentIndexes = [];
417
- for (const candidateParent of collectCandidateParentSelectors(childSelector)) {
418
- const parentIndex = indexBySelector.get(candidateParent);
419
- if (parentIndex !== undefined) {
420
- parentIndexes.push(parentIndex);
421
- }
422
- }
423
- // The nearest preceding parent wins, exactly as a backwards scan would.
424
- parentIndexes.sort((first, second) => {
425
- return second - first;
426
- });
427
- for (const parentIndex of parentIndexes) {
428
- const nestedSelector = tryNestSelector(emittedRules[parentIndex].selectors[0], childSelector);
429
- if (nestedSelector !== null) {
430
- return {
431
- nestedSelector,
432
- parentIndex
433
- };
434
- }
435
- }
436
- return null;
437
- }
438
-
439
- /**
440
- * Groups flat CSS rules into nested structures where a child selector can be expressed relative to a preceding parent, reducing output size through CSS nesting.
441
- *
442
- * @param {Array} rules The flat AST rule nodes to nest.
443
- * @return {Array} A new array of rules with applicable children nested under their parents.
444
- */
445
- function nestFlatRules (rules) {
446
- const result = [];
447
- const indexBySelector = new Map();
448
- for (const rule of rules) {
449
- if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
450
- result.push(rule);
451
- continue;
452
- }
453
- const childSelector = rule.selectors[0].trim();
454
- const nesting = findNestingParent(result, indexBySelector, childSelector);
455
- if (nesting) {
456
- const parentRule = result[nesting.parentIndex];
457
- parentRule.declarations = parentRule.declarations || [];
458
- parentRule.declarations.push({ ...rule, selectors: [nesting.nestedSelector] });
459
- continue;
460
- }
461
- indexBySelector.set(childSelector, result.length);
462
- result.push(rule);
463
- }
464
- for (const rule of result) {
465
- if (rule.type === 'rule' && rule.declarations) {
466
- const innerRules = rule.declarations.filter((declaration) => {
467
- return declaration.type === 'rule';
468
- });
469
- if (innerRules.length > 0) {
470
- const nonRuleDeclarations = rule.declarations.filter((declaration) => {
471
- return declaration.type !== 'rule';
472
- });
473
- rule.declarations = [...nonRuleDeclarations, ...nestFlatRules(innerRules)];
474
- }
475
- }
476
- }
477
- return result;
478
- }
479
-
480
- /**
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.
484
- *
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.
487
- */
488
- function splitSelectorTail (selector) {
489
- const trimmed = selector.trim();
490
- let bracketDepth = 0;
491
- let parenthesisDepth = 0;
492
- let combinatorStart = -1;
493
- let combinator = '';
494
- let tailStart = -1;
495
- for (let index = 0; index < trimmed.length; index++) {
496
- const character = trimmed[index];
497
- if (character === '(') {
498
- parenthesisDepth++;
499
- } else if (character === ')') {
500
- parenthesisDepth--;
501
- } else if (character === '[') {
502
- bracketDepth++;
503
- } else if (character === ']') {
504
- bracketDepth--;
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
- }
536
- }
537
- }
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);
637
- }
638
-
639
- /**
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.
648
- *
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.
652
- */
653
- function factorCommonParents (rules, allowTrailingCompound = false) {
654
- const result = [];
655
- let index = 0;
656
- while (index < rules.length) {
657
- const rule = rules[index];
658
- if (rule.type !== 'rule' || !rule.selectors?.length) {
659
- result.push(rule);
660
- index++;
661
- continue;
662
- }
663
- let bestGrouping = null;
664
- for (const candidateParent of collectSharedParentCandidates(rule)) {
665
- if (!candidateParent || containsPseudoElement(candidateParent)) {
666
- continue;
667
- }
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);
710
- }
711
- index++;
712
- continue;
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;
724
- }
725
- return result;
726
- }
727
-
728
- /**
729
- * Shares a descendant prefix through `:is()` when a nesting wrapper did not
730
- * save enough space to factor the parent out of the selector list.
731
- *
732
- * @param {object} rule The style rule whose selector list may be shortened.
733
- */
734
- function factorSelectorBranches (rule) {
735
- if (rule.type !== 'rule' || !rule.selectors?.length) {
736
- return;
737
- }
738
- for (const parent of [...rule.selectors]) {
739
- const prefix = parent + ' ';
740
- const branches = rule.selectors.filter((selector) => {
741
- return selector.startsWith(prefix);
742
- });
743
- const children = branches.map((selector) => {
744
- return selector.slice(prefix.length);
745
- });
746
- if (children.length < 2 || !children.every(isUniversallyRecognizableSelector) || !selectorsShareSpecificity(children)) {
747
- continue;
748
- }
749
- const grouped = prefix + ':is(' + children.sort().join(',') + ')';
750
- if (grouped.length >= branches.join(',').length) {
751
- continue;
752
- }
753
- const branchSet = new Set(branches);
754
- const firstIndex = rule.selectors.indexOf(branches[0]);
755
- rule.selectors = rule.selectors.flatMap((selector, index) => {
756
- if (index === firstIndex) {
757
- return [grouped];
758
- }
759
- return branchSet.has(selector) ? [] : [selector];
760
- });
761
- }
762
- }
763
-
764
- /**
765
- * Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
766
- *
767
- * @param {Array} rules The AST rule nodes to process.
768
- * @param {function(Array): Array} mergeSelectorRules Callback to merge selector rules within each media block.
769
- * @return {Array} A new array of rules with consecutive identical `@media` blocks combined.
770
- */
771
- function mergeMediaRules (rules, mergeSelectorRules) {
772
- const mediaMap = new Map();
773
- const result = [];
774
- for (const rule of rules) {
775
- if (rule.type === 'media') {
776
- const normalizedQuery = normalizeMedia(rule.media);
777
- if (mediaMap.has(normalizedQuery)) {
778
- mediaMap.get(normalizedQuery).rules.push(...(rule.rules || []));
779
- } else {
780
- mediaMap.set(normalizedQuery, rule);
781
- result.push(rule);
782
- }
783
- } else {
784
- // Clearing a map replaces its storage, so an already empty one is left alone
785
- if (rule.type !== 'whitespace' && mediaMap.size) {
786
- mediaMap.clear();
787
- }
788
- result.push(rule);
789
- }
790
- }
791
- for (const rule of result) {
792
- if (rule.type === 'media' && rule.rules && rule.rules.length) {
793
- rule.rules = mergeSelectorRules(rule.rules);
794
- }
795
- }
796
- return result;
797
- }
798
-
799
- /**
800
- * Removes duplicate `@keyframes` definitions, keeping only the last occurrence of each named animation.
801
- *
802
- * @param {Array} rules The AST rule nodes to deduplicate.
803
- * @return {Array} A new array of rules with earlier duplicate `@keyframes` removed.
804
- */
805
- function deduplicateKeyframes (rules) {
806
- const lastIndexByName = new Map();
807
- for (let i = 0; i < rules.length; i++) {
808
- if (rules[i].type === 'keyframes' && rules[i].name) {
809
- lastIndexByName.set(rules[i].name, i);
810
- }
811
- }
812
- return rules.filter((rule, index) => {
813
- if (rule.type === 'keyframes' && rule.name) {
814
- return lastIndexByName.get(rule.name) === index;
815
- }
816
- return true;
817
- });
818
- }
819
-
820
- /**
821
- * Removes duplicate selectors from a combined selector list by normalizing
822
- * whitespace and preserving the first occurrence of each unique selector.
823
- *
824
- * @param {Array} selectors The selector strings to deduplicate.
825
- * @return {Array} A new array with duplicate selectors removed.
826
- */
827
- function deduplicateSelectors (selectors) {
828
- const seen = new Set();
829
- return selectors.filter((selector) => {
830
- // Normalize whitespace to single spaces for consistent comparison
831
- const normalized = selector.trim().replace(/\s+/g, ' ');
832
- if (seen.has(normalized)) {
833
- return false;
834
- }
835
- seen.add(normalized);
836
- return true;
837
- });
838
- }
839
-
840
- /**
841
- * The rule body entries that style nothing themselves, and so have no say in
842
- * whether one rule's body is carried by another's.
843
- *
844
- * @type {Set<string>}
845
- */
846
- const UNSTYLED_BODY_ENTRY_TYPES = new Set(['whitespace', 'comment']);
847
-
848
- /**
849
- * Splits a rule body into the declarations it sets directly and the blocks
850
- * nested inside it, leaving out the entries that style nothing.
851
- *
852
- * @param {Array} declarations The rule body entries.
853
- * @return {object} The body's `plainDeclarations` and `nestedBlocks`.
854
- */
855
- function partitionRuleBody (declarations) {
856
- const plainDeclarations = [];
857
- const nestedBlocks = [];
858
- for (const entry of declarations || []) {
859
- if (UNSTYLED_BODY_ENTRY_TYPES.has(entry.type)) {
860
- continue;
861
- }
862
- if (entry.property) {
863
- plainDeclarations.push(entry);
864
- } else {
865
- nestedBlocks.push(entry);
866
- }
867
- }
868
- return {
869
- nestedBlocks,
870
- plainDeclarations
871
- };
872
- }
873
-
874
- /**
875
- * Describes a block nested inside a rule precisely enough to tell whether two
876
- * rules nest the very same thing. Only plain nested rules can be described: an
877
- * at-rule nested in a rule carries a prelude that decides when its body applies,
878
- * so it is reported as indescribable rather than guessed at.
879
- *
880
- * @param {object} block A block nested inside a rule body.
881
- * @return {string|null} The signature, or null when the block cannot be described.
882
- */
883
- function nestedBlockSignature (block) {
884
- if (block.type !== 'rule' || !block.selectors?.length) {
885
- return null;
886
- }
887
- const {
888
- nestedBlocks,
889
- plainDeclarations
890
- } = partitionRuleBody(block.declarations);
891
- const declarationParts = plainDeclarations.map((declaration) => {
892
- return declaration.property + ':' + (declaration.value || '').trim();
893
- });
894
- const nestedParts = nestedBlocksSignature(nestedBlocks);
895
- if (nestedParts === null) {
896
- return null;
897
- }
898
- const selectors = block.selectors.map(normalizeSelector).join(',');
899
- return selectors + '{' + declarationParts.join(';') + nestedParts + '}';
900
- }
901
-
902
- /**
903
- * Describes every block nested in a rule body as one comparable string, in the
904
- * order the blocks are written, since that order decides which of them wins a
905
- * conflict.
906
- *
907
- * @param {Array} nestedBlocks The blocks nested in a rule body.
908
- * @return {string|null} The combined signature, or null when any block cannot be described.
909
- */
910
- function nestedBlocksSignature (nestedBlocks) {
911
- let combined = '';
912
- for (const block of nestedBlocks) {
913
- const signature = nestedBlockSignature(block);
914
- if (signature === null) {
915
- return null;
916
- }
917
- combined += signature;
918
- }
919
- return combined;
920
- }
921
-
922
- /**
923
- * Reports whether a rule that nests blocks may take on further selectors. A
924
- * nested selector resolves against its parent's whole selector list as `:is()`,
925
- * which matches with the specificity of the strongest selector in that list, so
926
- * the list may only grow while every selector in it weighs the same.
927
- *
928
- * @param {Array} selectors The selector list the merged rule would carry.
929
- * @return {boolean} Whether the nested blocks keep their current weight.
930
- */
931
- function selectorsShareSpecificity (selectors) {
932
- const strongest = maximumRuleSpecificity(selectors);
933
- const weakest = minimumRuleSpecificity(selectors);
934
- return compareSpecificity(strongest, weakest) === 0;
935
- }
936
-
937
- /**
938
- * Combines an earlier rule with the rule that follows it when everything the
939
- * earlier one styles is restated by the later one, so the earlier selector can
940
- * simply join the later rule's shared part and the remainder stays behind in a
941
- * rule of its own.
942
- *
943
- * Both rules have to nest the same blocks. A block nested in only one of them
944
- * would either be lost or start matching the other rule's selector, neither of
945
- * which the two rules said.
946
- *
947
- * @param {object} previousRule The rule immediately in front of `rule`.
948
- * @param {object} rule The rule being folded into its predecessor.
949
- * @return {Array|null} The rules that replace the pair, or null when they cannot be combined.
950
- */
951
- function mergeRuleIntoPredecessor (previousRule, rule) {
952
- const isMergeableRulePair = (
953
- previousRule?.type === 'rule' &&
954
- previousRule.selectors?.length &&
955
- rule.type === 'rule' &&
956
- rule.selectors?.length
957
- );
958
- if (!isMergeableRulePair) {
959
- return null;
960
- }
961
- const previousBody = partitionRuleBody(previousRule.declarations);
962
- const currentBody = partitionRuleBody(rule.declarations);
963
- if (!previousBody.plainDeclarations.length || !currentBody.plainDeclarations.length) {
964
- return null;
965
- }
966
- const previousNesting = nestedBlocksSignature(previousBody.nestedBlocks);
967
- const currentNesting = nestedBlocksSignature(currentBody.nestedBlocks);
968
- if (previousNesting === null || previousNesting !== currentNesting) {
969
- return null;
970
- }
971
- const currentValueByProperty = new Map(
972
- currentBody.plainDeclarations.map((declaration) => {
973
- return [declaration.property, (declaration.value || '').trim()];
974
- })
975
- );
976
- const previousIsRestated = previousBody.plainDeclarations.every((declaration) => {
977
- return currentValueByProperty.get(declaration.property) === (declaration.value || '').trim();
978
- });
979
- // `all` rewrites every other property, so the later rule restating a value is
980
- // no promise that it applies the same way once `all` has run.
981
- const currentResetsEverything = currentValueByProperty.has('all');
982
- if (!previousIsRestated || currentResetsEverything) {
983
- return null;
984
- }
985
- const combinedSelectors = deduplicateSelectors([...previousRule.selectors, ...rule.selectors]);
986
- if (previousBody.nestedBlocks.length && !selectorsShareSpecificity(combinedSelectors)) {
987
- return null;
988
- }
989
- const restatedProperties = new Set(
990
- previousBody.plainDeclarations.map((declaration) => {
991
- return declaration.property;
992
- })
993
- );
994
- const remainingDeclarations = currentBody.plainDeclarations.filter((declaration) => {
995
- return !restatedProperties.has(declaration.property);
996
- });
997
- const merged = [{ ...previousRule, selectors: combinedSelectors }];
998
- if (remainingDeclarations.length) {
999
- merged.push({ ...rule, declarations: remainingDeclarations });
1000
- }
1001
- return merged;
1002
- }
1003
-
1004
- /**
1005
- * Merges consecutive rules whose declarations are a subset of the following rule, combining their selectors and splitting out any extra declarations.
1006
- *
1007
- * @param {Array} rules The AST rule nodes to merge.
1008
- * @return {Array} A new array of rules with declaration-compatible consecutive rules combined.
1009
- */
1010
- function mergeByDeclarations (rules) {
1011
- const result = [];
1012
- for (const rule of rules) {
1013
- const merged = mergeRuleIntoPredecessor(result[result.length - 1], rule);
1014
- if (merged) {
1015
- result.pop();
1016
- result.push(...merged);
1017
- continue;
1018
- }
1019
- result.push(rule);
1020
- }
1021
- return result;
1022
- }
1023
-
1024
- /**
1025
- * Advances past a CSS identifier (name) starting at the given index.
1026
- *
1027
- * @param {string} text The selector text.
1028
- * @param {number} start The index to start scanning from.
1029
- * @return {number} The index just after the identifier.
1030
- */
1031
- function skipSelectorName (text, start) {
1032
- let index = start;
1033
- // Advance over identifier characters (letters, digits, hyphen, underscore)
1034
- while (index < text.length && isSelectorNameCharacter(text.charCodeAt(index))) {
1035
- index++;
1036
- }
1037
- return index;
1038
- }
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
-
1068
- /**
1069
- * Compares two specificity tuples [ids, classes, types] lexicographically.
1070
- *
1071
- * @param {Array} first The first specificity tuple.
1072
- * @param {Array} second The second specificity tuple.
1073
- * @return {number} Negative, zero, or positive per standard comparison.
1074
- */
1075
- function compareSpecificity (first, second) {
1076
- for (let index = 0; index < 3; index++) {
1077
- if (first[index] !== second[index]) {
1078
- return first[index] - second[index];
1079
- }
1080
- }
1081
- return 0;
1082
- }
1083
-
1084
- /**
1085
- * Computes the CSS specificity of a single complex selector as an
1086
- * [ids, classes, types] tuple. The nesting selector `&` is ignored because its
1087
- * contribution comes from the shared parent when comparing sibling selectors.
1088
- * Functional pseudo-classes `:is()`, `:not()`, `:has()`, and `:matches()` add
1089
- * the maximum specificity of their arguments; `:where()` adds nothing.
1090
- *
1091
- * @param {string} selector A single complex selector string.
1092
- * @return {Array} The [ids, classes, types] specificity tuple.
1093
- */
1094
- function computeSpecificity (selector) {
1095
- const specificity = [0, 0, 0];
1096
- const text = selector.trim();
1097
- let index = 0;
1098
- while (index < text.length) {
1099
- const character = text[index];
1100
- if (character === '#') {
1101
- specificity[0]++;
1102
- index = skipSelectorName(text, index + 1);
1103
- } else if (character === '.') {
1104
- specificity[1]++;
1105
- index = skipSelectorName(text, index + 1);
1106
- } else if (character === '[') {
1107
- specificity[1]++;
1108
- // An attribute selector that never closes runs to the end of the selector
1109
- const closeBracketIndex = findMatchingBracket(text, index);
1110
- index = closeBracketIndex === -1 ? text.length : closeBracketIndex + 1;
1111
- } else if (character === ':') {
1112
- if (text[index + 1] === ':') {
1113
- specificity[2]++;
1114
- index = skipSelectorName(text, index + 2);
1115
- } else {
1116
- const nameStart = index + 1;
1117
- const nameEnd = skipSelectorName(text, nameStart);
1118
- const name = text.slice(nameStart, nameEnd).toLowerCase();
1119
- if (text[nameEnd] === '(') {
1120
- // An argument list that never closes runs to the end of the selector
1121
- const closeIndex = findMatchingParenthesis(text, nameEnd);
1122
- const argumentsEnd = closeIndex === -1 ? text.length : closeIndex;
1123
- const inner = text.slice(nameEnd + 1, argumentsEnd);
1124
- if (name === 'where') {
1125
- // :where() contributes zero specificity
1126
- } else if (name === 'is' || name === 'not' || name === 'has' || name === 'matches') {
1127
- const innerMax = maxSelectorSpecificity(inner);
1128
- specificity[0] += innerMax[0];
1129
- specificity[1] += innerMax[1];
1130
- specificity[2] += innerMax[2];
1131
- } else {
1132
- specificity[1]++;
1133
- }
1134
- index = argumentsEnd + 1;
1135
- } else {
1136
- // Legacy single-colon pseudo-elements count as pseudo-elements
1137
- if (name === 'before' || name === 'after' || name === 'first-line' || name === 'first-letter') {
1138
- specificity[2]++;
1139
- } else {
1140
- specificity[1]++;
1141
- }
1142
- index = nameEnd;
1143
- }
1144
- }
1145
- } else if (character === '*' || character === '&') {
1146
- // Universal selector and nesting selector add no counted specificity here
1147
- index++;
1148
- } else if (isAsciiLetter(text.charCodeAt(index))) {
1149
- specificity[2]++;
1150
- index = skipSelectorName(text, index);
1151
- } else {
1152
- // Combinators and whitespace do not affect specificity
1153
- index++;
1154
- }
1155
- }
1156
- return specificity;
1157
- }
1158
-
1159
- /**
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.
1191
- *
1192
- * @param {string} selectorList The selector list string.
1193
- * @return {object} The `strongest` and `weakest` [ids, classes, types] tuples.
1194
- */
1195
- function computeSpecificityRange (selectorList) {
1196
- let strongest = [0, 0, 0];
1197
- let weakest = null;
1198
- for (const selector of splitTopLevelCommaList(selectorList)) {
1199
- const specificity = computeSpecificity(selector);
1200
- if (compareSpecificity(specificity, strongest) > 0) {
1201
- strongest = specificity;
1202
- }
1203
- if (!weakest || compareSpecificity(specificity, weakest) < 0) {
1204
- weakest = specificity;
1205
- }
1206
- }
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;
1238
- }
1239
-
1240
- /**
1241
- * Returns the strongest specificity a rule can match with, which is the highest
1242
- * of its individual selectors.
1243
- *
1244
- * @param {Array} selectors The rule's selector strings.
1245
- * @return {Array} The [ids, classes, types] specificity tuple.
1246
- */
1247
- function maximumRuleSpecificity (selectors) {
1248
- return maxSelectorSpecificity((selectors || []).join(','));
1249
- }
1250
-
1251
- /**
1252
- * Returns the weakest specificity a rule's declarations are applied with. Every
1253
- * selector of a top-level rule applies the same declarations under its own
1254
- * specificity, so the weakest selector decides whether another rule is able to
1255
- * override the rule as a whole.
1256
- *
1257
- * @param {Array} selectors The rule's selector strings.
1258
- * @return {Array} The [ids, classes, types] specificity tuple.
1259
- */
1260
- function minimumRuleSpecificity (selectors) {
1261
- return getSpecificityRange((selectors || []).join(',')).weakest;
1262
- }
1263
-
1264
- /**
1265
- * Adds two specificity tuples together, which is how a nested selector combines
1266
- * with the parent selector it only ever matches through.
1267
- *
1268
- * @param {Array} first The first [ids, classes, types] tuple.
1269
- * @param {Array} second The second [ids, classes, types] tuple.
1270
- * @return {Array} The summed specificity tuple.
1271
- */
1272
- function addSpecificity (first, second) {
1273
- return [
1274
- first[0] + second[0],
1275
- first[1] + second[1],
1276
- first[2] + second[2]
1277
- ];
1278
- }
1279
-
1280
- /**
1281
- * Builds a normalized signature of a rule's declaration body, or null when the
1282
- * rule contains anything other than plain declarations (e.g. nested rules).
1283
- *
1284
- * @param {object} rule The AST rule node.
1285
- * @return {string|null} The sorted "property:value" signature, or null.
1286
- */
1287
- function nestedRuleBodySignature (rule) {
1288
- const declarations = (rule.declarations || []).filter((declaration) => {
1289
- return declaration.type !== 'whitespace' && declaration.type !== 'comment';
1290
- });
1291
- if (declarations.length === 0) {
1292
- return null;
1293
- }
1294
- const isAllPlainDeclarations = declarations.every((declaration) => {
1295
- return declaration.type === 'declaration' && declaration.property;
1296
- });
1297
- if (!isAllPlainDeclarations) {
1298
- return null;
1299
- }
1300
- return declarations
1301
- .map((declaration) => {
1302
- return declaration.property + ':' + (declaration.value || '').trim();
1303
- })
1304
- .sort()
1305
- .join(';');
1306
- }
1307
-
1308
- /**
1309
- * Determines whether two nested rules can be merged into a shared selector list.
1310
- * They must have identical declaration bodies and the same specificity level,
1311
- * since comma-separated nested selectors share the highest specificity of the
1312
- * group (like `:is()`).
1313
- *
1314
- * @param {object} first The first nested rule.
1315
- * @param {object} second The second nested rule.
1316
- * @return {boolean} Whether the two nested rules may be merged.
1317
- */
1318
- function nestedRulesMergeable (first, second) {
1319
- if (!first.selectors?.length || !second.selectors?.length) {
1320
- return false;
1321
- }
1322
- const firstSignature = nestedRuleBodySignature(first);
1323
- if (firstSignature === null || firstSignature !== nestedRuleBodySignature(second)) {
1324
- return false;
1325
- }
1326
- const firstSpecificity = maxSelectorSpecificity(first.selectors.join(','));
1327
- const secondSpecificity = maxSelectorSpecificity(second.selectors.join(','));
1328
- return compareSpecificity(firstSpecificity, secondSpecificity) === 0;
1329
- }
1330
-
1331
- /**
1332
- * Recursively merges consecutive nested rules within a declaration list when
1333
- * they share identical bodies and specificity, combining their selector lists.
1334
- *
1335
- * @param {Array} declarations The declaration/nested-rule entries of a parent rule.
1336
- * @return {Array} The declaration list with mergeable nested rules combined.
1337
- */
1338
- function mergeConsecutiveNestedRules (declarations) {
1339
- const recursed = declarations.map((declaration) => {
1340
- if (declaration.type === 'rule' && declaration.declarations) {
1341
- return { ...declaration, declarations: mergeConsecutiveNestedRules(declaration.declarations) };
1342
- }
1343
- return declaration;
1344
- });
1345
-
1346
- const result = [];
1347
- for (const declaration of recursed) {
1348
- const previous = result[result.length - 1];
1349
- if (
1350
- declaration.type === 'rule' &&
1351
- previous &&
1352
- previous.type === 'rule' &&
1353
- nestedRulesMergeable(previous, declaration)
1354
- ) {
1355
- result[result.length - 1] = {
1356
- ...previous,
1357
- selectors: [...previous.selectors, ...declaration.selectors]
1358
- };
1359
- continue;
1360
- }
1361
- result.push(declaration);
1362
- }
1363
- return result;
1364
- }
1365
-
1366
- /**
1367
- * Merges mergeable nested rules within every rule's body across the stylesheet,
1368
- * recursing into `@media` and `@layer` blocks. Top-level rules are not merged
1369
- * here, since only nested (comma-grouped) selectors share specificity.
1370
- *
1371
- * @param {Array} rules The AST rule nodes to process.
1372
- * @return {Array} The rules with mergeable nested rules combined.
1373
- */
1374
- function mergeIdenticalNestedRules (rules) {
1375
- for (const rule of rules) {
1376
- if (rule.type === 'rule' && rule.declarations) {
1377
- rule.declarations = mergeConsecutiveNestedRules(rule.declarations);
1378
- } else if ((rule.type === 'media' || rule.type === 'layer') && rule.rules) {
1379
- rule.rules = mergeIdenticalNestedRules(rule.rules);
1380
- }
1381
- }
1382
- return rules;
1383
- }
1384
-
1385
- /**
1386
- * The property that rewrites every other standard property, and so conflicts
1387
- * with any declaration it is ordered against.
1388
- *
1389
- * @type {string}
1390
- */
1391
- const RESET_ALL_PROPERTY = 'all';
1392
-
1393
- /**
1394
- * Index key standing for "declares anything at all", used to order a rule that
1395
- * sets `all` against every other declaration.
1396
- *
1397
- * @type {symbol}
1398
- */
1399
- const ANY_PROPERTY = Symbol('any property');
1400
-
1401
- /**
1402
- * The leaf properties each declared property can write to, computed on first
1403
- * use. The shorthand tables never change, so a property always expands the same
1404
- * way.
1405
- *
1406
- * @type {Map<string, Set<string>>}
1407
- */
1408
- const overridablePropertiesByProperty = new Map();
1409
-
1410
- /**
1411
- * Expands a declared property into every leaf longhand it can write to,
1412
- * including the extras a shorthand resets beyond its own longhands. Two
1413
- * declarations can only override one another when these sets intersect, which
1414
- * is what makes `margin` conflict with `margin-top` but not with `color`.
1415
- *
1416
- * @param {string} property The declared property name.
1417
- * @return {Set} The leaf property names the declaration writes to.
1418
- */
1419
- function expandToOverridableProperties (property) {
1420
- const cachedProperties = overridablePropertiesByProperty.get(property);
1421
- if (cachedProperties) {
1422
- return cachedProperties;
1423
- }
1424
- const overridableProperties = new Set(expandToLeafProperties(property));
1425
- for (const resetProperty of getOverridesOf(property)) {
1426
- for (const leafProperty of expandToLeafProperties(resetProperty)) {
1427
- overridableProperties.add(leafProperty);
1428
- }
1429
- }
1430
- overridablePropertiesByProperty.set(property, overridableProperties);
1431
- return overridableProperties;
1432
- }
1433
-
1434
- /**
1435
- * Whether a property name is a custom property, which shorthands and `all`
1436
- * never reset.
1437
- *
1438
- * @param {string} property The leaf property name.
1439
- * @return {boolean} True when the property is a custom property.
1440
- */
1441
- function isCustomProperty (property) {
1442
- return property.startsWith('--');
1443
- }
1444
-
1445
- /**
1446
- * @typedef {object} RuleWriteProfile
1447
- * @property {Map} specificityByProperty The strongest specificity each leaf property is written with.
1448
- * @property {Array} weakestSpecificity The weakest specificity the rule's declarations are applied with.
1449
- * @property {Array} strongestSpecificity The strongest specificity anything in the rule is written with.
1450
- * @property {boolean} writesStandardProperty Whether the rule writes any property other than a custom property.
1451
- */
1452
-
1453
- /**
1454
- * Notes in a profile that a leaf property is written with a specificity,
1455
- * keeping only the strongest specificity seen for it.
1456
- *
1457
- * @param {RuleWriteProfile} profile The profile to extend.
1458
- * @param {string} property The leaf property name.
1459
- * @param {Array} specificity The specificity the property is written with.
1460
- */
1461
- function addWrittenProperty (profile, property, specificity) {
1462
- const strongestSoFar = profile.specificityByProperty.get(property);
1463
- if (!strongestSoFar || compareSpecificity(specificity, strongestSoFar) > 0) {
1464
- profile.specificityByProperty.set(property, specificity);
1465
- }
1466
- if (compareSpecificity(specificity, profile.strongestSpecificity) > 0) {
1467
- profile.strongestSpecificity = specificity;
1468
- }
1469
- profile.writesStandardProperty = profile.writesStandardProperty || !isCustomProperty(property);
1470
- }
1471
-
1472
- /**
1473
- * Adds every leaf property a rule's declarations write to into a profile,
1474
- * descending into nested rules, whose declarations only ever match through the
1475
- * parent and so carry the specificity of both selectors.
1476
- *
1477
- * @param {RuleWriteProfile} profile The profile to extend.
1478
- * @param {object} rule The AST rule node to read declarations from.
1479
- * @param {Array} specificity The specificity the rule's own declarations apply with.
1480
- */
1481
- function addWrittenProperties (profile, rule, specificity) {
1482
- for (const declaration of rule.declarations || []) {
1483
- if (declaration.type === 'rule') {
1484
- const nestedSpecificity = addSpecificity(specificity, maximumRuleSpecificity(declaration.selectors));
1485
- addWrittenProperties(profile, declaration, nestedSpecificity);
1486
- } else if (declaration.type === 'declaration' && declaration.property) {
1487
- for (const leafProperty of expandToOverridableProperties(declaration.property)) {
1488
- addWrittenProperty(profile, leafProperty, specificity);
1489
- }
1490
- }
1491
- }
1492
- }
1493
-
1494
- /**
1495
- * Summarizes which properties a rule writes to and how strongly, which is
1496
- * everything the cascade needs to know about a rule to decide whether it may be
1497
- * reordered against another one.
1498
- *
1499
- * @param {object} rule The AST rule node.
1500
- * @return {RuleWriteProfile} The rule's write profile.
1501
- */
1502
- function buildRuleWriteProfile (rule) {
1503
- const profile = {
1504
- specificityByProperty: new Map(),
1505
- weakestSpecificity: minimumRuleSpecificity(rule.selectors),
1506
- strongestSpecificity: [0, 0, 0],
1507
- writesStandardProperty: false
1508
- };
1509
- addWrittenProperties(profile, rule, maximumRuleSpecificity(rule.selectors));
1510
- return profile;
1511
- }
1512
-
1513
- /**
1514
- * Folds one rule's writes into another's profile, which is how a profile stays
1515
- * current after a merge appends declarations to a rule. Both rules share the
1516
- * same selectors, so the absorbed writes keep their recorded specificity.
1517
- *
1518
- * @param {RuleWriteProfile} profile The profile of the rule that grew.
1519
- * @param {RuleWriteProfile} absorbedWrites The profile of the rule whose declarations were appended.
1520
- */
1521
- function absorbWriteProfile (profile, absorbedWrites) {
1522
- for (const [property, specificity] of absorbedWrites.specificityByProperty) {
1523
- addWrittenProperty(profile, property, specificity);
1524
- }
1525
- }
1526
-
1527
- /**
1528
- * Tracks which properties each rule writes to and where it sits, so that a
1529
- * merge can ask whether anything after a position could override the
1530
- * declarations it wants to move, instead of rescanning the stylesheet once per
1531
- * merge.
1532
- *
1533
- * Entries for a property are appended in increasing position order, and each
1534
- * new entry discards the earlier ones it dominates, since a declaration that is
1535
- * both later and at least as specific blocks everything a weaker, earlier one
1536
- * would. What remains is a list whose specificity strictly decreases as
1537
- * position grows, so the first entry past a queried position is also the
1538
- * strongest one past it.
1539
- *
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`.
1542
- */
1543
- function createOverrideIndex (profileByRule) {
1544
- const entriesByProperty = new Map();
1545
-
1546
- /**
1547
- * Records that a property is written at a position with a given specificity.
1548
- *
1549
- * @param {string|symbol} property The leaf property name, or an index key.
1550
- * @param {number} position The slot the writing rule occupies.
1551
- * @param {Array} specificity The specificity the declaration applies with.
1552
- */
1553
- function addEntry (property, position, specificity) {
1554
- let entries = entriesByProperty.get(property);
1555
- if (!entries) {
1556
- entries = [];
1557
- entriesByProperty.set(property, entries);
1558
- }
1559
- while (entries.length && compareSpecificity(entries[entries.length - 1].specificity, specificity) <= 0) {
1560
- entries.pop();
1561
- }
1562
- entries.push({ position, specificity });
1563
- }
1564
-
1565
- /**
1566
- * Whether some rule after a position writes to a property with at least a
1567
- * given specificity, and could therefore win the cascade against it.
1568
- *
1569
- * @param {string|symbol} property The leaf property name, or an index key.
1570
- * @param {number} position The position to search after.
1571
- * @param {Array} specificity The specificity to compare against.
1572
- * @return {boolean} True when a later rule could override the property.
1573
- */
1574
- function hasStrongerEntryAfter (property, position, specificity) {
1575
- const entries = entriesByProperty.get(property);
1576
- if (!entries) {
1577
- return false;
1578
- }
1579
- let low = 0;
1580
- let high = entries.length;
1581
- while (low < high) {
1582
- const middle = Math.floor((low + high) / 2);
1583
- if (entries[middle].position > position) {
1584
- high = middle;
1585
- } else {
1586
- low = middle + 1;
1587
- }
1588
- }
1589
- if (low === entries.length) {
1590
- return false;
1591
- }
1592
- return compareSpecificity(entries[low].specificity, specificity) >= 0;
1593
- }
1594
-
1595
- /**
1596
- * Returns a rule's write profile, building it the first time it is needed.
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.
1600
- *
1601
- * @param {object} rule The AST rule node.
1602
- * @return {RuleWriteProfile} The rule's write profile.
1603
- */
1604
- function getWriteProfile (rule) {
1605
- let profile = profileByRule.get(rule);
1606
- if (!profile) {
1607
- profile = buildRuleWriteProfile(rule);
1608
- profileByRule.set(rule, profile);
1609
- }
1610
- return profile;
1611
- }
1612
-
1613
- /**
1614
- * Records every property a rule writes to at the position it occupies.
1615
- *
1616
- * @param {object} rule The AST rule node.
1617
- * @param {number} position The slot the rule occupies.
1618
- */
1619
- function recordRule (rule, position) {
1620
- const profile = getWriteProfile(rule);
1621
- if (!profile.specificityByProperty.size) {
1622
- return;
1623
- }
1624
- for (const [property, specificity] of profile.specificityByProperty) {
1625
- addEntry(property, position, specificity);
1626
- }
1627
- addEntry(ANY_PROPERTY, position, profile.strongestSpecificity);
1628
- }
1629
-
1630
- /**
1631
- * Folds a rule's writes into the rule that just absorbed its declarations, so
1632
- * the grown rule is treated as writing to both sets of properties.
1633
- *
1634
- * @param {object} rule The rule that grew.
1635
- * @param {object} absorbedRule The rule whose declarations were appended to it.
1636
- */
1637
- function absorbRule (rule, absorbedRule) {
1638
- absorbWriteProfile(getWriteProfile(rule), getWriteProfile(absorbedRule));
1639
- }
1640
-
1641
- /**
1642
- * Whether moving a rule's declarations past everything recorded after a
1643
- * position would change which declaration wins the cascade. Only a rule that
1644
- * is strictly less specific is guaranteed to lose either way, so anything of
1645
- * equal or greater specificity that writes to the same properties blocks the
1646
- * move.
1647
- *
1648
- * @param {object} movingRule The rule whose declarations would relocate.
1649
- * @param {number} afterPosition The position the declarations would move across.
1650
- * @return {boolean} True when the relocation is unsafe.
1651
- */
1652
- function blocksRelocation (movingRule, afterPosition) {
1653
- const profile = getWriteProfile(movingRule);
1654
- const specificity = profile.weakestSpecificity;
1655
- for (const property of profile.specificityByProperty.keys()) {
1656
- if (hasStrongerEntryAfter(property, afterPosition, specificity)) {
1657
- return true;
1658
- }
1659
- }
1660
- if (profile.writesStandardProperty && hasStrongerEntryAfter(RESET_ALL_PROPERTY, afterPosition, specificity)) {
1661
- return true;
1662
- }
1663
- return (
1664
- profile.specificityByProperty.has(RESET_ALL_PROPERTY) &&
1665
- hasStrongerEntryAfter(ANY_PROPERTY, afterPosition, specificity)
1666
- );
1667
- }
1668
-
1669
- return {
1670
- absorbRule,
1671
- blocksRelocation,
1672
- recordRule
1673
- };
1674
- }
1675
-
1676
- /**
1677
- * Builds the key two rules must share to be considered the same selector.
1678
- *
1679
- * @param {object} rule The AST rule node.
1680
- * @return {string} The normalized, order independent selector key.
1681
- */
1682
- function buildSelectorKey (rule) {
1683
- if (!rule.selectors) {
1684
- return '';
1685
- }
1686
- return rule.selectors
1687
- .map((selector) => {
1688
- return normalizeSelector(selector);
1689
- })
1690
- .sort()
1691
- .join(',');
1692
- }
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
-
1712
- /**
1713
- * Placement for a merged rule whose combined declarations belong where the
1714
- * later of the two rules was, because the earlier declarations moved down.
1715
- *
1716
- * @type {string}
1717
- */
1718
- const MERGE_AT_LATER_RULE = 'later';
1719
-
1720
- /**
1721
- * Placement for a merged rule whose combined declarations belong where the
1722
- * earlier of the two rules was, because the later declarations moved up.
1723
- *
1724
- * @type {string}
1725
- */
1726
- const MERGE_AT_EARLIER_RULE = 'earlier';
1727
-
1728
- /**
1729
- * Reports whether an entry of a rule's body is a declaration whose standing in
1730
- * the cascade can be read off the property it writes. Anything else a body may
1731
- * hold, such as a nested rule, is never shadowed by a declaration beside it.
1732
- *
1733
- * @param {object} entry An entry of a rule's declaration list.
1734
- * @return {boolean} True when the entry is a property declaration.
1735
- */
1736
- function isPropertyDeclaration (entry) {
1737
- return entry.type === 'declaration' && Boolean(entry.property);
1738
- }
1739
-
1740
- /**
1741
- * Finds which declaration decides each leaf property once a list of
1742
- * declarations is read as one rule's body. Within a rule, an important
1743
- * declaration beats a non-important one, and between two of equal weight the
1744
- * last one written wins.
1745
- *
1746
- * @param {Array} declarations The declarations of the combined rule, in the order they would be written.
1747
- * @return {Map} Map of leaf property name to the declaration that decides it.
1748
- */
1749
- function findDecidingDeclarations (declarations) {
1750
- const decidingDeclarations = new Map();
1751
- const importantDeclarations = new Set();
1752
- for (const declaration of declarations) {
1753
- if (!isPropertyDeclaration(declaration)) {
1754
- continue;
1755
- }
1756
- const isImportant = isImportantDeclaration(declaration);
1757
- if (isImportant) {
1758
- importantDeclarations.add(declaration);
1759
- }
1760
- for (const leafProperty of expandToOverridableProperties(declaration.property)) {
1761
- const decidingDeclaration = decidingDeclarations.get(leafProperty);
1762
- const outranksIt = (
1763
- !decidingDeclaration ||
1764
- isImportant ||
1765
- !importantDeclarations.has(decidingDeclaration)
1766
- );
1767
- if (outranksIt) {
1768
- decidingDeclarations.set(leafProperty, declaration);
1769
- }
1770
- }
1771
- }
1772
- return decidingDeclarations;
1773
- }
1774
-
1775
- /**
1776
- * Reports whether anything a rule brings to a merge still decides a property
1777
- * of the combined rule. A declaration that loses every property it writes to
1778
- * another declaration of the same rule is dropped from the merged rule, so
1779
- * where the merged rule sits cannot change how it reads.
1780
- *
1781
- * @param {object} rule The rule contributing the entries.
1782
- * @param {Map} decidingDeclarations The deciding declaration of each leaf property of the merged rule.
1783
- * @return {boolean} True when the rule still decides something after the merge.
1784
- */
1785
- function decidesAnythingAfterMerge (rule, decidingDeclarations) {
1786
- return (rule.declarations || []).some((entry) => {
1787
- if (!isPropertyDeclaration(entry)) {
1788
- return entry.type !== 'whitespace' && entry.type !== 'comment';
1789
- }
1790
- return [...expandToOverridableProperties(entry.property)].some((leafProperty) => {
1791
- return decidingDeclarations.get(leafProperty) === entry;
1792
- });
1793
- });
1794
- }
1795
-
1796
- /**
1797
- * Decides where two rules with the same selector can be combined. Merging them
1798
- * always makes one set of declarations cross whatever separates the two rules,
1799
- * which can flip a conflict the crossing declarations used to win or lose, so
1800
- * the merged rule goes wherever the set that moved keeps its old standing.
1801
- * Nothing separates adjacent rules, so those always merge.
1802
- *
1803
- * The declarations one rule loses to the other are the exception: they are
1804
- * dropped from the merged rule, so the merged rule belongs wherever the
1805
- * declarations that outlive the merge already were, and nothing has to cross
1806
- * anything at all. That is what lets `.a{color:red!important}` swallow a later
1807
- * `.a{color:tan}` across a rule that also writes `color`.
1808
- *
1809
- * @param {object} overrideIndex The index of what each position writes to.
1810
- * @param {object} earlierRule The first of the two rules with this selector.
1811
- * @param {object} laterRule The second of the two rules with this selector.
1812
- * @param {number} earlierPosition The slot the earlier rule occupies.
1813
- * @param {number} laterPosition The slot the later rule would occupy.
1814
- * @return {string|null} Where to place the merged rule, or null when merging is unsafe.
1815
- */
1816
- function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPosition, laterPosition) {
1817
- const rulesAreAdjacent = earlierPosition === laterPosition - 1;
1818
- if (rulesAreAdjacent) {
1819
- return MERGE_AT_LATER_RULE;
1820
- }
1821
- const decidingDeclarations = findDecidingDeclarations([
1822
- ...(earlierRule.declarations || []),
1823
- ...(laterRule.declarations || [])
1824
- ]);
1825
- if (!decidesAnythingAfterMerge(laterRule, decidingDeclarations)) {
1826
- return MERGE_AT_EARLIER_RULE;
1827
- }
1828
- if (!decidesAnythingAfterMerge(earlierRule, decidingDeclarations)) {
1829
- return MERGE_AT_LATER_RULE;
1830
- }
1831
- if (!overrideIndex.blocksRelocation(earlierRule, earlierPosition)) {
1832
- return MERGE_AT_LATER_RULE;
1833
- }
1834
- if (!overrideIndex.blocksRelocation(laterRule, earlierPosition)) {
1835
- return MERGE_AT_EARLIER_RULE;
1836
- }
1837
- return null;
1838
- }
1839
-
1840
- /**
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.
1842
- *
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.
1846
- */
1847
- function mergeSelectorRulePass (rules, mergeCaches) {
1848
- // A rule merged at the later of the two positions moves to the end of the
1849
- // output. Its old slot is emptied instead of spliced out so that every
1850
- // recorded position stays valid, and the position map locates that slot
1851
- // without searching the output.
1852
- const slots = [];
1853
- const positionByRule = new Map();
1854
- const selectorMap = new Map();
1855
- const overrideIndex = createOverrideIndex(mergeCaches.profileByRule);
1856
- for (const rule of rules) {
1857
- if (rule.type === 'whitespace') {
1858
- continue;
1859
- }
1860
- if (rule.type !== 'rule') {
1861
- slots.push(rule);
1862
- // Clearing a map replaces its storage, so already empty maps are left alone
1863
- if (rule.type !== 'comment' && selectorMap.size) {
1864
- selectorMap.clear();
1865
- positionByRule.clear();
1866
- }
1867
- continue;
1868
- }
1869
- const selectorKey = getSelectorKey(rule, mergeCaches.selectorKeyByRule);
1870
- const existingRule = selectorKey && selectorMap.get(selectorKey);
1871
- if (existingRule) {
1872
- const existingPosition = positionByRule.get(existingRule);
1873
- const mergedPosition = slots.length;
1874
- const placement = chooseMergePlacement(overrideIndex, existingRule, rule, existingPosition, mergedPosition);
1875
- if (placement) {
1876
- existingRule.declarations.push(...(rule.declarations || []));
1877
- overrideIndex.absorbRule(existingRule, rule);
1878
- if (placement === MERGE_AT_LATER_RULE) {
1879
- slots[existingPosition] = null;
1880
- positionByRule.set(existingRule, mergedPosition);
1881
- slots.push(existingRule);
1882
- overrideIndex.recordRule(existingRule, mergedPosition);
1883
- } else {
1884
- // The merged declarations now live at the earlier slot, but they are
1885
- // recorded at the later one, since the index is only ever appended
1886
- // to. Reading them as later than they are can only hold back a
1887
- // further merge, never allow an unsafe one.
1888
- overrideIndex.recordRule(rule, mergedPosition);
1889
- }
1890
- continue;
1891
- }
1892
- }
1893
- selectorMap.set(selectorKey, rule);
1894
- positionByRule.set(rule, slots.length);
1895
- overrideIndex.recordRule(rule, slots.length);
1896
- slots.push(rule);
1897
- }
1898
- return slots.filter((slot) => {
1899
- return slot !== null;
1900
- });
1901
- }
1902
-
1903
- /**
1904
- * Reconsiders blocked pairs after merges relocate the rules that blocked them.
1905
- * Each productive pass removes a rule, so this reaches a fixed point without
1906
- * rerunning parsing or unrelated optimizations.
1907
- *
1908
- * @param {Array} rules The sibling rules to merge.
1909
- * @return {Array} Rules with all currently safe same-selector merges.
1910
- */
1911
- function mergeSelectorRules (rules) {
1912
- const mergeCaches = {
1913
- profileByRule: new Map(),
1914
- selectorKeyByRule: new Map()
1915
- };
1916
- let previousRules = rules;
1917
- let mergedRules = mergeSelectorRulePass(previousRules, mergeCaches);
1918
- while (mergedRules.length < previousRules.length) {
1919
- previousRules = mergedRules;
1920
- mergedRules = mergeSelectorRulePass(previousRules, mergeCaches);
1921
- }
1922
- return mergedRules;
1923
- }
1924
-
1925
- /**
1926
- * Merges `@layer` blocks with the same name by combining their child rules, deduplicates layer statements, and merges selector rules within each block.
1927
- *
1928
- * @param {Array} rules The AST rule nodes to process.
1929
- * @param {function(Array): Array} mergeSelectorRules Callback to merge selector rules within each layer block.
1930
- * @return {Array} A new array of rules with same-name `@layer` blocks combined.
1931
- */
1932
- function mergeLayerRules (rules, mergeSelectorRules) {
1933
- const layerBlockMap = new Map();
1934
- const layerStatementSeen = new Set();
1935
- const result = [];
1936
- for (const rule of rules) {
1937
- if (rule.type === 'layer') {
1938
- const layerName = normalizeLayerNames(rule.layer);
1939
- if (rule.rules && rule.rules.length > 0) {
1940
- if (layerName && layerBlockMap.has(layerName)) {
1941
- layerBlockMap.get(layerName).rules.push(...rule.rules);
1942
- } else {
1943
- if (layerName) {
1944
- layerBlockMap.set(layerName, rule);
1945
- }
1946
- result.push(rule);
1947
- }
1948
- } else {
1949
- if (!layerName || !layerStatementSeen.has(layerName)) {
1950
- if (layerName) {
1951
- layerStatementSeen.add(layerName);
1952
- }
1953
- result.push(rule);
1954
- }
1955
- }
1956
- } else {
1957
- result.push(rule);
1958
- }
1959
- }
1960
- for (const rule of result) {
1961
- if (rule.type === 'layer' && rule.rules && rule.rules.length) {
1962
- rule.rules = mergeSelectorRules(rule.rules);
1963
- }
1964
- }
1965
- return result;
1966
- }
1967
-
1968
- /**
1969
- * Normalizes a selector string for consistent comparison by trimming
1970
- * and collapsing internal whitespace.
1971
- *
1972
- * @param {string} selector The raw selector string.
1973
- * @return {string} The normalized selector.
1974
- */
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
1982
- // Convert double-colon ::before/::after to single-colon legacy form
1983
- .replace(/::before\b/g, ':before')
1984
- .replace(/::after\b/g, ':after');
1985
- }
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
-
2004
- /**
2005
- * Indexes, for every normalized selector in the stylesheet, the last rule that
2006
- * declares each property under it. Comparing that index against a rule's own
2007
- * position answers "is this selector's property overridden later" in constant
2008
- * time, instead of rescanning and renormalizing every following rule.
2009
- *
2010
- * @param {Array} rules The flat list of AST rule nodes.
2011
- * @return {Map} Map of normalized selector to a map of property name to its last declaring rule index.
2012
- */
2013
- function indexLastDeclaringRuleBySelector (rules) {
2014
- const lastRuleIndexBySelector = new Map();
2015
- rules.forEach((rule, ruleIndex) => {
2016
- if (rule.type !== 'rule' || !rule.selectors) {
2017
- return;
2018
- }
2019
- const declaredProperties = (rule.declarations || []).filter((declaration) => {
2020
- return declaration.type === 'declaration';
2021
- }).map((declaration) => {
2022
- return declaration.property;
2023
- });
2024
- if (declaredProperties.length === 0) {
2025
- return;
2026
- }
2027
- for (const selector of rule.selectors) {
2028
- const normalizedSelector = normalizeSelector(selector);
2029
- let lastRuleIndexByProperty = lastRuleIndexBySelector.get(normalizedSelector);
2030
- if (!lastRuleIndexByProperty) {
2031
- lastRuleIndexByProperty = new Map();
2032
- lastRuleIndexBySelector.set(normalizedSelector, lastRuleIndexByProperty);
2033
- }
2034
- for (const property of declaredProperties) {
2035
- lastRuleIndexByProperty.set(property, ruleIndex);
2036
- }
2037
- }
2038
- });
2039
- return lastRuleIndexBySelector;
2040
- }
2041
-
2042
- /**
2043
- * Checks whether a given selector has a specific property overridden
2044
- * by any later rule in the stylesheet. A property is considered
2045
- * overridden if a subsequent rule contains that selector (as its only
2046
- * selector or among its selectors) and declares the same property.
2047
- *
2048
- * @param {Map} lastRuleIndexBySelector The index built by `indexLastDeclaringRuleBySelector`.
2049
- * @param {number} startIndex The index of the current rule (search starts after this).
2050
- * @param {string} selector The normalized selector to check.
2051
- * @param {string} property The CSS property name to check.
2052
- * @return {boolean} True if a later rule overrides this selector+property.
2053
- */
2054
- function isSelectorPropertyOverriddenLater (lastRuleIndexBySelector, startIndex, selector, property) {
2055
- const lastRuleIndex = lastRuleIndexBySelector.get(selector)?.get(property);
2056
- return lastRuleIndex !== undefined && lastRuleIndex > startIndex;
2057
- }
2058
-
2059
- /**
2060
- * Determines whether a declaration carries a trailing `!important` flag.
2061
- *
2062
- * @param {object} declaration The AST declaration node.
2063
- * @return {boolean} True when the declaration is important.
2064
- */
2065
- function isImportantDeclaration (declaration) {
2066
- // A trailing !important suffix on the raw declaration value
2067
- return /!\s*important\s*$/i.test(declaration.rawValue || declaration.value || '');
2068
- }
2069
-
2070
- /**
2071
- * Indexes, for every normalized selector in the stylesheet, the last rule that
2072
- * covers each leaf property under it, where coverage follows the same "writes
2073
- * to" model as `expandToOverridableProperties` (a later `border` declaration
2074
- * on the same selector also overrides an earlier `border-color` on it), along
2075
- * with whether that covering declaration is important. A last-covering entry
2076
- * is consulted to answer "is every declaration this selector still gets from
2077
- * the rule at position X re-declared later".
2078
- *
2079
- * @param {Array} rules The flat list of AST rule nodes.
2080
- * @return {Map} Map of normalized selector to a map of covered leaf property name to `{ ruleIndex, important }`.
2081
- */
2082
- function indexFinalCoveringRuleBySelector (rules) {
2083
- const coverageBySelector = new Map();
2084
- rules.forEach((rule, ruleIndex) => {
2085
- if (rule.type !== 'rule' || !rule.selectors?.length) {
2086
- return;
2087
- }
2088
- const declarations = (rule.declarations || []).filter((declaration) => {
2089
- return declaration.type === 'declaration' && declaration.property;
2090
- });
2091
- if (!declarations.length) {
2092
- return;
2093
- }
2094
- for (const selector of rule.selectors) {
2095
- const normalizedSelector = normalizeSelector(selector);
2096
- let coverageByProperty = coverageBySelector.get(normalizedSelector);
2097
- if (!coverageByProperty) {
2098
- coverageByProperty = new Map();
2099
- coverageBySelector.set(normalizedSelector, coverageByProperty);
2100
- }
2101
- for (const declaration of declarations) {
2102
- const important = isImportantDeclaration(declaration);
2103
- for (const leafProperty of expandToOverridableProperties(declaration.property)) {
2104
- coverageByProperty.set(leafProperty, { important, ruleIndex });
2105
- }
2106
- }
2107
- }
2108
- });
2109
- return coverageBySelector;
2110
- }
2111
-
2112
- /**
2113
- * Determines whether a later rule overrides every leaf property an earlier
2114
- * declaration writes to for the same selector, including via shorthand
2115
- * coverage, with an important earlier write requiring an important later
2116
- * write to beat it.
2117
- *
2118
- * @param {Map} coverageByProperty The leaf property coverage index for the selector.
2119
- * @param {object} declaration The earlier declaration to test.
2120
- * @param {number} ruleIndex The position of the rule holding the declaration.
2121
- * @param {boolean} declarationIsImportant Whether the earlier write of this property is important.
2122
- * @return {boolean} True when the declaration is conclusively overridden later.
2123
- */
2124
- function isDeclarationOverriddenLater (coverageByProperty, declaration, ruleIndex, declarationIsImportant) {
2125
- for (const leafProperty of expandToOverridableProperties(declaration.property)) {
2126
- const coverage = coverageByProperty.get(leafProperty);
2127
- if (!coverage || coverage.ruleIndex <= ruleIndex) {
2128
- return false;
2129
- }
2130
- // An important declaration outlives a later non-important one
2131
- if (declarationIsImportant && !coverage.important) {
2132
- return false;
2133
- }
2134
- }
2135
- return true;
2136
- }
2137
-
2138
- /**
2139
- * Removes a selector from a multi-selector rule's list when every declaration
2140
- * the rule would still give that selector is overridden by a later rule that
2141
- * contains the same selector. For example, if `h1,h2{color:red}` is followed
2142
- * by `h2{color:tan}`, the `h2` entry contributes nothing and can be dropped,
2143
- * leaving `h1{color:red}`. If the list empties out entirely, the rule is
2144
- * stripped of declarations so `removeEmptyRules` discards it.
2145
- *
2146
- * @param {Array} rules The flat list of AST rule nodes.
2147
- * @return {Array} The rules with fully overridden selectors removed from their lists.
2148
- */
2149
- function removeFullyOverriddenSelectors (rules) {
2150
- // Pruning a selector only ever shrinks lists: a covering entry that wins
2151
- // today is supplied by a later rule, and pruning earlier rules never moves
2152
- // coverage forward, so a single index built up front stays accurate.
2153
- const coverageBySelector = indexFinalCoveringRuleBySelector(rules);
2154
-
2155
- for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
2156
- const rule = rules[ruleIndex];
2157
- if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
2158
- continue;
2159
- }
2160
- const entries = rule.declarations || [];
2161
- // Removing a selector would also un-scope nested rules, which may depend
2162
- // on it, so only rules made purely of declarations qualify
2163
- const hasNestedContent = entries.some((entry) => {
2164
- return entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment';
2165
- });
2166
- if (hasNestedContent) {
2167
- continue;
2168
- }
2169
- const declarations = entries.filter((entry) => {
2170
- return entry.type === 'declaration' && entry.property;
2171
- });
2172
- if (!declarations.length) {
2173
- continue;
2174
- }
2175
- // Within a rule, any important declaration of a property makes the rule's
2176
- // write of that property important (a conservative stand-in for tracking
2177
- // which of several same-property declarations survives inside the rule)
2178
- const importantByProperty = new Map();
2179
- for (const declaration of declarations) {
2180
- if (isImportantDeclaration(declaration)) {
2181
- importantByProperty.set(declaration.property, true);
2182
- } else if (!importantByProperty.has(declaration.property)) {
2183
- importantByProperty.set(declaration.property, false);
2184
- }
2185
- }
2186
- const keptSelectors = rule.selectors.filter((selector) => {
2187
- const coverageByProperty = coverageBySelector.get(normalizeSelector(selector));
2188
- if (!coverageByProperty) {
2189
- return true;
2190
- }
2191
- // Keep the selector while at least one declaration is not conclusively
2192
- // overridden for it by a later rule
2193
- return declarations.some((declaration) => {
2194
- return !isDeclarationOverriddenLater(
2195
- coverageByProperty,
2196
- declaration,
2197
- ruleIndex,
2198
- importantByProperty.get(declaration.property)
2199
- );
2200
- });
2201
- });
2202
- if (keptSelectors.length !== rule.selectors.length) {
2203
- if (!keptSelectors.length) {
2204
- rule.declarations = entries.filter((entry) => {
2205
- return entry.type !== 'declaration';
2206
- });
2207
- }
2208
- rule.selectors = keptSelectors;
2209
- }
2210
- }
2211
- return rules;
2212
- }
2213
-
2214
- /**
2215
- * Removes properties from multi-selector rules when every selector in
2216
- * the rule has that property overridden by a later rule. For example,
2217
- * if `h1,h2{color:red}` is followed by `h1{color:blue}` and
2218
- * `h2{color:green}`, the `color` in the first rule is redundant and
2219
- * can be removed. If all properties are removed, the empty rule will
2220
- * be cleaned up by `removeEmptyRules`.
2221
- *
2222
- * @param {Array} rules The flat list of AST rule nodes.
2223
- * @return {Array} The rules with overridden multi-selector properties removed.
2224
- */
2225
- function removeOverriddenMultiSelectorProperties (rules) {
2226
- // Only rules that follow the one being pruned are ever consulted, and pruning
2227
- // never adds a declaration, so a single index built up front stays accurate.
2228
- const lastRuleIndexBySelector = indexLastDeclaringRuleBySelector(rules);
2229
-
2230
- for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
2231
- const rule = rules[ruleIndex];
2232
- if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
2233
- continue;
2234
- }
2235
- const normalizedSelectors = rule.selectors.map(normalizeSelector);
2236
- const declarations = (rule.declarations || []).filter((declaration) => {
2237
- return declaration.type === 'declaration';
2238
- });
2239
- if (declarations.length === 0) {
2240
- continue;
2241
- }
2242
-
2243
- // For each property in this multi-selector rule, check if ALL selectors
2244
- // get that property overridden in later rules
2245
- const propertiesToRemove = new Set();
2246
- for (const declaration of declarations) {
2247
- const property = declaration.property;
2248
- const allSelectorsOverridden = normalizedSelectors.every((selector) => {
2249
- return isSelectorPropertyOverriddenLater(lastRuleIndexBySelector, ruleIndex, selector, property);
2250
- });
2251
- if (allSelectorsOverridden) {
2252
- propertiesToRemove.add(property);
2253
- }
2254
- }
2255
-
2256
- if (propertiesToRemove.size > 0) {
2257
- rule.declarations = (rule.declarations || []).filter((declaration) => {
2258
- if (declaration.type !== 'declaration') {
2259
- return true;
2260
- }
2261
- return !propertiesToRemove.has(declaration.property);
2262
- });
2263
- }
2264
- }
2265
- return rules;
2266
- }
2267
-
2268
- /**
2269
- * Applies the structural optimization pipeline to a stylesheet or grouping
2270
- * block, so merging at-rules does not leave their children unoptimized.
2271
- *
2272
- * @param {Array} rules The sibling AST entries.
2273
- * @return {Array} The optimized sibling entries.
2274
- */
2275
- function optimizeRuleList (rules) {
2276
- let result = expandPureNestedRules(rules);
2277
- result = mergeLayerRules(result, mergeSelectorRules);
2278
- result = mergeMediaRules(result, mergeSelectorRules);
2279
- result = deduplicateKeyframes(result);
2280
- for (const rule of result) {
2281
- if (rule.rules) {
2282
- rule.rules = optimizeRuleList(rule.rules);
2283
- }
2284
- }
2285
- result = mergeSelectorRules(result);
2286
- result = removeOverriddenMultiSelectorProperties(result);
2287
- result = removeFullyOverriddenSelectors(result);
2288
- result = removeEmptyRules(result);
2289
- result = mergeByDeclarations(result);
2290
- result = nestFlatRules(result);
2291
- result = removeEmptyRules(result);
2292
- result = mergeByDeclarations(result);
2293
- result = factorCommonParents(result);
2294
- result = nestFlatRules(result);
2295
- result.forEach(factorSelectorBranches);
2296
- return mergeIdenticalNestedRules(result);
2297
- }
2298
-
2299
- export {
2300
- clearSelectorCaches,
2301
- deduplicateKeyframes,
2302
- expandPureNestedRules,
2303
- factorCommonParents,
2304
- mergeByDeclarations,
2305
- mergeIdenticalNestedRules,
2306
- mergeLayerRules,
2307
- mergeMediaRules,
2308
- mergeSelectorRules,
2309
- nestFlatRules,
2310
- optimizeRuleList,
2311
- removeEmptyRules,
2312
- removeFullyOverriddenSelectors,
2313
- removeOverriddenMultiSelectorProperties
2314
- };