@thejaredwilcurt/csslop 0.0.16 → 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.16",
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,