@thejaredwilcurt/csslop 0.0.16 → 0.0.18

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,188 @@
1
+ /**
2
+ * @file Simplifies CSS light-dark functions whose light and dark values are equivalent.
3
+ */
4
+
5
+ import { findMatchingParenthesis } from './syntax.js';
6
+
7
+ /**
8
+ * Determines whether a character can appear inside a CSS identifier.
9
+ *
10
+ * @param {string} character A single character.
11
+ * @return {boolean} True when the character is identifier-like.
12
+ */
13
+ function isIdentifierCharacter (character) {
14
+ if (!character) {
15
+ return false;
16
+ }
17
+ const codePoint = character.charCodeAt(0);
18
+ const isUppercaseLetter = codePoint >= 65 && codePoint <= 90;
19
+ const isLowercaseLetter = codePoint >= 97 && codePoint <= 122;
20
+ const isDigit = codePoint >= 48 && codePoint <= 57;
21
+ return isUppercaseLetter || isLowercaseLetter || isDigit || character === '_' || character === '-';
22
+ }
23
+
24
+ /**
25
+ * Detects a `light-dark(` function call at the provided position.
26
+ *
27
+ * @param {string} value The CSS value being scanned.
28
+ * @param {number} index The candidate start index.
29
+ * @return {boolean} True when a light-dark function starts at index.
30
+ */
31
+ function startsLightDarkFunction (value, index) {
32
+ if (value.slice(index, index + 11).toLowerCase() !== 'light-dark(') {
33
+ return false;
34
+ }
35
+ return !isIdentifierCharacter(value[index - 1]);
36
+ }
37
+
38
+ /**
39
+ * Splits a CSS function argument list at top-level commas while respecting
40
+ * nested parentheses and quoted strings.
41
+ *
42
+ * @param {string} argumentString The raw content between function parentheses.
43
+ * @return {Array} The top-level argument strings.
44
+ */
45
+ function splitTopLevelFunctionArguments (argumentString) {
46
+ const argumentsList = [];
47
+ let currentArgument = '';
48
+ let depth = 0;
49
+ let index = 0;
50
+ let activeQuote = '';
51
+
52
+ while (index < argumentString.length) {
53
+ const character = argumentString[index];
54
+ if (activeQuote) {
55
+ currentArgument += character;
56
+ if (character === '\\') {
57
+ if (index + 1 < argumentString.length) {
58
+ currentArgument += argumentString[index + 1];
59
+ index += 2;
60
+ continue;
61
+ }
62
+ } else if (character === activeQuote) {
63
+ activeQuote = '';
64
+ }
65
+ index++;
66
+ continue;
67
+ }
68
+
69
+ if (character === '"' || character === '\'') {
70
+ activeQuote = character;
71
+ currentArgument += character;
72
+ index++;
73
+ continue;
74
+ }
75
+
76
+ if (character === '(') {
77
+ depth++;
78
+ currentArgument += character;
79
+ index++;
80
+ continue;
81
+ }
82
+
83
+ if (character === ')') {
84
+ if (depth > 0) {
85
+ depth--;
86
+ }
87
+ currentArgument += character;
88
+ index++;
89
+ continue;
90
+ }
91
+
92
+ if (character === ',' && depth === 0) {
93
+ argumentsList.push(currentArgument.trim());
94
+ currentArgument = '';
95
+ index++;
96
+ continue;
97
+ }
98
+
99
+ currentArgument += character;
100
+ index++;
101
+ }
102
+
103
+ argumentsList.push(currentArgument.trim());
104
+ return argumentsList;
105
+ }
106
+
107
+ /**
108
+ * Simplifies `light-dark(a,b)` to `a` when both top-level arguments are identical
109
+ * after prior minification has normalized them.
110
+ *
111
+ * @param {string} value The CSS value to simplify.
112
+ * @return {string} The value with redundant light-dark functions removed.
113
+ */
114
+ function simplifyEquivalentLightDarkFunctions (value) {
115
+ let result = '';
116
+ let index = 0;
117
+
118
+ const consumeQuoted = (start) => {
119
+ const quote = value[start];
120
+ let end = start + 1;
121
+ while (end < value.length) {
122
+ if (value[end] === '\\') {
123
+ end += 2;
124
+ continue;
125
+ }
126
+ if (value[end] === quote) {
127
+ end++;
128
+ break;
129
+ }
130
+ end++;
131
+ }
132
+ return end;
133
+ };
134
+
135
+ const startsUrl = (start) => {
136
+ return value.slice(start, start + 4).toLowerCase() === 'url(';
137
+ };
138
+
139
+ while (index < value.length) {
140
+ if (value[index] === '"' || value[index] === '\'') {
141
+ const end = consumeQuoted(index);
142
+ result += value.slice(index, end);
143
+ index = end;
144
+ continue;
145
+ }
146
+
147
+ if (startsUrl(index)) {
148
+ const end = findMatchingParenthesis(value, index + 3);
149
+ if (end === -1) {
150
+ result += value.slice(index);
151
+ break;
152
+ }
153
+ result += value.slice(index, end + 1);
154
+ index = end + 1;
155
+ continue;
156
+ }
157
+
158
+ if (startsLightDarkFunction(value, index)) {
159
+ const openParenIndex = index + 10;
160
+ const closingParenIndex = findMatchingParenthesis(value, openParenIndex);
161
+ if (closingParenIndex === -1) {
162
+ result += value.slice(index);
163
+ break;
164
+ }
165
+
166
+ const argumentString = value.slice(openParenIndex + 1, closingParenIndex);
167
+ const argumentsList = splitTopLevelFunctionArguments(argumentString);
168
+ if (argumentsList.length === 2) {
169
+ const firstArgument = simplifyEquivalentLightDarkFunctions(argumentsList[0]);
170
+ const secondArgument = simplifyEquivalentLightDarkFunctions(argumentsList[1]);
171
+ if (firstArgument === secondArgument) {
172
+ result += firstArgument;
173
+ } else {
174
+ result += value.slice(index, openParenIndex + 1) + firstArgument + ',' + secondArgument + ')';
175
+ }
176
+ index = closingParenIndex + 1;
177
+ continue;
178
+ }
179
+ }
180
+
181
+ result += value[index];
182
+ index++;
183
+ }
184
+
185
+ return result;
186
+ }
187
+
188
+ export { simplifyEquivalentLightDarkFunctions };
@@ -5,10 +5,10 @@
5
5
  import { isUnicodeCharset } from '../context.js';
6
6
  import { resolveUnicodeEscape } from '../utilities.js';
7
7
 
8
+ import { evaluateColorMix } from './color-mix.js';
8
9
  import {
10
+ convertLabToHex,
9
11
  convertOklabToHex,
10
- evaluateColorMix,
11
- evaluateRelativeColor,
12
12
  hslToRgbChannels,
13
13
  hwbToRgbChannels,
14
14
  parseHex,
@@ -16,18 +16,24 @@ import {
16
16
  shortestColor
17
17
  } from './colors.js';
18
18
  import { minifyGradients } from './gradients.js';
19
+ import { simplifyEquivalentLightDarkFunctions } from './light-dark.js';
19
20
  import {
20
21
  normalizeMathFunctions,
21
22
  simplifyStandaloneCalc
22
23
  } from './math.js';
23
24
  import { namedColors } from './named-colors.js';
24
25
  import { isQuotesNoneEquivalent } from './quotes.js';
26
+ import {
27
+ evaluateRelativeColor,
28
+ minifyRelativeColorSyntax
29
+ } from './relative-colors.js';
25
30
  import {
26
31
  collapseShorthandParts,
27
32
  normalizeScaleComponent,
28
33
  parseAlphaString,
29
34
  roundCompactNumber
30
35
  } from './shared.js';
36
+ import { findMatchingParenthesis } from './syntax.js';
31
37
  import { minifyTransformValue } from './transforms.js';
32
38
  import { optimizeUnicodeRange } from './unicode-range.js';
33
39
 
@@ -173,164 +179,63 @@ function replaceOutsideStringsAndUrls (value, replacer) {
173
179
  }
174
180
 
175
181
  /**
176
- * Determines whether a character can appear inside a CSS identifier.
182
+ * Chooses the shortest valid representation for the path inside a `url(...)`
183
+ * token, weighing an unquoted form, an escaped single space, and a quoted form.
177
184
  *
178
- * @param {string} character A single character.
179
- * @return {boolean} True when the character is identifier-like.
185
+ * @param {string} path The resolved url path, without surrounding quotes.
186
+ * @return {string} The shortest valid url() content string.
180
187
  */
181
- function isIdentifierCharacter (character) {
182
- if (!character) {
183
- return false;
184
- }
185
- const codePoint = character.charCodeAt(0);
186
- const isUppercaseLetter = codePoint >= 65 && codePoint <= 90;
187
- const isLowercaseLetter = codePoint >= 97 && codePoint <= 122;
188
- const isDigit = codePoint >= 48 && codePoint <= 57;
189
- return isUppercaseLetter || isLowercaseLetter || isDigit || character === '_' || character === '-';
190
- }
188
+ function formatUrlPath (path) {
189
+ // Parentheses and quote characters are invalid inside an unquoted url() token
190
+ const hasQuoteForcingCharacters = /[()"']/.test(path);
191
+ // Count spaces so escaping them can be compared against keeping the quotes
192
+ const spaceCount = (path.match(/ /g) || []).length;
191
193
 
192
- /**
193
- * Detects a `light-dark(` function call at the provided position.
194
- *
195
- * @param {string} value The CSS value being scanned.
196
- * @param {number} index The candidate start index.
197
- * @return {boolean} True when a light-dark function starts at index.
198
- */
199
- function startsLightDarkFunction (value, index) {
200
- if (value.slice(index, index + 11).toLowerCase() !== 'light-dark(') {
201
- return false;
194
+ if (hasQuoteForcingCharacters || spaceCount >= 2) {
195
+ // Escape any embedded double quotes so the double-quoted wrapper stays valid
196
+ return '"' + path.replace(/"/g, '\\"') + '"';
202
197
  }
203
- return !isIdentifierCharacter(value[index - 1]);
204
- }
205
-
206
- /**
207
- * Finds the closing parenthesis for an opening parenthesis while respecting
208
- * nested parentheses and quoted strings.
209
- *
210
- * @param {string} value The CSS text being scanned.
211
- * @param {number} openParenIndex The index of the opening `(` character.
212
- * @return {number} The closing `)` index, or -1 if unmatched.
213
- */
214
- function findMatchingParenthesis (value, openParenIndex) {
215
- let depth = 1;
216
- let index = openParenIndex + 1;
217
- let activeQuote = '';
218
-
219
- while (index < value.length) {
220
- const character = value[index];
221
- if (activeQuote) {
222
- if (character === '\\') {
223
- index += 2;
224
- continue;
225
- }
226
- if (character === activeQuote) {
227
- activeQuote = '';
228
- }
229
- index++;
230
- continue;
231
- }
232
198
 
233
- if (character === '"' || character === '\'') {
234
- activeQuote = character;
235
- index++;
236
- continue;
237
- }
238
-
239
- if (character === '(') {
240
- depth++;
241
- index++;
242
- continue;
243
- }
244
-
245
- if (character === ')') {
246
- depth--;
247
- if (depth === 0) {
248
- return index;
249
- }
250
- }
251
- index++;
199
+ if (spaceCount === 1) {
200
+ // A lone space is one byte shorter to escape than to wrap the value in quotes
201
+ return path.replace(/ /g, '\\ ');
252
202
  }
253
203
 
254
- return -1;
204
+ return path;
255
205
  }
256
206
 
257
207
  /**
258
- * Splits a CSS function argument list at top-level commas while respecting
259
- * nested parentheses and quoted strings.
208
+ * Produces the shortest valid contents for a single `url(...)` token from the
209
+ * raw text between its parentheses, stripping a leading current-directory
210
+ * indicator and normalizing quoting.
260
211
  *
261
- * @param {string} argumentString The raw content between function parentheses.
262
- * @return {Array} The top-level argument strings.
212
+ * @param {string} rawContent The trimmed text found between the url parentheses.
213
+ * @return {string} The minified url() content.
263
214
  */
264
- function splitTopLevelFunctionArguments (argumentString) {
265
- const argumentsList = [];
266
- let currentArgument = '';
267
- let depth = 0;
268
- let index = 0;
269
- let activeQuote = '';
270
-
271
- while (index < argumentString.length) {
272
- const character = argumentString[index];
273
- if (activeQuote) {
274
- currentArgument += character;
275
- if (character === '\\') {
276
- if (index + 1 < argumentString.length) {
277
- currentArgument += argumentString[index + 1];
278
- index += 2;
279
- continue;
280
- }
281
- } else if (character === activeQuote) {
282
- activeQuote = '';
283
- }
284
- index++;
285
- continue;
286
- }
287
-
288
- if (character === '"' || character === '\'') {
289
- activeQuote = character;
290
- currentArgument += character;
291
- index++;
292
- continue;
215
+ function minifyUrlContent (rawContent) {
216
+ const wasQuoted = rawContent.startsWith('"') || rawContent.startsWith('\'');
217
+ let path = rawContent;
218
+ if (wasQuoted) {
219
+ const quote = rawContent[0];
220
+ if (rawContent.length >= 2 && rawContent.endsWith(quote)) {
221
+ path = rawContent.slice(1, -1);
293
222
  }
294
-
295
- if (character === '(') {
296
- depth++;
297
- currentArgument += character;
298
- index++;
299
- continue;
300
- }
301
-
302
- if (character === ')') {
303
- if (depth > 0) {
304
- depth--;
305
- }
306
- currentArgument += character;
307
- index++;
308
- continue;
309
- }
310
-
311
- if (character === ',' && depth === 0) {
312
- argumentsList.push(currentArgument.trim());
313
- currentArgument = '';
314
- index++;
315
- continue;
316
- }
317
-
318
- currentArgument += character;
319
- index++;
320
223
  }
321
224
 
322
- argumentsList.push(currentArgument.trim());
323
- return argumentsList;
225
+ // Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
226
+ path = path.replace(/^\.\//, '');
227
+
228
+ return formatUrlPath(path);
324
229
  }
325
230
 
326
231
  /**
327
- * Simplifies `light-dark(a,b)` to `a` when both top-level arguments are identical
328
- * after prior minification has normalized them.
232
+ * Rewrites every `url(...)` token in a CSS value to its shortest valid form,
233
+ * skipping any quoted strings so an embedded `url(` inside a string is ignored.
329
234
  *
330
- * @param {string} value The CSS value to simplify.
331
- * @return {string} The value with redundant light-dark functions removed.
235
+ * @param {string} value The CSS value string potentially containing url() tokens.
236
+ * @return {string} The value with all url() tokens minified.
332
237
  */
333
- function simplifyEquivalentLightDarkFunctions (value) {
238
+ function minifyUrls (value) {
334
239
  let result = '';
335
240
  let index = 0;
336
241
 
@@ -364,37 +269,15 @@ function simplifyEquivalentLightDarkFunctions (value) {
364
269
  }
365
270
 
366
271
  if (startsUrl(index)) {
367
- const end = findMatchingParenthesis(value, index + 3);
368
- if (end === -1) {
369
- result += value.slice(index);
370
- break;
371
- }
372
- result += value.slice(index, end + 1);
373
- index = end + 1;
374
- continue;
375
- }
376
-
377
- if (startsLightDarkFunction(value, index)) {
378
- const openParenIndex = index + 10;
379
- const closingParenIndex = findMatchingParenthesis(value, openParenIndex);
272
+ const closingParenIndex = findMatchingParenthesis(value, index + 3);
380
273
  if (closingParenIndex === -1) {
381
274
  result += value.slice(index);
382
275
  break;
383
276
  }
384
-
385
- const argumentString = value.slice(openParenIndex + 1, closingParenIndex);
386
- const argumentsList = splitTopLevelFunctionArguments(argumentString);
387
- if (argumentsList.length === 2) {
388
- const firstArgument = simplifyEquivalentLightDarkFunctions(argumentsList[0]);
389
- const secondArgument = simplifyEquivalentLightDarkFunctions(argumentsList[1]);
390
- if (firstArgument === secondArgument) {
391
- result += firstArgument;
392
- } else {
393
- result += value.slice(index, openParenIndex + 1) + firstArgument + ',' + secondArgument + ')';
394
- }
395
- index = closingParenIndex + 1;
396
- continue;
397
- }
277
+ const inner = value.slice(index + 4, closingParenIndex).trim();
278
+ result += 'url(' + minifyUrlContent(inner) + ')';
279
+ index = closingParenIndex + 1;
280
+ continue;
398
281
  }
399
282
 
400
283
  result += value[index];
@@ -477,6 +360,24 @@ function convertColorsToHex (val) {
477
360
  }
478
361
  }
479
362
 
363
+ // Convert in-gamut lab() (CIE Lab, D50) to hex when it produces a shorter representation
364
+ 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) => {
365
+ const alpha = parseAlphaString(alphaStr);
366
+ const l = parseFloat(lStr);
367
+ const aNumber = parseFloat(aStr);
368
+ const a = aStr.endsWith('%') ? aNumber * 1.25 : aNumber;
369
+ const bNumber = parseFloat(bStr);
370
+ const b = bStr.endsWith('%') ? bNumber * 1.25 : bNumber;
371
+ const hex = convertLabToHex(l, a, b, alpha);
372
+ if (!hex) {
373
+ return match; // out-of-gamut: keep native lab form
374
+ }
375
+ if (hex.length < match.length) {
376
+ return hex;
377
+ }
378
+ return match;
379
+ });
380
+
480
381
  // Minify whitespace and numeric precision inside wide-gamut and functional color notations
481
382
  val = val.replace(/\b(oklab|oklch|lch|lab|color|hwb)\((.*?)\)/gi, (match, func, inner) => {
482
383
  // Collapse whitespace to single space
@@ -519,6 +420,14 @@ function convertColorsToHex (val) {
519
420
  }
520
421
  return before + rounded;
521
422
  });
423
+ // Remove trailing ".0" from numbers so integer channel values stay integer
424
+ // (e.g. display-p3 1.0 0.0 0.0 becomes display-p3 1 0 0)
425
+ minified = minified.replace(/(-?\d*)\.0\b/g, (match, integer) => {
426
+ if (integer === '' || integer === '-' || integer === '-0') {
427
+ return '0';
428
+ }
429
+ return integer;
430
+ });
522
431
  return func + '(' + minified.trim() + ')';
523
432
  });
524
433
 
@@ -835,6 +744,11 @@ function applyPropertyOptimizations (val, property) {
835
744
  });
836
745
  }
837
746
 
747
+ if (property === 'syntax') {
748
+ // Remove whitespace around pipe separators in @property syntax descriptors
749
+ val = val.replace(/\s*\|\s*/g, '|');
750
+ }
751
+
838
752
  // Simplify clamp() where all three arguments are identical (e.g. clamp(1rem,1rem,1rem) → 1rem)
839
753
  val = val.replace(/\bclamp\(([^,]+),\1,\1\)/gi, '$1');
840
754
 
@@ -976,6 +890,7 @@ function minifyValue (declaration) {
976
890
  if (typeof val === 'string') {
977
891
  val = val.trim();
978
892
  val = normalizeWhitespaceAndQuotes(val, declaration.property);
893
+ val = minifyUrls(val);
979
894
 
980
895
  // Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
981
896
  // Collapse spaces around division operator
@@ -1039,6 +954,9 @@ function minifyValue (declaration) {
1039
954
 
1040
955
  // Property-specific optimizations
1041
956
  val = applyPropertyOptimizations(val, declaration.property);
957
+
958
+ // Minify relative color syntax (identity resolution and whitespace collapsing)
959
+ val = minifyRelativeColorSyntax(val);
1042
960
  }
1043
961
 
1044
962
  // Gradient optimizations