@thejaredwilcurt/csslop 0.0.37 → 0.0.39

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.
@@ -2,126 +2,33 @@
2
2
  * @file Selector minification utilities for CSS rule stringification.
3
3
  */
4
4
 
5
- /**
6
- * Splits a parameter string by commas while respecting nested parentheses,
7
- * so commas inside function calls within default values are not treated as separators.
8
- *
9
- * @param {string} parameterString The comma-separated parameter string to split.
10
- * @return {Array} An array of individual parameter strings.
11
- */
12
- function splitParametersByComma (parameterString) {
13
- const parameters = [];
14
- let currentParameter = '';
15
- let parenthesisDepth = 0;
16
- for (const character of parameterString) {
17
- if (character === '(') {
18
- parenthesisDepth++;
19
- } else if (character === ')') {
20
- parenthesisDepth--;
21
- }
22
- if (character === ',' && parenthesisDepth === 0) {
23
- parameters.push(currentParameter);
24
- currentParameter = '';
25
- } else {
26
- currentParameter += character;
27
- }
28
- }
29
- parameters.push(currentParameter);
30
- return parameters;
31
- }
5
+ import {
6
+ findMatchingBracket,
7
+ findMatchingParenthesis,
8
+ findTopLevelDelimiter,
9
+ skipBracketedGroup,
10
+ skipQuotedString,
11
+ splitTopLevel,
12
+ splitTopLevelCommaList
13
+ } from '../parser/source-search.js';
32
14
 
33
15
  /**
34
- * Advances past a quoted string in a selector, honoring backslash escapes so a
35
- * quoted close-quote does not end the skip early.
16
+ * The characters that end one compound selector of a complex selector: the
17
+ * three combinators that join compounds, and the whitespace of the descendant
18
+ * combinator.
36
19
  *
37
- * @param {string} text The selector string being scanned.
38
- * @param {number} quoteIndex The index of the opening quote character.
39
- * @return {number} The index right after the closing quote, or the end of the string if the quote never closes.
20
+ * @type {string}
40
21
  */
41
- function skipQuotedText (text, quoteIndex) {
42
- const quote = text[quoteIndex];
43
- let index = quoteIndex + 1;
44
- while (index < text.length) {
45
- if (text[index] === '\\') {
46
- index++;
47
- } else if (text[index] === quote) {
48
- return index + 1;
49
- }
50
- index++;
51
- }
52
- return text.length;
53
- }
54
-
55
- /**
56
- * Advances past an attribute selector `[...]` in a selector, skipping quoted
57
- * values and escaped characters so brackets inside them are not miscounted.
58
- *
59
- * @param {string} text The selector string being scanned.
60
- * @param {number} openIndex The index of the `[` that opens the attribute selector.
61
- * @return {number} The index right after the closing `]`, or the end of the string if it never closes.
62
- */
63
- function skipAttributeSelector (text, openIndex) {
64
- let index = openIndex + 1;
65
- while (index < text.length) {
66
- const character = text[index];
67
- if (character === '"' || character === '\'') {
68
- index = skipQuotedText(text, index);
69
- continue;
70
- }
71
- if (character === '\\') {
72
- index += 2;
73
- continue;
74
- }
75
- if (character === ']') {
76
- return index + 1;
77
- }
78
- index++;
79
- }
80
- return text.length;
81
- }
82
-
83
- /**
84
- * Finds the index of the closing parenthesis that matches the opening
85
- * parenthesis at the given position in the string, skipping over quoted
86
- * strings and attribute selectors so parentheses inside them are ignored.
87
- *
88
- * @param {string} text The string to search within.
89
- * @param {number} openIndex The index of the opening parenthesis.
90
- * @return {number} The index of the matching closing parenthesis, or -1 if not found.
91
- */
92
- function findMatchingCloseParenthesis (text, openIndex) {
93
- let depth = 0;
94
- let index = openIndex;
95
- while (index < text.length) {
96
- const character = text[index];
97
- if (character === '"' || character === '\'') {
98
- index = skipQuotedText(text, index);
99
- continue;
100
- }
101
- if (character === '[') {
102
- index = skipAttributeSelector(text, index);
103
- continue;
104
- }
105
- if (character === '(') {
106
- depth++;
107
- } else if (character === ')') {
108
- depth--;
109
- if (depth === 0) {
110
- return index;
111
- }
112
- }
113
- index++;
114
- }
115
- return -1;
116
- }
22
+ const COMPOUND_SELECTOR_BOUNDARIES = '>+~ \t\n\r\f';
117
23
 
118
24
  /**
119
25
  * Finds the next occurrence of a pseudo-class function token (e.g. `:is(`)
120
26
  * that sits at the top level of a selector, outside any quoted string or
121
- * attribute selector.
27
+ * attribute selector. Pseudo-class names are case-insensitive, so the token is
28
+ * matched without regard to case and must be given in lowercase.
122
29
  *
123
30
  * @param {string} text The selector string to scan.
124
- * @param {string} functionCall The function token to find, including its opening parenthesis.
31
+ * @param {string} functionCall The lowercase function token to find, including its opening parenthesis.
125
32
  * @param {number} start The index to start scanning from.
126
33
  * @return {number} The index of the next top-level occurrence, or -1 if none remains.
127
34
  */
@@ -130,18 +37,24 @@ function findNextFunctionCallOutsideStrings (text, functionCall, start) {
130
37
  while (index < text.length) {
131
38
  const character = text[index];
132
39
  if (character === '"' || character === '\'') {
133
- index = skipQuotedText(text, index);
40
+ index = skipQuotedString(text, index);
134
41
  continue;
135
42
  }
136
43
  if (character === '[') {
137
- index = skipAttributeSelector(text, index);
44
+ index = skipBracketedGroup(text, index);
138
45
  continue;
139
46
  }
140
47
  if (character === '\\') {
141
48
  index += 2;
142
49
  continue;
143
50
  }
144
- if (text.startsWith(functionCall, index)) {
51
+ // The token's first character is compared before its whole length is read,
52
+ // so only the few positions that could start it are lowercased
53
+ const startsToken = (
54
+ character === functionCall[0] &&
55
+ text.slice(index, index + functionCall.length).toLowerCase() === functionCall
56
+ );
57
+ if (startsToken) {
145
58
  return index;
146
59
  }
147
60
  index++;
@@ -201,7 +114,7 @@ function buildWhereCartesianProduct (leftParts, rightParts) {
201
114
  const products = [];
202
115
  for (const leftSelector of leftParts) {
203
116
  for (const rightSelector of rightParts) {
204
- const merged = mergeCompoundSelectors(leftSelector.trim(), rightSelector.trim());
117
+ const merged = mergeCompoundSelectors(leftSelector, rightSelector);
205
118
  if (merged === null) {
206
119
  return null;
207
120
  }
@@ -232,7 +145,7 @@ function mergeAdjacentWherePseudoClasses (selector) {
232
145
  }
233
146
  // Index of the '(' in the first ':where('
234
147
  const firstOpenParenthesis = whereIndex + 6;
235
- const firstCloseParenthesis = findMatchingCloseParenthesis(result, firstOpenParenthesis);
148
+ const firstCloseParenthesis = findMatchingParenthesis(result, firstOpenParenthesis);
236
149
  if (firstCloseParenthesis === -1) {
237
150
  break;
238
151
  }
@@ -244,14 +157,14 @@ function mergeAdjacentWherePseudoClasses (selector) {
244
157
  }
245
158
  // Index of the '(' in the second ':where('
246
159
  const secondOpenParenthesis = adjacentStart + 6;
247
- const secondCloseParenthesis = findMatchingCloseParenthesis(result, secondOpenParenthesis);
160
+ const secondCloseParenthesis = findMatchingParenthesis(result, secondOpenParenthesis);
248
161
  if (secondCloseParenthesis === -1) {
249
162
  break;
250
163
  }
251
164
  const firstInnerContent = result.slice(firstOpenParenthesis + 1, firstCloseParenthesis);
252
165
  const secondInnerContent = result.slice(secondOpenParenthesis + 1, secondCloseParenthesis);
253
- const leftParts = splitParametersByComma(firstInnerContent);
254
- const rightParts = splitParametersByComma(secondInnerContent);
166
+ const leftParts = splitTopLevelCommaList(firstInnerContent);
167
+ const rightParts = splitTopLevelCommaList(secondInnerContent);
255
168
  const mergedParts = buildWhereCartesianProduct(leftParts, rightParts);
256
169
  if (mergedParts === null) {
257
170
  position = firstCloseParenthesis + 1;
@@ -461,7 +374,7 @@ function summarizeCompoundSelector (selector) {
461
374
  }
462
375
  } else if (character === '[') {
463
376
  // Attribute selector: well-formed input has a closing bracket
464
- const closeBracketIndex = selector.indexOf(']', index + 1);
377
+ const closeBracketIndex = findMatchingBracket(selector, index);
465
378
  if (closeBracketIndex === -1) {
466
379
  return null;
467
380
  }
@@ -481,7 +394,7 @@ function summarizeCompoundSelector (selector) {
481
394
  index = nameEnd;
482
395
  if (selector[index] === '(') {
483
396
  // Functional pseudo-class: skip past its balanced argument parentheses
484
- const closeParenthesisIndex = findMatchingCloseParenthesis(selector, index);
397
+ const closeParenthesisIndex = findMatchingParenthesis(selector, index);
485
398
  if (closeParenthesisIndex === -1) {
486
399
  return null;
487
400
  }
@@ -523,30 +436,8 @@ function summarizeCompoundSelector (selector) {
523
436
  * @return {Array} The compound selector segments, combinators excluded.
524
437
  */
525
438
  function splitCombinatorSegments (selector) {
526
- const segments = [];
527
- let currentSegment = '';
528
- let depth = 0;
529
- for (const character of selector) {
530
- if (character === '(' || character === '[') {
531
- depth++;
532
- } else if (character === ')' || character === ']') {
533
- depth--;
534
- }
535
- // A combinator at the top level ends the current compound segment;
536
- // whitespace runs collapse into a single boundary
537
- if (depth === 0 && (character === '>' || character === '+' || character === '~' || /\s/.test(character))) {
538
- if (currentSegment) {
539
- segments.push(currentSegment);
540
- currentSegment = '';
541
- }
542
- continue;
543
- }
544
- currentSegment += character;
545
- }
546
- if (currentSegment) {
547
- segments.push(currentSegment);
548
- }
549
- return segments;
439
+ // A run of boundaries, such as the spaces around a `>`, yields empty segments
440
+ return splitTopLevel(selector, COMPOUND_SELECTOR_BOUNDARIES).filter(Boolean);
550
441
  }
551
442
 
552
443
  /**
@@ -607,18 +498,7 @@ function containsPseudoElement (selector) {
607
498
  * @return {boolean} True when a top-level combinator is present.
608
499
  */
609
500
  function hasTopLevelCombinator (selector) {
610
- let depth = 0;
611
- for (const character of selector) {
612
- if (character === '(' || character === '[') {
613
- depth++;
614
- } else if (character === ')' || character === ']') {
615
- depth--;
616
- } else if (depth === 0 && (character === '>' || character === '+' || character === '~' || /\s/.test(character))) {
617
- // A combinator or whitespace boundary at the top level joins two compounds
618
- return true;
619
- }
620
- }
621
- return false;
501
+ return findTopLevelDelimiter(selector, COMPOUND_SELECTOR_BOUNDARIES, 0) !== -1;
622
502
  }
623
503
 
624
504
  /**
@@ -666,13 +546,13 @@ function unwrapSingleArgumentIsFunctions (selector, hasSiblingSelectors) {
666
546
  break;
667
547
  }
668
548
  const openParenthesisIndex = isIndex + 3;
669
- const closeParenthesisIndex = findMatchingCloseParenthesis(result, openParenthesisIndex);
549
+ const closeParenthesisIndex = findMatchingParenthesis(result, openParenthesisIndex);
670
550
  if (closeParenthesisIndex === -1) {
671
551
  break;
672
552
  }
673
553
  const content = result.slice(openParenthesisIndex + 1, closeParenthesisIndex);
674
554
  const innerSelector = content.trim();
675
- const parts = splitParametersByComma(content);
555
+ const parts = splitTopLevelCommaList(content);
676
556
  if (parts.length !== 1 || !innerSelector) {
677
557
  position = closeParenthesisIndex + 1;
678
558
  continue;
@@ -727,11 +607,7 @@ function canDecomposeIsSelector (parts) {
727
607
  }
728
608
  const summaries = [];
729
609
  for (const part of parts) {
730
- const trimmedPart = part.trim();
731
- if (!trimmedPart) {
732
- return false;
733
- }
734
- const summary = summarizeCompoundSelector(trimmedPart);
610
+ const summary = summarizeCompoundSelector(part);
735
611
  if (!summary || !summary.recognizable || summary.hasPseudoElement) {
736
612
  return false;
737
613
  }
@@ -747,6 +623,25 @@ function canDecomposeIsSelector (parts) {
747
623
  });
748
624
  }
749
625
 
626
+ /**
627
+ * Reads the selector list of an `:is()` that makes up the whole of a selector,
628
+ * which is the only shape of `:is()` whose parts can be lifted out of it.
629
+ *
630
+ * @param {string} selector A minified CSS selector string.
631
+ * @return {Array|null} The selectors inside the `:is()`, or null when the selector is anything more than one `:is()`.
632
+ */
633
+ function readWholeSelectorIsParts (selector) {
634
+ if (!selector.startsWith(':is(')) {
635
+ return null;
636
+ }
637
+ // The `:is(` token opens its argument list at its fourth character
638
+ const closingIndex = findMatchingParenthesis(selector, 3);
639
+ if (closingIndex !== selector.length - 1) {
640
+ return null;
641
+ }
642
+ return splitTopLevelCommaList(selector.slice(4, -1));
643
+ }
644
+
750
645
  /**
751
646
  * Processes a selector by unwrapping redundant single-argument `:is()`
752
647
  * functions within it, then — for bare `:is()` selectors (where `:is()` is the
@@ -765,43 +660,10 @@ function processIsSelector (selector, hasSiblingSelectors) {
765
660
  selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
766
661
  selector = unwrapSingleArgumentIsFunctions(selector, hasSiblingSelectors);
767
662
  // Only process bare :is() selectors (where :is() is the entire selector)
768
- if (!selector.startsWith(':is(')) {
769
- return [selector];
770
- }
771
- let depth = 0;
772
- let closingIndex = -1;
773
- for (let index = 4; index < selector.length; index++) {
774
- if (selector[index] === '(') {
775
- depth++;
776
- } else if (selector[index] === ')') {
777
- if (depth === 0) {
778
- closingIndex = index;
779
- break;
780
- }
781
- depth--;
782
- }
783
- }
784
- if (closingIndex !== selector.length - 1) {
663
+ let parts = readWholeSelectorIsParts(selector);
664
+ if (!parts) {
785
665
  return [selector];
786
666
  }
787
- const content = selector.slice(4, -1);
788
- let parts = [];
789
- let currentPart = '';
790
- let parenDepth = 0;
791
- for (const character of content) {
792
- if (character === '(') {
793
- parenDepth++;
794
- } else if (character === ')') {
795
- parenDepth--;
796
- }
797
- if (character === ',' && parenDepth === 0) {
798
- parts.push(currentPart);
799
- currentPart = '';
800
- } else {
801
- currentPart += character;
802
- }
803
- }
804
- parts.push(currentPart);
805
667
  // Replace :link + :visited with :any-link
806
668
  const hasLink = parts.includes(':link');
807
669
  const hasVisited = parts.includes(':visited');
@@ -819,11 +681,11 @@ function processIsSelector (selector, hasSiblingSelectors) {
819
681
  parts.sort();
820
682
  // Unwrap :is() with a single selector
821
683
  if (parts.length === 1) {
822
- const onlyPart = parts[0].trim();
684
+ const onlyPart = parts[0];
823
685
  const canUnwrap = hasSiblingSelectors ?
824
686
  isUniversallyRecognizableSelector(onlyPart) :
825
687
  !containsPseudoElement(onlyPart);
826
- return canUnwrap ? [onlyPart] : [':is(' + parts[0] + ')'];
688
+ return canUnwrap ? [onlyPart] : [':is(' + onlyPart + ')'];
827
689
  }
828
690
  // Drop the :is() wrapper when the parts are equivalent as a plain selector list
829
691
  if (canDecomposeIsSelector(parts)) {
@@ -844,29 +706,10 @@ function processIsSelector (selector, hasSiblingSelectors) {
844
706
  * @return {Array} The flattened selector parts, or the original selector.
845
707
  */
846
708
  function flattenNestingParentIsSelector (selector) {
847
- if (!selector.startsWith(':is(')) {
709
+ const parts = readWholeSelectorIsParts(selector);
710
+ if (!parts) {
848
711
  return [selector];
849
712
  }
850
- let depth = 0;
851
- let closingIndex = -1;
852
- for (let index = 4; index < selector.length; index++) {
853
- if (selector[index] === '(') {
854
- depth++;
855
- } else if (selector[index] === ')') {
856
- if (depth === 0) {
857
- closingIndex = index;
858
- break;
859
- }
860
- depth--;
861
- }
862
- }
863
- // The :is() must span the entire selector to be safely liftable
864
- if (closingIndex !== selector.length - 1) {
865
- return [selector];
866
- }
867
- const parts = splitParametersByComma(selector.slice(4, -1)).map((part) => {
868
- return part.trim();
869
- });
870
713
  const hasPotentiallyUnsupportedPart = parts.some((part) => {
871
714
  return part.includes(':');
872
715
  });
@@ -875,9 +718,10 @@ function flattenNestingParentIsSelector (selector) {
875
718
  }
876
719
  return parts;
877
720
  }
721
+
878
722
  export {
723
+ findNextFunctionCallOutsideStrings,
879
724
  flattenNestingParentIsSelector,
880
725
  mergeAdjacentWherePseudoClasses,
881
- processIsSelector,
882
- splitParametersByComma
726
+ processIsSelector
883
727
  };
@@ -3,12 +3,17 @@
3
3
  */
4
4
 
5
5
  import { processDeclarations } from '../declarations/process.js';
6
- import { minifyValue } from '../value/minify.js';
6
+ import { splitTopLevelCommaList } from '../parser/source-search.js';
7
+ import { minifyValueForOutput } from '../value/minify.js';
7
8
 
8
9
  import {
9
10
  collapseCustomPropertyWhitespace,
10
11
  processCustomPropertyComments
11
12
  } from './custom-properties.js';
13
+ import {
14
+ combineLanguageSelectors,
15
+ minifyLanguageSelector
16
+ } from './lang.js';
12
17
  import {
13
18
  canUnwrapSupports,
14
19
  normalizeLayerNames,
@@ -21,8 +26,7 @@ import { resolvePropertyDescriptors } from './property.js';
21
26
  import {
22
27
  flattenNestingParentIsSelector,
23
28
  mergeAdjacentWherePseudoClasses,
24
- processIsSelector,
25
- splitParametersByComma
29
+ processIsSelector
26
30
  } from './selectors.js';
27
31
 
28
32
  /**
@@ -37,7 +41,7 @@ function stringifyDeclarations (declarations) {
37
41
  return declaration.type !== 'whitespace' && declaration.type !== 'comment' && declaration.property;
38
42
  })
39
43
  .map((declaration) => {
40
- return [declaration.property, ':', minifyValue(declaration)].join('');
44
+ return [declaration.property, ':', minifyValueForOutput(declaration)].join('');
41
45
  })
42
46
  .join(';');
43
47
  }
@@ -99,10 +103,9 @@ function minifyFunctionPrelude (prelude) {
99
103
  const functionName = trimmedPrelude.slice(0, openParenIndex);
100
104
  const closeParenIndex = trimmedPrelude.lastIndexOf(')');
101
105
  const innerContent = trimmedPrelude.slice(openParenIndex + 1, closeParenIndex);
102
- const parameters = splitParametersByComma(innerContent);
103
- const minifiedParameters = parameters.map((parameter) => {
106
+ const minifiedParameters = splitTopLevelCommaList(innerContent).map((parameter) => {
104
107
  // Collapse whitespace around the colon separating parameter name from default value
105
- return parameter.trim().replace(/\s*:\s*/, ':');
108
+ return parameter.replace(/\s*:\s*/, ':');
106
109
  });
107
110
  return functionName + '(' + minifiedParameters.join(',') + ')';
108
111
  }
@@ -251,6 +254,7 @@ function stringifyRule (rule, context) {
251
254
  // Remove redundant leading "& " nesting selector
252
255
  minified = minified.replace(/^& /, '');
253
256
  minified = mergeAdjacentWherePseudoClasses(minified);
257
+ minified = minifyLanguageSelector(minified);
254
258
  return minified;
255
259
  });
256
260
  // When this rule is a nesting parent, its whole selector list is treated
@@ -267,6 +271,7 @@ function stringifyRule (rule, context) {
267
271
  return processIsSelector(selector, hasSiblingSelectors);
268
272
  });
269
273
  uniqueSelectors = [...new Set(uniqueSelectors)];
274
+ uniqueSelectors = combineLanguageSelectors(uniqueSelectors);
270
275
  output.push(uniqueSelectors.join(','));
271
276
  }
272
277
  output.push('{');
@@ -295,7 +300,7 @@ function stringifyRule (rule, context) {
295
300
  if (property.startsWith('--')) {
296
301
  const syntax = context.registeredCustomPropertySyntax.get(property);
297
302
  if (syntax === '"<color>"') {
298
- value = minifyValue(declaration);
303
+ value = minifyValueForOutput(declaration);
299
304
  } else {
300
305
  const rawValue = declaration.rawValue || declaration.value || '';
301
306
  const commentProcessedValue = processCustomPropertyComments(rawValue);
@@ -323,7 +328,7 @@ function stringifyRule (rule, context) {
323
328
  }
324
329
  }
325
330
  } else {
326
- value = minifyValue(declaration);
331
+ value = minifyValueForOutput(declaration);
327
332
  }
328
333
  return [property, ':', value].join('');
329
334
  })
@@ -362,7 +367,7 @@ function stringifyRule (rule, context) {
362
367
  return item.type !== 'declaration';
363
368
  });
364
369
  const renderedDeclarations = mediaDeclarations.map((declaration) => {
365
- return [unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('');
370
+ return [unescapeIdent(declaration.property), ':', minifyValueForOutput(declaration)].join('');
366
371
  }).join(';');
367
372
  const renderedRules = stringifyChildRules(subRules, context);
368
373
  const children = [renderedDeclarations, renderedRules].filter(Boolean).join('');
@@ -440,7 +445,7 @@ function stringifyRule (rule, context) {
440
445
  return declaration.type !== 'whitespace' && declaration.type !== 'comment';
441
446
  })
442
447
  ?.map((declaration) => {
443
- return [declaration.property, ':', minifyValue(declaration)].join('');
448
+ return [declaration.property, ':', minifyValueForOutput(declaration)].join('');
444
449
  })
445
450
  .join(';') || '';
446
451
  output.push(renderedKeyframeDeclarations);
@@ -558,7 +563,7 @@ function stringifyRule (rule, context) {
558
563
  return innerDeclaration.type !== 'whitespace' && innerDeclaration.property;
559
564
  })
560
565
  .map((innerDeclaration) => {
561
- return [unescapeIdent(innerDeclaration.property), ':', minifyValue(innerDeclaration)].join('');
566
+ return [unescapeIdent(innerDeclaration.property), ':', minifyValueForOutput(innerDeclaration)].join('');
562
567
  })
563
568
  .join(';');
564
569
  if (innerDeclarations) {
@@ -567,7 +572,7 @@ function stringifyRule (rule, context) {
567
572
  return [];
568
573
  }
569
574
  if (declaration.property) {
570
- return [[unescapeIdent(declaration.property), ':', minifyValue(declaration)].join('')];
575
+ return [[unescapeIdent(declaration.property), ':', minifyValueForOutput(declaration)].join('')];
571
576
  }
572
577
  return [];
573
578
  });
@@ -2,6 +2,11 @@
2
2
  * @file Evaluates CSS color-mix() expressions and normalizes them into compact color values.
3
3
  */
4
4
 
5
+ import {
6
+ findMatchingParenthesis,
7
+ splitTopLevelCommaList
8
+ } from '../parser/source-search.js';
9
+
5
10
  import {
6
11
  convertOklchToHex,
7
12
  oklabToRgb,
@@ -87,7 +92,7 @@ function findNoneChannels (rawColorStr) {
87
92
  function evaluateNColorMix (colorSpace, args) {
88
93
  const parsedArgs = [];
89
94
  for (const arg of args) {
90
- const parsed = parseColorMixArg(arg.trim());
95
+ const parsed = parseColorMixArg(arg);
91
96
  if (!parsed) {
92
97
  return null;
93
98
  }
@@ -252,7 +257,7 @@ function mixNColorsOklab (colors, weights, alphaMultiplier) {
252
257
  function evaluateColorMix (expr) {
253
258
  // Parse: color-mix(in <space> [<hue-method>], <color> [<p>%], <color> [<p>%])
254
259
  // We need to handle nested parentheses for inner color functions
255
- const inner = extractBalancedArgs(expr, 'color-mix');
260
+ const inner = extractFunctionArgumentText(expr, 'color-mix');
256
261
  if (!inner) {
257
262
  return null;
258
263
  }
@@ -267,7 +272,7 @@ function evaluateColorMix (expr) {
267
272
  const rest = inner.slice(inMatch[0].length);
268
273
 
269
274
  // Split color arguments (handling nested parens)
270
- const args = splitColorMixArgs(rest);
275
+ const args = splitTopLevelCommaList(rest);
271
276
  if (args.length < 2) {
272
277
  return null;
273
278
  }
@@ -278,8 +283,8 @@ function evaluateColorMix (expr) {
278
283
  }
279
284
 
280
285
  // Parse each argument: "<color> [<percentage>]"
281
- const parsed1 = parseColorMixArg(args[0].trim());
282
- const parsed2 = parseColorMixArg(args[1].trim());
286
+ const parsed1 = parseColorMixArg(args[0]);
287
+ const parsed2 = parseColorMixArg(args[1]);
283
288
  if (!parsed1 || !parsed2) {
284
289
  return null;
285
290
  }
@@ -385,53 +390,23 @@ function evaluateColorMix (expr) {
385
390
  }
386
391
 
387
392
  /**
388
- * Extract the balanced content inside a function call.
393
+ * Extract the arguments written inside a function call.
389
394
  *
390
- * @param {string} expr The expression string containing the function call.
391
- * @param {string} funcName The function name to locate (e.g. "color-mix").
392
- * @return {string|null} The content between the matching parentheses, or null if not found.
395
+ * @param {string} expression The expression string containing the function call.
396
+ * @param {string} functionName The function name to locate (e.g. "color-mix").
397
+ * @return {string|null} The text between the function's parentheses, or null when the function is not called.
393
398
  */
394
- function extractBalancedArgs (expr, funcName) {
395
- const prefix = funcName + '(';
396
- const start = expr.indexOf(prefix);
397
- if (start === -1) {
399
+ function extractFunctionArgumentText (expression, functionName) {
400
+ const callIndex = expression.indexOf(functionName + '(');
401
+ if (callIndex === -1) {
398
402
  return null;
399
403
  }
400
- let depth = 1;
401
- let position = start + prefix.length;
402
- while (position < expr.length && depth > 0) {
403
- if (expr[position] === '(') {
404
- depth++;
405
- } else if (expr[position] === ')') {
406
- depth--;
407
- }
408
- position++;
409
- }
410
- return expr.slice(start + prefix.length, position - 1);
411
- }
412
-
413
- /**
414
- * Split color-mix arguments at top-level commas (handling nested parens).
415
- *
416
- * @param {string} str The color arguments string, with arguments separated by commas.
417
- * @return {Array} An array of argument strings split at each top-level comma.
418
- */
419
- function splitColorMixArgs (str) {
420
- const args = [];
421
- let depth = 0;
422
- let start = 0;
423
- for (let position = 0; position < str.length; position++) {
424
- if (str[position] === '(') {
425
- depth++;
426
- } else if (str[position] === ')') {
427
- depth--;
428
- } else if (str[position] === ',' && depth === 0) {
429
- args.push(str.slice(start, position));
430
- start = position + 1;
431
- }
404
+ const openIndex = callIndex + functionName.length;
405
+ const closeIndex = findMatchingParenthesis(expression, openIndex);
406
+ if (closeIndex === -1) {
407
+ return expression.slice(openIndex + 1);
432
408
  }
433
- args.push(str.slice(start));
434
- return args;
409
+ return expression.slice(openIndex + 1, closeIndex);
435
410
  }
436
411
 
437
412
  /**