@thejaredwilcurt/csslop 0.0.16 → 0.0.17

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 };
@@ -8,7 +8,6 @@ import { resolveUnicodeEscape } from '../utilities.js';
8
8
  import {
9
9
  convertOklabToHex,
10
10
  evaluateColorMix,
11
- evaluateRelativeColor,
12
11
  hslToRgbChannels,
13
12
  hwbToRgbChannels,
14
13
  parseHex,
@@ -16,18 +15,24 @@ import {
16
15
  shortestColor
17
16
  } from './colors.js';
18
17
  import { minifyGradients } from './gradients.js';
18
+ import { simplifyEquivalentLightDarkFunctions } from './light-dark.js';
19
19
  import {
20
20
  normalizeMathFunctions,
21
21
  simplifyStandaloneCalc
22
22
  } from './math.js';
23
23
  import { namedColors } from './named-colors.js';
24
24
  import { isQuotesNoneEquivalent } from './quotes.js';
25
+ import {
26
+ evaluateRelativeColor,
27
+ minifyRelativeColorSyntax
28
+ } from './relative-colors.js';
25
29
  import {
26
30
  collapseShorthandParts,
27
31
  normalizeScaleComponent,
28
32
  parseAlphaString,
29
33
  roundCompactNumber
30
34
  } from './shared.js';
35
+ import { findMatchingParenthesis } from './syntax.js';
31
36
  import { minifyTransformValue } from './transforms.js';
32
37
  import { optimizeUnicodeRange } from './unicode-range.js';
33
38
 
@@ -173,164 +178,63 @@ function replaceOutsideStringsAndUrls (value, replacer) {
173
178
  }
174
179
 
175
180
  /**
176
- * Determines whether a character can appear inside a CSS identifier.
181
+ * Chooses the shortest valid representation for the path inside a `url(...)`
182
+ * token, weighing an unquoted form, an escaped single space, and a quoted form.
177
183
  *
178
- * @param {string} character A single character.
179
- * @return {boolean} True when the character is identifier-like.
184
+ * @param {string} path The resolved url path, without surrounding quotes.
185
+ * @return {string} The shortest valid url() content string.
180
186
  */
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
- }
187
+ function formatUrlPath (path) {
188
+ // Parentheses and quote characters are invalid inside an unquoted url() token
189
+ const hasQuoteForcingCharacters = /[()"']/.test(path);
190
+ // Count spaces so escaping them can be compared against keeping the quotes
191
+ const spaceCount = (path.match(/ /g) || []).length;
191
192
 
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;
193
+ if (hasQuoteForcingCharacters || spaceCount >= 2) {
194
+ // Escape any embedded double quotes so the double-quoted wrapper stays valid
195
+ return '"' + path.replace(/"/g, '\\"') + '"';
202
196
  }
203
- return !isIdentifierCharacter(value[index - 1]);
204
- }
205
197
 
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
-
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++;
198
+ if (spaceCount === 1) {
199
+ // A lone space is one byte shorter to escape than to wrap the value in quotes
200
+ return path.replace(/ /g, '\\ ');
252
201
  }
253
202
 
254
- return -1;
203
+ return path;
255
204
  }
256
205
 
257
206
  /**
258
- * Splits a CSS function argument list at top-level commas while respecting
259
- * nested parentheses and quoted strings.
207
+ * Produces the shortest valid contents for a single `url(...)` token from the
208
+ * raw text between its parentheses, stripping a leading current-directory
209
+ * indicator and normalizing quoting.
260
210
  *
261
- * @param {string} argumentString The raw content between function parentheses.
262
- * @return {Array} The top-level argument strings.
211
+ * @param {string} rawContent The trimmed text found between the url parentheses.
212
+ * @return {string} The minified url() content.
263
213
  */
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;
293
- }
294
-
295
- if (character === '(') {
296
- depth++;
297
- currentArgument += character;
298
- index++;
299
- continue;
214
+ function minifyUrlContent (rawContent) {
215
+ const wasQuoted = rawContent.startsWith('"') || rawContent.startsWith('\'');
216
+ let path = rawContent;
217
+ if (wasQuoted) {
218
+ const quote = rawContent[0];
219
+ if (rawContent.length >= 2 && rawContent.endsWith(quote)) {
220
+ path = rawContent.slice(1, -1);
300
221
  }
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
222
  }
321
223
 
322
- argumentsList.push(currentArgument.trim());
323
- return argumentsList;
224
+ // Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
225
+ path = path.replace(/^\.\//, '');
226
+
227
+ return formatUrlPath(path);
324
228
  }
325
229
 
326
230
  /**
327
- * Simplifies `light-dark(a,b)` to `a` when both top-level arguments are identical
328
- * after prior minification has normalized them.
231
+ * Rewrites every `url(...)` token in a CSS value to its shortest valid form,
232
+ * skipping any quoted strings so an embedded `url(` inside a string is ignored.
329
233
  *
330
- * @param {string} value The CSS value to simplify.
331
- * @return {string} The value with redundant light-dark functions removed.
234
+ * @param {string} value The CSS value string potentially containing url() tokens.
235
+ * @return {string} The value with all url() tokens minified.
332
236
  */
333
- function simplifyEquivalentLightDarkFunctions (value) {
237
+ function minifyUrls (value) {
334
238
  let result = '';
335
239
  let index = 0;
336
240
 
@@ -364,37 +268,15 @@ function simplifyEquivalentLightDarkFunctions (value) {
364
268
  }
365
269
 
366
270
  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);
271
+ const closingParenIndex = findMatchingParenthesis(value, index + 3);
380
272
  if (closingParenIndex === -1) {
381
273
  result += value.slice(index);
382
274
  break;
383
275
  }
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
- }
276
+ const inner = value.slice(index + 4, closingParenIndex).trim();
277
+ result += 'url(' + minifyUrlContent(inner) + ')';
278
+ index = closingParenIndex + 1;
279
+ continue;
398
280
  }
399
281
 
400
282
  result += value[index];
@@ -976,6 +858,7 @@ function minifyValue (declaration) {
976
858
  if (typeof val === 'string') {
977
859
  val = val.trim();
978
860
  val = normalizeWhitespaceAndQuotes(val, declaration.property);
861
+ val = minifyUrls(val);
979
862
 
980
863
  // Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
981
864
  // Collapse spaces around division operator
@@ -1039,6 +922,9 @@ function minifyValue (declaration) {
1039
922
 
1040
923
  // Property-specific optimizations
1041
924
  val = applyPropertyOptimizations(val, declaration.property);
925
+
926
+ // Minify relative color syntax (identity resolution and whitespace collapsing)
927
+ val = minifyRelativeColorSyntax(val);
1042
928
  }
1043
929
 
1044
930
  // Gradient optimizations