@thejaredwilcurt/csslop 0.0.38 → 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,118 +2,24 @@
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
- }
32
-
33
- /**
34
- * Advances past a quoted string in a selector, honoring backslash escapes so a
35
- * quoted close-quote does not end the skip early.
36
- *
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.
40
- */
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
- }
5
+ import {
6
+ findMatchingBracket,
7
+ findMatchingParenthesis,
8
+ findTopLevelDelimiter,
9
+ skipBracketedGroup,
10
+ skipQuotedString,
11
+ splitTopLevel,
12
+ splitTopLevelCommaList
13
+ } from '../parser/source-search.js';
82
14
 
83
15
  /**
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.
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.
87
19
  *
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.
20
+ * @type {string}
91
21
  */
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(`)
@@ -131,11 +37,11 @@ function findNextFunctionCallOutsideStrings (text, functionCall, start) {
131
37
  while (index < text.length) {
132
38
  const character = text[index];
133
39
  if (character === '"' || character === '\'') {
134
- index = skipQuotedText(text, index);
40
+ index = skipQuotedString(text, index);
135
41
  continue;
136
42
  }
137
43
  if (character === '[') {
138
- index = skipAttributeSelector(text, index);
44
+ index = skipBracketedGroup(text, index);
139
45
  continue;
140
46
  }
141
47
  if (character === '\\') {
@@ -208,7 +114,7 @@ function buildWhereCartesianProduct (leftParts, rightParts) {
208
114
  const products = [];
209
115
  for (const leftSelector of leftParts) {
210
116
  for (const rightSelector of rightParts) {
211
- const merged = mergeCompoundSelectors(leftSelector.trim(), rightSelector.trim());
117
+ const merged = mergeCompoundSelectors(leftSelector, rightSelector);
212
118
  if (merged === null) {
213
119
  return null;
214
120
  }
@@ -239,7 +145,7 @@ function mergeAdjacentWherePseudoClasses (selector) {
239
145
  }
240
146
  // Index of the '(' in the first ':where('
241
147
  const firstOpenParenthesis = whereIndex + 6;
242
- const firstCloseParenthesis = findMatchingCloseParenthesis(result, firstOpenParenthesis);
148
+ const firstCloseParenthesis = findMatchingParenthesis(result, firstOpenParenthesis);
243
149
  if (firstCloseParenthesis === -1) {
244
150
  break;
245
151
  }
@@ -251,14 +157,14 @@ function mergeAdjacentWherePseudoClasses (selector) {
251
157
  }
252
158
  // Index of the '(' in the second ':where('
253
159
  const secondOpenParenthesis = adjacentStart + 6;
254
- const secondCloseParenthesis = findMatchingCloseParenthesis(result, secondOpenParenthesis);
160
+ const secondCloseParenthesis = findMatchingParenthesis(result, secondOpenParenthesis);
255
161
  if (secondCloseParenthesis === -1) {
256
162
  break;
257
163
  }
258
164
  const firstInnerContent = result.slice(firstOpenParenthesis + 1, firstCloseParenthesis);
259
165
  const secondInnerContent = result.slice(secondOpenParenthesis + 1, secondCloseParenthesis);
260
- const leftParts = splitParametersByComma(firstInnerContent);
261
- const rightParts = splitParametersByComma(secondInnerContent);
166
+ const leftParts = splitTopLevelCommaList(firstInnerContent);
167
+ const rightParts = splitTopLevelCommaList(secondInnerContent);
262
168
  const mergedParts = buildWhereCartesianProduct(leftParts, rightParts);
263
169
  if (mergedParts === null) {
264
170
  position = firstCloseParenthesis + 1;
@@ -468,7 +374,7 @@ function summarizeCompoundSelector (selector) {
468
374
  }
469
375
  } else if (character === '[') {
470
376
  // Attribute selector: well-formed input has a closing bracket
471
- const closeBracketIndex = selector.indexOf(']', index + 1);
377
+ const closeBracketIndex = findMatchingBracket(selector, index);
472
378
  if (closeBracketIndex === -1) {
473
379
  return null;
474
380
  }
@@ -488,7 +394,7 @@ function summarizeCompoundSelector (selector) {
488
394
  index = nameEnd;
489
395
  if (selector[index] === '(') {
490
396
  // Functional pseudo-class: skip past its balanced argument parentheses
491
- const closeParenthesisIndex = findMatchingCloseParenthesis(selector, index);
397
+ const closeParenthesisIndex = findMatchingParenthesis(selector, index);
492
398
  if (closeParenthesisIndex === -1) {
493
399
  return null;
494
400
  }
@@ -530,30 +436,8 @@ function summarizeCompoundSelector (selector) {
530
436
  * @return {Array} The compound selector segments, combinators excluded.
531
437
  */
532
438
  function splitCombinatorSegments (selector) {
533
- const segments = [];
534
- let currentSegment = '';
535
- let depth = 0;
536
- for (const character of selector) {
537
- if (character === '(' || character === '[') {
538
- depth++;
539
- } else if (character === ')' || character === ']') {
540
- depth--;
541
- }
542
- // A combinator at the top level ends the current compound segment;
543
- // whitespace runs collapse into a single boundary
544
- if (depth === 0 && (character === '>' || character === '+' || character === '~' || /\s/.test(character))) {
545
- if (currentSegment) {
546
- segments.push(currentSegment);
547
- currentSegment = '';
548
- }
549
- continue;
550
- }
551
- currentSegment += character;
552
- }
553
- if (currentSegment) {
554
- segments.push(currentSegment);
555
- }
556
- 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);
557
441
  }
558
442
 
559
443
  /**
@@ -614,18 +498,7 @@ function containsPseudoElement (selector) {
614
498
  * @return {boolean} True when a top-level combinator is present.
615
499
  */
616
500
  function hasTopLevelCombinator (selector) {
617
- let depth = 0;
618
- for (const character of selector) {
619
- if (character === '(' || character === '[') {
620
- depth++;
621
- } else if (character === ')' || character === ']') {
622
- depth--;
623
- } else if (depth === 0 && (character === '>' || character === '+' || character === '~' || /\s/.test(character))) {
624
- // A combinator or whitespace boundary at the top level joins two compounds
625
- return true;
626
- }
627
- }
628
- return false;
501
+ return findTopLevelDelimiter(selector, COMPOUND_SELECTOR_BOUNDARIES, 0) !== -1;
629
502
  }
630
503
 
631
504
  /**
@@ -673,13 +546,13 @@ function unwrapSingleArgumentIsFunctions (selector, hasSiblingSelectors) {
673
546
  break;
674
547
  }
675
548
  const openParenthesisIndex = isIndex + 3;
676
- const closeParenthesisIndex = findMatchingCloseParenthesis(result, openParenthesisIndex);
549
+ const closeParenthesisIndex = findMatchingParenthesis(result, openParenthesisIndex);
677
550
  if (closeParenthesisIndex === -1) {
678
551
  break;
679
552
  }
680
553
  const content = result.slice(openParenthesisIndex + 1, closeParenthesisIndex);
681
554
  const innerSelector = content.trim();
682
- const parts = splitParametersByComma(content);
555
+ const parts = splitTopLevelCommaList(content);
683
556
  if (parts.length !== 1 || !innerSelector) {
684
557
  position = closeParenthesisIndex + 1;
685
558
  continue;
@@ -734,11 +607,7 @@ function canDecomposeIsSelector (parts) {
734
607
  }
735
608
  const summaries = [];
736
609
  for (const part of parts) {
737
- const trimmedPart = part.trim();
738
- if (!trimmedPart) {
739
- return false;
740
- }
741
- const summary = summarizeCompoundSelector(trimmedPart);
610
+ const summary = summarizeCompoundSelector(part);
742
611
  if (!summary || !summary.recognizable || summary.hasPseudoElement) {
743
612
  return false;
744
613
  }
@@ -754,6 +623,25 @@ function canDecomposeIsSelector (parts) {
754
623
  });
755
624
  }
756
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
+
757
645
  /**
758
646
  * Processes a selector by unwrapping redundant single-argument `:is()`
759
647
  * functions within it, then — for bare `:is()` selectors (where `:is()` is the
@@ -772,43 +660,10 @@ function processIsSelector (selector, hasSiblingSelectors) {
772
660
  selector = selector.replace(/:is\(:visited,:link\)/g, ':any-link');
773
661
  selector = unwrapSingleArgumentIsFunctions(selector, hasSiblingSelectors);
774
662
  // Only process bare :is() selectors (where :is() is the entire selector)
775
- if (!selector.startsWith(':is(')) {
776
- return [selector];
777
- }
778
- let depth = 0;
779
- let closingIndex = -1;
780
- for (let index = 4; index < selector.length; index++) {
781
- if (selector[index] === '(') {
782
- depth++;
783
- } else if (selector[index] === ')') {
784
- if (depth === 0) {
785
- closingIndex = index;
786
- break;
787
- }
788
- depth--;
789
- }
790
- }
791
- if (closingIndex !== selector.length - 1) {
663
+ let parts = readWholeSelectorIsParts(selector);
664
+ if (!parts) {
792
665
  return [selector];
793
666
  }
794
- const content = selector.slice(4, -1);
795
- let parts = [];
796
- let currentPart = '';
797
- let parenDepth = 0;
798
- for (const character of content) {
799
- if (character === '(') {
800
- parenDepth++;
801
- } else if (character === ')') {
802
- parenDepth--;
803
- }
804
- if (character === ',' && parenDepth === 0) {
805
- parts.push(currentPart);
806
- currentPart = '';
807
- } else {
808
- currentPart += character;
809
- }
810
- }
811
- parts.push(currentPart);
812
667
  // Replace :link + :visited with :any-link
813
668
  const hasLink = parts.includes(':link');
814
669
  const hasVisited = parts.includes(':visited');
@@ -826,11 +681,11 @@ function processIsSelector (selector, hasSiblingSelectors) {
826
681
  parts.sort();
827
682
  // Unwrap :is() with a single selector
828
683
  if (parts.length === 1) {
829
- const onlyPart = parts[0].trim();
684
+ const onlyPart = parts[0];
830
685
  const canUnwrap = hasSiblingSelectors ?
831
686
  isUniversallyRecognizableSelector(onlyPart) :
832
687
  !containsPseudoElement(onlyPart);
833
- return canUnwrap ? [onlyPart] : [':is(' + parts[0] + ')'];
688
+ return canUnwrap ? [onlyPart] : [':is(' + onlyPart + ')'];
834
689
  }
835
690
  // Drop the :is() wrapper when the parts are equivalent as a plain selector list
836
691
  if (canDecomposeIsSelector(parts)) {
@@ -851,29 +706,10 @@ function processIsSelector (selector, hasSiblingSelectors) {
851
706
  * @return {Array} The flattened selector parts, or the original selector.
852
707
  */
853
708
  function flattenNestingParentIsSelector (selector) {
854
- if (!selector.startsWith(':is(')) {
709
+ const parts = readWholeSelectorIsParts(selector);
710
+ if (!parts) {
855
711
  return [selector];
856
712
  }
857
- let depth = 0;
858
- let closingIndex = -1;
859
- for (let index = 4; index < selector.length; index++) {
860
- if (selector[index] === '(') {
861
- depth++;
862
- } else if (selector[index] === ')') {
863
- if (depth === 0) {
864
- closingIndex = index;
865
- break;
866
- }
867
- depth--;
868
- }
869
- }
870
- // The :is() must span the entire selector to be safely liftable
871
- if (closingIndex !== selector.length - 1) {
872
- return [selector];
873
- }
874
- const parts = splitParametersByComma(selector.slice(4, -1)).map((part) => {
875
- return part.trim();
876
- });
877
713
  const hasPotentiallyUnsupportedPart = parts.some((part) => {
878
714
  return part.includes(':');
879
715
  });
@@ -882,11 +718,10 @@ function flattenNestingParentIsSelector (selector) {
882
718
  }
883
719
  return parts;
884
720
  }
721
+
885
722
  export {
886
- findMatchingCloseParenthesis,
887
723
  findNextFunctionCallOutsideStrings,
888
724
  flattenNestingParentIsSelector,
889
725
  mergeAdjacentWherePseudoClasses,
890
- processIsSelector,
891
- splitParametersByComma
726
+ processIsSelector
892
727
  };
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { processDeclarations } from '../declarations/process.js';
6
+ import { splitTopLevelCommaList } from '../parser/source-search.js';
6
7
  import { minifyValueForOutput } from '../value/minify.js';
7
8
 
8
9
  import {
@@ -25,8 +26,7 @@ import { resolvePropertyDescriptors } from './property.js';
25
26
  import {
26
27
  flattenNestingParentIsSelector,
27
28
  mergeAdjacentWherePseudoClasses,
28
- processIsSelector,
29
- splitParametersByComma
29
+ processIsSelector
30
30
  } from './selectors.js';
31
31
 
32
32
  /**
@@ -103,10 +103,9 @@ function minifyFunctionPrelude (prelude) {
103
103
  const functionName = trimmedPrelude.slice(0, openParenIndex);
104
104
  const closeParenIndex = trimmedPrelude.lastIndexOf(')');
105
105
  const innerContent = trimmedPrelude.slice(openParenIndex + 1, closeParenIndex);
106
- const parameters = splitParametersByComma(innerContent);
107
- const minifiedParameters = parameters.map((parameter) => {
106
+ const minifiedParameters = splitTopLevelCommaList(innerContent).map((parameter) => {
108
107
  // Collapse whitespace around the colon separating parameter name from default value
109
- return parameter.trim().replace(/\s*:\s*/, ':');
108
+ return parameter.replace(/\s*:\s*/, ':');
110
109
  });
111
110
  return functionName + '(' + minifiedParameters.join(',') + ')';
112
111
  }
@@ -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
  /**