@thejaredwilcurt/csslop 0.0.1

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.
@@ -0,0 +1,716 @@
1
+ /**
2
+ * @file Minifies CSS declaration values by applying color conversion, math simplification, shorthand compression, and other property-specific optimizations.
3
+ */
4
+
5
+ import { resolveUnicodeEscape } from '../utilities.js';
6
+
7
+ import {
8
+ convertOklabToHex,
9
+ evaluateColorMix,
10
+ evaluateRelativeColor,
11
+ hslToRgbChannels,
12
+ hwbToRgbChannels,
13
+ parseHex,
14
+ rgbaToHex,
15
+ shortestColor
16
+ } from './colors.js';
17
+ import { minifyGradients } from './gradients.js';
18
+ import {
19
+ normalizeMathFunctions,
20
+ simplifyStandaloneCalc
21
+ } from './math.js';
22
+ import { namedColors } from './named-colors.js';
23
+ import {
24
+ collapseShorthandParts,
25
+ normalizeScaleComponent,
26
+ parseAlphaString,
27
+ roundCompactNumber
28
+ } from './shared.js';
29
+ import { minifyTransformValue } from './transforms.js';
30
+
31
+ /**
32
+ * Map of position-area two-keyword values to their single-keyword equivalents.
33
+ * Per CSS spec, `center center` is redundant with `center`, etc.
34
+ *
35
+ * @type {{[key: string]: string}}
36
+ */
37
+ const POSITION_AREA_SHORTHANDS = {
38
+ 'center center': 'center',
39
+ 'top center': 'top',
40
+ 'bottom center': 'bottom',
41
+ 'center top': 'top',
42
+ 'center bottom': 'bottom',
43
+ 'left center': 'left',
44
+ 'right center': 'right'
45
+ };
46
+
47
+ /**
48
+ * Regex matching hex color tokens (#rgb, #rgba, #rrggbb, #rrggbbaa) and CSS named
49
+ * color keywords. Hex patterns are ordered longest-first to avoid partial matches.
50
+ * Named colors are sorted longest-first so longer names like `darkslategray` are
51
+ * matched before shorter substrings.
52
+ *
53
+ * @type {RegExp}
54
+ */
55
+ const COLOR_TOKEN_PATTERN = new RegExp(
56
+ '#[0-9a-fA-F]{8}(?![0-9a-fA-F])|' +
57
+ '#[0-9a-fA-F]{6}(?![0-9a-fA-F])|' +
58
+ '#[0-9a-fA-F]{4}(?![0-9a-fA-F])|' +
59
+ '#[0-9a-fA-F]{3}(?![0-9a-fA-F])|' +
60
+ '\\b(?:' +
61
+ Object.keys(namedColors).sort((a, b) => {
62
+ return b.length - a.length;
63
+ }).join('|') +
64
+ ')\\b',
65
+ 'gi'
66
+ );
67
+
68
+ /**
69
+ * Replaces every hex color and named color keyword in a CSS value segment with
70
+ * the shortest equivalent representation, comparing full hex, shortened hex,
71
+ * and any matching named color keyword.
72
+ *
73
+ * @param {string} segment A CSS value segment (outside strings and urls).
74
+ * @return {string} The segment with all colors shortened to their minimal form.
75
+ */
76
+ function shortenColorValues (segment) {
77
+ return segment.replace(COLOR_TOKEN_PATTERN, (match) => {
78
+ let channels;
79
+ if (match.startsWith('#')) {
80
+ channels = parseHex(match);
81
+ } else {
82
+ const rgb = namedColors[match.toLowerCase()];
83
+ if (rgb) {
84
+ channels = [rgb[0], rgb[1], rgb[2], 1];
85
+ }
86
+ }
87
+ if (!channels) {
88
+ return match;
89
+ }
90
+ return shortestColor(channels[0], channels[1], channels[2], channels[3]);
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Applies a replacer function only to segments of a CSS value that are outside quoted strings and url() functions, preserving those literal segments unchanged.
96
+ *
97
+ * @param {string} value The full CSS value string.
98
+ * @param {function(string): string} replacer A function called with each non-string, non-url segment, returning the replacement string.
99
+ * @return {string} The value with the replacer applied to all eligible segments.
100
+ */
101
+ function replaceOutsideStringsAndUrls (value, replacer) {
102
+ let result = '';
103
+ let index = 0;
104
+
105
+ const consumeQuoted = (start) => {
106
+ const quote = value[start];
107
+ let end = start + 1;
108
+ while (end < value.length) {
109
+ if (value[end] === '\\') {
110
+ end += 2;
111
+ continue;
112
+ }
113
+ if (value[end] === quote) {
114
+ end++;
115
+ break;
116
+ }
117
+ end++;
118
+ }
119
+ return end;
120
+ };
121
+
122
+ const startsUrl = (start) => {
123
+ return value.slice(start, start + 4).toLowerCase() === 'url(';
124
+ };
125
+
126
+ while (index < value.length) {
127
+ if (value[index] === '"' || value[index] === '\'') {
128
+ const end = consumeQuoted(index);
129
+ result += value.slice(index, end);
130
+ index = end;
131
+ continue;
132
+ }
133
+
134
+ if (startsUrl(index)) {
135
+ let depth = 1;
136
+ let end = index + 4;
137
+ while (end < value.length && depth > 0) {
138
+ if (value[end] === '"' || value[end] === '\'') {
139
+ end = consumeQuoted(end);
140
+ continue;
141
+ }
142
+ if (value[end] === '(') {
143
+ depth++;
144
+ }
145
+ if (value[end] === ')') {
146
+ depth--;
147
+ }
148
+ end++;
149
+ }
150
+ result += value.slice(index, end);
151
+ index = end;
152
+ continue;
153
+ }
154
+
155
+ const start = index;
156
+ while (index < value.length && value[index] !== '"' && value[index] !== '\'' && !startsUrl(index)) {
157
+ index++;
158
+ }
159
+ result += replacer(value.slice(start, index));
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Normalizes whitespace, quotes, and unicode escapes in a CSS value string.
167
+ *
168
+ * @param {string} val The raw CSS value string to normalize.
169
+ * @param {string} property The CSS property name, used for context-aware quote handling.
170
+ * @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
171
+ */
172
+ function normalizeWhitespaceAndQuotes (val, property) {
173
+ // Unescape unicode (skip control characters — they must stay escaped in CSS strings)
174
+ val = val.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
175
+ return resolveUnicodeEscape(hex) ?? match;
176
+ });
177
+ // Normalize single-quoted strings to double-quoted
178
+ val = val.replace(/'((?:[^'\\]|\\.)*?)'/g, '"$1"');
179
+
180
+ // Remove space between string literals
181
+ val = val.replace(/("(?:[^"\\]|\\.)*")\s+(?=")/g, '$1');
182
+
183
+ // Whitespace minification
184
+ val = val.replace(/\s*!\s*important/i, '!important');
185
+ // val = val.replace(/\s*([+*/=])\s*/g, '$1');
186
+ // Strip whitespace around commas
187
+ val = val.replace(/\s*([,])\s*/g, '$1');
188
+ // Match quoted strings (to skip them) or parentheses with surrounding whitespace (to strip whitespace)
189
+ val = val.replace(/("[^"]*"|'[^']*')|\s*([()])\s*/g, (match, str, paren) => {
190
+ if (str) {
191
+ return str;
192
+ }
193
+ return paren;
194
+ });
195
+
196
+ // Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
197
+ if (property !== 'content' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
198
+ // Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
199
+ val = val.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
200
+ // Keep quotes around CSS generic font-family keywords — unquoted they mean something different
201
+ 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)) {
202
+ return before + '"' + inner + '"';
203
+ }
204
+ return before + inner;
205
+ });
206
+ }
207
+
208
+ return val;
209
+ }
210
+
211
+ /**
212
+ * Converts CSS color functions (rgb, hsl, hwb, oklab, color-mix, etc.) to their
213
+ * shortest hex equivalents and applies hex shortening.
214
+ *
215
+ * @param {string} val The CSS value string with potential color functions.
216
+ * @return {string} The value with color functions converted to hex where shorter.
217
+ */
218
+ function convertColorsToHex (val) {
219
+ // Evaluate color-mix() expressions (before space minification to avoid nested-paren issues)
220
+ if (/\bcolor-mix\(/i.test(val)) {
221
+ const result = evaluateColorMix(val);
222
+ if (result) {
223
+ val = result;
224
+ }
225
+ }
226
+
227
+ // Handle color(from ...) relative color syntax (identity case)
228
+ if (/\bcolor\(\s*from\b/i.test(val)) {
229
+ const result = evaluateRelativeColor(val);
230
+ if (result) {
231
+ val = result;
232
+ }
233
+ }
234
+
235
+ // Minify whitespace and numeric precision inside wide-gamut and functional color notations
236
+ val = val.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, func, inner) => {
237
+ // Collapse whitespace to single space
238
+ let minified = inner.replace(/\s+/g, ' ');
239
+ // Remove space after commas
240
+ minified = minified.replace(/, /g, ',');
241
+ // Remove spaces around slash separator (alpha delimiter)
242
+ minified = minified.replace(/ \/ /g, '/');
243
+ // Strip leading zeros from decimal numbers (e.g. 0.5 → .5)
244
+ minified = minified.replace(/\b0+(\.[\d]+)/g, '$1');
245
+ // Strip leading zeros from decimals preceded by a keyword (e.g. srgb 0.5 → srgb .5)
246
+ minified = minified.replace(/([A-Za-z]) 0+(\.[\d]+)/g, '$1 $2');
247
+ // Check if function uses a wide-gamut color space requiring higher numeric precision
248
+ const useWidePrecision = func.toLowerCase() === 'color' && /\b(srgb-linear|xyz-d65|xyz-d50|xyz)\b/i.test(inner);
249
+ // Round numbers with 3+ decimal places, using context-aware precision
250
+ minified = minified.replace(/(^|[\s(,/-])(-?\d*\.\d{3,})/g, (match, before, num) => {
251
+ const isAlpha = before === '/';
252
+ const absoluteValue = Math.abs(parseFloat(num));
253
+ // Check if function is a Lab/LCH color notation with a large channel value (less precision needed)
254
+ const isLargeLabValue = /^(?:lch|lab|oklch|oklab)$/i.test(func) && absoluteValue >= 10;
255
+ let precision;
256
+ if (isAlpha) {
257
+ precision = 3;
258
+ } else if (isLargeLabValue) {
259
+ precision = 1;
260
+ } else if (useWidePrecision) {
261
+ precision = 4;
262
+ } else {
263
+ precision = 3;
264
+ }
265
+ const factor = Math.pow(10, precision);
266
+ const roundedNum = Math.round(parseFloat(num) * factor) / factor;
267
+ // Strip trailing zeros and trailing decimal point from the rounded number
268
+ let rounded = roundedNum.toFixed(precision).replace(/0+$/, '').replace(/\.$/, '');
269
+ if (rounded.startsWith('0.')) {
270
+ rounded = rounded.substring(1);
271
+ }
272
+ if (rounded.startsWith('-0.')) {
273
+ rounded = '-' + rounded.substring(2);
274
+ }
275
+ return before + rounded;
276
+ });
277
+ return func + '(' + minified.trim() + ')';
278
+ });
279
+
280
+ // Convert in-gamut oklab() to hex when it produces a shorter representation
281
+ 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) => {
282
+ const alpha = parseAlphaString(alphaStr);
283
+ const hex = convertOklabToHex(parseFloat(lStr), parseFloat(aStr), parseFloat(bStr), alpha);
284
+ if (!hex) {
285
+ return match; // out-of-gamut: keep native oklab form
286
+ }
287
+ if (hex.length < match.length) {
288
+ return hex;
289
+ }
290
+ return match;
291
+ });
292
+
293
+ // Handle 'none' keyword in rgb/hsl functions (CSS Color Level 4: treated as 0)
294
+ val = val.replace(/\b(rgba?|hsla?)\([^)]*\)/gi, (match) => {
295
+ return match.replace(/\bnone\b/gi, '0');
296
+ });
297
+
298
+ // hwb() → hex
299
+ 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) => {
300
+ const [r, g, b] = hwbToRgbChannels(parseFloat(hStr), parseFloat(wStr) / 100, parseFloat(bStr) / 100);
301
+ return rgbaToHex(r, g, b, parseAlphaString(aStr));
302
+ });
303
+
304
+ // rgb() space syntax → hex (handles decimals and any alpha)
305
+ val = val.replace(/\brgb\(\s*(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/g, (match, rStr, gStr, bStr, aStr) => {
306
+ const r = Math.round(parseFloat(rStr));
307
+ const g = Math.round(parseFloat(gStr));
308
+ const b = Math.round(parseFloat(bStr));
309
+ return rgbaToHex(r, g, b, parseAlphaString(aStr));
310
+ });
311
+
312
+ // hsl() space syntax → hex (handles any alpha)
313
+ val = val.replace(/\bhsl\(\s*(-?(?:\d+|\d*\.\d+))\s+((?:\d+|\d*\.\d+))%\s+((?:\d+|\d*\.\d+))%(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/g, (match, hStr, sStr, lStr, aStr) => {
314
+ const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
315
+ return rgbaToHex(r, g, b, parseAlphaString(aStr));
316
+ });
317
+
318
+ // rgba() comma syntax → hex (handles any alpha)
319
+ val = val.replace(/\brgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/g, (match, rStr, gStr, bStr, aStr) => {
320
+ const r = parseInt(rStr, 10);
321
+ const g = parseInt(gStr, 10);
322
+ const b = parseInt(bStr, 10);
323
+ return rgbaToHex(r, g, b, parseAlphaString(aStr));
324
+ });
325
+
326
+ // hsla() comma syntax → hex (handles any alpha)
327
+ val = val.replace(/\bhsla\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%\s*,\s*((?:\d+|\d*\.\d+))%\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/g, (match, hStr, sStr, lStr, aStr) => {
328
+ const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
329
+ return rgbaToHex(r, g, b, parseAlphaString(aStr));
330
+ });
331
+
332
+ // hsl() comma syntax → hex
333
+ val = val.replace(/\bhsl\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%\s*,\s*((?:\d+|\d*\.\d+))%\s*\)/g, (match, hStr, sStr, lStr) => {
334
+ const [r, g, b] = hslToRgbChannels(parseFloat(hStr), parseFloat(sStr) / 100, parseFloat(lStr) / 100);
335
+ return rgbaToHex(r, g, b, 1);
336
+ });
337
+
338
+ // rgb() comma syntax → hex
339
+ val = val.replace(/\brgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/g, (match, r, g, b) => {
340
+ return rgbaToHex(parseInt(r, 10), parseInt(g, 10), parseInt(b, 10), 1);
341
+ });
342
+ return val;
343
+ }
344
+
345
+ /**
346
+ * Applies property-specific optimizations to a CSS value (transition, flex, font,
347
+ * background, display, scale, border-radius, shorthand collapsing, etc.).
348
+ *
349
+ * @param {string} val The CSS value string after generic minification.
350
+ * @param {string} property The CSS property name.
351
+ * @return {string} The value with property-specific optimizations applied.
352
+ */
353
+ function applyPropertyOptimizations (val, property) {
354
+ if (property === 'font-weight') {
355
+ // Replace font-weight keyword "bold" with its numeric equivalent
356
+ val = val.replace(/\bbold\b/gi, '700');
357
+ // Replace font-weight keyword "normal" with its numeric equivalent
358
+ val = val.replace(/\bnormal\b/gi, '400');
359
+ }
360
+
361
+ if (property === 'transition-duration') {
362
+ // Convert millisecond duration to seconds when the result is shorter (e.g. 200ms → .2s)
363
+ val = val.replace(/^(-?(?:\d+|\d*\.\d+))ms$/i, (match, amount) => {
364
+ return roundCompactNumber(parseFloat(amount) / 1000) + 's';
365
+ });
366
+ }
367
+
368
+ // Transition: remove " 0s" duration (transition: all 0s -> transition: all)
369
+ if (property === 'transition') {
370
+ // Remove zero-second duration from transition shorthand
371
+ val = val.replace(/\s+0s/g, ' ');
372
+ // Remove leading zero-pixel value from transition shorthand
373
+ val = val.replace(/^0px\s*/, '');
374
+ // Replace cubic-bezier functions with their equivalent named timing-function keywords
375
+ val = val.replace(/cubic-bezier\(0,0,1,1\)/g, 'linear');
376
+ val = val.replace(/cubic-bezier\(\.25,\.1,\.25,1\)/g, 'ease');
377
+ val = val.replace(/cubic-bezier\(\.42,0,1,1\)/g, 'ease-in');
378
+ val = val.replace(/cubic-bezier\(0,0,\.58,1\)/g, 'ease-out');
379
+ val = val.trim();
380
+ }
381
+
382
+ if (property === 'animation') {
383
+ // Replace steps() functions with their equivalent named timing-function keywords
384
+ val = val.replace(/steps\(1,start\)/g, 'step-start');
385
+ val = val.replace(/steps\(1,end\)/g, 'step-end');
386
+ }
387
+
388
+ // Flex: remove " 0px" from flex shorthand (flex: 0 0 0px -> flex: 0 0)
389
+ if (property === 'flex') {
390
+ // Remove trailing zero-pixel basis value
391
+ val = val.replace(/\s+0px/g, ' ');
392
+ // Remove leading zero-pixel value
393
+ val = val.replace(/^0px\s*/, '');
394
+ // Remove trailing zero
395
+ val = val.replace(/\s+0$/, '');
396
+ // Remove standalone zero-pixel value
397
+ val = val.replace(/^0px$/, '');
398
+ val = val.trim();
399
+ }
400
+
401
+ // Initial values
402
+ if (val === 'initial') {
403
+ if (['opacity', 'z-index', 'flex-grow', 'flex-shrink', 'order', 'line-height', 'zoom'].includes(property)) {
404
+ // Just leaving them or mapping some: opacity: initial -> opacity: 1
405
+ if (property === 'opacity') {
406
+ val = '1';
407
+ }
408
+ if (property === 'z-index') {
409
+ val = 'auto';
410
+ }
411
+ }
412
+ if (['margin', 'padding'].includes(property)) {
413
+ val = '0';
414
+ }
415
+ if (['min-width', 'min-height'].includes(property)) {
416
+ val = 'auto';
417
+ }
418
+ }
419
+
420
+ if (property === 'background' && val === 'none') {
421
+ val = '0 0';
422
+ }
423
+
424
+ if (property === 'display') {
425
+ if (val === 'block flow') {
426
+ val = 'block';
427
+ }
428
+ if (val === 'inline flow-root') {
429
+ val = 'inline-block';
430
+ }
431
+ }
432
+
433
+ if (property === 'background-repeat') {
434
+ if (val === 'no-repeat no-repeat') {
435
+ val = 'no-repeat';
436
+ }
437
+ if (val === 'repeat no-repeat') {
438
+ val = 'repeat-x';
439
+ }
440
+ if (val === 'no-repeat repeat') {
441
+ val = 'repeat-y';
442
+ }
443
+ }
444
+
445
+ if (property === 'background-position') {
446
+ if (val === 'center center') {
447
+ val = '50%';
448
+ }
449
+ if (val === 'left top') {
450
+ val = '0 0';
451
+ }
452
+ }
453
+
454
+ // Check if border value starts with a style keyword, and reorder to canonical width-style-color order
455
+ if (property === 'border' && /^(?:solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)\s+/i.test(val)) {
456
+ // Reorder border shorthand from style-width-color to width-style-color
457
+ val = val.replace(/^((?:solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none))\s+([^\s]+)\s+(.+)$/i, '$2 $1 $3');
458
+ }
459
+
460
+ if (property === 'flex-flow') {
461
+ // Reorder flex-flow from wrap-direction to direction-wrap (canonical order)
462
+ val = val.replace(/^(nowrap|wrap|wrap-reverse)\s+(row|row-reverse|column|column-reverse)$/i, '$2 $1');
463
+ }
464
+
465
+ if (property === 'font-family') {
466
+ // Strip quotes from simple multi-word font family names that don't require quoting
467
+ val = val.replace(/"([A-Za-z0-9-]+(?: [A-Za-z0-9-]+)+)"/g, '$1');
468
+ const seenFamilies = new Set();
469
+ val = val.split(',').map((part) => {
470
+ return part.trim();
471
+ }).filter(Boolean).filter((part) => {
472
+ const lowercaseName = part.toLowerCase();
473
+ if (seenFamilies.has(lowercaseName)) {
474
+ return false;
475
+ }
476
+ seenFamilies.add(lowercaseName);
477
+ return true;
478
+ }).join(',');
479
+ }
480
+
481
+ if (property === 'grid-template-areas') {
482
+ // Normalize each quoted grid-template-areas row string
483
+ val = val.replace(/"([^"]*)"/g, (match, inner) => {
484
+ // Collapse whitespace to single space within grid row
485
+ let normalized = inner.replace(/\s+/g, ' ').trim();
486
+ // Collapse consecutive dots (null cell tokens) to a single dot
487
+ normalized = normalized.replace(/(^| )\.{2,}(?= |$)/g, '$1.');
488
+ return '"' + normalized + '"';
489
+ });
490
+ }
491
+
492
+ if (property === 'font-size') {
493
+ // Convert point (pt) font-size values to their pixel (px) equivalent
494
+ val = val.replace(/^(-?(?:\d+|\d*\.\d+))pt$/i, (match, amount) => {
495
+ return roundCompactNumber(parseFloat(amount) * (96 / 72)) + 'px';
496
+ });
497
+ }
498
+
499
+ // Simplify clamp() where all three arguments are identical (e.g. clamp(1rem,1rem,1rem) → 1rem)
500
+ val = val.replace(/\bclamp\(([^,]+),\1,\1\)/gi, '$1');
501
+
502
+ // Convert display-p3 neutral grays to sRGB (equal channels are identical across gamuts)
503
+ val = val.replace(/\bcolor\(display-p3\s+([\d.]+)\s+\1\s+\1(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, channelStr, alphaStr) => {
504
+ const channelValue = parseFloat(channelStr);
505
+ const r = Math.round(channelValue * 255);
506
+ return rgbaToHex(r, r, r, parseAlphaString(alphaStr));
507
+ });
508
+
509
+ // Shorten all color tokens (second pass after property-specific color evaluations)
510
+ val = replaceOutsideStringsAndUrls(val, shortenColorValues);
511
+
512
+ // Remove space before hex colors (second pass after color evaluations)
513
+ val = replaceOutsideStringsAndUrls(val, (segment) => {
514
+ return segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
515
+ });
516
+ if (property !== 'transform' && property !== 'background' && property !== 'src') {
517
+ // Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
518
+ val = val.replace(/\)(?=[0-9a-zA-Z#-])/g, ') ');
519
+ }
520
+
521
+ if (property === 'font') {
522
+ // Split font shorthand on whitespace
523
+ const parts = val.split(/\s+/);
524
+ // Find the font-size part: contains a digit and a recognized CSS length/percentage unit
525
+ const sizeIndex = parts.findIndex((part) => {
526
+ return /\d/.test(part) && /(?:px|em|rem|%|pt|pc|vw|vh|vmin|vmax|ch|ex|cm|mm|in|lh|rlh)/i.test(part);
527
+ });
528
+ if (sizeIndex > 0) {
529
+ val = [...parts.slice(0, sizeIndex).filter((part) => {
530
+ return part !== 'normal' && part !== '400';
531
+ }), ...parts.slice(sizeIndex)].join(' ');
532
+ }
533
+ }
534
+
535
+ if (property === 'background' && val !== 'none') {
536
+ const normalized = val
537
+ // Remove default "0 0" background-position values
538
+ .replace(/(?:^|\s)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ' ')
539
+ // Remove "0 0" background-position after a close-paren (e.g. after url())
540
+ .replace(/\)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ') ')
541
+ // Remove default "repeat" background-repeat keyword (excluding compound values like no-repeat)
542
+ .replace(/(?<!-)\brepeat\b(?!-)/g, ' ')
543
+ // Remove default "scroll" background-attachment keyword
544
+ .replace(/\bscroll\b/g, ' ')
545
+ // Remove default "none" background-image keyword
546
+ .replace(/\bnone\b/g, ' ')
547
+ // Collapse whitespace to single space
548
+ .replace(/\s+/g, ' ')
549
+ .trim();
550
+ if (normalized) {
551
+ val = normalized;
552
+ }
553
+ }
554
+
555
+ if (property === 'border') {
556
+ // Remove default "medium" border-width keyword
557
+ val = val.replace(/\bmedium\s+/g, '');
558
+ }
559
+
560
+ if (property === 'outline') {
561
+ // Restore missing space between outline-style and a color keyword when they are adjacent
562
+ val = val.replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)(red|green|olive|tan|transparent)\b/g, '$1 $2');
563
+ }
564
+
565
+ if (property === 'transform') {
566
+ val = minifyTransformValue(val);
567
+ // Remove whitespace between consecutive transform functions
568
+ val = val.replace(/\)\s+(?=[a-z-]+\()/gi, ')');
569
+ }
570
+
571
+ if (property === 'scale') {
572
+ // Split scale value on whitespace into individual axis components
573
+ const parts = val.split(/\s+/).filter(Boolean).map(normalizeScaleComponent);
574
+ if (parts.length === 2 && parts[0] === parts[1]) {
575
+ val = parts[0];
576
+ } else if (parts.length === 3 && parts[2] === '1') {
577
+ if (parts[0] === parts[1]) {
578
+ val = parts[0];
579
+ } else {
580
+ val = parts[0] + ' ' + parts[1];
581
+ }
582
+ } else {
583
+ val = parts.join(' ');
584
+ }
585
+ }
586
+
587
+ // Replace multiple spaces
588
+ val = val.replace(/\s+/g, ' ');
589
+
590
+ // Shorthands: margin, padding, border-width, border-style, border-color, inset
591
+ // Check if property supports box-model shorthand collapsing (4 → 3 → 2 → 1 values)
592
+ if (/^(margin|padding|inset|border-width|border-style|border-color|gap|overflow)$/.test(property)) {
593
+ val = collapseShorthandParts(val.split(' ')).join(' ');
594
+ }
595
+
596
+ if (property === 'border-radius') {
597
+ const segments = val.split('/').map((segment) => {
598
+ return segment.trim();
599
+ }).filter(Boolean).map((segment) => {
600
+ // Split each segment on whitespace and collapse redundant parts
601
+ return collapseShorthandParts(segment.split(/\s+/)).join(' ');
602
+ });
603
+ val = segments.join('/');
604
+ }
605
+
606
+ return val;
607
+ }
608
+
609
+ /**
610
+ * Minifies a CSS declaration's value by applying color conversion, math simplification, shorthand compression, gradient optimization, and other property-specific optimizations.
611
+ *
612
+ * @param {object} declaration The CSS declaration object with property and value fields.
613
+ * @return {string} The minified value string.
614
+ */
615
+ function minifyValue (declaration) {
616
+ if (declaration.property === 'position-area') {
617
+ const shorthand = POSITION_AREA_SHORTHANDS[declaration.value];
618
+ if (shorthand) {
619
+ return shorthand;
620
+ }
621
+ }
622
+ let val = declaration.value;
623
+
624
+ if (typeof val === 'string') {
625
+ val = val.trim();
626
+ val = normalizeWhitespaceAndQuotes(val, declaration.property);
627
+
628
+ // Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
629
+ // Collapse spaces around division operator
630
+ val = val.replace(/ \/ /g, '/');
631
+ // Remove whitespace around * and / operators (safe outside calc context)
632
+ val = val.replace(/\s*([*/])\s*/g, '$1');
633
+ val = normalizeMathFunctions(val, declaration.property, declaration.value || '');
634
+ val = simplifyStandaloneCalc(val);
635
+ // Simplify calc() expressions containing zero-percent additive terms
636
+ val = val.replace(/calc\(([^()]+)\)/gi, (match, inner) => {
637
+ // Collapse whitespace inside calc expression
638
+ const compactInner = inner.replace(/\s+/g, ' ').trim();
639
+ // Extract all percentage terms from the expression
640
+ const percentTerms = compactInner.match(/[+-]?\s*(?:\d*\.\d+|\d+)%/g) || [];
641
+ const hasNonZeroPercent = percentTerms.some((term) => {
642
+ return Math.abs(parseFloat(term)) > 0;
643
+ });
644
+ if (!hasNonZeroPercent) {
645
+ return match;
646
+ }
647
+ // Remove trailing "+ 0%" and leading "0% +" additive identity terms
648
+ return 'calc(' + compactInner.replace(/\s*\+\s*0%(?=\s*$)/g, '').replace(/^0%\s*\+\s*/g, '').trim() + ')';
649
+ });
650
+
651
+ // Zeros and Decimals
652
+ if (declaration.property !== 'initial-value') {
653
+ // Strip units from zero values (0px → 0, 0em → 0, etc.) at a value boundary
654
+ val = val.replace(/(^|\s|,|\()0(?:px|em|rem|vw|vh|cm|mm|in|pt|pc|ex|ch|vmin|vmax)(?=\s|,|$|\)|!)/g, '$10');
655
+ }
656
+ val = val.replace(/(^|\s|,|\()(-?)0+(\.\d+)/g, '$1$2$3'); // e.g. 0.5 -> .5, -0.5 -> -.5
657
+
658
+ // If value is a standalone number with optional unit, round it compactly
659
+ if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
660
+ const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
661
+ val = roundCompactNumber(rawNumber, 4) + rawUnit;
662
+ }
663
+
664
+ // Remove space before hex colors
665
+ val = replaceOutsideStringsAndUrls(val, (segment) => {
666
+ segment = segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
667
+ // Lowercase hex color tokens for consistency and shorter output
668
+ segment = segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (m) => {
669
+ return m.toLowerCase();
670
+ });
671
+ return segment;
672
+ });
673
+
674
+ // Convert color functions to hex equivalents
675
+ val = convertColorsToHex(val);
676
+
677
+ // Shorten all color tokens (hex and named) to their shortest representation
678
+ val = replaceOutsideStringsAndUrls(val, shortenColorValues);
679
+
680
+ // Property-specific optimizations
681
+ val = applyPropertyOptimizations(val, declaration.property);
682
+ }
683
+
684
+ // Gradient optimizations
685
+ // Check if value contains a gradient function
686
+ if (/gradient\(/.test(val)) {
687
+ val = minifyGradients(val);
688
+ }
689
+
690
+ // Unicode range compaction: U+0000-00FF -> U+??
691
+ if (declaration.property === 'unicode-range') {
692
+ val = val.replace(/U\+([0-9a-fA-F]+)-([0-9a-fA-F]+)/gi, (match, startHex, endHex) => {
693
+ const len = Math.max(startHex.length, endHex.length);
694
+ const s = startHex.padStart(len, '0').toUpperCase();
695
+ const e = endHex.padStart(len, '0').toUpperCase();
696
+ let prefixLen = 0;
697
+ while (prefixLen < len && s[prefixLen] === e[prefixLen]) {
698
+ prefixLen++;
699
+ }
700
+ const suffixS = s.slice(prefixLen);
701
+ const suffixE = e.slice(prefixLen);
702
+ // Check if the suffix range spans all values (all-zeros start, all-F end) for wildcard replacement
703
+ if (/^0*$/.test(suffixS) && /^F*$/i.test(suffixE)) {
704
+ const wildcardCount = len - prefixLen;
705
+ // Strip leading zeros from the common prefix
706
+ const prefix = s.slice(0, prefixLen).replace(/^0+/, '');
707
+ return 'U+' + prefix + '?'.repeat(wildcardCount);
708
+ }
709
+ return match;
710
+ });
711
+ }
712
+
713
+ return val;
714
+ }
715
+
716
+ export { minifyValue };