@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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/declarations/border.js +47 -33
- package/src/declarations/process.js +2 -2
- package/src/index.js +6 -33
- package/src/rules/optimize.js +167 -2
- package/src/rules/selectors.js +41 -0
- package/src/rules/stringify.js +2 -0
- package/src/rules/validate.js +145 -0
- package/src/value/color-mix.js +157 -68
- package/src/value/colors.js +36 -1
- package/src/value/font.js +111 -0
- package/src/value/minify.js +160 -33
- package/src/value/svg-data-uri.js +280 -0
- package/src/value/svg-markup.js +557 -0
- package/src/value/tokens.js +38 -1
package/src/value/minify.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
1139
|
-
* @param {string} property
|
|
1140
|
-
* @
|
|
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
|
-
//
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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(
|
|
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 };
|