@thejaredwilcurt/csslop 0.0.16 → 0.0.18
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 +22 -3
- package/package.json +8 -7
- package/src/index.js +3 -1
- package/src/rules/custom-properties.js +89 -0
- package/src/rules/optimize.js +294 -1
- package/src/rules/selectors.js +304 -0
- package/src/rules/stringify.js +61 -337
- package/src/value/color-mix.js +500 -0
- package/src/value/colors.js +54 -483
- package/src/value/light-dark.js +188 -0
- package/src/value/minify.js +85 -167
- package/src/value/relative-colors.js +336 -0
- package/src/value/syntax.js +56 -0
package/README.md
CHANGED
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
* Use the tests from the open source, 3rd-party, CSS minification auditing library [css-minify-tests](https://github.com/keithamus/css-minify-tests).
|
|
16
16
|
* Have AI completely generate the library logic to pass all tests.
|
|
17
|
-
* Have me (an experienced library author) validate the outcomes and [make upstream corrections](https://github.com/keithamus/css-minify-tests/issues?q=author%3ATheJaredWilcurt) to the tests library.
|
|
17
|
+
* Have me (an experienced library author) validate the outcomes and [make upstream corrections and improvements](https://github.com/keithamus/css-minify-tests/issues?q=author%3ATheJaredWilcurt) to the tests library.
|
|
18
18
|
|
|
19
19
|
**The Results:**
|
|
20
20
|
|
|
21
21
|
* All tests pass.
|
|
22
22
|
* Several new tests were created upstream, and some existing tests were improved/fixed upstream (all manually by me, no AI used).
|
|
23
|
-
* The `src` folder is 100% vibe coded, and despite passing all tests, is almost certainly not worth using. Though if you do, and you find issues, you can
|
|
23
|
+
* The `src` folder is 100% vibe coded, and despite passing all tests, is almost certainly not worth using. Though if you do, and you find issues, you can isolate the problem and [compare against the correct output of real minifiers](https://TheJaredWilcurt.com/playground), then use that to create a [new test upstream](https://github.com/keithamus/css-minify-tests/issues) for the AI's to be forced to pass.
|
|
24
24
|
* No AI generated code exists outside of the `src` folder, this README, for example, is 100% human crafted.
|
|
25
25
|
|
|
26
26
|
**AI's used:**
|
|
@@ -278,7 +278,7 @@ Two different cases:
|
|
|
278
278
|
1. `git add -A && git commit -m "Updated tests"`
|
|
279
279
|
1. Then run `npm t` to see if any tests fail
|
|
280
280
|
1. If they fail, give an AI this prompt:
|
|
281
|
-
* **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Run `npm run lint` and
|
|
281
|
+
* **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Run `npm run lint` and correct any linter warnings/errors that occur in the `/src` folder. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)! When all done, run the `beep` command to alert me you finished.
|
|
282
282
|
1. Verify only code in the `src` folder was modified
|
|
283
283
|
1. Verify `npm t` passes with a 100% score
|
|
284
284
|
1. Run `npm run lint`, if anything fails, have the AI fix it.
|
|
@@ -289,3 +289,22 @@ Two different cases:
|
|
|
289
289
|
1. Do a new release on GitHub
|
|
290
290
|
1. `git checkout main && git pull origin main && git pull`
|
|
291
291
|
1. `npm run publish`
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
## Code Organization prompt:
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
1. Check the files in the `/src` folder. If any of them are over 1000 lines long, look across the file for commonalities that could be grouped and moved to a separate file and/or folder. When creating new files, if a new organizational approach is used, re-evaluate how other files are organized and reorganize them, or their contents, into new files to match the new organizational structure, as needed.
|
|
298
|
+
1. After organizing check that no tests are failing with `npm t`. If any are, fix them by modifying files in `src`.
|
|
299
|
+
1. During any of your edits:
|
|
300
|
+
* Do not use naive solutions, hacks, or hard coded values.
|
|
301
|
+
* Make sure implementations not only makes the test pass, but would also pass similar tests based on the description of the test and its intent.
|
|
302
|
+
* Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB.
|
|
303
|
+
* Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc).
|
|
304
|
+
* Group related logic into well named functions.
|
|
305
|
+
* Ensure arrow functions always take up at least 3 lines, with explicit returns when needed.
|
|
306
|
+
* Always comment regex if used.
|
|
307
|
+
1. After all other code edits, run `npm run lint` and ensure the linter passes.
|
|
308
|
+
1. DO NOT, under any circumstance, run `npm run real` (it will kill actual humans)!
|
|
309
|
+
1. When all done, run the `beep` command to alert me you finished.
|
|
310
|
+
```
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@thejaredwilcurt/csslop",
|
|
3
3
|
"main": "index.js",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.18",
|
|
6
6
|
"description": "Experimental CSS minification",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"prestart": "node ./scripts/prestart.js",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"prebuild": "node ./scripts/prebuild.js",
|
|
12
12
|
"build": "vite build",
|
|
13
13
|
"postbuild": "node ./scripts/postbuild.js",
|
|
14
|
-
"copy": "node ./scripts/
|
|
14
|
+
"copy": "node ./scripts/copyTestsFromNodeModules.js",
|
|
15
|
+
"copy-repo": "node ./scripts/copyTestsFromRepo.js",
|
|
15
16
|
"test": "node ./tests/index.test.js",
|
|
16
17
|
"real": "node ./tests/realworld.test.js",
|
|
17
18
|
"lint": "eslint *.js scripts src tests --fix",
|
|
@@ -20,25 +21,25 @@
|
|
|
20
21
|
"publisho": "npm publish --access=public"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
|
-
"@csstools/css-calc": "^3.
|
|
24
|
+
"@csstools/css-calc": "^3.3.0",
|
|
24
25
|
"@node-projects/css-parser": "^5.2.0"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"@codemirror/autocomplete": "^6.20.3",
|
|
28
29
|
"@codemirror/lang-css": "^6.3.1",
|
|
29
|
-
"@codemirror/view": "^6.43.
|
|
30
|
+
"@codemirror/view": "^6.43.7",
|
|
30
31
|
"@eslint/js": "^10.0.1",
|
|
31
32
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
32
33
|
"codemirror": "^6.0.2",
|
|
33
34
|
"css-minify-tests": "github:keithamus/css-minify-tests",
|
|
34
|
-
"eslint": "^10.
|
|
35
|
+
"eslint": "^10.8.0",
|
|
35
36
|
"eslint-config-tjw-base": "^5.0.0",
|
|
36
37
|
"eslint-config-tjw-import-x": "^1.0.1",
|
|
37
38
|
"eslint-config-tjw-jsdoc": "^2.0.1",
|
|
38
39
|
"eslint-plugin-import-x": "^4.17.0",
|
|
39
|
-
"eslint-plugin-jsdoc": "^63.
|
|
40
|
+
"eslint-plugin-jsdoc": "^63.3.2",
|
|
40
41
|
"fflate": "^0.8.3",
|
|
41
|
-
"globals": "^17.
|
|
42
|
+
"globals": "^17.8.0",
|
|
42
43
|
"pretty-ms": "^9.3.0",
|
|
43
44
|
"real-world-css-libraries": "^1.0.5",
|
|
44
45
|
"vite": "^8.1.2"
|
package/src/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
expandPureNestedRules,
|
|
27
27
|
factorCommonParents,
|
|
28
28
|
mergeByDeclarations,
|
|
29
|
+
mergeIdenticalNestedRules,
|
|
29
30
|
mergeLayerRules,
|
|
30
31
|
mergeMediaRules,
|
|
31
32
|
mergeSelectorRules,
|
|
@@ -248,7 +249,8 @@ export const minifyCSS = function (input) {
|
|
|
248
249
|
const nestedRules = nestFlatRules(declarationMergedRules);
|
|
249
250
|
const nonEmptyRules = removeEmptyRules(nestedRules);
|
|
250
251
|
const factoredRules = factorCommonParents(nonEmptyRules);
|
|
251
|
-
const
|
|
252
|
+
const nestedFinalRules = nestFlatRules(factoredRules);
|
|
253
|
+
const finalRules = mergeIdenticalNestedRules(nestedFinalRules);
|
|
252
254
|
|
|
253
255
|
for (const rule of finalRules) {
|
|
254
256
|
output.push(stringifyRule(rule, context));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Custom property value whitespace and comment processing for CSS minification.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Removes spaces after commas only inside parenthesized groups (function
|
|
7
|
+
* calls like `var()`, `calc()`), leaving top-level comma spacing intact.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} value The whitespace-collapsed custom property value.
|
|
10
|
+
* @return {string} The value with post-comma spaces removed inside function calls only.
|
|
11
|
+
*/
|
|
12
|
+
function removeSpacesAfterCommasInsideFunctions (value) {
|
|
13
|
+
let result = '';
|
|
14
|
+
let parenthesisDepth = 0;
|
|
15
|
+
for (let index = 0; index < value.length; index++) {
|
|
16
|
+
const character = value[index];
|
|
17
|
+
if (character === '(') {
|
|
18
|
+
parenthesisDepth++;
|
|
19
|
+
}
|
|
20
|
+
if (character === ')') {
|
|
21
|
+
parenthesisDepth--;
|
|
22
|
+
}
|
|
23
|
+
if (character === ',' && parenthesisDepth > 0) {
|
|
24
|
+
result += ',';
|
|
25
|
+
// Skip whitespace after the comma inside function calls
|
|
26
|
+
while (index + 1 < value.length && value[index + 1] === ' ') {
|
|
27
|
+
index++;
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
result += character;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strips leading zeros from decimal numbers in a custom property value
|
|
38
|
+
* (e.g. `0.5` becomes `.5`, `-0.02em` becomes `-.02em`).
|
|
39
|
+
*
|
|
40
|
+
* @param {string} value The custom property value string.
|
|
41
|
+
* @return {string} The value with leading zeros removed from decimals.
|
|
42
|
+
*/
|
|
43
|
+
function stripLeadingZerosFromDecimals (value) {
|
|
44
|
+
// Match a boundary (start, whitespace, comma, open-paren), optional sign, then leading zeros before a decimal
|
|
45
|
+
return value.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Processes CSS comments within a custom property value. If the value
|
|
50
|
+
* consists entirely of a comment, the comment is removed (producing an
|
|
51
|
+
* empty value). If comments appear between other tokens, their content
|
|
52
|
+
* is stripped but empty comment delimiters are kept as zero-width
|
|
53
|
+
* token separators to preserve the token sequence.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} value The raw custom property value string.
|
|
56
|
+
* @return {string} The value with comments processed.
|
|
57
|
+
*/
|
|
58
|
+
function processCustomPropertyComments (value) {
|
|
59
|
+
// Match values that are entirely a comment (with optional surrounding whitespace)
|
|
60
|
+
const commentOnlyPattern = /^\s*\/\*.*?\*\/\s*$/s;
|
|
61
|
+
if (commentOnlyPattern.test(value)) {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
// Strip comment content but keep empty markers as token separators
|
|
65
|
+
return value.replace(/\/\*.*?\*\//g, '/**/');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Collapses whitespace in a custom property value while preserving
|
|
70
|
+
* token boundaries. Each whitespace sequence is reduced to a single
|
|
71
|
+
* space, spaces after commas inside function calls are removed, and
|
|
72
|
+
* leading zeros on decimal numbers are stripped.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} value The raw custom property value string.
|
|
75
|
+
* @return {string} The minified custom property value.
|
|
76
|
+
*/
|
|
77
|
+
function collapseCustomPropertyWhitespace (value) {
|
|
78
|
+
// Collapse all whitespace sequences (newlines, tabs, multiple spaces) to a single space
|
|
79
|
+
let collapsed = value.replace(/\s+/g, ' ');
|
|
80
|
+
// Remove spaces after commas only inside function calls (e.g. var(--bar, 1.5) → var(--bar,1.5))
|
|
81
|
+
collapsed = removeSpacesAfterCommasInsideFunctions(collapsed);
|
|
82
|
+
// Strip leading zeros from decimals (e.g. 0.5 → .5, -0.02em → -.02em)
|
|
83
|
+
collapsed = stripLeadingZerosFromDecimals(collapsed);
|
|
84
|
+
return collapsed;
|
|
85
|
+
}
|
|
86
|
+
export {
|
|
87
|
+
collapseCustomPropertyWhitespace,
|
|
88
|
+
processCustomPropertyComments
|
|
89
|
+
};
|
package/src/rules/optimize.js
CHANGED
|
@@ -44,7 +44,10 @@ function expandPureNestedRules (rules) {
|
|
|
44
44
|
const isPureNested = nonWhitespace.length > 0 && nonWhitespace.every((declaration) => {
|
|
45
45
|
return declaration.type === 'rule';
|
|
46
46
|
});
|
|
47
|
-
|
|
47
|
+
// A comma-separated parent selector list behaves like :is() for specificity,
|
|
48
|
+
// so expanding it into descendant combinations (e.g. #a,.b → #a .c,.b .c)
|
|
49
|
+
// would change specificity. Only single-selector parents can expand safely.
|
|
50
|
+
if (!isPureNested || rule.selectors.length !== 1) {
|
|
48
51
|
result.push(rule);
|
|
49
52
|
continue;
|
|
50
53
|
}
|
|
@@ -443,6 +446,295 @@ function mergeByDeclarations (rules) {
|
|
|
443
446
|
return result;
|
|
444
447
|
}
|
|
445
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Splits a selector list on top-level commas, respecting parentheses and
|
|
451
|
+
* brackets so commas inside `:is(...)` or `[attr="a,b"]` are not split.
|
|
452
|
+
*
|
|
453
|
+
* @param {string} selectorList The selector list string.
|
|
454
|
+
* @return {Array} The individual selector strings.
|
|
455
|
+
*/
|
|
456
|
+
function splitSelectorListTopLevel (selectorList) {
|
|
457
|
+
const selectors = [];
|
|
458
|
+
let current = '';
|
|
459
|
+
let depth = 0;
|
|
460
|
+
for (const character of selectorList) {
|
|
461
|
+
if (character === '(' || character === '[') {
|
|
462
|
+
depth++;
|
|
463
|
+
} else if (character === ')' || character === ']') {
|
|
464
|
+
depth--;
|
|
465
|
+
}
|
|
466
|
+
if (character === ',' && depth === 0) {
|
|
467
|
+
selectors.push(current.trim());
|
|
468
|
+
current = '';
|
|
469
|
+
} else {
|
|
470
|
+
current += character;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
selectors.push(current.trim());
|
|
474
|
+
return selectors;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Advances past a CSS identifier (name) starting at the given index.
|
|
479
|
+
*
|
|
480
|
+
* @param {string} text The selector text.
|
|
481
|
+
* @param {number} start The index to start scanning from.
|
|
482
|
+
* @return {number} The index just after the identifier.
|
|
483
|
+
*/
|
|
484
|
+
function skipSelectorName (text, start) {
|
|
485
|
+
let index = start;
|
|
486
|
+
// Advance over identifier characters (letters, digits, hyphen, underscore)
|
|
487
|
+
while (index < text.length && (/[a-zA-Z0-9_-]/).test(text[index])) {
|
|
488
|
+
index++;
|
|
489
|
+
}
|
|
490
|
+
return index;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Finds the index of the closing parenthesis matching the one at openIndex.
|
|
495
|
+
*
|
|
496
|
+
* @param {string} text The text to scan.
|
|
497
|
+
* @param {number} openIndex Index of the opening parenthesis.
|
|
498
|
+
* @return {number} Index of the matching close parenthesis, or text length.
|
|
499
|
+
*/
|
|
500
|
+
function findMatchingParenthesisIndex (text, openIndex) {
|
|
501
|
+
let depth = 0;
|
|
502
|
+
for (let index = openIndex; index < text.length; index++) {
|
|
503
|
+
if (text[index] === '(') {
|
|
504
|
+
depth++;
|
|
505
|
+
} else if (text[index] === ')') {
|
|
506
|
+
depth--;
|
|
507
|
+
if (depth === 0) {
|
|
508
|
+
return index;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return text.length;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Compares two specificity tuples [ids, classes, types] lexicographically.
|
|
517
|
+
*
|
|
518
|
+
* @param {Array} first The first specificity tuple.
|
|
519
|
+
* @param {Array} second The second specificity tuple.
|
|
520
|
+
* @return {number} Negative, zero, or positive per standard comparison.
|
|
521
|
+
*/
|
|
522
|
+
function compareSpecificity (first, second) {
|
|
523
|
+
for (let index = 0; index < 3; index++) {
|
|
524
|
+
if (first[index] !== second[index]) {
|
|
525
|
+
return first[index] - second[index];
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Computes the CSS specificity of a single complex selector as an
|
|
533
|
+
* [ids, classes, types] tuple. The nesting selector `&` is ignored because its
|
|
534
|
+
* contribution comes from the shared parent when comparing sibling selectors.
|
|
535
|
+
* Functional pseudo-classes `:is()`, `:not()`, `:has()`, and `:matches()` add
|
|
536
|
+
* the maximum specificity of their arguments; `:where()` adds nothing.
|
|
537
|
+
*
|
|
538
|
+
* @param {string} selector A single complex selector string.
|
|
539
|
+
* @return {Array} The [ids, classes, types] specificity tuple.
|
|
540
|
+
*/
|
|
541
|
+
function computeSpecificity (selector) {
|
|
542
|
+
const specificity = [0, 0, 0];
|
|
543
|
+
const text = selector.trim();
|
|
544
|
+
let index = 0;
|
|
545
|
+
while (index < text.length) {
|
|
546
|
+
const character = text[index];
|
|
547
|
+
if (character === '#') {
|
|
548
|
+
specificity[0]++;
|
|
549
|
+
index = skipSelectorName(text, index + 1);
|
|
550
|
+
} else if (character === '.') {
|
|
551
|
+
specificity[1]++;
|
|
552
|
+
index = skipSelectorName(text, index + 1);
|
|
553
|
+
} else if (character === '[') {
|
|
554
|
+
specificity[1]++;
|
|
555
|
+
// Advance to just past the matching closing bracket
|
|
556
|
+
let bracketDepth = 0;
|
|
557
|
+
while (index < text.length) {
|
|
558
|
+
if (text[index] === '[') {
|
|
559
|
+
bracketDepth++;
|
|
560
|
+
} else if (text[index] === ']') {
|
|
561
|
+
bracketDepth--;
|
|
562
|
+
if (bracketDepth === 0) {
|
|
563
|
+
index++;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
index++;
|
|
568
|
+
}
|
|
569
|
+
} else if (character === ':') {
|
|
570
|
+
if (text[index + 1] === ':') {
|
|
571
|
+
specificity[2]++;
|
|
572
|
+
index = skipSelectorName(text, index + 2);
|
|
573
|
+
} else {
|
|
574
|
+
const nameStart = index + 1;
|
|
575
|
+
const nameEnd = skipSelectorName(text, nameStart);
|
|
576
|
+
const name = text.slice(nameStart, nameEnd).toLowerCase();
|
|
577
|
+
if (text[nameEnd] === '(') {
|
|
578
|
+
const closeIndex = findMatchingParenthesisIndex(text, nameEnd);
|
|
579
|
+
const inner = text.slice(nameEnd + 1, closeIndex);
|
|
580
|
+
if (name === 'where') {
|
|
581
|
+
// :where() contributes zero specificity
|
|
582
|
+
} else if (name === 'is' || name === 'not' || name === 'has' || name === 'matches') {
|
|
583
|
+
const innerMax = maxSelectorSpecificity(inner);
|
|
584
|
+
specificity[0] += innerMax[0];
|
|
585
|
+
specificity[1] += innerMax[1];
|
|
586
|
+
specificity[2] += innerMax[2];
|
|
587
|
+
} else {
|
|
588
|
+
specificity[1]++;
|
|
589
|
+
}
|
|
590
|
+
index = closeIndex + 1;
|
|
591
|
+
} else {
|
|
592
|
+
// Legacy single-colon pseudo-elements count as pseudo-elements
|
|
593
|
+
if (name === 'before' || name === 'after' || name === 'first-line' || name === 'first-letter') {
|
|
594
|
+
specificity[2]++;
|
|
595
|
+
} else {
|
|
596
|
+
specificity[1]++;
|
|
597
|
+
}
|
|
598
|
+
index = nameEnd;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} else if (character === '*' || character === '&') {
|
|
602
|
+
// Universal selector and nesting selector add no counted specificity here
|
|
603
|
+
index++;
|
|
604
|
+
} else if ((/[a-zA-Z]/).test(character)) {
|
|
605
|
+
specificity[2]++;
|
|
606
|
+
index = skipSelectorName(text, index);
|
|
607
|
+
} else {
|
|
608
|
+
// Combinators and whitespace do not affect specificity
|
|
609
|
+
index++;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return specificity;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Returns the maximum specificity tuple across a comma-separated selector list,
|
|
617
|
+
* matching how a comma-separated group behaves as `:is()`.
|
|
618
|
+
*
|
|
619
|
+
* @param {string} selectorList The selector list string.
|
|
620
|
+
* @return {Array} The maximum [ids, classes, types] tuple.
|
|
621
|
+
*/
|
|
622
|
+
function maxSelectorSpecificity (selectorList) {
|
|
623
|
+
let maximum = [0, 0, 0];
|
|
624
|
+
for (const selector of splitSelectorListTopLevel(selectorList)) {
|
|
625
|
+
const specificity = computeSpecificity(selector);
|
|
626
|
+
if (compareSpecificity(specificity, maximum) > 0) {
|
|
627
|
+
maximum = specificity;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return maximum;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Builds a normalized signature of a rule's declaration body, or null when the
|
|
635
|
+
* rule contains anything other than plain declarations (e.g. nested rules).
|
|
636
|
+
*
|
|
637
|
+
* @param {object} rule The AST rule node.
|
|
638
|
+
* @return {string|null} The sorted "property:value" signature, or null.
|
|
639
|
+
*/
|
|
640
|
+
function nestedRuleBodySignature (rule) {
|
|
641
|
+
const declarations = (rule.declarations || []).filter((declaration) => {
|
|
642
|
+
return declaration.type !== 'whitespace' && declaration.type !== 'comment';
|
|
643
|
+
});
|
|
644
|
+
if (declarations.length === 0) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
const isAllPlainDeclarations = declarations.every((declaration) => {
|
|
648
|
+
return declaration.type === 'declaration' && declaration.property;
|
|
649
|
+
});
|
|
650
|
+
if (!isAllPlainDeclarations) {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
return declarations
|
|
654
|
+
.map((declaration) => {
|
|
655
|
+
return declaration.property + ':' + (declaration.value || '').trim();
|
|
656
|
+
})
|
|
657
|
+
.sort()
|
|
658
|
+
.join(';');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Determines whether two nested rules can be merged into a shared selector list.
|
|
663
|
+
* They must have identical declaration bodies and the same specificity level,
|
|
664
|
+
* since comma-separated nested selectors share the highest specificity of the
|
|
665
|
+
* group (like `:is()`).
|
|
666
|
+
*
|
|
667
|
+
* @param {object} first The first nested rule.
|
|
668
|
+
* @param {object} second The second nested rule.
|
|
669
|
+
* @return {boolean} Whether the two nested rules may be merged.
|
|
670
|
+
*/
|
|
671
|
+
function nestedRulesMergeable (first, second) {
|
|
672
|
+
if (!first.selectors?.length || !second.selectors?.length) {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
const firstSignature = nestedRuleBodySignature(first);
|
|
676
|
+
if (firstSignature === null || firstSignature !== nestedRuleBodySignature(second)) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
const firstSpecificity = maxSelectorSpecificity(first.selectors.join(','));
|
|
680
|
+
const secondSpecificity = maxSelectorSpecificity(second.selectors.join(','));
|
|
681
|
+
return compareSpecificity(firstSpecificity, secondSpecificity) === 0;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Recursively merges consecutive nested rules within a declaration list when
|
|
686
|
+
* they share identical bodies and specificity, combining their selector lists.
|
|
687
|
+
*
|
|
688
|
+
* @param {Array} declarations The declaration/nested-rule entries of a parent rule.
|
|
689
|
+
* @return {Array} The declaration list with mergeable nested rules combined.
|
|
690
|
+
*/
|
|
691
|
+
function mergeConsecutiveNestedRules (declarations) {
|
|
692
|
+
const recursed = declarations.map((declaration) => {
|
|
693
|
+
if (declaration.type === 'rule' && declaration.declarations) {
|
|
694
|
+
return { ...declaration, declarations: mergeConsecutiveNestedRules(declaration.declarations) };
|
|
695
|
+
}
|
|
696
|
+
return declaration;
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const result = [];
|
|
700
|
+
for (const declaration of recursed) {
|
|
701
|
+
const previous = result[result.length - 1];
|
|
702
|
+
if (
|
|
703
|
+
declaration.type === 'rule' &&
|
|
704
|
+
previous &&
|
|
705
|
+
previous.type === 'rule' &&
|
|
706
|
+
nestedRulesMergeable(previous, declaration)
|
|
707
|
+
) {
|
|
708
|
+
result[result.length - 1] = {
|
|
709
|
+
...previous,
|
|
710
|
+
selectors: [...previous.selectors, ...declaration.selectors]
|
|
711
|
+
};
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
result.push(declaration);
|
|
715
|
+
}
|
|
716
|
+
return result;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Merges mergeable nested rules within every rule's body across the stylesheet,
|
|
721
|
+
* recursing into `@media` and `@layer` blocks. Top-level rules are not merged
|
|
722
|
+
* here, since only nested (comma-grouped) selectors share specificity.
|
|
723
|
+
*
|
|
724
|
+
* @param {Array} rules The AST rule nodes to process.
|
|
725
|
+
* @return {Array} The rules with mergeable nested rules combined.
|
|
726
|
+
*/
|
|
727
|
+
function mergeIdenticalNestedRules (rules) {
|
|
728
|
+
for (const rule of rules) {
|
|
729
|
+
if (rule.type === 'rule' && rule.declarations) {
|
|
730
|
+
rule.declarations = mergeConsecutiveNestedRules(rule.declarations);
|
|
731
|
+
} else if ((rule.type === 'media' || rule.type === 'layer') && rule.rules) {
|
|
732
|
+
rule.rules = mergeIdenticalNestedRules(rule.rules);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return rules;
|
|
736
|
+
}
|
|
737
|
+
|
|
446
738
|
/**
|
|
447
739
|
* Merges rules with identical normalized selectors by combining their declarations. Non-rule entries (like `@media`) break the merge window.
|
|
448
740
|
*
|
|
@@ -630,6 +922,7 @@ export {
|
|
|
630
922
|
expandPureNestedRules,
|
|
631
923
|
factorCommonParents,
|
|
632
924
|
mergeByDeclarations,
|
|
925
|
+
mergeIdenticalNestedRules,
|
|
633
926
|
mergeLayerRules,
|
|
634
927
|
mergeMediaRules,
|
|
635
928
|
mergeSelectorRules,
|