@thejaredwilcurt/csslop 0.0.46 → 0.0.48

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.48",
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.13.0"
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) {
@@ -76,6 +76,25 @@ function shortenNumericToken (tokenText) {
76
76
  if (shortened.includes('e')) {
77
77
  return tokenText;
78
78
  }
79
+ return preserveNumberType(shortened, unit);
80
+ }
81
+
82
+ /**
83
+ * Keeps a unitless number that was written with a decimal point typed as a
84
+ * `<number>`. Shedding its zeros could leave only digits, which reads as an
85
+ * `<integer>`, and a custom property substituted into, or registered with, an
86
+ * `<integer>` grammar would then accept a value it used to reject.
87
+ *
88
+ * @param {string} shortened The shortened number, without its unit.
89
+ * @param {string} unit The unit written after the number, if any.
90
+ * @return {string} The shortened token, still typed as a `<number>`.
91
+ */
92
+ function preserveNumberType (shortened, unit) {
93
+ // An optional sign followed by nothing but digits reads as an integer
94
+ const isIntegerSpelling = /^[+-]?\d+$/.test(shortened);
95
+ if (!unit && isIntegerSpelling) {
96
+ return shortened + '.0';
97
+ }
79
98
  return shortened + unit;
80
99
  }
81
100
 
@@ -2,7 +2,9 @@
2
2
  * @file Normalizes CSS selectors, `@media` queries, and `@supports` conditions by unescaping identifiers and collapsing whitespace.
3
3
  */
4
4
 
5
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
5
6
  import { resolveUnicodeEscape } from '../utilities.js';
7
+ import { shortenNumericText } from '../value/numbers.js';
6
8
 
7
9
  /**
8
10
  * Converts CSS Unicode escape sequences in an identifier to their literal characters, preserving control characters that must remain escaped.
@@ -161,7 +163,38 @@ function normalizeMedia (media) {
161
163
  return fullMatch;
162
164
  }
163
165
  );
164
- return compactLogicalOperators(media.trim());
166
+ return compactLogicalOperators(shortenRatioNumbers(media.trim()));
167
+ }
168
+
169
+ /**
170
+ * Shortens the numbers of every `<ratio>` in a media query, such as the
171
+ * `1000000/1` of `(min-aspect-ratio:1000000/1)`. A ratio is made of
172
+ * `<number>`s rather than `<integer>`s, so its terms may use scientific
173
+ * notation.
174
+ *
175
+ * @param {string} media A normalized `@media` query string.
176
+ * @return {string} The query with shorter ratio numbers.
177
+ */
178
+ function shortenRatioNumbers (media) {
179
+ const tokens = tokenizeCssValue(media);
180
+ const ratioTermIndexes = new Set();
181
+ tokens.forEach((token, index) => {
182
+ const isRatioSlash = (
183
+ token.text === '/' &&
184
+ tokens[index - 1]?.type === 'number' &&
185
+ tokens[index + 1]?.type === 'number'
186
+ );
187
+ if (isRatioSlash) {
188
+ ratioTermIndexes.add(index - 1);
189
+ ratioTermIndexes.add(index + 1);
190
+ }
191
+ });
192
+ return tokens.map((token, index) => {
193
+ if (ratioTermIndexes.has(index)) {
194
+ return shortenNumericText(token.text, true);
195
+ }
196
+ return token.text;
197
+ }).join('');
165
198
  }
166
199
 
167
200
  /**
@@ -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
  /**
@@ -9,6 +9,7 @@ import {
9
9
  skipQuotedString,
10
10
  startsUrlToken
11
11
  } from '../parser/source-search.js';
12
+ import { tokenizeCssValue } from '../parser/tokenizer.js';
12
13
  import { resolveUnicodeEscape } from '../utilities.js';
13
14
 
14
15
  import { evaluateColorMix } from './color-mix.js';
@@ -30,6 +31,12 @@ import {
30
31
  simplifyStandaloneCalc
31
32
  } from './math.js';
32
33
  import { namedColors } from './named-colors.js';
34
+ import {
35
+ formatShortestNumber,
36
+ parseNumericParts,
37
+ scaleByPowerOfTen,
38
+ shortenNumericText
39
+ } from './numbers.js';
33
40
  import { isQuotesNoneEquivalent } from './quotes.js';
34
41
  import {
35
42
  evaluateRelativeColor,
@@ -38,6 +45,7 @@ import {
38
45
  import {
39
46
  collapseShorthandParts,
40
47
  convertAbsoluteLengthToPx,
48
+ formatCompactNumber,
41
49
  formatResolvedNumber,
42
50
  hasSubstitutedParts,
43
51
  normalizeScaleComponent,
@@ -623,6 +631,40 @@ function isCssDigit (character) {
623
631
  return character >= '0' && character <= '9';
624
632
  }
625
633
 
634
+ /**
635
+ * Converts OKLCH lightness and chroma percentages when doing so is shorter,
636
+ * including the separator each spelling needs before the hue.
637
+ *
638
+ * @param {string} value The normalized arguments of an `oklch()` function.
639
+ * @return {string} The arguments with shorter percentage spellings.
640
+ */
641
+ function minifyOklchPercentages (value) {
642
+ const slashIndex = value.indexOf('/');
643
+ const componentsText = slashIndex === -1 ? value : value.slice(0, slashIndex);
644
+ const alphaText = slashIndex === -1 ? '' : value.slice(slashIndex + 1);
645
+ const components = componentsText.trim().split(' ');
646
+ if (components.length !== 3) {
647
+ return value;
648
+ }
649
+
650
+ let [lightness, chroma, hue] = components;
651
+ if (lightness.endsWith('%')) {
652
+ lightness = formatCompactNumber(Number.parseFloat(lightness) / 100);
653
+ }
654
+ if (chroma.endsWith('%')) {
655
+ const convertedChroma = formatCompactNumber(
656
+ Number.parseFloat(chroma) / 100 * OKLCH_CHROMA_PERCENT_REFERENCE
657
+ );
658
+ const convertedWithHueSeparator = convertedChroma.length + 1;
659
+ if (chroma.length < convertedWithHueSeparator) {
660
+ return [lightness, chroma + hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
661
+ }
662
+ chroma = convertedChroma;
663
+ }
664
+
665
+ return [lightness, chroma, hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
666
+ }
667
+
626
668
  /**
627
669
  * Converts `oklch()` colors that fall inside the sRGB gamut to their hex
628
670
  * equivalent when that is shorter. Out-of-gamut colors have no sRGB
@@ -747,6 +789,9 @@ function convertColorsToHex (val) {
747
789
  return integer;
748
790
  });
749
791
  minified = compactNegativeColorChannelSpacing(func, minified);
792
+ if (func.toLowerCase() === 'oklch') {
793
+ minified = minifyOklchPercentages(minified);
794
+ }
750
795
  return func + '(' + minified.trim() + ')';
751
796
  });
752
797
 
@@ -880,26 +925,89 @@ function convertBackgroundPositionKeywords (value) {
880
925
  }
881
926
 
882
927
  /**
883
- * Converts millisecond time values to seconds when the result is a
884
- * shorter string. Values of 0ms become 0s (time must keep a unit),
885
- * and values at or below 99ms stay in milliseconds (shorter representation).
928
+ * The power of ten that separates a time in seconds from the same time in
929
+ * milliseconds, keyed by the lowercased time unit.
886
930
  *
887
- * @param {string} value The CSS value string potentially containing ms time values.
888
- * @return {string} The value with eligible ms times converted to seconds.
889
- */
890
- function convertMillisecondsToSeconds (value) {
891
- // Match numeric values followed by the "ms" unit at word boundaries
892
- return value.replace(/\b(\d+(?:\.\d+)?)ms\b/gi, (match, amount) => {
893
- const milliseconds = parseFloat(amount);
894
- if (milliseconds === 0) {
895
- return '0s';
896
- }
897
- // Keep ms for values at or below 99ms (ms representation is shorter)
898
- if (milliseconds <= 99) {
899
- return match;
931
+ * @type {{[unit: string]: number}}
932
+ */
933
+ const TIME_UNIT_POWERS = {
934
+ s: 0,
935
+ ms: -3
936
+ };
937
+
938
+ /**
939
+ * Rewrites one time token in whichever of seconds or milliseconds spells it
940
+ * shortest, scientific notation included. Every conversion moves the exponent
941
+ * of the exact digits, so no precision is lost however large the time is.
942
+ * Seconds win a tie, and a zero time becomes `0s`.
943
+ *
944
+ * @param {string} text The text of a dimension token.
945
+ * @return {string} The shortest spelling of the same time.
946
+ */
947
+ function shortenTimeToken (text) {
948
+ const parts = parseNumericParts(text);
949
+ const writtenUnit = parts?.unit.toLowerCase();
950
+ if (!parts || !(writtenUnit in TIME_UNIT_POWERS)) {
951
+ return text;
952
+ }
953
+ if (!parts.digits) {
954
+ return '0s';
955
+ }
956
+ const writtenSpelling = shortenNumericText(text, true);
957
+ const secondsSpelling = formatShortestNumber(
958
+ scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit], 's'),
959
+ true
960
+ );
961
+ const millisecondsSpelling = formatShortestNumber(
962
+ scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit] - TIME_UNIT_POWERS.ms, 'ms'),
963
+ true
964
+ );
965
+ // Seconds are the conventional unit, so they win a tie with milliseconds
966
+ const convertedSpelling = millisecondsSpelling.length < secondsSpelling.length ? millisecondsSpelling : secondsSpelling;
967
+ return convertedSpelling.length <= writtenSpelling.length ? convertedSpelling : writtenSpelling;
968
+ }
969
+
970
+ /**
971
+ * Reports whether rewriting a number keeps its numeric type. A number written
972
+ * with a decimal point or an exponent has the `<number>` type, while one
973
+ * written with digits alone is an `<integer>`. A custom property can be
974
+ * substituted into, or registered with, a grammar that tells the two apart, so
975
+ * `100.0` cannot become `100` there.
976
+ *
977
+ * @param {string} originalNumber The number as it was written.
978
+ * @param {string} rewrittenNumber The number as it would be rewritten.
979
+ * @return {boolean} True when both spellings have the same type.
980
+ */
981
+ function keepsNumberType (originalNumber, rewrittenNumber) {
982
+ return isIntegerSpelling(originalNumber) === isIntegerSpelling(rewrittenNumber);
983
+ }
984
+
985
+ /**
986
+ * Reports whether a number is spelled as an `<integer>`: digits with an
987
+ * optional sign, and no decimal point or exponent.
988
+ *
989
+ * @param {string} number The number as written.
990
+ * @return {boolean} True when the spelling is an integer.
991
+ */
992
+ function isIntegerSpelling (number) {
993
+ // An optional sign followed by nothing but digits
994
+ return /^[+-]?\d+$/.test(number);
995
+ }
996
+
997
+ /**
998
+ * Converts each time in a value between seconds and milliseconds, whichever
999
+ * is the shorter string.
1000
+ *
1001
+ * @param {string} value The CSS value string potentially containing time values.
1002
+ * @return {string} The value with each time in its shortest unit.
1003
+ */
1004
+ function convertTimesToShortestUnit (value) {
1005
+ return tokenizeCssValue(value).map((token) => {
1006
+ if (token.type !== 'dimension') {
1007
+ return token.text;
900
1008
  }
901
- return roundCompactNumber(milliseconds / 1000) + 's';
902
- });
1009
+ return shortenTimeToken(token.text);
1010
+ }).join('');
903
1011
  }
904
1012
 
905
1013
  /**
@@ -1266,7 +1374,7 @@ function applyPropertyOptimizations (val, property, urlTokens) {
1266
1374
 
1267
1375
  // Convert ms to s for time-related properties when the seconds form is shorter
1268
1376
  if (TIME_PROPERTIES.has(property)) {
1269
- val = convertMillisecondsToSeconds(val);
1377
+ val = convertTimesToShortestUnit(val);
1270
1378
  }
1271
1379
 
1272
1380
  // Transition: remove " 0s" duration (transition: all 0s -> transition: all)
@@ -1578,12 +1686,17 @@ function computeMinifiedValue (declaration) {
1578
1686
  // character budget a number is written within
1579
1687
  if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1580
1688
  const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1581
- const scientificValue = isCustomProperty(declaration.property) ? val : shortenDimensionNumbers(val);
1689
+ const isCustom = isCustomProperty(declaration.property);
1690
+ const scientificValue = isCustom ? val : shortenDimensionNumbers(val, declaration.property);
1582
1691
  const roundedNumber = formatResolvedNumber(rawNumber);
1583
1692
  const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
1584
1693
  // Preserve tiny dimensions whose significant digits would otherwise be
1585
1694
  // rounded away before their shorter exponent form can be considered.
1586
- val = losesPrecision && scientificValue !== val ? scientificValue : roundedNumber + rawUnit;
1695
+ if (losesPrecision && scientificValue !== val) {
1696
+ val = scientificValue;
1697
+ } else if (!isCustom || keepsNumberType(rawNumber, roundedNumber)) {
1698
+ val = roundedNumber + rawUnit;
1699
+ }
1587
1700
  }
1588
1701
 
1589
1702
  val = lowercaseHexColors(val);
@@ -1632,7 +1745,7 @@ function computeMinifiedValue (declaration) {
1632
1745
  !isCustomProperty(declaration.property)
1633
1746
  );
1634
1747
  if (elidesRedundantSeparators) {
1635
- val = shortenDimensionNumbers(val);
1748
+ val = shortenDimensionNumbers(val, declaration.property);
1636
1749
  // A property that punctuates its own components already had the whitespace
1637
1750
  // after its closing parentheses taken out, and only the separators its
1638
1751
  // grammar still needs put back, so those are the ones left alone here.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @file Reads the numbers of numeric CSS tokens as exact digits and an exponent, so they can be rewritten in their shortest decimal or scientific spelling without any floating point rounding.
3
+ */
4
+
5
+ /**
6
+ * A number taken apart into the pieces its spelling is built from. The digits
7
+ * never start or end with a zero, so the same value always has the same parts.
8
+ *
9
+ * @typedef {object} NumericParts
10
+ * @property {string} sign The sign as written, kept so a `+` that tells the number apart from a neighboring token survives, or an empty string for zero.
11
+ * @property {string} digits The significant digits, or an empty string for zero.
12
+ * @property {number} exponent The power of ten the digits, read as an integer, are multiplied by.
13
+ * @property {string} unit The unit or percent sign that follows the number.
14
+ * @property {boolean} hasExponent Whether the original spelling used scientific notation.
15
+ */
16
+
17
+ /**
18
+ * Matches the text of a numeric token, capturing its sign, its coefficient,
19
+ * the exponent of its scientific notation when it has one, and the unit or
20
+ * percent sign written after it. An exponent needs digits after the `e`, so the
21
+ * `e` that starts a unit such as `em` is left to the unit.
22
+ *
23
+ * @type {RegExp}
24
+ */
25
+ const NUMERIC_TOKEN_PATTERN = /^([+-]?)(\d*\.\d+|\d+)(?:[eE]([+-]?\d+))?(%|[a-zA-Z_-][\w-]*)?$/;
26
+
27
+ /**
28
+ * Takes the text of a numeric token apart into its sign, significant digits,
29
+ * exponent, and unit.
30
+ *
31
+ * @param {string} text The text of a number, percentage, or dimension token.
32
+ * @return {NumericParts|null} The parts of the number, or null when the text is not a plain number.
33
+ */
34
+ function parseNumericParts (text) {
35
+ const match = text.match(NUMERIC_TOKEN_PATTERN);
36
+ if (!match) {
37
+ return null;
38
+ }
39
+ const [, sign, coefficient, writtenExponent, unit = ''] = match;
40
+ const decimalIndex = coefficient.indexOf('.');
41
+ const fractionalDigitCount = decimalIndex === -1 ? 0 : coefficient.length - decimalIndex - 1;
42
+ // Leading zeros carry no magnitude, so only the digits after them count
43
+ const unpaddedDigits = coefficient.replace('.', '').replace(/^0+/, '');
44
+ // Trailing zeros only scale the digits before them, so they move into the exponent
45
+ const digits = unpaddedDigits.replace(/0+$/, '');
46
+ const trailingZeroCount = unpaddedDigits.length - digits.length;
47
+ return {
48
+ sign: digits ? sign : '',
49
+ digits,
50
+ exponent: Number(writtenExponent || 0) + trailingZeroCount - fractionalDigitCount,
51
+ unit,
52
+ hasExponent: writtenExponent !== undefined
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Spells a number out in plain decimal notation, without a leading zero before
58
+ * its decimal point.
59
+ *
60
+ * @param {NumericParts} parts The parts of a non-zero number.
61
+ * @return {string} The decimal spelling, without the unit.
62
+ */
63
+ function formatDecimal (parts) {
64
+ const { sign, digits, exponent } = parts;
65
+ if (exponent >= 0) {
66
+ return sign + digits + '0'.repeat(exponent);
67
+ }
68
+ const decimalIndex = digits.length + exponent;
69
+ if (decimalIndex > 0) {
70
+ return sign + digits.slice(0, decimalIndex) + '.' + digits.slice(decimalIndex);
71
+ }
72
+ return sign + '.' + '0'.repeat(-decimalIndex) + digits;
73
+ }
74
+
75
+ /**
76
+ * Spells a number out in scientific notation, keeping every significant digit
77
+ * in an integer mantissa so no decimal point is ever needed.
78
+ *
79
+ * @param {NumericParts} parts The parts of a non-zero number.
80
+ * @return {string} The scientific spelling, without the unit.
81
+ */
82
+ function formatScientific (parts) {
83
+ if (parts.exponent === 0) {
84
+ return parts.sign + parts.digits;
85
+ }
86
+ return parts.sign + parts.digits + 'e' + parts.exponent;
87
+ }
88
+
89
+ /**
90
+ * Spells a number in the shortest way its grammar allows. Plain decimal
91
+ * notation wins a tie, since it is the more familiar spelling.
92
+ *
93
+ * @param {NumericParts} parts The parts of a non-zero number.
94
+ * @param {boolean} allowsScientific Whether the number may be written in scientific notation.
95
+ * @return {string} The shortest spelling, unit included.
96
+ */
97
+ function formatShortestNumber (parts, allowsScientific) {
98
+ const decimal = formatDecimal(parts);
99
+ if (!allowsScientific) {
100
+ return decimal + parts.unit;
101
+ }
102
+ const scientific = formatScientific(parts);
103
+ const shortest = scientific.length < decimal.length ? scientific : decimal;
104
+ return shortest + parts.unit;
105
+ }
106
+
107
+ /**
108
+ * Rewrites the text of a numeric token in its shortest spelling. A number
109
+ * written without an exponent only changes when the new spelling is shorter,
110
+ * while one written with an exponent is always normalized, which lowercases
111
+ * the `e`, drops a `+` sign, and folds trailing zeros into the exponent.
112
+ *
113
+ * @param {string} text The text of a number, percentage, or dimension token.
114
+ * @param {boolean} allowsScientific Whether the number may be written in scientific notation.
115
+ * @return {string} The shortest equivalent text.
116
+ */
117
+ function shortenNumericText (text, allowsScientific) {
118
+ const parts = parseNumericParts(text);
119
+ if (!parts || !parts.digits) {
120
+ return text;
121
+ }
122
+ if (parts.hasExponent && !allowsScientific) {
123
+ return text;
124
+ }
125
+ const shortest = formatShortestNumber(parts, allowsScientific);
126
+ if (parts.hasExponent || shortest.length < text.length) {
127
+ return shortest;
128
+ }
129
+ return text;
130
+ }
131
+
132
+ /**
133
+ * Scales a number by a power of ten by moving its exponent, which is exact for
134
+ * any number of digits.
135
+ *
136
+ * @param {NumericParts} parts The parts of the number.
137
+ * @param {number} power The power of ten to multiply by.
138
+ * @param {string} unit The unit the scaled number is written with.
139
+ * @return {NumericParts} The parts of the scaled number.
140
+ */
141
+ function scaleByPowerOfTen (parts, power, unit) {
142
+ return {
143
+ ...parts,
144
+ exponent: parts.exponent + power,
145
+ unit
146
+ };
147
+ }
148
+
149
+ export {
150
+ formatShortestNumber,
151
+ parseNumericParts,
152
+ scaleByPowerOfTen,
153
+ shortenNumericText
154
+ };
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { tokenizeCssValue } from '../parser/tokenizer.js';
6
6
 
7
+ import { shortenNumericText } from './numbers.js';
8
+
7
9
  /**
8
10
  * One token of a value, as the tokenizer reads it.
9
11
  *
@@ -142,36 +144,92 @@ function elideRedundantSeparators (value, elidesAfterSelfEndingTokens = true) {
142
144
  }
143
145
 
144
146
  /**
145
- * Shortens decimal dimension and percentage tokens without changing their
146
- * digits. Unitless numbers stay untouched because integer grammars reject
147
+ * Properties whose whole value is a unitless `<number>` rather than an
148
+ * `<integer>`, so a number written directly in them may use scientific
149
+ * notation.
150
+ *
151
+ * @type {Set<string>}
152
+ */
153
+ const NUMBER_VALUED_PROPERTIES = new Set([
154
+ 'animation-iteration-count',
155
+ 'fill-opacity',
156
+ 'flex-grow',
157
+ 'flex-shrink',
158
+ 'flood-opacity',
159
+ 'opacity',
160
+ 'stop-opacity',
161
+ 'stroke-miterlimit',
162
+ 'stroke-opacity',
163
+ 'tab-size'
164
+ ]);
165
+
166
+ /**
167
+ * Functions whose arguments are unitless `<number>`s rather than `<integer>`s,
168
+ * so the numbers written directly inside them may use scientific notation.
169
+ *
170
+ * @type {Set<string>}
171
+ */
172
+ const NUMBER_ARGUMENT_FUNCTIONS = new Set([
173
+ 'matrix',
174
+ 'matrix3d',
175
+ 'scale',
176
+ 'scale3d',
177
+ 'scalex',
178
+ 'scaley',
179
+ 'scalez'
180
+ ]);
181
+
182
+ /**
183
+ * Reports whether a unitless number token sits where only a `<number>` is
184
+ * accepted, either directly in a number-valued property or directly inside a
185
+ * function that takes number arguments.
186
+ *
187
+ * @param {string|null} enclosingFunction The lowercased name of the innermost function holding the token, or null at the top level.
188
+ * @param {string} property The lowercased property the value belongs to.
189
+ * @return {boolean} Whether the number may use scientific notation.
190
+ */
191
+ function isNumberTypedPosition (enclosingFunction, property) {
192
+ if (enclosingFunction === null) {
193
+ return NUMBER_VALUED_PROPERTIES.has(property);
194
+ }
195
+ return NUMBER_ARGUMENT_FUNCTIONS.has(enclosingFunction);
196
+ }
197
+
198
+ /**
199
+ * Shortens the numeric tokens of a value into their shortest spelling,
200
+ * including scientific notation. Dimensions and percentages are always
201
+ * `<number>` based, but a unitless number is only shortened where its grammar
202
+ * is known to take a `<number>`, because `<integer>` grammars reject
147
203
  * scientific notation.
148
204
  *
149
- * @param {string} value The CSS value.
150
- * @return {string} The value with shorter numeric spellings.
205
+ * @param {string} value The CSS value.
206
+ * @param {string} [property] The property the value belongs to.
207
+ * @return {string} The value with shorter numeric spellings.
151
208
  */
152
- function shortenDimensionNumbers (value) {
209
+ function shortenDimensionNumbers (value, property = '') {
210
+ const normalizedProperty = property.toLowerCase();
211
+ const functionStack = [];
153
212
  return tokenizeCssValue(value).map((token) => {
154
- if (token.type !== 'dimension' && token.type !== 'percentage') {
213
+ if (token.type === 'function') {
214
+ functionStack.push(token.text.slice(0, -1).toLowerCase());
155
215
  return token.text;
156
216
  }
157
- // Capture a decimal coefficient and its unit, excluding existing exponents.
158
- const match = token.text.match(/^([+-]?)(\d*\.\d+|\d+)([%a-z]+)$/i);
159
- if (!match) {
217
+ if (token.text === '(') {
218
+ functionStack.push('');
160
219
  return token.text;
161
220
  }
162
- const [, sign, coefficient, unit] = match;
163
- const decimalIndex = coefficient.indexOf('.');
164
- const fractionalDigits = decimalIndex === -1 ? 0 : coefficient.length - decimalIndex - 1;
165
- const digits = coefficient.replace('.', '');
166
- // Leading zeros carry no magnitude; trailing zeros move into the exponent.
167
- const significantDigits = digits.replace(/^0+/, '').replace(/0+$/, '');
168
- if (!significantDigits) {
221
+ if (token.text === ')') {
222
+ functionStack.pop();
169
223
  return token.text;
170
224
  }
171
- const trailingZeros = digits.length - digits.replace(/0+$/, '').length;
172
- const exponent = trailingZeros - fractionalDigits;
173
- const scientific = sign + significantDigits + 'e' + exponent + unit;
174
- return scientific.length < token.text.length ? scientific : token.text;
225
+ if (token.type === 'dimension' || token.type === 'percentage') {
226
+ return shortenNumericText(token.text, true);
227
+ }
228
+ const enclosingFunction = functionStack.length ? functionStack[functionStack.length - 1] : null;
229
+ if (token.type === 'number' && isNumberTypedPosition(enclosingFunction, normalizedProperty)) {
230
+ return shortenNumericText(token.text, true);
231
+ }
232
+ return token.text;
175
233
  }).join('');
176
234
  }
177
235