@thejaredwilcurt/csslop 0.0.19 → 0.0.21

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.
@@ -9,6 +9,7 @@ import { evaluateColorMix } from './color-mix.js';
9
9
  import {
10
10
  convertLabToHex,
11
11
  convertOklabToHex,
12
+ convertOklchToHex,
12
13
  hslToRgbChannels,
13
14
  hwbToRgbChannels,
14
15
  parseHex,
@@ -31,6 +32,7 @@ import {
31
32
  collapseShorthandParts,
32
33
  normalizeScaleComponent,
33
34
  parseAlphaString,
35
+ parseAngleToDegrees,
34
36
  roundCompactNumber
35
37
  } from './shared.js';
36
38
  import { findMatchingParenthesis } from './syntax.js';
@@ -57,7 +59,9 @@ const POSITION_AREA_SHORTHANDS = {
57
59
  * Regex matching hex color tokens (#rgb, #rgba, #rrggbb, #rrggbbaa) and CSS named
58
60
  * color keywords. Hex patterns are ordered longest-first to avoid partial matches.
59
61
  * Named colors are sorted longest-first so longer names like `darkslategray` are
60
- * matched before shorter substrings.
62
+ * matched before shorter substrings. A named color only counts as a color keyword
63
+ * when it is a complete identifier, so hyphens on either side disqualify it (this
64
+ * keeps identifiers such as the custom property name in `var(--grey)` intact).
61
65
  *
62
66
  * @type {RegExp}
63
67
  */
@@ -66,11 +70,11 @@ const COLOR_TOKEN_PATTERN = new RegExp(
66
70
  '#[0-9a-fA-F]{6}(?![0-9a-fA-F])|' +
67
71
  '#[0-9a-fA-F]{4}(?![0-9a-fA-F])|' +
68
72
  '#[0-9a-fA-F]{3}(?![0-9a-fA-F])|' +
69
- '\\b(?:' +
73
+ '(?<![\\w-])(?:' +
70
74
  Object.keys(namedColors).sort((a, b) => {
71
75
  return b.length - a.length;
72
76
  }).join('|') +
73
- ')\\b',
77
+ ')(?![\\w-])',
74
78
  'gi'
75
79
  );
76
80
 
@@ -178,6 +182,37 @@ function replaceOutsideStringsAndUrls (value, replacer) {
178
182
  return result;
179
183
  }
180
184
 
185
+ /**
186
+ * Lowercases every hex color token in a CSS value, since uppercase hex digits
187
+ * compress worse and are equivalent to their lowercase form.
188
+ *
189
+ * @param {string} value The CSS value string.
190
+ * @return {string} The value with all hex color tokens lowercased.
191
+ */
192
+ function lowercaseHexColors (value) {
193
+ return replaceOutsideStringsAndUrls(value, (segment) => {
194
+ // Match hex color tokens of 3 to 8 hex digits
195
+ return segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (hexColor) => {
196
+ return hexColor.toLowerCase();
197
+ });
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Removes whitespace that precedes a hex color token. A `#` unambiguously starts
203
+ * a hash token in CSS, so no separator is required between it and a preceding
204
+ * ident, keyword, or number (e.g. `1px solid #f00` becomes `1px solid#f00`).
205
+ *
206
+ * @param {string} value The CSS value string.
207
+ * @return {string} The value with spaces before hex colors removed.
208
+ */
209
+ function elideSpaceBeforeHexColors (value) {
210
+ return replaceOutsideStringsAndUrls(value, (segment) => {
211
+ // Match whitespace followed by a hex color token of 3 to 8 hex digits
212
+ return segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
213
+ });
214
+ }
215
+
181
216
  /**
182
217
  * Chooses the shortest valid representation for the path inside a `url(...)`
183
218
  * token, weighing an unquoted form, an escaped single space, and a quoted form.
@@ -336,6 +371,97 @@ function normalizeWhitespaceAndQuotes (val, property) {
336
371
  return val;
337
372
  }
338
373
 
374
+ /**
375
+ * The OKLCH chroma value that `100%` resolves to, per CSS Color Level 4.
376
+ *
377
+ * @type {number}
378
+ */
379
+ const OKLCH_CHROMA_PERCENT_REFERENCE = 0.4;
380
+
381
+ /**
382
+ * Regex matching an `oklch()` function with three space-separated components
383
+ * and an optional slash-delimited alpha. Lightness and chroma accept numbers
384
+ * or percentages, hue accepts a number with an optional CSS angle unit, and
385
+ * every component accepts the `none` keyword.
386
+ *
387
+ * @type {RegExp}
388
+ */
389
+ const OKLCH_FUNCTION_PATTERN = new RegExp(
390
+ '\\boklch\\(\\s*' +
391
+ '(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
392
+ '(none|-?(?:\\d+|\\d*\\.\\d+)%?)\\s+' +
393
+ '(none|-?(?:\\d+|\\d*\\.\\d+)(?:deg|grad|rad|turn)?)' +
394
+ '(?:\\s*/\\s*(none|-?(?:\\d+|\\d*\\.\\d+)%?))?' +
395
+ '\\s*\\)',
396
+ 'gi'
397
+ );
398
+
399
+ /**
400
+ * Parses an OKLCH lightness or chroma component into its numeric value.
401
+ * Missing components (`none`) resolve to zero, and percentages are scaled
402
+ * against the reference value for that component.
403
+ *
404
+ * @param {string} token The raw component token.
405
+ * @param {number} percentReference The value that `100%` represents for this component.
406
+ * @return {number|null} The numeric component value, or null when unparsable.
407
+ */
408
+ function parseOklchComponent (token, percentReference) {
409
+ const normalized = token.trim().toLowerCase();
410
+ if (normalized === 'none') {
411
+ return 0;
412
+ }
413
+ const numeric = parseFloat(normalized);
414
+ if (!Number.isFinite(numeric)) {
415
+ return null;
416
+ }
417
+ if (normalized.endsWith('%')) {
418
+ return numeric / 100 * percentReference;
419
+ }
420
+ return numeric;
421
+ }
422
+
423
+ /**
424
+ * Parses an OKLCH hue component into degrees, treating `none` as zero.
425
+ *
426
+ * @param {string} token The raw hue token, optionally carrying an angle unit.
427
+ * @return {number|null} The hue in degrees, or null when unparsable.
428
+ */
429
+ function parseOklchHue (token) {
430
+ const normalized = token.trim().toLowerCase();
431
+ if (normalized === 'none') {
432
+ return 0;
433
+ }
434
+ return parseAngleToDegrees(normalized);
435
+ }
436
+
437
+ /**
438
+ * Converts `oklch()` colors that fall inside the sRGB gamut to their hex
439
+ * equivalent when that is shorter. Out-of-gamut colors have no sRGB
440
+ * representation, so they are left in their native color space.
441
+ *
442
+ * @param {string} value The CSS value string that may contain oklch() colors.
443
+ * @return {string} The value with in-gamut oklch() colors replaced by hex.
444
+ */
445
+ function convertOklchFunctionsToHex (value) {
446
+ return value.replace(OKLCH_FUNCTION_PATTERN, (match, lightnessToken, chromaToken, hueToken, alphaToken) => {
447
+ const lightness = parseOklchComponent(lightnessToken, 1);
448
+ const chroma = parseOklchComponent(chromaToken, OKLCH_CHROMA_PERCENT_REFERENCE);
449
+ const hue = parseOklchHue(hueToken);
450
+ if (lightness === null || chroma === null || hue === null) {
451
+ return match;
452
+ }
453
+ const alpha = alphaToken?.trim().toLowerCase() === 'none' ? 0 : parseAlphaString(alphaToken);
454
+ const hex = convertOklchToHex(lightness, chroma, hue, alpha);
455
+ if (!hex) {
456
+ return match; // out-of-gamut: keep native oklch form
457
+ }
458
+ if (hex.length < match.length) {
459
+ return hex;
460
+ }
461
+ return match;
462
+ });
463
+ }
464
+
339
465
  /**
340
466
  * Converts CSS color functions (rgb, hsl, hwb, oklab, color-mix, etc.) to their
341
467
  * shortest hex equivalents and applies hex shortening.
@@ -378,6 +504,9 @@ function convertColorsToHex (val) {
378
504
  return match;
379
505
  });
380
506
 
507
+ // Convert in-gamut oklch() to hex before precision rounding, so the full authored precision is used
508
+ val = convertOklchFunctionsToHex(val);
509
+
381
510
  // Minify whitespace and numeric precision inside wide-gamut and functional color notations
382
511
  val = val.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, func, inner) => {
383
512
  // Collapse whitespace to single space
@@ -586,11 +715,12 @@ function convertMillisecondsToSeconds (value) {
586
715
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
587
716
  * background, display, scale, border-radius, shorthand collapsing, etc.).
588
717
  *
589
- * @param {string} val The CSS value string after generic minification.
590
- * @param {string} property The CSS property name.
591
- * @return {string} The value with property-specific optimizations applied.
718
+ * @param {string} val The CSS value string after generic minification.
719
+ * @param {string} property The CSS property name.
720
+ * @param {boolean} allowsHexSpaceElision Whether the space preceding a hex color may be removed.
721
+ * @return {string} The value with property-specific optimizations applied.
592
722
  */
593
- function applyPropertyOptimizations (val, property) {
723
+ function applyPropertyOptimizations (val, property, allowsHexSpaceElision) {
594
724
  if (property === 'font-weight' && isUnicodeCharset()) {
595
725
  // Replace font-weight keyword "bold" with its numeric equivalent
596
726
  val = val.replace(/\bbold\b/gi, '700');
@@ -763,12 +893,9 @@ function applyPropertyOptimizations (val, property) {
763
893
  val = replaceOutsideStringsAndUrls(val, shortenColorValues);
764
894
 
765
895
  // Remove space before hex colors (second pass after color evaluations)
766
- val = replaceOutsideStringsAndUrls(val, (segment) => {
767
- // Preserve space after border style keywords (solid, dashed, etc.) before hex colors
768
- segment = segment.replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)\s+#([0-9a-fA-F]{3,8})\b/gi, '$1 #$2');
769
- // Then remove other spaces before hex colors
770
- return segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
771
- });
896
+ if (allowsHexSpaceElision) {
897
+ val = elideSpaceBeforeHexColors(val);
898
+ }
772
899
  if (property !== 'transform' && property !== 'background' && property !== 'src') {
773
900
  // Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
774
901
  val = val.replace(/\)(?=[0-9a-zA-Z#-])/g, ') ');
@@ -815,9 +942,6 @@ function applyPropertyOptimizations (val, property) {
815
942
  if (property === 'border') {
816
943
  // Remove default "medium" border-width keyword
817
944
  val = val.replace(/\bmedium\s+/g, '');
818
- // Restore missing space between border-style and a 4-digit hex color (with alpha) when they are adjacent
819
- // This is needed because solid#0000 could be parsed as solid followed by #000 followed by position 0
820
- val = val.replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)#([0-9a-fA-F]{4})\b/gi, '$1 #$2');
821
945
  }
822
946
 
823
947
  if (property === 'outline') {
@@ -886,6 +1010,10 @@ function minifyValue (declaration) {
886
1010
  return 'none';
887
1011
  }
888
1012
  let val = declaration.value;
1013
+ // Values assembled from already-minified longhands keep the separator spaces
1014
+ // between their components, because those spaces delimit the shorthand's
1015
+ // parts rather than the authored whitespace of a single written value.
1016
+ const allowsHexSpaceElision = !declaration.isAssembledShorthand;
889
1017
 
890
1018
  if (typeof val === 'string') {
891
1019
  val = val.trim();
@@ -928,20 +1056,10 @@ function minifyValue (declaration) {
928
1056
  val = roundCompactNumber(rawNumber, 4) + rawUnit;
929
1057
  }
930
1058
 
931
- // Remove space before hex colors
932
- val = replaceOutsideStringsAndUrls(val, (segment) => {
933
- // Preserve space after border style keywords by using a temporary placeholder
934
- segment = segment.replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)\s+#([0-9a-fA-F]{3,8})\b/gi, '$1__BORDER_SPACE__#$2');
935
- // Remove other spaces before hex colors
936
- segment = segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
937
- // Restore the preserved space
938
- segment = segment.replace(/__BORDER_SPACE__#/g, ' #');
939
- // Lowercase hex color tokens for consistency and shorter output
940
- segment = segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (m) => {
941
- return m.toLowerCase();
942
- });
943
- return segment;
944
- });
1059
+ val = lowercaseHexColors(val);
1060
+ if (allowsHexSpaceElision) {
1061
+ val = elideSpaceBeforeHexColors(val);
1062
+ }
945
1063
 
946
1064
  // Convert color functions to hex equivalents
947
1065
  val = convertColorsToHex(val);
@@ -953,7 +1071,7 @@ function minifyValue (declaration) {
953
1071
  val = simplifyEquivalentLightDarkFunctions(val);
954
1072
 
955
1073
  // Property-specific optimizations
956
- val = applyPropertyOptimizations(val, declaration.property);
1074
+ val = applyPropertyOptimizations(val, declaration.property, allowsHexSpaceElision);
957
1075
 
958
1076
  // Minify relative color syntax (identity resolution and whitespace collapsing)
959
1077
  val = minifyRelativeColorSyntax(val);
@@ -113,6 +113,36 @@ function parseAlphaString (alphaStr, fallback = 1) {
113
113
  return parseFloat(alphaStr);
114
114
  }
115
115
 
116
+ /**
117
+ * Conversion factors from each CSS angle unit to degrees.
118
+ *
119
+ * @type {{[key: string]: number}}
120
+ */
121
+ const ANGLE_UNIT_TO_DEGREES = {
122
+ deg: 1,
123
+ grad: 360 / 400,
124
+ rad: 180 / Math.PI,
125
+ turn: 360
126
+ };
127
+
128
+ /**
129
+ * Parses a CSS angle token (e.g. "90", "90deg", ".25turn") into degrees.
130
+ * Unitless values are treated as degrees, per the CSS Color specification's
131
+ * handling of hue components.
132
+ *
133
+ * @param {string} angleToken The angle token, with or without a unit suffix.
134
+ * @return {number|null} The angle in degrees, or null if the token is not a valid angle.
135
+ */
136
+ function parseAngleToDegrees (angleToken) {
137
+ // Capture the numeric portion and an optional CSS angle unit suffix
138
+ const match = String(angleToken).trim().match(/^(-?(?:\d+|\d*\.\d+))(deg|grad|rad|turn)?$/i);
139
+ if (!match) {
140
+ return null;
141
+ }
142
+ const unit = match[2] ? match[2].toLowerCase() : 'deg';
143
+ return parseFloat(match[1]) * ANGLE_UNIT_TO_DEGREES[unit];
144
+ }
145
+
116
146
  /**
117
147
  * Collapses redundant CSS shorthand parts using the standard box-model
118
148
  * reduction rules: 4-value → 3-value → 2-value → 1-value.
@@ -142,5 +172,6 @@ export {
142
172
  formatDimension,
143
173
  normalizeScaleComponent,
144
174
  parseAlphaString,
175
+ parseAngleToDegrees,
145
176
  roundCompactNumber
146
177
  };
@@ -53,4 +53,85 @@ function findMatchingParenthesis (value, openParenIndex) {
53
53
  return -1;
54
54
  }
55
55
 
56
- export { findMatchingParenthesis };
56
+ /**
57
+ * Splits a CSS value into its top-level components, keeping parenthesized
58
+ * function arguments and quoted strings intact. Components are separated by
59
+ * whitespace, or by a `#`, which always starts a hash token and therefore ends
60
+ * any component already in progress.
61
+ *
62
+ * For example, `rgb(0 0 0) red` yields `["rgb(0 0 0)", "red"]`, and the
63
+ * minified `red#00f` yields `["red", "#00f"]`.
64
+ *
65
+ * @param {string} value The CSS value to split.
66
+ * @return {Array} The top-level components of the value.
67
+ */
68
+ function splitTopLevelComponents (value) {
69
+ const components = [];
70
+ let current = '';
71
+ let depth = 0;
72
+ let activeQuote = '';
73
+ let index = 0;
74
+
75
+ while (index < value.length) {
76
+ const character = value[index];
77
+
78
+ if (activeQuote) {
79
+ current += character;
80
+ if (character === '\\') {
81
+ current += value[index + 1] ?? '';
82
+ index += 2;
83
+ continue;
84
+ }
85
+ if (character === activeQuote) {
86
+ activeQuote = '';
87
+ }
88
+ index++;
89
+ continue;
90
+ }
91
+
92
+ if (character === '"' || character === '\'') {
93
+ activeQuote = character;
94
+ current += character;
95
+ index++;
96
+ continue;
97
+ }
98
+
99
+ if (character === '(') {
100
+ depth++;
101
+ }
102
+ if (character === ')' && depth > 0) {
103
+ depth--;
104
+ }
105
+
106
+ // Match any whitespace character, which separates components at depth zero
107
+ const isSeparator = depth === 0 && /\s/.test(character);
108
+ if (isSeparator) {
109
+ if (current) {
110
+ components.push(current);
111
+ current = '';
112
+ }
113
+ index++;
114
+ continue;
115
+ }
116
+
117
+ const startsHashToken = character === '#' && depth === 0 && current !== '';
118
+ if (startsHashToken) {
119
+ components.push(current);
120
+ current = '';
121
+ }
122
+
123
+ current += character;
124
+ index++;
125
+ }
126
+
127
+ if (current) {
128
+ components.push(current);
129
+ }
130
+
131
+ return components;
132
+ }
133
+
134
+ export {
135
+ findMatchingParenthesis,
136
+ splitTopLevelComponents
137
+ };