@thejaredwilcurt/csslop 0.0.1
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 +213 -0
- package/index.js +5 -0
- package/package.json +48 -0
- package/src/context.js +17 -0
- package/src/declarations/config.js +56 -0
- package/src/declarations/process.js +662 -0
- package/src/index.js +90 -0
- package/src/position-try.js +199 -0
- package/src/preprocess.js +27 -0
- package/src/rules/normalize.js +108 -0
- package/src/rules/optimize.js +375 -0
- package/src/rules/stringify.js +556 -0
- package/src/utilities.js +37 -0
- package/src/value/colors.js +780 -0
- package/src/value/gradients.js +121 -0
- package/src/value/math.js +281 -0
- package/src/value/minify.js +716 -0
- package/src/value/named-colors.js +159 -0
- package/src/value/shared.js +146 -0
- package/src/value/transforms.js +222 -0
|
@@ -0,0 +1,375 @@
|
|
|
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 { escapeRegexString } from '../utilities.js';
|
|
6
|
+
|
|
7
|
+
import { normalizeMedia } from './normalize.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Expands rules that contain only nested sub-rules into flat rules with combined selectors, enabling further merging when the combined selectors already exist elsewhere.
|
|
11
|
+
*
|
|
12
|
+
* @param {Array} rules The AST rule nodes to process.
|
|
13
|
+
* @return {Array} A new array of rules with pure-nested rules expanded.
|
|
14
|
+
*/
|
|
15
|
+
function expandPureNestedRules (rules) {
|
|
16
|
+
const flatSelectors = new Set();
|
|
17
|
+
for (const rule of rules) {
|
|
18
|
+
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const nonWhitespace = (rule.declarations || []).filter((declaration) => {
|
|
22
|
+
return declaration.type !== 'whitespace';
|
|
23
|
+
});
|
|
24
|
+
const hasNonRuleDeclarations = nonWhitespace.some((declaration) => {
|
|
25
|
+
return declaration.type !== 'rule';
|
|
26
|
+
});
|
|
27
|
+
if (hasNonRuleDeclarations) {
|
|
28
|
+
rule.selectors.forEach((selector) => {
|
|
29
|
+
// Normalize selector whitespace to single space for deduplication
|
|
30
|
+
flatSelectors.add(selector.trim().replace(/\s+/g, ' '));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = [];
|
|
36
|
+
for (const rule of rules) {
|
|
37
|
+
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
38
|
+
result.push(rule);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const nonWhitespace = (rule.declarations || []).filter((declaration) => {
|
|
42
|
+
return declaration.type !== 'whitespace';
|
|
43
|
+
});
|
|
44
|
+
const isPureNested = nonWhitespace.length > 0 && nonWhitespace.every((declaration) => {
|
|
45
|
+
return declaration.type === 'rule';
|
|
46
|
+
});
|
|
47
|
+
if (!isPureNested) {
|
|
48
|
+
result.push(rule);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let anyMatch = false;
|
|
53
|
+
let expandedRules = [];
|
|
54
|
+
let canExpand = true;
|
|
55
|
+
|
|
56
|
+
for (const nestedRule of nonWhitespace) {
|
|
57
|
+
if (!nestedRule.selectors?.length) {
|
|
58
|
+
canExpand = false;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
const combinedSelectors = [];
|
|
62
|
+
for (const parentSelector of rule.selectors) {
|
|
63
|
+
for (const childSelector of nestedRule.selectors) {
|
|
64
|
+
const trimmedChild = childSelector.trim();
|
|
65
|
+
let combinedSelector;
|
|
66
|
+
if (trimmedChild.startsWith('&')) {
|
|
67
|
+
combinedSelector = trimmedChild.replace(/^&/, parentSelector.trim());
|
|
68
|
+
} else {
|
|
69
|
+
combinedSelector = parentSelector.trim() + ' ' + trimmedChild;
|
|
70
|
+
}
|
|
71
|
+
combinedSelectors.push(combinedSelector);
|
|
72
|
+
if (flatSelectors.has(combinedSelector)) {
|
|
73
|
+
anyMatch = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
expandedRules.push({ ...nestedRule, selectors: combinedSelectors });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const allChildSelectorStrings = expandedRules.map((expandedRule) => {
|
|
81
|
+
return expandedRule.selectors.join(',');
|
|
82
|
+
});
|
|
83
|
+
const allSame = (
|
|
84
|
+
allChildSelectorStrings.length > 0 &&
|
|
85
|
+
allChildSelectorStrings.every((selectorString) => {
|
|
86
|
+
return selectorString === allChildSelectorStrings[0];
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
if (canExpand && (allSame || anyMatch)) {
|
|
90
|
+
for (const expandedRule of expandedRules) {
|
|
91
|
+
result.push(expandedRule);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
result.push(rule);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 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.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} parentSel The parent selector string.
|
|
104
|
+
* @param {string} childSel The child selector string to try nesting.
|
|
105
|
+
* @return {string|null} The nested selector using & syntax, or null if the child cannot be nested under the parent.
|
|
106
|
+
*/
|
|
107
|
+
function tryNestSelector (parentSel, childSel) {
|
|
108
|
+
const parent = parentSel.trim();
|
|
109
|
+
const child = childSel.trim();
|
|
110
|
+
if (child.startsWith(parent + ':') || child.startsWith(parent + '::')) {
|
|
111
|
+
return '&' + child.slice(parent.length);
|
|
112
|
+
}
|
|
113
|
+
if (child.startsWith(parent + ' ')) {
|
|
114
|
+
return child.slice(parent.length + 1);
|
|
115
|
+
}
|
|
116
|
+
// Match child selector that starts with the parent followed by a combinator (>, +, ~)
|
|
117
|
+
const combinatorMatch = child.match(
|
|
118
|
+
new RegExp('^' + escapeRegexString(parent) + '\\s*([>+~])\\s*(.+)$')
|
|
119
|
+
);
|
|
120
|
+
if (combinatorMatch) {
|
|
121
|
+
return combinatorMatch[1] + combinatorMatch[2];
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 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.
|
|
128
|
+
*
|
|
129
|
+
* @param {Array} rules The flat AST rule nodes to nest.
|
|
130
|
+
* @return {Array} A new array of rules with applicable children nested under their parents.
|
|
131
|
+
*/
|
|
132
|
+
function nestFlatRules (rules) {
|
|
133
|
+
const result = [];
|
|
134
|
+
for (const rule of rules) {
|
|
135
|
+
if (rule.type !== 'rule' || rule.selectors?.length !== 1) {
|
|
136
|
+
result.push(rule);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const childSelector = rule.selectors[0].trim();
|
|
140
|
+
let wasNested = false;
|
|
141
|
+
for (let j = result.length - 1; j >= 0; j--) {
|
|
142
|
+
const parentRule = result[j];
|
|
143
|
+
if (parentRule.type !== 'rule' || parentRule.selectors?.length !== 1) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const nestedSelector = tryNestSelector(parentRule.selectors[0], childSelector);
|
|
147
|
+
if (nestedSelector !== null) {
|
|
148
|
+
parentRule.declarations = parentRule.declarations || [];
|
|
149
|
+
parentRule.declarations.push({ ...rule, selectors: [nestedSelector] });
|
|
150
|
+
wasNested = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!wasNested) {
|
|
155
|
+
result.push(rule);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const rule of result) {
|
|
159
|
+
if (rule.type === 'rule' && rule.declarations) {
|
|
160
|
+
const innerRules = rule.declarations.filter((declaration) => {
|
|
161
|
+
return declaration.type === 'rule';
|
|
162
|
+
});
|
|
163
|
+
if (innerRules.length > 0) {
|
|
164
|
+
const nonRuleDeclarations = rule.declarations.filter((declaration) => {
|
|
165
|
+
return declaration.type !== 'rule';
|
|
166
|
+
});
|
|
167
|
+
rule.declarations = [...nonRuleDeclarations, ...nestFlatRules(innerRules)];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Merges adjacent `@media` rules that share an identical normalized query string and deduplicates their child selector rules.
|
|
176
|
+
*
|
|
177
|
+
* @param {Array} rules The AST rule nodes to process.
|
|
178
|
+
* @param {function(Array): Array} mergeSelectorRules Callback to merge selector rules within each media block.
|
|
179
|
+
* @return {Array} A new array of rules with consecutive identical `@media` blocks combined.
|
|
180
|
+
*/
|
|
181
|
+
function mergeMediaRules (rules, mergeSelectorRules) {
|
|
182
|
+
const mediaMap = new Map();
|
|
183
|
+
const result = [];
|
|
184
|
+
for (const rule of rules) {
|
|
185
|
+
if (rule.type === 'media') {
|
|
186
|
+
const normalizedQuery = normalizeMedia(rule.media);
|
|
187
|
+
if (mediaMap.has(normalizedQuery)) {
|
|
188
|
+
mediaMap.get(normalizedQuery).rules.push(...(rule.rules || []));
|
|
189
|
+
} else {
|
|
190
|
+
mediaMap.set(normalizedQuery, rule);
|
|
191
|
+
result.push(rule);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
if (rule.type !== 'whitespace') {
|
|
195
|
+
mediaMap.clear();
|
|
196
|
+
}
|
|
197
|
+
result.push(rule);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const rule of result) {
|
|
201
|
+
if (rule.type === 'media' && rule.rules && rule.rules.length) {
|
|
202
|
+
rule.rules = mergeSelectorRules(rule.rules);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Removes duplicate `@keyframes` definitions, keeping only the last occurrence of each named animation.
|
|
210
|
+
*
|
|
211
|
+
* @param {Array} rules The AST rule nodes to deduplicate.
|
|
212
|
+
* @return {Array} A new array of rules with earlier duplicate `@keyframes` removed.
|
|
213
|
+
*/
|
|
214
|
+
function deduplicateKeyframes (rules) {
|
|
215
|
+
const lastIndexByName = new Map();
|
|
216
|
+
for (let i = 0; i < rules.length; i++) {
|
|
217
|
+
if (rules[i].type === 'keyframes' && rules[i].name) {
|
|
218
|
+
lastIndexByName.set(rules[i].name, i);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return rules.filter((rule, index) => {
|
|
222
|
+
if (rule.type === 'keyframes' && rule.name) {
|
|
223
|
+
return lastIndexByName.get(rule.name) === index;
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Merges consecutive rules whose declarations are a subset of the following rule, combining their selectors and splitting out any extra declarations.
|
|
231
|
+
*
|
|
232
|
+
* @param {Array} rules The AST rule nodes to merge.
|
|
233
|
+
* @return {Array} A new array of rules with declaration-compatible consecutive rules combined.
|
|
234
|
+
*/
|
|
235
|
+
function mergeByDeclarations (rules) {
|
|
236
|
+
const result = [];
|
|
237
|
+
for (const rule of rules) {
|
|
238
|
+
if (rule.type !== 'rule' || !rule.selectors?.length) {
|
|
239
|
+
result.push(rule);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const previousRule = result[result.length - 1];
|
|
243
|
+
if (previousRule && previousRule.type === 'rule' && previousRule.selectors?.length) {
|
|
244
|
+
const previousDeclarations = (previousRule.declarations || []).filter((declaration) => {
|
|
245
|
+
return declaration.type !== 'whitespace' && declaration.property;
|
|
246
|
+
});
|
|
247
|
+
const currentDeclarations = (rule.declarations || []).filter((declaration) => {
|
|
248
|
+
return declaration.type !== 'whitespace' && declaration.property;
|
|
249
|
+
});
|
|
250
|
+
if (previousDeclarations.length > 0 && currentDeclarations.length > 0) {
|
|
251
|
+
const currentDeclarationMap = new Map(
|
|
252
|
+
currentDeclarations.map((declaration) => {
|
|
253
|
+
return [declaration.property, (declaration.value || '').trim()];
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
const previousIsSubset = previousDeclarations.every((declaration) => {
|
|
257
|
+
return currentDeclarationMap.get(declaration.property) === (declaration.value || '').trim();
|
|
258
|
+
});
|
|
259
|
+
const currentHasAllProperty = currentDeclarations.some((declaration) => {
|
|
260
|
+
return declaration.property === 'all';
|
|
261
|
+
});
|
|
262
|
+
if (previousIsSubset && !currentHasAllProperty) {
|
|
263
|
+
const commonProperties = new Set(
|
|
264
|
+
previousDeclarations.map((declaration) => {
|
|
265
|
+
return declaration.property;
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
const currentOnlyDeclarations = currentDeclarations.filter((declaration) => {
|
|
269
|
+
return !commonProperties.has(declaration.property);
|
|
270
|
+
});
|
|
271
|
+
result.pop();
|
|
272
|
+
result.push({ ...previousRule, selectors: [...previousRule.selectors, ...rule.selectors] });
|
|
273
|
+
if (currentOnlyDeclarations.length > 0) {
|
|
274
|
+
result.push({ ...rule, declarations: currentOnlyDeclarations });
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
result.push(rule);
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Merges rules with identical normalized selectors by combining their declarations. Non-rule entries (like `@media`) break the merge window.
|
|
287
|
+
*
|
|
288
|
+
* @param {Array} rules The AST rule nodes to merge.
|
|
289
|
+
* @return {Array} A new array of rules with same-selector rules combined.
|
|
290
|
+
*/
|
|
291
|
+
function mergeSelectorRules (rules) {
|
|
292
|
+
let result = [];
|
|
293
|
+
let selectorMap = new Map();
|
|
294
|
+
for (const rule of rules) {
|
|
295
|
+
if (rule.type === 'rule') {
|
|
296
|
+
const selectorKey = rule.selectors ?
|
|
297
|
+
rule.selectors.map((selector) => {
|
|
298
|
+
// Normalize selector whitespace for consistent comparison
|
|
299
|
+
return selector.trim().replace(/\s+/g, ' ');
|
|
300
|
+
}).sort().join(',') :
|
|
301
|
+
'';
|
|
302
|
+
if (selectorKey && selectorMap.has(selectorKey)) {
|
|
303
|
+
const existingRule = selectorMap.get(selectorKey);
|
|
304
|
+
existingRule.declarations.push(...(rule.declarations || []));
|
|
305
|
+
result = result.filter((candidate) => {
|
|
306
|
+
return candidate !== existingRule;
|
|
307
|
+
});
|
|
308
|
+
result.push(existingRule);
|
|
309
|
+
} else {
|
|
310
|
+
selectorMap.set(selectorKey, rule);
|
|
311
|
+
result.push(rule);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
if (rule.type === 'whitespace') {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
result.push(rule);
|
|
318
|
+
selectorMap.clear();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Merges `@layer` blocks with the same name by combining their child rules, deduplicates layer statements, and merges selector rules within each block.
|
|
326
|
+
*
|
|
327
|
+
* @param {Array} rules The AST rule nodes to process.
|
|
328
|
+
* @param {function(Array): Array} mergeSelectorRules Callback to merge selector rules within each layer block.
|
|
329
|
+
* @return {Array} A new array of rules with same-name `@layer` blocks combined.
|
|
330
|
+
*/
|
|
331
|
+
function mergeLayerRules (rules, mergeSelectorRules) {
|
|
332
|
+
const layerBlockMap = new Map();
|
|
333
|
+
const layerStatementSeen = new Set();
|
|
334
|
+
const result = [];
|
|
335
|
+
for (const rule of rules) {
|
|
336
|
+
if (rule.type === 'layer') {
|
|
337
|
+
const layerName = rule.layer || '';
|
|
338
|
+
if (rule.rules && rule.rules.length > 0) {
|
|
339
|
+
if (layerName && layerBlockMap.has(layerName)) {
|
|
340
|
+
layerBlockMap.get(layerName).rules.push(...rule.rules);
|
|
341
|
+
} else {
|
|
342
|
+
if (layerName) {
|
|
343
|
+
layerBlockMap.set(layerName, rule);
|
|
344
|
+
}
|
|
345
|
+
result.push(rule);
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
if (!layerName || !layerStatementSeen.has(layerName)) {
|
|
349
|
+
if (layerName) {
|
|
350
|
+
layerStatementSeen.add(layerName);
|
|
351
|
+
}
|
|
352
|
+
result.push(rule);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
result.push(rule);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const rule of result) {
|
|
360
|
+
if (rule.type === 'layer' && rule.rules && rule.rules.length) {
|
|
361
|
+
rule.rules = mergeSelectorRules(rule.rules);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export {
|
|
368
|
+
deduplicateKeyframes,
|
|
369
|
+
expandPureNestedRules,
|
|
370
|
+
mergeByDeclarations,
|
|
371
|
+
mergeLayerRules,
|
|
372
|
+
mergeMediaRules,
|
|
373
|
+
mergeSelectorRules,
|
|
374
|
+
nestFlatRules
|
|
375
|
+
};
|