html-minifier 5.0.1 → 5.0.3
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 +12 -7
- package/cli.js +91 -32
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +3 -1
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +5 -1
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts.map +1 -1
- package/dist/types/lib/fragments.d.ts +13 -1
- package/dist/types/lib/fragments.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts +1 -0
- package/dist/types/lib/options.d.ts.map +1 -1
- package/package.json +6 -4
- package/src/htmlminifier.js +182 -72
- package/src/htmlparser.js +309 -24
- package/src/lib/attributes.js +108 -20
- package/src/lib/constants.js +16 -5
- package/src/lib/elements.js +7 -0
- package/src/lib/fragments.js +274 -1
- package/src/lib/options.js +5 -4
package/src/lib/attributes.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
RE_CAN_REMOVE_ATTR_QUOTES,
|
|
8
8
|
RE_AMP_REFERENCE_ATTR,
|
|
9
9
|
RE_CR,
|
|
10
|
+
RE_ATTR_SELECTOR,
|
|
10
11
|
RE_BASE_ELEMENT,
|
|
11
12
|
RE_ATTR_WS_CHECK,
|
|
12
13
|
RE_ATTR_WS_COLLAPSE,
|
|
@@ -29,7 +30,8 @@ import {
|
|
|
29
30
|
collapsibleValues,
|
|
30
31
|
srcsetElements,
|
|
31
32
|
RE_EMPTY_ATTRIBUTE,
|
|
32
|
-
RE_STYLE_ELEMENT
|
|
33
|
+
RE_STYLE_ELEMENT,
|
|
34
|
+
RE_STYLE_TAG
|
|
33
35
|
} from './constants.js';
|
|
34
36
|
import { trimWhitespace, collapseWhitespaceAll } from './whitespace.js';
|
|
35
37
|
import { shouldMinifyInnerHTML } from './options.js';
|
|
@@ -197,6 +199,54 @@ function isTargetSelfRedundant(attrValue, options) {
|
|
|
197
199
|
return !state || !(state.hasBase ??= RE_BASE_ELEMENT.test(state.source));
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The attribute names `style` elements in `source` select on, in lowercase
|
|
204
|
+
* @param {string} source
|
|
205
|
+
* @returns {Set<string>}
|
|
206
|
+
*/
|
|
207
|
+
function findSelectedAttributes(source) {
|
|
208
|
+
/** @type {Set<string>} */
|
|
209
|
+
const names = new Set();
|
|
210
|
+
if (!RE_STYLE_ELEMENT.test(source)) {
|
|
211
|
+
return names;
|
|
212
|
+
}
|
|
213
|
+
const addSelected = (/** @type {number} */ start, /** @type {number} */ end) => {
|
|
214
|
+
for (const selector of source.slice(start, end).toLowerCase().matchAll(RE_ATTR_SELECTOR)) {
|
|
215
|
+
names.add(/** @type {string} */ (selector[1]));
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
// Scanned rather than matched, as a lazy pattern is quadratic on `style` tags left open
|
|
219
|
+
let start = -1;
|
|
220
|
+
RE_STYLE_TAG.lastIndex = 0;
|
|
221
|
+
for (let match = RE_STYLE_TAG.exec(source); match; match = RE_STYLE_TAG.exec(source)) {
|
|
222
|
+
if (match[0][1] !== '/') {
|
|
223
|
+
if (start === -1) start = match.index;
|
|
224
|
+
} else if (start !== -1) {
|
|
225
|
+
addSelected(start, match.index);
|
|
226
|
+
start = -1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Browsers apply a `style` element the document ends in, but not one that is script text,
|
|
230
|
+
// with a script’s end tag after it (without, `<script` before it is text of some other kind)
|
|
231
|
+
if (start !== -1) {
|
|
232
|
+
const before = source.slice(0, start).toLowerCase();
|
|
233
|
+
if (before.lastIndexOf('<script') <= before.lastIndexOf('</script') || !source.slice(start).toLowerCase().includes('</script')) {
|
|
234
|
+
addSelected(start, source.length);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return names;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Whether a style sheet of the document selects on the attribute, which removing it would break
|
|
242
|
+
* @param {string} attrName
|
|
243
|
+
* @param {ProcessedOptions} options
|
|
244
|
+
*/
|
|
245
|
+
function isAttributeSelected(attrName, options) {
|
|
246
|
+
const state = options.baseState;
|
|
247
|
+
return state !== undefined && (state.selectedAttrs ??= findSelectedAttributes(state.source)).has(attrName.toLowerCase());
|
|
248
|
+
}
|
|
249
|
+
|
|
200
250
|
/**
|
|
201
251
|
* @param {string} tag - In lowercase
|
|
202
252
|
* @param {string} attrName - In lowercase
|
|
@@ -268,7 +318,18 @@ function isAttributeRedundant(tag, attrName, attrValue, attrs, options) {
|
|
|
268
318
|
: undefined;
|
|
269
319
|
const tagDefault = tagAttrDefaults && Object.hasOwn(tagAttrDefaults, attrName) ? tagAttrDefaults[attrName] : undefined;
|
|
270
320
|
|
|
271
|
-
|
|
321
|
+
// As written, for browsers match fragments case-sensitively and without trimming
|
|
322
|
+
if (tag === 'a' && attrName === 'name') {
|
|
323
|
+
const id = attrs.find(attr => attr.name.toLowerCase() === 'id');
|
|
324
|
+
const idValue = id?.value ?? '';
|
|
325
|
+
// With `decodeEntities`, the name is decoded by now, the ID isn’t
|
|
326
|
+
if (id === undefined || (options.decodeEntities && idValue.indexOf('&') !== -1)) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
return options.collapseAttributeWhitespace ? collapseAttributeValue(idValue) === collapseAttributeValue(attrValue) : idValue === attrValue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const isLegacyAttr = tag === 'script' && (attrName === 'language' || attrName === 'charset');
|
|
272
333
|
|
|
273
334
|
// Without a default for this very attribute, nor a legacy rule, the value cannot be redundant
|
|
274
335
|
if (generalDefault === undefined && tagDefault === undefined && !isLegacyAttr) {
|
|
@@ -284,9 +345,6 @@ function isAttributeRedundant(tag, attrName, attrValue, attrs, options) {
|
|
|
284
345
|
if (tag === 'script' && attrName === 'charset' && !attributesInclude(attrs, 'src')) {
|
|
285
346
|
return true;
|
|
286
347
|
}
|
|
287
|
-
if (tag === 'a' && attrName === 'name' && attributesInclude(attrs, 'id')) {
|
|
288
|
-
return true;
|
|
289
|
-
}
|
|
290
348
|
if (tag === 'ol' && attrName === 'start' && attributesInclude(attrs, 'reversed')) {
|
|
291
349
|
return false;
|
|
292
350
|
}
|
|
@@ -342,21 +400,42 @@ function isStyleElement(tag, attrs) {
|
|
|
342
400
|
}
|
|
343
401
|
|
|
344
402
|
/**
|
|
345
|
-
* Whether an attribute collapses to its name;
|
|
346
|
-
*
|
|
403
|
+
* Whether an attribute collapses to its name; lower-cases the value only for the few names
|
|
404
|
+
* that depend on it
|
|
347
405
|
*
|
|
348
|
-
* @param {string}
|
|
406
|
+
* @param {string} name - In lowercase
|
|
349
407
|
* @param {string} attrValue
|
|
408
|
+
* @param {string} tag
|
|
409
|
+
* @param {string[]} markers - What custom fragment and `htmlmin:ignore` placeholders hold
|
|
350
410
|
*/
|
|
351
|
-
function collapsesToBooleanName(
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
411
|
+
function collapsesToBooleanName(name, attrValue, tag, markers) {
|
|
412
|
+
const simple = isSimpleBoolean.has(name);
|
|
413
|
+
if (!simple && name !== 'hidden' && name !== 'draggable' && !collapsibleValues.has(name)) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
// Custom elements define what their attributes mean, and `marquee` counts its loops
|
|
417
|
+
if (tag.includes('-') || (name === 'loop' && tag.toLowerCase() === 'marquee')) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
// Template code and ignored markup are not the minifier’s to remove
|
|
421
|
+
if (markers.some(marker => attrValue.indexOf(marker) !== -1)) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
if (simple) {
|
|
354
425
|
return true;
|
|
355
426
|
}
|
|
356
427
|
const values = collapsibleValues.get(name);
|
|
357
428
|
if (values) {
|
|
358
429
|
return values.has(attrValue.toLowerCase());
|
|
359
430
|
}
|
|
431
|
+
// What a reference decodes to, the value as written doesn’t show
|
|
432
|
+
if ((name === 'hidden' || name === 'draggable') && attrValue.indexOf('&') !== -1) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
// Any other value means the hidden state, which the bare attribute does, too
|
|
436
|
+
if (name === 'hidden') {
|
|
437
|
+
return attrValue.toLowerCase() !== 'until-found';
|
|
438
|
+
}
|
|
360
439
|
return name === 'draggable' && !isBooleanValue.has(attrValue.toLowerCase());
|
|
361
440
|
}
|
|
362
441
|
|
|
@@ -637,6 +716,15 @@ const collapseAttributeWhitespaceExempt = new Set(['pattern', 'placeholder', 'ti
|
|
|
637
716
|
// `value` whitespace matters only on form-submission and machine-readable elements
|
|
638
717
|
const valueWhitespaceExemptElements = new Set(['button', 'data', 'input', 'option', 'param']);
|
|
639
718
|
|
|
719
|
+
/**
|
|
720
|
+
* @param {string} value
|
|
721
|
+
* @returns {string}
|
|
722
|
+
*/
|
|
723
|
+
function collapseAttributeValue(value) {
|
|
724
|
+
// Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
|
|
725
|
+
return RE_ATTR_WS_CHECK.test(value) ? trimWhitespace(value).replace(RE_ATTR_WS_COLLAPSE, ' ') : value;
|
|
726
|
+
}
|
|
727
|
+
|
|
640
728
|
/**
|
|
641
729
|
* Drop repeated names from a whitespace-separated class list, keeping the first of each.
|
|
642
730
|
* @param {string} value
|
|
@@ -669,10 +757,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
|
|
|
669
757
|
// Special spaces (no-break space, hair space, etc.) stay, for consistency
|
|
670
758
|
// with `collapseWhitespace`
|
|
671
759
|
if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !isEventAttr) {
|
|
672
|
-
|
|
673
|
-
if (RE_ATTR_WS_CHECK.test(attrValue)) {
|
|
674
|
-
attrValue = trimWhitespace(attrValue).replace(RE_ATTR_WS_COLLAPSE, ' ');
|
|
675
|
-
}
|
|
760
|
+
attrValue = collapseAttributeValue(attrValue);
|
|
676
761
|
}
|
|
677
762
|
|
|
678
763
|
if (isEventAttr) {
|
|
@@ -995,12 +1080,12 @@ function normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers) {
|
|
|
995
1080
|
* @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
|
|
996
1081
|
*/
|
|
997
1082
|
function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, tag, tagOut, options, minifyHTML, markers) {
|
|
998
|
-
if ((options.removeRedundantAttributes &&
|
|
1083
|
+
if (((options.removeRedundantAttributes &&
|
|
999
1084
|
isAttributeRedundant(tag, attrName, attrValue ?? '', attrs, options)) ||
|
|
1000
1085
|
(options.removeDefaultTypeAttributes && attrName === 'type' && (
|
|
1001
1086
|
((tag === 'style' || tag === 'link') && isStyleLinkTypeAttribute(attrValue)) ||
|
|
1002
1087
|
(tag === 'script' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue))
|
|
1003
|
-
))) {
|
|
1088
|
+
))) && !isAttributeSelected(attrName, options)) {
|
|
1004
1089
|
return;
|
|
1005
1090
|
}
|
|
1006
1091
|
|
|
@@ -1027,7 +1112,8 @@ function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, ta
|
|
|
1027
1112
|
*/
|
|
1028
1113
|
function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut, options) {
|
|
1029
1114
|
if (options.removeEmptyAttributes &&
|
|
1030
|
-
canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut)
|
|
1115
|
+
canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut) &&
|
|
1116
|
+
!isAttributeSelected(attrName, options)) {
|
|
1031
1117
|
return;
|
|
1032
1118
|
}
|
|
1033
1119
|
|
|
@@ -1051,12 +1137,14 @@ function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut
|
|
|
1051
1137
|
|
|
1052
1138
|
/**
|
|
1053
1139
|
* @param {{name: string, value: string | undefined, attr: HTMLAttribute}} normalized
|
|
1140
|
+
* @param {string} tag
|
|
1054
1141
|
* @param {string | boolean | undefined} hasUnarySlash
|
|
1055
1142
|
* @param {ProcessedOptions} options
|
|
1056
1143
|
* @param {boolean} isLast
|
|
1057
1144
|
* @param {string | undefined} uidAttr
|
|
1145
|
+
* @param {string[]} markers - What custom fragment and `htmlmin:ignore` placeholders hold
|
|
1058
1146
|
*/
|
|
1059
|
-
function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
1147
|
+
function buildAttr(normalized, tag, hasUnarySlash, options, isLast, uidAttr, markers) {
|
|
1060
1148
|
const attrName = normalized.name;
|
|
1061
1149
|
let attrValue = normalized.value;
|
|
1062
1150
|
const attr = normalized.attr;
|
|
@@ -1070,7 +1158,7 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
|
|
|
1070
1158
|
|
|
1071
1159
|
// An attribute written as its name alone skips the quoting work below
|
|
1072
1160
|
if (typeof attrValue === 'undefined' ||
|
|
1073
|
-
(options.collapseBooleanAttributes && collapsesToBooleanName(attrName, attrValue ?? '')) ||
|
|
1161
|
+
(options.collapseBooleanAttributes && !options.insideForeignContent && collapsesToBooleanName(options.namesAsWritten ? attrName.toLowerCase() : attrName, attrValue ?? '', tag, markers)) ||
|
|
1074
1162
|
(options.collapseEmptyAttributes && attrValue === '' && (attr.customAssign ?? '=') === '=')) {
|
|
1075
1163
|
return attr.customOpen + attrName + (isLast ? '' : ' ') + attr.customClose;
|
|
1076
1164
|
}
|
package/src/lib/constants.js
CHANGED
|
@@ -48,10 +48,17 @@ const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
|
|
|
48
48
|
// identifier precedes it, anything but `>` sets quirks mode
|
|
49
49
|
// https://html.spec.whatwg.org/multipage/parsing.html#doctype-state
|
|
50
50
|
const RE_DOCTYPE = /^<!doctype[ \n\r\t\f]*([^ \n\r\t\f>]+)[ \n\r\t\f]*(?:public[ \n\r\t\f]*("[^">]*"|'[^'>]*')[ \n\r\t\f]*("[^">]*"|'[^'>]*')?|system[ \n\r\t\f]*("[^">]*"|'[^'>]*'))?([^]*)$/i;
|
|
51
|
+
// A doctype’s quoted identifiers (captured) and what lies between them
|
|
52
|
+
const RE_DOCTYPE_SEGMENT = /("[^"]*(?:"|$)|'[^']*(?:'|$))|[^"']+/g;
|
|
51
53
|
const RE_ATTR_WS_CHECK = /[ \n\r\t\f]/;
|
|
52
54
|
const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
|
|
53
55
|
const RE_WS_CHAR = /\s/;
|
|
54
56
|
const RE_STYLE_ELEMENT = /<style[\s/>]/i;
|
|
57
|
+
const RE_STYLE_TAG = /<\/?style/gi;
|
|
58
|
+
// Text that a space after would collapse into (a no-break space does not take it in)
|
|
59
|
+
const RE_ENDS_COLLAPSIBLE = /(?:^|[ \n\r\t\f])$/;
|
|
60
|
+
// An attribute selector’s name, namespace prefix aside, in lowercased CSS
|
|
61
|
+
const RE_ATTR_SELECTOR = /\[\s*(?:[\w-]*\|)?([\w:-]+)\s*[\]=~|^$*]/g;
|
|
55
62
|
const RE_EMPTY_ATTRIBUTE = new RegExp(
|
|
56
63
|
'^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
|
|
57
64
|
'?:down|up|over|move|out)|key(?:press|down|up)))$');
|
|
@@ -59,14 +66,14 @@ const RE_EMPTY_ATTRIBUTE = new RegExp(
|
|
|
59
66
|
// Inline element sets for whitespace handling
|
|
60
67
|
|
|
61
68
|
// Non-empty elements that will maintain whitespace around them
|
|
62
|
-
const inlineElementsToKeepWhitespaceAround = new Set(['a', 'abbr', 'acronym', 'audio', 'b', 'bdi', 'bdo', 'big', 'button', 'canvas', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'embed', 'font', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'map', 'mark', 'math', 'meter', 'nobr', 'object', 'output', 'picture', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'selectedcontent', 'slot', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'time', 'tt', 'u', 'var', 'video', 'wbr']);
|
|
69
|
+
const inlineElementsToKeepWhitespaceAround = new Set(['a', 'abbr', 'acronym', 'audio', 'b', 'bdi', 'bdo', 'big', 'button', 'canvas', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'embed', 'font', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'map', 'mark', 'math', 'meter', 'nobr', 'object', 'output', 'picture', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'selectedcontent', 'slot', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'textpath', 'time', 'tspan', 'tt', 'u', 'var', 'video', 'wbr']);
|
|
63
70
|
|
|
64
71
|
// Non-empty elements that will maintain whitespace within them
|
|
65
|
-
const inlineElementsToKeepWhitespaceWithin = new Set(['a', 'abbr', 'acronym', 'b', 'big', 'data', 'del', 'em', 'font', 'i', 'ins', 'kbd', 'map', 'mark', 'nobr', 's', 'samp', 'slot', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'time', 'tt', 'u', 'var']);
|
|
72
|
+
const inlineElementsToKeepWhitespaceWithin = new Set(['a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'font', 'i', 'ins', 'kbd', 'label', 'map', 'mark', 'nobr', 'q', 'ruby', 's', 'samp', 'slot', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'textpath', 'time', 'tspan', 'tt', 'u', 'var']);
|
|
66
73
|
|
|
67
74
|
// Text-level elements that keep whitespace between them and other elements under
|
|
68
75
|
// `collapseInlineTagWhitespace`
|
|
69
|
-
const inlineElementsToKeepWhitespaceBetween = new Set([...inlineElementsToKeepWhitespaceWithin, '
|
|
76
|
+
const inlineElementsToKeepWhitespaceBetween = new Set([...inlineElementsToKeepWhitespaceWithin, 'math']);
|
|
70
77
|
|
|
71
78
|
// Phrasing elements that don’t render, whose neighbors decide on the whitespace around them
|
|
72
79
|
const inlineElementsTransparent = new Set(['area', 'datalist', 'link', 'meta', 'noscript', 'script', 'style', 'template']);
|
|
@@ -259,7 +266,7 @@ const keepScriptsMimetypes = new Set([
|
|
|
259
266
|
|
|
260
267
|
// Boolean attribute sets
|
|
261
268
|
|
|
262
|
-
const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 'default', '
|
|
269
|
+
const isSimpleBoolean = new Set(['allowfullscreen', 'alpha', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 'default', 'defer', 'disabled', 'formnovalidate', 'headingreset', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nohref', 'nomodule', 'noresize', 'noshade', 'novalidate', 'nowrap', 'open', 'playsinline', 'readonly', 'required', 'reversed', 'scoped', 'seamless', 'selected', 'shadowrootclonable', 'shadowrootcustomelementregistry', 'shadowrootdelegatesfocus', 'shadowrootserializable', 'sortable', 'truespeed', 'typemustmatch']);
|
|
263
270
|
|
|
264
271
|
const isBooleanValue = new Set(['true', 'false']);
|
|
265
272
|
|
|
@@ -296,7 +303,7 @@ const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody'])
|
|
|
296
303
|
|
|
297
304
|
const optionalEndTags = new Set(['html', 'head', 'body', 'li', 'dt', 'dd', 'p', 'rb', 'rt', 'rtc', 'rp', 'optgroup', 'option', 'colgroup', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th']);
|
|
298
305
|
|
|
299
|
-
const headerElements = new Set(['
|
|
306
|
+
const headerElements = new Set(['base', 'basefont', 'bgsound', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'template', 'title']);
|
|
300
307
|
|
|
301
308
|
const descriptionElements = new Set(['dt', 'dd']);
|
|
302
309
|
|
|
@@ -432,16 +439,20 @@ export {
|
|
|
432
439
|
RE_LINE_BREAK_CR,
|
|
433
440
|
RE_LEGACY_ENTITIES,
|
|
434
441
|
RE_ESCAPE_LT,
|
|
442
|
+
RE_ATTR_SELECTOR,
|
|
443
|
+
RE_ENDS_COLLAPSIBLE,
|
|
435
444
|
RE_BASE_ELEMENT,
|
|
436
445
|
RE_ESCAPE_LT_RAW_TEXT,
|
|
437
446
|
RE_XML_ESCAPE_TEXT,
|
|
438
447
|
RE_XML_ESCAPE_ATTR,
|
|
439
448
|
RE_HTML_ENCODING,
|
|
440
449
|
RE_DOCTYPE,
|
|
450
|
+
RE_DOCTYPE_SEGMENT,
|
|
441
451
|
RE_ATTR_WS_CHECK,
|
|
442
452
|
RE_ATTR_WS_COLLAPSE,
|
|
443
453
|
RE_WS_CHAR,
|
|
444
454
|
RE_STYLE_ELEMENT,
|
|
455
|
+
RE_STYLE_TAG,
|
|
445
456
|
RE_EMPTY_ATTRIBUTE,
|
|
446
457
|
|
|
447
458
|
// Inline element sets
|
package/src/lib/elements.js
CHANGED
|
@@ -124,9 +124,16 @@ function canRemoveElement(tag, attrs) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
switch (tag) {
|
|
127
|
+
// Without a `frameset`, browsers render what follows it
|
|
127
128
|
case 'canvas':
|
|
129
|
+
case 'frameset':
|
|
128
130
|
case 'textarea':
|
|
129
131
|
return false;
|
|
132
|
+
// Browsers create them anyway, but not with their attributes
|
|
133
|
+
case 'body':
|
|
134
|
+
case 'head':
|
|
135
|
+
case 'html':
|
|
136
|
+
return attrs.length === 0;
|
|
130
137
|
case 'audio':
|
|
131
138
|
case 'script':
|
|
132
139
|
case 'video':
|
package/src/lib/fragments.js
CHANGED
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const RE_WHITESPACE = /\s/;
|
|
14
|
+
const RE_NAME_CHAR = /[\w$-]/;
|
|
15
|
+
|
|
16
|
+
// Run on at most the eight characters ending in the word
|
|
17
|
+
const RE_BLOCK_KEYWORD = /(?:^|[^\w$.])(?:do|else|finally|try)$/;
|
|
18
|
+
|
|
19
|
+
// The last character before a `/` that starts a regular expression, not a division
|
|
20
|
+
const RE_BEFORE_REGEX = /[(,=:[!&|?{};+*%<>~^}-]/;
|
|
21
|
+
|
|
22
|
+
// Run on at most the eleven characters ending in the word
|
|
23
|
+
const RE_BEFORE_REGEX_KEYWORD = /(?:^|[^\w$.])(?:await|case|delete|do|else|in|instanceof|new|of|return|throw|typeof|void|yield)$/;
|
|
24
|
+
|
|
25
|
+
// Run on at most the eleven characters from the `@`
|
|
26
|
+
const RE_DROPPABLE_AT_RULE = /^@(?:charset|import|namespace)(?![\w-])/i;
|
|
14
27
|
|
|
15
28
|
// Any-character and negated-class bodies; without the `s` flag, `.` is itself a
|
|
16
29
|
// negated class, excluding line terminators
|
|
@@ -333,8 +346,268 @@ function replaceCustomFragments(value, fragments, replacer) {
|
|
|
333
346
|
return copied === 0 ? value : out + value.slice(copied);
|
|
334
347
|
}
|
|
335
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Index past the regular expression starting at the `/` at an index
|
|
351
|
+
* @param {string} source
|
|
352
|
+
* @param {number} index
|
|
353
|
+
* @returns {number} The index itself when the line ends before the expression does
|
|
354
|
+
*/
|
|
355
|
+
function skipRegex(source, index) {
|
|
356
|
+
let inClass = false;
|
|
357
|
+
for (let position = index + 1; position < source.length; position++) {
|
|
358
|
+
const current = source[position];
|
|
359
|
+
if (current === '\n' || current === '\r') return index;
|
|
360
|
+
if (current === '\\') position++;
|
|
361
|
+
else if (current === '[') inClass = true;
|
|
362
|
+
else if (current === ']') inClass = false;
|
|
363
|
+
else if (current === '/' && !inClass) return position + 1;
|
|
364
|
+
}
|
|
365
|
+
return index;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Index past the comment, string, or regular expression starting at an index
|
|
370
|
+
* @param {string} source
|
|
371
|
+
* @param {number} index
|
|
372
|
+
* @param {boolean} isJS
|
|
373
|
+
* @returns {number} The index itself when none starts there
|
|
374
|
+
*/
|
|
375
|
+
function skipCommentOrString(source, index, isJS) {
|
|
376
|
+
const char = source[index];
|
|
377
|
+
const next = source[index + 1];
|
|
378
|
+
|
|
379
|
+
if (char === '/' && next === '*') {
|
|
380
|
+
const end = source.indexOf('*/', index + 2);
|
|
381
|
+
return end === -1 ? source.length : end + 2;
|
|
382
|
+
}
|
|
383
|
+
if (isJS && char === '/' && next === '/') {
|
|
384
|
+
const end = source.indexOf('\n', index + 2);
|
|
385
|
+
return end === -1 ? source.length : end;
|
|
386
|
+
}
|
|
387
|
+
if (isJS && char === '/') return isRegexStart(source, index) ? skipRegex(source, index) : index;
|
|
388
|
+
if (char !== '"' && char !== '\'' && !(isJS && char === '`')) return index;
|
|
389
|
+
|
|
390
|
+
for (let position = index + 1; position < source.length; position++) {
|
|
391
|
+
const current = source[position];
|
|
392
|
+
if (current === '\\') position++;
|
|
393
|
+
else if (current === char) return position + 1;
|
|
394
|
+
else if (current === '\n' && char !== '`') return position;
|
|
395
|
+
}
|
|
396
|
+
return source.length;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Index of the last non-whitespace character before an index
|
|
401
|
+
* @param {string} source
|
|
402
|
+
* @param {number} index
|
|
403
|
+
* @returns {number} -1 when there is none
|
|
404
|
+
*/
|
|
405
|
+
function findPreviousToken(source, index) {
|
|
406
|
+
let position = index - 1;
|
|
407
|
+
while (position >= 0 && RE_WHITESPACE.test(source[position] ?? '')) position--;
|
|
408
|
+
return position;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Whether a `/` starts a regular expression rather than a division, going by what precedes
|
|
413
|
+
* it; after a `)`, which `if (a) /b/.test(c)` leaves open, it’s read as a division
|
|
414
|
+
* @param {string} source
|
|
415
|
+
* @param {number} slash
|
|
416
|
+
* @returns {boolean}
|
|
417
|
+
*/
|
|
418
|
+
function isRegexStart(source, slash) {
|
|
419
|
+
const before = findPreviousToken(source, slash);
|
|
420
|
+
if (before === -1) return true;
|
|
421
|
+
|
|
422
|
+
const char = source[before] ?? '';
|
|
423
|
+
// `++` and `--` can only end an operand here, as a literal cannot be incremented
|
|
424
|
+
if ((char === '+' || char === '-') && source[before - 1] === char) return false;
|
|
425
|
+
return RE_BEFORE_REGEX.test(char) || RE_BEFORE_REGEX_KEYWORD.test(source.slice(Math.max(before - 10, 0), before + 1));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Whether a block keyword ends at an index
|
|
430
|
+
* @param {string} source
|
|
431
|
+
* @param {number} index
|
|
432
|
+
* @returns {boolean}
|
|
433
|
+
*/
|
|
434
|
+
function isBlockKeywordEnd(source, index) {
|
|
435
|
+
return RE_BLOCK_KEYWORD.test(source.slice(Math.max(index - 7, 0), index + 1));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Whether a `{` opens a block rather than an object literal
|
|
440
|
+
* @param {string} source
|
|
441
|
+
* @param {number} brace
|
|
442
|
+
* @returns {boolean}
|
|
443
|
+
*/
|
|
444
|
+
function isBlockBrace(source, brace) {
|
|
445
|
+
const before = findPreviousToken(source, brace);
|
|
446
|
+
if (before === -1) return true;
|
|
447
|
+
|
|
448
|
+
const char = source[before] ?? '';
|
|
449
|
+
if (char === ';' || char === '{' || char === '}' || char === ')') return true;
|
|
450
|
+
if (char === '>') return source[before - 1] === '=';
|
|
451
|
+
return isBlockKeywordEnd(source, before);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Whether a colon ends a statement label or `default`, going by what precedes the name.
|
|
456
|
+
* Two expressions cannot follow each other, so a name after one of these tokens cannot
|
|
457
|
+
* be the middle of a ternary
|
|
458
|
+
* @param {string} source
|
|
459
|
+
* @param {number} colon
|
|
460
|
+
* @returns {boolean}
|
|
461
|
+
*/
|
|
462
|
+
function isLabelColon(source, colon) {
|
|
463
|
+
const end = findPreviousToken(source, colon);
|
|
464
|
+
let start = end;
|
|
465
|
+
while (start >= 0 && RE_NAME_CHAR.test(source[start] ?? '')) start--;
|
|
466
|
+
if (start === end) return false;
|
|
467
|
+
|
|
468
|
+
const before = findPreviousToken(source, start + 1);
|
|
469
|
+
const char = source[before];
|
|
470
|
+
// After `:`, any other name may be a nested ternary’s branch
|
|
471
|
+
if (char === ':') return source.slice(start + 1, end + 1) === 'default';
|
|
472
|
+
return before === -1 || char === ';' || char === '}' || char === ')' || (char === '{' && isBlockBrace(source, before));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Whether the `case` keyword starts at an index, rather than a name or property holding it
|
|
477
|
+
* @param {string} source
|
|
478
|
+
* @param {number} index
|
|
479
|
+
* @returns {boolean}
|
|
480
|
+
*/
|
|
481
|
+
function isCaseKeyword(source, index) {
|
|
482
|
+
if (!source.startsWith('case', index) || RE_NAME_CHAR.test(source[index + 4] ?? '')) return false;
|
|
483
|
+
|
|
484
|
+
const before = source[index - 1] ?? '';
|
|
485
|
+
if (before === '.' || RE_NAME_CHAR.test(before)) return false;
|
|
486
|
+
|
|
487
|
+
let after = index + 4;
|
|
488
|
+
while (RE_WHITESPACE.test(source[after] ?? '')) after++;
|
|
489
|
+
return source[after] !== ':';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Indexes of the colons after which a JavaScript statement starts
|
|
494
|
+
* @param {string} source
|
|
495
|
+
* @returns {Set<number>}
|
|
496
|
+
*/
|
|
497
|
+
function findStatementColons(source) {
|
|
498
|
+
const colons = new Set();
|
|
499
|
+
// Unpaired `?` in the current `case` expression, -1 outside of one
|
|
500
|
+
let ternaries = -1;
|
|
501
|
+
|
|
502
|
+
for (let i = 0; i < source.length; i++) {
|
|
503
|
+
const end = skipCommentOrString(source, i, true);
|
|
504
|
+
if (end > i) {
|
|
505
|
+
i = end - 1;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const char = source[i];
|
|
510
|
+
if (ternaries === -1) {
|
|
511
|
+
if (char === ':') {
|
|
512
|
+
if (isLabelColon(source, i)) colons.add(i);
|
|
513
|
+
} else if (char === 'c' && isCaseKeyword(source, i)) {
|
|
514
|
+
ternaries = 0;
|
|
515
|
+
i += 3;
|
|
516
|
+
}
|
|
517
|
+
} else if (char === '?') {
|
|
518
|
+
// `?.` and `??` have no `:` to pair with
|
|
519
|
+
if (source[i + 1] === '.' || source[i + 1] === '?') i++;
|
|
520
|
+
else ternaries++;
|
|
521
|
+
} else if (char === ':') {
|
|
522
|
+
if (ternaries === 0) colons.add(i);
|
|
523
|
+
ternaries--;
|
|
524
|
+
} else if (char === ';' || char === '{' || char === '}') {
|
|
525
|
+
ternaries = -1;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return colons;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Placeholders within `@charset`, `@import`, and `@namespace` statements, which
|
|
534
|
+
* Lightning CSS drops whole where it cannot read them, and `@charset` always
|
|
535
|
+
* @param {string} source
|
|
536
|
+
* @param {RegExp} placeholder
|
|
537
|
+
* @returns {string[]}
|
|
538
|
+
*/
|
|
539
|
+
function findDroppableAtRulePlaceholders(source, placeholder) {
|
|
540
|
+
const tokens = [];
|
|
541
|
+
let start = -1;
|
|
542
|
+
|
|
543
|
+
for (let i = 0; i <= source.length; i++) {
|
|
544
|
+
const end = skipCommentOrString(source, i, false);
|
|
545
|
+
if (end > i) {
|
|
546
|
+
i = end - 1;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const char = source[i];
|
|
551
|
+
if (start === -1) {
|
|
552
|
+
if (char === '@' && RE_DROPPABLE_AT_RULE.test(source.slice(i, i + 11))) start = i;
|
|
553
|
+
} else if (char === ';' || char === '{' || char === '}' || char === undefined) {
|
|
554
|
+
for (const match of source.slice(start, i).matchAll(placeholder)) tokens.push(match[0]);
|
|
555
|
+
start = -1;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return tokens;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Whether what comes before an index allows a statement or rule to start there
|
|
564
|
+
* @param {string} source
|
|
565
|
+
* @param {number} index
|
|
566
|
+
* @param {Set<number>} colons - Colons after which a statement starts
|
|
567
|
+
* @param {boolean} isJS
|
|
568
|
+
* @returns {boolean}
|
|
569
|
+
*/
|
|
570
|
+
function isCodeStart(source, index, colons, isJS) {
|
|
571
|
+
let position = index;
|
|
572
|
+
while (position > 0) {
|
|
573
|
+
const char = source[--position] ?? '';
|
|
574
|
+
if (char === '\n' || char === '\r' || char === ';' || char === '{' || char === '}') return true;
|
|
575
|
+
if (!RE_WHITESPACE.test(char)) {
|
|
576
|
+
return char === ')' || (char === '/' && source[position - 1] === '*') || colons.has(position) || (isJS && isBlockKeywordEnd(source, position));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Whether minified CSS or JavaScript can stand in for its source. A fragment starting a
|
|
584
|
+
* statement or rule can render as anything, or as nothing, while engines read it as an
|
|
585
|
+
* identifier or selector and join it with the code around it, or drop it; a fragment in
|
|
586
|
+
* an at-rule Lightning CSS cannot read would go with that at-rule
|
|
587
|
+
* @param {string} source
|
|
588
|
+
* @param {unknown} output - Anything but a string, from a minifier of one’s own, passes
|
|
589
|
+
* @param {RegExp} placeholder - Global pattern matching one fragment placeholder
|
|
590
|
+
* @param {boolean} [isJS] - Whether the code is JavaScript rather than CSS
|
|
591
|
+
* @returns {boolean}
|
|
592
|
+
*/
|
|
593
|
+
function isMinifiedCodeSafe(source, output, placeholder, isJS = false) {
|
|
594
|
+
if (typeof output !== 'string' || output === source) return true;
|
|
595
|
+
|
|
596
|
+
const matches = [...source.matchAll(placeholder)];
|
|
597
|
+
if (!matches.length) return true;
|
|
598
|
+
|
|
599
|
+
const colons = isJS ? findStatementColons(source) : new Set();
|
|
600
|
+
const starts = matches.filter(match => isCodeStart(source, match.index, colons, isJS));
|
|
601
|
+
const kept = new Set(Array.from(output.matchAll(placeholder), match => match[0]));
|
|
602
|
+
|
|
603
|
+
// Output of nothing but fragments only holds up where no label or `case` went with the code
|
|
604
|
+
if (starts.length && (colons.size || output.replace(placeholder, '').trim() || !starts.every(match => kept.has(match[0])))) return false;
|
|
605
|
+
return isJS || !source.includes('@') || findDroppableAtRulePlaceholders(source, placeholder).every(token => kept.has(token));
|
|
606
|
+
}
|
|
607
|
+
|
|
336
608
|
export {
|
|
337
609
|
toDelimitedFragment,
|
|
338
610
|
toFragment,
|
|
339
|
-
replaceCustomFragments
|
|
611
|
+
replaceCustomFragments,
|
|
612
|
+
isMinifiedCodeSafe
|
|
340
613
|
};
|
package/src/lib/options.js
CHANGED
|
@@ -51,7 +51,7 @@ import { optionDefinitions, optionDefaults } from './option-definitions.js';
|
|
|
51
51
|
* removeUnusedCSS: {safelist: Array<string | RegExp>, scripts: boolean} | null,
|
|
52
52
|
* cssContext?: CSSContext,
|
|
53
53
|
* documentState?: {hasMathML: boolean},
|
|
54
|
-
* baseState?: {hasBase: boolean | undefined, source: string},
|
|
54
|
+
* baseState?: {hasBase: boolean | undefined, selectedAttrs?: Set<string>, source: string},
|
|
55
55
|
* parallelJS?: boolean,
|
|
56
56
|
* nameParent?: (name: string) => string,
|
|
57
57
|
* namesAsWritten?: boolean,
|
|
@@ -519,14 +519,15 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
519
519
|
const outputCSS = unwrapCSS(result.code.toString(), type);
|
|
520
520
|
|
|
521
521
|
// Where Lightning CSS emptied template syntax (`<?php ?>`, `{{ }}`: `<` or `>` without
|
|
522
|
-
// `CDATA`)
|
|
523
|
-
//
|
|
522
|
+
// `CDATA`), custom-fragment UIDs (long lowercase alphanumeric runs), or a media query
|
|
523
|
+
// (an invalid one matches nothing, an empty one everything), keep the original;
|
|
524
|
+
// CDATA sections, entities, and other invalid CSS may go
|
|
524
525
|
const isCDATA = text.includes('<![CDATA[');
|
|
525
526
|
const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
|
|
526
527
|
const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
|
|
527
528
|
const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
|
|
528
529
|
|
|
529
|
-
const css = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
530
|
+
const css = (text.trim() && !outputCSS.trim() && (type === 'media' || looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
530
531
|
return /** @type {CSSResult} */ ({ css, warnings });
|
|
531
532
|
});
|
|
532
533
|
report(resolved.warnings);
|