@thejaredwilcurt/csslop 0.0.40 → 0.0.42

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/src/value/math.js CHANGED
@@ -2,10 +2,13 @@
2
2
  * @file Simplifies CSS math functions (calc, min, max) by folding constant expressions, flattening nested calcs, and converting absolute lengths to pixels.
3
3
  */
4
4
 
5
- import { calc } from '@csstools/css-calc';
6
-
7
5
  import { findMatchingParenthesis } from '../parser/source-search.js';
8
6
 
7
+ import {
8
+ formatUnitTotals,
9
+ MATH_FUNCTION_NAMES,
10
+ simplifyCalc
11
+ } from './math-expression.js';
9
12
  import {
10
13
  convertAbsoluteLengthToPx,
11
14
  formatResolvedDimension,
@@ -86,25 +89,6 @@ function keepSeparatorAfterDissolvedFunction (replacer) {
86
89
  };
87
90
  }
88
91
 
89
- /**
90
- * The functions whose arguments are a math expression, where `*` and `/` are
91
- * arithmetic operators rather than characters of some other syntax.
92
- *
93
- * @type {Set<string>}
94
- */
95
- const MATH_FUNCTION_NAMES = new Set([
96
- 'abs',
97
- 'calc',
98
- 'clamp',
99
- 'hypot',
100
- 'max',
101
- 'min',
102
- 'mod',
103
- 'rem',
104
- 'round',
105
- 'sign'
106
- ]);
107
-
108
92
  /**
109
93
  * Matches a single character that may appear in a CSS function name.
110
94
  *
@@ -270,6 +254,38 @@ function shorterOfResolutionAndCalculation (resolved, calculation) {
270
254
  return calculation;
271
255
  }
272
256
 
257
+ /**
258
+ * Writes a calculation as compactly as its syntax allows, squeezing out every
259
+ * space but the ones a `+` or a `-` needs to be read as an operator.
260
+ *
261
+ * @param {string} inner The contents of the `calc()`.
262
+ * @return {string} The whole `calc()`, written as compactly as it can be.
263
+ */
264
+ function compactCalculation (inner) {
265
+ // Remove whitespace around multiplication and division operators
266
+ const compactInner = inner.replace(/\s+/g, ' ').trim().replace(/\s*([*/])\s*/g, '$1');
267
+ return 'calc(' + compactInner + ')';
268
+ }
269
+
270
+ /**
271
+ * Picks between a calculation and what resolving its arithmetic came to. A
272
+ * resolved value states the same thing without any arithmetic, so it wins a
273
+ * tie, but a result that is itself a calculation only states the same terms
274
+ * another way, and writing them out again for no saving gains nothing.
275
+ *
276
+ * @param {string} resolved The value the calculation resolved to.
277
+ * @param {string} calculation The calculation, written as compactly as it can be.
278
+ * @return {string} Whichever of the two the value is written as.
279
+ */
280
+ function chooseResolvedCalculation (resolved, calculation) {
281
+ // A resolution that kept its function form left terms behind that only
282
+ // resolve once the element they are measured against is laid out
283
+ if (resolved.startsWith('calc(') && resolved.length >= calculation.length) {
284
+ return calculation;
285
+ }
286
+ return shorterOfResolutionAndCalculation(resolved, calculation);
287
+ }
288
+
273
289
  /**
274
290
  * Resolves a percentage divided by a number into the single percentage it
275
291
  * equals, which is how `calc(100%/16)` comes to be written as `6.25%`. The
@@ -398,21 +414,6 @@ function clampResolvedValueToPropertyRange (resolvedValue, property) {
398
414
  return '0';
399
415
  }
400
416
 
401
- /**
402
- * The units a folded calc() expression leads with, in the order they are
403
- * written. Every other unit follows them alphabetically.
404
- *
405
- * @type {Array}
406
- */
407
- const PREFERRED_UNIT_ORDER = ['%', '', 'px'];
408
-
409
- /**
410
- * The same leading units, for excluding them from the alphabetical remainder.
411
- *
412
- * @type {Set<string>}
413
- */
414
- const PREFERRED_UNITS = new Set(PREFERRED_UNIT_ORDER);
415
-
416
417
  /**
417
418
  * Attempts to simplify a calc() expression by combining like-unit terms and evaluating pure arithmetic, returning the simplified string or null if folding is not possible.
418
419
  *
@@ -492,38 +493,7 @@ function tryFoldCalcExpression (expression) {
492
493
  totals.set(unit, (totals.get(unit) || 0) + number);
493
494
  }
494
495
 
495
- const orderedUnits = [...PREFERRED_UNIT_ORDER, ...[...totals.keys()].filter((unit) => {
496
- return !PREFERRED_UNITS.has(unit);
497
- }).sort()];
498
- const outputTerms = [];
499
-
500
- for (const unit of orderedUnits) {
501
- if (!totals.has(unit)) {
502
- continue;
503
- }
504
- const value = totals.get(unit);
505
- if (Math.abs(value) < 1e-12) {
506
- continue;
507
- }
508
- outputTerms.push({ unit, value });
509
- }
510
-
511
- if (!outputTerms.length) {
512
- return '0';
513
- }
514
-
515
- if (outputTerms.length === 1) {
516
- const { unit, value } = outputTerms[0];
517
- return formatResolvedDimension(value, unit);
518
- }
519
-
520
- const [first, ...rest] = outputTerms;
521
- let result = formatResolvedDimension(first.value, first.unit);
522
- for (const term of rest) {
523
- const sign = term.value < 0 ? '-' : '+';
524
- result += ' ' + sign + ' ' + formatResolvedDimension(Math.abs(term.value), term.unit);
525
- }
526
- return 'calc(' + result + ')';
496
+ return formatUnitTotals(totals);
527
497
  }
528
498
 
529
499
  /**
@@ -578,8 +548,7 @@ function chooseResolvedPercentage (simplified, compactInner, match) {
578
548
  */
579
549
  function resolveCalcExpression (match, compactInner) {
580
550
  if (PERCENTAGE_MULTIPLICATION.test(compactInner)) {
581
- // Remove whitespace around multiplication/division operators
582
- return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
551
+ return compactCalculation(compactInner);
583
552
  }
584
553
 
585
554
  const dividedPercentage = resolvePercentageDivision(compactInner);
@@ -592,26 +561,41 @@ function resolveCalcExpression (match, compactInner) {
592
561
  return folded;
593
562
  }
594
563
 
595
- try {
596
- const simplified = calc(match);
597
- if (typeof simplified !== 'string') {
598
- return match;
599
- }
600
- return chooseResolvedPercentage(simplified, compactInner, match);
601
- } catch {
564
+ const simplified = simplifyCalc(match);
565
+ if (simplified === null) {
602
566
  return match;
603
567
  }
568
+ const resolved = chooseResolvedPercentage(simplified, compactInner, match);
569
+ return chooseResolvedCalculation(resolved, compactCalculation(compactInner));
604
570
  }
605
571
 
606
572
  /**
607
- * Simplifies calc(), min(), and max() expressions within a CSS value string using the `@csstools`/css-calc library, falling back to the original value on failure.
573
+ * Matches a math function other than `calc()` whose arguments hold no further
574
+ * parentheses, so that the whole of the function is matched. A `calc()` is
575
+ * left to its own pass, which weighs what the calculation resolves to against
576
+ * the calculation itself.
608
577
  *
609
- * @param {string} value The CSS value string containing math functions to simplify.
610
- * @param {string} property The CSS property name, whose range every resolved result is clamped into.
611
- * @param {string} originalValue The original unmodified value to fall back to if simplification produces an invalid result.
612
- * @return {string} The value with math functions simplified where possible.
578
+ * @type {RegExp}
613
579
  */
614
- function normalizeMathFunctions (value, property, originalValue = '') {
580
+ const RESOLVABLE_MATH_FUNCTION = new RegExp(
581
+ '\\b(?:' +
582
+ [...MATH_FUNCTION_NAMES].filter((name) => {
583
+ return name !== 'calc';
584
+ }).join('|') +
585
+ ')\\([^()]+\\)',
586
+ 'gi'
587
+ );
588
+
589
+ /**
590
+ * Runs one pass of the math simplifications over a value: the rewrites that
591
+ * state a calculation more compactly, and the resolutions that replace a math
592
+ * function with the value it works out to.
593
+ *
594
+ * @param {string} value The CSS value string containing math functions to simplify.
595
+ * @param {string} property The CSS property name, whose range every resolved result is clamped into.
596
+ * @return {string} The value with its math functions simplified where possible.
597
+ */
598
+ function simplifyMathFunctionsOnce (value, property) {
615
599
  let result = rewriteMathFunctionContents(convertReciprocalMultiplicationToDivision, value);
616
600
 
617
601
  // Unwrap calc(1 / (1 / x)) → x (double-reciprocal identity)
@@ -627,20 +611,16 @@ function normalizeMathFunctions (value, property, originalValue = '') {
627
611
  return 'calc(' + inner + ')';
628
612
  }));
629
613
 
630
- // Simplify min()/max() expressions using @csstools/css-calc
631
- result = result.replace(/\b(min|max)\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match) => {
632
- try {
633
- const simplified = calc(match);
634
- if (typeof simplified !== 'string') {
635
- return match;
636
- }
637
- return clampResolvedValueToPropertyRange(simplified, property);
638
- } catch {
614
+ // Resolve every math function that compares or rounds its arguments
615
+ result = result.replace(RESOLVABLE_MATH_FUNCTION, keepSeparatorAfterDissolvedFunction((match) => {
616
+ const simplified = simplifyCalc(match);
617
+ if (simplified === null) {
639
618
  return match;
640
619
  }
620
+ return clampResolvedValueToPropertyRange(simplified, property);
641
621
  }));
642
622
 
643
- // Simplify calc() expressions using constant folding and @csstools/css-calc
623
+ // Simplify calc() expressions using constant folding and expression resolution
644
624
  result = result.replace(/calc\(([^()]+)\)/gi, keepSeparatorAfterDissolvedFunction((match, inner) => {
645
625
  // Collapse whitespace inside calc expression
646
626
  const compactInner = inner.replace(/\s+/g, ' ').trim();
@@ -648,6 +628,31 @@ function normalizeMathFunctions (value, property, originalValue = '') {
648
628
  return clampResolvedValueToPropertyRange(resolved, property);
649
629
  }));
650
630
 
631
+ return result;
632
+ }
633
+
634
+ /**
635
+ * Simplifies every math function within a CSS value string, resolving the ones
636
+ * whose operands are all known and stating the rest as compactly as they can
637
+ * be written.
638
+ *
639
+ * @param {string} value The CSS value string containing math functions to simplify.
640
+ * @param {string} property The CSS property name, whose range every resolved result is clamped into.
641
+ * @param {string} originalValue The value as it was authored, which tells whether a result was ever part of a calculation.
642
+ * @return {string} The value with math functions simplified where possible.
643
+ */
644
+ function normalizeMathFunctions (value, property, originalValue = '') {
645
+ let result = value;
646
+ let previous;
647
+
648
+ do {
649
+ previous = result;
650
+ // Resolving a math function leaves a plain value where it stood, which can
651
+ // bring the function it was nested in within reach of the next pass, so
652
+ // the value is simplified until it settles
653
+ result = simplifyMathFunctionsOnce(result, property);
654
+ } while (result !== previous);
655
+
651
656
  // When calc() folded to an absolute-length unit (pt, pc, in, cm, mm, q), convert to pixels
652
657
  if (originalValue.includes('calc(') && /^-?(?:\d+|\d*\.\d+)(pt|pc|in|cm|mm|q)$/i.test(result)) {
653
658
  // Extract the absolute-length unit from the folded result
@@ -667,7 +672,7 @@ function normalizeMathFunctions (value, property, originalValue = '') {
667
672
  }
668
673
 
669
674
  /**
670
- * Simplifies a standalone calc() value by flattening nested calc expressions, converting absolute length units to pixels, and folding constant terms.
675
+ * Simplifies a standalone calc() value by resolving its arithmetic, flattening nested calc expressions, converting absolute length units to pixels, and folding constant terms.
671
676
  *
672
677
  * @param {string} value The CSS value string, already known to be a single `calc()`.
673
678
  * @return {string} The simplified value, or the original value if simplification is not applicable.
@@ -677,7 +682,15 @@ function foldStandaloneCalc (value) {
677
682
 
678
683
  // Preserve percent-times-number expressions, only stripping whitespace around operators
679
684
  if (PERCENTAGE_MULTIPLICATION.test(inner.replace(/\s+/g, ' ').trim())) {
680
- return 'calc(' + inner.replace(/\s*([*/])\s*/g, '$1') + ')';
685
+ return compactCalculation(inner);
686
+ }
687
+
688
+ // Resolve the calculation as it was written, which is the only reading of it
689
+ // that keeps the grouping its parentheses state. Everything below reads a
690
+ // flattened copy of the expression, where that grouping is already gone.
691
+ const resolved = simplifyCalc(value);
692
+ if (resolved !== null) {
693
+ return chooseResolvedCalculation(resolved, compactCalculation(inner));
681
694
  }
682
695
 
683
696
  // If no nested function calls and no multiplication/division involving parens, try flattening
@@ -714,14 +727,12 @@ function foldStandaloneCalc (value) {
714
727
  }
715
728
 
716
729
  // Collapse whitespace and check for percent-division expressions
717
- const compactInner = inner.replace(/\s+/g, ' ').trim();
718
- const dividedPercentage = resolvePercentageDivision(compactInner);
730
+ const dividedPercentage = resolvePercentageDivision(inner.replace(/\s+/g, ' ').trim());
719
731
  if (dividedPercentage) {
720
732
  return dividedPercentage;
721
733
  }
722
734
 
723
- // Default: strip whitespace around multiplication/division operators
724
- return 'calc(' + compactInner.replace(/\s*([*/])\s*/g, '$1') + ')';
735
+ return compactCalculation(inner);
725
736
  }
726
737
 
727
738
  /**
@@ -451,6 +451,67 @@ function parseOklchHue (token) {
451
451
  return parseAngleToDegrees(normalized);
452
452
  }
453
453
 
454
+ /**
455
+ * Removes whitespace before a negative numeric channel in Lab-family colors.
456
+ * A preceding numeric token already separates the sign from the prior channel.
457
+ *
458
+ * @param {string} functionName The name of the color function.
459
+ * @param {string} value The minified function arguments.
460
+ * @return {string} The arguments with redundant channel spacing removed.
461
+ */
462
+ function compactNegativeColorChannelSpacing (functionName, value) {
463
+ const normalizedFunctionName = functionName.toLowerCase();
464
+ if (!['lab', 'lch', 'oklab', 'oklch'].includes(normalizedFunctionName)) {
465
+ return value;
466
+ }
467
+
468
+ let result = '';
469
+ let nestingDepth = 0;
470
+ for (let index = 0; index < value.length; index++) {
471
+ const character = value[index];
472
+ if (character === ' ' && nestingDepth === 0) {
473
+ const previousCharacter = result[result.length - 1];
474
+ const nextCharacter = value[index + 1];
475
+ const characterAfterSign = value[index + 2];
476
+ const characterAfterDecimal = value[index + 3];
477
+ const previousEndsNumber = (
478
+ isCssDigit(previousCharacter) ||
479
+ previousCharacter === '%'
480
+ );
481
+ const nextStartsNegativeNumber = (
482
+ nextCharacter === '-' &&
483
+ (
484
+ isCssDigit(characterAfterSign) ||
485
+ (
486
+ characterAfterSign === '.' &&
487
+ isCssDigit(characterAfterDecimal)
488
+ )
489
+ )
490
+ );
491
+ if (previousEndsNumber && nextStartsNegativeNumber) {
492
+ continue;
493
+ }
494
+ }
495
+ result += character;
496
+ if (character === '(') {
497
+ nestingDepth++;
498
+ } else if (character === ')') {
499
+ nestingDepth--;
500
+ }
501
+ }
502
+ return result;
503
+ }
504
+
505
+ /**
506
+ * Checks whether a character is an ASCII decimal digit.
507
+ *
508
+ * @param {string} character The character to inspect.
509
+ * @return {boolean} Whether the character is a digit.
510
+ */
511
+ function isCssDigit (character) {
512
+ return character >= '0' && character <= '9';
513
+ }
514
+
454
515
  /**
455
516
  * Converts `oklch()` colors that fall inside the sRGB gamut to their hex
456
517
  * equivalent when that is shorter. Out-of-gamut colors have no sRGB
@@ -574,11 +635,13 @@ function convertColorsToHex (val) {
574
635
  }
575
636
  return integer;
576
637
  });
638
+ minified = compactNegativeColorChannelSpacing(func, minified);
577
639
  return func + '(' + minified.trim() + ')';
578
640
  });
579
641
 
580
642
  // Convert in-gamut oklab() to hex when it produces a shorter representation
581
- val = val.replace(/\boklab\(\s*(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, lStr, aStr, bStr, alphaStr) => {
643
+ // A negative channel may touch the preceding number because its sign separates the CSS number tokens.
644
+ val = val.replace(/\boklab\(\s*(-?(?:\d+|\d*\.\d+))(?:\s+|(?=-))(-?(?:\d+|\d*\.\d+))(?:\s+|(?=-))(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, lStr, aStr, bStr, alphaStr) => {
582
645
  const alpha = parseAlphaString(alphaStr);
583
646
  const hex = convertOklabToHex(parseFloat(lStr), parseFloat(aStr), parseFloat(bStr), alpha);
584
647
  if (!hex) {
@@ -892,14 +955,13 @@ const POSITION_COMPONENT_SOURCE = '(' + POSITION_OFFSET_SOURCE + '(?:\\s+' + POS
892
955
  const URL_TOKEN_BODY_SOURCE = 'url\\([^()]*';
893
956
 
894
957
  /**
895
- * Matches a close-paren that ends an image function, directly followed by a
896
- * position that no slash follows, which is the position that needs its
897
- * separator put back. The parenthesis that ends a `url()` is skipped, since
898
- * that one closes a token rather than a function.
958
+ * Matches a close-paren that ends an image function, followed by a separated
959
+ * position that no slash follows. The parenthesis that ends a `url()` is
960
+ * skipped, since that one closes a token rather than a function.
899
961
  *
900
962
  * @type {RegExp}
901
963
  */
902
- const UNSEPARATED_FUNCTION_POSITION_PATTERN = new RegExp('(?<!' + URL_TOKEN_BODY_SOURCE + ')\\)' + POSITION_COMPONENT_SOURCE + '(?!\\/)', 'gi');
964
+ const SEPARATED_FUNCTION_POSITION_PATTERN = new RegExp('(?<!' + URL_TOKEN_BODY_SOURCE + ')\\)\\s+' + POSITION_COMPONENT_SOURCE + '(?!\\/)', 'gi');
903
965
 
904
966
  /**
905
967
  * Matches a `url()` separated from the position that follows it, which is the
@@ -920,18 +982,16 @@ const SEPARATED_IMAGE_SIZE_POSITION_PATTERN = new RegExp('\\)\\s+' + POSITION_CO
920
982
  /**
921
983
  * Normalizes the separator between the image of a layer and the position that
922
984
  * follows it. Both `background` and `mask` take a `<position> [ / <size> ]`
923
- * component after their image, and a bare position only reads as its own
924
- * component while whitespace separates it from an image function. Neither a
925
- * position that a `/` follows nor one that follows a `url()` needs the
926
- * separator, since the slash delimits the pair and a url is consumed whole as a
927
- * single token that ends at its own closing parenthesis.
985
+ * component after their image. The closing parenthesis already delimits a
986
+ * function token from the position, and neither a position that a `/` follows
987
+ * nor one that follows a `url()` needs a separator either.
928
988
  *
929
989
  * @param {string} value The layered image value, with the parenthesis padding removed.
930
990
  * @return {string} The value with the position separator normalized.
931
991
  */
932
992
  function normalizeImagePositionSeparator (value) {
933
- const separatedFromFunction = value.replace(UNSEPARATED_FUNCTION_POSITION_PATTERN, ') $1');
934
- const joinedToUrl = separatedFromFunction.replace(SEPARATED_URL_POSITION_PATTERN, '$1$2');
993
+ const joinedToFunction = value.replace(SEPARATED_FUNCTION_POSITION_PATTERN, ')$1');
994
+ const joinedToUrl = joinedToFunction.replace(SEPARATED_URL_POSITION_PATTERN, '$1$2');
935
995
  return joinedToUrl.replace(SEPARATED_IMAGE_SIZE_POSITION_PATTERN, ')$1');
936
996
  }
937
997
 
package/vite.config.js CHANGED
@@ -27,10 +27,6 @@ const config = defineConfig({
27
27
  test: /node_modules\/@codemirror\/lang-css/,
28
28
  name: 'codemirror-lang-css'
29
29
  },
30
- {
31
- test: /node_modules\/@csstools\/css-calc/,
32
- name: 'css-calc'
33
- },
34
30
  {
35
31
  test: /node_modules\/@node-projects\/css-parser/,
36
32
  name: 'css-parser'