@thejaredwilcurt/csslop 0.0.43 → 0.0.45

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.
@@ -22,6 +22,7 @@ import {
22
22
  rgbaToHex,
23
23
  shortestColor
24
24
  } from './colors.js';
25
+ import { minifyFontShorthand } from './font.js';
25
26
  import { minifyGradients } from './gradients.js';
26
27
  import { simplifyEquivalentLightDarkFunctions } from './light-dark.js';
27
28
  import {
@@ -44,7 +45,11 @@ import {
44
45
  parseAngleToDegrees,
45
46
  roundCompactNumber
46
47
  } from './shared.js';
47
- import { elideRedundantSeparators } from './tokens.js';
48
+ import { minifySvgDataUri } from './svg-data-uri.js';
49
+ import {
50
+ elideRedundantSeparators,
51
+ shortenDimensionNumbers
52
+ } from './tokens.js';
48
53
  import { minifyTransformValue } from './transforms.js';
49
54
  import { optimizeUnicodeRange } from './unicode-range.js';
50
55
 
@@ -274,12 +279,114 @@ function minifyUrlContent (rawContent) {
274
279
  }
275
280
  }
276
281
 
282
+ const minifiedSvgDataUri = minifySvgDataUri(path);
283
+ if (minifiedSvgDataUri) {
284
+ return minifiedSvgDataUri;
285
+ }
286
+
277
287
  // Remove a leading current-directory indicator (`./`); browsers resolve it implicitly
278
288
  path = path.replace(/^\.\//, '');
279
289
 
280
290
  return formatUrlPath(path);
281
291
  }
282
292
 
293
+ /**
294
+ * Builds a marker that cannot appear in a parsed CSS value. URL functions are
295
+ * temporarily replaced with these markers while generic string operations run.
296
+ *
297
+ * @param {number} index The URL token index.
298
+ * @return {string} The marker representing that URL token.
299
+ */
300
+ function createUrlTokenMarker (index) {
301
+ return '\u0000csslop-url-' + index + '\u0000';
302
+ }
303
+
304
+ /**
305
+ * Replaces URL tokens with stable markers so generic value normalization does
306
+ * not alter data carried inside a URL.
307
+ *
308
+ * @param {string} value The CSS value to protect.
309
+ * @return {object} The protected value and the original URL tokens.
310
+ */
311
+ function protectUrlTokens (value) {
312
+ const urlTokens = [];
313
+ let protectedValue = '';
314
+ for (const segment of splitValueSegments(value)) {
315
+ const isUrlToken = segment.isLiteral && startsUrlToken(segment.text, 0);
316
+ if (isUrlToken) {
317
+ protectedValue += createUrlTokenMarker(urlTokens.length);
318
+ urlTokens.push(segment.text);
319
+ } else {
320
+ protectedValue += segment.text;
321
+ }
322
+ }
323
+ return { value: protectedValue, urlTokens };
324
+ }
325
+
326
+ /**
327
+ * Restores the URL tokens removed by `protectUrlTokens`.
328
+ *
329
+ * @param {string} value The value containing URL markers.
330
+ * @param {Array} urlTokens The original URL token text.
331
+ * @return {string} The value with URL tokens restored.
332
+ */
333
+ function restoreUrlTokens (value, urlTokens) {
334
+ let restoredValue = value;
335
+ for (let index = 0; index < urlTokens.length; index++) {
336
+ const marker = createUrlTokenMarker(index);
337
+ restoredValue = restoredValue.split(marker).join(urlTokens[index]);
338
+ }
339
+ return restoredValue;
340
+ }
341
+
342
+ /**
343
+ * Removes whitespace after protected URL tokens. A CSS URL token closes itself
344
+ * at `)`, so the following component remains distinguishable without a space.
345
+ *
346
+ * @param {string} value The protected CSS value.
347
+ * @param {Array} urlTokens The original URL token text.
348
+ * @return {string} The protected value without trailing URL spacing.
349
+ */
350
+ function removeWhitespaceAfterUrlTokens (value, urlTokens) {
351
+ let compactValue = value;
352
+ for (let index = 0; index < urlTokens.length; index++) {
353
+ const marker = createUrlTokenMarker(index);
354
+ const markerIndex = compactValue.indexOf(marker);
355
+ if (markerIndex === -1) {
356
+ continue;
357
+ }
358
+ const contentStart = markerIndex + marker.length;
359
+ let nextContentIndex = contentStart;
360
+ while (' \t\n\r\f'.includes(compactValue[nextContentIndex])) {
361
+ nextContentIndex++;
362
+ }
363
+ compactValue = compactValue.slice(0, contentStart) + compactValue.slice(nextContentIndex);
364
+ }
365
+ return compactValue;
366
+ }
367
+
368
+ /**
369
+ * Removes an explicit default background position directly after a protected
370
+ * URL marker, where the ordinary close-parenthesis optimization cannot see it.
371
+ *
372
+ * @param {string} value The protected background value.
373
+ * @param {Array} urlTokens The original URL token text.
374
+ * @return {string} The value with redundant URL positions removed.
375
+ */
376
+ function removeDefaultBackgroundPositionsAfterUrlTokens (value, urlTokens) {
377
+ let normalizedValue = value;
378
+ for (let index = 0; index < urlTokens.length; index++) {
379
+ const marker = createUrlTokenMarker(index);
380
+ // Match a URL marker followed by the default 0 0 background-position pair.
381
+ const defaultPositionPattern = new RegExp(
382
+ marker + '\\s*0(?:%|px)?\\s+0(?:%|px)?(?=\\s|$)',
383
+ 'g'
384
+ );
385
+ normalizedValue = normalizedValue.replace(defaultPositionPattern, marker);
386
+ }
387
+ return normalizedValue;
388
+ }
389
+
283
390
  /**
284
391
  * Rewrites every `url(...)` token in a CSS value to its shortest valid form,
285
392
  * skipping any quoted strings so an embedded `url(` inside a string is ignored.
@@ -347,6 +454,9 @@ function minifyUrls (value) {
347
454
  * @return {string} The value with whitespace collapsed, quotes normalized, and unicode escapes resolved.
348
455
  */
349
456
  function normalizeWhitespaceAndQuotes (val, property) {
457
+ const protectedUrls = protectUrlTokens(val);
458
+ val = protectedUrls.value;
459
+
350
460
  // Unescape unicode (skip control characters — they must stay escaped in CSS strings).
351
461
  // Only resolve escapes when the charset is unicode-compatible (UTF-8/UTF-16 or default).
352
462
  if (isUnicodeCharset()) {
@@ -374,7 +484,7 @@ function normalizeWhitespaceAndQuotes (val, property) {
374
484
  });
375
485
 
376
486
  // Strip quotes from simple strings (like "Custom", "image.png"), but not for content where quoted strings are semantically distinct
377
- if (property !== 'content' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
487
+ if (property !== 'content' && property !== 'font' && property !== 'font-feature-settings' && property !== 'font-variation-settings') {
378
488
  // Match a boundary (start, whitespace, comma, open-paren), then a quoted simple value (alphanumeric, dots, slashes, hyphens), then a boundary lookahead
379
489
  val = val.replace(/(^|\s|,|\()("|')([a-zA-Z0-9_./-]+)\2(?=\s|,|$|\)|!)/g, (match, before, quote, inner) => {
380
490
  // Keep quotes around CSS generic font-family keywords — unquoted they mean something different
@@ -385,7 +495,8 @@ function normalizeWhitespaceAndQuotes (val, property) {
385
495
  });
386
496
  }
387
497
 
388
- return val;
498
+ val = removeWhitespaceAfterUrlTokens(val, protectedUrls.urlTokens);
499
+ return restoreUrlTokens(val, protectedUrls.urlTokens);
389
500
  }
390
501
 
391
502
  /**
@@ -881,6 +992,11 @@ const LENGTHY_INITIAL_KEYWORDS = new Map([
881
992
  ['background-clip', 'border-box'],
882
993
  ['background-origin', 'padding-box'],
883
994
  ['border-collapse', 'separate'],
995
+ ['border-color', 'currentcolor'],
996
+ ['border-top-color', 'currentcolor'],
997
+ ['border-right-color', 'currentcolor'],
998
+ ['border-bottom-color', 'currentcolor'],
999
+ ['border-left-color', 'currentcolor'],
884
1000
  ['box-sizing', 'content-box'],
885
1001
  ['mask-clip', 'border-box'],
886
1002
  ['mask-origin', 'border-box'],
@@ -1008,7 +1124,7 @@ const BORDER_STYLE_KEYWORD_SOURCE = 'solid|dashed|dotted|double|groove|ridge|ins
1008
1124
  *
1009
1125
  * @type {string}
1010
1126
  */
1011
- const BORDER_WIDTH_SOURCE = 'thin|medium|thick|[+-]?(?:\\d+|\\d*\\.\\d+)[a-z]*|(?:calc|min|max|clamp)\\([^()]*\\)';
1127
+ const BORDER_WIDTH_SOURCE = 'thin|medium|thick|[+-]?(?:\\d+|\\d*\\.\\d+)(?:e[+-]?\\d+)?[a-z]*|(?:calc|min|max|clamp)\\([^()]*\\)';
1012
1128
 
1013
1129
  /**
1014
1130
  * Matches a `border` value that states its line style in front of its line
@@ -1135,11 +1251,12 @@ function minifyValueForOutput (declaration) {
1135
1251
  * Applies property-specific optimizations to a CSS value (transition, flex, font,
1136
1252
  * background, display, scale, border-radius, shorthand collapsing, etc.).
1137
1253
  *
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.
1254
+ * @param {string} val The CSS value string after generic minification.
1255
+ * @param {string} property The CSS property name.
1256
+ * @param {Array} urlTokens URL tokens currently represented by markers.
1257
+ * @return {string} The value with property-specific optimizations applied.
1141
1258
  */
1142
- function applyPropertyOptimizations (val, property) {
1259
+ function applyPropertyOptimizations (val, property, urlTokens) {
1143
1260
  if (property === 'font-weight' && isUnicodeCharset()) {
1144
1261
  // Replace font-weight keyword "bold" with its numeric equivalent
1145
1262
  val = val.replace(/\bbold\b/gi, '700');
@@ -1300,10 +1417,12 @@ function applyPropertyOptimizations (val, property) {
1300
1417
  return rgbaToHex(r, r, r, parseAlphaString(alphaStr));
1301
1418
  });
1302
1419
 
1303
- // Shorten all color tokens (second pass after property-specific color evaluations)
1304
- val = replaceOutsideStringsAndUrls(val, (segment) => {
1305
- return shortenColorValues(segment);
1306
- });
1420
+ // Font families may contain color names, but never color values.
1421
+ if (property !== 'font' && property !== 'font-family') {
1422
+ val = replaceOutsideStringsAndUrls(val, (segment) => {
1423
+ return shortenColorValues(segment);
1424
+ });
1425
+ }
1307
1426
 
1308
1427
  if (!PUNCTUATED_COMPONENT_PROPERTIES.has(property)) {
1309
1428
  // Restore space after close-paren when followed by an alphanumeric, hash, or hyphen
@@ -1312,21 +1431,11 @@ function applyPropertyOptimizations (val, property) {
1312
1431
  val = restoreSpaceBeforeMathOperators(val);
1313
1432
 
1314
1433
  if (property === 'font') {
1315
- // Split font shorthand on whitespace
1316
- const parts = val.split(/\s+/);
1317
- // Find the font-size part: contains a digit and a recognized CSS length/percentage unit
1318
- const sizeIndex = parts.findIndex((part) => {
1319
- return /\d/.test(part) && /(?:px|em|rem|%|pt|pc|vw|vh|vmin|vmax|ch|ex|cm|mm|in|lh|rlh)/i.test(part);
1320
- });
1321
- if (sizeIndex > 0) {
1322
- val = [...parts.slice(0, sizeIndex).filter((part) => {
1323
- return part !== 'normal' && part !== '400';
1324
- }), ...parts.slice(sizeIndex)].join(' ');
1325
- }
1434
+ val = minifyFontShorthand(val);
1326
1435
  }
1327
1436
 
1328
1437
  if (property === 'background' && val !== 'none') {
1329
- const normalized = val
1438
+ let normalized = val
1330
1439
  // Remove default "0 0" background-position values
1331
1440
  .replace(/(?:^|\s)0(?:%|px)? 0(?:%|px)?(?=\s|$)/g, ' ')
1332
1441
  // Remove "0 0" background-position after a close-paren (e.g. after url())
@@ -1340,6 +1449,7 @@ function applyPropertyOptimizations (val, property) {
1340
1449
  // Collapse whitespace to single space
1341
1450
  .replace(/\s+/g, ' ')
1342
1451
  .trim();
1452
+ normalized = removeDefaultBackgroundPositionsAfterUrlTokens(normalized, urlTokens);
1343
1453
  if (normalized) {
1344
1454
  val = normalized;
1345
1455
  }
@@ -1425,11 +1535,14 @@ function computeMinifiedValue (declaration) {
1425
1535
  return 'none';
1426
1536
  }
1427
1537
  let val = declaration.value;
1538
+ let protectedUrlState = null;
1428
1539
 
1429
1540
  if (typeof val === 'string') {
1430
1541
  val = val.trim();
1431
1542
  val = normalizeWhitespaceAndQuotes(val, declaration.property);
1432
1543
  val = minifyUrls(val);
1544
+ protectedUrlState = protectUrlTokens(val);
1545
+ val = protectedUrlState.value;
1433
1546
 
1434
1547
  // Instead of unconditionally removing spaces around + and - and *, handle math vs non-math
1435
1548
  // Collapse spaces around division operator
@@ -1465,24 +1578,32 @@ function computeMinifiedValue (declaration) {
1465
1578
  // character budget a number is written within
1466
1579
  if (/^[+-]?(?:\d+|\d*\.\d+)([a-z%]+)?$/i.test(val)) {
1467
1580
  const [, rawNumber, rawUnit = ''] = val.match(/^([+-]?(?:\d+|\d*\.\d+))([a-z%]+)?$/i);
1468
- val = formatResolvedNumber(rawNumber) + rawUnit;
1581
+ const scientificValue = isCustomProperty(declaration.property) ? val : shortenDimensionNumbers(val);
1582
+ const roundedNumber = formatResolvedNumber(rawNumber);
1583
+ const losesPrecision = Number(roundedNumber) !== Number(rawNumber);
1584
+ // Preserve tiny dimensions whose significant digits would otherwise be
1585
+ // rounded away before their shorter exponent form can be considered.
1586
+ val = losesPrecision && scientificValue !== val ? scientificValue : roundedNumber + rawUnit;
1469
1587
  }
1470
1588
 
1471
1589
  val = lowercaseHexColors(val);
1472
1590
 
1473
- // Convert color functions to hex equivalents
1474
- val = convertColorsToHex(val);
1475
-
1476
- // Shorten all color tokens (hex and named) to their shortest representation
1477
- val = replaceOutsideStringsAndUrls(val, (segment) => {
1478
- return shortenColorValues(segment);
1479
- });
1591
+ if (declaration.property !== 'font' && declaration.property !== 'font-family') {
1592
+ val = convertColorsToHex(val);
1593
+ val = replaceOutsideStringsAndUrls(val, (segment) => {
1594
+ return shortenColorValues(segment);
1595
+ });
1596
+ }
1480
1597
 
1481
1598
  // Collapse light-dark() when both normalized branches are identical
1482
1599
  val = simplifyEquivalentLightDarkFunctions(val);
1483
1600
 
1484
1601
  // Property-specific optimizations
1485
- val = applyPropertyOptimizations(val, declaration.property);
1602
+ val = applyPropertyOptimizations(
1603
+ val,
1604
+ declaration.property,
1605
+ protectedUrlState ? protectedUrlState.urlTokens : []
1606
+ );
1486
1607
 
1487
1608
  // Minify relative color syntax (identity resolution and whitespace collapsing)
1488
1609
  val = minifyRelativeColorSyntax(val);
@@ -1494,6 +1615,11 @@ function computeMinifiedValue (declaration) {
1494
1615
  val = minifyGradients(val);
1495
1616
  }
1496
1617
 
1618
+ if (protectedUrlState) {
1619
+ val = removeWhitespaceAfterUrlTokens(val, protectedUrlState.urlTokens);
1620
+ val = restoreUrlTokens(val, protectedUrlState.urlTokens);
1621
+ }
1622
+
1497
1623
  // Unicode range optimization: dedup, merge overlapping/adjacent, wildcard compression
1498
1624
  if (declaration.property === 'unicode-range') {
1499
1625
  val = optimizeUnicodeRange(val);
@@ -1506,6 +1632,7 @@ function computeMinifiedValue (declaration) {
1506
1632
  !isCustomProperty(declaration.property)
1507
1633
  );
1508
1634
  if (elidesRedundantSeparators) {
1635
+ val = shortenDimensionNumbers(val);
1509
1636
  // A property that punctuates its own components already had the whitespace
1510
1637
  // after its closing parentheses taken out, and only the separators its
1511
1638
  // grammar still needs put back, so those are the ones left alone here.
@@ -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 };