@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 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 report them and I might tell the AI's to fix it.
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 ensure the linter passes when done. 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.
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.15",
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/copyTests.js",
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.2.1",
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.5.0",
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.0.12",
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 finalRules = nestFlatRules(factoredRules);
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));
@@ -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
- if (!isPureNested) {
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,
@@ -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
  }
@@ -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 };
@@ -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 };