@thejaredwilcurt/csslop 0.0.48 → 0.0.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/package.json +3 -3
- package/src/declarations/border.js +1 -1
- package/src/declarations/css-wide-keywords.js +37 -1
- package/src/declarations/fold.js +13 -3
- package/src/declarations/process.js +60 -7
- package/src/index.js +12 -7
- package/src/parser/source-search.js +6 -0
- package/src/rules/optimize/at-rules.js +113 -0
- package/src/rules/optimize/declaration-writes.js +70 -0
- package/src/rules/optimize/empty-rules.js +51 -0
- package/src/rules/optimize/expand-nesting.js +236 -0
- package/src/rules/optimize/factor-parents.js +343 -0
- package/src/rules/optimize/index.js +71 -0
- package/src/rules/optimize/merge-declarations.js +199 -0
- package/src/rules/optimize/merge-nested-rules.js +117 -0
- package/src/rules/optimize/merge-selectors.js +252 -0
- package/src/rules/optimize/nest-rules.js +202 -0
- package/src/rules/optimize/override-index.js +264 -0
- package/src/rules/optimize/prune-overridden.js +267 -0
- package/src/rules/optimize/selector-normalization.js +60 -0
- package/src/rules/optimize/specificity.js +280 -0
- package/src/{position-try.js → rules/position-try.js} +1 -1
- package/src/rules/selectors.js +9 -0
- package/src/rules/stringify.js +138 -76
- package/src/utilities.js +9 -8
- package/src/value/border.js +85 -0
- package/src/value/{colors.js → color/conversion.js} +3 -2
- package/src/value/color/functions.js +463 -0
- package/src/value/{light-dark.js → color/light-dark.js} +1 -1
- package/src/value/{color-mix.js → color/mix.js} +3 -3
- package/src/value/{relative-colors.js → color/relative.js} +3 -3
- package/src/value/color/shorten.js +93 -0
- package/src/value/gradients.js +1 -1
- package/src/value/hyphenate-limit-chars.js +72 -0
- package/src/value/initial-keywords.js +128 -0
- package/src/value/lengths.js +55 -0
- package/src/value/math/evaluate.js +36 -0
- package/src/value/math/fold.js +315 -0
- package/src/value/math/format.js +230 -0
- package/src/value/math/functions.js +302 -0
- package/src/value/math/index.js +219 -0
- package/src/value/math/parser.js +301 -0
- package/src/value/math/property-ranges.js +174 -0
- package/src/value/math/rewrite.js +222 -0
- package/src/value/math/tokenizer.js +160 -0
- package/src/value/math/unit-totals.js +194 -0
- package/src/value/minify.js +162 -1678
- package/src/value/numbers.js +28 -0
- package/src/value/positions.js +152 -0
- package/src/value/property-optimizations.js +485 -0
- package/src/value/segments.js +74 -0
- package/src/value/{svg-data-uri.js → svg/data-uri.js} +1 -1
- package/src/value/{svg-markup.js → svg/markup.js} +1 -1
- package/src/value/time.js +75 -0
- package/src/value/urls.js +242 -0
- package/src/value/whitespace.js +88 -0
- package/src/rules/optimize.js +0 -2031
- package/src/value/math-expression.js +0 -1156
- package/src/value/math.js +0 -886
- /package/src/value/{named-colors.js → color/named.js} +0 -0
package/src/rules/optimize.js
DELETED
|
@@ -1,2031 +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
|
-
import { escapeRegexString } from '../utilities.js';
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
normalizeLayerNames,
|
|
19
|
-
normalizeMedia
|
|
20
|
-
} from './normalize.js';
|
|
21
|
-
import {
|
|
22
|
-
containsPseudoElement,
|
|
23
|
-
isUniversallyRecognizableSelector
|
|
24
|
-
} from './selectors.js';
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Reports whether an entry of a rule's body is itself a nested rule rather
|
|
28
|
-
* than a plain declaration.
|
|
29
|
-
*
|
|
30
|
-
* @param {object} entry An entry of a rule's declaration list.
|
|
31
|
-
* @return {boolean} True when the entry is a nested rule.
|
|
32
|
-
*/
|
|
33
|
-
function isNestedRule (entry) {
|
|
34
|
-
return entry.type === 'rule';
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Expands the rules nested inside each rule's body, so a chain of wrappers
|
|
39
|
-
* collapses from the inside out and the outermost wrapper is then judged
|
|
40
|
-
* against an already flattened child. A rule's body holds the same kinds of
|
|
41
|
-
* entries a stylesheet does, so the body is expanded by the same rules, and
|
|
42
|
-
* the plain declarations it also holds pass through untouched.
|
|
43
|
-
*
|
|
44
|
-
* @param {Array} rules The AST rule nodes whose bodies may hold nested rules.
|
|
45
|
-
* @return {Array} The rules, each with its body expanded.
|
|
46
|
-
*/
|
|
47
|
-
function expandNestedRuleBodies (rules) {
|
|
48
|
-
return rules.map((rule) => {
|
|
49
|
-
if (rule.type !== 'rule' || !(rule.declarations || []).some(isNestedRule)) {
|
|
50
|
-
return rule;
|
|
51
|
-
}
|
|
52
|
-
const declarations = expandPureNestedRules(rule.declarations);
|
|
53
|
-
const canFlattenSelf = selectorsShareSpecificity(rule.selectors) && !rule.selectors.some(containsPseudoElement);
|
|
54
|
-
let encounteredNestedContent = false;
|
|
55
|
-
const flattened = declarations.flatMap((entry) => {
|
|
56
|
-
const isSelfRule = entry.type === 'rule' && entry.selectors?.length === 1 && entry.selectors[0].trim() === '&';
|
|
57
|
-
if (canFlattenSelf && isSelfRule && !encounteredNestedContent) {
|
|
58
|
-
return entry.declarations || [];
|
|
59
|
-
}
|
|
60
|
-
if (entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment') {
|
|
61
|
-
encounteredNestedContent = true;
|
|
62
|
-
}
|
|
63
|
-
if (entry.type === 'rule' && rule.selectors.length === 1) {
|
|
64
|
-
entry.selectors = entry.selectors.map((selector) => {
|
|
65
|
-
return shortenRepeatedNestingSelector(rule.selectors[0], selector);
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
return [entry];
|
|
69
|
-
});
|
|
70
|
-
return {
|
|
71
|
-
...rule,
|
|
72
|
-
declarations: flattened
|
|
73
|
-
};
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Replaces repetitions of a simple class or ID beside a leading nesting
|
|
79
|
-
* selector. Token comparisons keep longer names and escaped text intact.
|
|
80
|
-
*
|
|
81
|
-
* @param {string} parentSelector The sole parent selector.
|
|
82
|
-
* @param {string} childSelector The nested selector.
|
|
83
|
-
* @return {string} The shorter equivalent selector.
|
|
84
|
-
*/
|
|
85
|
-
function shortenRepeatedNestingSelector (parentSelector, childSelector) {
|
|
86
|
-
const parentTokens = tokenizeCssValue(parentSelector);
|
|
87
|
-
const isClass = parentTokens.length === 2 && parentTokens[0].text === '.' && parentTokens[1].type === 'ident';
|
|
88
|
-
const isIdentifier = parentTokens.length === 1 && parentTokens[0].type === 'hash';
|
|
89
|
-
if (!isClass && !isIdentifier) {
|
|
90
|
-
return childSelector;
|
|
91
|
-
}
|
|
92
|
-
const childTokens = tokenizeCssValue(childSelector);
|
|
93
|
-
if (childTokens[0]?.text !== '&') {
|
|
94
|
-
return childSelector;
|
|
95
|
-
}
|
|
96
|
-
let index = 1;
|
|
97
|
-
let result = '&';
|
|
98
|
-
while (index < childTokens.length) {
|
|
99
|
-
const matchesParent = parentTokens.every((token, position) => {
|
|
100
|
-
return childTokens[index + position]?.text === token.text;
|
|
101
|
-
});
|
|
102
|
-
if (!matchesParent) {
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
105
|
-
result += '&';
|
|
106
|
-
index += parentTokens.length;
|
|
107
|
-
}
|
|
108
|
-
return result + childTokens.slice(index).map((token) => {
|
|
109
|
-
return token.text;
|
|
110
|
-
}).join('');
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Reports whether a nested child selector still means the same thing once it
|
|
115
|
-
* is written out in full under its parent.
|
|
116
|
-
*
|
|
117
|
-
* @param {string} childSelector The selector of a rule nested inside another.
|
|
118
|
-
* @return {boolean} True when the child can be written as a flat selector.
|
|
119
|
-
*/
|
|
120
|
-
function canFlattenChildSelector (childSelector) {
|
|
121
|
-
const child = childSelector.trim();
|
|
122
|
-
if (!child) {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
// A `&` anywhere but the very start puts the parent where flattening cannot:
|
|
126
|
-
// `.b &` means the parent follows the child rather than leading it
|
|
127
|
-
return !child.slice(1).includes('&');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Writes a nested child selector out in full, as the selector it stands for
|
|
132
|
-
* when read through its parent.
|
|
133
|
-
*
|
|
134
|
-
* @param {string} parentSelector The selector of the rule the child is nested in.
|
|
135
|
-
* @param {string} childSelector The selector of the nested child rule.
|
|
136
|
-
* @return {string} The flat selector the nested pair stands for.
|
|
137
|
-
*/
|
|
138
|
-
function combineNestedSelector (parentSelector, childSelector) {
|
|
139
|
-
const parent = parentSelector.trim();
|
|
140
|
-
const child = childSelector.trim();
|
|
141
|
-
// A leading `&` names the parent itself, so it is what the parent replaces
|
|
142
|
-
if (child.startsWith('&')) {
|
|
143
|
-
return parent + child.slice(1);
|
|
144
|
-
}
|
|
145
|
-
return parent + ' ' + child;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Expands rules that contain only nested sub-rules into flat rules with combined selectors, enabling further merging when the combined selectors already exist elsewhere.
|
|
150
|
-
*
|
|
151
|
-
* @param {Array} inputRules The AST rule nodes to process.
|
|
152
|
-
* @return {Array} A new array of rules with pure-nested rules expanded.
|
|
153
|
-
*/
|
|
154
|
-
function expandPureNestedRules (inputRules) {
|
|
155
|
-
const rules = expandNestedRuleBodies(inputRules);
|
|
156
|
-
const flatSelectors = new Set();
|
|
157
|
-
for (const rule of rules) {
|
|
158
|
-
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
const nonWhitespace = (rule.declarations || []).filter((declaration) => {
|
|
162
|
-
return declaration.type !== 'whitespace';
|
|
163
|
-
});
|
|
164
|
-
const hasNonRuleDeclarations = nonWhitespace.some((declaration) => {
|
|
165
|
-
return declaration.type !== 'rule';
|
|
166
|
-
});
|
|
167
|
-
if (hasNonRuleDeclarations) {
|
|
168
|
-
rule.selectors.forEach((selector) => {
|
|
169
|
-
// Normalize selector whitespace to single space for deduplication
|
|
170
|
-
flatSelectors.add(selector.trim().replace(/\s+/g, ' '));
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const result = [];
|
|
176
|
-
for (const rule of rules) {
|
|
177
|
-
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
178
|
-
result.push(rule);
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
const nonWhitespace = (rule.declarations || []).filter((declaration) => {
|
|
182
|
-
return declaration.type !== 'whitespace';
|
|
183
|
-
});
|
|
184
|
-
const isPureNested = nonWhitespace.length > 0 && nonWhitespace.every((declaration) => {
|
|
185
|
-
return declaration.type === 'rule';
|
|
186
|
-
});
|
|
187
|
-
// A comma-separated parent selector list behaves like :is() for specificity,
|
|
188
|
-
// so expanding it into descendant combinations (e.g. #a,.b → #a .c,.b .c)
|
|
189
|
-
// would change specificity. Only single-selector parents can expand safely.
|
|
190
|
-
if (!isPureNested || rule.selectors.length !== 1) {
|
|
191
|
-
result.push(rule);
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
let anyMatch = false;
|
|
196
|
-
let expandedRules = [];
|
|
197
|
-
let canExpand = true;
|
|
198
|
-
|
|
199
|
-
for (const nestedRule of nonWhitespace) {
|
|
200
|
-
if (!nestedRule.selectors?.length) {
|
|
201
|
-
canExpand = false;
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
// A child written as a comma-separated selector list, such as `.b,.c`,
|
|
205
|
-
// repeats the parent in front of every item when expanded (`.a .b,.a .c`),
|
|
206
|
-
// while the nested form states the parent once. Expanding is always
|
|
207
|
-
// longer, and leaving the rule nested keeps one pass from expanding what
|
|
208
|
-
// the next pass would then keep, so the output stays idempotent.
|
|
209
|
-
if (nestedRule.selectors.length > 1) {
|
|
210
|
-
canExpand = false;
|
|
211
|
-
break;
|
|
212
|
-
}
|
|
213
|
-
if (!nestedRule.selectors.every(canFlattenChildSelector)) {
|
|
214
|
-
canExpand = false;
|
|
215
|
-
break;
|
|
216
|
-
}
|
|
217
|
-
const combinedSelectors = [];
|
|
218
|
-
for (const parentSelector of rule.selectors) {
|
|
219
|
-
for (const childSelector of nestedRule.selectors) {
|
|
220
|
-
const combinedSelector = combineNestedSelector(parentSelector, childSelector);
|
|
221
|
-
combinedSelectors.push(combinedSelector);
|
|
222
|
-
if (flatSelectors.has(combinedSelector)) {
|
|
223
|
-
anyMatch = true;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
expandedRules.push({ ...nestedRule, selectors: combinedSelectors });
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const allChildSelectorStrings = expandedRules.map((expandedRule) => {
|
|
231
|
-
return expandedRule.selectors.join(',');
|
|
232
|
-
});
|
|
233
|
-
const allSame = (
|
|
234
|
-
allChildSelectorStrings.length > 0 &&
|
|
235
|
-
allChildSelectorStrings.every((selectorString) => {
|
|
236
|
-
return selectorString === allChildSelectorStrings[0];
|
|
237
|
-
})
|
|
238
|
-
);
|
|
239
|
-
if (canExpand && (allSame || anyMatch)) {
|
|
240
|
-
for (const expandedRule of expandedRules) {
|
|
241
|
-
result.push(expandedRule);
|
|
242
|
-
}
|
|
243
|
-
} else {
|
|
244
|
-
result.push(rule);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return result;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Builds a reusable matcher that expresses child selectors as nested selectors
|
|
252
|
-
* relative to one parent. The combinator pattern is compiled once per parent so
|
|
253
|
-
* that testing many children against the same parent does not rebuild it.
|
|
254
|
-
*
|
|
255
|
-
* @param {string} parentSelector The parent selector string.
|
|
256
|
-
* @return {function(string): (string|null)} A matcher returning the nested selector, or null when the child cannot be nested.
|
|
257
|
-
*/
|
|
258
|
-
function createNestedSelectorMatcher (parentSelector) {
|
|
259
|
-
const parent = parentSelector.trim();
|
|
260
|
-
// Match a child selector that starts with the parent followed by a combinator (>, +, ~)
|
|
261
|
-
const combinatorPattern = new RegExp('^' + escapeRegexString(parent) + '\\s*([>+~])\\s*(.+)$');
|
|
262
|
-
|
|
263
|
-
return (childSelector) => {
|
|
264
|
-
const child = childSelector.trim();
|
|
265
|
-
if (child === parent) {
|
|
266
|
-
return '&';
|
|
267
|
-
}
|
|
268
|
-
if (child.startsWith(parent + ':') || child.startsWith(parent + '::')) {
|
|
269
|
-
return '&' + child.slice(parent.length);
|
|
270
|
-
}
|
|
271
|
-
if (child.startsWith(parent + ' ')) {
|
|
272
|
-
return child.slice(parent.length + 1);
|
|
273
|
-
}
|
|
274
|
-
const combinatorMatch = child.match(combinatorPattern);
|
|
275
|
-
if (combinatorMatch) {
|
|
276
|
-
return combinatorMatch[1] + combinatorMatch[2];
|
|
277
|
-
}
|
|
278
|
-
return null;
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* 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.
|
|
284
|
-
*
|
|
285
|
-
* @param {string} parentSel The parent selector string.
|
|
286
|
-
* @param {string} childSel The child selector string to try nesting.
|
|
287
|
-
* @return {string|null} The nested selector using & syntax, or null if the child cannot be nested under the parent.
|
|
288
|
-
*/
|
|
289
|
-
function tryNestSelector (parentSel, childSel) {
|
|
290
|
-
return createNestedSelectorMatcher(parentSel)(childSel);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Determines whether a rule is effectively empty, containing no meaningful
|
|
295
|
-
* CSS output after minification. A rule is effectively empty when it has
|
|
296
|
-
* no declarations, or all of its entries are whitespace, non-important
|
|
297
|
-
* comments, or recursively empty nested rules.
|
|
298
|
-
*
|
|
299
|
-
* @param {object} rule The AST rule node to evaluate.
|
|
300
|
-
* @return {boolean} True if the rule produces no CSS output.
|
|
301
|
-
*/
|
|
302
|
-
function isRuleEffectivelyEmpty (rule) {
|
|
303
|
-
if (rule.type !== 'rule') {
|
|
304
|
-
return false;
|
|
305
|
-
}
|
|
306
|
-
const nonWhitespaceEntries = (rule.declarations || []).filter((declaration) => {
|
|
307
|
-
return declaration.type !== 'whitespace';
|
|
308
|
-
});
|
|
309
|
-
if (nonWhitespaceEntries.length === 0) {
|
|
310
|
-
return true;
|
|
311
|
-
}
|
|
312
|
-
return nonWhitespaceEntries.every((entry) => {
|
|
313
|
-
if (entry.type === 'comment') {
|
|
314
|
-
return !entry.comment?.startsWith('!');
|
|
315
|
-
}
|
|
316
|
-
if (entry.type === 'rule') {
|
|
317
|
-
return isRuleEffectivelyEmpty(entry);
|
|
318
|
-
}
|
|
319
|
-
return false;
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Filters out effectively empty rules from the rules array, preventing
|
|
325
|
-
* empty rules from being nested into parent rules during later
|
|
326
|
-
* optimization passes and producing incorrect non-empty output.
|
|
327
|
-
*
|
|
328
|
-
* @param {Array} rules The AST rule nodes to filter.
|
|
329
|
-
* @return {Array} A new array with effectively empty rules removed.
|
|
330
|
-
*/
|
|
331
|
-
function removeEmptyRules (rules) {
|
|
332
|
-
return rules.filter((rule) => {
|
|
333
|
-
return !isRuleEffectivelyEmpty(rule);
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Determines whether a character is CSS whitespace.
|
|
339
|
-
*
|
|
340
|
-
* @param {string} character A single character from a selector.
|
|
341
|
-
* @return {boolean} Whether the character is whitespace.
|
|
342
|
-
*/
|
|
343
|
-
function isSelectorWhitespace (character) {
|
|
344
|
-
// Match a single whitespace character
|
|
345
|
-
return (/\s/).test(character);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Collects every prefix of a child selector that could act as its nesting
|
|
350
|
-
* parent. A parent always ends immediately before a pseudo-class colon, a
|
|
351
|
-
* descendant whitespace run, or the whitespace run leading into a combinator,
|
|
352
|
-
* so only those few positions can start a nestable remainder. Enumerating them
|
|
353
|
-
* turns "which earlier rule can host this one" into a set of key lookups
|
|
354
|
-
* instead of a comparison against every preceding rule.
|
|
355
|
-
*
|
|
356
|
-
* @param {string} childSelector The trimmed child selector.
|
|
357
|
-
* @return {Set} The candidate parent selector strings.
|
|
358
|
-
*/
|
|
359
|
-
function collectCandidateParentSelectors (childSelector) {
|
|
360
|
-
const candidates = new Set();
|
|
361
|
-
for (let index = 1; index < childSelector.length; index++) {
|
|
362
|
-
const character = childSelector[index];
|
|
363
|
-
if (character === ':' || isSelectorWhitespace(character)) {
|
|
364
|
-
candidates.add(childSelector.slice(0, index));
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
if (character !== '>' && character !== '+' && character !== '~') {
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
// A combinator may be separated from its parent by whitespace, which
|
|
371
|
-
// belongs to neither side, so the parent ends where that run begins.
|
|
372
|
-
let boundary = index;
|
|
373
|
-
while (boundary > 0 && isSelectorWhitespace(childSelector[boundary - 1])) {
|
|
374
|
-
boundary--;
|
|
375
|
-
}
|
|
376
|
-
if (boundary > 0) {
|
|
377
|
-
candidates.add(childSelector.slice(0, boundary));
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
return candidates;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Finds the most recently emitted rule that can host a child selector as a
|
|
385
|
-
* nested rule, matching the behaviour of scanning backwards through the emitted
|
|
386
|
-
* rules but only visiting the handful of rules whose selector is a viable
|
|
387
|
-
* parent prefix.
|
|
388
|
-
*
|
|
389
|
-
* @param {Array} emittedRules The rules emitted so far.
|
|
390
|
-
* @param {Map} indexBySelector Map of each emitted single-selector rule's selector to its index.
|
|
391
|
-
* @param {string} childSelector The trimmed child selector to nest.
|
|
392
|
-
* @return {object|null} The `parentIndex` and `nestedSelector`, or null when nothing can host the child.
|
|
393
|
-
*/
|
|
394
|
-
function findNestingParent (emittedRules, indexBySelector, childSelector) {
|
|
395
|
-
const parentIndexes = [];
|
|
396
|
-
for (const candidateParent of collectCandidateParentSelectors(childSelector)) {
|
|
397
|
-
const parentIndex = indexBySelector.get(candidateParent);
|
|
398
|
-
if (parentIndex !== undefined) {
|
|
399
|
-
parentIndexes.push(parentIndex);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
// The nearest preceding parent wins, exactly as a backwards scan would.
|
|
403
|
-
parentIndexes.sort((first, second) => {
|
|
404
|
-
return second - first;
|
|
405
|
-
});
|
|
406
|
-
for (const parentIndex of parentIndexes) {
|
|
407
|
-
const nestedSelector = tryNestSelector(emittedRules[parentIndex].selectors[0], childSelector);
|
|
408
|
-
if (nestedSelector !== null) {
|
|
409
|
-
return {
|
|
410
|
-
nestedSelector,
|
|
411
|
-
parentIndex
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
return null;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* 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.
|
|
420
|
-
*
|
|
421
|
-
* @param {Array} rules The flat AST rule nodes to nest.
|
|
422
|
-
* @return {Array} A new array of rules with applicable children nested under their parents.
|
|
423
|
-
*/
|
|
424
|
-
function nestFlatRules (rules) {
|
|
425
|
-
const result = [];
|
|
426
|
-
const indexBySelector = new Map();
|
|
427
|
-
for (const rule of rules) {
|
|
428
|
-
if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
|
|
429
|
-
result.push(rule);
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
const childSelector = rule.selectors[0].trim();
|
|
433
|
-
const nesting = findNestingParent(result, indexBySelector, childSelector);
|
|
434
|
-
if (nesting) {
|
|
435
|
-
const parentRule = result[nesting.parentIndex];
|
|
436
|
-
parentRule.declarations = parentRule.declarations || [];
|
|
437
|
-
parentRule.declarations.push({ ...rule, selectors: [nesting.nestedSelector] });
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
indexBySelector.set(childSelector, result.length);
|
|
441
|
-
result.push(rule);
|
|
442
|
-
}
|
|
443
|
-
for (const rule of result) {
|
|
444
|
-
if (rule.type === 'rule' && rule.declarations) {
|
|
445
|
-
const innerRules = rule.declarations.filter((declaration) => {
|
|
446
|
-
return declaration.type === 'rule';
|
|
447
|
-
});
|
|
448
|
-
if (innerRules.length > 0) {
|
|
449
|
-
const nonRuleDeclarations = rule.declarations.filter((declaration) => {
|
|
450
|
-
return declaration.type !== 'rule';
|
|
451
|
-
});
|
|
452
|
-
rule.declarations = [...nonRuleDeclarations, ...nestFlatRules(innerRules)];
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return result;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Extracts the leading compound selector of a complex selector: everything up to
|
|
461
|
-
* the first top-level combinator (descendant whitespace, `>`, `+`, or `~`).
|
|
462
|
-
* Combinator characters nested inside `()` or `[]` (such as `:nth-child(2n+1)` or
|
|
463
|
-
* `[a~=b]`) are ignored so only structural combinators split the selector.
|
|
464
|
-
*
|
|
465
|
-
* @param {string} selector The selector string to inspect.
|
|
466
|
-
* @return {string|null} The leading compound selector, or null when the selector has no descendant part to factor out.
|
|
467
|
-
*/
|
|
468
|
-
function extractLeadingCompound (selector) {
|
|
469
|
-
const trimmed = selector.trim();
|
|
470
|
-
let bracketDepth = 0;
|
|
471
|
-
for (let index = 0; index < trimmed.length; index++) {
|
|
472
|
-
const character = trimmed[index];
|
|
473
|
-
if (character === '(' || character === '[') {
|
|
474
|
-
bracketDepth++;
|
|
475
|
-
} else if (character === ')' || character === ']') {
|
|
476
|
-
bracketDepth--;
|
|
477
|
-
} else if (
|
|
478
|
-
bracketDepth === 0 &&
|
|
479
|
-
(character === ' ' || character === '>' || character === '+' || character === '~')
|
|
480
|
-
) {
|
|
481
|
-
const compound = trimmed.slice(0, index).trim();
|
|
482
|
-
return compound.length ? compound : null;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
return null;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Groups consecutive sibling rules that share a common leading compound selector
|
|
490
|
-
* (such as `.foo` in `.foo .a`, `.foo .b`) into a synthesized parent rule with the
|
|
491
|
-
* shared portion factored out, but only when nesting trims more characters from the
|
|
492
|
-
* child selectors than the wrapper itself costs.
|
|
493
|
-
*
|
|
494
|
-
* @param {Array} rules The flat AST rule nodes to factor.
|
|
495
|
-
* @return {Array} A new array of rules with shared parent selectors factored into nesting wrappers.
|
|
496
|
-
*/
|
|
497
|
-
function factorCommonParents (rules) {
|
|
498
|
-
const result = [];
|
|
499
|
-
let index = 0;
|
|
500
|
-
while (index < rules.length) {
|
|
501
|
-
const rule = rules[index];
|
|
502
|
-
if (rule.type === 'rule' && rule.selectors?.length > 1) {
|
|
503
|
-
const candidateParent = extractLeadingCompound(rule.selectors[0]) || rule.selectors[0];
|
|
504
|
-
if (candidateParent) {
|
|
505
|
-
const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
|
|
506
|
-
const childSelectors = rule.selectors.map(matchNestedSelector);
|
|
507
|
-
const allMatch = childSelectors.every((selector) => {
|
|
508
|
-
return selector !== null;
|
|
509
|
-
});
|
|
510
|
-
const wrapperLength = candidateParent.length + 2 + childSelectors.join(',').length;
|
|
511
|
-
if (allMatch && !containsPseudoElement(candidateParent) && wrapperLength < rule.selectors.join(',').length) {
|
|
512
|
-
result.push({
|
|
513
|
-
type: 'rule',
|
|
514
|
-
selectors: [candidateParent],
|
|
515
|
-
declarations: [{ ...rule, selectors: childSelectors }]
|
|
516
|
-
});
|
|
517
|
-
index++;
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
// Only single-selector style rules can act as a factoring candidate.
|
|
523
|
-
if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
|
|
524
|
-
result.push(rule);
|
|
525
|
-
index++;
|
|
526
|
-
continue;
|
|
527
|
-
}
|
|
528
|
-
const candidateParent = extractLeadingCompound(rule.selectors[0]);
|
|
529
|
-
if (candidateParent === null) {
|
|
530
|
-
result.push(rule);
|
|
531
|
-
index++;
|
|
532
|
-
continue;
|
|
533
|
-
}
|
|
534
|
-
// Collect the run of consecutive rules that can all nest under the candidate.
|
|
535
|
-
const matchNestedSelector = createNestedSelectorMatcher(candidateParent);
|
|
536
|
-
const run = [];
|
|
537
|
-
const nestedForms = [];
|
|
538
|
-
let lookahead = index;
|
|
539
|
-
while (lookahead < rules.length) {
|
|
540
|
-
const sibling = rules[lookahead];
|
|
541
|
-
if (sibling.type !== 'rule' || sibling.selectors?.length !== 1) {
|
|
542
|
-
break;
|
|
543
|
-
}
|
|
544
|
-
const nestedSelector = matchNestedSelector(sibling.selectors[0]);
|
|
545
|
-
if (nestedSelector === null) {
|
|
546
|
-
break;
|
|
547
|
-
}
|
|
548
|
-
run.push(sibling);
|
|
549
|
-
nestedForms.push(nestedSelector);
|
|
550
|
-
lookahead++;
|
|
551
|
-
}
|
|
552
|
-
// The wrapper writes the shared selector once plus its surrounding braces.
|
|
553
|
-
const wrapperCost = candidateParent.length + 2;
|
|
554
|
-
const charactersSaved = run.reduce((total, sibling, position) => {
|
|
555
|
-
return total + sibling.selectors[0].trim().length - nestedForms[position].length;
|
|
556
|
-
}, 0);
|
|
557
|
-
if (run.length >= 2 && charactersSaved > wrapperCost) {
|
|
558
|
-
const children = run.map((sibling, position) => {
|
|
559
|
-
return { ...sibling, selectors: [nestedForms[position]] };
|
|
560
|
-
});
|
|
561
|
-
result.push({
|
|
562
|
-
type: 'rule',
|
|
563
|
-
selectors: [candidateParent],
|
|
564
|
-
// Recurse so deeper shared prefixes among the children also factor out.
|
|
565
|
-
declarations: factorCommonParents(children)
|
|
566
|
-
});
|
|
567
|
-
index = lookahead;
|
|
568
|
-
} else {
|
|
569
|
-
result.push(rule);
|
|
570
|
-
index++;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
return result;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Shares a descendant prefix through `:is()` when a nesting wrapper did not
|
|
578
|
-
* save enough space to factor the parent out of the selector list.
|
|
579
|
-
*
|
|
580
|
-
* @param {object} rule The style rule whose selector list may be shortened.
|
|
581
|
-
*/
|
|
582
|
-
function factorSelectorBranches (rule) {
|
|
583
|
-
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
for (const parent of [...rule.selectors]) {
|
|
587
|
-
const prefix = parent + ' ';
|
|
588
|
-
const branches = rule.selectors.filter((selector) => {
|
|
589
|
-
return selector.startsWith(prefix);
|
|
590
|
-
});
|
|
591
|
-
const children = branches.map((selector) => {
|
|
592
|
-
return selector.slice(prefix.length);
|
|
593
|
-
});
|
|
594
|
-
if (children.length < 2 || !children.every(isUniversallyRecognizableSelector) || !selectorsShareSpecificity(children)) {
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
const grouped = prefix + ':is(' + children.sort().join(',') + ')';
|
|
598
|
-
if (grouped.length >= branches.join(',').length) {
|
|
599
|
-
continue;
|
|
600
|
-
}
|
|
601
|
-
const branchSet = new Set(branches);
|
|
602
|
-
const firstIndex = rule.selectors.indexOf(branches[0]);
|
|
603
|
-
rule.selectors = rule.selectors.flatMap((selector, index) => {
|
|
604
|
-
if (index === firstIndex) {
|
|
605
|
-
return [grouped];
|
|
606
|
-
}
|
|
607
|
-
return branchSet.has(selector) ? [] : [selector];
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/**
|
|
613
|
-
* Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
|
|
614
|
-
*
|
|
615
|
-
* @param {Array} rules The AST rule nodes to process.
|
|
616
|
-
* @param {function(Array): Array} mergeSelectorRules Callback to merge selector rules within each media block.
|
|
617
|
-
* @return {Array} A new array of rules with consecutive identical `@media` blocks combined.
|
|
618
|
-
*/
|
|
619
|
-
function mergeMediaRules (rules, mergeSelectorRules) {
|
|
620
|
-
const mediaMap = new Map();
|
|
621
|
-
const result = [];
|
|
622
|
-
for (const rule of rules) {
|
|
623
|
-
if (rule.type === 'media') {
|
|
624
|
-
const normalizedQuery = normalizeMedia(rule.media);
|
|
625
|
-
if (mediaMap.has(normalizedQuery)) {
|
|
626
|
-
mediaMap.get(normalizedQuery).rules.push(...(rule.rules || []));
|
|
627
|
-
} else {
|
|
628
|
-
mediaMap.set(normalizedQuery, rule);
|
|
629
|
-
result.push(rule);
|
|
630
|
-
}
|
|
631
|
-
} else {
|
|
632
|
-
if (rule.type !== 'whitespace') {
|
|
633
|
-
mediaMap.clear();
|
|
634
|
-
}
|
|
635
|
-
result.push(rule);
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
for (const rule of result) {
|
|
639
|
-
if (rule.type === 'media' && rule.rules && rule.rules.length) {
|
|
640
|
-
rule.rules = mergeSelectorRules(rule.rules);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
return result;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
/**
|
|
647
|
-
* Removes duplicate `@keyframes` definitions, keeping only the last occurrence of each named animation.
|
|
648
|
-
*
|
|
649
|
-
* @param {Array} rules The AST rule nodes to deduplicate.
|
|
650
|
-
* @return {Array} A new array of rules with earlier duplicate `@keyframes` removed.
|
|
651
|
-
*/
|
|
652
|
-
function deduplicateKeyframes (rules) {
|
|
653
|
-
const lastIndexByName = new Map();
|
|
654
|
-
for (let i = 0; i < rules.length; i++) {
|
|
655
|
-
if (rules[i].type === 'keyframes' && rules[i].name) {
|
|
656
|
-
lastIndexByName.set(rules[i].name, i);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
return rules.filter((rule, index) => {
|
|
660
|
-
if (rule.type === 'keyframes' && rule.name) {
|
|
661
|
-
return lastIndexByName.get(rule.name) === index;
|
|
662
|
-
}
|
|
663
|
-
return true;
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* Removes duplicate selectors from a combined selector list by normalizing
|
|
669
|
-
* whitespace and preserving the first occurrence of each unique selector.
|
|
670
|
-
*
|
|
671
|
-
* @param {Array} selectors The selector strings to deduplicate.
|
|
672
|
-
* @return {Array} A new array with duplicate selectors removed.
|
|
673
|
-
*/
|
|
674
|
-
function deduplicateSelectors (selectors) {
|
|
675
|
-
const seen = new Set();
|
|
676
|
-
return selectors.filter((selector) => {
|
|
677
|
-
// Normalize whitespace to single spaces for consistent comparison
|
|
678
|
-
const normalized = selector.trim().replace(/\s+/g, ' ');
|
|
679
|
-
if (seen.has(normalized)) {
|
|
680
|
-
return false;
|
|
681
|
-
}
|
|
682
|
-
seen.add(normalized);
|
|
683
|
-
return true;
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* The rule body entries that style nothing themselves, and so have no say in
|
|
689
|
-
* whether one rule's body is carried by another's.
|
|
690
|
-
*
|
|
691
|
-
* @type {Set<string>}
|
|
692
|
-
*/
|
|
693
|
-
const UNSTYLED_BODY_ENTRY_TYPES = new Set(['whitespace', 'comment']);
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* Splits a rule body into the declarations it sets directly and the blocks
|
|
697
|
-
* nested inside it, leaving out the entries that style nothing.
|
|
698
|
-
*
|
|
699
|
-
* @param {Array} declarations The rule body entries.
|
|
700
|
-
* @return {object} The body's `plainDeclarations` and `nestedBlocks`.
|
|
701
|
-
*/
|
|
702
|
-
function partitionRuleBody (declarations) {
|
|
703
|
-
const plainDeclarations = [];
|
|
704
|
-
const nestedBlocks = [];
|
|
705
|
-
for (const entry of declarations || []) {
|
|
706
|
-
if (UNSTYLED_BODY_ENTRY_TYPES.has(entry.type)) {
|
|
707
|
-
continue;
|
|
708
|
-
}
|
|
709
|
-
if (entry.property) {
|
|
710
|
-
plainDeclarations.push(entry);
|
|
711
|
-
} else {
|
|
712
|
-
nestedBlocks.push(entry);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
return {
|
|
716
|
-
nestedBlocks,
|
|
717
|
-
plainDeclarations
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Describes a block nested inside a rule precisely enough to tell whether two
|
|
723
|
-
* rules nest the very same thing. Only plain nested rules can be described: an
|
|
724
|
-
* at-rule nested in a rule carries a prelude that decides when its body applies,
|
|
725
|
-
* so it is reported as indescribable rather than guessed at.
|
|
726
|
-
*
|
|
727
|
-
* @param {object} block A block nested inside a rule body.
|
|
728
|
-
* @return {string|null} The signature, or null when the block cannot be described.
|
|
729
|
-
*/
|
|
730
|
-
function nestedBlockSignature (block) {
|
|
731
|
-
if (block.type !== 'rule' || !block.selectors?.length) {
|
|
732
|
-
return null;
|
|
733
|
-
}
|
|
734
|
-
const {
|
|
735
|
-
nestedBlocks,
|
|
736
|
-
plainDeclarations
|
|
737
|
-
} = partitionRuleBody(block.declarations);
|
|
738
|
-
const declarationParts = plainDeclarations.map((declaration) => {
|
|
739
|
-
return declaration.property + ':' + (declaration.value || '').trim();
|
|
740
|
-
});
|
|
741
|
-
const nestedParts = nestedBlocksSignature(nestedBlocks);
|
|
742
|
-
if (nestedParts === null) {
|
|
743
|
-
return null;
|
|
744
|
-
}
|
|
745
|
-
const selectors = block.selectors.map(normalizeSelector).join(',');
|
|
746
|
-
return selectors + '{' + declarationParts.join(';') + nestedParts + '}';
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* Describes every block nested in a rule body as one comparable string, in the
|
|
751
|
-
* order the blocks are written, since that order decides which of them wins a
|
|
752
|
-
* conflict.
|
|
753
|
-
*
|
|
754
|
-
* @param {Array} nestedBlocks The blocks nested in a rule body.
|
|
755
|
-
* @return {string|null} The combined signature, or null when any block cannot be described.
|
|
756
|
-
*/
|
|
757
|
-
function nestedBlocksSignature (nestedBlocks) {
|
|
758
|
-
let combined = '';
|
|
759
|
-
for (const block of nestedBlocks) {
|
|
760
|
-
const signature = nestedBlockSignature(block);
|
|
761
|
-
if (signature === null) {
|
|
762
|
-
return null;
|
|
763
|
-
}
|
|
764
|
-
combined += signature;
|
|
765
|
-
}
|
|
766
|
-
return combined;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
/**
|
|
770
|
-
* Reports whether a rule that nests blocks may take on further selectors. A
|
|
771
|
-
* nested selector resolves against its parent's whole selector list as `:is()`,
|
|
772
|
-
* which matches with the specificity of the strongest selector in that list, so
|
|
773
|
-
* the list may only grow while every selector in it weighs the same.
|
|
774
|
-
*
|
|
775
|
-
* @param {Array} selectors The selector list the merged rule would carry.
|
|
776
|
-
* @return {boolean} Whether the nested blocks keep their current weight.
|
|
777
|
-
*/
|
|
778
|
-
function selectorsShareSpecificity (selectors) {
|
|
779
|
-
const strongest = maximumRuleSpecificity(selectors);
|
|
780
|
-
const weakest = minimumRuleSpecificity(selectors);
|
|
781
|
-
return compareSpecificity(strongest, weakest) === 0;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/**
|
|
785
|
-
* Combines an earlier rule with the rule that follows it when everything the
|
|
786
|
-
* earlier one styles is restated by the later one, so the earlier selector can
|
|
787
|
-
* simply join the later rule's shared part and the remainder stays behind in a
|
|
788
|
-
* rule of its own.
|
|
789
|
-
*
|
|
790
|
-
* Both rules have to nest the same blocks. A block nested in only one of them
|
|
791
|
-
* would either be lost or start matching the other rule's selector, neither of
|
|
792
|
-
* which the two rules said.
|
|
793
|
-
*
|
|
794
|
-
* @param {object} previousRule The rule immediately in front of `rule`.
|
|
795
|
-
* @param {object} rule The rule being folded into its predecessor.
|
|
796
|
-
* @return {Array|null} The rules that replace the pair, or null when they cannot be combined.
|
|
797
|
-
*/
|
|
798
|
-
function mergeRuleIntoPredecessor (previousRule, rule) {
|
|
799
|
-
const isMergeableRulePair = (
|
|
800
|
-
previousRule?.type === 'rule' &&
|
|
801
|
-
previousRule.selectors?.length &&
|
|
802
|
-
rule.type === 'rule' &&
|
|
803
|
-
rule.selectors?.length
|
|
804
|
-
);
|
|
805
|
-
if (!isMergeableRulePair) {
|
|
806
|
-
return null;
|
|
807
|
-
}
|
|
808
|
-
const previousBody = partitionRuleBody(previousRule.declarations);
|
|
809
|
-
const currentBody = partitionRuleBody(rule.declarations);
|
|
810
|
-
if (!previousBody.plainDeclarations.length || !currentBody.plainDeclarations.length) {
|
|
811
|
-
return null;
|
|
812
|
-
}
|
|
813
|
-
const previousNesting = nestedBlocksSignature(previousBody.nestedBlocks);
|
|
814
|
-
const currentNesting = nestedBlocksSignature(currentBody.nestedBlocks);
|
|
815
|
-
if (previousNesting === null || previousNesting !== currentNesting) {
|
|
816
|
-
return null;
|
|
817
|
-
}
|
|
818
|
-
const currentValueByProperty = new Map(
|
|
819
|
-
currentBody.plainDeclarations.map((declaration) => {
|
|
820
|
-
return [declaration.property, (declaration.value || '').trim()];
|
|
821
|
-
})
|
|
822
|
-
);
|
|
823
|
-
const previousIsRestated = previousBody.plainDeclarations.every((declaration) => {
|
|
824
|
-
return currentValueByProperty.get(declaration.property) === (declaration.value || '').trim();
|
|
825
|
-
});
|
|
826
|
-
// `all` rewrites every other property, so the later rule restating a value is
|
|
827
|
-
// no promise that it applies the same way once `all` has run.
|
|
828
|
-
const currentResetsEverything = currentValueByProperty.has('all');
|
|
829
|
-
if (!previousIsRestated || currentResetsEverything) {
|
|
830
|
-
return null;
|
|
831
|
-
}
|
|
832
|
-
const combinedSelectors = deduplicateSelectors([...previousRule.selectors, ...rule.selectors]);
|
|
833
|
-
if (previousBody.nestedBlocks.length && !selectorsShareSpecificity(combinedSelectors)) {
|
|
834
|
-
return null;
|
|
835
|
-
}
|
|
836
|
-
const restatedProperties = new Set(
|
|
837
|
-
previousBody.plainDeclarations.map((declaration) => {
|
|
838
|
-
return declaration.property;
|
|
839
|
-
})
|
|
840
|
-
);
|
|
841
|
-
const remainingDeclarations = currentBody.plainDeclarations.filter((declaration) => {
|
|
842
|
-
return !restatedProperties.has(declaration.property);
|
|
843
|
-
});
|
|
844
|
-
const merged = [{ ...previousRule, selectors: combinedSelectors }];
|
|
845
|
-
if (remainingDeclarations.length) {
|
|
846
|
-
merged.push({ ...rule, declarations: remainingDeclarations });
|
|
847
|
-
}
|
|
848
|
-
return merged;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
/**
|
|
852
|
-
* Merges consecutive rules whose declarations are a subset of the following rule, combining their selectors and splitting out any extra declarations.
|
|
853
|
-
*
|
|
854
|
-
* @param {Array} rules The AST rule nodes to merge.
|
|
855
|
-
* @return {Array} A new array of rules with declaration-compatible consecutive rules combined.
|
|
856
|
-
*/
|
|
857
|
-
function mergeByDeclarations (rules) {
|
|
858
|
-
const result = [];
|
|
859
|
-
for (const rule of rules) {
|
|
860
|
-
const merged = mergeRuleIntoPredecessor(result[result.length - 1], rule);
|
|
861
|
-
if (merged) {
|
|
862
|
-
result.pop();
|
|
863
|
-
result.push(...merged);
|
|
864
|
-
continue;
|
|
865
|
-
}
|
|
866
|
-
result.push(rule);
|
|
867
|
-
}
|
|
868
|
-
return result;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
/**
|
|
872
|
-
* Advances past a CSS identifier (name) starting at the given index.
|
|
873
|
-
*
|
|
874
|
-
* @param {string} text The selector text.
|
|
875
|
-
* @param {number} start The index to start scanning from.
|
|
876
|
-
* @return {number} The index just after the identifier.
|
|
877
|
-
*/
|
|
878
|
-
function skipSelectorName (text, start) {
|
|
879
|
-
let index = start;
|
|
880
|
-
// Advance over identifier characters (letters, digits, hyphen, underscore)
|
|
881
|
-
while (index < text.length && (/[a-zA-Z0-9_-]/).test(text[index])) {
|
|
882
|
-
index++;
|
|
883
|
-
}
|
|
884
|
-
return index;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
/**
|
|
888
|
-
* Compares two specificity tuples [ids, classes, types] lexicographically.
|
|
889
|
-
*
|
|
890
|
-
* @param {Array} first The first specificity tuple.
|
|
891
|
-
* @param {Array} second The second specificity tuple.
|
|
892
|
-
* @return {number} Negative, zero, or positive per standard comparison.
|
|
893
|
-
*/
|
|
894
|
-
function compareSpecificity (first, second) {
|
|
895
|
-
for (let index = 0; index < 3; index++) {
|
|
896
|
-
if (first[index] !== second[index]) {
|
|
897
|
-
return first[index] - second[index];
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
return 0;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Computes the CSS specificity of a single complex selector as an
|
|
905
|
-
* [ids, classes, types] tuple. The nesting selector `&` is ignored because its
|
|
906
|
-
* contribution comes from the shared parent when comparing sibling selectors.
|
|
907
|
-
* Functional pseudo-classes `:is()`, `:not()`, `:has()`, and `:matches()` add
|
|
908
|
-
* the maximum specificity of their arguments; `:where()` adds nothing.
|
|
909
|
-
*
|
|
910
|
-
* @param {string} selector A single complex selector string.
|
|
911
|
-
* @return {Array} The [ids, classes, types] specificity tuple.
|
|
912
|
-
*/
|
|
913
|
-
function computeSpecificity (selector) {
|
|
914
|
-
const specificity = [0, 0, 0];
|
|
915
|
-
const text = selector.trim();
|
|
916
|
-
let index = 0;
|
|
917
|
-
while (index < text.length) {
|
|
918
|
-
const character = text[index];
|
|
919
|
-
if (character === '#') {
|
|
920
|
-
specificity[0]++;
|
|
921
|
-
index = skipSelectorName(text, index + 1);
|
|
922
|
-
} else if (character === '.') {
|
|
923
|
-
specificity[1]++;
|
|
924
|
-
index = skipSelectorName(text, index + 1);
|
|
925
|
-
} else if (character === '[') {
|
|
926
|
-
specificity[1]++;
|
|
927
|
-
// An attribute selector that never closes runs to the end of the selector
|
|
928
|
-
const closeBracketIndex = findMatchingBracket(text, index);
|
|
929
|
-
index = closeBracketIndex === -1 ? text.length : closeBracketIndex + 1;
|
|
930
|
-
} else if (character === ':') {
|
|
931
|
-
if (text[index + 1] === ':') {
|
|
932
|
-
specificity[2]++;
|
|
933
|
-
index = skipSelectorName(text, index + 2);
|
|
934
|
-
} else {
|
|
935
|
-
const nameStart = index + 1;
|
|
936
|
-
const nameEnd = skipSelectorName(text, nameStart);
|
|
937
|
-
const name = text.slice(nameStart, nameEnd).toLowerCase();
|
|
938
|
-
if (text[nameEnd] === '(') {
|
|
939
|
-
// An argument list that never closes runs to the end of the selector
|
|
940
|
-
const closeIndex = findMatchingParenthesis(text, nameEnd);
|
|
941
|
-
const argumentsEnd = closeIndex === -1 ? text.length : closeIndex;
|
|
942
|
-
const inner = text.slice(nameEnd + 1, argumentsEnd);
|
|
943
|
-
if (name === 'where') {
|
|
944
|
-
// :where() contributes zero specificity
|
|
945
|
-
} else if (name === 'is' || name === 'not' || name === 'has' || name === 'matches') {
|
|
946
|
-
const innerMax = maxSelectorSpecificity(inner);
|
|
947
|
-
specificity[0] += innerMax[0];
|
|
948
|
-
specificity[1] += innerMax[1];
|
|
949
|
-
specificity[2] += innerMax[2];
|
|
950
|
-
} else {
|
|
951
|
-
specificity[1]++;
|
|
952
|
-
}
|
|
953
|
-
index = argumentsEnd + 1;
|
|
954
|
-
} else {
|
|
955
|
-
// Legacy single-colon pseudo-elements count as pseudo-elements
|
|
956
|
-
if (name === 'before' || name === 'after' || name === 'first-line' || name === 'first-letter') {
|
|
957
|
-
specificity[2]++;
|
|
958
|
-
} else {
|
|
959
|
-
specificity[1]++;
|
|
960
|
-
}
|
|
961
|
-
index = nameEnd;
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
} else if (character === '*' || character === '&') {
|
|
965
|
-
// Universal selector and nesting selector add no counted specificity here
|
|
966
|
-
index++;
|
|
967
|
-
} else if ((/[a-zA-Z]/).test(character)) {
|
|
968
|
-
specificity[2]++;
|
|
969
|
-
index = skipSelectorName(text, index);
|
|
970
|
-
} else {
|
|
971
|
-
// Combinators and whitespace do not affect specificity
|
|
972
|
-
index++;
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
return specificity;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
/**
|
|
979
|
-
* Returns the maximum specificity tuple across a comma-separated selector list,
|
|
980
|
-
* matching how a comma-separated group behaves as `:is()`.
|
|
981
|
-
*
|
|
982
|
-
* @param {string} selectorList The selector list string.
|
|
983
|
-
* @return {Array} The maximum [ids, classes, types] tuple.
|
|
984
|
-
*/
|
|
985
|
-
function maxSelectorSpecificity (selectorList) {
|
|
986
|
-
let maximum = [0, 0, 0];
|
|
987
|
-
for (const selector of splitTopLevelCommaList(selectorList)) {
|
|
988
|
-
const specificity = computeSpecificity(selector);
|
|
989
|
-
if (compareSpecificity(specificity, maximum) > 0) {
|
|
990
|
-
maximum = specificity;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
return maximum;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Returns the strongest specificity a rule can match with, which is the highest
|
|
998
|
-
* of its individual selectors.
|
|
999
|
-
*
|
|
1000
|
-
* @param {Array} selectors The rule's selector strings.
|
|
1001
|
-
* @return {Array} The [ids, classes, types] specificity tuple.
|
|
1002
|
-
*/
|
|
1003
|
-
function maximumRuleSpecificity (selectors) {
|
|
1004
|
-
return maxSelectorSpecificity((selectors || []).join(','));
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* Returns the weakest specificity a rule's declarations are applied with. Every
|
|
1009
|
-
* selector of a top-level rule applies the same declarations under its own
|
|
1010
|
-
* specificity, so the weakest selector decides whether another rule is able to
|
|
1011
|
-
* override the rule as a whole.
|
|
1012
|
-
*
|
|
1013
|
-
* @param {Array} selectors The rule's selector strings.
|
|
1014
|
-
* @return {Array} The [ids, classes, types] specificity tuple.
|
|
1015
|
-
*/
|
|
1016
|
-
function minimumRuleSpecificity (selectors) {
|
|
1017
|
-
let minimum = null;
|
|
1018
|
-
for (const selector of splitTopLevelCommaList((selectors || []).join(','))) {
|
|
1019
|
-
const specificity = computeSpecificity(selector);
|
|
1020
|
-
if (!minimum || compareSpecificity(specificity, minimum) < 0) {
|
|
1021
|
-
minimum = specificity;
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
return minimum || [0, 0, 0];
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
/**
|
|
1028
|
-
* Adds two specificity tuples together, which is how a nested selector combines
|
|
1029
|
-
* with the parent selector it only ever matches through.
|
|
1030
|
-
*
|
|
1031
|
-
* @param {Array} first The first [ids, classes, types] tuple.
|
|
1032
|
-
* @param {Array} second The second [ids, classes, types] tuple.
|
|
1033
|
-
* @return {Array} The summed specificity tuple.
|
|
1034
|
-
*/
|
|
1035
|
-
function addSpecificity (first, second) {
|
|
1036
|
-
return [
|
|
1037
|
-
first[0] + second[0],
|
|
1038
|
-
first[1] + second[1],
|
|
1039
|
-
first[2] + second[2]
|
|
1040
|
-
];
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
/**
|
|
1044
|
-
* Builds a normalized signature of a rule's declaration body, or null when the
|
|
1045
|
-
* rule contains anything other than plain declarations (e.g. nested rules).
|
|
1046
|
-
*
|
|
1047
|
-
* @param {object} rule The AST rule node.
|
|
1048
|
-
* @return {string|null} The sorted "property:value" signature, or null.
|
|
1049
|
-
*/
|
|
1050
|
-
function nestedRuleBodySignature (rule) {
|
|
1051
|
-
const declarations = (rule.declarations || []).filter((declaration) => {
|
|
1052
|
-
return declaration.type !== 'whitespace' && declaration.type !== 'comment';
|
|
1053
|
-
});
|
|
1054
|
-
if (declarations.length === 0) {
|
|
1055
|
-
return null;
|
|
1056
|
-
}
|
|
1057
|
-
const isAllPlainDeclarations = declarations.every((declaration) => {
|
|
1058
|
-
return declaration.type === 'declaration' && declaration.property;
|
|
1059
|
-
});
|
|
1060
|
-
if (!isAllPlainDeclarations) {
|
|
1061
|
-
return null;
|
|
1062
|
-
}
|
|
1063
|
-
return declarations
|
|
1064
|
-
.map((declaration) => {
|
|
1065
|
-
return declaration.property + ':' + (declaration.value || '').trim();
|
|
1066
|
-
})
|
|
1067
|
-
.sort()
|
|
1068
|
-
.join(';');
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
/**
|
|
1072
|
-
* Determines whether two nested rules can be merged into a shared selector list.
|
|
1073
|
-
* They must have identical declaration bodies and the same specificity level,
|
|
1074
|
-
* since comma-separated nested selectors share the highest specificity of the
|
|
1075
|
-
* group (like `:is()`).
|
|
1076
|
-
*
|
|
1077
|
-
* @param {object} first The first nested rule.
|
|
1078
|
-
* @param {object} second The second nested rule.
|
|
1079
|
-
* @return {boolean} Whether the two nested rules may be merged.
|
|
1080
|
-
*/
|
|
1081
|
-
function nestedRulesMergeable (first, second) {
|
|
1082
|
-
if (!first.selectors?.length || !second.selectors?.length) {
|
|
1083
|
-
return false;
|
|
1084
|
-
}
|
|
1085
|
-
const firstSignature = nestedRuleBodySignature(first);
|
|
1086
|
-
if (firstSignature === null || firstSignature !== nestedRuleBodySignature(second)) {
|
|
1087
|
-
return false;
|
|
1088
|
-
}
|
|
1089
|
-
const firstSpecificity = maxSelectorSpecificity(first.selectors.join(','));
|
|
1090
|
-
const secondSpecificity = maxSelectorSpecificity(second.selectors.join(','));
|
|
1091
|
-
return compareSpecificity(firstSpecificity, secondSpecificity) === 0;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
/**
|
|
1095
|
-
* Recursively merges consecutive nested rules within a declaration list when
|
|
1096
|
-
* they share identical bodies and specificity, combining their selector lists.
|
|
1097
|
-
*
|
|
1098
|
-
* @param {Array} declarations The declaration/nested-rule entries of a parent rule.
|
|
1099
|
-
* @return {Array} The declaration list with mergeable nested rules combined.
|
|
1100
|
-
*/
|
|
1101
|
-
function mergeConsecutiveNestedRules (declarations) {
|
|
1102
|
-
const recursed = declarations.map((declaration) => {
|
|
1103
|
-
if (declaration.type === 'rule' && declaration.declarations) {
|
|
1104
|
-
return { ...declaration, declarations: mergeConsecutiveNestedRules(declaration.declarations) };
|
|
1105
|
-
}
|
|
1106
|
-
return declaration;
|
|
1107
|
-
});
|
|
1108
|
-
|
|
1109
|
-
const result = [];
|
|
1110
|
-
for (const declaration of recursed) {
|
|
1111
|
-
const previous = result[result.length - 1];
|
|
1112
|
-
if (
|
|
1113
|
-
declaration.type === 'rule' &&
|
|
1114
|
-
previous &&
|
|
1115
|
-
previous.type === 'rule' &&
|
|
1116
|
-
nestedRulesMergeable(previous, declaration)
|
|
1117
|
-
) {
|
|
1118
|
-
result[result.length - 1] = {
|
|
1119
|
-
...previous,
|
|
1120
|
-
selectors: [...previous.selectors, ...declaration.selectors]
|
|
1121
|
-
};
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
result.push(declaration);
|
|
1125
|
-
}
|
|
1126
|
-
return result;
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
/**
|
|
1130
|
-
* Merges mergeable nested rules within every rule's body across the stylesheet,
|
|
1131
|
-
* recursing into `@media` and `@layer` blocks. Top-level rules are not merged
|
|
1132
|
-
* here, since only nested (comma-grouped) selectors share specificity.
|
|
1133
|
-
*
|
|
1134
|
-
* @param {Array} rules The AST rule nodes to process.
|
|
1135
|
-
* @return {Array} The rules with mergeable nested rules combined.
|
|
1136
|
-
*/
|
|
1137
|
-
function mergeIdenticalNestedRules (rules) {
|
|
1138
|
-
for (const rule of rules) {
|
|
1139
|
-
if (rule.type === 'rule' && rule.declarations) {
|
|
1140
|
-
rule.declarations = mergeConsecutiveNestedRules(rule.declarations);
|
|
1141
|
-
} else if ((rule.type === 'media' || rule.type === 'layer') && rule.rules) {
|
|
1142
|
-
rule.rules = mergeIdenticalNestedRules(rule.rules);
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
return rules;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* The property that rewrites every other standard property, and so conflicts
|
|
1150
|
-
* with any declaration it is ordered against.
|
|
1151
|
-
*
|
|
1152
|
-
* @type {string}
|
|
1153
|
-
*/
|
|
1154
|
-
const RESET_ALL_PROPERTY = 'all';
|
|
1155
|
-
|
|
1156
|
-
/**
|
|
1157
|
-
* Index key standing for "declares anything at all", used to order a rule that
|
|
1158
|
-
* sets `all` against every other declaration.
|
|
1159
|
-
*
|
|
1160
|
-
* @type {symbol}
|
|
1161
|
-
*/
|
|
1162
|
-
const ANY_PROPERTY = Symbol('any property');
|
|
1163
|
-
|
|
1164
|
-
/**
|
|
1165
|
-
* The leaf properties each declared property can write to, computed on first
|
|
1166
|
-
* use. The shorthand tables never change, so a property always expands the same
|
|
1167
|
-
* way.
|
|
1168
|
-
*
|
|
1169
|
-
* @type {Map<string, Set<string>>}
|
|
1170
|
-
*/
|
|
1171
|
-
const overridablePropertiesByProperty = new Map();
|
|
1172
|
-
|
|
1173
|
-
/**
|
|
1174
|
-
* Expands a declared property into every leaf longhand it can write to,
|
|
1175
|
-
* including the extras a shorthand resets beyond its own longhands. Two
|
|
1176
|
-
* declarations can only override one another when these sets intersect, which
|
|
1177
|
-
* is what makes `margin` conflict with `margin-top` but not with `color`.
|
|
1178
|
-
*
|
|
1179
|
-
* @param {string} property The declared property name.
|
|
1180
|
-
* @return {Set} The leaf property names the declaration writes to.
|
|
1181
|
-
*/
|
|
1182
|
-
function expandToOverridableProperties (property) {
|
|
1183
|
-
const cachedProperties = overridablePropertiesByProperty.get(property);
|
|
1184
|
-
if (cachedProperties) {
|
|
1185
|
-
return cachedProperties;
|
|
1186
|
-
}
|
|
1187
|
-
const overridableProperties = new Set(expandToLeafProperties(property));
|
|
1188
|
-
for (const resetProperty of getOverridesOf(property)) {
|
|
1189
|
-
for (const leafProperty of expandToLeafProperties(resetProperty)) {
|
|
1190
|
-
overridableProperties.add(leafProperty);
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
overridablePropertiesByProperty.set(property, overridableProperties);
|
|
1194
|
-
return overridableProperties;
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
/**
|
|
1198
|
-
* Whether a property name is a custom property, which shorthands and `all`
|
|
1199
|
-
* never reset.
|
|
1200
|
-
*
|
|
1201
|
-
* @param {string} property The leaf property name.
|
|
1202
|
-
* @return {boolean} True when the property is a custom property.
|
|
1203
|
-
*/
|
|
1204
|
-
function isCustomProperty (property) {
|
|
1205
|
-
return property.startsWith('--');
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/**
|
|
1209
|
-
* @typedef {object} RuleWriteProfile
|
|
1210
|
-
* @property {Map} specificityByProperty The strongest specificity each leaf property is written with.
|
|
1211
|
-
* @property {Array} weakestSpecificity The weakest specificity the rule's declarations are applied with.
|
|
1212
|
-
* @property {Array} strongestSpecificity The strongest specificity anything in the rule is written with.
|
|
1213
|
-
* @property {boolean} writesStandardProperty Whether the rule writes any property other than a custom property.
|
|
1214
|
-
*/
|
|
1215
|
-
|
|
1216
|
-
/**
|
|
1217
|
-
* Notes in a profile that a leaf property is written with a specificity,
|
|
1218
|
-
* keeping only the strongest specificity seen for it.
|
|
1219
|
-
*
|
|
1220
|
-
* @param {RuleWriteProfile} profile The profile to extend.
|
|
1221
|
-
* @param {string} property The leaf property name.
|
|
1222
|
-
* @param {Array} specificity The specificity the property is written with.
|
|
1223
|
-
*/
|
|
1224
|
-
function addWrittenProperty (profile, property, specificity) {
|
|
1225
|
-
const strongestSoFar = profile.specificityByProperty.get(property);
|
|
1226
|
-
if (!strongestSoFar || compareSpecificity(specificity, strongestSoFar) > 0) {
|
|
1227
|
-
profile.specificityByProperty.set(property, specificity);
|
|
1228
|
-
}
|
|
1229
|
-
if (compareSpecificity(specificity, profile.strongestSpecificity) > 0) {
|
|
1230
|
-
profile.strongestSpecificity = specificity;
|
|
1231
|
-
}
|
|
1232
|
-
profile.writesStandardProperty = profile.writesStandardProperty || !isCustomProperty(property);
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
/**
|
|
1236
|
-
* Adds every leaf property a rule's declarations write to into a profile,
|
|
1237
|
-
* descending into nested rules, whose declarations only ever match through the
|
|
1238
|
-
* parent and so carry the specificity of both selectors.
|
|
1239
|
-
*
|
|
1240
|
-
* @param {RuleWriteProfile} profile The profile to extend.
|
|
1241
|
-
* @param {object} rule The AST rule node to read declarations from.
|
|
1242
|
-
* @param {Array} specificity The specificity the rule's own declarations apply with.
|
|
1243
|
-
*/
|
|
1244
|
-
function addWrittenProperties (profile, rule, specificity) {
|
|
1245
|
-
for (const declaration of rule.declarations || []) {
|
|
1246
|
-
if (declaration.type === 'rule') {
|
|
1247
|
-
const nestedSpecificity = addSpecificity(specificity, maximumRuleSpecificity(declaration.selectors));
|
|
1248
|
-
addWrittenProperties(profile, declaration, nestedSpecificity);
|
|
1249
|
-
} else if (declaration.type === 'declaration' && declaration.property) {
|
|
1250
|
-
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
1251
|
-
addWrittenProperty(profile, leafProperty, specificity);
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
/**
|
|
1258
|
-
* Summarizes which properties a rule writes to and how strongly, which is
|
|
1259
|
-
* everything the cascade needs to know about a rule to decide whether it may be
|
|
1260
|
-
* reordered against another one.
|
|
1261
|
-
*
|
|
1262
|
-
* @param {object} rule The AST rule node.
|
|
1263
|
-
* @return {RuleWriteProfile} The rule's write profile.
|
|
1264
|
-
*/
|
|
1265
|
-
function buildRuleWriteProfile (rule) {
|
|
1266
|
-
const profile = {
|
|
1267
|
-
specificityByProperty: new Map(),
|
|
1268
|
-
weakestSpecificity: minimumRuleSpecificity(rule.selectors),
|
|
1269
|
-
strongestSpecificity: [0, 0, 0],
|
|
1270
|
-
writesStandardProperty: false
|
|
1271
|
-
};
|
|
1272
|
-
addWrittenProperties(profile, rule, maximumRuleSpecificity(rule.selectors));
|
|
1273
|
-
return profile;
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
/**
|
|
1277
|
-
* Folds one rule's writes into another's profile, which is how a profile stays
|
|
1278
|
-
* current after a merge appends declarations to a rule. Both rules share the
|
|
1279
|
-
* same selectors, so the absorbed writes keep their recorded specificity.
|
|
1280
|
-
*
|
|
1281
|
-
* @param {RuleWriteProfile} profile The profile of the rule that grew.
|
|
1282
|
-
* @param {RuleWriteProfile} absorbedWrites The profile of the rule whose declarations were appended.
|
|
1283
|
-
*/
|
|
1284
|
-
function absorbWriteProfile (profile, absorbedWrites) {
|
|
1285
|
-
for (const [property, specificity] of absorbedWrites.specificityByProperty) {
|
|
1286
|
-
addWrittenProperty(profile, property, specificity);
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
/**
|
|
1291
|
-
* Tracks which properties each rule writes to and where it sits, so that a
|
|
1292
|
-
* merge can ask whether anything after a position could override the
|
|
1293
|
-
* declarations it wants to move, instead of rescanning the stylesheet once per
|
|
1294
|
-
* merge.
|
|
1295
|
-
*
|
|
1296
|
-
* Entries for a property are appended in increasing position order, and each
|
|
1297
|
-
* new entry discards the earlier ones it dominates, since a declaration that is
|
|
1298
|
-
* both later and at least as specific blocks everything a weaker, earlier one
|
|
1299
|
-
* would. What remains is a list whose specificity strictly decreases as
|
|
1300
|
-
* position grows, so the first entry past a queried position is also the
|
|
1301
|
-
* strongest one past it.
|
|
1302
|
-
*
|
|
1303
|
-
* @return {object} An index exposing `recordRule` and `blocksRelocation`.
|
|
1304
|
-
*/
|
|
1305
|
-
function createOverrideIndex () {
|
|
1306
|
-
const entriesByProperty = new Map();
|
|
1307
|
-
const profileByRule = new Map();
|
|
1308
|
-
|
|
1309
|
-
/**
|
|
1310
|
-
* Records that a property is written at a position with a given specificity.
|
|
1311
|
-
*
|
|
1312
|
-
* @param {string|symbol} property The leaf property name, or an index key.
|
|
1313
|
-
* @param {number} position The slot the writing rule occupies.
|
|
1314
|
-
* @param {Array} specificity The specificity the declaration applies with.
|
|
1315
|
-
*/
|
|
1316
|
-
function addEntry (property, position, specificity) {
|
|
1317
|
-
let entries = entriesByProperty.get(property);
|
|
1318
|
-
if (!entries) {
|
|
1319
|
-
entries = [];
|
|
1320
|
-
entriesByProperty.set(property, entries);
|
|
1321
|
-
}
|
|
1322
|
-
while (entries.length && compareSpecificity(entries[entries.length - 1].specificity, specificity) <= 0) {
|
|
1323
|
-
entries.pop();
|
|
1324
|
-
}
|
|
1325
|
-
entries.push({ position, specificity });
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
/**
|
|
1329
|
-
* Whether some rule after a position writes to a property with at least a
|
|
1330
|
-
* given specificity, and could therefore win the cascade against it.
|
|
1331
|
-
*
|
|
1332
|
-
* @param {string|symbol} property The leaf property name, or an index key.
|
|
1333
|
-
* @param {number} position The position to search after.
|
|
1334
|
-
* @param {Array} specificity The specificity to compare against.
|
|
1335
|
-
* @return {boolean} True when a later rule could override the property.
|
|
1336
|
-
*/
|
|
1337
|
-
function hasStrongerEntryAfter (property, position, specificity) {
|
|
1338
|
-
const entries = entriesByProperty.get(property);
|
|
1339
|
-
if (!entries) {
|
|
1340
|
-
return false;
|
|
1341
|
-
}
|
|
1342
|
-
let low = 0;
|
|
1343
|
-
let high = entries.length;
|
|
1344
|
-
while (low < high) {
|
|
1345
|
-
const middle = Math.floor((low + high) / 2);
|
|
1346
|
-
if (entries[middle].position > position) {
|
|
1347
|
-
high = middle;
|
|
1348
|
-
} else {
|
|
1349
|
-
low = middle + 1;
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
if (low === entries.length) {
|
|
1353
|
-
return false;
|
|
1354
|
-
}
|
|
1355
|
-
return compareSpecificity(entries[low].specificity, specificity) >= 0;
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
/**
|
|
1359
|
-
* Returns a rule's write profile, building it the first time it is needed.
|
|
1360
|
-
* Rules only ever grow here, by absorbing another rule's declarations, so a
|
|
1361
|
-
* profile stays valid for as long as the merge pass runs.
|
|
1362
|
-
*
|
|
1363
|
-
* @param {object} rule The AST rule node.
|
|
1364
|
-
* @return {RuleWriteProfile} The rule's write profile.
|
|
1365
|
-
*/
|
|
1366
|
-
function getWriteProfile (rule) {
|
|
1367
|
-
let profile = profileByRule.get(rule);
|
|
1368
|
-
if (!profile) {
|
|
1369
|
-
profile = buildRuleWriteProfile(rule);
|
|
1370
|
-
profileByRule.set(rule, profile);
|
|
1371
|
-
}
|
|
1372
|
-
return profile;
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
/**
|
|
1376
|
-
* Records every property a rule writes to at the position it occupies.
|
|
1377
|
-
*
|
|
1378
|
-
* @param {object} rule The AST rule node.
|
|
1379
|
-
* @param {number} position The slot the rule occupies.
|
|
1380
|
-
*/
|
|
1381
|
-
function recordRule (rule, position) {
|
|
1382
|
-
const profile = getWriteProfile(rule);
|
|
1383
|
-
if (!profile.specificityByProperty.size) {
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
for (const [property, specificity] of profile.specificityByProperty) {
|
|
1387
|
-
addEntry(property, position, specificity);
|
|
1388
|
-
}
|
|
1389
|
-
addEntry(ANY_PROPERTY, position, profile.strongestSpecificity);
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
/**
|
|
1393
|
-
* Folds a rule's writes into the rule that just absorbed its declarations, so
|
|
1394
|
-
* the grown rule is treated as writing to both sets of properties.
|
|
1395
|
-
*
|
|
1396
|
-
* @param {object} rule The rule that grew.
|
|
1397
|
-
* @param {object} absorbedRule The rule whose declarations were appended to it.
|
|
1398
|
-
*/
|
|
1399
|
-
function absorbRule (rule, absorbedRule) {
|
|
1400
|
-
absorbWriteProfile(getWriteProfile(rule), getWriteProfile(absorbedRule));
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
/**
|
|
1404
|
-
* Whether moving a rule's declarations past everything recorded after a
|
|
1405
|
-
* position would change which declaration wins the cascade. Only a rule that
|
|
1406
|
-
* is strictly less specific is guaranteed to lose either way, so anything of
|
|
1407
|
-
* equal or greater specificity that writes to the same properties blocks the
|
|
1408
|
-
* move.
|
|
1409
|
-
*
|
|
1410
|
-
* @param {object} movingRule The rule whose declarations would relocate.
|
|
1411
|
-
* @param {number} afterPosition The position the declarations would move across.
|
|
1412
|
-
* @return {boolean} True when the relocation is unsafe.
|
|
1413
|
-
*/
|
|
1414
|
-
function blocksRelocation (movingRule, afterPosition) {
|
|
1415
|
-
const profile = getWriteProfile(movingRule);
|
|
1416
|
-
const specificity = profile.weakestSpecificity;
|
|
1417
|
-
for (const property of profile.specificityByProperty.keys()) {
|
|
1418
|
-
if (hasStrongerEntryAfter(property, afterPosition, specificity)) {
|
|
1419
|
-
return true;
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
if (profile.writesStandardProperty && hasStrongerEntryAfter(RESET_ALL_PROPERTY, afterPosition, specificity)) {
|
|
1423
|
-
return true;
|
|
1424
|
-
}
|
|
1425
|
-
return (
|
|
1426
|
-
profile.specificityByProperty.has(RESET_ALL_PROPERTY) &&
|
|
1427
|
-
hasStrongerEntryAfter(ANY_PROPERTY, afterPosition, specificity)
|
|
1428
|
-
);
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
return {
|
|
1432
|
-
absorbRule,
|
|
1433
|
-
blocksRelocation,
|
|
1434
|
-
recordRule
|
|
1435
|
-
};
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
/**
|
|
1439
|
-
* Builds the key two rules must share to be considered the same selector.
|
|
1440
|
-
*
|
|
1441
|
-
* @param {object} rule The AST rule node.
|
|
1442
|
-
* @return {string} The normalized, order independent selector key.
|
|
1443
|
-
*/
|
|
1444
|
-
function buildSelectorKey (rule) {
|
|
1445
|
-
if (!rule.selectors) {
|
|
1446
|
-
return '';
|
|
1447
|
-
}
|
|
1448
|
-
return rule.selectors
|
|
1449
|
-
.map((selector) => {
|
|
1450
|
-
return normalizeSelector(selector);
|
|
1451
|
-
})
|
|
1452
|
-
.sort()
|
|
1453
|
-
.join(',');
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
/**
|
|
1457
|
-
* Placement for a merged rule whose combined declarations belong where the
|
|
1458
|
-
* later of the two rules was, because the earlier declarations moved down.
|
|
1459
|
-
*
|
|
1460
|
-
* @type {string}
|
|
1461
|
-
*/
|
|
1462
|
-
const MERGE_AT_LATER_RULE = 'later';
|
|
1463
|
-
|
|
1464
|
-
/**
|
|
1465
|
-
* Placement for a merged rule whose combined declarations belong where the
|
|
1466
|
-
* earlier of the two rules was, because the later declarations moved up.
|
|
1467
|
-
*
|
|
1468
|
-
* @type {string}
|
|
1469
|
-
*/
|
|
1470
|
-
const MERGE_AT_EARLIER_RULE = 'earlier';
|
|
1471
|
-
|
|
1472
|
-
/**
|
|
1473
|
-
* Reports whether an entry of a rule's body is a declaration whose standing in
|
|
1474
|
-
* the cascade can be read off the property it writes. Anything else a body may
|
|
1475
|
-
* hold, such as a nested rule, is never shadowed by a declaration beside it.
|
|
1476
|
-
*
|
|
1477
|
-
* @param {object} entry An entry of a rule's declaration list.
|
|
1478
|
-
* @return {boolean} True when the entry is a property declaration.
|
|
1479
|
-
*/
|
|
1480
|
-
function isPropertyDeclaration (entry) {
|
|
1481
|
-
return entry.type === 'declaration' && Boolean(entry.property);
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
/**
|
|
1485
|
-
* Finds which declaration decides each leaf property once a list of
|
|
1486
|
-
* declarations is read as one rule's body. Within a rule, an important
|
|
1487
|
-
* declaration beats a non-important one, and between two of equal weight the
|
|
1488
|
-
* last one written wins.
|
|
1489
|
-
*
|
|
1490
|
-
* @param {Array} declarations The declarations of the combined rule, in the order they would be written.
|
|
1491
|
-
* @return {Map} Map of leaf property name to the declaration that decides it.
|
|
1492
|
-
*/
|
|
1493
|
-
function findDecidingDeclarations (declarations) {
|
|
1494
|
-
const decidingDeclarations = new Map();
|
|
1495
|
-
const importantDeclarations = new Set();
|
|
1496
|
-
for (const declaration of declarations) {
|
|
1497
|
-
if (!isPropertyDeclaration(declaration)) {
|
|
1498
|
-
continue;
|
|
1499
|
-
}
|
|
1500
|
-
const isImportant = isImportantDeclaration(declaration);
|
|
1501
|
-
if (isImportant) {
|
|
1502
|
-
importantDeclarations.add(declaration);
|
|
1503
|
-
}
|
|
1504
|
-
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
1505
|
-
const decidingDeclaration = decidingDeclarations.get(leafProperty);
|
|
1506
|
-
const outranksIt = (
|
|
1507
|
-
!decidingDeclaration ||
|
|
1508
|
-
isImportant ||
|
|
1509
|
-
!importantDeclarations.has(decidingDeclaration)
|
|
1510
|
-
);
|
|
1511
|
-
if (outranksIt) {
|
|
1512
|
-
decidingDeclarations.set(leafProperty, declaration);
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
return decidingDeclarations;
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
/**
|
|
1520
|
-
* Reports whether anything a rule brings to a merge still decides a property
|
|
1521
|
-
* of the combined rule. A declaration that loses every property it writes to
|
|
1522
|
-
* another declaration of the same rule is dropped from the merged rule, so
|
|
1523
|
-
* where the merged rule sits cannot change how it reads.
|
|
1524
|
-
*
|
|
1525
|
-
* @param {object} rule The rule contributing the entries.
|
|
1526
|
-
* @param {Map} decidingDeclarations The deciding declaration of each leaf property of the merged rule.
|
|
1527
|
-
* @return {boolean} True when the rule still decides something after the merge.
|
|
1528
|
-
*/
|
|
1529
|
-
function decidesAnythingAfterMerge (rule, decidingDeclarations) {
|
|
1530
|
-
return (rule.declarations || []).some((entry) => {
|
|
1531
|
-
if (!isPropertyDeclaration(entry)) {
|
|
1532
|
-
return entry.type !== 'whitespace' && entry.type !== 'comment';
|
|
1533
|
-
}
|
|
1534
|
-
return [...expandToOverridableProperties(entry.property)].some((leafProperty) => {
|
|
1535
|
-
return decidingDeclarations.get(leafProperty) === entry;
|
|
1536
|
-
});
|
|
1537
|
-
});
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
/**
|
|
1541
|
-
* Decides where two rules with the same selector can be combined. Merging them
|
|
1542
|
-
* always makes one set of declarations cross whatever separates the two rules,
|
|
1543
|
-
* which can flip a conflict the crossing declarations used to win or lose, so
|
|
1544
|
-
* the merged rule goes wherever the set that moved keeps its old standing.
|
|
1545
|
-
* Nothing separates adjacent rules, so those always merge.
|
|
1546
|
-
*
|
|
1547
|
-
* The declarations one rule loses to the other are the exception: they are
|
|
1548
|
-
* dropped from the merged rule, so the merged rule belongs wherever the
|
|
1549
|
-
* declarations that outlive the merge already were, and nothing has to cross
|
|
1550
|
-
* anything at all. That is what lets `.a{color:red!important}` swallow a later
|
|
1551
|
-
* `.a{color:tan}` across a rule that also writes `color`.
|
|
1552
|
-
*
|
|
1553
|
-
* @param {object} overrideIndex The index of what each position writes to.
|
|
1554
|
-
* @param {object} earlierRule The first of the two rules with this selector.
|
|
1555
|
-
* @param {object} laterRule The second of the two rules with this selector.
|
|
1556
|
-
* @param {number} earlierPosition The slot the earlier rule occupies.
|
|
1557
|
-
* @param {number} laterPosition The slot the later rule would occupy.
|
|
1558
|
-
* @return {string|null} Where to place the merged rule, or null when merging is unsafe.
|
|
1559
|
-
*/
|
|
1560
|
-
function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPosition, laterPosition) {
|
|
1561
|
-
const rulesAreAdjacent = earlierPosition === laterPosition - 1;
|
|
1562
|
-
if (rulesAreAdjacent) {
|
|
1563
|
-
return MERGE_AT_LATER_RULE;
|
|
1564
|
-
}
|
|
1565
|
-
const decidingDeclarations = findDecidingDeclarations([
|
|
1566
|
-
...(earlierRule.declarations || []),
|
|
1567
|
-
...(laterRule.declarations || [])
|
|
1568
|
-
]);
|
|
1569
|
-
if (!decidesAnythingAfterMerge(laterRule, decidingDeclarations)) {
|
|
1570
|
-
return MERGE_AT_EARLIER_RULE;
|
|
1571
|
-
}
|
|
1572
|
-
if (!decidesAnythingAfterMerge(earlierRule, decidingDeclarations)) {
|
|
1573
|
-
return MERGE_AT_LATER_RULE;
|
|
1574
|
-
}
|
|
1575
|
-
if (!overrideIndex.blocksRelocation(earlierRule, earlierPosition)) {
|
|
1576
|
-
return MERGE_AT_LATER_RULE;
|
|
1577
|
-
}
|
|
1578
|
-
if (!overrideIndex.blocksRelocation(laterRule, earlierPosition)) {
|
|
1579
|
-
return MERGE_AT_EARLIER_RULE;
|
|
1580
|
-
}
|
|
1581
|
-
return null;
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
/**
|
|
1585
|
-
* Merges rules with identical normalized selectors by combining their declarations, as long as `chooseMergePlacement` finds a spot for the combined rule that the cascade reads the same way. Non-rule entries that affect the cascade (like `@media`) break the merge window, while comments do not, since comments never influence how declarations apply.
|
|
1586
|
-
*
|
|
1587
|
-
* @param {Array} rules The AST rule nodes to merge.
|
|
1588
|
-
* @return {Array} A new array of rules with same-selector rules combined.
|
|
1589
|
-
*/
|
|
1590
|
-
function mergeSelectorRulePass (rules) {
|
|
1591
|
-
// A rule merged at the later of the two positions moves to the end of the
|
|
1592
|
-
// output. Its old slot is emptied instead of spliced out so that every
|
|
1593
|
-
// recorded position stays valid, and the position map locates that slot
|
|
1594
|
-
// without searching the output.
|
|
1595
|
-
const slots = [];
|
|
1596
|
-
const positionByRule = new Map();
|
|
1597
|
-
const selectorMap = new Map();
|
|
1598
|
-
const overrideIndex = createOverrideIndex();
|
|
1599
|
-
for (const rule of rules) {
|
|
1600
|
-
if (rule.type === 'whitespace') {
|
|
1601
|
-
continue;
|
|
1602
|
-
}
|
|
1603
|
-
if (rule.type !== 'rule') {
|
|
1604
|
-
slots.push(rule);
|
|
1605
|
-
if (rule.type !== 'comment') {
|
|
1606
|
-
selectorMap.clear();
|
|
1607
|
-
positionByRule.clear();
|
|
1608
|
-
}
|
|
1609
|
-
continue;
|
|
1610
|
-
}
|
|
1611
|
-
const selectorKey = buildSelectorKey(rule);
|
|
1612
|
-
const existingRule = selectorKey && selectorMap.get(selectorKey);
|
|
1613
|
-
if (existingRule) {
|
|
1614
|
-
const existingPosition = positionByRule.get(existingRule);
|
|
1615
|
-
const mergedPosition = slots.length;
|
|
1616
|
-
const placement = chooseMergePlacement(overrideIndex, existingRule, rule, existingPosition, mergedPosition);
|
|
1617
|
-
if (placement) {
|
|
1618
|
-
existingRule.declarations.push(...(rule.declarations || []));
|
|
1619
|
-
overrideIndex.absorbRule(existingRule, rule);
|
|
1620
|
-
if (placement === MERGE_AT_LATER_RULE) {
|
|
1621
|
-
slots[existingPosition] = null;
|
|
1622
|
-
positionByRule.set(existingRule, mergedPosition);
|
|
1623
|
-
slots.push(existingRule);
|
|
1624
|
-
overrideIndex.recordRule(existingRule, mergedPosition);
|
|
1625
|
-
} else {
|
|
1626
|
-
// The merged declarations now live at the earlier slot, but they are
|
|
1627
|
-
// recorded at the later one, since the index is only ever appended
|
|
1628
|
-
// to. Reading them as later than they are can only hold back a
|
|
1629
|
-
// further merge, never allow an unsafe one.
|
|
1630
|
-
overrideIndex.recordRule(rule, mergedPosition);
|
|
1631
|
-
}
|
|
1632
|
-
continue;
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
selectorMap.set(selectorKey, rule);
|
|
1636
|
-
positionByRule.set(rule, slots.length);
|
|
1637
|
-
overrideIndex.recordRule(rule, slots.length);
|
|
1638
|
-
slots.push(rule);
|
|
1639
|
-
}
|
|
1640
|
-
return slots.filter((slot) => {
|
|
1641
|
-
return slot !== null;
|
|
1642
|
-
});
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
/**
|
|
1646
|
-
* Reconsiders blocked pairs after merges relocate the rules that blocked them.
|
|
1647
|
-
* Each productive pass removes a rule, so this reaches a fixed point without
|
|
1648
|
-
* rerunning parsing or unrelated optimizations.
|
|
1649
|
-
*
|
|
1650
|
-
* @param {Array} rules The sibling rules to merge.
|
|
1651
|
-
* @return {Array} Rules with all currently safe same-selector merges.
|
|
1652
|
-
*/
|
|
1653
|
-
function mergeSelectorRules (rules) {
|
|
1654
|
-
let previousRules = rules;
|
|
1655
|
-
let mergedRules = mergeSelectorRulePass(previousRules);
|
|
1656
|
-
while (mergedRules.length < previousRules.length) {
|
|
1657
|
-
previousRules = mergedRules;
|
|
1658
|
-
mergedRules = mergeSelectorRulePass(previousRules);
|
|
1659
|
-
}
|
|
1660
|
-
return mergedRules;
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
/**
|
|
1664
|
-
* Merges `@layer` blocks with the same name by combining their child rules, deduplicates layer statements, and merges selector rules within each block.
|
|
1665
|
-
*
|
|
1666
|
-
* @param {Array} rules The AST rule nodes to process.
|
|
1667
|
-
* @param {function(Array): Array} mergeSelectorRules Callback to merge selector rules within each layer block.
|
|
1668
|
-
* @return {Array} A new array of rules with same-name `@layer` blocks combined.
|
|
1669
|
-
*/
|
|
1670
|
-
function mergeLayerRules (rules, mergeSelectorRules) {
|
|
1671
|
-
const layerBlockMap = new Map();
|
|
1672
|
-
const layerStatementSeen = new Set();
|
|
1673
|
-
const result = [];
|
|
1674
|
-
for (const rule of rules) {
|
|
1675
|
-
if (rule.type === 'layer') {
|
|
1676
|
-
const layerName = normalizeLayerNames(rule.layer);
|
|
1677
|
-
if (rule.rules && rule.rules.length > 0) {
|
|
1678
|
-
if (layerName && layerBlockMap.has(layerName)) {
|
|
1679
|
-
layerBlockMap.get(layerName).rules.push(...rule.rules);
|
|
1680
|
-
} else {
|
|
1681
|
-
if (layerName) {
|
|
1682
|
-
layerBlockMap.set(layerName, rule);
|
|
1683
|
-
}
|
|
1684
|
-
result.push(rule);
|
|
1685
|
-
}
|
|
1686
|
-
} else {
|
|
1687
|
-
if (!layerName || !layerStatementSeen.has(layerName)) {
|
|
1688
|
-
if (layerName) {
|
|
1689
|
-
layerStatementSeen.add(layerName);
|
|
1690
|
-
}
|
|
1691
|
-
result.push(rule);
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
} else {
|
|
1695
|
-
result.push(rule);
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
|
-
for (const rule of result) {
|
|
1699
|
-
if (rule.type === 'layer' && rule.rules && rule.rules.length) {
|
|
1700
|
-
rule.rules = mergeSelectorRules(rule.rules);
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
return result;
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
/**
|
|
1707
|
-
* Normalizes a selector string for consistent comparison by trimming
|
|
1708
|
-
* and collapsing internal whitespace.
|
|
1709
|
-
*
|
|
1710
|
-
* @param {string} selector The raw selector string.
|
|
1711
|
-
* @return {string} The normalized selector.
|
|
1712
|
-
*/
|
|
1713
|
-
function normalizeSelector (selector) {
|
|
1714
|
-
return selector
|
|
1715
|
-
.trim()
|
|
1716
|
-
.replace(/\s+/g, ' ')
|
|
1717
|
-
// Convert double-colon ::before/::after to single-colon legacy form
|
|
1718
|
-
.replace(/::before\b/g, ':before')
|
|
1719
|
-
.replace(/::after\b/g, ':after');
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
/**
|
|
1723
|
-
* Indexes, for every normalized selector in the stylesheet, the last rule that
|
|
1724
|
-
* declares each property under it. Comparing that index against a rule's own
|
|
1725
|
-
* position answers "is this selector's property overridden later" in constant
|
|
1726
|
-
* time, instead of rescanning and renormalizing every following rule.
|
|
1727
|
-
*
|
|
1728
|
-
* @param {Array} rules The flat list of AST rule nodes.
|
|
1729
|
-
* @return {Map} Map of normalized selector to a map of property name to its last declaring rule index.
|
|
1730
|
-
*/
|
|
1731
|
-
function indexLastDeclaringRuleBySelector (rules) {
|
|
1732
|
-
const lastRuleIndexBySelector = new Map();
|
|
1733
|
-
rules.forEach((rule, ruleIndex) => {
|
|
1734
|
-
if (rule.type !== 'rule' || !rule.selectors) {
|
|
1735
|
-
return;
|
|
1736
|
-
}
|
|
1737
|
-
const declaredProperties = (rule.declarations || []).filter((declaration) => {
|
|
1738
|
-
return declaration.type === 'declaration';
|
|
1739
|
-
}).map((declaration) => {
|
|
1740
|
-
return declaration.property;
|
|
1741
|
-
});
|
|
1742
|
-
if (declaredProperties.length === 0) {
|
|
1743
|
-
return;
|
|
1744
|
-
}
|
|
1745
|
-
for (const selector of rule.selectors) {
|
|
1746
|
-
const normalizedSelector = normalizeSelector(selector);
|
|
1747
|
-
let lastRuleIndexByProperty = lastRuleIndexBySelector.get(normalizedSelector);
|
|
1748
|
-
if (!lastRuleIndexByProperty) {
|
|
1749
|
-
lastRuleIndexByProperty = new Map();
|
|
1750
|
-
lastRuleIndexBySelector.set(normalizedSelector, lastRuleIndexByProperty);
|
|
1751
|
-
}
|
|
1752
|
-
for (const property of declaredProperties) {
|
|
1753
|
-
lastRuleIndexByProperty.set(property, ruleIndex);
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
});
|
|
1757
|
-
return lastRuleIndexBySelector;
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
/**
|
|
1761
|
-
* Checks whether a given selector has a specific property overridden
|
|
1762
|
-
* by any later rule in the stylesheet. A property is considered
|
|
1763
|
-
* overridden if a subsequent rule contains that selector (as its only
|
|
1764
|
-
* selector or among its selectors) and declares the same property.
|
|
1765
|
-
*
|
|
1766
|
-
* @param {Map} lastRuleIndexBySelector The index built by `indexLastDeclaringRuleBySelector`.
|
|
1767
|
-
* @param {number} startIndex The index of the current rule (search starts after this).
|
|
1768
|
-
* @param {string} selector The normalized selector to check.
|
|
1769
|
-
* @param {string} property The CSS property name to check.
|
|
1770
|
-
* @return {boolean} True if a later rule overrides this selector+property.
|
|
1771
|
-
*/
|
|
1772
|
-
function isSelectorPropertyOverriddenLater (lastRuleIndexBySelector, startIndex, selector, property) {
|
|
1773
|
-
const lastRuleIndex = lastRuleIndexBySelector.get(selector)?.get(property);
|
|
1774
|
-
return lastRuleIndex !== undefined && lastRuleIndex > startIndex;
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
/**
|
|
1778
|
-
* Determines whether a declaration carries a trailing `!important` flag.
|
|
1779
|
-
*
|
|
1780
|
-
* @param {object} declaration The AST declaration node.
|
|
1781
|
-
* @return {boolean} True when the declaration is important.
|
|
1782
|
-
*/
|
|
1783
|
-
function isImportantDeclaration (declaration) {
|
|
1784
|
-
// A trailing !important suffix on the raw declaration value
|
|
1785
|
-
return /!\s*important\s*$/i.test(declaration.rawValue || declaration.value || '');
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
/**
|
|
1789
|
-
* Indexes, for every normalized selector in the stylesheet, the last rule that
|
|
1790
|
-
* covers each leaf property under it, where coverage follows the same "writes
|
|
1791
|
-
* to" model as `expandToOverridableProperties` (a later `border` declaration
|
|
1792
|
-
* on the same selector also overrides an earlier `border-color` on it), along
|
|
1793
|
-
* with whether that covering declaration is important. A last-covering entry
|
|
1794
|
-
* is consulted to answer "is every declaration this selector still gets from
|
|
1795
|
-
* the rule at position X re-declared later".
|
|
1796
|
-
*
|
|
1797
|
-
* @param {Array} rules The flat list of AST rule nodes.
|
|
1798
|
-
* @return {Map} Map of normalized selector to a map of covered leaf property name to `{ ruleIndex, important }`.
|
|
1799
|
-
*/
|
|
1800
|
-
function indexFinalCoveringRuleBySelector (rules) {
|
|
1801
|
-
const coverageBySelector = new Map();
|
|
1802
|
-
rules.forEach((rule, ruleIndex) => {
|
|
1803
|
-
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
1804
|
-
return;
|
|
1805
|
-
}
|
|
1806
|
-
const declarations = (rule.declarations || []).filter((declaration) => {
|
|
1807
|
-
return declaration.type === 'declaration' && declaration.property;
|
|
1808
|
-
});
|
|
1809
|
-
if (!declarations.length) {
|
|
1810
|
-
return;
|
|
1811
|
-
}
|
|
1812
|
-
for (const selector of rule.selectors) {
|
|
1813
|
-
const normalizedSelector = normalizeSelector(selector);
|
|
1814
|
-
let coverageByProperty = coverageBySelector.get(normalizedSelector);
|
|
1815
|
-
if (!coverageByProperty) {
|
|
1816
|
-
coverageByProperty = new Map();
|
|
1817
|
-
coverageBySelector.set(normalizedSelector, coverageByProperty);
|
|
1818
|
-
}
|
|
1819
|
-
for (const declaration of declarations) {
|
|
1820
|
-
const important = isImportantDeclaration(declaration);
|
|
1821
|
-
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
1822
|
-
coverageByProperty.set(leafProperty, { important, ruleIndex });
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
}
|
|
1826
|
-
});
|
|
1827
|
-
return coverageBySelector;
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
/**
|
|
1831
|
-
* Determines whether a later rule overrides every leaf property an earlier
|
|
1832
|
-
* declaration writes to for the same selector, including via shorthand
|
|
1833
|
-
* coverage, with an important earlier write requiring an important later
|
|
1834
|
-
* write to beat it.
|
|
1835
|
-
*
|
|
1836
|
-
* @param {Map} coverageByProperty The leaf property coverage index for the selector.
|
|
1837
|
-
* @param {object} declaration The earlier declaration to test.
|
|
1838
|
-
* @param {number} ruleIndex The position of the rule holding the declaration.
|
|
1839
|
-
* @param {boolean} declarationIsImportant Whether the earlier write of this property is important.
|
|
1840
|
-
* @return {boolean} True when the declaration is conclusively overridden later.
|
|
1841
|
-
*/
|
|
1842
|
-
function isDeclarationOverriddenLater (coverageByProperty, declaration, ruleIndex, declarationIsImportant) {
|
|
1843
|
-
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
1844
|
-
const coverage = coverageByProperty.get(leafProperty);
|
|
1845
|
-
if (!coverage || coverage.ruleIndex <= ruleIndex) {
|
|
1846
|
-
return false;
|
|
1847
|
-
}
|
|
1848
|
-
// An important declaration outlives a later non-important one
|
|
1849
|
-
if (declarationIsImportant && !coverage.important) {
|
|
1850
|
-
return false;
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
return true;
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
|
-
/**
|
|
1857
|
-
* Removes a selector from a multi-selector rule's list when every declaration
|
|
1858
|
-
* the rule would still give that selector is overridden by a later rule that
|
|
1859
|
-
* contains the same selector. For example, if `h1,h2{color:red}` is followed
|
|
1860
|
-
* by `h2{color:tan}`, the `h2` entry contributes nothing and can be dropped,
|
|
1861
|
-
* leaving `h1{color:red}`. If the list empties out entirely, the rule is
|
|
1862
|
-
* stripped of declarations so `removeEmptyRules` discards it.
|
|
1863
|
-
*
|
|
1864
|
-
* @param {Array} rules The flat list of AST rule nodes.
|
|
1865
|
-
* @return {Array} The rules with fully overridden selectors removed from their lists.
|
|
1866
|
-
*/
|
|
1867
|
-
function removeFullyOverriddenSelectors (rules) {
|
|
1868
|
-
// Pruning a selector only ever shrinks lists: a covering entry that wins
|
|
1869
|
-
// today is supplied by a later rule, and pruning earlier rules never moves
|
|
1870
|
-
// coverage forward, so a single index built up front stays accurate.
|
|
1871
|
-
const coverageBySelector = indexFinalCoveringRuleBySelector(rules);
|
|
1872
|
-
|
|
1873
|
-
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
|
|
1874
|
-
const rule = rules[ruleIndex];
|
|
1875
|
-
if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
|
|
1876
|
-
continue;
|
|
1877
|
-
}
|
|
1878
|
-
const entries = rule.declarations || [];
|
|
1879
|
-
// Removing a selector would also un-scope nested rules, which may depend
|
|
1880
|
-
// on it, so only rules made purely of declarations qualify
|
|
1881
|
-
const hasNestedContent = entries.some((entry) => {
|
|
1882
|
-
return entry.type !== 'declaration' && entry.type !== 'whitespace' && entry.type !== 'comment';
|
|
1883
|
-
});
|
|
1884
|
-
if (hasNestedContent) {
|
|
1885
|
-
continue;
|
|
1886
|
-
}
|
|
1887
|
-
const declarations = entries.filter((entry) => {
|
|
1888
|
-
return entry.type === 'declaration' && entry.property;
|
|
1889
|
-
});
|
|
1890
|
-
if (!declarations.length) {
|
|
1891
|
-
continue;
|
|
1892
|
-
}
|
|
1893
|
-
// Within a rule, any important declaration of a property makes the rule's
|
|
1894
|
-
// write of that property important (a conservative stand-in for tracking
|
|
1895
|
-
// which of several same-property declarations survives inside the rule)
|
|
1896
|
-
const importantByProperty = new Map();
|
|
1897
|
-
for (const declaration of declarations) {
|
|
1898
|
-
if (isImportantDeclaration(declaration)) {
|
|
1899
|
-
importantByProperty.set(declaration.property, true);
|
|
1900
|
-
} else if (!importantByProperty.has(declaration.property)) {
|
|
1901
|
-
importantByProperty.set(declaration.property, false);
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
const keptSelectors = rule.selectors.filter((selector) => {
|
|
1905
|
-
const coverageByProperty = coverageBySelector.get(normalizeSelector(selector));
|
|
1906
|
-
if (!coverageByProperty) {
|
|
1907
|
-
return true;
|
|
1908
|
-
}
|
|
1909
|
-
// Keep the selector while at least one declaration is not conclusively
|
|
1910
|
-
// overridden for it by a later rule
|
|
1911
|
-
return declarations.some((declaration) => {
|
|
1912
|
-
return !isDeclarationOverriddenLater(
|
|
1913
|
-
coverageByProperty,
|
|
1914
|
-
declaration,
|
|
1915
|
-
ruleIndex,
|
|
1916
|
-
importantByProperty.get(declaration.property)
|
|
1917
|
-
);
|
|
1918
|
-
});
|
|
1919
|
-
});
|
|
1920
|
-
if (keptSelectors.length !== rule.selectors.length) {
|
|
1921
|
-
if (!keptSelectors.length) {
|
|
1922
|
-
rule.declarations = entries.filter((entry) => {
|
|
1923
|
-
return entry.type !== 'declaration';
|
|
1924
|
-
});
|
|
1925
|
-
}
|
|
1926
|
-
rule.selectors = keptSelectors;
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
return rules;
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
/**
|
|
1933
|
-
* Removes properties from multi-selector rules when every selector in
|
|
1934
|
-
* the rule has that property overridden by a later rule. For example,
|
|
1935
|
-
* if `h1,h2{color:red}` is followed by `h1{color:blue}` and
|
|
1936
|
-
* `h2{color:green}`, the `color` in the first rule is redundant and
|
|
1937
|
-
* can be removed. If all properties are removed, the empty rule will
|
|
1938
|
-
* be cleaned up by `removeEmptyRules`.
|
|
1939
|
-
*
|
|
1940
|
-
* @param {Array} rules The flat list of AST rule nodes.
|
|
1941
|
-
* @return {Array} The rules with overridden multi-selector properties removed.
|
|
1942
|
-
*/
|
|
1943
|
-
function removeOverriddenMultiSelectorProperties (rules) {
|
|
1944
|
-
// Only rules that follow the one being pruned are ever consulted, and pruning
|
|
1945
|
-
// never adds a declaration, so a single index built up front stays accurate.
|
|
1946
|
-
const lastRuleIndexBySelector = indexLastDeclaringRuleBySelector(rules);
|
|
1947
|
-
|
|
1948
|
-
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
|
|
1949
|
-
const rule = rules[ruleIndex];
|
|
1950
|
-
if (rule.type !== 'rule' || !rule.selectors || rule.selectors.length < 2) {
|
|
1951
|
-
continue;
|
|
1952
|
-
}
|
|
1953
|
-
const normalizedSelectors = rule.selectors.map(normalizeSelector);
|
|
1954
|
-
const declarations = (rule.declarations || []).filter((declaration) => {
|
|
1955
|
-
return declaration.type === 'declaration';
|
|
1956
|
-
});
|
|
1957
|
-
if (declarations.length === 0) {
|
|
1958
|
-
continue;
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
// For each property in this multi-selector rule, check if ALL selectors
|
|
1962
|
-
// get that property overridden in later rules
|
|
1963
|
-
const propertiesToRemove = new Set();
|
|
1964
|
-
for (const declaration of declarations) {
|
|
1965
|
-
const property = declaration.property;
|
|
1966
|
-
const allSelectorsOverridden = normalizedSelectors.every((selector) => {
|
|
1967
|
-
return isSelectorPropertyOverriddenLater(lastRuleIndexBySelector, ruleIndex, selector, property);
|
|
1968
|
-
});
|
|
1969
|
-
if (allSelectorsOverridden) {
|
|
1970
|
-
propertiesToRemove.add(property);
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
if (propertiesToRemove.size > 0) {
|
|
1975
|
-
rule.declarations = (rule.declarations || []).filter((declaration) => {
|
|
1976
|
-
if (declaration.type !== 'declaration') {
|
|
1977
|
-
return true;
|
|
1978
|
-
}
|
|
1979
|
-
return !propertiesToRemove.has(declaration.property);
|
|
1980
|
-
});
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
return rules;
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
/**
|
|
1987
|
-
* Applies the structural optimization pipeline to a stylesheet or grouping
|
|
1988
|
-
* block, so merging at-rules does not leave their children unoptimized.
|
|
1989
|
-
*
|
|
1990
|
-
* @param {Array} rules The sibling AST entries.
|
|
1991
|
-
* @return {Array} The optimized sibling entries.
|
|
1992
|
-
*/
|
|
1993
|
-
function optimizeRuleList (rules) {
|
|
1994
|
-
let result = expandPureNestedRules(rules);
|
|
1995
|
-
result = mergeLayerRules(result, mergeSelectorRules);
|
|
1996
|
-
result = mergeMediaRules(result, mergeSelectorRules);
|
|
1997
|
-
result = deduplicateKeyframes(result);
|
|
1998
|
-
for (const rule of result) {
|
|
1999
|
-
if (rule.rules) {
|
|
2000
|
-
rule.rules = optimizeRuleList(rule.rules);
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2003
|
-
result = mergeSelectorRules(result);
|
|
2004
|
-
result = removeOverriddenMultiSelectorProperties(result);
|
|
2005
|
-
result = removeFullyOverriddenSelectors(result);
|
|
2006
|
-
result = removeEmptyRules(result);
|
|
2007
|
-
result = mergeByDeclarations(result);
|
|
2008
|
-
result = nestFlatRules(result);
|
|
2009
|
-
result = removeEmptyRules(result);
|
|
2010
|
-
result = mergeByDeclarations(result);
|
|
2011
|
-
result = factorCommonParents(result);
|
|
2012
|
-
result = nestFlatRules(result);
|
|
2013
|
-
result.forEach(factorSelectorBranches);
|
|
2014
|
-
return mergeIdenticalNestedRules(result);
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
export {
|
|
2018
|
-
deduplicateKeyframes,
|
|
2019
|
-
expandPureNestedRules,
|
|
2020
|
-
factorCommonParents,
|
|
2021
|
-
mergeByDeclarations,
|
|
2022
|
-
mergeIdenticalNestedRules,
|
|
2023
|
-
mergeLayerRules,
|
|
2024
|
-
mergeMediaRules,
|
|
2025
|
-
mergeSelectorRules,
|
|
2026
|
-
nestFlatRules,
|
|
2027
|
-
optimizeRuleList,
|
|
2028
|
-
removeEmptyRules,
|
|
2029
|
-
removeFullyOverriddenSelectors,
|
|
2030
|
-
removeOverriddenMultiSelectorProperties
|
|
2031
|
-
};
|