@thejaredwilcurt/csslop 0.0.15 → 0.0.17
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 +6 -5
- package/src/index.js +3 -1
- package/src/rules/optimize.js +294 -1
- package/src/rules/stringify.js +69 -1
- package/src/value/colors.js +0 -21
- package/src/value/light-dark.js +188 -0
- package/src/value/minify.js +122 -1
- 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.17",
|
|
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,7 +21,7 @@
|
|
|
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": {
|
|
@@ -31,12 +32,12 @@
|
|
|
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.2.2",
|
|
40
41
|
"fflate": "^0.8.3",
|
|
41
42
|
"globals": "^17.7.0",
|
|
42
43
|
"pretty-ms": "^9.3.0",
|
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));
|
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,
|
package/src/rules/stringify.js
CHANGED
|
@@ -361,6 +361,50 @@ function processIsSelector (selector) {
|
|
|
361
361
|
return [':is(' + parts.join(',') + ')'];
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Flattens a top-level `:is()` selector into its individual parts when the rule
|
|
366
|
+
* acts as a nesting parent. A nesting parent's entire selector list is treated
|
|
367
|
+
* as `:is()` when computing the specificity of its nested children, so lifting
|
|
368
|
+
* the parts out of an inner `:is()` does not change specificity. The `:is()` is
|
|
369
|
+
* kept when any part contains a pseudo (`:`), since such selectors may be
|
|
370
|
+
* unsupported and rely on `:is()` for forgiving parsing.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} selector A minified CSS selector string.
|
|
373
|
+
* @return {Array} The flattened selector parts, or the original selector.
|
|
374
|
+
*/
|
|
375
|
+
function flattenNestingParentIsSelector (selector) {
|
|
376
|
+
if (!selector.startsWith(':is(')) {
|
|
377
|
+
return [selector];
|
|
378
|
+
}
|
|
379
|
+
let depth = 0;
|
|
380
|
+
let closingIndex = -1;
|
|
381
|
+
for (let index = 4; index < selector.length; index++) {
|
|
382
|
+
if (selector[index] === '(') {
|
|
383
|
+
depth++;
|
|
384
|
+
} else if (selector[index] === ')') {
|
|
385
|
+
if (depth === 0) {
|
|
386
|
+
closingIndex = index;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
depth--;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// The :is() must span the entire selector to be safely liftable
|
|
393
|
+
if (closingIndex !== selector.length - 1) {
|
|
394
|
+
return [selector];
|
|
395
|
+
}
|
|
396
|
+
const parts = splitParametersByComma(selector.slice(4, -1)).map((part) => {
|
|
397
|
+
return part.trim();
|
|
398
|
+
});
|
|
399
|
+
const hasPotentiallyUnsupportedPart = parts.some((part) => {
|
|
400
|
+
return part.includes(':');
|
|
401
|
+
});
|
|
402
|
+
if (hasPotentiallyUnsupportedPart) {
|
|
403
|
+
return [selector];
|
|
404
|
+
}
|
|
405
|
+
return parts;
|
|
406
|
+
}
|
|
407
|
+
|
|
364
408
|
/**
|
|
365
409
|
* Removes spaces after commas only inside parenthesized groups (function
|
|
366
410
|
* calls like `var()`, `calc()`), leaving top-level comma spacing intact.
|
|
@@ -548,6 +592,15 @@ function stringifyRule (rule, context, nested = false) {
|
|
|
548
592
|
minified = mergeAdjacentWherePseudoClasses(minified);
|
|
549
593
|
return minified;
|
|
550
594
|
});
|
|
595
|
+
// When this rule is a nesting parent, its whole selector list is treated
|
|
596
|
+
// as :is() for the children's specificity, so a top-level :is() can be
|
|
597
|
+
// safely lifted into the list without altering specificity.
|
|
598
|
+
const isNestingParent = (rule.declarations || []).some((declaration) => {
|
|
599
|
+
return declaration.type === 'rule';
|
|
600
|
+
});
|
|
601
|
+
if (isNestingParent) {
|
|
602
|
+
uniqueSelectors = uniqueSelectors.flatMap(flattenNestingParentIsSelector);
|
|
603
|
+
}
|
|
551
604
|
uniqueSelectors = uniqueSelectors.flatMap(processIsSelector);
|
|
552
605
|
uniqueSelectors = [...new Set(uniqueSelectors)];
|
|
553
606
|
const headingSet = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
@@ -639,8 +692,11 @@ function stringifyRule (rule, context, nested = false) {
|
|
|
639
692
|
|
|
640
693
|
if (rule.type === 'media') {
|
|
641
694
|
const normalizedMedia = normalizeMedia(rule.media);
|
|
695
|
+
// A custom-media reference is a parenthesized dashed-ident like (--modern);
|
|
696
|
+
// no space is needed after @media when the query begins with such a token.
|
|
697
|
+
const isCustomMediaReference = normalizedMedia.startsWith('(--');
|
|
642
698
|
let separator;
|
|
643
|
-
if (nested && normalizedMedia.startsWith('(')) {
|
|
699
|
+
if ((nested && normalizedMedia.startsWith('(')) || isCustomMediaReference) {
|
|
644
700
|
separator = '';
|
|
645
701
|
} else {
|
|
646
702
|
separator = ' ';
|
|
@@ -905,6 +961,18 @@ function stringifyRule (rule, context, nested = false) {
|
|
|
905
961
|
return '';
|
|
906
962
|
}
|
|
907
963
|
|
|
964
|
+
if (rule.type === 'custom-media') {
|
|
965
|
+
// Collapse whitespace, strip spaces around commas, and tighten parentheses
|
|
966
|
+
const condition = (rule.media || '')
|
|
967
|
+
.replace(/\s+/g, ' ')
|
|
968
|
+
.replace(/\s*,\s*/g, ',')
|
|
969
|
+
.replace(/\(\s+/g, '(')
|
|
970
|
+
.replace(/\s+\)/g, ')')
|
|
971
|
+
.trim();
|
|
972
|
+
const conditionSeparator = condition ? ' ' : '';
|
|
973
|
+
return '@custom-media ' + rule.name + conditionSeparator + condition + ';';
|
|
974
|
+
}
|
|
975
|
+
|
|
908
976
|
if (rule.type === 'at-rule') {
|
|
909
977
|
return stringifyAtRule(rule, context);
|
|
910
978
|
}
|
package/src/value/colors.js
CHANGED
|
@@ -916,26 +916,6 @@ function convertOklabToHex (L, a, b, alpha) {
|
|
|
916
916
|
return rgbaToHex(r, g, bl, alpha !== undefined ? alpha : 1);
|
|
917
917
|
}
|
|
918
918
|
|
|
919
|
-
/**
|
|
920
|
-
* Handle color(from ...) relative color syntax for simple identity cases.
|
|
921
|
-
*
|
|
922
|
-
* @param {string} expr The color(from ...) expression string.
|
|
923
|
-
* @return {string|null} A hex color string if the relative color is a simple identity transform, or null otherwise.
|
|
924
|
-
*/
|
|
925
|
-
function evaluateRelativeColor (expr) {
|
|
926
|
-
// Match: color(from <base-color> srgb r g b [/ <alpha>]) identity transform pattern
|
|
927
|
-
const match = expr.match(/^color\(\s*from\s+(.+?)\s+srgb\s+r\s+g\s+b(?:\s*\/\s*([\d.]+%?))?\s*\)$/i);
|
|
928
|
-
if (!match) {
|
|
929
|
-
return null;
|
|
930
|
-
}
|
|
931
|
-
const baseColor = parseColor(match[1]);
|
|
932
|
-
if (!baseColor) {
|
|
933
|
-
return null;
|
|
934
|
-
}
|
|
935
|
-
const alpha = parseAlphaString(match[2], baseColor[3]);
|
|
936
|
-
return rgbaToHex(baseColor[0], baseColor[1], baseColor[2], alpha);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
919
|
export {
|
|
940
920
|
hslToRgbChannels,
|
|
941
921
|
rgbaToHex,
|
|
@@ -945,7 +925,6 @@ export {
|
|
|
945
925
|
parseHex,
|
|
946
926
|
evaluateColorMix,
|
|
947
927
|
convertOklabToHex,
|
|
948
|
-
evaluateRelativeColor,
|
|
949
928
|
shortestColor,
|
|
950
929
|
srgbToOklab,
|
|
951
930
|
oklabToSrgb,
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Simplifies CSS light-dark functions whose light and dark values are equivalent.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { findMatchingParenthesis } from './syntax.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Determines whether a character can appear inside a CSS identifier.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} character A single character.
|
|
11
|
+
* @return {boolean} True when the character is identifier-like.
|
|
12
|
+
*/
|
|
13
|
+
function isIdentifierCharacter (character) {
|
|
14
|
+
if (!character) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const codePoint = character.charCodeAt(0);
|
|
18
|
+
const isUppercaseLetter = codePoint >= 65 && codePoint <= 90;
|
|
19
|
+
const isLowercaseLetter = codePoint >= 97 && codePoint <= 122;
|
|
20
|
+
const isDigit = codePoint >= 48 && codePoint <= 57;
|
|
21
|
+
return isUppercaseLetter || isLowercaseLetter || isDigit || character === '_' || character === '-';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Detects a `light-dark(` function call at the provided position.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} value The CSS value being scanned.
|
|
28
|
+
* @param {number} index The candidate start index.
|
|
29
|
+
* @return {boolean} True when a light-dark function starts at index.
|
|
30
|
+
*/
|
|
31
|
+
function startsLightDarkFunction (value, index) {
|
|
32
|
+
if (value.slice(index, index + 11).toLowerCase() !== 'light-dark(') {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return !isIdentifierCharacter(value[index - 1]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Splits a CSS function argument list at top-level commas while respecting
|
|
40
|
+
* nested parentheses and quoted strings.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} argumentString The raw content between function parentheses.
|
|
43
|
+
* @return {Array} The top-level argument strings.
|
|
44
|
+
*/
|
|
45
|
+
function splitTopLevelFunctionArguments (argumentString) {
|
|
46
|
+
const argumentsList = [];
|
|
47
|
+
let currentArgument = '';
|
|
48
|
+
let depth = 0;
|
|
49
|
+
let index = 0;
|
|
50
|
+
let activeQuote = '';
|
|
51
|
+
|
|
52
|
+
while (index < argumentString.length) {
|
|
53
|
+
const character = argumentString[index];
|
|
54
|
+
if (activeQuote) {
|
|
55
|
+
currentArgument += character;
|
|
56
|
+
if (character === '\\') {
|
|
57
|
+
if (index + 1 < argumentString.length) {
|
|
58
|
+
currentArgument += argumentString[index + 1];
|
|
59
|
+
index += 2;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
} else if (character === activeQuote) {
|
|
63
|
+
activeQuote = '';
|
|
64
|
+
}
|
|
65
|
+
index++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (character === '"' || character === '\'') {
|
|
70
|
+
activeQuote = character;
|
|
71
|
+
currentArgument += character;
|
|
72
|
+
index++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (character === '(') {
|
|
77
|
+
depth++;
|
|
78
|
+
currentArgument += character;
|
|
79
|
+
index++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (character === ')') {
|
|
84
|
+
if (depth > 0) {
|
|
85
|
+
depth--;
|
|
86
|
+
}
|
|
87
|
+
currentArgument += character;
|
|
88
|
+
index++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (character === ',' && depth === 0) {
|
|
93
|
+
argumentsList.push(currentArgument.trim());
|
|
94
|
+
currentArgument = '';
|
|
95
|
+
index++;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
currentArgument += character;
|
|
100
|
+
index++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
argumentsList.push(currentArgument.trim());
|
|
104
|
+
return argumentsList;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Simplifies `light-dark(a,b)` to `a` when both top-level arguments are identical
|
|
109
|
+
* after prior minification has normalized them.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} value The CSS value to simplify.
|
|
112
|
+
* @return {string} The value with redundant light-dark functions removed.
|
|
113
|
+
*/
|
|
114
|
+
function simplifyEquivalentLightDarkFunctions (value) {
|
|
115
|
+
let result = '';
|
|
116
|
+
let index = 0;
|
|
117
|
+
|
|
118
|
+
const consumeQuoted = (start) => {
|
|
119
|
+
const quote = value[start];
|
|
120
|
+
let end = start + 1;
|
|
121
|
+
while (end < value.length) {
|
|
122
|
+
if (value[end] === '\\') {
|
|
123
|
+
end += 2;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (value[end] === quote) {
|
|
127
|
+
end++;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
end++;
|
|
131
|
+
}
|
|
132
|
+
return end;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const startsUrl = (start) => {
|
|
136
|
+
return value.slice(start, start + 4).toLowerCase() === 'url(';
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
while (index < value.length) {
|
|
140
|
+
if (value[index] === '"' || value[index] === '\'') {
|
|
141
|
+
const end = consumeQuoted(index);
|
|
142
|
+
result += value.slice(index, end);
|
|
143
|
+
index = end;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (startsUrl(index)) {
|
|
148
|
+
const end = findMatchingParenthesis(value, index + 3);
|
|
149
|
+
if (end === -1) {
|
|
150
|
+
result += value.slice(index);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
result += value.slice(index, end + 1);
|
|
154
|
+
index = end + 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (startsLightDarkFunction(value, index)) {
|
|
159
|
+
const openParenIndex = index + 10;
|
|
160
|
+
const closingParenIndex = findMatchingParenthesis(value, openParenIndex);
|
|
161
|
+
if (closingParenIndex === -1) {
|
|
162
|
+
result += value.slice(index);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const argumentString = value.slice(openParenIndex + 1, closingParenIndex);
|
|
167
|
+
const argumentsList = splitTopLevelFunctionArguments(argumentString);
|
|
168
|
+
if (argumentsList.length === 2) {
|
|
169
|
+
const firstArgument = simplifyEquivalentLightDarkFunctions(argumentsList[0]);
|
|
170
|
+
const secondArgument = simplifyEquivalentLightDarkFunctions(argumentsList[1]);
|
|
171
|
+
if (firstArgument === secondArgument) {
|
|
172
|
+
result += firstArgument;
|
|
173
|
+
} else {
|
|
174
|
+
result += value.slice(index, openParenIndex + 1) + firstArgument + ',' + secondArgument + ')';
|
|
175
|
+
}
|
|
176
|
+
index = closingParenIndex + 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
result += value[index];
|
|
182
|
+
index++;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { simplifyEquivalentLightDarkFunctions };
|
package/src/value/minify.js
CHANGED
|
@@ -8,7 +8,6 @@ import { resolveUnicodeEscape } from '../utilities.js';
|
|
|
8
8
|
import {
|
|
9
9
|
convertOklabToHex,
|
|
10
10
|
evaluateColorMix,
|
|
11
|
-
evaluateRelativeColor,
|
|
12
11
|
hslToRgbChannels,
|
|
13
12
|
hwbToRgbChannels,
|
|
14
13
|
parseHex,
|
|
@@ -16,18 +15,24 @@ import {
|
|
|
16
15
|
shortestColor
|
|
17
16
|
} from './colors.js';
|
|
18
17
|
import { minifyGradients } from './gradients.js';
|
|
18
|
+
import { simplifyEquivalentLightDarkFunctions } from './light-dark.js';
|
|
19
19
|
import {
|
|
20
20
|
normalizeMathFunctions,
|
|
21
21
|
simplifyStandaloneCalc
|
|
22
22
|
} from './math.js';
|
|
23
23
|
import { namedColors } from './named-colors.js';
|
|
24
24
|
import { isQuotesNoneEquivalent } from './quotes.js';
|
|
25
|
+
import {
|
|
26
|
+
evaluateRelativeColor,
|
|
27
|
+
minifyRelativeColorSyntax
|
|
28
|
+
} from './relative-colors.js';
|
|
25
29
|
import {
|
|
26
30
|
collapseShorthandParts,
|
|
27
31
|
normalizeScaleComponent,
|
|
28
32
|
parseAlphaString,
|
|
29
33
|
roundCompactNumber
|
|
30
34
|
} from './shared.js';
|
|
35
|
+
import { findMatchingParenthesis } from './syntax.js';
|
|
31
36
|
import { minifyTransformValue } from './transforms.js';
|
|
32
37
|
import { optimizeUnicodeRange } from './unicode-range.js';
|
|
33
38
|
|
|
@@ -172,6 +177,115 @@ function replaceOutsideStringsAndUrls (value, replacer) {
|
|
|
172
177
|
return result;
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Chooses the shortest valid representation for the path inside a `url(...)`
|
|
182
|
+
* token, weighing an unquoted form, an escaped single space, and a quoted form.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} path The resolved url path, without surrounding quotes.
|
|
185
|
+
* @return {string} The shortest valid url() content string.
|
|
186
|
+
*/
|
|
187
|
+
function formatUrlPath (path) {
|
|
188
|
+
// Parentheses and quote characters are invalid inside an unquoted url() token
|
|
189
|
+
const hasQuoteForcingCharacters = /[()"']/.test(path);
|
|
190
|
+
// Count spaces so escaping them can be compared against keeping the quotes
|
|
191
|
+
const spaceCount = (path.match(/ /g) || []).length;
|
|
192
|
+
|
|
193
|
+
if (hasQuoteForcingCharacters || spaceCount >= 2) {
|
|
194
|
+
// Escape any embedded double quotes so the double-quoted wrapper stays valid
|
|
195
|
+
return '"' + path.replace(/"/g, '\\"') + '"';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (spaceCount === 1) {
|
|
199
|
+
// A lone space is one byte shorter to escape than to wrap the value in quotes
|
|
200
|
+
return path.replace(/ /g, '\\ ');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return path;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Produces the shortest valid contents for a single `url(...)` token from the
|
|
208
|
+
* raw text between its parentheses, stripping a leading current-directory
|
|
209
|
+
* indicator and normalizing quoting.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} rawContent The trimmed text found between the url parentheses.
|
|
212
|
+
* @return {string} The minified url() content.
|
|
213
|
+
*/
|
|
214
|
+
function minifyUrlContent (rawContent) {
|
|
215
|
+
const wasQuoted = rawContent.startsWith('"') || rawContent.startsWith('\'');
|
|
216
|
+
let path = rawContent;
|
|
217
|
+
if (wasQuoted) {
|
|
218
|
+
const quote = rawContent[0];
|
|
219
|
+
if (rawContent.length >= 2 && rawContent.endsWith(quote)) {
|
|
220
|
+
path = rawContent.slice(1, -1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
|
|
225
|
+
path = path.replace(/^\.\//, '');
|
|
226
|
+
|
|
227
|
+
return formatUrlPath(path);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Rewrites every `url(...)` token in a CSS value to its shortest valid form,
|
|
232
|
+
* skipping any quoted strings so an embedded `url(` inside a string is ignored.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} value The CSS value string potentially containing url() tokens.
|
|
235
|
+
* @return {string} The value with all url() tokens minified.
|
|
236
|
+
*/
|
|
237
|
+
function minifyUrls (value) {
|
|
238
|
+
let result = '';
|
|
239
|
+
let index = 0;
|
|
240
|
+
|
|
241
|
+
const consumeQuoted = (start) => {
|
|
242
|
+
const quote = value[start];
|
|
243
|
+
let end = start + 1;
|
|
244
|
+
while (end < value.length) {
|
|
245
|
+
if (value[end] === '\\') {
|
|
246
|
+
end += 2;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (value[end] === quote) {
|
|
250
|
+
end++;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
end++;
|
|
254
|
+
}
|
|
255
|
+
return end;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const startsUrl = (start) => {
|
|
259
|
+
return value.slice(start, start + 4).toLowerCase() === 'url(';
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
while (index < value.length) {
|
|
263
|
+
if (value[index] === '"' || value[index] === '\'') {
|
|
264
|
+
const end = consumeQuoted(index);
|
|
265
|
+
result += value.slice(index, end);
|
|
266
|
+
index = end;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (startsUrl(index)) {
|
|
271
|
+
const closingParenIndex = findMatchingParenthesis(value, index + 3);
|
|
272
|
+
if (closingParenIndex === -1) {
|
|
273
|
+
result += value.slice(index);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
const inner = value.slice(index + 4, closingParenIndex).trim();
|
|
277
|
+
result += 'url(' + minifyUrlContent(inner) + ')';
|
|
278
|
+
index = closingParenIndex + 1;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
result += value[index];
|
|
283
|
+
index++;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
|
|
175
289
|
/**
|
|
176
290
|
* Normalizes whitespace, quotes, and unicode escapes in a CSS value string.
|
|
177
291
|
*
|
|
@@ -744,6 +858,7 @@ function minifyValue (declaration) {
|
|
|
744
858
|
if (typeof val === 'string') {
|
|
745
859
|
val = val.trim();
|
|
746
860
|
val = normalizeWhitespaceAndQuotes(val, declaration.property);
|
|
861
|
+
val = minifyUrls(val);
|
|
747
862
|
|
|
748
863
|
// Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
|
|
749
864
|
// Collapse spaces around division operator
|
|
@@ -802,8 +917,14 @@ function minifyValue (declaration) {
|
|
|
802
917
|
// Shorten all color tokens (hex and named) to their shortest representation
|
|
803
918
|
val = replaceOutsideStringsAndUrls(val, shortenColorValues);
|
|
804
919
|
|
|
920
|
+
// Collapse light-dark() when both normalized branches are identical
|
|
921
|
+
val = simplifyEquivalentLightDarkFunctions(val);
|
|
922
|
+
|
|
805
923
|
// Property-specific optimizations
|
|
806
924
|
val = applyPropertyOptimizations(val, declaration.property);
|
|
925
|
+
|
|
926
|
+
// Minify relative color syntax (identity resolution and whitespace collapsing)
|
|
927
|
+
val = minifyRelativeColorSyntax(val);
|
|
807
928
|
}
|
|
808
929
|
|
|
809
930
|
// Gradient optimizations
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Evaluates and minifies CSS relative color syntax.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
parseColor,
|
|
7
|
+
rgbaToHex,
|
|
8
|
+
shortestColor
|
|
9
|
+
} from './colors.js';
|
|
10
|
+
import { parseAlphaString } from './shared.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Handle color(from ...) relative color syntax for simple identity cases.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} expr The color(from ...) expression string.
|
|
16
|
+
* @return {string|null} A hex color string if the relative color is a simple identity transform, or null otherwise.
|
|
17
|
+
*/
|
|
18
|
+
function evaluateRelativeColor (expr) {
|
|
19
|
+
// Match: color(from <base-color> srgb r g b [/ <alpha>]) identity transform pattern
|
|
20
|
+
const match = expr.match(/^color\(\s*from\s+(.+?)\s+srgb\s+r\s+g\s+b(?:\s*\/\s*([\d.]+%?))?\s*\)$/i);
|
|
21
|
+
if (!match) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const baseColor = parseColor(match[1]);
|
|
25
|
+
if (!baseColor) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const alpha = parseAlphaString(match[2], baseColor[3]);
|
|
29
|
+
return rgbaToHex(baseColor[0], baseColor[1], baseColor[2], alpha);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Canonical channel keyword order for each relative-color function. When the
|
|
34
|
+
* channel expressions in a `<func>(from <color> ...)` match this order exactly,
|
|
35
|
+
* the function reproduces the base color unchanged (an identity transform).
|
|
36
|
+
*
|
|
37
|
+
* @type {{[key: string]: Array<string>}}
|
|
38
|
+
*/
|
|
39
|
+
const RELATIVE_COLOR_CHANNELS = {
|
|
40
|
+
rgb: ['r', 'g', 'b'],
|
|
41
|
+
rgba: ['r', 'g', 'b'],
|
|
42
|
+
hsl: ['h', 's', 'l'],
|
|
43
|
+
hsla: ['h', 's', 'l'],
|
|
44
|
+
hwb: ['h', 'w', 'b'],
|
|
45
|
+
lab: ['l', 'a', 'b'],
|
|
46
|
+
oklab: ['l', 'a', 'b'],
|
|
47
|
+
lch: ['l', 'c', 'h'],
|
|
48
|
+
oklch: ['l', 'c', 'h']
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Finds the closing parenthesis matching the opening one at the given index,
|
|
53
|
+
* accounting for nested parentheses.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} value The string being scanned.
|
|
56
|
+
* @param {number} openIndex Index of the opening parenthesis.
|
|
57
|
+
* @return {number} Index of the matching close parenthesis, or -1.
|
|
58
|
+
*/
|
|
59
|
+
function findClosingParenthesis (value, openIndex) {
|
|
60
|
+
let depth = 1;
|
|
61
|
+
let index = openIndex + 1;
|
|
62
|
+
while (index < value.length) {
|
|
63
|
+
const character = value[index];
|
|
64
|
+
if (character === '(') {
|
|
65
|
+
depth++;
|
|
66
|
+
} else if (character === ')') {
|
|
67
|
+
depth--;
|
|
68
|
+
if (depth === 0) {
|
|
69
|
+
return index;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
index++;
|
|
73
|
+
}
|
|
74
|
+
return -1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Splits a string on top-level whitespace while treating each parenthesized
|
|
79
|
+
* group as part of a single token, keeping nested function arguments intact.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} text The text to split.
|
|
82
|
+
* @return {Array} The list of top-level tokens.
|
|
83
|
+
*/
|
|
84
|
+
function splitTopLevelWhitespace (text) {
|
|
85
|
+
const tokens = [];
|
|
86
|
+
let current = '';
|
|
87
|
+
let depth = 0;
|
|
88
|
+
for (let index = 0; index < text.length; index++) {
|
|
89
|
+
const character = text[index];
|
|
90
|
+
if (character === '(') {
|
|
91
|
+
depth++;
|
|
92
|
+
current += character;
|
|
93
|
+
} else if (character === ')') {
|
|
94
|
+
if (depth > 0) {
|
|
95
|
+
depth--;
|
|
96
|
+
}
|
|
97
|
+
current += character;
|
|
98
|
+
// Match any single whitespace character at the top level
|
|
99
|
+
} else if (depth === 0 && /\s/.test(character)) {
|
|
100
|
+
if (current) {
|
|
101
|
+
tokens.push(current);
|
|
102
|
+
current = '';
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
current += character;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (current) {
|
|
109
|
+
tokens.push(current);
|
|
110
|
+
}
|
|
111
|
+
return tokens;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Splits a relative-color body into its channel portion and its optional alpha
|
|
116
|
+
* portion at the top-level `/` separator.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} body The body of a relative color, after the base color.
|
|
119
|
+
* @return {Array} A two-element array of [channelsPart, alphaPart|null].
|
|
120
|
+
*/
|
|
121
|
+
function splitRelativeAlpha (body) {
|
|
122
|
+
let depth = 0;
|
|
123
|
+
for (let index = 0; index < body.length; index++) {
|
|
124
|
+
const character = body[index];
|
|
125
|
+
if (character === '(') {
|
|
126
|
+
depth++;
|
|
127
|
+
} else if (character === ')') {
|
|
128
|
+
if (depth > 0) {
|
|
129
|
+
depth--;
|
|
130
|
+
}
|
|
131
|
+
} else if (character === '/' && depth === 0) {
|
|
132
|
+
return [body.slice(0, index).trim(), body.slice(index + 1).trim()];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [body.trim(), null];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Removes redundant leading zeros from a plain numeric token (e.g. `0.6` → `.6`).
|
|
140
|
+
*
|
|
141
|
+
* @param {string} token A candidate numeric token.
|
|
142
|
+
* @return {string} The token with redundant leading zeros stripped.
|
|
143
|
+
*/
|
|
144
|
+
function stripLeadingZeroFromNumber (token) {
|
|
145
|
+
// Match an optional sign, redundant leading zeros, then a decimal fraction
|
|
146
|
+
const match = token.match(/^(-?)0*(\.\d+)$/);
|
|
147
|
+
if (match) {
|
|
148
|
+
return match[1] + match[2];
|
|
149
|
+
}
|
|
150
|
+
return token;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Determines whether a channel expression is a single value that can safely
|
|
155
|
+
* leave a surrounding calc() wrapper: a channel keyword or a plain number.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} token The simplified channel expression.
|
|
158
|
+
* @return {boolean} Whether the token can stand alone without calc().
|
|
159
|
+
*/
|
|
160
|
+
function isBareChannelToken (token) {
|
|
161
|
+
// A run of letters (a channel keyword) or a plain signed number
|
|
162
|
+
return (/^[a-z]+$/i).test(token) || (/^-?(?:\d*\.\d+|\d+)$/).test(token);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Simplifies a single relative-color channel expression by resolving arithmetic
|
|
167
|
+
* identities inside calc() and unwrapping calc() when it holds a lone value.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} channel The raw channel expression.
|
|
170
|
+
* @return {string} The simplified channel expression.
|
|
171
|
+
*/
|
|
172
|
+
function simplifyRelativeChannel (channel) {
|
|
173
|
+
const calcMatch = channel.match(/^calc\((.*)\)$/is);
|
|
174
|
+
if (!calcMatch) {
|
|
175
|
+
return channel;
|
|
176
|
+
}
|
|
177
|
+
let inner = calcMatch[1].trim();
|
|
178
|
+
let previous;
|
|
179
|
+
do {
|
|
180
|
+
previous = inner;
|
|
181
|
+
// Drop multiply-by-one on the right, e.g. "s*1" → "s"
|
|
182
|
+
inner = inner.replace(/\*\s*1(?![\d.])/g, '');
|
|
183
|
+
// Drop multiply-by-one on the left, e.g. "1*s" → "s"
|
|
184
|
+
inner = inner.replace(/(?<![\d.])1\s*\*/g, '');
|
|
185
|
+
// Drop divide-by-one, e.g. "l/1" → "l"
|
|
186
|
+
inner = inner.replace(/\/\s*1(?![\d.])/g, '');
|
|
187
|
+
// Drop an additive or subtractive zero, e.g. "l + 0" or "l - 0" → "l"
|
|
188
|
+
inner = inner.replace(/\s*[+-]\s*0(?![\d.])/g, '');
|
|
189
|
+
// Drop a leading additive zero, e.g. "0 + l" → "l"
|
|
190
|
+
inner = inner.replace(/^0\s*\+\s*/g, '');
|
|
191
|
+
inner = inner.trim();
|
|
192
|
+
} while (inner !== previous);
|
|
193
|
+
if (isBareChannelToken(inner)) {
|
|
194
|
+
return inner;
|
|
195
|
+
}
|
|
196
|
+
return 'calc(' + inner + ')';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Determines whether simplified channel expressions match the canonical channel
|
|
201
|
+
* keyword order, indicating an identity transform.
|
|
202
|
+
*
|
|
203
|
+
* @param {Array} channels The simplified channel expressions.
|
|
204
|
+
* @param {Array} canonical The canonical channel keyword order.
|
|
205
|
+
* @return {boolean} Whether the channels are an identity pass-through.
|
|
206
|
+
*/
|
|
207
|
+
function channelsMatchCanonical (channels, canonical) {
|
|
208
|
+
if (channels.length !== canonical.length) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
for (let index = 0; index < channels.length; index++) {
|
|
212
|
+
if (channels[index].toLowerCase() !== canonical[index]) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolves an identity relative color to its shortest concrete representation
|
|
221
|
+
* when the base color can be parsed to concrete channel values.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} baseColor The base color token.
|
|
224
|
+
* @param {string|null} alpha The alpha token, or null when absent.
|
|
225
|
+
* @return {string|null} The shortest color string, or null when unresolvable.
|
|
226
|
+
*/
|
|
227
|
+
function resolveRelativeIdentity (baseColor, alpha) {
|
|
228
|
+
const parsed = parseColor(baseColor);
|
|
229
|
+
if (!parsed) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
let alphaValue = parsed[3];
|
|
233
|
+
if (alpha !== null) {
|
|
234
|
+
alphaValue = alpha.endsWith('%') ? parseFloat(alpha) / 100 : parseFloat(alpha);
|
|
235
|
+
if (Number.isNaN(alphaValue)) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return shortestColor(parsed[0], parsed[1], parsed[2], alphaValue);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Joins the parts of a relative color back together, omitting whitespace after a
|
|
244
|
+
* closing parenthesis (which already delimits adjacent tokens) and appending the
|
|
245
|
+
* alpha value after a `/` when present.
|
|
246
|
+
*
|
|
247
|
+
* @param {Array} parts The ordered parts, starting with the `from` keyword.
|
|
248
|
+
* @param {string|null} alpha The alpha token, or null when absent.
|
|
249
|
+
* @return {string} The joined relative-color body.
|
|
250
|
+
*/
|
|
251
|
+
function joinRelativeColorParts (parts, alpha) {
|
|
252
|
+
let result = parts[0];
|
|
253
|
+
for (let index = 1; index < parts.length; index++) {
|
|
254
|
+
if (result.endsWith(')')) {
|
|
255
|
+
result += parts[index];
|
|
256
|
+
} else {
|
|
257
|
+
result += ' ' + parts[index];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (alpha !== null) {
|
|
261
|
+
result += '/' + alpha;
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Rewrites a single relative-color function body to its shortest form, resolving
|
|
268
|
+
* identity transforms to a concrete color and otherwise minifying whitespace and
|
|
269
|
+
* numeric tokens.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} functionName The lowercased color function name.
|
|
272
|
+
* @param {string} inner The text between the function parentheses.
|
|
273
|
+
* @return {string} The minified relative-color function string.
|
|
274
|
+
*/
|
|
275
|
+
function rewriteRelativeColor (functionName, inner) {
|
|
276
|
+
// Strip the leading `from` keyword, allowing it to abut the base color
|
|
277
|
+
const body = inner.replace(/^\s*from\b\s*/i, '').trim();
|
|
278
|
+
const [channelsPart, alphaRaw] = splitRelativeAlpha(body);
|
|
279
|
+
const tokens = splitTopLevelWhitespace(channelsPart);
|
|
280
|
+
if (tokens.length < 2) {
|
|
281
|
+
return functionName + '(' + inner + ')';
|
|
282
|
+
}
|
|
283
|
+
const baseColor = tokens[0];
|
|
284
|
+
const channels = tokens.slice(1).map(simplifyRelativeChannel);
|
|
285
|
+
const alpha = alphaRaw === null ? null : stripLeadingZeroFromNumber(alphaRaw);
|
|
286
|
+
|
|
287
|
+
const canonicalChannels = RELATIVE_COLOR_CHANNELS[functionName];
|
|
288
|
+
if (canonicalChannels && channelsMatchCanonical(channels, canonicalChannels)) {
|
|
289
|
+
const identity = resolveRelativeIdentity(baseColor, alpha);
|
|
290
|
+
if (identity) {
|
|
291
|
+
return identity;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const parts = ['from', baseColor, ...channels];
|
|
296
|
+
return functionName + '(' + joinRelativeColorParts(parts, alpha) + ')';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Minifies every relative-color function (`<func>(from ...)`) in a CSS value,
|
|
301
|
+
* resolving identity transforms and collapsing redundant whitespace. The
|
|
302
|
+
* `color(from ...)` form is intentionally left to `evaluateRelativeColor`.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} value The CSS value string potentially containing relative colors.
|
|
305
|
+
* @return {string} The value with relative colors minified.
|
|
306
|
+
*/
|
|
307
|
+
function minifyRelativeColorSyntax (value) {
|
|
308
|
+
let result = '';
|
|
309
|
+
let index = 0;
|
|
310
|
+
while (index < value.length) {
|
|
311
|
+
// Match a relative-color function name immediately followed by "(from"
|
|
312
|
+
const match = value.slice(index).match(/^(rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*from\b/i);
|
|
313
|
+
const previousCharacter = index > 0 ? value[index - 1] : '';
|
|
314
|
+
// Skip matches that are part of a longer identifier (e.g. the "lch" in "oklch")
|
|
315
|
+
const precededByIdentifier = (/[a-z0-9-]/i).test(previousCharacter);
|
|
316
|
+
if (match && !precededByIdentifier) {
|
|
317
|
+
const functionName = match[1].toLowerCase();
|
|
318
|
+
const openParenIndex = index + match[1].length;
|
|
319
|
+
const closingParenIndex = findClosingParenthesis(value, openParenIndex);
|
|
320
|
+
if (closingParenIndex !== -1) {
|
|
321
|
+
const inner = value.slice(openParenIndex + 1, closingParenIndex);
|
|
322
|
+
result += rewriteRelativeColor(functionName, inner);
|
|
323
|
+
index = closingParenIndex + 1;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
result += value[index];
|
|
328
|
+
index++;
|
|
329
|
+
}
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export {
|
|
334
|
+
evaluateRelativeColor,
|
|
335
|
+
minifyRelativeColorSyntax
|
|
336
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Provides syntax-aware CSS string scanning utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Finds the closing parenthesis for an opening parenthesis while respecting
|
|
7
|
+
* nested parentheses and quoted strings.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} value The CSS text being scanned.
|
|
10
|
+
* @param {number} openParenIndex The index of the opening `(` character.
|
|
11
|
+
* @return {number} The closing `)` index, or -1 if unmatched.
|
|
12
|
+
*/
|
|
13
|
+
function findMatchingParenthesis (value, openParenIndex) {
|
|
14
|
+
let depth = 1;
|
|
15
|
+
let index = openParenIndex + 1;
|
|
16
|
+
let activeQuote = '';
|
|
17
|
+
|
|
18
|
+
while (index < value.length) {
|
|
19
|
+
const character = value[index];
|
|
20
|
+
if (activeQuote) {
|
|
21
|
+
if (character === '\\') {
|
|
22
|
+
index += 2;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (character === activeQuote) {
|
|
26
|
+
activeQuote = '';
|
|
27
|
+
}
|
|
28
|
+
index++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (character === '"' || character === '\'') {
|
|
33
|
+
activeQuote = character;
|
|
34
|
+
index++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (character === '(') {
|
|
39
|
+
depth++;
|
|
40
|
+
index++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (character === ')') {
|
|
45
|
+
depth--;
|
|
46
|
+
if (depth === 0) {
|
|
47
|
+
return index;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
index++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { findMatchingParenthesis };
|