@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
@@ -0,0 +1,463 @@
1
+ /**
2
+ * @file Converts CSS color functions (rgb, hsl, hwb, lab, oklab, oklch, color-mix, and relative colors) to their shortest equivalent, and compacts the ones that have to stay functions.
3
+ */
4
+
5
+ import {
6
+ formatCompactNumber,
7
+ parseAlphaString,
8
+ parseAngleToDegrees
9
+ } from '../shared.js';
10
+
11
+ import {
12
+ convertLabToHex,
13
+ convertOklabToHex,
14
+ convertOklchToHex,
15
+ hslToRgbChannels,
16
+ hwbToRgbChannels,
17
+ rgbaToHex
18
+ } from './conversion.js';
19
+ import { evaluateColorMix } from './mix.js';
20
+ import { evaluateRelativeColor } from './relative.js';
21
+
22
+ /**
23
+ * The OKLCH chroma value that `100%` resolves to, per CSS Color Level 4.
24
+ *
25
+ * @type {number}
26
+ */
27
+ const OKLCH_CHROMA_PERCENT_REFERENCE = 0.4;
28
+
29
+ /**
30
+ * Regex matching an `oklch()` function with three space-separated components
31
+ * and an optional slash-delimited alpha. Lightness and chroma accept numbers
32
+ * or percentages, hue accepts a number with an optional CSS angle unit, and
33
+ * every component accepts the `none` keyword.
34
+ *
35
+ * @type {RegExp}
36
+ */
37
+ const OKLCH_FUNCTION_PATTERN = new RegExp(
38
+ '\\boklch\\(\\s*' +
39
+ '(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
40
+ '(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
41
+ '(none|-?(?:\\d+|\\d*\\.\\d+)(?:deg|grad|rad|turn)?)' +
42
+ '(?:\\s*/\\s*(none|-?(?:\\d+|\\d*\\.\\d+)%?))?' +
43
+ '\\s*\\)',
44
+ 'gi'
45
+ );
46
+
47
+ /**
48
+ * Parses an OKLCH lightness or chroma component into its numeric value.
49
+ * Missing components (`none`) resolve to zero, and percentages are scaled
50
+ * against the reference value for that component.
51
+ *
52
+ * @param {string} token The raw component token.
53
+ * @param {number} percentReference The value that `100%` represents for this component.
54
+ * @return {number|null} The numeric component value, or null when unparsable.
55
+ */
56
+ function parseOklchComponent (token, percentReference) {
57
+ const normalized = token.trim().toLowerCase();
58
+ if (normalized === 'none') {
59
+ return 0;
60
+ }
61
+ const numeric = parseFloat(normalized);
62
+ if (!Number.isFinite(numeric)) {
63
+ return null;
64
+ }
65
+ if (normalized.endsWith('%')) {
66
+ return numeric / 100 * percentReference;
67
+ }
68
+ return numeric;
69
+ }
70
+
71
+ /**
72
+ * Parses an OKLCH hue component into degrees, treating `none` as zero.
73
+ *
74
+ * @param {string} token The raw hue token, optionally carrying an angle unit.
75
+ * @return {number|null} The hue in degrees, or null when unparsable.
76
+ */
77
+ function parseOklchHue (token) {
78
+ const normalized = token.trim().toLowerCase();
79
+ if (normalized === 'none') {
80
+ return 0;
81
+ }
82
+ return parseAngleToDegrees(normalized);
83
+ }
84
+
85
+ /**
86
+ * Removes whitespace before a negative numeric channel in Lab-family colors.
87
+ * A preceding numeric token already separates the sign from the prior channel.
88
+ *
89
+ * @param {string} functionName The name of the color function.
90
+ * @param {string} value The minified function arguments.
91
+ * @return {string} The arguments with redundant channel spacing removed.
92
+ */
93
+ function compactNegativeColorChannelSpacing (functionName, value) {
94
+ const normalizedFunctionName = functionName.toLowerCase();
95
+ if (!['lab', 'lch', 'oklab', 'oklch'].includes(normalizedFunctionName)) {
96
+ return value;
97
+ }
98
+
99
+ let result = '';
100
+ let nestingDepth = 0;
101
+ for (let index = 0; index < value.length; index++) {
102
+ const character = value[index];
103
+ if (character === ' ' && nestingDepth === 0) {
104
+ const previousCharacter = result[result.length - 1];
105
+ const nextCharacter = value[index + 1];
106
+ const characterAfterSign = value[index + 2];
107
+ const characterAfterDecimal = value[index + 3];
108
+ const previousEndsNumber = (
109
+ isCssDigit(previousCharacter) ||
110
+ previousCharacter === '%'
111
+ );
112
+ const nextStartsNegativeNumber = (
113
+ nextCharacter === '-' &&
114
+ (
115
+ isCssDigit(characterAfterSign) ||
116
+ (
117
+ characterAfterSign === '.' &&
118
+ isCssDigit(characterAfterDecimal)
119
+ )
120
+ )
121
+ );
122
+ if (previousEndsNumber && nextStartsNegativeNumber) {
123
+ continue;
124
+ }
125
+ }
126
+ result += character;
127
+ if (character === '(') {
128
+ nestingDepth++;
129
+ } else if (character === ')') {
130
+ nestingDepth--;
131
+ }
132
+ }
133
+ return result;
134
+ }
135
+
136
+ /**
137
+ * Checks whether a character is an ASCII decimal digit.
138
+ *
139
+ * @param {string} character The character to inspect.
140
+ * @return {boolean} Whether the character is a digit.
141
+ */
142
+ function isCssDigit (character) {
143
+ return character >= '0' && character <= '9';
144
+ }
145
+
146
+ /**
147
+ * Converts OKLCH lightness and chroma percentages when doing so is shorter,
148
+ * including the separator each spelling needs before the hue.
149
+ *
150
+ * @param {string} value The normalized arguments of an `oklch()` function.
151
+ * @return {string} The arguments with shorter percentage spellings.
152
+ */
153
+ function minifyOklchPercentages (value) {
154
+ const slashIndex = value.indexOf('/');
155
+ const componentsText = slashIndex === -1 ? value : value.slice(0, slashIndex);
156
+ const alphaText = slashIndex === -1 ? '' : value.slice(slashIndex + 1);
157
+ const components = componentsText.trim().split(' ');
158
+ if (components.length !== 3) {
159
+ return value;
160
+ }
161
+
162
+ let [lightness, chroma, hue] = components;
163
+ if (lightness.endsWith('%')) {
164
+ lightness = formatCompactNumber(Number.parseFloat(lightness) / 100);
165
+ }
166
+ if (chroma.endsWith('%')) {
167
+ const convertedChroma = formatCompactNumber(
168
+ Number.parseFloat(chroma) / 100 * OKLCH_CHROMA_PERCENT_REFERENCE
169
+ );
170
+ const convertedWithHueSeparator = convertedChroma.length + 1;
171
+ if (chroma.length < convertedWithHueSeparator) {
172
+ return [lightness, chroma + hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
173
+ }
174
+ chroma = convertedChroma;
175
+ }
176
+
177
+ return [lightness, chroma, hue].join(' ') + (slashIndex === -1 ? '' : '/' + alphaText);
178
+ }
179
+
180
+ /**
181
+ * Converts `oklch()` colors that fall inside the sRGB gamut to their hex
182
+ * equivalent when that is shorter. Out-of-gamut colors have no sRGB
183
+ * representation, so they are left in their native color space.
184
+ *
185
+ * @param {string} value The CSS value string that may contain oklch() colors.
186
+ * @return {string} The value with in-gamut oklch() colors replaced by hex.
187
+ */
188
+ function convertOklchFunctionsToHex (value) {
189
+ return value.replace(OKLCH_FUNCTION_PATTERN, (match, lightnessToken, chromaToken, hueToken, alphaToken) => {
190
+ const lightness = parseOklchComponent(lightnessToken, 1);
191
+ const chroma = parseOklchComponent(chromaToken, OKLCH_CHROMA_PERCENT_REFERENCE);
192
+ const hue = parseOklchHue(hueToken);
193
+ if (lightness === null || chroma === null || hue === null) {
194
+ return match;
195
+ }
196
+ const alpha = alphaToken?.trim().toLowerCase() === 'none' ? 0 : parseAlphaString(alphaToken);
197
+ const hex = convertOklchToHex(lightness, chroma, hue, alpha);
198
+ if (!hex) {
199
+ return match; // out-of-gamut: keep native oklch form
200
+ }
201
+ if (hex.length < match.length) {
202
+ return hex;
203
+ }
204
+ return match;
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Evaluates the colors that are computed from other colors, `color-mix()` and
210
+ * the `color(from ...)` relative color syntax, into the color they resolve to.
211
+ *
212
+ * @param {string} value The CSS value string with potential computed colors.
213
+ * @return {string} The value with computed colors resolved where possible.
214
+ */
215
+ function resolveComputedColors (value) {
216
+ // Evaluate color-mix() expressions (before space minification to avoid nested-paren issues)
217
+ if (/\bcolor-mix\(/i.test(value)) {
218
+ const result = evaluateColorMix(value);
219
+ if (result) {
220
+ value = result;
221
+ }
222
+ }
223
+
224
+ // Handle color(from ...) relative color syntax (identity case)
225
+ if (/\bcolor\(\s*from\b/i.test(value)) {
226
+ const result = evaluateRelativeColor(value);
227
+ if (result) {
228
+ value = result;
229
+ }
230
+ }
231
+ return value;
232
+ }
233
+
234
+ /**
235
+ * Converts in-gamut `lab()` colors (CIE Lab, D50) to hex when that produces a
236
+ * shorter representation. Out-of-gamut colors have no sRGB representation, so
237
+ * they are left in their native color space.
238
+ *
239
+ * @param {string} value The CSS value string that may contain lab() colors.
240
+ * @return {string} The value with in-gamut lab() colors replaced by hex where shorter.
241
+ */
242
+ function convertLabFunctionsToHex (value) {
243
+ // Match lab() with three numeric or percentage channels and an optional slash-delimited alpha
244
+ return value.replace(/\blab\(\s*(-?(?:\d+|\d*\.\d+)%?)\s+(-?(?:\d+|\d*\.\d+)%?)\s+(-?(?:\d+|\d*\.\d+)%?)(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, lightnessText, aAxisText, bAxisText, alphaText) => {
245
+ const alpha = parseAlphaString(alphaText);
246
+ const lightness = parseFloat(lightnessText);
247
+ const aNumber = parseFloat(aAxisText);
248
+ const a = aAxisText.endsWith('%') ? aNumber * 1.25 : aNumber;
249
+ const bNumber = parseFloat(bAxisText);
250
+ const b = bAxisText.endsWith('%') ? bNumber * 1.25 : bNumber;
251
+ const hex = convertLabToHex(lightness, a, b, alpha);
252
+ if (!hex) {
253
+ return match; // out-of-gamut: keep native lab form
254
+ }
255
+ if (hex.length < match.length) {
256
+ return hex;
257
+ }
258
+ return match;
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Minifies the whitespace and numeric precision inside the wide-gamut and
264
+ * functional color notations, rounding each channel to the precision its color
265
+ * space needs.
266
+ *
267
+ * @param {string} value The CSS value string that may contain color functions.
268
+ * @return {string} The value with every color function's arguments compacted.
269
+ */
270
+ function compactColorFunctionArguments (value) {
271
+ // Match a wide-gamut or functional color notation, capturing its name and its arguments
272
+ return value.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, functionName, inner) => {
273
+ // Collapse whitespace to single space
274
+ let minified = inner.replace(/\s+/g, ' ');
275
+ // Remove space after commas
276
+ minified = minified.replace(/, /g, ',');
277
+ // Remove spaces around slash separator (alpha delimiter)
278
+ minified = minified.replace(/ \/ /g, '/');
279
+ // Strip leading zeros from decimal numbers (e.g. 0.5 → .5)
280
+ minified = minified.replace(/\b0+(\.[\d]+)/g, '$1');
281
+ // Strip leading zeros from decimals preceded by a keyword (e.g. srgb 0.5 → srgb .5)
282
+ minified = minified.replace(/([A-Za-z]) 0+(\.[\d]+)/g, '$1 $2');
283
+ // Check if function uses a wide-gamut color space requiring higher numeric precision
284
+ const useWidePrecision = functionName.toLowerCase() === 'color' && /\b(srgb-linear|xyz-d65|xyz-d50|xyz)\b/i.test(inner);
285
+ // Round numbers with 3+ decimal places, using context-aware precision
286
+ minified = minified.replace(/(^|[\s(,/-])(-?\d*\.\d{3,})/g, (match, before, number) => {
287
+ const isAlpha = before === '/';
288
+ const absoluteValue = Math.abs(parseFloat(number));
289
+ // Check if function is a Lab/LCH color notation with a large channel value (less precision needed)
290
+ const isLargeLabValue = /^(?:lch|lab|oklch|oklab)$/i.test(functionName) && absoluteValue >= 10;
291
+ let precision;
292
+ if (isAlpha) {
293
+ precision = 3;
294
+ } else if (isLargeLabValue) {
295
+ precision = 1;
296
+ } else if (useWidePrecision) {
297
+ precision = 4;
298
+ } else {
299
+ precision = 3;
300
+ }
301
+ const factor = Math.pow(10, precision);
302
+ const roundedNumber = Math.round(parseFloat(number) * factor) / factor;
303
+ // Strip trailing zeros and trailing decimal point from the rounded number
304
+ let rounded = roundedNumber.toFixed(precision).replace(/0+$/, '').replace(/\.$/, '');
305
+ if (rounded.startsWith('0.')) {
306
+ rounded = rounded.substring(1);
307
+ }
308
+ if (rounded.startsWith('-0.')) {
309
+ rounded = '-' + rounded.substring(2);
310
+ }
311
+ return before + rounded;
312
+ });
313
+ // Remove trailing ".0" from numbers so integer channel values stay integer
314
+ // (e.g. display-p3 1.0 0.0 0.0 becomes display-p3 1 0 0)
315
+ minified = minified.replace(/(-?\d*)\.0\b/g, (match, integer) => {
316
+ if (integer === '' || integer === '-' || integer === '-0') {
317
+ return '0';
318
+ }
319
+ return integer;
320
+ });
321
+ minified = compactNegativeColorChannelSpacing(functionName, minified);
322
+ if (functionName.toLowerCase() === 'oklch') {
323
+ minified = minifyOklchPercentages(minified);
324
+ }
325
+ return functionName + '(' + minified.trim() + ')';
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Converts in-gamut `oklab()` colors to hex when that produces a shorter
331
+ * representation. Out-of-gamut colors have no sRGB representation, so they are
332
+ * left in their native color space.
333
+ *
334
+ * @param {string} value The CSS value string that may contain oklab() colors.
335
+ * @return {string} The value with in-gamut oklab() colors replaced by hex where shorter.
336
+ */
337
+ function convertOklabFunctionsToHex (value) {
338
+ // Match oklab() with three numeric channels and an optional slash-delimited alpha.
339
+ // A negative channel may touch the preceding number because its sign separates the CSS number tokens.
340
+ return value.replace(/\boklab\(\s*(-?(?:\d+|\d*\.\d+))(?:\s+|(?=-))(-?(?:\d+|\d*\.\d+))(?:\s+|(?=-))(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, lightnessText, aAxisText, bAxisText, alphaText) => {
341
+ const alpha = parseAlphaString(alphaText);
342
+ const hex = convertOklabToHex(parseFloat(lightnessText), parseFloat(aAxisText), parseFloat(bAxisText), alpha);
343
+ if (!hex) {
344
+ return match; // out-of-gamut: keep native oklab form
345
+ }
346
+ if (hex.length < match.length) {
347
+ return hex;
348
+ }
349
+ return match;
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Replaces the `none` keyword inside `rgb()` and `hsl()` functions with zero,
355
+ * which is how CSS Color Level 4 treats a missing component.
356
+ *
357
+ * @param {string} value The CSS value string that may contain rgb() or hsl() colors.
358
+ * @return {string} The value with every `none` component of those functions written as 0.
359
+ */
360
+ function replaceNoneInLegacyColorFunctions (value) {
361
+ // Match an rgb(), rgba(), hsl(), or hsla() function up to its closing parenthesis
362
+ return value.replace(/\b(rgba?|hsla?)\([^)]*\)/gi, (match) => {
363
+ // Match the none keyword as a whole word
364
+ return match.replace(/\bnone\b/gi, '0');
365
+ });
366
+ }
367
+
368
+ /**
369
+ * Converts `hwb()` colors to hex. Whiteness and blackness are always read as
370
+ * percentages, so their percent signs are optional.
371
+ *
372
+ * @param {string} value The CSS value string that may contain hwb() colors.
373
+ * @return {string} The value with hwb() colors replaced by hex.
374
+ */
375
+ function convertHwbFunctionsToHex (value) {
376
+ // hwb() → hex (percent signs optional for whiteness/blackness, values always treated as percentages)
377
+ return value.replace(/\bhwb\(\s*(-?(?:\d+|\d*\.\d+))\s+((?:\d+|\d*\.\d+))%?\s+((?:\d+|\d*\.\d+))%?(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, hueText, whitenessText, blacknessText, alphaText) => {
378
+ const [r, g, b] = hwbToRgbChannels(parseFloat(hueText), parseFloat(whitenessText) / 100, parseFloat(blacknessText) / 100);
379
+ return rgbaToHex(r, g, b, parseAlphaString(alphaText));
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Converts the space-separated syntax of `rgb()` and `hsl()` colors to hex.
385
+ *
386
+ * @param {string} value The CSS value string that may contain rgb() or hsl() colors.
387
+ * @return {string} The value with space-separated rgb() and hsl() colors replaced by hex.
388
+ */
389
+ function convertSpaceSeparatedRgbAndHslToHex (value) {
390
+ // rgb()/rgba() space syntax → hex, case-insensitive (handles decimals and any alpha)
391
+ value = value.replace(/\brgba?\(\s*(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))\s+(-?(?:\d+|\d*\.\d+))(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, redText, greenText, blueText, alphaText) => {
392
+ const r = Math.round(parseFloat(redText));
393
+ const g = Math.round(parseFloat(greenText));
394
+ const b = Math.round(parseFloat(blueText));
395
+ return rgbaToHex(r, g, b, parseAlphaString(alphaText));
396
+ });
397
+
398
+ // hsl()/hsla() space syntax → hex, case-insensitive (percent signs optional, values always treated as percentages)
399
+ value = value.replace(/\bhsla?\(\s*(-?(?:\d+|\d*\.\d+))\s+((?:\d+|\d*\.\d+))%?\s+((?:\d+|\d*\.\d+))%?(?:\s*\/\s*(-?(?:\d+|\d*\.\d+)%?))?\s*\)/gi, (match, hueText, saturationText, lightnessText, alphaText) => {
400
+ const [r, g, b] = hslToRgbChannels(parseFloat(hueText), parseFloat(saturationText) / 100, parseFloat(lightnessText) / 100);
401
+ return rgbaToHex(r, g, b, parseAlphaString(alphaText));
402
+ });
403
+ return value;
404
+ }
405
+
406
+ /**
407
+ * Converts the legacy comma-separated syntax of `rgb()` and `hsl()` colors to
408
+ * hex, with or without an alpha component.
409
+ *
410
+ * @param {string} value The CSS value string that may contain rgb() or hsl() colors.
411
+ * @return {string} The value with comma-separated rgb() and hsl() colors replaced by hex.
412
+ */
413
+ function convertCommaSeparatedRgbAndHslToHex (value) {
414
+ // rgba() comma syntax → hex, case-insensitive (handles any alpha)
415
+ value = value.replace(/\brgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/gi, (match, redText, greenText, blueText, alphaText) => {
416
+ const r = parseInt(redText, 10);
417
+ const g = parseInt(greenText, 10);
418
+ const b = parseInt(blueText, 10);
419
+ return rgbaToHex(r, g, b, parseAlphaString(alphaText));
420
+ });
421
+
422
+ // hsla() comma syntax → hex, case-insensitive (percent signs optional, values always treated as percentages)
423
+ value = value.replace(/\bhsla\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*(-?(?:\d+|\d*\.\d+)%?)\s*\)/gi, (match, hueText, saturationText, lightnessText, alphaText) => {
424
+ const [r, g, b] = hslToRgbChannels(parseFloat(hueText), parseFloat(saturationText) / 100, parseFloat(lightnessText) / 100);
425
+ return rgbaToHex(r, g, b, parseAlphaString(alphaText));
426
+ });
427
+
428
+ // hsl()/hsla() comma syntax without alpha → hex, case-insensitive (percent signs optional, values always treated as percentages)
429
+ value = value.replace(/\bhsla?\(\s*(-?(?:\d+|\d*\.\d+))\s*,\s*((?:\d+|\d*\.\d+))%?\s*,\s*((?:\d+|\d*\.\d+))%?\s*\)/gi, (match, hueText, saturationText, lightnessText) => {
430
+ const [r, g, b] = hslToRgbChannels(parseFloat(hueText), parseFloat(saturationText) / 100, parseFloat(lightnessText) / 100);
431
+ return rgbaToHex(r, g, b, 1);
432
+ });
433
+
434
+ // rgb() comma syntax → hex, case-insensitive
435
+ value = value.replace(/\brgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/gi, (match, r, g, b) => {
436
+ return rgbaToHex(parseInt(r, 10), parseInt(g, 10), parseInt(b, 10), 1);
437
+ });
438
+ return value;
439
+ }
440
+
441
+ /**
442
+ * Converts CSS color functions (rgb, hsl, hwb, oklab, color-mix, etc.) to their
443
+ * shortest hex equivalents and applies hex shortening.
444
+ *
445
+ * @param {string} value The CSS value string with potential color functions.
446
+ * @return {string} The value with color functions converted to hex where shorter.
447
+ */
448
+ function convertColorsToHex (value) {
449
+ let converted = resolveComputedColors(value);
450
+ converted = convertLabFunctionsToHex(converted);
451
+ // Convert in-gamut oklch() to hex before precision rounding, so the full authored precision is used
452
+ converted = convertOklchFunctionsToHex(converted);
453
+ converted = compactColorFunctionArguments(converted);
454
+ converted = convertOklabFunctionsToHex(converted);
455
+ converted = replaceNoneInLegacyColorFunctions(converted);
456
+ converted = convertHwbFunctionsToHex(converted);
457
+ converted = convertSpaceSeparatedRgbAndHslToHex(converted);
458
+ return convertCommaSeparatedRgbAndHslToHex(converted);
459
+ }
460
+
461
+ export {
462
+ convertColorsToHex
463
+ };
@@ -7,7 +7,7 @@ import {
7
7
  skipQuotedString,
8
8
  splitTopLevelCommaList,
9
9
  startsUrlToken
10
- } from '../parser/source-search.js';
10
+ } from '../../parser/source-search.js';
11
11
 
12
12
  /**
13
13
  * Determines whether a character can appear inside a CSS identifier.
@@ -5,7 +5,8 @@
5
5
  import {
6
6
  findMatchingParenthesis,
7
7
  splitTopLevelCommaList
8
- } from '../parser/source-search.js';
8
+ } from '../../parser/source-search.js';
9
+ import { roundCompactNumber } from '../shared.js';
9
10
 
10
11
  import {
11
12
  convertOklchToHex,
@@ -14,8 +15,7 @@ import {
14
15
  rgbToOklab,
15
16
  rgbToOklch,
16
17
  rgbaToHex
17
- } from './colors.js';
18
- import { roundCompactNumber } from './shared.js';
18
+ } from './conversion.js';
19
19
 
20
20
  /**
21
21
  * Interpolates between two hue angles along the shorter arc, per the CSS Color specification.
@@ -6,14 +6,14 @@ import {
6
6
  findMatchingParenthesis,
7
7
  findTopLevelDelimiter,
8
8
  splitTopLevelComponents
9
- } from '../parser/source-search.js';
9
+ } from '../../parser/source-search.js';
10
+ import { parseAlphaString } from '../shared.js';
10
11
 
11
12
  import {
12
13
  parseColor,
13
14
  rgbaToHex,
14
15
  shortestColor
15
- } from './colors.js';
16
- import { parseAlphaString } from './shared.js';
16
+ } from './conversion.js';
17
17
 
18
18
  /**
19
19
  * Handle color(from ...) relative color syntax for simple identity cases.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @file Rewrites hex colors and named color keywords in their shortest equivalent spelling.
3
+ */
4
+
5
+ import { replaceOutsideStringsAndUrls } from '../segments.js';
6
+
7
+ import {
8
+ parseHex,
9
+ shortestColor
10
+ } from './conversion.js';
11
+ import { namedColors } from './named.js';
12
+
13
+ /**
14
+ * Regex matching hex color tokens (#rgb, #rgba, #rrggbb, #rrggbbaa) and CSS named
15
+ * color keywords. Hex patterns are ordered longest-first to avoid partial matches.
16
+ * Named colors are sorted longest-first so longer names like `darkslategray` are
17
+ * matched before shorter substrings. A named color only counts as a color keyword
18
+ * when it is a complete identifier, so hyphens on either side disqualify it (this
19
+ * keeps identifiers such as the custom property name in `var(--grey)` intact).
20
+ *
21
+ * @type {RegExp}
22
+ */
23
+ const COLOR_TOKEN_PATTERN = new RegExp(
24
+ '#[0-9a-fA-F]{8}(?![0-9a-fA-F])|' +
25
+ '#[0-9a-fA-F]{6}(?![0-9a-fA-F])|' +
26
+ '#[0-9a-fA-F]{4}(?![0-9a-fA-F])|' +
27
+ '#[0-9a-fA-F]{3}(?![0-9a-fA-F])|' +
28
+ '(?<![\\w-])(?:' +
29
+ Object.keys(namedColors).sort((first, second) => {
30
+ return second.length - first.length;
31
+ }).join('|') +
32
+ ')(?![\\w-])',
33
+ 'gi'
34
+ );
35
+
36
+ /**
37
+ * Replaces every hex color and named color keyword in a CSS value segment with
38
+ * the shortest equivalent representation, comparing full hex, shortened hex,
39
+ * and any matching named color keyword.
40
+ *
41
+ * @param {string} segment A CSS value segment (outside strings and urls).
42
+ * @param {boolean} rewritesEqualLengthSpelling Whether a spelling of the same length is worth switching to.
43
+ * @return {string} The segment with all colors shortened to their minimal form.
44
+ */
45
+ function shortenColorValues (segment, rewritesEqualLengthSpelling = true) {
46
+ // Match "color-mix(" as a whole word, case-insensitive
47
+ const hasColorMix = /\bcolor-mix\(/i.test(segment);
48
+ return segment.replace(COLOR_TOKEN_PATTERN, (match) => {
49
+ let channels;
50
+ if (match.startsWith('#')) {
51
+ channels = parseHex(match);
52
+ } else {
53
+ if (hasColorMix) {
54
+ return match;
55
+ }
56
+ const rgb = namedColors[match.toLowerCase()];
57
+ if (rgb) {
58
+ // transparent has alpha=0, all other named colors have alpha=1
59
+ const alpha = match.toLowerCase() === 'transparent' ? 0 : 1;
60
+ channels = [rgb[0], rgb[1], rgb[2], alpha];
61
+ }
62
+ }
63
+ if (!channels) {
64
+ return match;
65
+ }
66
+ const shortest = shortestColor(channels[0], channels[1], channels[2], channels[3]);
67
+ if (!rewritesEqualLengthSpelling && shortest.length >= match.length) {
68
+ return match;
69
+ }
70
+ return shortest;
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Lowercases every hex color token in a CSS value, since uppercase hex digits
76
+ * compress worse and are equivalent to their lowercase form.
77
+ *
78
+ * @param {string} value The CSS value string.
79
+ * @return {string} The value with all hex color tokens lowercased.
80
+ */
81
+ function lowercaseHexColors (value) {
82
+ return replaceOutsideStringsAndUrls(value, (segment) => {
83
+ // Match hex color tokens of 3 to 8 hex digits
84
+ return segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (hexColor) => {
85
+ return hexColor.toLowerCase();
86
+ });
87
+ });
88
+ }
89
+
90
+ export {
91
+ lowercaseHexColors,
92
+ shortenColorValues
93
+ };
@@ -10,7 +10,7 @@ import {
10
10
  import {
11
11
  parseHex,
12
12
  shortestColor
13
- } from './colors.js';
13
+ } from './color/conversion.js';
14
14
 
15
15
  /**
16
16
  * Checks whether a string is a valid gradient stop position consisting of one
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @file Collapses `hyphenate-limit-chars` values to their shortest equivalent form.
3
+ */
4
+
5
+ import { hasSubstitutedParts } from './shared.js';
6
+
7
+ /**
8
+ * Matches a trailing `!important` flag, so the components in front of it can be
9
+ * rewritten without the flag being mistaken for part of the last one.
10
+ *
11
+ * @type {RegExp}
12
+ */
13
+ const IMPORTANT_SUFFIX_PATTERN = /!important$/i;
14
+
15
+ /**
16
+ * Separates a value from the `!important` flag it may end with.
17
+ *
18
+ * @param {string} value The declaration value, possibly flagged important.
19
+ * @return {object} The components in front of the flag, and the flag itself or an empty string.
20
+ */
21
+ function splitImportantSuffix (value) {
22
+ const match = value.match(IMPORTANT_SUFFIX_PATTERN);
23
+ if (!match) {
24
+ return { components: value, importantSuffix: '' };
25
+ }
26
+ return {
27
+ components: value.slice(0, match.index).trim(),
28
+ importantSuffix: match[0]
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Collapses a `hyphenate-limit-chars` value to its shortest equivalent form.
34
+ * The property states up to three components: the shortest word that may be
35
+ * hyphenated, then the fewest characters allowed before a hyphen, then the
36
+ * fewest allowed after it. An unstated "before" component is read as `auto`,
37
+ * and an unstated "after" component repeats whatever "before" holds, so a
38
+ * trailing component that only restates the default it stands in for is
39
+ * redundant.
40
+ *
41
+ * @param {string} value The `hyphenate-limit-chars` value.
42
+ * @return {string} The value with its redundant trailing components dropped.
43
+ */
44
+ function collapseHyphenateLimitChars (value) {
45
+ const {
46
+ components,
47
+ importantSuffix
48
+ } = splitImportantSuffix(value);
49
+ const parts = components.split(' ').filter(Boolean);
50
+ if (hasSubstitutedParts(parts)) {
51
+ return value;
52
+ }
53
+ const isAfterMatchingBefore = (
54
+ parts.length === 3 &&
55
+ parts[2].toLowerCase() === parts[1].toLowerCase()
56
+ );
57
+ if (isAfterMatchingBefore) {
58
+ parts.pop();
59
+ }
60
+ const isBeforeLeftAtDefault = (
61
+ parts.length === 2 &&
62
+ parts[1].toLowerCase() === 'auto'
63
+ );
64
+ if (isBeforeLeftAtDefault) {
65
+ parts.pop();
66
+ }
67
+ return parts.join(' ') + importantSuffix;
68
+ }
69
+
70
+ export {
71
+ collapseHyphenateLimitChars
72
+ };