@thejaredwilcurt/csslop 0.0.49 → 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 +0 -1
- package/package.json +1 -1
- package/src/declarations/border.js +1 -1
- package/src/index.js +7 -7
- 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/utilities.js +14 -0
- 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 +127 -1669
- 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 -2314
- 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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Combines consecutive nested rules that share a declaration body and a specificity into one rule with a comma-separated selector list.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
compareSpecificity,
|
|
7
|
+
maxSelectorSpecificity
|
|
8
|
+
} from './specificity.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Builds a normalized signature of a rule's declaration body, or null when the
|
|
12
|
+
* rule contains anything other than plain declarations (e.g. nested rules).
|
|
13
|
+
*
|
|
14
|
+
* @param {object} rule The AST rule node.
|
|
15
|
+
* @return {string|null} The sorted "property:value" signature, or null.
|
|
16
|
+
*/
|
|
17
|
+
function nestedRuleBodySignature (rule) {
|
|
18
|
+
const declarations = (rule.declarations || []).filter((declaration) => {
|
|
19
|
+
return declaration.type !== 'whitespace' && declaration.type !== 'comment';
|
|
20
|
+
});
|
|
21
|
+
if (declarations.length === 0) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const isAllPlainDeclarations = declarations.every((declaration) => {
|
|
25
|
+
return declaration.type === 'declaration' && declaration.property;
|
|
26
|
+
});
|
|
27
|
+
if (!isAllPlainDeclarations) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return declarations
|
|
31
|
+
.map((declaration) => {
|
|
32
|
+
return declaration.property + ':' + (declaration.value || '').trim();
|
|
33
|
+
})
|
|
34
|
+
.sort()
|
|
35
|
+
.join(';');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Determines whether two nested rules can be merged into a shared selector list.
|
|
40
|
+
* They must have identical declaration bodies and the same specificity level,
|
|
41
|
+
* since comma-separated nested selectors share the highest specificity of the
|
|
42
|
+
* group (like `:is()`).
|
|
43
|
+
*
|
|
44
|
+
* @param {object} first The first nested rule.
|
|
45
|
+
* @param {object} second The second nested rule.
|
|
46
|
+
* @return {boolean} Whether the two nested rules may be merged.
|
|
47
|
+
*/
|
|
48
|
+
function nestedRulesMergeable (first, second) {
|
|
49
|
+
if (!first.selectors?.length || !second.selectors?.length) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const firstSignature = nestedRuleBodySignature(first);
|
|
53
|
+
if (firstSignature === null || firstSignature !== nestedRuleBodySignature(second)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const firstSpecificity = maxSelectorSpecificity(first.selectors.join(','));
|
|
57
|
+
const secondSpecificity = maxSelectorSpecificity(second.selectors.join(','));
|
|
58
|
+
return compareSpecificity(firstSpecificity, secondSpecificity) === 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Recursively merges consecutive nested rules within a declaration list when
|
|
63
|
+
* they share identical bodies and specificity, combining their selector lists.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array} declarations The declaration/nested-rule entries of a parent rule.
|
|
66
|
+
* @return {Array} The declaration list with mergeable nested rules combined.
|
|
67
|
+
*/
|
|
68
|
+
function mergeConsecutiveNestedRules (declarations) {
|
|
69
|
+
const recursed = declarations.map((declaration) => {
|
|
70
|
+
if (declaration.type === 'rule' && declaration.declarations) {
|
|
71
|
+
return { ...declaration, declarations: mergeConsecutiveNestedRules(declaration.declarations) };
|
|
72
|
+
}
|
|
73
|
+
return declaration;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = [];
|
|
77
|
+
for (const declaration of recursed) {
|
|
78
|
+
const previous = result[result.length - 1];
|
|
79
|
+
if (
|
|
80
|
+
declaration.type === 'rule' &&
|
|
81
|
+
previous &&
|
|
82
|
+
previous.type === 'rule' &&
|
|
83
|
+
nestedRulesMergeable(previous, declaration)
|
|
84
|
+
) {
|
|
85
|
+
result[result.length - 1] = {
|
|
86
|
+
...previous,
|
|
87
|
+
selectors: [...previous.selectors, ...declaration.selectors]
|
|
88
|
+
};
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
result.push(declaration);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Merges mergeable nested rules within every rule's body across the stylesheet,
|
|
98
|
+
* recursing into `@media` and `@layer` blocks. Top-level rules are not merged
|
|
99
|
+
* here, since only nested (comma-grouped) selectors share specificity.
|
|
100
|
+
*
|
|
101
|
+
* @param {Array} rules The AST rule nodes to process.
|
|
102
|
+
* @return {Array} The rules with mergeable nested rules combined.
|
|
103
|
+
*/
|
|
104
|
+
function mergeIdenticalNestedRules (rules) {
|
|
105
|
+
for (const rule of rules) {
|
|
106
|
+
if (rule.type === 'rule' && rule.declarations) {
|
|
107
|
+
rule.declarations = mergeConsecutiveNestedRules(rule.declarations);
|
|
108
|
+
} else if ((rule.type === 'media' || rule.type === 'layer') && rule.rules) {
|
|
109
|
+
rule.rules = mergeIdenticalNestedRules(rule.rules);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return rules;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export {
|
|
116
|
+
mergeIdenticalNestedRules
|
|
117
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Merges rules that share a selector, placing the merged rule wherever the cascade reads it the same way it read the two rules apart.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
expandToOverridableProperties,
|
|
7
|
+
isImportantDeclaration,
|
|
8
|
+
isPropertyDeclaration
|
|
9
|
+
} from './declaration-writes.js';
|
|
10
|
+
import { createOverrideIndex } from './override-index.js';
|
|
11
|
+
import { normalizeSelector } from './selector-normalization.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds the key two rules must share to be considered the same selector.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} rule The AST rule node.
|
|
17
|
+
* @return {string} The normalized, order independent selector key.
|
|
18
|
+
*/
|
|
19
|
+
function buildSelectorKey (rule) {
|
|
20
|
+
if (!rule.selectors) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
return rule.selectors
|
|
24
|
+
.map((selector) => {
|
|
25
|
+
return normalizeSelector(selector);
|
|
26
|
+
})
|
|
27
|
+
.sort()
|
|
28
|
+
.join(',');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns a rule's selector key, building it the first time it is needed. A
|
|
33
|
+
* merge only ever grows a rule's declarations, never its selectors, so a key
|
|
34
|
+
* stays valid for every pass of the merge.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} rule The AST rule node.
|
|
37
|
+
* @param {Map} selectorKeyByRule The selector keys already built, shared by every pass of one merge.
|
|
38
|
+
* @return {string} The normalized, order independent selector key.
|
|
39
|
+
*/
|
|
40
|
+
function getSelectorKey (rule, selectorKeyByRule) {
|
|
41
|
+
let selectorKey = selectorKeyByRule.get(rule);
|
|
42
|
+
if (selectorKey === undefined) {
|
|
43
|
+
selectorKey = buildSelectorKey(rule);
|
|
44
|
+
selectorKeyByRule.set(rule, selectorKey);
|
|
45
|
+
}
|
|
46
|
+
return selectorKey;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Placement for a merged rule whose combined declarations belong where the
|
|
51
|
+
* later of the two rules was, because the earlier declarations moved down.
|
|
52
|
+
*
|
|
53
|
+
* @type {string}
|
|
54
|
+
*/
|
|
55
|
+
const MERGE_AT_LATER_RULE = 'later';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Placement for a merged rule whose combined declarations belong where the
|
|
59
|
+
* earlier of the two rules was, because the later declarations moved up.
|
|
60
|
+
*
|
|
61
|
+
* @type {string}
|
|
62
|
+
*/
|
|
63
|
+
const MERGE_AT_EARLIER_RULE = 'earlier';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Finds which declaration decides each leaf property once a list of
|
|
67
|
+
* declarations is read as one rule's body. Within a rule, an important
|
|
68
|
+
* declaration beats a non-important one, and between two of equal weight the
|
|
69
|
+
* last one written wins.
|
|
70
|
+
*
|
|
71
|
+
* @param {Array} declarations The declarations of the combined rule, in the order they would be written.
|
|
72
|
+
* @return {Map} Map of leaf property name to the declaration that decides it.
|
|
73
|
+
*/
|
|
74
|
+
function findDecidingDeclarations (declarations) {
|
|
75
|
+
const decidingDeclarations = new Map();
|
|
76
|
+
const importantDeclarations = new Set();
|
|
77
|
+
for (const declaration of declarations) {
|
|
78
|
+
if (!isPropertyDeclaration(declaration)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const isImportant = isImportantDeclaration(declaration);
|
|
82
|
+
if (isImportant) {
|
|
83
|
+
importantDeclarations.add(declaration);
|
|
84
|
+
}
|
|
85
|
+
for (const leafProperty of expandToOverridableProperties(declaration.property)) {
|
|
86
|
+
const decidingDeclaration = decidingDeclarations.get(leafProperty);
|
|
87
|
+
const outranksIt = (
|
|
88
|
+
!decidingDeclaration ||
|
|
89
|
+
isImportant ||
|
|
90
|
+
!importantDeclarations.has(decidingDeclaration)
|
|
91
|
+
);
|
|
92
|
+
if (outranksIt) {
|
|
93
|
+
decidingDeclarations.set(leafProperty, declaration);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return decidingDeclarations;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reports whether anything a rule brings to a merge still decides a property
|
|
102
|
+
* of the combined rule. A declaration that loses every property it writes to
|
|
103
|
+
* another declaration of the same rule is dropped from the merged rule, so
|
|
104
|
+
* where the merged rule sits cannot change how it reads.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} rule The rule contributing the entries.
|
|
107
|
+
* @param {Map} decidingDeclarations The deciding declaration of each leaf property of the merged rule.
|
|
108
|
+
* @return {boolean} True when the rule still decides something after the merge.
|
|
109
|
+
*/
|
|
110
|
+
function decidesAnythingAfterMerge (rule, decidingDeclarations) {
|
|
111
|
+
return (rule.declarations || []).some((entry) => {
|
|
112
|
+
if (!isPropertyDeclaration(entry)) {
|
|
113
|
+
return entry.type !== 'whitespace' && entry.type !== 'comment';
|
|
114
|
+
}
|
|
115
|
+
return [...expandToOverridableProperties(entry.property)].some((leafProperty) => {
|
|
116
|
+
return decidingDeclarations.get(leafProperty) === entry;
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Decides where two rules with the same selector can be combined. Merging them
|
|
123
|
+
* always makes one set of declarations cross whatever separates the two rules,
|
|
124
|
+
* which can flip a conflict the crossing declarations used to win or lose, so
|
|
125
|
+
* the merged rule goes wherever the set that moved keeps its old standing.
|
|
126
|
+
* Nothing separates adjacent rules, so those always merge.
|
|
127
|
+
*
|
|
128
|
+
* The declarations one rule loses to the other are the exception: they are
|
|
129
|
+
* dropped from the merged rule, so the merged rule belongs wherever the
|
|
130
|
+
* declarations that outlive the merge already were, and nothing has to cross
|
|
131
|
+
* anything at all. That is what lets `.a{color:red!important}` swallow a later
|
|
132
|
+
* `.a{color:tan}` across a rule that also writes `color`.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} overrideIndex The index of what each position writes to.
|
|
135
|
+
* @param {object} earlierRule The first of the two rules with this selector.
|
|
136
|
+
* @param {object} laterRule The second of the two rules with this selector.
|
|
137
|
+
* @param {number} earlierPosition The slot the earlier rule occupies.
|
|
138
|
+
* @param {number} laterPosition The slot the later rule would occupy.
|
|
139
|
+
* @return {string|null} Where to place the merged rule, or null when merging is unsafe.
|
|
140
|
+
*/
|
|
141
|
+
function chooseMergePlacement (overrideIndex, earlierRule, laterRule, earlierPosition, laterPosition) {
|
|
142
|
+
const rulesAreAdjacent = earlierPosition === laterPosition - 1;
|
|
143
|
+
if (rulesAreAdjacent) {
|
|
144
|
+
return MERGE_AT_LATER_RULE;
|
|
145
|
+
}
|
|
146
|
+
const decidingDeclarations = findDecidingDeclarations([
|
|
147
|
+
...(earlierRule.declarations || []),
|
|
148
|
+
...(laterRule.declarations || [])
|
|
149
|
+
]);
|
|
150
|
+
if (!decidesAnythingAfterMerge(laterRule, decidingDeclarations)) {
|
|
151
|
+
return MERGE_AT_EARLIER_RULE;
|
|
152
|
+
}
|
|
153
|
+
if (!decidesAnythingAfterMerge(earlierRule, decidingDeclarations)) {
|
|
154
|
+
return MERGE_AT_LATER_RULE;
|
|
155
|
+
}
|
|
156
|
+
if (!overrideIndex.blocksRelocation(earlierRule, earlierPosition)) {
|
|
157
|
+
return MERGE_AT_LATER_RULE;
|
|
158
|
+
}
|
|
159
|
+
if (!overrideIndex.blocksRelocation(laterRule, earlierPosition)) {
|
|
160
|
+
return MERGE_AT_EARLIER_RULE;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 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.
|
|
167
|
+
*
|
|
168
|
+
* @param {Array} rules The AST rule nodes to merge.
|
|
169
|
+
* @param {object} mergeCaches The write profiles and selector keys shared by every pass of one merge.
|
|
170
|
+
* @return {Array} A new array of rules with same-selector rules combined.
|
|
171
|
+
*/
|
|
172
|
+
function mergeSelectorRulePass (rules, mergeCaches) {
|
|
173
|
+
// A rule merged at the later of the two positions moves to the end of the
|
|
174
|
+
// output. Its old slot is emptied instead of spliced out so that every
|
|
175
|
+
// recorded position stays valid, and the position map locates that slot
|
|
176
|
+
// without searching the output.
|
|
177
|
+
const slots = [];
|
|
178
|
+
const positionByRule = new Map();
|
|
179
|
+
const selectorMap = new Map();
|
|
180
|
+
const overrideIndex = createOverrideIndex(mergeCaches.profileByRule);
|
|
181
|
+
for (const rule of rules) {
|
|
182
|
+
if (rule.type === 'whitespace') {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (rule.type !== 'rule') {
|
|
186
|
+
slots.push(rule);
|
|
187
|
+
// Clearing a map replaces its storage, so already empty maps are left alone
|
|
188
|
+
if (rule.type !== 'comment' && selectorMap.size) {
|
|
189
|
+
selectorMap.clear();
|
|
190
|
+
positionByRule.clear();
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const selectorKey = getSelectorKey(rule, mergeCaches.selectorKeyByRule);
|
|
195
|
+
const existingRule = selectorKey && selectorMap.get(selectorKey);
|
|
196
|
+
if (existingRule) {
|
|
197
|
+
const existingPosition = positionByRule.get(existingRule);
|
|
198
|
+
const mergedPosition = slots.length;
|
|
199
|
+
const placement = chooseMergePlacement(overrideIndex, existingRule, rule, existingPosition, mergedPosition);
|
|
200
|
+
if (placement) {
|
|
201
|
+
existingRule.declarations.push(...(rule.declarations || []));
|
|
202
|
+
overrideIndex.absorbRule(existingRule, rule);
|
|
203
|
+
if (placement === MERGE_AT_LATER_RULE) {
|
|
204
|
+
slots[existingPosition] = null;
|
|
205
|
+
positionByRule.set(existingRule, mergedPosition);
|
|
206
|
+
slots.push(existingRule);
|
|
207
|
+
overrideIndex.recordRule(existingRule, mergedPosition);
|
|
208
|
+
} else {
|
|
209
|
+
// The merged declarations now live at the earlier slot, but they are
|
|
210
|
+
// recorded at the later one, since the index is only ever appended
|
|
211
|
+
// to. Reading them as later than they are can only hold back a
|
|
212
|
+
// further merge, never allow an unsafe one.
|
|
213
|
+
overrideIndex.recordRule(rule, mergedPosition);
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
selectorMap.set(selectorKey, rule);
|
|
219
|
+
positionByRule.set(rule, slots.length);
|
|
220
|
+
overrideIndex.recordRule(rule, slots.length);
|
|
221
|
+
slots.push(rule);
|
|
222
|
+
}
|
|
223
|
+
return slots.filter((slot) => {
|
|
224
|
+
return slot !== null;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Reconsiders blocked pairs after merges relocate the rules that blocked them.
|
|
230
|
+
* Each productive pass removes a rule, so this reaches a fixed point without
|
|
231
|
+
* rerunning parsing or unrelated optimizations.
|
|
232
|
+
*
|
|
233
|
+
* @param {Array} rules The sibling rules to merge.
|
|
234
|
+
* @return {Array} Rules with all currently safe same-selector merges.
|
|
235
|
+
*/
|
|
236
|
+
function mergeSelectorRules (rules) {
|
|
237
|
+
const mergeCaches = {
|
|
238
|
+
profileByRule: new Map(),
|
|
239
|
+
selectorKeyByRule: new Map()
|
|
240
|
+
};
|
|
241
|
+
let previousRules = rules;
|
|
242
|
+
let mergedRules = mergeSelectorRulePass(previousRules, mergeCaches);
|
|
243
|
+
while (mergedRules.length < previousRules.length) {
|
|
244
|
+
previousRules = mergedRules;
|
|
245
|
+
mergedRules = mergeSelectorRulePass(previousRules, mergeCaches);
|
|
246
|
+
}
|
|
247
|
+
return mergedRules;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export {
|
|
251
|
+
mergeSelectorRules
|
|
252
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Nests flat rules under a preceding rule whose selector their own selector starts with, so CSS nesting writes the shared part once.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Matches the part of a child selector that follows its parent when that part
|
|
7
|
+
* starts with an explicit combinator. Optional whitespace, then the combinator
|
|
8
|
+
* (`>`, `+`, or `~`) is captured, then optional whitespace, then the rest of
|
|
9
|
+
* the selector is captured.
|
|
10
|
+
*
|
|
11
|
+
* @type {RegExp}
|
|
12
|
+
*/
|
|
13
|
+
const LEADING_COMBINATOR_PATTERN = /^\s*([>+~])\s*(.+)$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Expresses the part of a child selector that follows its parent as the
|
|
17
|
+
* selector it would be once nested inside that parent.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} remainder The child selector with the parent prefix removed.
|
|
20
|
+
* @return {string|null} The nested selector, or null when the remainder cannot be nested.
|
|
21
|
+
*/
|
|
22
|
+
function nestSelectorRemainder (remainder) {
|
|
23
|
+
if (!remainder) {
|
|
24
|
+
return '&';
|
|
25
|
+
}
|
|
26
|
+
if (remainder.startsWith(':')) {
|
|
27
|
+
return '&' + remainder;
|
|
28
|
+
}
|
|
29
|
+
if (remainder.startsWith(' ')) {
|
|
30
|
+
return remainder.slice(1);
|
|
31
|
+
}
|
|
32
|
+
const combinatorMatch = remainder.match(LEADING_COMBINATOR_PATTERN);
|
|
33
|
+
if (combinatorMatch) {
|
|
34
|
+
return combinatorMatch[1] + combinatorMatch[2];
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds a reusable matcher that expresses child selectors as nested selectors
|
|
41
|
+
* relative to one parent. Most children tested against a parent do not start
|
|
42
|
+
* with it at all, so that cheap prefix check runs before anything else.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} parentSelector The parent selector string.
|
|
45
|
+
* @return {function(string): (string|null)} A matcher returning the nested selector, or null when the child cannot be nested.
|
|
46
|
+
*/
|
|
47
|
+
function createNestedSelectorMatcher (parentSelector) {
|
|
48
|
+
const parent = parentSelector.trim();
|
|
49
|
+
|
|
50
|
+
return (childSelector) => {
|
|
51
|
+
const child = childSelector.trim();
|
|
52
|
+
if (!child.startsWith(parent)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return nestSelectorRemainder(child.slice(parent.length));
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 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.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} parentSelector The parent selector string.
|
|
63
|
+
* @param {string} childSelector The child selector string to try nesting.
|
|
64
|
+
* @return {string|null} The nested selector using & syntax, or null if the child cannot be nested under the parent.
|
|
65
|
+
*/
|
|
66
|
+
function tryNestSelector (parentSelector, childSelector) {
|
|
67
|
+
return createNestedSelectorMatcher(parentSelector)(childSelector);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Determines whether a character is CSS whitespace.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} character A single character from a selector.
|
|
74
|
+
* @return {boolean} Whether the character is whitespace.
|
|
75
|
+
*/
|
|
76
|
+
function isSelectorWhitespace (character) {
|
|
77
|
+
// Match a single whitespace character
|
|
78
|
+
return (/\s/).test(character);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Collects every prefix of a child selector that could act as its nesting
|
|
83
|
+
* parent. A parent always ends immediately before a pseudo-class colon, a
|
|
84
|
+
* descendant whitespace run, or the whitespace run leading into a combinator,
|
|
85
|
+
* so only those few positions can start a nestable remainder. Enumerating them
|
|
86
|
+
* turns "which earlier rule can host this one" into a set of key lookups
|
|
87
|
+
* instead of a comparison against every preceding rule.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} childSelector The trimmed child selector.
|
|
90
|
+
* @return {Set} The candidate parent selector strings.
|
|
91
|
+
*/
|
|
92
|
+
function collectCandidateParentSelectors (childSelector) {
|
|
93
|
+
const candidates = new Set();
|
|
94
|
+
for (let index = 1; index < childSelector.length; index++) {
|
|
95
|
+
const character = childSelector[index];
|
|
96
|
+
if (character === ':' || isSelectorWhitespace(character)) {
|
|
97
|
+
// The second colon of a pseudo-element is part of its `::` introducer,
|
|
98
|
+
// not a boundary between a potential parent and nested child.
|
|
99
|
+
if (character === ':' && childSelector[index - 1] === ':') {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
candidates.add(childSelector.slice(0, index));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (character !== '>' && character !== '+' && character !== '~') {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// A combinator may be separated from its parent by whitespace, which
|
|
109
|
+
// belongs to neither side, so the parent ends where that run begins.
|
|
110
|
+
let boundary = index;
|
|
111
|
+
while (boundary > 0 && isSelectorWhitespace(childSelector[boundary - 1])) {
|
|
112
|
+
boundary--;
|
|
113
|
+
}
|
|
114
|
+
if (boundary > 0) {
|
|
115
|
+
candidates.add(childSelector.slice(0, boundary));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return candidates;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Finds the most recently emitted rule that can host a child selector as a
|
|
123
|
+
* nested rule, matching the behaviour of scanning backwards through the emitted
|
|
124
|
+
* rules but only visiting the handful of rules whose selector is a viable
|
|
125
|
+
* parent prefix.
|
|
126
|
+
*
|
|
127
|
+
* @param {Array} emittedRules The rules emitted so far.
|
|
128
|
+
* @param {Map} indexBySelector Map of each emitted single-selector rule's selector to its index.
|
|
129
|
+
* @param {string} childSelector The trimmed child selector to nest.
|
|
130
|
+
* @return {object|null} The `parentIndex` and `nestedSelector`, or null when nothing can host the child.
|
|
131
|
+
*/
|
|
132
|
+
function findNestingParent (emittedRules, indexBySelector, childSelector) {
|
|
133
|
+
const parentIndexes = [];
|
|
134
|
+
for (const candidateParent of collectCandidateParentSelectors(childSelector)) {
|
|
135
|
+
const parentIndex = indexBySelector.get(candidateParent);
|
|
136
|
+
if (parentIndex !== undefined) {
|
|
137
|
+
parentIndexes.push(parentIndex);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// The nearest preceding parent wins, exactly as a backwards scan would.
|
|
141
|
+
parentIndexes.sort((first, second) => {
|
|
142
|
+
return second - first;
|
|
143
|
+
});
|
|
144
|
+
for (const parentIndex of parentIndexes) {
|
|
145
|
+
const nestedSelector = tryNestSelector(emittedRules[parentIndex].selectors[0], childSelector);
|
|
146
|
+
if (nestedSelector !== null) {
|
|
147
|
+
return {
|
|
148
|
+
nestedSelector,
|
|
149
|
+
parentIndex
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 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.
|
|
158
|
+
*
|
|
159
|
+
* @param {Array} rules The flat AST rule nodes to nest.
|
|
160
|
+
* @return {Array} A new array of rules with applicable children nested under their parents.
|
|
161
|
+
*/
|
|
162
|
+
function nestFlatRules (rules) {
|
|
163
|
+
const result = [];
|
|
164
|
+
const indexBySelector = new Map();
|
|
165
|
+
for (const rule of rules) {
|
|
166
|
+
if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
|
|
167
|
+
result.push(rule);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const childSelector = rule.selectors[0].trim();
|
|
171
|
+
const nesting = findNestingParent(result, indexBySelector, childSelector);
|
|
172
|
+
if (nesting) {
|
|
173
|
+
const parentRule = result[nesting.parentIndex];
|
|
174
|
+
parentRule.declarations = parentRule.declarations || [];
|
|
175
|
+
parentRule.declarations.push({ ...rule, selectors: [nesting.nestedSelector] });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
indexBySelector.set(childSelector, result.length);
|
|
179
|
+
result.push(rule);
|
|
180
|
+
}
|
|
181
|
+
for (const rule of result) {
|
|
182
|
+
if (rule.type === 'rule' && rule.declarations) {
|
|
183
|
+
const innerRules = rule.declarations.filter((declaration) => {
|
|
184
|
+
return declaration.type === 'rule';
|
|
185
|
+
});
|
|
186
|
+
if (innerRules.length > 0) {
|
|
187
|
+
const nonRuleDeclarations = rule.declarations.filter((declaration) => {
|
|
188
|
+
return declaration.type !== 'rule';
|
|
189
|
+
});
|
|
190
|
+
rule.declarations = [...nonRuleDeclarations, ...nestFlatRules(innerRules)];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export {
|
|
198
|
+
collectCandidateParentSelectors,
|
|
199
|
+
createNestedSelectorMatcher,
|
|
200
|
+
isSelectorWhitespace,
|
|
201
|
+
nestFlatRules
|
|
202
|
+
};
|