@thejaredwilcurt/csslop 0.0.47 → 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.47",
5
+ "version": "0.0.48",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "prestart": "node ./scripts/pre/start.js",
@@ -57,7 +57,7 @@
57
57
  "devEngines": {
58
58
  "runtime": {
59
59
  "name": "node",
60
- "version": "26.12.1"
60
+ "version": "26.13.0"
61
61
  },
62
62
  "packageManager": {
63
63
  "name": "npm",
@@ -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
  /**
@@ -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,
@@ -918,26 +925,89 @@ function convertBackgroundPositionKeywords (value) {
918
925
  }
919
926
 
920
927
  /**
921
- * Converts millisecond time values to seconds when the result is a
922
- * shorter string. Values of 0ms become 0s (time must keep a unit),
923
- * 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.
924
930
  *
925
- * @param {string} value The CSS value string potentially containing ms time values.
926
- * @return {string} The value with eligible ms times converted to seconds.
927
- */
928
- function convertMillisecondsToSeconds (value) {
929
- // Match numeric values followed by the "ms" unit at word boundaries
930
- return value.replace(/\b(\d+(?:\.\d+)?)ms\b/gi, (match, amount) => {
931
- const milliseconds = parseFloat(amount);
932
- if (milliseconds === 0) {
933
- return '0s';
934
- }
935
- // Keep ms for values at or below 99ms (ms representation is shorter)
936
- if (milliseconds <= 99) {
937
- 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;
938
1008
  }
939
- return roundCompactNumber(milliseconds / 1000) + 's';
940
- });
1009
+ return shortenTimeToken(token.text);
1010
+ }).join('');
941
1011
  }
942
1012
 
943
1013
  /**
@@ -1304,7 +1374,7 @@ function applyPropertyOptimizations (val, property, urlTokens) {
1304
1374
 
1305
1375
  // Convert ms to s for time-related properties when the seconds form is shorter
1306
1376
  if (TIME_PROPERTIES.has(property)) {
1307
- val = convertMillisecondsToSeconds(val);
1377
+ val = convertTimesToShortestUnit(val);
1308
1378
  }
1309
1379
 
1310
1380
  // Transition: remove " 0s" duration (transition: all 0s -> transition: all)
@@ -1616,12 +1686,17 @@ function computeMinifiedValue (declaration) {
1616
1686
  // character budget a number is written within
1617
1687
  if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1618
1688
  const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1619
- const scientificValue = isCustomProperty(declaration.property) ? val : shortenDimensionNumbers(val);
1689
+ const isCustom = isCustomProperty(declaration.property);
1690
+ const scientificValue = isCustom ? val : shortenDimensionNumbers(val, declaration.property);
1620
1691
  const roundedNumber = formatResolvedNumber(rawNumber);
1621
1692
  const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
1622
1693
  // Preserve tiny dimensions whose significant digits would otherwise be
1623
1694
  // rounded away before their shorter exponent form can be considered.
1624
- 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
+ }
1625
1700
  }
1626
1701
 
1627
1702
  val = lowercaseHexColors(val);
@@ -1670,7 +1745,7 @@ function computeMinifiedValue (declaration) {
1670
1745
  !isCustomProperty(declaration.property)
1671
1746
  );
1672
1747
  if (elidesRedundantSeparators) {
1673
- val = shortenDimensionNumbers(val);
1748
+ val = shortenDimensionNumbers(val, declaration.property);
1674
1749
  // A property that punctuates its own components already had the whitespace
1675
1750
  // after its closing parentheses taken out, and only the separators its
1676
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