@thejaredwilcurt/csslop 0.0.46 → 0.0.47

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/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.46",
5
+ "version": "0.0.47",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/pre/start.js",
@@ -35,12 +35,12 @@
35
35
  "eslint-config-tjw-import-x": "^1.0.1",
36
36
  "eslint-config-tjw-jsdoc": "^2.0.1",
37
37
  "eslint-plugin-import-x": "^4.17.0",
38
- "eslint-plugin-jsdoc": "^65.4.0",
38
+ "eslint-plugin-jsdoc": "^65.4.2",
39
39
  "fflate": "^0.8.3",
40
40
  "globals": "^17.13.0",
41
41
  "pretty-ms": "^10.0.0",
42
42
  "real-world-css-libraries": "^1.0.8",
43
- "vite": "^8.3.4"
43
+ "vite": "^8.3.5"
44
44
  },
45
45
  "author": "The Jared Wilcurt",
46
46
  "homepage": "https://github.com/TheJaredWilcurt/csslop#readme",
@@ -57,11 +57,11 @@
57
57
  "devEngines": {
58
58
  "runtime": {
59
59
  "name": "node",
60
- "version": "26.12.0"
60
+ "version": "26.12.1"
61
61
  },
62
62
  "packageManager": {
63
63
  "name": "npm",
64
- "version": "12.2.0"
64
+ "version": "12.2.1"
65
65
  }
66
66
  }
67
67
  }
@@ -182,7 +182,7 @@ function collapseBorderTrioWithPerEdgeOverrides (declarations, context) {
182
182
  const borderValue = buildBorderShorthandValue(
183
183
  widthComponents.length === 1 ? width : '',
184
184
  styleComponents.length === 1 ? style : '',
185
- colorComponents[0]
185
+ colorComponents.length === 1 ? colorComponents[0] : ''
186
186
  ) + importantSuffix;
187
187
  const overrides = BORDER_TRIO_PROPERTIES.flatMap((property, index) => {
188
188
  if (componentsByProperty[index].length === 1) {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { processDeclarations } from '../declarations/process.js';
6
6
  import {
7
+ findMatchingParenthesis,
7
8
  skipParenthesizedGroup,
8
9
  skipQuotedString,
9
10
  splitTopLevelCommaList,
@@ -74,7 +75,20 @@ const LAYER_STATEMENT_PATTERN = /^@layer [^{}();'"]*;$/;
74
75
  */
75
76
  function removeRedundantLayerStatementSemicolon (ruleStrings) {
76
77
  const lastIndex = ruleStrings.length - 1;
77
- if (lastIndex < 0 || !LAYER_STATEMENT_PATTERN.test(ruleStrings[lastIndex])) {
78
+ if (lastIndex < 0) {
79
+ return ruleStrings;
80
+ }
81
+ const lastRule = ruleStrings[lastIndex];
82
+ const isTrailingLayerStatement = LAYER_STATEMENT_PATTERN.test(lastRule);
83
+ const isTrailingImportStatement = (
84
+ lastRule.endsWith(';') &&
85
+ (
86
+ lastRule.startsWith('@import ') ||
87
+ lastRule.startsWith('@import"') ||
88
+ lastRule.startsWith('@import\'')
89
+ )
90
+ );
91
+ if (!isTrailingLayerStatement && !isTrailingImportStatement) {
78
92
  return ruleStrings;
79
93
  }
80
94
  const result = [...ruleStrings];
@@ -82,6 +96,47 @@ function removeRedundantLayerStatementSemicolon (ruleStrings) {
82
96
  return result;
83
97
  }
84
98
 
99
+ /**
100
+ * Normalizes every `supports()` condition in an import prelude while leaving
101
+ * nested functions and quoted strings to the condition parser.
102
+ *
103
+ * @param {string} importStatement The import prelude being minified.
104
+ * @return {string} The import prelude with normalized conditions.
105
+ */
106
+ function normalizeImportSupports (importStatement) {
107
+ let result = '';
108
+ let index = 0;
109
+ const normalizedStatement = importStatement.toLowerCase();
110
+ while (index < importStatement.length) {
111
+ let supportsIndex = -1;
112
+ let scanIndex = index;
113
+ while (scanIndex < importStatement.length) {
114
+ if (importStatement[scanIndex] === '"' || importStatement[scanIndex] === '\'') {
115
+ scanIndex = skipQuotedString(importStatement, scanIndex);
116
+ continue;
117
+ }
118
+ if (normalizedStatement.startsWith('supports(', scanIndex)) {
119
+ supportsIndex = scanIndex;
120
+ break;
121
+ }
122
+ scanIndex++;
123
+ }
124
+ if (supportsIndex === -1) {
125
+ break;
126
+ }
127
+ const openIndex = supportsIndex + 'supports'.length;
128
+ const closeIndex = findMatchingParenthesis(importStatement, openIndex);
129
+ if (closeIndex === -1) {
130
+ break;
131
+ }
132
+ const condition = importStatement.slice(openIndex + 1, closeIndex);
133
+ result += importStatement.slice(index, openIndex + 1);
134
+ result += normalizeSupports(condition) + ')';
135
+ index = closeIndex + 1;
136
+ }
137
+ return result + importStatement.slice(index);
138
+ }
139
+
85
140
  /**
86
141
  * Recursively stringifies child rules into a concatenated minified CSS string.
87
142
  *
@@ -550,13 +605,7 @@ function stringifyRule (rule, context) {
550
605
  importStatement = importStatement.replace(/^(".*?"|'.*?') /, '$1');
551
606
  // Remove space between closing paren and next at-rule condition keyword
552
607
  importStatement = importStatement.replace(/\) ([a-zA-Z])/g, ')$1');
553
- // Minify property:value pairs inside supports() by removing whitespace around colons
554
- importStatement = importStatement.replace(
555
- /supports\(([^()]*)\)/g,
556
- (fullMatch, content) => {
557
- return 'supports(' + content.replace(/\s*:\s*/g, ':') + ')';
558
- }
559
- );
608
+ importStatement = normalizeImportSupports(importStatement);
560
609
  const startsWithQuote = importStatement.startsWith('"') || importStatement.startsWith('\'');
561
610
  let importSeparator;
562
611
  if (startsWithQuote) {
@@ -66,17 +66,29 @@ const MAXIMUM_NESTING_DEPTH = 32;
66
66
  * length is held as the pixels it measures, since every length stating the
67
67
  * same measurement then totals under the same unit.
68
68
  *
69
- * @param {number} number The number the quantity is written from.
70
- * @param {string} unit The unit the quantity is written with, or the empty string when it is a plain number.
71
- * @return {Map} The unit totals the quantity states.
69
+ * @param {number} number The number the quantity is written from.
70
+ * @param {string} unit The unit the quantity is written with, or the empty string when it is a plain number.
71
+ * @param {boolean} preserveWrittenQuantity Whether to retain the original spelling for a standalone quantity.
72
+ * @return {Map} The unit totals the quantity states.
72
73
  */
73
- function createUnitTotals (number, unit) {
74
+ function createUnitTotals (number, unit, preserveWrittenQuantity = false) {
74
75
  const lowercasedUnit = unit.toLowerCase();
75
76
  const pixels = convertAbsoluteLengthToPx(number, lowercasedUnit);
77
+ let totals;
76
78
  if (pixels === null) {
77
- return new Map([[lowercasedUnit, number]]);
79
+ totals = new Map([[lowercasedUnit, number]]);
80
+ } else {
81
+ totals = new Map([['px', pixels]]);
82
+ }
83
+ if (preserveWrittenQuantity) {
84
+ totals.writtenQuantity = {
85
+ number,
86
+ unit: lowercasedUnit,
87
+ canonicalUnit: pixels === null ? lowercasedUnit : 'px',
88
+ canonicalNumber: pixels === null ? number : pixels
89
+ };
78
90
  }
79
- return new Map([['px', pixels]]);
91
+ return totals;
80
92
  }
81
93
 
82
94
  /**
@@ -162,6 +174,16 @@ function multiplyUnitTotals (left, right) {
162
174
  if (isPlainNumber(left)) {
163
175
  return scaleUnitTotals(right, left.get(UNITLESS));
164
176
  }
177
+ const leftQuantity = readSingleQuantity(left);
178
+ const rightQuantity = readSingleQuantity(right);
179
+ if (leftQuantity && rightQuantity) {
180
+ if (leftQuantity.unit.startsWith('reciprocal:') && leftQuantity.unit.slice('reciprocal:'.length) === rightQuantity.unit) {
181
+ return createUnitTotals(leftQuantity.number * rightQuantity.number, UNITLESS);
182
+ }
183
+ if (rightQuantity.unit.startsWith('reciprocal:') && rightQuantity.unit.slice('reciprocal:'.length) === leftQuantity.unit) {
184
+ return createUnitTotals(leftQuantity.number * rightQuantity.number, UNITLESS);
185
+ }
186
+ }
165
187
  return null;
166
188
  }
167
189
 
@@ -175,14 +197,28 @@ function multiplyUnitTotals (left, right) {
175
197
  * @return {Map|null} The totals of the quotient, or null when the division cannot be worked out.
176
198
  */
177
199
  function divideUnitTotals (left, right) {
178
- if (!isPlainNumber(right)) {
179
- return null;
200
+ if (isPlainNumber(right)) {
201
+ const divisor = right.get(UNITLESS);
202
+ if (divisor === 0) {
203
+ return null;
204
+ }
205
+ return scaleUnitTotals(left, 1 / divisor);
180
206
  }
181
- const divisor = right.get(UNITLESS);
182
- if (divisor === 0) {
207
+ const dividendQuantity = readSingleQuantity(left);
208
+ const divisorQuantity = readSingleQuantity(right);
209
+ if (!dividendQuantity || !divisorQuantity || divisorQuantity.number === 0) {
183
210
  return null;
184
211
  }
185
- return scaleUnitTotals(left, 1 / divisor);
212
+ if (dividendQuantity.unit === divisorQuantity.unit) {
213
+ return createUnitTotals(dividendQuantity.number / divisorQuantity.number, UNITLESS);
214
+ }
215
+ if (dividendQuantity.unit === UNITLESS) {
216
+ return createUnitTotals(
217
+ dividendQuantity.number / divisorQuantity.number,
218
+ 'reciprocal:' + divisorQuantity.unit
219
+ );
220
+ }
221
+ return null;
186
222
  }
187
223
 
188
224
  /**
@@ -478,7 +514,7 @@ function parseOperand (reader) {
478
514
  }
479
515
  if (token.type === 'number') {
480
516
  reader.index++;
481
- return createUnitTotals(token.number, token.unit);
517
+ return createUnitTotals(token.number, token.unit, true);
482
518
  }
483
519
  if (token.type === 'identifier') {
484
520
  reader.index++;
@@ -661,7 +697,8 @@ function evaluateMinimum (argumentList) {
661
697
  if (!comparable) {
662
698
  return null;
663
699
  }
664
- return createUnitTotals(Math.min(...comparable.numbers), comparable.unit);
700
+ const winningIndex = comparable.numbers.indexOf(Math.min(...comparable.numbers));
701
+ return argumentList[winningIndex].value;
665
702
  }
666
703
 
667
704
  /**
@@ -675,7 +712,8 @@ function evaluateMaximum (argumentList) {
675
712
  if (!comparable) {
676
713
  return null;
677
714
  }
678
- return createUnitTotals(Math.max(...comparable.numbers), comparable.unit);
715
+ const winningIndex = comparable.numbers.indexOf(Math.max(...comparable.numbers));
716
+ return argumentList[winningIndex].value;
679
717
  }
680
718
 
681
719
  /**
@@ -1043,7 +1081,22 @@ function writeTermNumber (total) {
1043
1081
  * @return {string|null} The value the calculation resolved to, or null when it cannot be written faithfully.
1044
1082
  */
1045
1083
  function formatUnitTotals (totals) {
1084
+ const writtenQuantity = totals.writtenQuantity;
1085
+ if (
1086
+ writtenQuantity &&
1087
+ totals.size === 1 &&
1088
+ totals.has(writtenQuantity.canonicalUnit) &&
1089
+ totals.get(writtenQuantity.canonicalUnit) === writtenQuantity.canonicalNumber
1090
+ ) {
1091
+ return formatResolvedNumber(writtenQuantity.number) + writtenQuantity.unit;
1092
+ }
1046
1093
  const mergedTotals = mergeUnitsOfSameKind(totals);
1094
+ const hasUnrepresentableReciprocalUnit = [...mergedTotals.keys()].some((unit) => {
1095
+ return unit.startsWith('reciprocal:');
1096
+ });
1097
+ if (hasUnrepresentableReciprocalUnit) {
1098
+ return null;
1099
+ }
1047
1100
  const outputTerms = collectOutputTerms(mergedTotals);
1048
1101
  if (dropsPercentageBasis(mergedTotals, outputTerms)) {
1049
1102
  return null;
package/src/value/math.js CHANGED
@@ -453,6 +453,30 @@ const NON_NEGATIVE_PROPERTIES = new Set([
453
453
  'width'
454
454
  ]);
455
455
 
456
+ /**
457
+ * Properties whose computed math values must be integers.
458
+ *
459
+ * @type {Set<string>}
460
+ */
461
+ const INTEGER_VALUE_PROPERTIES = new Set([
462
+ 'column-count',
463
+ 'order',
464
+ 'orphans',
465
+ 'widows',
466
+ 'z-index'
467
+ ]);
468
+
469
+ /**
470
+ * The inclusive bounds that CSS applies to numeric property values after
471
+ * resolving a math expression.
472
+ *
473
+ * @type {Map<string, {minimum?: number, maximum?: number}>}
474
+ */
475
+ const NUMERIC_PROPERTY_RANGES = new Map([
476
+ ['column-count', { minimum: 1 }],
477
+ ['opacity', { minimum: 0, maximum: 1 }]
478
+ ]);
479
+
456
480
  /**
457
481
  * Matches a value written as a single negative number, with or without the
458
482
  * unit or percent sign that follows its digits.
@@ -461,6 +485,14 @@ const NON_NEGATIVE_PROPERTIES = new Set([
461
485
  */
462
486
  const NEGATIVE_NUMBER_VALUE = /^-(?:\d+|\d*\.\d+)(?:[a-z]+|%)?$/i;
463
487
 
488
+ /**
489
+ * Matches a resolved unitless number whose value can be rounded or clamped by
490
+ * a property's numeric range.
491
+ *
492
+ * @type {RegExp}
493
+ */
494
+ const UNIT_LESS_NUMBER_VALUE = /^[+-]?(?:\d+|\d*\.\d+)$/;
495
+
464
496
  /**
465
497
  * Clamps what a math function resolved to into the range its property
466
498
  * accepts. A negative length written out directly makes the declaration
@@ -475,11 +507,39 @@ const NEGATIVE_NUMBER_VALUE = /^-(?:\d+|\d*\.\d+)(?:[a-z]+|%)?$/i;
475
507
  * @return {string} The resolved value, held inside the property's range.
476
508
  */
477
509
  function clampResolvedValueToPropertyRange (resolvedValue, property) {
478
- const acceptsNegativeValues = !NON_NEGATIVE_PROPERTIES.has(String(property).toLowerCase());
479
- if (acceptsNegativeValues || !NEGATIVE_NUMBER_VALUE.test(resolvedValue)) {
510
+ const normalizedProperty = String(property).toLowerCase();
511
+ const acceptsNegativeValues = !NON_NEGATIVE_PROPERTIES.has(normalizedProperty);
512
+ const hasPropertyRange = NUMERIC_PROPERTY_RANGES.has(normalizedProperty);
513
+ const range = NUMERIC_PROPERTY_RANGES.get(normalizedProperty) || {};
514
+ const isNegativeNonNegativeValue = !acceptsNegativeValues && NEGATIVE_NUMBER_VALUE.test(resolvedValue);
515
+ if (isNegativeNonNegativeValue) {
516
+ const isUnitlessNumber = UNIT_LESS_NUMBER_VALUE.test(resolvedValue);
517
+ if (!isUnitlessNumber) {
518
+ return formatResolvedNumber(range.minimum === undefined ? 0 : range.minimum);
519
+ }
520
+ if (!hasPropertyRange && !INTEGER_VALUE_PROPERTIES.has(normalizedProperty)) {
521
+ return '0';
522
+ }
523
+ }
524
+ if (!hasPropertyRange && !INTEGER_VALUE_PROPERTIES.has(normalizedProperty)) {
480
525
  return resolvedValue;
481
526
  }
482
- return '0';
527
+ if (!UNIT_LESS_NUMBER_VALUE.test(resolvedValue)) {
528
+ return resolvedValue;
529
+ }
530
+
531
+ const shouldRoundToInteger = INTEGER_VALUE_PROPERTIES.has(normalizedProperty);
532
+ let numericValue = Number(resolvedValue);
533
+ if (shouldRoundToInteger) {
534
+ numericValue = Math.round(numericValue);
535
+ }
536
+ if (range.minimum !== undefined) {
537
+ numericValue = Math.max(range.minimum, numericValue);
538
+ }
539
+ if (range.maximum !== undefined) {
540
+ numericValue = Math.min(range.maximum, numericValue);
541
+ }
542
+ return formatResolvedNumber(numericValue);
483
543
  }
484
544
 
485
545
  /**
@@ -38,6 +38,7 @@ import {
38
38
  import {
39
39
  collapseShorthandParts,
40
40
  convertAbsoluteLengthToPx,
41
+ formatCompactNumber,
41
42
  formatResolvedNumber,
42
43
  hasSubstitutedParts,
43
44
  normalizeScaleComponent,
@@ -623,6 +624,40 @@ function isCssDigit (character) {
623
624
  return character >= '0' && character <= '9';
624
625
  }
625
626
 
627
+ /**
628
+ * Converts OKLCH lightness and chroma percentages when doing so is shorter,
629
+ * including the separator each spelling needs before the hue.
630
+ *
631
+ * @param {string} value The normalized arguments of an `oklch()` function.
632
+ * @return {string} The arguments with shorter percentage spellings.
633
+ */
634
+ function minifyOklchPercentages (value) {
635
+ const slashIndex = value.indexOf('/');
636
+ const componentsText = slashIndex === -1 ? value : value.slice(0, slashIndex);
637
+ const alphaText = slashIndex === -1 ? '' : value.slice(slashIndex + 1);
638
+ const components = componentsText.trim().split(' ');
639
+ if (components.length !== 3) {
640
+ return value;
641
+ }
642
+
643
+ let [lightness, chroma, hue] = components;
644
+ if (lightness.endsWith('%')) {
645
+ lightness = formatCompactNumber(Number.parseFloat(lightness) / 100);
646
+ }
647
+ if (chroma.endsWith('%')) {
648
+ const convertedChroma = formatCompactNumber(
649
+ Number.parseFloat(chroma) / 100 * OKLCH_CHROMA_PERCENT_REFERENCE
650
+ );
651
+ const convertedWithHueSeparator = convertedChroma.length + 1;
652
+ if (chroma.length < convertedWithHueSeparator) {
653
+ return [lightness, chroma + hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
654
+ }
655
+ chroma = convertedChroma;
656
+ }
657
+
658
+ return [lightness, chroma, hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
659
+ }
660
+
626
661
  /**
627
662
  * Converts `oklch()` colors that fall inside the sRGB gamut to their hex
628
663
  * equivalent when that is shorter. Out-of-gamut colors have no sRGB
@@ -747,6 +782,9 @@ function convertColorsToHex (val) {
747
782
  return integer;
748
783
  });
749
784
  minified = compactNegativeColorChannelSpacing(func, minified);
785
+ if (func.toLowerCase() === 'oklch') {
786
+ minified = minifyOklchPercentages(minified);
787
+ }
750
788
  return func + '(' + minified.trim() + ')';
751
789
  });
752
790