@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.
@@ -0,0 +1,557 @@
1
+ /**
2
+ * @file Safely minifies the SVG markup carried by UTF-8 data URLs.
3
+ */
4
+
5
+ import {
6
+ namedColors,
7
+ parseColor,
8
+ rgbaToHex
9
+ } from './colors.js';
10
+
11
+ const COLOR_ATTRIBUTE_NAMES = new Set([
12
+ 'color',
13
+ 'fill',
14
+ 'flood-color',
15
+ 'lighting-color',
16
+ 'solid-color',
17
+ 'stop-color',
18
+ 'stroke'
19
+ ]);
20
+
21
+ const SELF_CLOSING_SVG_ELEMENTS = new Set([
22
+ 'animate',
23
+ 'animatemotion',
24
+ 'animatetransform',
25
+ 'circle',
26
+ 'ellipse',
27
+ 'image',
28
+ 'line',
29
+ 'path',
30
+ 'polygon',
31
+ 'polyline',
32
+ 'rect',
33
+ 'set',
34
+ 'stop',
35
+ 'use'
36
+ ]);
37
+
38
+ const TEXT_WHITESPACE_ELEMENTS = new Set([
39
+ 'desc',
40
+ 'foreignobject',
41
+ 'metadata',
42
+ 'script',
43
+ 'style',
44
+ 'text',
45
+ 'textpath',
46
+ 'title',
47
+ 'tspan'
48
+ ]);
49
+
50
+ /**
51
+ * Determines whether a character is CSS or XML whitespace.
52
+ *
53
+ * @param {string} character The character to inspect.
54
+ * @return {boolean} Whether the character is whitespace.
55
+ */
56
+ function isWhitespace (character) {
57
+ return ' \t\n\r\f'.includes(character);
58
+ }
59
+
60
+ /**
61
+ * Determines whether a character may occur in an XML element or attribute name.
62
+ *
63
+ * @param {string} character The character to inspect.
64
+ * @return {boolean} Whether the character is a supported XML name character.
65
+ */
66
+ function isXmlNameCharacter (character) {
67
+ return (
68
+ (character >= 'a' && character <= 'z') ||
69
+ (character >= 'A' && character <= 'Z') ||
70
+ (character >= '0' && character <= '9') ||
71
+ '_:-.'.includes(character)
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Reads an XML name beginning at an index.
77
+ *
78
+ * @param {string} text The SVG markup being read.
79
+ * @param {number} index The index where the name starts.
80
+ * @return {object} The name and the index immediately after it.
81
+ */
82
+ function readXmlName (text, index) {
83
+ const startIndex = index;
84
+ while (index < text.length && isXmlNameCharacter(text[index])) {
85
+ index++;
86
+ }
87
+ return {
88
+ name: text.slice(startIndex, index),
89
+ nextIndex: index
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Converts a full hex color to its short notation when each channel pair matches.
95
+ *
96
+ * @param {string} hexColor A CSS hex color beginning with `#`.
97
+ * @return {string} The shortest equivalent hex color.
98
+ */
99
+ function shortenHexColor (hexColor) {
100
+ const digits = hexColor.slice(1);
101
+ if (digits.length !== 6 && digits.length !== 8) {
102
+ return hexColor;
103
+ }
104
+ let shortenedDigits = '';
105
+ for (let index = 0; index < digits.length; index += 2) {
106
+ if (digits[index] !== digits[index + 1]) {
107
+ return hexColor;
108
+ }
109
+ shortenedDigits += digits[index];
110
+ }
111
+ return '#' + shortenedDigits;
112
+ }
113
+
114
+ /**
115
+ * Chooses the shortest data-URL-safe spelling of a parsed SVG color.
116
+ *
117
+ * @param {Array} color The parsed [red, green, blue, alpha] channels.
118
+ * @return {string} The shortest encoded hex or named color spelling.
119
+ */
120
+ function formatSvgColor (color) {
121
+ const hexColor = shortenHexColor(rgbaToHex(color[0], color[1], color[2], color[3]));
122
+ let shortest = '%23' + hexColor.slice(1);
123
+ const isOpaque = Math.round(color[3] * 255) === 255;
124
+ if (!isOpaque) {
125
+ return shortest;
126
+ }
127
+
128
+ for (const [name, channels] of Object.entries(namedColors)) {
129
+ const channelsMatch = (
130
+ channels[0] === color[0] &&
131
+ channels[1] === color[1] &&
132
+ channels[2] === color[2]
133
+ );
134
+ if (channelsMatch && name.length < shortest.length) {
135
+ shortest = name;
136
+ }
137
+ }
138
+ return shortest;
139
+ }
140
+
141
+ /**
142
+ * Reads past one CSS escape sequence. A non-hex escape consumes its following
143
+ * character, which keeps an escaped comma, quote, or parenthesis from being
144
+ * interpreted as font-family syntax.
145
+ *
146
+ * @param {string} value The CSS value containing the escape.
147
+ * @param {number} index The index of the escape's backslash.
148
+ * @return {number} The index immediately after the escape.
149
+ */
150
+ function skipCssEscape (value, index) {
151
+ return Math.min(index + 2, value.length);
152
+ }
153
+
154
+ /**
155
+ * Reads past a CSS string, preserving escaped delimiters within it.
156
+ *
157
+ * @param {string} value The CSS value containing the string.
158
+ * @param {number} index The index of the opening quote.
159
+ * @return {number} The index immediately after the string, or the end.
160
+ */
161
+ function skipCssString (value, index) {
162
+ const quoteCharacter = value[index];
163
+ index++;
164
+ while (index < value.length) {
165
+ if (value[index] === '\\') {
166
+ index = skipCssEscape(value, index);
167
+ continue;
168
+ }
169
+ if (value[index] === quoteCharacter) {
170
+ return index + 1;
171
+ }
172
+ index++;
173
+ }
174
+ return index;
175
+ }
176
+
177
+ /**
178
+ * Reads past a CSS comment so punctuation in comment text does not affect
179
+ * font-family list parsing.
180
+ *
181
+ * @param {string} value The CSS value containing the comment.
182
+ * @param {number} index The index of the comment's opening slash.
183
+ * @return {number} The index immediately after the comment, or the end.
184
+ */
185
+ function skipCssComment (value, index) {
186
+ const commentEndIndex = value.indexOf('*/', index + 2);
187
+ if (commentEndIndex === -1) {
188
+ return value.length;
189
+ }
190
+ return commentEndIndex + 2;
191
+ }
192
+
193
+ /**
194
+ * Removes CSS whitespace after top-level font-family separators. Quotes,
195
+ * escapes, comments, and function arguments are kept opaque so that commas
196
+ * belonging to a family name or a substituted value are not altered.
197
+ *
198
+ * @param {string} attributeValue The unquoted SVG font-family value.
199
+ * @return {string} The compacted font-family value.
200
+ */
201
+ function minifySvgFontFamilyValue (attributeValue) {
202
+ // XML entities are resolved before this presentation attribute is parsed as
203
+ // CSS, so an encoded quote could otherwise be mistaken for ordinary text.
204
+ if (attributeValue.includes('&')) {
205
+ return attributeValue;
206
+ }
207
+
208
+ let minifiedValue = '';
209
+ let index = 0;
210
+ let parenthesisDepth = 0;
211
+ while (index < attributeValue.length) {
212
+ const character = attributeValue[index];
213
+ if (character === '\\') {
214
+ const escapeEndIndex = skipCssEscape(attributeValue, index);
215
+ minifiedValue += attributeValue.slice(index, escapeEndIndex);
216
+ index = escapeEndIndex;
217
+ continue;
218
+ }
219
+ if (character === '"' || character === '\'') {
220
+ const stringEndIndex = skipCssString(attributeValue, index);
221
+ minifiedValue += attributeValue.slice(index, stringEndIndex);
222
+ index = stringEndIndex;
223
+ continue;
224
+ }
225
+ if (character === '/' && attributeValue[index + 1] === '*') {
226
+ const commentEndIndex = skipCssComment(attributeValue, index);
227
+ minifiedValue += attributeValue.slice(index, commentEndIndex);
228
+ index = commentEndIndex;
229
+ continue;
230
+ }
231
+ if (character === '(') {
232
+ parenthesisDepth++;
233
+ } else if (character === ')' && parenthesisDepth > 0) {
234
+ parenthesisDepth--;
235
+ } else if (character === ',' && parenthesisDepth === 0) {
236
+ minifiedValue += character;
237
+ index++;
238
+ while (isWhitespace(attributeValue[index])) {
239
+ index++;
240
+ }
241
+ continue;
242
+ }
243
+ minifiedValue += character;
244
+ index++;
245
+ }
246
+ return minifiedValue;
247
+ }
248
+
249
+ /**
250
+ * Minifies supported SVG presentation attribute values when their syntax is
251
+ * safe to rewrite.
252
+ *
253
+ * @param {string} attributeName The attribute name.
254
+ * @param {string} attributeValue The attribute value without its quotes.
255
+ * @return {string} The minified value, or the original value.
256
+ */
257
+ function minifySvgAttributeValue (attributeName, attributeValue) {
258
+ const normalizedAttributeName = attributeName.toLowerCase();
259
+ if (normalizedAttributeName === 'font-family') {
260
+ return minifySvgFontFamilyValue(attributeValue);
261
+ }
262
+ if (!COLOR_ATTRIBUTE_NAMES.has(normalizedAttributeName)) {
263
+ return attributeValue;
264
+ }
265
+ const color = parseColor(attributeValue);
266
+ if (!color) {
267
+ return attributeValue;
268
+ }
269
+ return formatSvgColor(color);
270
+ }
271
+
272
+ /**
273
+ * Finds the closing angle bracket of an XML tag without treating a quoted
274
+ * attribute value as markup.
275
+ *
276
+ * @param {string} text The SVG markup being read.
277
+ * @param {number} startIndex The index of the opening `<`.
278
+ * @return {number} The closing `>` index, or -1 when unterminated.
279
+ */
280
+ function findSvgTagEnd (text, startIndex) {
281
+ let quoteCharacter = '';
282
+ for (let index = startIndex + 1; index < text.length; index++) {
283
+ const character = text[index];
284
+ if (quoteCharacter) {
285
+ if (character === quoteCharacter) {
286
+ quoteCharacter = '';
287
+ }
288
+ continue;
289
+ }
290
+ if (character === '"' || character === '\'') {
291
+ quoteCharacter = character;
292
+ continue;
293
+ }
294
+ if (character === '>') {
295
+ return index;
296
+ }
297
+ }
298
+ return -1;
299
+ }
300
+
301
+ /**
302
+ * Parses and minifies an SVG start or end tag while preserving all attribute
303
+ * values except static color values.
304
+ *
305
+ * @param {string} rawTag The complete source tag, including angle brackets.
306
+ * @return {object|null} A normalized token, or null when unsupported.
307
+ */
308
+ function minifySvgTag (rawTag) {
309
+ let index = 1;
310
+ if (rawTag[index] === '/') {
311
+ index++;
312
+ while (isWhitespace(rawTag[index])) {
313
+ index++;
314
+ }
315
+ const nameResult = readXmlName(rawTag, index);
316
+ if (!nameResult.name) {
317
+ return null;
318
+ }
319
+ index = nameResult.nextIndex;
320
+ while (isWhitespace(rawTag[index])) {
321
+ index++;
322
+ }
323
+ if (rawTag[index] !== '>') {
324
+ return null;
325
+ }
326
+ return {
327
+ type: 'end',
328
+ name: nameResult.name.toLowerCase(),
329
+ value: '</' + nameResult.name + '>'
330
+ };
331
+ }
332
+
333
+ while (isWhitespace(rawTag[index])) {
334
+ index++;
335
+ }
336
+ const nameResult = readXmlName(rawTag, index);
337
+ if (!nameResult.name) {
338
+ return null;
339
+ }
340
+ index = nameResult.nextIndex;
341
+ const attributes = [];
342
+ let selfClosing = false;
343
+
344
+ while (index < rawTag.length) {
345
+ while (isWhitespace(rawTag[index])) {
346
+ index++;
347
+ }
348
+ if (rawTag[index] === '>') {
349
+ break;
350
+ }
351
+ if (rawTag[index] === '/' && rawTag[index + 1] === '>') {
352
+ selfClosing = true;
353
+ break;
354
+ }
355
+
356
+ const attributeResult = readXmlName(rawTag, index);
357
+ if (!attributeResult.name) {
358
+ return null;
359
+ }
360
+ index = attributeResult.nextIndex;
361
+ while (isWhitespace(rawTag[index])) {
362
+ index++;
363
+ }
364
+
365
+ let attribute = attributeResult.name;
366
+ if (rawTag[index] === '=') {
367
+ index++;
368
+ while (isWhitespace(rawTag[index])) {
369
+ index++;
370
+ }
371
+ const quoteCharacter = rawTag[index];
372
+ if (quoteCharacter === '"' || quoteCharacter === '\'') {
373
+ const valueStart = index + 1;
374
+ index = valueStart;
375
+ while (index < rawTag.length && rawTag[index] !== quoteCharacter) {
376
+ index++;
377
+ }
378
+ if (index >= rawTag.length) {
379
+ return null;
380
+ }
381
+ const attributeValue = rawTag.slice(valueStart, index);
382
+ index++;
383
+ attribute += '=' + quoteCharacter +
384
+ minifySvgAttributeValue(attributeResult.name, attributeValue) +
385
+ quoteCharacter;
386
+ } else {
387
+ const valueStart = index;
388
+ while (index < rawTag.length && !isWhitespace(rawTag[index]) && rawTag[index] !== '>') {
389
+ index++;
390
+ }
391
+ const attributeValue = rawTag.slice(valueStart, index);
392
+ attribute += '=' + minifySvgAttributeValue(attributeResult.name, attributeValue);
393
+ }
394
+ }
395
+ attributes.push(attribute);
396
+ }
397
+
398
+ return {
399
+ type: 'start',
400
+ name: nameResult.name.toLowerCase(),
401
+ selfClosing,
402
+ value: '<' + nameResult.name + (attributes.length ? ' ' + attributes.join(' ') : '') + (selfClosing ? '/>' : '>')
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Turns SVG markup into tag and text tokens while preserving opaque XML
408
+ * constructs that could carry script or declaration content.
409
+ *
410
+ * @param {string} svgMarkup The decoded SVG markup.
411
+ * @return {Array|null} The tokens, or null when markup cannot be safely read.
412
+ */
413
+ function tokenizeSvgMarkup (svgMarkup) {
414
+ const tokens = [];
415
+ let index = 0;
416
+ while (index < svgMarkup.length) {
417
+ const tagStartIndex = svgMarkup.indexOf('<', index);
418
+ if (tagStartIndex === -1) {
419
+ tokens.push({ type: 'text', value: svgMarkup.slice(index) });
420
+ break;
421
+ }
422
+ if (tagStartIndex > index) {
423
+ tokens.push({ type: 'text', value: svgMarkup.slice(index, tagStartIndex) });
424
+ }
425
+ if (svgMarkup.startsWith('<!--', tagStartIndex)) {
426
+ const commentEndIndex = svgMarkup.indexOf('-->', tagStartIndex + 4);
427
+ if (commentEndIndex === -1) {
428
+ return null;
429
+ }
430
+ tokens.push({ type: 'other', value: svgMarkup.slice(tagStartIndex, commentEndIndex + 3) });
431
+ index = commentEndIndex + 3;
432
+ continue;
433
+ }
434
+ if (svgMarkup.startsWith('<![CDATA[', tagStartIndex)) {
435
+ const cdataEndIndex = svgMarkup.indexOf(']]>', tagStartIndex + 9);
436
+ if (cdataEndIndex === -1) {
437
+ return null;
438
+ }
439
+ tokens.push({ type: 'text', value: svgMarkup.slice(tagStartIndex, cdataEndIndex + 3) });
440
+ index = cdataEndIndex + 3;
441
+ continue;
442
+ }
443
+ if (svgMarkup.startsWith('<!', tagStartIndex)) {
444
+ return null;
445
+ }
446
+
447
+ const tagEndIndex = findSvgTagEnd(svgMarkup, tagStartIndex);
448
+ if (tagEndIndex === -1) {
449
+ return null;
450
+ }
451
+ const rawTag = svgMarkup.slice(tagStartIndex, tagEndIndex + 1);
452
+ if (rawTag.startsWith('<?')) {
453
+ tokens.push({ type: 'other', value: rawTag });
454
+ } else {
455
+ const tag = minifySvgTag(rawTag);
456
+ if (!tag) {
457
+ return null;
458
+ }
459
+ tokens.push(tag);
460
+ }
461
+ index = tagEndIndex + 1;
462
+ }
463
+ return tokens;
464
+ }
465
+
466
+ /**
467
+ * Determines whether a text token contains no non-whitespace content.
468
+ *
469
+ * @param {string} text The text to inspect.
470
+ * @return {boolean} Whether the text contains only whitespace.
471
+ */
472
+ function hasOnlyWhitespace (text) {
473
+ for (const character of text) {
474
+ if (!isWhitespace(character)) {
475
+ return false;
476
+ }
477
+ }
478
+ return true;
479
+ }
480
+
481
+ /**
482
+ * Drops formatting whitespace outside SVG elements whose text content matters.
483
+ *
484
+ * @param {Array} tokens The SVG tokens in source order.
485
+ * @return {Array} Tokens with insignificant text whitespace removed.
486
+ */
487
+ function dropInsignificantWhitespace (tokens) {
488
+ const result = [];
489
+ const openElements = [];
490
+ for (const token of tokens) {
491
+ const currentElement = openElements[openElements.length - 1];
492
+ const preservesWhitespace = TEXT_WHITESPACE_ELEMENTS.has(currentElement);
493
+ if (token.type !== 'text' || !hasOnlyWhitespace(token.value) || preservesWhitespace) {
494
+ result.push(token);
495
+ }
496
+ if (token.type === 'start' && !token.selfClosing) {
497
+ openElements.push(token.name);
498
+ }
499
+ if (token.type === 'end' && openElements[openElements.length - 1] === token.name) {
500
+ openElements.pop();
501
+ }
502
+ }
503
+ return result;
504
+ }
505
+
506
+ /**
507
+ * Rewrites supported SVG elements with no content into their XML self-closing form.
508
+ *
509
+ * @param {Array} tokens The compact SVG tokens.
510
+ * @return {Array} The compact tokens with empty elements collapsed.
511
+ */
512
+ function collapseEmptySvgElements (tokens) {
513
+ const result = [];
514
+ for (let index = 0; index < tokens.length; index++) {
515
+ const token = tokens[index];
516
+ const nextToken = tokens[index + 1];
517
+ const canCollapse = (
518
+ token.type === 'start' &&
519
+ !token.selfClosing &&
520
+ SELF_CLOSING_SVG_ELEMENTS.has(token.name) &&
521
+ nextToken?.type === 'end' &&
522
+ nextToken.name === token.name
523
+ );
524
+ if (canCollapse) {
525
+ result.push({
526
+ ...token,
527
+ selfClosing: true,
528
+ value: token.value.slice(0, -1) + '/>'
529
+ });
530
+ index++;
531
+ continue;
532
+ }
533
+ result.push(token);
534
+ }
535
+ return result;
536
+ }
537
+
538
+ /**
539
+ * Minifies SVG markup while leaving unrecognized XML constructs untouched.
540
+ *
541
+ * @param {string} svgMarkup The decoded SVG data.
542
+ * @return {string|null} The minified SVG, or null when it is unsafe to rewrite.
543
+ */
544
+ function minifySvgMarkup (svgMarkup) {
545
+ const tokens = tokenizeSvgMarkup(svgMarkup);
546
+ if (!tokens) {
547
+ return null;
548
+ }
549
+ const compactTokens = collapseEmptySvgElements(dropInsignificantWhitespace(tokens));
550
+ let result = '';
551
+ for (const token of compactTokens) {
552
+ result += token.value;
553
+ }
554
+ return result;
555
+ }
556
+
557
+ export { minifySvgMarkup };
@@ -141,4 +141,41 @@ function elideRedundantSeparators (value, elidesAfterSelfEndingTokens = true) {
141
141
  }).join('');
142
142
  }
143
143
 
144
- export { elideRedundantSeparators };
144
+ /**
145
+ * Shortens decimal dimension and percentage tokens without changing their
146
+ * digits. Unitless numbers stay untouched because integer grammars reject
147
+ * scientific notation.
148
+ *
149
+ * @param {string} value The CSS value.
150
+ * @return {string} The value with shorter numeric spellings.
151
+ */
152
+ function shortenDimensionNumbers (value) {
153
+ return tokenizeCssValue(value).map((token) => {
154
+ if (token.type !== 'dimension' && token.type !== 'percentage') {
155
+ return token.text;
156
+ }
157
+ // Capture a decimal coefficient and its unit, excluding existing exponents.
158
+ const match = token.text.match(/^([+-]?)(\d*\.\d+|\d+)([%a-z]+)$/i);
159
+ if (!match) {
160
+ return token.text;
161
+ }
162
+ const [, sign, coefficient, unit] = match;
163
+ const decimalIndex = coefficient.indexOf('.');
164
+ const fractionalDigits = decimalIndex === -1 ? 0 : coefficient.length - decimalIndex - 1;
165
+ const digits = coefficient.replace('.', '');
166
+ // Leading zeros carry no magnitude; trailing zeros move into the exponent.
167
+ const significantDigits = digits.replace(/^0+/, '').replace(/0+$/, '');
168
+ if (!significantDigits) {
169
+ return token.text;
170
+ }
171
+ const trailingZeros = digits.length - digits.replace(/0+$/, '').length;
172
+ const exponent = trailingZeros - fractionalDigits;
173
+ const scientific = sign + significantDigits + 'e' + exponent + unit;
174
+ return scientific.length < token.text.length ? scientific : token.text;
175
+ }).join('');
176
+ }
177
+
178
+ export {
179
+ elideRedundantSeparators,
180
+ shortenDimensionNumbers
181
+ };