@thejaredwilcurt/csslop 0.0.20 → 0.0.22

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.
@@ -30,6 +30,7 @@ import {
30
30
  } from './relative-colors.js';
31
31
  import {
32
32
  collapseShorthandParts,
33
+ convertAbsoluteLengthToPx,
33
34
  normalizeScaleComponent,
34
35
  parseAlphaString,
35
36
  parseAngleToDegrees,
@@ -59,7 +60,9 @@ const POSITION_AREA_SHORTHANDS = {
59
60
  * Regex matching hex color tokens (#rgb, #rgba, #rrggbb, #rrggbbaa) and CSS named
60
61
  * color keywords. Hex patterns are ordered longest-first to avoid partial matches.
61
62
  * Named colors are sorted longest-first so longer names like `darkslategray` are
62
- * matched before shorter substrings.
63
+ * matched before shorter substrings. A named color only counts as a color keyword
64
+ * when it is a complete identifier, so hyphens on either side disqualify it (this
65
+ * keeps identifiers such as the custom property name in `var(--grey)` intact).
63
66
  *
64
67
  * @type {RegExp}
65
68
  */
@@ -68,11 +71,11 @@ const COLOR_TOKEN_PATTERN = new RegExp(
68
71
  '#[0-9a-fA-F]{6}(?![0-9a-fA-F])|' +
69
72
  '#[0-9a-fA-F]{4}(?![0-9a-fA-F])|' +
70
73
  '#[0-9a-fA-F]{3}(?![0-9a-fA-F])|' +
71
- '\\b(?:' +
74
+ '(?<![\\w-])(?:' +
72
75
  Object.keys(namedColors).sort((a, b) => {
73
76
  return b.length - a.length;
74
77
  }).join('|') +
75
- ')\\b',
78
+ ')(?![\\w-])',
76
79
  'gi'
77
80
  );
78
81
 
@@ -180,6 +183,37 @@ function replaceOutsideStringsAndUrls (value, replacer) {
180
183
  return result;
181
184
  }
182
185
 
186
+ /**
187
+ * Lowercases every hex color token in a CSS value, since uppercase hex digits
188
+ * compress worse and are equivalent to their lowercase form.
189
+ *
190
+ * @param {string} value The CSS value string.
191
+ * @return {string} The value with all hex color tokens lowercased.
192
+ */
193
+ function lowercaseHexColors (value) {
194
+ return replaceOutsideStringsAndUrls(value, (segment) => {
195
+ // Match hex color tokens of 3 to 8 hex digits
196
+ return segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (hexColor) => {
197
+ return hexColor.toLowerCase();
198
+ });
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Removes whitespace that precedes a hex color token. A `#` unambiguously starts
204
+ * a hash token in CSS, so no separator is required between it and a preceding
205
+ * ident, keyword, or number (e.g. `1px solid #f00` becomes `1px solid#f00`).
206
+ *
207
+ * @param {string} value The CSS value string.
208
+ * @return {string} The value with spaces before hex colors removed.
209
+ */
210
+ function elideSpaceBeforeHexColors (value) {
211
+ return replaceOutsideStringsAndUrls(value, (segment) => {
212
+ // Match whitespace followed by a hex color token of 3 to 8 hex digits
213
+ return segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
214
+ });
215
+ }
216
+
183
217
  /**
184
218
  * Chooses the shortest valid representation for the path inside a `url(...)`
185
219
  * token, weighing an unquoted form, an escaped single space, and a quoted form.
@@ -678,15 +712,59 @@ function convertMillisecondsToSeconds (value) {
678
712
  });
679
713
  }
680
714
 
715
+ /**
716
+ * Matches an absolute CSS length token: an optionally signed number followed by
717
+ * an absolute length unit. The lookbehind rejects digits that belong to a larger
718
+ * identifier, such as the custom property name in `var(--size-2in)`, where the
719
+ * digits and unit do not form a value of their own.
720
+ *
721
+ * @type {RegExp}
722
+ */
723
+ const ABSOLUTE_LENGTH_PATTERN = /(?<![\w#.%-])(-?(?:\d+|\d*\.\d+))(pt|pc|in|cm|mm|q)\b/gi;
724
+
725
+ /**
726
+ * The largest difference in pixels that a rounded conversion may introduce and
727
+ * still count as exact, which allows for binary floating point error without
728
+ * allowing a visible change to the rendered length.
729
+ *
730
+ * @type {number}
731
+ */
732
+ const PIXEL_ROUNDING_TOLERANCE = 1e-6;
733
+
734
+ /**
735
+ * Converts absolute length values (pt, pc, in, cm, mm, Q) to their pixel
736
+ * equivalent when the conversion is exact and the pixel form is no longer than
737
+ * the original. Normalizing to px also improves compression by reducing the
738
+ * number of distinct unit strings in the output.
739
+ *
740
+ * @param {string} value A CSS value segment, outside strings and urls.
741
+ * @return {string} The segment with eligible absolute lengths converted to px.
742
+ */
743
+ function convertAbsoluteLengthsToPx (value) {
744
+ return value.replace(ABSOLUTE_LENGTH_PATTERN, (token, amount, unit) => {
745
+ const pixels = convertAbsoluteLengthToPx(amount, unit);
746
+ if (pixels === null) {
747
+ return token;
748
+ }
749
+ const converted = roundCompactNumber(pixels) + 'px';
750
+ const isExact = Math.abs(parseFloat(converted) - pixels) < PIXEL_ROUNDING_TOLERANCE;
751
+ if (!isExact || converted.length > token.length) {
752
+ return token;
753
+ }
754
+ return converted;
755
+ });
756
+ }
757
+
681
758
  /**
682
759
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
683
760
  * background, display, scale, border-radius, shorthand collapsing, etc.).
684
761
  *
685
- * @param {string} val The CSS value string after generic minification.
686
- * @param {string} property The CSS property name.
687
- * @return {string} The value with property-specific optimizations applied.
762
+ * @param {string} val The CSS value string after generic minification.
763
+ * @param {string} property The CSS property name.
764
+ * @param {boolean} allowsHexSpaceElision Whether the space preceding a hex color may be removed.
765
+ * @return {string} The value with property-specific optimizations applied.
688
766
  */
689
- function applyPropertyOptimizations (val, property) {
767
+ function applyPropertyOptimizations (val, property, allowsHexSpaceElision) {
690
768
  if (property === 'font-weight' && isUnicodeCharset()) {
691
769
  // Replace font-weight keyword "bold" with its numeric equivalent
692
770
  val = val.replace(/\bbold\b/gi, '700');
@@ -833,11 +911,11 @@ function applyPropertyOptimizations (val, property) {
833
911
  });
834
912
  }
835
913
 
836
- if (property === 'font-size') {
837
- // Convert point (pt) font-size values to their pixel (px) equivalent
838
- val = val.replace(/^(-?(?:\d+|\d*\.\d+))pt$/i, (match, amount) => {
839
- return roundCompactNumber(parseFloat(amount) * (96 / 72)) + 'px';
840
- });
914
+ // Custom properties hold an arbitrary token stream rather than a typed value,
915
+ // so a unit-like token in one is not necessarily a length.
916
+ const isCustomProperty = Boolean(property) && property.startsWith('--');
917
+ if (!isCustomProperty) {
918
+ val = replaceOutsideStringsAndUrls(val, convertAbsoluteLengthsToPx);
841
919
  }
842
920
 
843
921
  if (property === 'syntax') {
@@ -859,12 +937,9 @@ function applyPropertyOptimizations (val, property) {
859
937
  val = replaceOutsideStringsAndUrls(val, shortenColorValues);
860
938
 
861
939
  // Remove space before hex colors (second pass after color evaluations)
862
- val = replaceOutsideStringsAndUrls(val, (segment) => {
863
- // Preserve space after border style keywords (solid, dashed, etc.) before hex colors
864
- 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');
865
- // Then remove other spaces before hex colors
866
- return segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
867
- });
940
+ if (allowsHexSpaceElision) {
941
+ val = elideSpaceBeforeHexColors(val);
942
+ }
868
943
  if (property !== 'transform' && property !== 'background' && property !== 'src') {
869
944
  // Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
870
945
  val = val.replace(/\)(?=[0-9a-zA-Z#-])/g, ') ');
@@ -911,9 +986,6 @@ function applyPropertyOptimizations (val, property) {
911
986
  if (property === 'border') {
912
987
  // Remove default "medium" border-width keyword
913
988
  val = val.replace(/\bmedium\s+/g, '');
914
- // Restore missing space between border-style and a 4-digit hex color (with alpha) when they are adjacent
915
- // This is needed because solid#0000 could be parsed as solid followed by #000 followed by position 0
916
- val = val.replace(/\b(solid|dashed|dotted|double|groove|ridge|inset|outset|hidden|none)#([0-9a-fA-F]{4})\b/gi, '$1 #$2');
917
989
  }
918
990
 
919
991
  if (property === 'outline') {
@@ -982,6 +1054,10 @@ function minifyValue (declaration) {
982
1054
  return 'none';
983
1055
  }
984
1056
  let val = declaration.value;
1057
+ // Values assembled from already-minified longhands keep the separator spaces
1058
+ // between their components, because those spaces delimit the shorthand's
1059
+ // parts rather than the authored whitespace of a single written value.
1060
+ const allowsHexSpaceElision = !declaration.isAssembledShorthand;
985
1061
 
986
1062
  if (typeof val === 'string') {
987
1063
  val = val.trim();
@@ -1024,20 +1100,10 @@ function minifyValue (declaration) {
1024
1100
  val = roundCompactNumber(rawNumber, 4) + rawUnit;
1025
1101
  }
1026
1102
 
1027
- // Remove space before hex colors
1028
- val = replaceOutsideStringsAndUrls(val, (segment) => {
1029
- // Preserve space after border style keywords by using a temporary placeholder
1030
- 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');
1031
- // Remove other spaces before hex colors
1032
- segment = segment.replace(/\s+#([0-9a-fA-F]{3,8})\b/gi, '#$1');
1033
- // Restore the preserved space
1034
- segment = segment.replace(/__BORDER_SPACE__#/g, ' #');
1035
- // Lowercase hex color tokens for consistency and shorter output
1036
- segment = segment.replace(/#([0-9a-fA-F]{3,8})\b/gi, (m) => {
1037
- return m.toLowerCase();
1038
- });
1039
- return segment;
1040
- });
1103
+ val = lowercaseHexColors(val);
1104
+ if (allowsHexSpaceElision) {
1105
+ val = elideSpaceBeforeHexColors(val);
1106
+ }
1041
1107
 
1042
1108
  // Convert color functions to hex equivalents
1043
1109
  val = convertColorsToHex(val);
@@ -1049,7 +1115,7 @@ function minifyValue (declaration) {
1049
1115
  val = simplifyEquivalentLightDarkFunctions(val);
1050
1116
 
1051
1117
  // Property-specific optimizations
1052
- val = applyPropertyOptimizations(val, declaration.property);
1118
+ val = applyPropertyOptimizations(val, declaration.property, allowsHexSpaceElision);
1053
1119
 
1054
1120
  // Minify relative color syntax (identity resolution and whitespace collapsing)
1055
1121
  val = minifyRelativeColorSyntax(val);
@@ -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
+ };