@thejaredwilcurt/csslop 0.0.49 → 0.0.50

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.
Files changed (55) hide show
  1. package/README.md +0 -1
  2. package/package.json +1 -1
  3. package/src/declarations/border.js +1 -1
  4. package/src/index.js +7 -7
  5. package/src/rules/optimize/at-rules.js +113 -0
  6. package/src/rules/optimize/declaration-writes.js +70 -0
  7. package/src/rules/optimize/empty-rules.js +51 -0
  8. package/src/rules/optimize/expand-nesting.js +236 -0
  9. package/src/rules/optimize/factor-parents.js +343 -0
  10. package/src/rules/optimize/index.js +71 -0
  11. package/src/rules/optimize/merge-declarations.js +199 -0
  12. package/src/rules/optimize/merge-nested-rules.js +117 -0
  13. package/src/rules/optimize/merge-selectors.js +252 -0
  14. package/src/rules/optimize/nest-rules.js +202 -0
  15. package/src/rules/optimize/override-index.js +264 -0
  16. package/src/rules/optimize/prune-overridden.js +267 -0
  17. package/src/rules/optimize/selector-normalization.js +60 -0
  18. package/src/rules/optimize/specificity.js +280 -0
  19. package/src/{position-try.js → rules/position-try.js} +1 -1
  20. package/src/utilities.js +14 -0
  21. package/src/value/border.js +85 -0
  22. package/src/value/{colors.js → color/conversion.js} +3 -2
  23. package/src/value/color/functions.js +463 -0
  24. package/src/value/{light-dark.js → color/light-dark.js} +1 -1
  25. package/src/value/{color-mix.js → color/mix.js} +3 -3
  26. package/src/value/{relative-colors.js → color/relative.js} +3 -3
  27. package/src/value/color/shorten.js +93 -0
  28. package/src/value/gradients.js +1 -1
  29. package/src/value/hyphenate-limit-chars.js +72 -0
  30. package/src/value/initial-keywords.js +128 -0
  31. package/src/value/lengths.js +55 -0
  32. package/src/value/math/evaluate.js +36 -0
  33. package/src/value/math/fold.js +315 -0
  34. package/src/value/math/format.js +230 -0
  35. package/src/value/math/functions.js +302 -0
  36. package/src/value/math/index.js +219 -0
  37. package/src/value/math/parser.js +301 -0
  38. package/src/value/math/property-ranges.js +174 -0
  39. package/src/value/math/rewrite.js +222 -0
  40. package/src/value/math/tokenizer.js +160 -0
  41. package/src/value/math/unit-totals.js +194 -0
  42. package/src/value/minify.js +127 -1669
  43. package/src/value/numbers.js +28 -0
  44. package/src/value/positions.js +152 -0
  45. package/src/value/property-optimizations.js +485 -0
  46. package/src/value/segments.js +74 -0
  47. package/src/value/{svg-data-uri.js → svg/data-uri.js} +1 -1
  48. package/src/value/{svg-markup.js → svg/markup.js} +1 -1
  49. package/src/value/time.js +75 -0
  50. package/src/value/urls.js +242 -0
  51. package/src/value/whitespace.js +88 -0
  52. package/src/rules/optimize.js +0 -2314
  53. package/src/value/math-expression.js +0 -1156
  54. package/src/value/math.js +0 -886
  55. /package/src/value/{named-colors.js → color/named.js} +0 -0
@@ -2,1634 +2,105 @@
2
2
  * @file Minifies CSS declaration values by applying color conversion, math simplification, shorthand compression, and other property-specific optimizations.
3
3
  */
4
4
 
5
- import { isUnicodeCharset } from '../context.js';
6
- import {
7
- findMatchingParenthesis,
8
- skipParenthesizedGroup,
9
- skipQuotedString,
10
- splitTopLevelComponents,
11
- startsUrlToken
12
- } from '../parser/source-search.js';
13
- import { tokenizeCssValue } from '../parser/tokenizer.js';
14
- import { resolveUnicodeEscape } from '../utilities.js';
15
-
16
- import { evaluateColorMix } from './color-mix.js';
17
- import {
18
- convertLabToHex,
19
- convertOklabToHex,
20
- convertOklchToHex,
21
- hslToRgbChannels,
22
- hwbToRgbChannels,
23
- parseHex,
24
- rgbaToHex,
25
- shortestColor
26
- } from './colors.js';
27
- import { minifyFontShorthand } from './font.js';
28
- import { minifyGradients } from './gradients.js';
29
- import { simplifyEquivalentLightDarkFunctions } from './light-dark.js';
30
- import {
31
- normalizeMathFunctions,
32
- simplifyStandaloneCalc
33
- } from './math.js';
34
- import { namedColors } from './named-colors.js';
35
- import {
36
- formatShortestNumber,
37
- parseNumericParts,
38
- scaleByPowerOfTen,
39
- shortenNumericText
40
- } from './numbers.js';
41
- import { isQuotesNoneEquivalent } from './quotes.js';
42
- import {
43
- evaluateRelativeColor,
44
- minifyRelativeColorSyntax
45
- } from './relative-colors.js';
46
- import {
47
- collapseShorthandParts,
48
- convertAbsoluteLengthToPx,
49
- formatCompactNumber,
50
- formatResolvedNumber,
51
- hasSubstitutedParts,
52
- normalizeScaleComponent,
53
- parseAlphaString,
54
- parseAngleToDegrees,
55
- roundCompactNumber
56
- } from './shared.js';
57
- import { minifySvgDataUri } from './svg-data-uri.js';
58
- import {
59
- elideRedundantSeparators,
60
- shortenDimensionNumbers
61
- } from './tokens.js';
62
- import { minifyTransformValue } from './transforms.js';
63
- import { optimizeUnicodeRange } from './unicode-range.js';
64
-
65
- /**
66
- * Map of position-area two-keyword values to their single-keyword equivalents.
67
- * Per CSS spec, `center center` is redundant with `center`, etc.
68
- *
69
- * @type {{[key: string]: string}}
70
- */
71
- const POSITION_AREA_SHORTHANDS = {
72
- 'center center': 'center',
73
- 'top center': 'top',
74
- 'bottom center': 'bottom',
75
- 'center top': 'top',
76
- 'center bottom': 'bottom',
77
- 'left center': 'left',
78
- 'right center': 'right'
79
- };
80
-
81
- /**
82
- * Regex matching hex color tokens (#rgb, #rgba, #rrggbb, #rrggbbaa) and CSS named
83
- * color keywords. Hex patterns are ordered longest-first to avoid partial matches.
84
- * Named colors are sorted longest-first so longer names like `darkslategray` are
85
- * matched before shorter substrings. A named color only counts as a color keyword
86
- * when it is a complete identifier, so hyphens on either side disqualify it (this
87
- * keeps identifiers such as the custom property name in `var(--grey)` intact).
88
- *
89
- * @type {RegExp}
90
- */
91
- const COLOR_TOKEN_PATTERN = new RegExp(
92
- '#[0-9a-fA-F]{8}(?![0-9a-fA-F])|' +
93
- '#[0-9a-fA-F]{6}(?![0-9a-fA-F])|' +
94
- '#[0-9a-fA-F]{4}(?![0-9a-fA-F])|' +
95
- '#[0-9a-fA-F]{3}(?![0-9a-fA-F])|' +
96
- '(?<![\\w-])(?:' +
97
- Object.keys(namedColors).sort((a, b) => {
98
- return b.length - a.length;
99
- }).join('|') +
100
- ')(?![\\w-])',
101
- 'gi'
102
- );
103
-
104
- /**
105
- * Replaces every hex color and named color keyword in a CSS value segment with
106
- * the shortest equivalent representation, comparing full hex, shortened hex,
107
- * and any matching named color keyword.
108
- *
109
- * @param {string} segment A CSS value segment (outside strings and urls).
110
- * @param {boolean} rewritesEqualLengthSpelling Whether a spelling of the same length is worth switching to.
111
- * @return {string} The segment with all colors shortened to their minimal form.
112
- */
113
- function shortenColorValues (segment, rewritesEqualLengthSpelling = true) {
114
- // Match "color-mix(" as a whole word, case-insensitive
115
- const hasColorMix = /\bcolor-mix\(/i.test(segment);
116
- return segment.replace(COLOR_TOKEN_PATTERN, (match) => {
117
- let channels;
118
- if (match.startsWith('#')) {
119
- channels = parseHex(match);
120
- } else {
121
- if (hasColorMix) {
122
- return match;
123
- }
124
- const rgb = namedColors[match.toLowerCase()];
125
- if (rgb) {
126
- // transparent has alpha=0, all other named colors have alpha=1
127
- const alpha = match.toLowerCase() === 'transparent' ? 0 : 1;
128
- channels = [rgb[0], rgb[1], rgb[2], alpha];
129
- }
130
- }
131
- if (!channels) {
132
- return match;
133
- }
134
- const shortest = shortestColor(channels[0], channels[1], channels[2], channels[3]);
135
- if (!rewritesEqualLengthSpelling && shortest.length >= match.length) {
136
- return match;
137
- }
138
- return shortest;
139
- });
140
- }
141
-
142
- /**
143
- * @typedef {object} ValueSegment
144
- * @property {string} text The segment's slice of the value.
145
- * @property {boolean} isLiteral Whether the segment is a quoted string or a url() token.
146
- */
147
-
148
- /**
149
- * Splits a CSS value into literal and syntax segments. A quoted string and a
150
- * `url()` token are literals: they hold data rather than CSS syntax, so no pass
151
- * may rewrite what is inside them. Everything between the literals is syntax.
152
- *
153
- * @param {string} value The full CSS value string.
154
- * @return {Array} The value's segments, in order.
155
- */
156
- function splitValueSegments (value) {
157
- const segments = [];
158
- let index = 0;
159
-
160
- while (index < value.length) {
161
- if (value[index] === '"' || value[index] === '\'') {
162
- const end = skipQuotedString(value, index);
163
- segments.push({ text: value.slice(index, end), isLiteral: true });
164
- index = end;
165
- continue;
166
- }
167
-
168
- if (startsUrlToken(value, index)) {
169
- // The `url(` token opens its parenthesized group at its fourth character
170
- const end = skipParenthesizedGroup(value, index + 3);
171
- segments.push({ text: value.slice(index, end), isLiteral: true });
172
- index = end;
173
- continue;
174
- }
175
-
176
- const start = index;
177
- while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrlToken(value, index)) {
178
- index++;
179
- }
180
- segments.push({ text: value.slice(start, index), isLiteral: false });
181
- }
182
-
183
- return segments;
184
- }
185
-
186
- /**
187
- * Applies a replacer function only to segments of a CSS value that are outside quoted strings and url() functions, preserving those literal segments unchanged.
188
- *
189
- * @param {string} value The full CSS value string.
190
- * @param {function(string): string} replacer A function called with each non-string, non-url segment, returning the replacement string.
191
- * @return {string} The value with the replacer applied to all eligible segments.
192
- */
193
- function replaceOutsideStringsAndUrls (value, replacer) {
194
- return splitValueSegments(value).map((segment) => {
195
- if (segment.isLiteral) {
196
- return segment.text;
197
- }
198
- return replacer(segment.text);
199
- }).join('');
200
- }
201
-
202
- /**
203
- * Lowercases every hex color token in a CSS value, since uppercase hex digits
204
- * compress worse and are equivalent to their lowercase form.
205
- *
206
- * @param {string} value The CSS value string.
207
- * @return {string} The value with all hex color tokens lowercased.
208
- */
209
- function lowercaseHexColors (value) {
210
- return replaceOutsideStringsAndUrls(value, (segment) => {
211
- // Match hex color tokens of 3 to 8 hex digits
212
- return segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (hexColor) => {
213
- return hexColor.toLowerCase();
214
- });
215
- });
216
- }
217
-
218
- /**
219
- * Restores the whitespace that a math function requires before a `+` or a `-`
220
- * operator. Both operators have to be surrounded by whitespace to be read as
221
- * operators rather than as the sign of the term that follows, and the
222
- * whitespace before one that trails a closing parenthesis is removed together
223
- * with the rest of the parenthesis padding.
224
- *
225
- * @param {string} value The CSS value string with parenthesis padding removed.
226
- * @return {string} The value with the operator separator restored.
227
- */
228
- function restoreSpaceBeforeMathOperators (value) {
229
- return replaceOutsideStringsAndUrls(value, (segment) => {
230
- // Match a closing parenthesis immediately followed by a `+` or `-` operator
231
- return segment.replace(/\)(?=[+-])/g, ') ');
232
- });
233
- }
234
-
235
- /**
236
- * Escapes the characters an unquoted url() token cannot hold, so the path can
237
- * drop its quotes. A backslash already escaping a character is left alone, so
238
- * a path that arrived escaped is never escaped a second time.
239
- *
240
- * @param {string} path The url path, without surrounding quotes.
241
- * @return {string} The path with every bare space and parenthesis escaped.
242
- */
243
- function escapeUrlPathCharacters (path) {
244
- // Match each space and parenthesis that no escaping backslash precedes
245
- return path.replace(/(?<!\\)([ ()])/g, '\\$1');
246
- }
247
-
248
- /**
249
- * Chooses the shortest valid representation for the path inside a `url(...)`
250
- * token, weighing an unquoted form — its spaces and parentheses escaped —
251
- * against a quoted form. Quotation marks can only ever appear in the quoted
252
- * form, so a path holding one always keeps its wrapper.
253
- *
254
- * @param {string} path The resolved url path, without surrounding quotes.
255
- * @return {string} The shortest valid url() content string.
256
- */
257
- function formatUrlPath (path) {
258
- // Escape any embedded double quotes so the double-quoted wrapper stays valid
259
- const quotedForm = '"' + path.replace(/"/g, '\\"') + '"';
260
- // Match a quotation mark, which an unquoted url() token cannot hold
261
- if (/["']/.test(path)) {
262
- return quotedForm;
263
- }
264
- const unquotedForm = escapeUrlPathCharacters(path);
265
- // Each escaped character costs one backslash, so dropping the two quotation
266
- // marks pays off whenever at most one character needs escaping
267
- if (unquotedForm.length < quotedForm.length) {
268
- return unquotedForm;
269
- }
270
- return quotedForm;
271
- }
272
-
273
- /**
274
- * Produces the shortest valid contents for a single `url(...)` token from the
275
- * raw text between its parentheses, stripping a leading current-directory
276
- * indicator and normalizing quoting.
277
- *
278
- * @param {string} rawContent The trimmed text found between the url parentheses.
279
- * @return {string} The minified url() content.
280
- */
281
- function minifyUrlContent (rawContent) {
282
- const wasQuoted = rawContent.startsWith('"') || rawContent.startsWith('\'');
283
- let path = rawContent;
284
- if (wasQuoted) {
285
- const quote = rawContent[0];
286
- if (rawContent.length >= 2 && rawContent.endsWith(quote)) {
287
- path = rawContent.slice(1, -1);
288
- }
289
- }
290
-
291
- const minifiedSvgDataUri = minifySvgDataUri(path);
292
- if (minifiedSvgDataUri) {
293
- return minifiedSvgDataUri;
294
- }
295
-
296
- // Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
297
- path = path.replace(/^\.\//, '');
298
-
299
- return formatUrlPath(path);
300
- }
301
-
302
- /**
303
- * Builds a marker that cannot appear in a parsed CSS value. URL functions are
304
- * temporarily replaced with these markers while generic string operations run.
305
- *
306
- * @param {number} index The URL token index.
307
- * @return {string} The marker representing that URL token.
308
- */
309
- function createUrlTokenMarker (index) {
310
- return '\u0000csslop-url-' + index + '\u0000';
311
- }
312
-
313
- /**
314
- * Replaces URL tokens with stable markers so generic value normalization does
315
- * not alter data carried inside a URL.
316
- *
317
- * @param {string} value The CSS value to protect.
318
- * @return {object} The protected value and the original URL tokens.
319
- */
320
- function protectUrlTokens (value) {
321
- const urlTokens = [];
322
- let protectedValue = '';
323
- for (const segment of splitValueSegments(value)) {
324
- const isUrlToken = segment.isLiteral && startsUrlToken(segment.text, 0);
325
- if (isUrlToken) {
326
- protectedValue += createUrlTokenMarker(urlTokens.length);
327
- urlTokens.push(segment.text);
328
- } else {
329
- protectedValue += segment.text;
330
- }
331
- }
332
- return { value: protectedValue, urlTokens };
333
- }
334
-
335
- /**
336
- * Restores the URL tokens removed by `protectUrlTokens`.
337
- *
338
- * @param {string} value The value containing URL markers.
339
- * @param {Array} urlTokens The original URL token text.
340
- * @return {string} The value with URL tokens restored.
341
- */
342
- function restoreUrlTokens (value, urlTokens) {
343
- let restoredValue = value;
344
- for (let index = 0; index < urlTokens.length; index++) {
345
- const marker = createUrlTokenMarker(index);
346
- restoredValue = restoredValue.split(marker).join(urlTokens[index]);
347
- }
348
- return restoredValue;
349
- }
350
-
351
- /**
352
- * Removes whitespace after protected URL tokens. A CSS URL token closes itself
353
- * at `)`, so the following component remains distinguishable without a space.
354
- *
355
- * @param {string} value The protected CSS value.
356
- * @param {Array} urlTokens The original URL token text.
357
- * @return {string} The protected value without trailing URL spacing.
358
- */
359
- function removeWhitespaceAfterUrlTokens (value, urlTokens) {
360
- let compactValue = value;
361
- for (let index = 0; index < urlTokens.length; index++) {
362
- const marker = createUrlTokenMarker(index);
363
- const markerIndex = compactValue.indexOf(marker);
364
- if (markerIndex === -1) {
365
- continue;
366
- }
367
- const contentStart = markerIndex + marker.length;
368
- let nextContentIndex = contentStart;
369
- while (' \t\n\r\f'.includes(compactValue[nextContentIndex])) {
370
- nextContentIndex++;
371
- }
372
- compactValue = compactValue.slice(0, contentStart) + compactValue.slice(nextContentIndex);
373
- }
374
- return compactValue;
375
- }
376
-
377
- /**
378
- * Removes an explicit default background position directly after a protected
379
- * URL marker, where the ordinary close-parenthesis optimization cannot see it.
380
- *
381
- * @param {string} value The protected background value.
382
- * @param {Array} urlTokens The original URL token text.
383
- * @return {string} The value with redundant URL positions removed.
384
- */
385
- function removeDefaultBackgroundPositionsAfterUrlTokens (value, urlTokens) {
386
- let normalizedValue = value;
387
- for (let index = 0; index < urlTokens.length; index++) {
388
- const marker = createUrlTokenMarker(index);
389
- // Match a URL marker followed by the default 0 0 background-position pair.
390
- const defaultPositionPattern = new RegExp(
391
- marker + '\\s*0(?:%|px)?\\s+0(?:%|px)?(?=\\s|$)',
392
- 'g'
393
- );
394
- normalizedValue = normalizedValue.replace(defaultPositionPattern, marker);
395
- }
396
- return normalizedValue;
397
- }
398
-
399
- /**
400
- * Rewrites every `url(...)` token in a CSS value to its shortest valid form,
401
- * skipping any quoted strings so an embedded `url(` inside a string is ignored.
402
- *
403
- * @param {string} value The CSS value string potentially containing url() tokens.
404
- * @return {string} The value with all url() tokens minified.
405
- */
406
- function minifyUrls (value) {
407
- let result = '';
408
- let index = 0;
409
-
410
- const consumeQuoted = (start) => {
411
- const quote = value[start];
412
- let end = start + 1;
413
- while (end < value.length) {
414
- if (value[end] === '\\') {
415
- end += 2;
416
- continue;
417
- }
418
- if (value[end] === quote) {
419
- end++;
420
- break;
421
- }
422
- end++;
423
- }
424
- return end;
425
- };
426
-
427
- const startsUrl = (start) => {
428
- return value.slice(start, start + 4).toLowerCase() === 'url(';
429
- };
430
-
431
- while (index < value.length) {
432
- if (value[index] === '"' || value[index] === '\'') {
433
- const end = consumeQuoted(index);
434
- result += value.slice(index, end);
435
- index = end;
436
- continue;
437
- }
438
-
439
- if (startsUrl(index)) {
440
- const closingParenIndex = findMatchingParenthesis(value, index + 3);
441
- if (closingParenIndex === -1) {
442
- result += value.slice(index);
443
- break;
444
- }
445
- const inner = value.slice(index + 4, closingParenIndex).trim();
446
- result += 'url(' + minifyUrlContent(inner) + ')';
447
- index = closingParenIndex + 1;
448
- continue;
449
- }
450
-
451
- result += value[index];
452
- index++;
453
- }
454
-
455
- return result;
456
- }
457
-
458
- /**
459
- * Normalizes whitespace, quotes, and unicode escapes in a CSS value string.
460
- *
461
- * @param {string} val The raw CSS value string to normalize.
462
- * @param {string} property The CSS property name, used for context-aware quote handling.
463
- * @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
464
- */
465
- function normalizeWhitespaceAndQuotes (val, property) {
466
- const protectedUrls = protectUrlTokens(val);
467
- val = protectedUrls.value;
468
-
469
- // Unescape unicode (skip control characters — they must stay escaped in CSS strings).
470
- // Only resolve escapes when the charset is unicode-compatible (UTF-8/UTF-16 or default).
471
- if (isUnicodeCharset()) {
472
- val = val.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
473
- return resolveUnicodeEscape(hex) ?? match;
474
- });
475
- }
476
- // Normalize single-quoted strings to double-quoted
477
- val = val.replace(/'((?:[^'\\]|\\.)*?)'/g, '"$1"');
478
-
479
- // Remove space between string literals
480
- val = val.replace(/("(?:[^"\\]|\\.)*")\s+(?=")/g, '$1');
481
-
482
- // Whitespace minification
483
- val = val.replace(/\s*!\s*important/i, '!important');
484
- // val = val.replace(/\s*([+*/=])\s*/g, '$1');
485
- // Strip whitespace around commas
486
- val = val.replace(/\s*([,])\s*/g, '$1');
487
- // Match quoted strings (to skip them) or parentheses with surrounding whitespace (to strip whitespace)
488
- val = val.replace(/("[^"]*"|'[^']*')|\s*([()])\s*/g, (match, str, paren) => {
489
- if (str) {
490
- return str;
491
- }
492
- return paren;
493
- });
494
-
495
- // Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
496
- if (property !== 'content' && property !== 'font' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
497
- // Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
498
- val = val.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
499
- // Keep quotes around CSS generic font-family keywords — unquoted they mean something different
500
- if (property === 'font-family' && /^(?:serif|sans-serif|monospace|cursive|fantasy|system-ui|math|emoji|fangsong|ui-serif|ui-sans-serif|ui-monospace|ui-rounded)$/i.test(inner)) {
501
- return before + '"' + inner + '"';
502
- }
503
- return before + inner;
504
- });
505
- }
506
-
507
- val = removeWhitespaceAfterUrlTokens(val, protectedUrls.urlTokens);
508
- return restoreUrlTokens(val, protectedUrls.urlTokens);
509
- }
510
-
511
- /**
512
- * The OKLCH chroma value that `100%` resolves to, per CSS Color Level 4.
513
- *
514
- * @type {number}
515
- */
516
- const OKLCH_CHROMA_PERCENT_REFERENCE = 0.4;
517
-
518
- /**
519
- * Regex matching an `oklch()` function with three space-separated components
520
- * and an optional slash-delimited alpha. Lightness and chroma accept numbers
521
- * or percentages, hue accepts a number with an optional CSS angle unit, and
522
- * every component accepts the `none` keyword.
523
- *
524
- * @type {RegExp}
525
- */
526
- const OKLCH_FUNCTION_PATTERN = new RegExp(
527
- '\\boklch\\(\\s*' +
528
- '(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
529
- '(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
530
- '(none|-?(?:\\d+|\\d*\\.\\d+)(?:deg|grad|rad|turn)?)' +
531
- '(?:\\s*/\\s*(none|-?(?:\\d+|\\d*\\.\\d+)%?))?' +
532
- '\\s*\\)',
533
- 'gi'
534
- );
535
-
536
- /**
537
- * Parses an OKLCH lightness or chroma component into its numeric value.
538
- * Missing components (`none`) resolve to zero, and percentages are scaled
539
- * against the reference value for that component.
540
- *
541
- * @param {string} token The raw component token.
542
- * @param {number} percentReference The value that `100%` represents for this component.
543
- * @return {number|null} The numeric component value, or null when unparsable.
544
- */
545
- function parseOklchComponent (token, percentReference) {
546
- const normalized = token.trim().toLowerCase();
547
- if (normalized === 'none') {
548
- return 0;
549
- }
550
- const numeric = parseFloat(normalized);
551
- if (!Number.isFinite(numeric)) {
552
- return null;
553
- }
554
- if (normalized.endsWith('%')) {
555
- return numeric / 100 * percentReference;
556
- }
557
- return numeric;
558
- }
559
-
560
- /**
561
- * Parses an OKLCH hue component into degrees, treating `none` as zero.
562
- *
563
- * @param {string} token The raw hue token, optionally carrying an angle unit.
564
- * @return {number|null} The hue in degrees, or null when unparsable.
565
- */
566
- function parseOklchHue (token) {
567
- const normalized = token.trim().toLowerCase();
568
- if (normalized === 'none') {
569
- return 0;
570
- }
571
- return parseAngleToDegrees(normalized);
572
- }
573
-
574
- /**
575
- * Removes whitespace before a negative numeric channel in Lab-family colors.
576
- * A preceding numeric token already separates the sign from the prior channel.
577
- *
578
- * @param {string} functionName The name of the color function.
579
- * @param {string} value The minified function arguments.
580
- * @return {string} The arguments with redundant channel spacing removed.
581
- */
582
- function compactNegativeColorChannelSpacing (functionName, value) {
583
- const normalizedFunctionName = functionName.toLowerCase();
584
- if (!['lab', 'lch', 'oklab', 'oklch'].includes(normalizedFunctionName)) {
585
- return value;
586
- }
587
-
588
- let result = '';
589
- let nestingDepth = 0;
590
- for (let index = 0; index < value.length; index++) {
591
- const character = value[index];
592
- if (character === ' ' && nestingDepth === 0) {
593
- const previousCharacter = result[result.length - 1];
594
- const nextCharacter = value[index + 1];
595
- const characterAfterSign = value[index + 2];
596
- const characterAfterDecimal = value[index + 3];
597
- const previousEndsNumber = (
598
- isCssDigit(previousCharacter) ||
599
- previousCharacter === '%'
600
- );
601
- const nextStartsNegativeNumber = (
602
- nextCharacter === '-' &&
603
- (
604
- isCssDigit(characterAfterSign) ||
605
- (
606
- characterAfterSign === '.' &&
607
- isCssDigit(characterAfterDecimal)
608
- )
609
- )
610
- );
611
- if (previousEndsNumber && nextStartsNegativeNumber) {
612
- continue;
613
- }
614
- }
615
- result += character;
616
- if (character === '(') {
617
- nestingDepth++;
618
- } else if (character === ')') {
619
- nestingDepth--;
620
- }
621
- }
622
- return result;
623
- }
624
-
625
- /**
626
- * Checks whether a character is an ASCII decimal digit.
627
- *
628
- * @param {string} character The character to inspect.
629
- * @return {boolean} Whether the character is a digit.
630
- */
631
- function isCssDigit (character) {
632
- return character >= '0' && character <= '9';
633
- }
634
-
635
- /**
636
- * Converts OKLCH lightness and chroma percentages when doing so is shorter,
637
- * including the separator each spelling needs before the hue.
638
- *
639
- * @param {string} value The normalized arguments of an `oklch()` function.
640
- * @return {string} The arguments with shorter percentage spellings.
641
- */
642
- function minifyOklchPercentages (value) {
643
- const slashIndex = value.indexOf('/');
644
- const componentsText = slashIndex === -1 ? value : value.slice(0, slashIndex);
645
- const alphaText = slashIndex === -1 ? '' : value.slice(slashIndex + 1);
646
- const components = componentsText.trim().split(' ');
647
- if (components.length !== 3) {
648
- return value;
649
- }
650
-
651
- let [lightness, chroma, hue] = components;
652
- if (lightness.endsWith('%')) {
653
- lightness = formatCompactNumber(Number.parseFloat(lightness) / 100);
654
- }
655
- if (chroma.endsWith('%')) {
656
- const convertedChroma = formatCompactNumber(
657
- Number.parseFloat(chroma) / 100 * OKLCH_CHROMA_PERCENT_REFERENCE
658
- );
659
- const convertedWithHueSeparator = convertedChroma.length + 1;
660
- if (chroma.length < convertedWithHueSeparator) {
661
- return [lightness, chroma + hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
662
- }
663
- chroma = convertedChroma;
664
- }
665
-
666
- return [lightness, chroma, hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
667
- }
668
-
669
- /**
670
- * Converts `oklch()` colors that fall inside the sRGB gamut to their hex
671
- * equivalent when that is shorter. Out-of-gamut colors have no sRGB
672
- * representation, so they are left in their native color space.
673
- *
674
- * @param {string} value The CSS value string that may contain oklch() colors.
675
- * @return {string} The value with in-gamut oklch() colors replaced by hex.
676
- */
677
- function convertOklchFunctionsToHex (value) {
678
- return value.replace(OKLCH_FUNCTION_PATTERN, (match, lightnessToken, chromaToken, hueToken, alphaToken) => {
679
- const lightness = parseOklchComponent(lightnessToken, 1);
680
- const chroma = parseOklchComponent(chromaToken, OKLCH_CHROMA_PERCENT_REFERENCE);
681
- const hue = parseOklchHue(hueToken);
682
- if (lightness === null || chroma === null || hue === null) {
683
- return match;
684
- }
685
- const alpha = alphaToken?.trim().toLowerCase() === 'none' ? 0 : parseAlphaString(alphaToken);
686
- const hex = convertOklchToHex(lightness, chroma, hue, alpha);
687
- if (!hex) {
688
- return match; // out-of-gamut: keep native oklch form
689
- }
690
- if (hex.length < match.length) {
691
- return hex;
692
- }
693
- return match;
694
- });
695
- }
696
-
697
- /**
698
- * Converts CSS color functions (rgb, hsl, hwb, oklab, color-mix, etc.) to their
699
- * shortest hex equivalents and applies hex shortening.
700
- *
701
- * @param {string} val The CSS value string with potential color functions.
702
- * @return {string} The value with color functions converted to hex where shorter.
703
- */
704
- function convertColorsToHex (val) {
705
- // Evaluate color-mix() expressions (before space minification to avoid nested-paren issues)
706
- if (/\bcolor-mix\(/i.test(val)) {
707
- const result = evaluateColorMix(val);
708
- if (result) {
709
- val = result;
710
- }
711
- }
712
-
713
- // Handle color(from ...) relative color syntax (identity case)
714
- if (/\bcolor\(\s*from\b/i.test(val)) {
715
- const result = evaluateRelativeColor(val);
716
- if (result) {
717
- val = result;
718
- }
719
- }
720
-
721
- // Convert in-gamut lab() (CIE Lab, D50) to hex when it produces a shorter representation
722
- val = val.replace(/\blab\(\s*(-?(?:\d+|\d*\.\d+)%?)\s+(-?(?:\d+|\d*\.\d+)%?)\s+(-?(?:\d+|\d*\.\d+)%?)(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, lStr, aStr, bStr, alphaStr) => {
723
- const alpha = parseAlphaString(alphaStr);
724
- const l = parseFloat(lStr);
725
- const aNumber = parseFloat(aStr);
726
- const a = aStr.endsWith('%') ? aNumber * 1.25 : aNumber;
727
- const bNumber = parseFloat(bStr);
728
- const b = bStr.endsWith('%') ? bNumber * 1.25 : bNumber;
729
- const hex = convertLabToHex(l, a, b, alpha);
730
- if (!hex) {
731
- return match; // out-of-gamut: keep native lab form
732
- }
733
- if (hex.length < match.length) {
734
- return hex;
735
- }
736
- return match;
737
- });
738
-
739
- // Convert in-gamut oklch() to hex before precision rounding, so the full authored precision is used
740
- val = convertOklchFunctionsToHex(val);
741
-
742
- // Minify whitespace and numeric precision inside wide-gamut and functional color notations
743
- val = val.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, func, inner) => {
744
- // Collapse whitespace to single space
745
- let minified = inner.replace(/\s+/g, ' ');
746
- // Remove space after commas
747
- minified = minified.replace(/, /g, ',');
748
- // Remove spaces around slash separator (alpha delimiter)
749
- minified = minified.replace(/ \/ /g, '/');
750
- // Strip leading zeros from decimal numbers (e.g. 0.5 → .5)
751
- minified = minified.replace(/\b0+(\.[\d]+)/g, '$1');
752
- // Strip leading zeros from decimals preceded by a keyword (e.g. srgb 0.5 → srgb .5)
753
- minified = minified.replace(/([A-Za-z]) 0+(\.[\d]+)/g, '$1 $2');
754
- // Check if function uses a wide-gamut color space requiring higher numeric precision
755
- const useWidePrecision = func.toLowerCase() === 'color' && /\b(srgb-linear|xyz-d65|xyz-d50|xyz)\b/i.test(inner);
756
- // Round numbers with 3+ decimal places, using context-aware precision
757
- minified = minified.replace(/(^|[\s(,/-])(-?\d*\.\d{3,})/g, (match, before, num) => {
758
- const isAlpha = before === '/';
759
- const absoluteValue = Math.abs(parseFloat(num));
760
- // Check if function is a Lab/LCH color notation with a large channel value (less precision needed)
761
- const isLargeLabValue = /^(?:lch|lab|oklch|oklab)$/i.test(func) && absoluteValue >= 10;
762
- let precision;
763
- if (isAlpha) {
764
- precision = 3;
765
- } else if (isLargeLabValue) {
766
- precision = 1;
767
- } else if (useWidePrecision) {
768
- precision = 4;
769
- } else {
770
- precision = 3;
771
- }
772
- const factor = Math.pow(10, precision);
773
- const roundedNum = Math.round(parseFloat(num) * factor) / factor;
774
- // Strip trailing zeros and trailing decimal point from the rounded number
775
- let rounded = roundedNum.toFixed(precision).replace(/0+$/, '').replace(/\.$/, '');
776
- if (rounded.startsWith('0.')) {
777
- rounded = rounded.substring(1);
778
- }
779
- if (rounded.startsWith('-0.')) {
780
- rounded = '-' + rounded.substring(2);
781
- }
782
- return before + rounded;
783
- });
784
- // Remove trailing ".0" from numbers so integer channel values stay integer
785
- // (e.g. display-p3 1.0 0.0 0.0 becomes display-p3 1 0 0)
786
- minified = minified.replace(/(-?\d*)\.0\b/g, (match, integer) => {
787
- if (integer === '' || integer === '-' || integer === '-0') {
788
- return '0';
789
- }
790
- return integer;
791
- });
792
- minified = compactNegativeColorChannelSpacing(func, minified);
793
- if (func.toLowerCase() === 'oklch') {
794
- minified = minifyOklchPercentages(minified);
795
- }
796
- return func + '(' + minified.trim() + ')';
797
- });
798
-
799
- // Convert in-gamut oklab() to hex when it produces a shorter representation
800
- // A negative channel may touch the preceding number because its sign separates the CSS number tokens.
801
- 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) => {
802
- const alpha = parseAlphaString(alphaStr);
803
- const hex = convertOklabToHex(parseFloat(lStr), parseFloat(aStr), parseFloat(bStr), alpha);
804
- if (!hex) {
805
- return match; // out-of-gamut: keep native oklab form
806
- }
807
- if (hex.length < match.length) {
808
- return hex;
809
- }
810
- return match;
811
- });
812
-
813
- // Handle 'none' keyword in rgb/hsl functions (CSS Color Level 4: treated as 0)
814
- val = val.replace(/\b(rgba?|hsla?)\([^)]*\)/gi, (match) => {
815
- return match.replace(/\bnone\b/gi, '0');
816
- });
817
-
818
- // hwb() → hex (percent signs optional for whiteness/blackness, values always treated as percentages)
819
- val = val.replace(/\bhwb\(\s*(-?(?:\d+|\d*\.\d+))\s+((?:\d+|\d*\.\d+))%?\s+((?:\d+|\d*\.\d+))%?(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, hStr, wStr, bStr, aStr) => {
820
- const [r, g, b] = hwbToRgbChannels(parseFloat(hStr), parseFloat(wStr) / 100, parseFloat(bStr) / 100);
821
- return rgbaToHex(r, g, b, parseAlphaString(aStr));
822
- });
823
-
824
- // rgb()/rgba() space syntax → hex, case-insensitive (handles decimals and any alpha)
825
- val = val.replace(/\brgba?\(\s*(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, rStr, gStr, bStr, aStr) => {
826
- const r = Math.round(parseFloat(rStr));
827
- const g = Math.round(parseFloat(gStr));
828
- const b = Math.round(parseFloat(bStr));
829
- return rgbaToHex(r, g, b, parseAlphaString(aStr));
830
- });
831
-
832
- // hsl()/hsla() space syntax → hex, case-insensitive (percent signs optional, values always treated as percentages)
833
- val = val.replace(/\bhsla?\(\s*(-?(?:\d+|\d*\.\d+))\s+((?:\d+|\d*\.\d+))%?\s+((?:\d+|\d*\.\d+))%?(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, hStr, sStr, lStr, aStr) => {
834
- const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
835
- return rgbaToHex(r, g, b, parseAlphaString(aStr));
836
- });
837
-
838
- // rgba() comma syntax → hex, case-insensitive (handles any alpha)
839
- val = val.replace(/\brgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/gi, (match, rStr, gStr, bStr, aStr) => {
840
- const r = parseInt(rStr, 10);
841
- const g = parseInt(gStr, 10);
842
- const b = parseInt(bStr, 10);
843
- return rgbaToHex(r, g, b, parseAlphaString(aStr));
844
- });
845
-
846
- // hsla() comma syntax → hex, case-insensitive (percent signs optional, values always treated as percentages)
847
- val = val.replace(/\bhsla\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/gi, (match, hStr, sStr, lStr, aStr) => {
848
- const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
849
- return rgbaToHex(r, g, b, parseAlphaString(aStr));
850
- });
851
-
852
- // hsl()/hsla() comma syntax without alpha → hex, case-insensitive (percent signs optional, values always treated as percentages)
853
- val = val.replace(/\bhsla?\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*((?:\d+|\d*\.\d+))%?\s*\)/gi, (match, hStr, sStr, lStr) => {
854
- const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
855
- return rgbaToHex(r, g, b, 1);
856
- });
857
-
858
- // rgb() comma syntax → hex, case-insensitive
859
- val = val.replace(/\brgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/gi, (match, r, g, b) => {
860
- return rgbaToHex(parseInt(r, 10), parseInt(g, 10), parseInt(b, 10), 1);
861
- });
862
- return val;
863
- }
864
-
865
- /**
866
- * Map from background-position keyword names to their equivalent percent values.
867
- *
868
- * @type {{[key: string]: string}}
869
- */
870
- const POSITION_KEYWORD_TO_PERCENT = {
871
- left: '0',
872
- center: '50%',
873
- right: '100%',
874
- top: '0',
875
- bottom: '100%'
876
- };
877
-
878
- /**
879
- * Converts background-position keyword values to their shorter percent
880
- * equivalents when possible. Single Y-axis keywords (top, bottom) and
881
- * multi-value offset syntax (3 or 4 values) are left unchanged.
882
- *
883
- * @param {string} value The background-position value string.
884
- * @return {string} The value with keywords converted to percents where shorter.
885
- */
886
- function convertBackgroundPositionKeywords (value) {
887
- // Split on whitespace to determine the number of position parts
888
- const parts = value.split(/\s+/);
889
-
890
- // 3 or 4 value syntax uses keyword offsets, retain keywords
891
- if (parts.length >= 3) {
892
- return value;
893
- }
894
-
895
- if (parts.length === 1) {
896
- const keyword = parts[0].toLowerCase();
897
- // Y-axis-only keywords (top, bottom) can't be expressed as a single X-axis percent
898
- const isYAxisOnly = keyword === 'top' || keyword === 'bottom';
899
- if (isYAxisOnly) {
900
- return value;
901
- }
902
- if (POSITION_KEYWORD_TO_PERCENT[keyword] !== undefined) {
903
- return POSITION_KEYWORD_TO_PERCENT[keyword];
904
- }
905
- return value;
906
- }
907
-
908
- if (parts.length === 2) {
909
- const firstKeyword = parts[0].toLowerCase();
910
- const secondKeyword = parts[1].toLowerCase();
911
- const firstIsPositionKeyword = POSITION_KEYWORD_TO_PERCENT[firstKeyword] !== undefined;
912
- const secondIsPositionKeyword = POSITION_KEYWORD_TO_PERCENT[secondKeyword] !== undefined;
913
-
914
- if (firstIsPositionKeyword && secondIsPositionKeyword) {
915
- const firstPercent = POSITION_KEYWORD_TO_PERCENT[firstKeyword];
916
- const secondPercent = POSITION_KEYWORD_TO_PERCENT[secondKeyword];
917
- // Collapse to single value when Y is center (50%), since a single value defaults Y to 50%
918
- if (secondPercent === '50%') {
919
- return firstPercent;
920
- }
921
- return firstPercent + ' ' + secondPercent;
922
- }
923
- }
924
-
925
- return value;
926
- }
927
-
928
- /**
929
- * The power of ten that separates a time in seconds from the same time in
930
- * milliseconds, keyed by the lowercased time unit.
931
- *
932
- * @type {{[unit: string]: number}}
933
- */
934
- const TIME_UNIT_POWERS = {
935
- s: 0,
936
- ms: -3
937
- };
938
-
939
- /**
940
- * Rewrites one time token in whichever of seconds or milliseconds spells it
941
- * shortest, scientific notation included. Every conversion moves the exponent
942
- * of the exact digits, so no precision is lost however large the time is.
943
- * Seconds win a tie, and a zero time becomes `0s`.
944
- *
945
- * @param {string} text The text of a dimension token.
946
- * @return {string} The shortest spelling of the same time.
947
- */
948
- function shortenTimeToken (text) {
949
- const parts = parseNumericParts(text);
950
- const writtenUnit = parts?.unit.toLowerCase();
951
- if (!parts || !(writtenUnit in TIME_UNIT_POWERS)) {
952
- return text;
953
- }
954
- if (!parts.digits) {
955
- return '0s';
956
- }
957
- const writtenSpelling = shortenNumericText(text, true);
958
- const secondsSpelling = formatShortestNumber(
959
- scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit], 's'),
960
- true
961
- );
962
- const millisecondsSpelling = formatShortestNumber(
963
- scaleByPowerOfTen(parts, TIME_UNIT_POWERS[writtenUnit] - TIME_UNIT_POWERS.ms, 'ms'),
964
- true
965
- );
966
- // Seconds are the conventional unit, so they win a tie with milliseconds
967
- const convertedSpelling = millisecondsSpelling.length < secondsSpelling.length ? millisecondsSpelling : secondsSpelling;
968
- return convertedSpelling.length <= writtenSpelling.length ? convertedSpelling : writtenSpelling;
969
- }
970
-
971
- /**
972
- * Reports whether rewriting a number keeps its numeric type. A number written
973
- * with a decimal point or an exponent has the `<number>` type, while one
974
- * written with digits alone is an `<integer>`. A custom property can be
975
- * substituted into, or registered with, a grammar that tells the two apart, so
976
- * `100.0` cannot become `100` there.
977
- *
978
- * @param {string} originalNumber The number as it was written.
979
- * @param {string} rewrittenNumber The number as it would be rewritten.
980
- * @return {boolean} True when both spellings have the same type.
981
- */
982
- function keepsNumberType (originalNumber, rewrittenNumber) {
983
- return isIntegerSpelling(originalNumber) === isIntegerSpelling(rewrittenNumber);
984
- }
985
-
986
- /**
987
- * Reports whether a number is spelled as an `<integer>`: digits with an
988
- * optional sign, and no decimal point or exponent.
989
- *
990
- * @param {string} number The number as written.
991
- * @return {boolean} True when the spelling is an integer.
992
- */
993
- function isIntegerSpelling (number) {
994
- // An optional sign followed by nothing but digits
995
- return /^[+-]?\d+$/.test(number);
996
- }
997
-
998
- /**
999
- * Converts each time in a value between seconds and milliseconds, whichever
1000
- * is the shorter string.
1001
- *
1002
- * @param {string} value The CSS value string potentially containing time values.
1003
- * @return {string} The value with each time in its shortest unit.
1004
- */
1005
- function convertTimesToShortestUnit (value) {
1006
- return tokenizeCssValue(value).map((token) => {
1007
- if (token.type !== 'dimension') {
1008
- return token.text;
1009
- }
1010
- return shortenTimeToken(token.text);
1011
- }).join('');
1012
- }
1013
-
1014
- /**
1015
- * Matches an absolute CSS length token: an optionally signed number followed by
1016
- * an absolute length unit. The lookbehind rejects digits that belong to a larger
1017
- * identifier, such as the custom property name in `var(--size-2in)`, where the
1018
- * digits and unit do not form a value of their own.
1019
- *
1020
- * @type {RegExp}
1021
- */
1022
- const ABSOLUTE_LENGTH_PATTERN = /(?<![\w#.%-])(-?(?:\d+|\d*\.\d+))(pt|pc|in|cm|mm|q)\b/gi;
5
+ import { isCustomProperty } from '../utilities.js';
1023
6
 
1024
- /**
1025
- * The largest difference in pixels that a rounded conversion may introduce and
1026
- * still count as exact, which allows for binary floating point error without
1027
- * allowing a visible change to the rendered length.
1028
- *
1029
- * @type {number}
1030
- */
1031
- const PIXEL_ROUNDING_TOLERANCE = 1e-6;
7
+ import { convertColorsToHex } from './color/functions.js';
8
+ import { simplifyEquivalentLightDarkFunctions } from './color/light-dark.js';
9
+ import { minifyRelativeColorSyntax } from './color/relative.js';
10
+ import {
11
+ lowercaseHexColors,
12
+ shortenColorValues
13
+ } from './color/shorten.js';
14
+ import { minifyGradients } from './gradients.js';
15
+ import { shortenLengthyInitialKeyword } from './initial-keywords.js';
16
+ import {
17
+ normalizeMathFunctions,
18
+ simplifyStandaloneCalc
19
+ } from './math/index.js';
20
+ import { keepsNumberType } from './numbers.js';
21
+ import { POSITION_AREA_SHORTHANDS } from './positions.js';
22
+ import {
23
+ applyPropertyOptimizations,
24
+ PUNCTUATED_COMPONENT_PROPERTIES
25
+ } from './property-optimizations.js';
26
+ import { isQuotesNoneEquivalent } from './quotes.js';
27
+ import { replaceOutsideStringsAndUrls } from './segments.js';
28
+ import { formatResolvedNumber } from './shared.js';
29
+ import {
30
+ elideRedundantSeparators,
31
+ shortenDimensionNumbers
32
+ } from './tokens.js';
33
+ import { optimizeUnicodeRange } from './unicode-range.js';
34
+ import {
35
+ minifyUrls,
36
+ protectUrlTokens,
37
+ removeWhitespaceAfterUrlTokens,
38
+ restoreUrlTokens
39
+ } from './urls.js';
40
+ import { normalizeWhitespaceAndQuotes } from './whitespace.js';
1032
41
 
1033
42
  /**
1034
- * Converts absolute length values (pt, pc, in, cm, mm, Q) to their pixel
1035
- * equivalent when the conversion is exact and the pixel form is no longer than
1036
- * the original. Normalizing to px also improves compression by reducing the
1037
- * number of distinct unit strings in the output.
43
+ * Simplifies the `calc()` expressions that add a zero percentage to a
44
+ * non-zero one, dropping the additive identity term.
1038
45
  *
1039
- * @param {string} value A CSS value segment, outside strings and urls.
1040
- * @return {string} The segment with eligible absolute lengths converted to px.
1041
- */
1042
- function convertAbsoluteLengthsToPx (value) {
1043
- return value.replace(ABSOLUTE_LENGTH_PATTERN, (token, amount, unit) => {
1044
- const pixels = convertAbsoluteLengthToPx(amount, unit);
1045
- if (pixels === null) {
1046
- return token;
1047
- }
1048
- const converted = roundCompactNumber(pixels) + 'px';
1049
- const isExact = Math.abs(parseFloat(converted) - pixels) < PIXEL_ROUNDING_TOLERANCE;
1050
- if (!isExact || converted.length > token.length) {
1051
- return token;
46
+ * @param {string} value The CSS value string.
47
+ * @return {string} The value with zero-percent additive terms removed.
48
+ */
49
+ function removeZeroPercentTermsFromCalc (value) {
50
+ // Match a calc() whose contents hold no further parentheses
51
+ return value.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
52
+ // Collapse whitespace inside calc expression
53
+ const compactInner = inner.replace(/\s+/g, ' ').trim();
54
+ // Extract all percentage terms from the expression
55
+ const percentTerms = compactInner.match(/[+-]?\s*(?:\d*\.\d+|\d+)%/g) || [];
56
+ const hasNonZeroPercent = percentTerms.some((term) => {
57
+ return Math.abs(parseFloat(term)) > 0;
58
+ });
59
+ if (!hasNonZeroPercent) {
60
+ return match;
1052
61
  }
1053
- return converted;
62
+ // Remove trailing "+ 0%" and leading "0% +" additive identity terms
63
+ return 'calc(' + compactInner.replace(/\s*\+\s*0%(?=\s*$)/g, '').replace(/^0%\s*\+\s*/g, '').trim() + ')';
1054
64
  });
1055
65
  }
1056
66
 
1057
67
  /**
1058
- * Properties whose `initial` value is a number the browser resolves, so the
1059
- * keyword only ever shortens for the two that have a shorter written form.
1060
- *
1061
- * @type {Set<string>}
1062
- */
1063
- const NUMERIC_INITIAL_PROPERTIES = new Set([
1064
- 'opacity',
1065
- 'z-index',
1066
- 'flex-grow',
1067
- 'flex-shrink',
1068
- 'order',
1069
- 'line-height',
1070
- 'zoom'
1071
- ]);
1072
-
1073
- /**
1074
- * Properties whose `initial` value is zero.
1075
- *
1076
- * @type {Set<string>}
1077
- */
1078
- const ZERO_INITIAL_PROPERTIES = new Set(['margin', 'padding']);
1079
-
1080
- /**
1081
- * Properties whose `initial` value is `auto`.
1082
- *
1083
- * @type {Set<string>}
1084
- */
1085
- const AUTO_INITIAL_PROPERTIES = new Set([
1086
- 'min-width',
1087
- 'min-height',
1088
- 'hyphenate-limit-chars'
1089
- ]);
1090
-
1091
- /**
1092
- * Properties whose initial value is a single keyword longer than the global
1093
- * `initial` keyword itself. These replacements are applied when serializing
1094
- * declarations, because shorthand builders reconstruct their value from the
1095
- * actual longhand keywords, and a CSS-wide keyword such as `initial` is only
1096
- * valid as a declaration's entire value.
1097
- *
1098
- * @type {Map<string, string>}
1099
- */
1100
- const LENGTHY_INITIAL_KEYWORDS = new Map([
1101
- ['background-clip', 'border-box'],
1102
- ['background-origin', 'padding-box'],
1103
- ['border-collapse', 'separate'],
1104
- ['border-color', 'currentcolor'],
1105
- ['border-top-color', 'currentcolor'],
1106
- ['border-right-color', 'currentcolor'],
1107
- ['border-bottom-color', 'currentcolor'],
1108
- ['border-left-color', 'currentcolor'],
1109
- ['box-sizing', 'content-box'],
1110
- ['mask-clip', 'border-box'],
1111
- ['mask-origin', 'border-box'],
1112
- ['vertical-align', 'baseline'],
1113
- ['writing-mode', 'horizontal-tb']
1114
- ]);
1115
-
1116
- /**
1117
- * Properties whose value is a time, where a millisecond amount may be worth
1118
- * rewriting in seconds.
1119
- *
1120
- * @type {Set<string>}
1121
- */
1122
- const TIME_PROPERTIES = new Set([
1123
- 'transition',
1124
- 'transition-duration',
1125
- 'transition-delay',
1126
- 'animation',
1127
- 'animation-duration',
1128
- 'animation-delay'
1129
- ]);
1130
-
1131
- /**
1132
- * Properties whose grammar delimits its own components, either with punctuation
1133
- * (the `/` and `,` of the `background`, `mask`, and `src` layers) or by taking
1134
- * nothing but functions (`transform`). Their components stay readable without
1135
- * the whitespace that follows a closing parenthesis, so that whitespace is left
1136
- * elided and each of them restores only the separators its grammar still needs.
1137
- *
1138
- * @type {Set<string>}
1139
- */
1140
- const PUNCTUATED_COMPONENT_PROPERTIES = new Set([
1141
- 'background',
1142
- 'mask',
1143
- 'src',
1144
- 'transform'
1145
- ]);
1146
-
1147
- /**
1148
- * Reports whether a property is a custom property. A custom property holds an
1149
- * arbitrary token stream rather than a typed value, so every one of its tokens,
1150
- * whitespace included, is part of what it stores.
1151
- *
1152
- * @param {string} property The CSS property name.
1153
- * @return {boolean} Whether the property is a custom property.
1154
- */
1155
- function isCustomProperty (property) {
1156
- return Boolean(property) && property.startsWith('--');
1157
- }
1158
-
1159
- /**
1160
- * Matches one offset of a position: an edge keyword or a numeric distance.
1161
- *
1162
- * @type {string}
1163
- */
1164
- const POSITION_OFFSET_SOURCE = '(?:left|center|right|top|bottom|[+-]?(?:\\d+|\\d*\\.\\d+)(?:%|[a-z]+)?)';
1165
-
1166
- /**
1167
- * Matches a whole position component, which is one or two of those offsets.
1168
- *
1169
- * @type {string}
1170
- */
1171
- const POSITION_COMPONENT_SOURCE = '(' + POSITION_OFFSET_SOURCE + '(?:\\s+' + POSITION_OFFSET_SOURCE + ')?)';
1172
-
1173
- /**
1174
- * Matches everything a `url()` holds up to but not including its own closing
1175
- * parenthesis. A url is written without nested parentheses, so the first one
1176
- * that closes it is the end of the token.
1177
- *
1178
- * @type {string}
1179
- */
1180
- const URL_TOKEN_BODY_SOURCE = 'url\\([^()]*';
1181
-
1182
- /**
1183
- * Matches a close-paren that ends an image function, followed by a separated
1184
- * position that no slash follows. The parenthesis that ends a `url()` is
1185
- * skipped, since that one closes a token rather than a function.
1186
- *
1187
- * @type {RegExp}
1188
- */
1189
- const SEPARATED_FUNCTION_POSITION_PATTERN = new RegExp('(?<!' + URL_TOKEN_BODY_SOURCE + ')\\)\\s+' + POSITION_COMPONENT_SOURCE + '(?!\\/)', 'gi');
1190
-
1191
- /**
1192
- * Matches a `url()` separated from the position that follows it, which is the
1193
- * separator a url does not need.
1194
- *
1195
- * @type {RegExp}
1196
- */
1197
- const SEPARATED_URL_POSITION_PATTERN = new RegExp('(' + URL_TOKEN_BODY_SOURCE + '\\))\\s+' + POSITION_COMPONENT_SOURCE, 'gi');
1198
-
1199
- /**
1200
- * Matches a close-paren separated from a position that a slash follows, where
1201
- * the slash already delimits the position from the size behind it.
1202
- *
1203
- * @type {RegExp}
1204
- */
1205
- const SEPARATED_IMAGE_SIZE_POSITION_PATTERN = new RegExp('\\)\\s+' + POSITION_COMPONENT_SOURCE + '(?=\\/)', 'gi');
1206
-
1207
- /**
1208
- * Normalizes the separator between the image of a layer and the position that
1209
- * follows it. Both `background` and `mask` take a `<position> [ / <size> ]`
1210
- * component after their image. The closing parenthesis already delimits a
1211
- * function token from the position, and neither a position that a `/` follows
1212
- * nor one that follows a `url()` needs a separator either.
1213
- *
1214
- * @param {string} value The layered image value, with the parenthesis padding removed.
1215
- * @return {string} The value with the position separator normalized.
1216
- */
1217
- function normalizeImagePositionSeparator (value) {
1218
- const joinedToFunction = value.replace(SEPARATED_FUNCTION_POSITION_PATTERN, ')$1');
1219
- const joinedToUrl = joinedToFunction.replace(SEPARATED_URL_POSITION_PATTERN, '$1$2');
1220
- return joinedToUrl.replace(SEPARATED_IMAGE_SIZE_POSITION_PATTERN, ')$1');
1221
- }
1222
-
1223
- /**
1224
- * Matches the keywords that state a border line style.
1225
- *
1226
- * @type {string}
1227
- */
1228
- const BORDER_STYLE_KEYWORD_SOURCE = 'solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none';
1229
-
1230
- /**
1231
- * Matches the ways a border line width is stated: one of its three keywords, a
1232
- * length, or a math function that resolves to one.
1233
- *
1234
- * @type {string}
1235
- */
1236
- const BORDER_WIDTH_SOURCE = 'thin|medium|thick|[+-]?(?:\\d+|\\d*\\.\\d+)(?:e[+-]?\\d+)?[a-z]*|(?:calc|min|max|clamp)\\([^()]*\\)';
1237
-
1238
- /**
1239
- * Matches a `border` value that states its line style in front of its line
1240
- * width, with the two captured so they can be swapped.
1241
- *
1242
- * @type {RegExp}
1243
- */
1244
- const BORDER_STYLE_BEFORE_WIDTH_PATTERN = new RegExp(
1245
- '^(' + BORDER_STYLE_KEYWORD_SOURCE + ')\\s+(' + BORDER_WIDTH_SOURCE + ')(?=\\s|$)',
1246
- 'i'
1247
- );
1248
-
1249
- /**
1250
- * Rewrites a `border` value that leads with its line style into the canonical
1251
- * width-style-color order. The width has to be recognized before the two are
1252
- * swapped: `border` states its components in any order, so the component behind
1253
- * the style may be a color instead, as in `solid #8aadf4 1px`, and that one
1254
- * belongs behind the width rather than in front of it.
1255
- *
1256
- * @param {string} value The `border` value.
1257
- * @return {string} The value with its width stated before its style.
1258
- */
1259
- function reorderBorderWidthBeforeStyle (value) {
1260
- return value.replace(BORDER_STYLE_BEFORE_WIDTH_PATTERN, '$2 $1');
1261
- }
1262
-
1263
- /**
1264
- * Matches a trailing `!important` flag, so the components in front of it can be
1265
- * rewritten without the flag being mistaken for part of the last one.
68
+ * Matches a value that is a single number with an optional unit, capturing
69
+ * the number and the unit.
1266
70
  *
1267
71
  * @type {RegExp}
1268
72
  */
1269
- const IMPORTANT_SUFFIX_PATTERN = /!important$/i;
1270
-
1271
- /**
1272
- * Separates a value from the `!important` flag it may end with.
1273
- *
1274
- * @param {string} value The declaration value, possibly flagged important.
1275
- * @return {object} The components in front of the flag, and the flag itself or an empty string.
1276
- */
1277
- function splitImportantSuffix (value) {
1278
- const match = value.match(IMPORTANT_SUFFIX_PATTERN);
1279
- if (!match) {
1280
- return { components: value, importantSuffix: '' };
1281
- }
1282
- return {
1283
- components: value.slice(0, match.index).trim(),
1284
- importantSuffix: match[0]
1285
- };
1286
- }
1287
-
1288
- /**
1289
- * Collapses a `hyphenate-limit-chars` value to its shortest equivalent form.
1290
- * The property states up to three components: the shortest word that may be
1291
- * hyphenated, then the fewest characters allowed before a hyphen, then the
1292
- * fewest allowed after it. An unstated "before" component is read as `auto`,
1293
- * and an unstated "after" component repeats whatever "before" holds, so a
1294
- * trailing component that only restates the default it stands in for is
1295
- * redundant.
1296
- *
1297
- * @param {string} value The `hyphenate-limit-chars` value.
1298
- * @return {string} The value with its redundant trailing components dropped.
1299
- */
1300
- function collapseHyphenateLimitChars (value) {
1301
- const {
1302
- components,
1303
- importantSuffix
1304
- } = splitImportantSuffix(value);
1305
- const parts = components.split(' ').filter(Boolean);
1306
- if (hasSubstitutedParts(parts)) {
1307
- return value;
1308
- }
1309
- const isAfterMatchingBefore = (
1310
- parts.length === 3 &&
1311
- parts[2].toLowerCase() === parts[1].toLowerCase()
1312
- );
1313
- if (isAfterMatchingBefore) {
1314
- parts.pop();
1315
- }
1316
- const isBeforeLeftAtDefault = (
1317
- parts.length === 2 &&
1318
- parts[1].toLowerCase() === 'auto'
1319
- );
1320
- if (isBeforeLeftAtDefault) {
1321
- parts.pop();
1322
- }
1323
- return parts.join(' ') + importantSuffix;
1324
- }
73
+ const STANDALONE_NUMBER_PATTERN = /^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i;
1325
74
 
1326
75
  /**
1327
- * Minifies a declaration's value for output, rewriting its initial keyword to
1328
- * the shorter global `initial` keyword after shorthand assembly. Both are
1329
- * equivalent, but `initial` is shorter than these particular keywords and,
1330
- * being repeated across properties, gives gzip more opportunities to deduplicate it.
76
+ * Rounds a value that is a standalone number with an optional unit into the
77
+ * character budget a number is written within. A custom property only takes
78
+ * the rounding when it keeps the number's type, and a tiny dimension whose
79
+ * significant digits rounding would lose keeps its exponent form instead.
1331
80
  *
1332
- * @param {object} declaration The CSS declaration object with property and value fields.
1333
- * @return {string} The minified value with its initial keyword shortened to `initial` when that saves bytes.
81
+ * @param {string} value The CSS value string.
82
+ * @param {string} property The CSS property name.
83
+ * @return {string} The rounded value, or the value unchanged when it is not a standalone number.
1334
84
  */
1335
- function minifyValueForOutput (declaration) {
1336
- const value = minifyValue(declaration);
1337
- const property = declaration.property;
1338
- // Custom properties hold an arbitrary token stream that JavaScript can read
1339
- // back, so their keywords are left exactly as written.
1340
- if (isCustomProperty(property)) {
1341
- return value;
1342
- }
1343
- // A keyword spelling the property's own initial value is shorter as the
1344
- // global keyword (box-sizing: content-box -> box-sizing: initial)
1345
- const initialKeyword = LENGTHY_INITIAL_KEYWORDS.get(property?.toLowerCase());
1346
- if (!initialKeyword || 'initial'.length >= initialKeyword.length) {
1347
- return value;
1348
- }
1349
- // A trailing `!important` rides along inside the value, so the keyword
1350
- // comparison and the rewrite only concern what sits in front of it.
1351
- const isImportant = value.endsWith('!important');
1352
- const keyword = isImportant ? value.slice(0, value.length - '!important'.length) : value;
1353
- if (keyword.toLowerCase() !== initialKeyword) {
85
+ function roundStandaloneNumber (value, property) {
86
+ const standaloneNumber = value.match(STANDALONE_NUMBER_PATTERN);
87
+ if (!standaloneNumber) {
1354
88
  return value;
1355
89
  }
1356
- return 'initial' + (isImportant ? '!important' : '');
1357
- }
1358
-
1359
- /**
1360
- * Applies property-specific optimizations to a CSS value (transition, flex, font,
1361
- * background, display, scale, border-radius, shorthand collapsing, etc.).
1362
- *
1363
- * @param {string} val The CSS value string after generic minification.
1364
- * @param {string} property The CSS property name.
1365
- * @param {Array} urlTokens URL tokens currently represented by markers.
1366
- * @return {string} The value with property-specific optimizations applied.
1367
- */
1368
- function applyPropertyOptimizations (val, property, urlTokens) {
1369
- if (property === 'font-weight' && isUnicodeCharset()) {
1370
- // Replace font-weight keyword "bold" with its numeric equivalent
1371
- val = val.replace(/\bbold\b/gi, '700');
1372
- // Replace font-weight keyword "normal" with its numeric equivalent
1373
- val = val.replace(/\bnormal\b/gi, '400');
1374
- }
1375
-
1376
- // Convert ms to s for time-related properties when the seconds form is shorter
1377
- if (TIME_PROPERTIES.has(property)) {
1378
- val = convertTimesToShortestUnit(val);
1379
- }
1380
-
1381
- // Transition: remove " 0s" duration (transition: all 0s -> transition: all)
1382
- if (property === 'transition') {
1383
- // Remove zero-second duration from transition shorthand
1384
- val = val.replace(/\s+0s/g, ' ');
1385
- // Remove leading zero-pixel value from transition shorthand
1386
- val = val.replace(/^0px\s*/, '');
1387
- // Replace cubic-bezier functions with their equivalent named timing-function keywords
1388
- val = val.replace(/cubic-bezier\(0,0,1,1\)/g, 'linear');
1389
- val = val.replace(/cubic-bezier\(\.25,\.1,\.25,1\)/g, 'ease');
1390
- val = val.replace(/cubic-bezier\(\.42,0,1,1\)/g, 'ease-in');
1391
- val = val.replace(/cubic-bezier\(0,0,\.58,1\)/g, 'ease-out');
1392
- val = val.trim();
1393
- }
1394
-
1395
- if (property === 'animation') {
1396
- // Replace steps() functions with their equivalent named timing-function keywords
1397
- val = val.replace(/steps\(1,start\)/g, 'step-start');
1398
- val = val.replace(/steps\(1,end\)/g, 'step-end');
1399
- // Restore space between step-start/step-end keyword and following token
1400
- // (the parenthesis whitespace stripping removes the space before replacement)
1401
- val = val.replace(/(step-start|step-end)(?=[a-zA-Z0-9#-])/g, '$1 ');
1402
- }
1403
-
1404
- // Flex: remove " 0px" from flex shorthand (flex: 0 0 0px -> flex: 0 0)
1405
- if (property === 'flex') {
1406
- // Remove trailing zero-pixel basis value
1407
- val = val.replace(/\s+0px/g, ' ');
1408
- // Remove leading zero-pixel value
1409
- val = val.replace(/^0px\s*/, '');
1410
- // Remove an explicit zero basis: a trailing zero is only a basis when grow
1411
- // and shrink precede it, and the shorthand already reads an unwritten basis
1412
- // as 0. In `0 0` the trailing zero is the shrink, which the shorthand
1413
- // defaults to 1 instead, so dropping it would size the element differently.
1414
- val = val.replace(/^(\S+\s+\S+)\s+0$/, '$1');
1415
- // Remove standalone zero-pixel value
1416
- val = val.replace(/^0px$/, '');
1417
- val = val.trim();
1418
- }
1419
-
1420
- // Initial values
1421
- if (val === 'initial') {
1422
- if (NUMERIC_INITIAL_PROPERTIES.has(property)) {
1423
- // Just leaving them or mapping some: opacity: initial -> opacity: 1
1424
- if (property === 'opacity') {
1425
- val = '1';
1426
- }
1427
- if (property === 'z-index') {
1428
- val = 'auto';
1429
- }
1430
- }
1431
- if (ZERO_INITIAL_PROPERTIES.has(property)) {
1432
- val = '0';
1433
- }
1434
- if (AUTO_INITIAL_PROPERTIES.has(property)) {
1435
- val = 'auto';
1436
- }
1437
- // background-color: initial should become #0000 (transparent)
1438
- if (property === 'background-color') {
1439
- val = '#0000';
1440
- }
1441
- }
1442
-
1443
- if (property === 'background' && val === 'none') {
1444
- val = '0 0';
1445
- }
1446
-
1447
- if (property === 'display') {
1448
- if (val === 'block flow') {
1449
- val = 'block';
1450
- }
1451
- if (val === 'inline flow-root') {
1452
- val = 'inline-block';
1453
- }
1454
- }
1455
-
1456
- if (property === 'background-repeat') {
1457
- if (val === 'no-repeat no-repeat') {
1458
- val = 'no-repeat';
1459
- }
1460
- if (val === 'repeat no-repeat') {
1461
- val = 'repeat-x';
1462
- }
1463
- if (val === 'no-repeat repeat') {
1464
- val = 'repeat-y';
1465
- }
1466
- }
1467
-
1468
- if (property === 'background-position') {
1469
- val = convertBackgroundPositionKeywords(val);
1470
- }
1471
-
1472
- if (property === 'border') {
1473
- val = reorderBorderWidthBeforeStyle(val);
1474
- }
1475
-
1476
- if (property === 'flex-flow') {
1477
- // Reorder flex-flow from wrap-direction to direction-wrap (canonical order)
1478
- val = val.replace(/^(nowrap|wrap|wrap-reverse)\s+(row|row-reverse|column|column-reverse)$/i, '$2 $1');
1479
- }
1480
-
1481
- if (property === 'font-family') {
1482
- // Strip quotes from simple multi-word font family names that don't require quoting
1483
- val = val.replace(/"([A-Za-z0-9-]+(?: [A-Za-z0-9-]+)+)"/g, '$1');
1484
- const seenFamilies = new Set();
1485
- val = val.split(',').map((part) => {
1486
- return part.trim();
1487
- }).filter(Boolean).filter((part) => {
1488
- const lowercaseName = part.toLowerCase();
1489
- if (seenFamilies.has(lowercaseName)) {
1490
- return false;
1491
- }
1492
- seenFamilies.add(lowercaseName);
1493
- return true;
1494
- }).join(',');
1495
- }
1496
-
1497
- if (property === 'grid-template-areas') {
1498
- // Normalize each quoted grid-template-areas row string
1499
- val = val.replace(/"([^"]*)"/g, (match, inner) => {
1500
- // Collapse whitespace to single space within grid row
1501
- let normalized = inner.replace(/\s+/g, ' ').trim();
1502
- // Collapse consecutive dots (null cell tokens) to a single dot
1503
- normalized = normalized.replace(/(^| )\.{2,}(?= |$)/g, '$1.');
1504
- return '"' + normalized + '"';
1505
- });
1506
- }
1507
-
1508
- // Custom properties hold an arbitrary token stream rather than a typed value,
1509
- // so a unit-like token in one is not necessarily a length.
1510
- if (!isCustomProperty(property)) {
1511
- val = replaceOutsideStringsAndUrls(val, convertAbsoluteLengthsToPx);
1512
- }
1513
-
1514
- if (property === 'syntax') {
1515
- // Remove whitespace around pipe separators in @property syntax descriptors
1516
- val = val.replace(/\s*\|\s*/g, '|');
1517
- }
1518
-
1519
- // Simplify clamp() where all three arguments are identical (e.g. clamp(1rem,1rem,1rem) → 1rem)
1520
- val = val.replace(/\bclamp\(([^,]+),\1,\1\)/gi, '$1');
1521
-
1522
- // Convert display-p3 neutral grays to sRGB (equal channels are identical across gamuts)
1523
- val = val.replace(/\bcolor\(display-p3\s+([\d.]+)\s+\1\s+\1(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, channelStr, alphaStr) => {
1524
- const channelValue = parseFloat(channelStr);
1525
- const r = Math.round(channelValue * 255);
1526
- return rgbaToHex(r, r, r, parseAlphaString(alphaStr));
1527
- });
1528
-
1529
- // Font families may contain color names, but never color values.
1530
- if (property !== 'font' && property !== 'font-family') {
1531
- val = replaceOutsideStringsAndUrls(val, (segment) => {
1532
- return shortenColorValues(segment);
1533
- });
1534
- }
1535
-
1536
- if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
1537
- // Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
1538
- val = val.replace(/\)(?=[0-9a-zA-Z#-])/g, ') ');
1539
- }
1540
- val = restoreSpaceBeforeMathOperators(val);
1541
-
1542
- if (property === 'font') {
1543
- val = minifyFontShorthand(val);
1544
- }
1545
-
1546
- if (property === 'background' && val !== 'none') {
1547
- let normalized = val
1548
- // Remove default "0 0" background-position values
1549
- .replace(/(?:^|\s)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ' ')
1550
- // Remove "0 0" background-position after a close-paren (e.g. after url())
1551
- .replace(/\)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ') ')
1552
- // Remove default "repeat" background-repeat keyword (excluding compound values like no-repeat)
1553
- .replace(/(?<!-)\brepeat\b(?!-)/g, ' ')
1554
- // Remove default "scroll" background-attachment keyword
1555
- .replace(/\bscroll\b/g, ' ')
1556
- // Remove default "none" background-image keyword
1557
- .replace(/\bnone\b/g, ' ')
1558
- // Collapse whitespace to single space
1559
- .replace(/\s+/g, ' ')
1560
- .trim();
1561
- normalized = removeDefaultBackgroundPositionsAfterUrlTokens(normalized, urlTokens);
1562
- if (normalized) {
1563
- val = normalized;
1564
- }
1565
- val = normalizeImagePositionSeparator(val);
1566
- }
1567
-
1568
- if (property === 'mask') {
1569
- val = normalizeImagePositionSeparator(val);
1570
- }
1571
-
1572
- if (property === 'border') {
1573
- // Remove default "medium" border-width keyword
1574
- val = val.replace(/\bmedium\s+/g, '');
1575
- }
1576
-
1577
- if (property === 'outline') {
1578
- // Restore missing space between outline-style and a color keyword when they are adjacent
1579
- val = val.replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)(red|green|olive|tan|transparent)\b/g, '$1 $2');
1580
- }
1581
-
1582
- if (property === 'transform') {
1583
- val = minifyTransformValue(val);
1584
- // Remove whitespace between consecutive transform functions
1585
- val = val.replace(/\)\s+(?=[a-z-]+\()/gi, ')');
1586
- }
1587
-
1588
- if (property === 'scale') {
1589
- // Split scale value on whitespace into individual axis components
1590
- const parts = val.split(/\s+/).filter(Boolean).map(normalizeScaleComponent);
1591
- if (parts.length === 2 && parts[0] === parts[1]) {
1592
- val = parts[0];
1593
- } else if (parts.length === 3 && parts[2] === '1') {
1594
- if (parts[0] === parts[1]) {
1595
- val = parts[0];
1596
- } else {
1597
- val = parts[0] + ' ' + parts[1];
1598
- }
1599
- } else {
1600
- val = parts.join(' ');
1601
- }
1602
- }
1603
-
1604
- // Replace multiple spaces
1605
- val = val.replace(/\s+/g, ' ');
1606
-
1607
- // Shorthands: margin, padding, border-width, border-style, border-color, inset
1608
- // Check if property supports box-model shorthand collapsing (4 → 3 → 2 → 1 values)
1609
- if (/^(margin|padding|inset|border-width|border-style|border-color|gap|overflow)$/.test(property)) {
1610
- // Split components instead of splitting on spaces, so values that legally
1611
- // touch, like the adjacent hex colors of `#ddd#ddd`, count separately
1612
- val = collapseShorthandParts(splitTopLevelComponents(val))
1613
- .join(' ')
1614
- // A hash token may touch the component before it, so the separator space is not needed
1615
- .replace(/ #/g, '#');
1616
- }
1617
-
1618
- if (property === 'hyphenate-limit-chars') {
1619
- val = collapseHyphenateLimitChars(val);
90
+ const [, rawNumber, rawUnit = ''] = standaloneNumber;
91
+ const isCustom = isCustomProperty(property);
92
+ const scientificValue = isCustom ? value : shortenDimensionNumbers(value, property);
93
+ const roundedNumber = formatResolvedNumber(rawNumber);
94
+ const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
95
+ // Preserve tiny dimensions whose significant digits would otherwise be
96
+ // rounded away before their shorter exponent form can be considered.
97
+ if (losesPrecision && scientificValue !== value) {
98
+ return scientificValue;
1620
99
  }
1621
-
1622
- if (property === 'border-radius') {
1623
- const segments = val.split('/').map((segment) => {
1624
- return segment.trim();
1625
- }).filter(Boolean).map((segment) => {
1626
- // Split each segment on whitespace and collapse redundant parts
1627
- return collapseShorthandParts(segment.split(/\s+/)).join(' ');
1628
- });
1629
- val = segments.join('/');
100
+ if (!isCustom || keepsNumberType(rawNumber, roundedNumber)) {
101
+ return roundedNumber + rawUnit;
1630
102
  }
1631
-
1632
- return val;
103
+ return value;
1633
104
  }
1634
105
 
1635
106
  /**
@@ -1648,118 +119,88 @@ function computeMinifiedValue (declaration) {
1648
119
  if (declaration.property === 'quotes' && isQuotesNoneEquivalent(declaration.value)) {
1649
120
  return 'none';
1650
121
  }
1651
- let val = declaration.value;
122
+ let value = declaration.value;
1652
123
  let protectedUrlState = null;
1653
124
 
1654
- if (typeof val === 'string') {
1655
- val = val.trim();
1656
- val = normalizeWhitespaceAndQuotes(val, declaration.property);
1657
- val = minifyUrls(val);
1658
- protectedUrlState = protectUrlTokens(val);
1659
- val = protectedUrlState.value;
125
+ if (typeof value === 'string') {
126
+ value = value.trim();
127
+ value = normalizeWhitespaceAndQuotes(value, declaration.property);
128
+ value = minifyUrls(value);
129
+ protectedUrlState = protectUrlTokens(value);
130
+ value = protectedUrlState.value;
1660
131
 
1661
132
  // Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
1662
133
  // Collapse spaces around division operator
1663
- val = val.replace(/ \/ /g, '/');
134
+ value = value.replace(/ \/ /g, '/');
1664
135
  // Remove whitespace around * and / operators (safe outside calc context)
1665
- val = val.replace(/\s*([*/])\s*/g, '$1');
1666
- val = normalizeMathFunctions(val, declaration.property, declaration.value || '');
1667
- val = simplifyStandaloneCalc(val, declaration.property);
1668
- // Simplify calc() expressions containing zero-percent additive terms
1669
- val = val.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
1670
- // Collapse whitespace inside calc expression
1671
- const compactInner = inner.replace(/\s+/g, ' ').trim();
1672
- // Extract all percentage terms from the expression
1673
- const percentTerms = compactInner.match(/[+-]?\s*(?:\d*\.\d+|\d+)%/g) || [];
1674
- const hasNonZeroPercent = percentTerms.some((term) => {
1675
- return Math.abs(parseFloat(term)) > 0;
1676
- });
1677
- if (!hasNonZeroPercent) {
1678
- return match;
1679
- }
1680
- // Remove trailing "+ 0%" and leading "0% +" additive identity terms
1681
- return 'calc(' + compactInner.replace(/\s*\+\s*0%(?=\s*$)/g, '').replace(/^0%\s*\+\s*/g, '').trim() + ')';
1682
- });
136
+ value = value.replace(/\s*([*/])\s*/g, '$1');
137
+ value = normalizeMathFunctions(value, declaration.property, declaration.value || '');
138
+ value = simplifyStandaloneCalc(value, declaration.property);
139
+ value = removeZeroPercentTermsFromCalc(value);
1683
140
 
1684
141
  // Zeros and Decimals
1685
142
  if (declaration.property !== 'initial-value') {
1686
143
  // Strip units from zero values (0px → 0, 0em → 0, etc.) at a value boundary
1687
- val = val.replace(/(^|\s|,|\()0(?:px|em|rem|vw|vh|cm|mm|in|pt|pc|ex|ch|vmin|vmax)(?=\s|,|$|\)|!)/g, '$10');
1688
- }
1689
- val = val.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
1690
-
1691
- // If value is a standalone number with optional unit, round it into the
1692
- // character budget a number is written within
1693
- if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1694
- const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1695
- const isCustom = isCustomProperty(declaration.property);
1696
- const scientificValue = isCustom ? val : shortenDimensionNumbers(val, declaration.property);
1697
- const roundedNumber = formatResolvedNumber(rawNumber);
1698
- const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
1699
- // Preserve tiny dimensions whose significant digits would otherwise be
1700
- // rounded away before their shorter exponent form can be considered.
1701
- if (losesPrecision && scientificValue !== val) {
1702
- val = scientificValue;
1703
- } else if (!isCustom || keepsNumberType(rawNumber, roundedNumber)) {
1704
- val = roundedNumber + rawUnit;
1705
- }
144
+ value = value.replace(/(^|\s|,|\()0(?:px|em|rem|vw|vh|cm|mm|in|pt|pc|ex|ch|vmin|vmax)(?=\s|,|$|\)|!)/g, '$10');
1706
145
  }
146
+ value = value.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
147
+ value = roundStandaloneNumber(value, declaration.property);
1707
148
 
1708
- val = lowercaseHexColors(val);
149
+ value = lowercaseHexColors(value);
1709
150
 
1710
151
  if (declaration.property !== 'font' && declaration.property !== 'font-family') {
1711
- val = convertColorsToHex(val);
1712
- val = replaceOutsideStringsAndUrls(val, (segment) => {
152
+ value = convertColorsToHex(value);
153
+ value = replaceOutsideStringsAndUrls(value, (segment) => {
1713
154
  return shortenColorValues(segment);
1714
155
  });
1715
156
  }
1716
157
 
1717
158
  // Collapse light-dark() when both normalized branches are identical
1718
- val = simplifyEquivalentLightDarkFunctions(val);
159
+ value = simplifyEquivalentLightDarkFunctions(value);
1719
160
 
1720
161
  // Property-specific optimizations
1721
- val = applyPropertyOptimizations(
1722
- val,
162
+ value = applyPropertyOptimizations(
163
+ value,
1723
164
  declaration.property,
1724
165
  protectedUrlState ? protectedUrlState.urlTokens : []
1725
166
  );
1726
167
 
1727
168
  // Minify relative color syntax (identity resolution and whitespace collapsing)
1728
- val = minifyRelativeColorSyntax(val);
169
+ value = minifyRelativeColorSyntax(value);
1729
170
  }
1730
171
 
1731
172
  // Gradient optimizations
1732
173
  // Check if value contains a gradient function
1733
- if (/gradient\(/.test(val)) {
1734
- val = minifyGradients(val);
174
+ if (/gradient\(/.test(value)) {
175
+ value = minifyGradients(value);
1735
176
  }
1736
177
 
1737
178
  if (protectedUrlState) {
1738
- val = removeWhitespaceAfterUrlTokens(val, protectedUrlState.urlTokens);
1739
- val = restoreUrlTokens(val, protectedUrlState.urlTokens);
179
+ value = removeWhitespaceAfterUrlTokens(value, protectedUrlState.urlTokens);
180
+ value = restoreUrlTokens(value, protectedUrlState.urlTokens);
1740
181
  }
1741
182
 
1742
183
  // Unicode range optimization: dedup, merge overlapping/adjacent, wildcard compression
1743
184
  if (declaration.property === 'unicode-range') {
1744
- val = optimizeUnicodeRange(val);
185
+ value = optimizeUnicodeRange(value);
1745
186
  }
1746
187
 
1747
188
  // Every earlier pass reads the value with its component separators in place,
1748
189
  // so the ones that turned out to be redundant are only dropped at the end.
1749
190
  const elidesRedundantSeparators = (
1750
- typeof val === 'string' &&
191
+ typeof value === 'string' &&
1751
192
  !isCustomProperty(declaration.property)
1752
193
  );
1753
194
  if (elidesRedundantSeparators) {
1754
- val = shortenDimensionNumbers(val, declaration.property);
195
+ value = shortenDimensionNumbers(value, declaration.property);
1755
196
  // A property that punctuates its own components already had the whitespace
1756
197
  // after its closing parentheses taken out, and only the separators its
1757
198
  // grammar still needs put back, so those are the ones left alone here.
1758
199
  const elidesAfterSelfEndingTokens = !PUNCTUATED_COMPONENT_PROPERTIES.has(declaration.property);
1759
- val = elideRedundantSeparators(val, elidesAfterSelfEndingTokens);
200
+ value = elideRedundantSeparators(value, elidesAfterSelfEndingTokens);
1760
201
  }
1761
202
 
1762
- return val;
203
+ return value;
1763
204
  }
1764
205
 
1765
206
  /**
@@ -1838,6 +279,23 @@ function minifyValue (declaration) {
1838
279
  return minifiedValue;
1839
280
  }
1840
281
 
282
+ /**
283
+ * Minifies a declaration's value for output, rewriting its initial keyword to
284
+ * the shorter global `initial` keyword after shorthand assembly.
285
+ *
286
+ * @param {object} declaration The CSS declaration object with property and value fields.
287
+ * @return {string} The minified value with its initial keyword shortened to `initial` when that saves bytes.
288
+ */
289
+ function minifyValueForOutput (declaration) {
290
+ const value = minifyValue(declaration);
291
+ // Custom properties hold an arbitrary token stream that JavaScript can read
292
+ // back, so their keywords are left exactly as written.
293
+ if (isCustomProperty(declaration.property)) {
294
+ return value;
295
+ }
296
+ return shortenLengthyInitialKeyword(value, declaration.property);
297
+ }
298
+
1841
299
  export {
1842
300
  clearMinifiedValueCache,
1843
301
  minifyValue,