@thejaredwilcurt/csslop 0.0.42 → 0.0.44

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.
@@ -44,6 +44,7 @@ import {
44
44
  parseAngleToDegrees,
45
45
  roundCompactNumber
46
46
  } from './shared.js';
47
+ import { minifySvgDataUri } from './svg-data-uri.js';
47
48
  import { elideRedundantSeparators } from './tokens.js';
48
49
  import { minifyTransformValue } from './transforms.js';
49
50
  import { optimizeUnicodeRange } from './unicode-range.js';
@@ -274,12 +275,114 @@ function minifyUrlContent (rawContent) {
274
275
  }
275
276
  }
276
277
 
278
+ const minifiedSvgDataUri = minifySvgDataUri(path);
279
+ if (minifiedSvgDataUri) {
280
+ return minifiedSvgDataUri;
281
+ }
282
+
277
283
  // Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
278
284
  path = path.replace(/^\.\//, '');
279
285
 
280
286
  return formatUrlPath(path);
281
287
  }
282
288
 
289
+ /**
290
+ * Builds a marker that cannot appear in a parsed CSS value. URL functions are
291
+ * temporarily replaced with these markers while generic string operations run.
292
+ *
293
+ * @param {number} index The URL token index.
294
+ * @return {string} The marker representing that URL token.
295
+ */
296
+ function createUrlTokenMarker (index) {
297
+ return '\u0000csslop-url-' + index + '\u0000';
298
+ }
299
+
300
+ /**
301
+ * Replaces URL tokens with stable markers so generic value normalization does
302
+ * not alter data carried inside a URL.
303
+ *
304
+ * @param {string} value The CSS value to protect.
305
+ * @return {object} The protected value and the original URL tokens.
306
+ */
307
+ function protectUrlTokens (value) {
308
+ const urlTokens = [];
309
+ let protectedValue = '';
310
+ for (const segment of splitValueSegments(value)) {
311
+ const isUrlToken = segment.isLiteral && startsUrlToken(segment.text, 0);
312
+ if (isUrlToken) {
313
+ protectedValue += createUrlTokenMarker(urlTokens.length);
314
+ urlTokens.push(segment.text);
315
+ } else {
316
+ protectedValue += segment.text;
317
+ }
318
+ }
319
+ return { value: protectedValue, urlTokens };
320
+ }
321
+
322
+ /**
323
+ * Restores the URL tokens removed by `protectUrlTokens`.
324
+ *
325
+ * @param {string} value The value containing URL markers.
326
+ * @param {Array} urlTokens The original URL token text.
327
+ * @return {string} The value with URL tokens restored.
328
+ */
329
+ function restoreUrlTokens (value, urlTokens) {
330
+ let restoredValue = value;
331
+ for (let index = 0; index < urlTokens.length; index++) {
332
+ const marker = createUrlTokenMarker(index);
333
+ restoredValue = restoredValue.split(marker).join(urlTokens[index]);
334
+ }
335
+ return restoredValue;
336
+ }
337
+
338
+ /**
339
+ * Removes whitespace after protected URL tokens. A CSS URL token closes itself
340
+ * at `)`, so the following component remains distinguishable without a space.
341
+ *
342
+ * @param {string} value The protected CSS value.
343
+ * @param {Array} urlTokens The original URL token text.
344
+ * @return {string} The protected value without trailing URL spacing.
345
+ */
346
+ function removeWhitespaceAfterUrlTokens (value, urlTokens) {
347
+ let compactValue = value;
348
+ for (let index = 0; index < urlTokens.length; index++) {
349
+ const marker = createUrlTokenMarker(index);
350
+ const markerIndex = compactValue.indexOf(marker);
351
+ if (markerIndex === -1) {
352
+ continue;
353
+ }
354
+ const contentStart = markerIndex + marker.length;
355
+ let nextContentIndex = contentStart;
356
+ while (' \t\n\r\f'.includes(compactValue[nextContentIndex])) {
357
+ nextContentIndex++;
358
+ }
359
+ compactValue = compactValue.slice(0, contentStart) + compactValue.slice(nextContentIndex);
360
+ }
361
+ return compactValue;
362
+ }
363
+
364
+ /**
365
+ * Removes an explicit default background position directly after a protected
366
+ * URL marker, where the ordinary close-parenthesis optimization cannot see it.
367
+ *
368
+ * @param {string} value The protected background value.
369
+ * @param {Array} urlTokens The original URL token text.
370
+ * @return {string} The value with redundant URL positions removed.
371
+ */
372
+ function removeDefaultBackgroundPositionsAfterUrlTokens (value, urlTokens) {
373
+ let normalizedValue = value;
374
+ for (let index = 0; index < urlTokens.length; index++) {
375
+ const marker = createUrlTokenMarker(index);
376
+ // Match a URL marker followed by the default 0 0 background-position pair.
377
+ const defaultPositionPattern = new RegExp(
378
+ marker + '\\s*0(?:%|px)?\\s+0(?:%|px)?(?=\\s|$)',
379
+ 'g'
380
+ );
381
+ normalizedValue = normalizedValue.replace(defaultPositionPattern, marker);
382
+ }
383
+ return normalizedValue;
384
+ }
385
+
283
386
  /**
284
387
  * Rewrites every `url(...)` token in a CSS value to its shortest valid form,
285
388
  * skipping any quoted strings so an embedded `url(` inside a string is ignored.
@@ -347,6 +450,9 @@ function minifyUrls (value) {
347
450
  * @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
348
451
  */
349
452
  function normalizeWhitespaceAndQuotes (val, property) {
453
+ const protectedUrls = protectUrlTokens(val);
454
+ val = protectedUrls.value;
455
+
350
456
  // Unescape unicode (skip control characters — they must stay escaped in CSS strings).
351
457
  // Only resolve escapes when the charset is unicode-compatible (UTF-8/UTF-16 or default).
352
458
  if (isUnicodeCharset()) {
@@ -385,7 +491,8 @@ function normalizeWhitespaceAndQuotes (val, property) {
385
491
  });
386
492
  }
387
493
 
388
- return val;
494
+ val = removeWhitespaceAfterUrlTokens(val, protectedUrls.urlTokens);
495
+ return restoreUrlTokens(val, protectedUrls.urlTokens);
389
496
  }
390
497
 
391
498
  /**
@@ -1135,11 +1242,12 @@ function minifyValueForOutput (declaration) {
1135
1242
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
1136
1243
  * background, display, scale, border-radius, shorthand collapsing, etc.).
1137
1244
  *
1138
- * @param {string} val The CSS value string after generic minification.
1139
- * @param {string} property The CSS property name.
1140
- * @return {string} The value with property-specific optimizations applied.
1245
+ * @param {string} val The CSS value string after generic minification.
1246
+ * @param {string} property The CSS property name.
1247
+ * @param {Array} urlTokens URL tokens currently represented by markers.
1248
+ * @return {string} The value with property-specific optimizations applied.
1141
1249
  */
1142
- function applyPropertyOptimizations (val, property) {
1250
+ function applyPropertyOptimizations (val, property, urlTokens) {
1143
1251
  if (property === 'font-weight' && isUnicodeCharset()) {
1144
1252
  // Replace font-weight keyword "bold" with its numeric equivalent
1145
1253
  val = val.replace(/\bbold\b/gi, '700');
@@ -1326,7 +1434,7 @@ function applyPropertyOptimizations (val, property) {
1326
1434
  }
1327
1435
 
1328
1436
  if (property === 'background' && val !== 'none') {
1329
- const normalized = val
1437
+ let normalized = val
1330
1438
  // Remove default "0 0" background-position values
1331
1439
  .replace(/(?:^|\s)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ' ')
1332
1440
  // Remove "0 0" background-position after a close-paren (e.g. after url())
@@ -1340,6 +1448,7 @@ function applyPropertyOptimizations (val, property) {
1340
1448
  // Collapse whitespace to single space
1341
1449
  .replace(/\s+/g, ' ')
1342
1450
  .trim();
1451
+ normalized = removeDefaultBackgroundPositionsAfterUrlTokens(normalized, urlTokens);
1343
1452
  if (normalized) {
1344
1453
  val = normalized;
1345
1454
  }
@@ -1425,11 +1534,14 @@ function computeMinifiedValue (declaration) {
1425
1534
  return 'none';
1426
1535
  }
1427
1536
  let val = declaration.value;
1537
+ let protectedUrlState = null;
1428
1538
 
1429
1539
  if (typeof val === 'string') {
1430
1540
  val = val.trim();
1431
1541
  val = normalizeWhitespaceAndQuotes(val, declaration.property);
1432
1542
  val = minifyUrls(val);
1543
+ protectedUrlState = protectUrlTokens(val);
1544
+ val = protectedUrlState.value;
1433
1545
 
1434
1546
  // Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
1435
1547
  // Collapse spaces around division operator
@@ -1482,7 +1594,11 @@ function computeMinifiedValue (declaration) {
1482
1594
  val = simplifyEquivalentLightDarkFunctions(val);
1483
1595
 
1484
1596
  // Property-specific optimizations
1485
- val = applyPropertyOptimizations(val, declaration.property);
1597
+ val = applyPropertyOptimizations(
1598
+ val,
1599
+ declaration.property,
1600
+ protectedUrlState ? protectedUrlState.urlTokens : []
1601
+ );
1486
1602
 
1487
1603
  // Minify relative color syntax (identity resolution and whitespace collapsing)
1488
1604
  val = minifyRelativeColorSyntax(val);
@@ -1494,6 +1610,11 @@ function computeMinifiedValue (declaration) {
1494
1610
  val = minifyGradients(val);
1495
1611
  }
1496
1612
 
1613
+ if (protectedUrlState) {
1614
+ val = removeWhitespaceAfterUrlTokens(val, protectedUrlState.urlTokens);
1615
+ val = restoreUrlTokens(val, protectedUrlState.urlTokens);
1616
+ }
1617
+
1497
1618
  // Unicode range optimization: dedup, merge overlapping/adjacent, wildcard compression
1498
1619
  if (declaration.property === 'unicode-range') {
1499
1620
  val = optimizeUnicodeRange(val);
@@ -0,0 +1,280 @@
1
+ /**
2
+ * @file Decodes, minifies, and safely reserializes SVG data URLs.
3
+ */
4
+
5
+ import { minifySvgMarkup } from './svg-markup.js';
6
+
7
+ const SVG_MEDIA_TYPE = 'data:image/svg+xml';
8
+
9
+ /**
10
+ * Determines whether a character is an ASCII hexadecimal digit.
11
+ *
12
+ * @param {string} character The character to inspect.
13
+ * @return {boolean} Whether the character is hexadecimal.
14
+ */
15
+ function isHexDigit (character) {
16
+ return (
17
+ (character >= '0' && character <= '9') ||
18
+ (character >= 'a' && character <= 'f') ||
19
+ (character >= 'A' && character <= 'F')
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Determines whether a character is CSS whitespace.
25
+ *
26
+ * @param {string} character The character to inspect.
27
+ * @return {boolean} Whether the character is whitespace.
28
+ */
29
+ function isCssWhitespace (character) {
30
+ return ' \t\n\r\f'.includes(character);
31
+ }
32
+
33
+ /**
34
+ * Resolves CSS string escapes so that embedded SVG markup can be parsed.
35
+ *
36
+ * @param {string} value The raw CSS string content.
37
+ * @return {string} The decoded string content.
38
+ */
39
+ function decodeCssStringEscapes (value) {
40
+ let result = '';
41
+ for (let index = 0; index < value.length; index++) {
42
+ const character = value[index];
43
+ if (character !== '\\' || index === value.length - 1) {
44
+ result += character;
45
+ continue;
46
+ }
47
+
48
+ const escapedCharacter = value[index + 1];
49
+ if (escapedCharacter === '\n') {
50
+ index++;
51
+ continue;
52
+ }
53
+ if (escapedCharacter === '\r') {
54
+ index++;
55
+ if (value[index + 1] === '\n') {
56
+ index++;
57
+ }
58
+ continue;
59
+ }
60
+ if (!isHexDigit(escapedCharacter)) {
61
+ result += escapedCharacter;
62
+ index++;
63
+ continue;
64
+ }
65
+
66
+ let escapeEnd = index + 1;
67
+ while (escapeEnd < value.length && escapeEnd < index + 7 && isHexDigit(value[escapeEnd])) {
68
+ escapeEnd++;
69
+ }
70
+ const codePoint = parseInt(value.slice(index + 1, escapeEnd), 16);
71
+ try {
72
+ result += String.fromCodePoint(codePoint);
73
+ } catch {
74
+ result += value.slice(index, escapeEnd);
75
+ }
76
+ index = escapeEnd - 1;
77
+ if (isCssWhitespace(value[index + 1])) {
78
+ index++;
79
+ }
80
+ }
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Decodes valid percent escapes without treating a literal percent sign as an
86
+ * error. Malformed UTF-8 stays unmodified so the URL is left untouched.
87
+ *
88
+ * @param {string} value The URI payload to decode.
89
+ * @return {string|null} The decoded payload, or null when unsafe.
90
+ */
91
+ function decodePercentEncodedText (value) {
92
+ let safelyEncodedValue = '';
93
+ for (let index = 0; index < value.length; index++) {
94
+ const isPercentEscape = (
95
+ value[index] === '%' &&
96
+ isHexDigit(value[index + 1]) &&
97
+ isHexDigit(value[index + 2])
98
+ );
99
+ if (value[index] === '%' && !isPercentEscape) {
100
+ safelyEncodedValue += '%25';
101
+ continue;
102
+ }
103
+ safelyEncodedValue += value[index];
104
+ }
105
+ try {
106
+ return decodeURIComponent(safelyEncodedValue);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Decodes base64 data to UTF-8 without depending on Node-only APIs.
114
+ *
115
+ * @param {string} value The base64 data.
116
+ * @return {string|null} The decoded SVG text, or null when unavailable or invalid.
117
+ */
118
+ function decodeBase64Utf8 (value) {
119
+ if (typeof atob !== 'function' || typeof TextDecoder !== 'function') {
120
+ return null;
121
+ }
122
+ try {
123
+ const binaryText = atob(value);
124
+ const bytes = new Uint8Array(binaryText.length);
125
+ for (let index = 0; index < binaryText.length; index++) {
126
+ bytes[index] = binaryText.codePointAt(index);
127
+ }
128
+ return new TextDecoder().decode(bytes);
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Parses the supported SVG data URL forms and chooses the output media prefix.
136
+ *
137
+ * @param {string} urlContent The decoded content inside `url(...)`.
138
+ * @return {object|null} The SVG markup and output prefix, or null when unsupported.
139
+ */
140
+ function parseSvgDataUri (urlContent) {
141
+ const separatorIndex = urlContent.indexOf(',');
142
+ if (separatorIndex === -1) {
143
+ return null;
144
+ }
145
+ const header = urlContent.slice(0, separatorIndex);
146
+ const normalizedHeader = header.toLowerCase();
147
+ if (
148
+ !normalizedHeader.startsWith(SVG_MEDIA_TYPE) ||
149
+ (header.length > SVG_MEDIA_TYPE.length && header[SVG_MEDIA_TYPE.length] !== ';')
150
+ ) {
151
+ return null;
152
+ }
153
+
154
+ const parameterText = header.slice(SVG_MEDIA_TYPE.length);
155
+ const parameters = parameterText ? parameterText.slice(1).split(';') : [];
156
+ let usesBase64 = false;
157
+ let usesUtf8 = false;
158
+ for (const parameter of parameters) {
159
+ const normalizedParameter = parameter.toLowerCase();
160
+ if (normalizedParameter === 'base64') {
161
+ usesBase64 = true;
162
+ } else if (
163
+ normalizedParameter === 'utf8' ||
164
+ normalizedParameter === 'charset=utf-8' ||
165
+ normalizedParameter === 'charset=utf8'
166
+ ) {
167
+ usesUtf8 = true;
168
+ } else {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ const encodedPayload = urlContent.slice(separatorIndex + 1);
174
+ const decodedPayload = decodePercentEncodedText(encodedPayload);
175
+ if (decodedPayload === null) {
176
+ return null;
177
+ }
178
+ const svgMarkup = usesBase64 ? decodeBase64Utf8(decodedPayload) : decodedPayload;
179
+ if (svgMarkup === null) {
180
+ return null;
181
+ }
182
+ return {
183
+ svgMarkup,
184
+ outputPrefix: SVG_MEDIA_TYPE + (usesBase64 || usesUtf8 ? ';utf8' : '')
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Checks whether decoded data begins with an SVG root element, allowing an XML
190
+ * declaration in front of it.
191
+ *
192
+ * @param {string} svgMarkup The decoded data URI payload.
193
+ * @return {boolean} Whether the payload appears to be SVG markup.
194
+ */
195
+ function startsWithSvgElement (svgMarkup) {
196
+ let index = 0;
197
+ while (isCssWhitespace(svgMarkup[index])) {
198
+ index++;
199
+ }
200
+ if (svgMarkup.startsWith('<?xml', index)) {
201
+ const declarationEnd = svgMarkup.indexOf('?>', index + 5);
202
+ if (declarationEnd === -1) {
203
+ return false;
204
+ }
205
+ index = declarationEnd + 2;
206
+ while (isCssWhitespace(svgMarkup[index])) {
207
+ index++;
208
+ }
209
+ }
210
+ const candidate = svgMarkup.slice(index, index + 4).toLowerCase();
211
+ const nextCharacter = svgMarkup[index + 4];
212
+ return candidate === '<svg' && (
213
+ isCssWhitespace(nextCharacter) ||
214
+ nextCharacter === '>' ||
215
+ nextCharacter === '/'
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Escapes text for one CSS string delimiter.
221
+ *
222
+ * @param {string} value The string content.
223
+ * @param {string} quoteCharacter The surrounding CSS string delimiter.
224
+ * @return {string} The escaped CSS string content.
225
+ */
226
+ function escapeCssStringContent (value, quoteCharacter) {
227
+ let result = '';
228
+ for (const character of value) {
229
+ if (character === '\\' || character === quoteCharacter) {
230
+ result += '\\' + character;
231
+ } else if (character === '\n') {
232
+ result += '\\a ';
233
+ } else if (character === '\r') {
234
+ result += '\\d ';
235
+ } else if (character === '\f') {
236
+ result += '\\c ';
237
+ } else {
238
+ result += character;
239
+ }
240
+ }
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * Chooses the CSS string wrapper that needs the fewest escaping backslashes.
246
+ *
247
+ * @param {string} dataUri The normalized SVG data URI.
248
+ * @return {string} The URL content with surrounding quotes.
249
+ */
250
+ function formatSvgDataUri (dataUri) {
251
+ const singleQuotedContent = escapeCssStringContent(dataUri, '\'');
252
+ const doubleQuotedContent = escapeCssStringContent(dataUri, '"');
253
+ if (singleQuotedContent.length < doubleQuotedContent.length) {
254
+ return '\'' + singleQuotedContent + '\'';
255
+ }
256
+ return '"' + doubleQuotedContent + '"';
257
+ }
258
+
259
+ /**
260
+ * Minifies an SVG data URI and returns CSS-ready URL content.
261
+ *
262
+ * @param {string} urlContent The unwrapped content of a CSS `url(...)`.
263
+ * @return {string|null} The formatted replacement, or null when not SVG data.
264
+ */
265
+ function minifySvgDataUri (urlContent) {
266
+ const decodedUrlContent = decodeCssStringEscapes(urlContent);
267
+ const parsedDataUri = parseSvgDataUri(decodedUrlContent);
268
+ if (!parsedDataUri || !startsWithSvgElement(parsedDataUri.svgMarkup)) {
269
+ return null;
270
+ }
271
+ const minifiedSvg = minifySvgMarkup(parsedDataUri.svgMarkup);
272
+ if (minifiedSvg === null) {
273
+ return null;
274
+ }
275
+ // A literal hash starts a URL fragment, so SVG IDs and hex colors need URI encoding.
276
+ const encodedSvg = minifiedSvg.split('#').join('%23');
277
+ return formatSvgDataUri(parsedDataUri.outputPrefix + ',' + encodedSvg);
278
+ }
279
+
280
+ export { minifySvgDataUri };