html-minifier 5.0.2 → 5.0.4
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 +8 -5
- package/cli.js +89 -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 +19 -1
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +6 -1
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts +3 -1
- package/dist/types/lib/elements.d.ts.map +1 -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/dist/types/lib/unused-css.d.ts.map +1 -1
- package/dist/types/lib/utils.d.ts +8 -1
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/dist/types/lib/whitespace.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/htmlminifier.js +220 -69
- package/src/htmlparser.js +333 -53
- package/src/lib/attributes.js +108 -18
- package/src/lib/constants.js +21 -5
- package/src/lib/elements.js +15 -3
- package/src/lib/fragments.js +12 -3
- package/src/lib/options.js +5 -4
- package/src/lib/unused-css.js +85 -68
- package/src/lib/utils.js +19 -0
- package/src/lib/whitespace.js +2 -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
|
}
|
|
@@ -639,6 +697,32 @@ function canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut = tag
|
|
|
639
697
|
return (tag === 'input' && attrName === 'value') || RE_EMPTY_ATTRIBUTE.test(attrName);
|
|
640
698
|
}
|
|
641
699
|
|
|
700
|
+
/**
|
|
701
|
+
* The name to write and to hand to hooks: as in the source for HTML names under
|
|
702
|
+
* `caseSensitive`, as named otherwise (foreign names are named as written anyway)
|
|
703
|
+
* @param {HTMLAttribute} attr
|
|
704
|
+
* @param {string} attrName
|
|
705
|
+
* @param {ProcessedOptions} options
|
|
706
|
+
*/
|
|
707
|
+
function nameAttrOut(attr, attrName, options) {
|
|
708
|
+
return options.namesAsWritten && options.name === lowercase ? attr.name : attrName;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Whether `removeEmptyAttributes` removes the attribute
|
|
713
|
+
* @param {string} tag
|
|
714
|
+
* @param {string} attrName
|
|
715
|
+
* @param {string | undefined} attrValue
|
|
716
|
+
* @param {ProcessedOptions} options
|
|
717
|
+
* @param {string} [tagOut]
|
|
718
|
+
* @param {string} [attrNameOut]
|
|
719
|
+
*/
|
|
720
|
+
function removesEmptyAttribute(tag, attrName, attrValue, options, tagOut = tag, attrNameOut = attrName) {
|
|
721
|
+
return Boolean(options.removeEmptyAttributes) &&
|
|
722
|
+
canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut) &&
|
|
723
|
+
!isAttributeSelected(attrName, options);
|
|
724
|
+
}
|
|
725
|
+
|
|
642
726
|
/**
|
|
643
727
|
* @param {string} name
|
|
644
728
|
* @param {HTMLAttribute[]} attrs
|
|
@@ -658,6 +742,15 @@ const collapseAttributeWhitespaceExempt = new Set(['pattern', 'placeholder', 'ti
|
|
|
658
742
|
// `value` whitespace matters only on form-submission and machine-readable elements
|
|
659
743
|
const valueWhitespaceExemptElements = new Set(['button', 'data', 'input', 'option', 'param']);
|
|
660
744
|
|
|
745
|
+
/**
|
|
746
|
+
* @param {string} value
|
|
747
|
+
* @returns {string}
|
|
748
|
+
*/
|
|
749
|
+
function collapseAttributeValue(value) {
|
|
750
|
+
// Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
|
|
751
|
+
return RE_ATTR_WS_CHECK.test(value) ? trimWhitespace(value).replace(RE_ATTR_WS_COLLAPSE, ' ') : value;
|
|
752
|
+
}
|
|
753
|
+
|
|
661
754
|
/**
|
|
662
755
|
* Drop repeated names from a whitespace-separated class list, keeping the first of each.
|
|
663
756
|
* @param {string} value
|
|
@@ -688,12 +781,10 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
|
|
|
688
781
|
const isEventAttr = isEventAttribute(attrName, options, attrNameOut);
|
|
689
782
|
|
|
690
783
|
// Special spaces (no-break space, hair space, etc.) stay, for consistency
|
|
691
|
-
// with `collapseWhitespace`
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
attrValue = trimWhitespace(attrValue).replace(RE_ATTR_WS_COLLAPSE, ' ');
|
|
696
|
-
}
|
|
784
|
+
// with `collapseWhitespace`; an `annotation-xml` encoding is matched as written,
|
|
785
|
+
// and with spaces taken out could make the element hold HTML
|
|
786
|
+
if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !(attrName.toLowerCase() === 'encoding' && tag.toLowerCase() === 'annotation-xml') && !isEventAttr) {
|
|
787
|
+
attrValue = collapseAttributeValue(attrValue);
|
|
697
788
|
}
|
|
698
789
|
|
|
699
790
|
if (isEventAttr) {
|
|
@@ -984,9 +1075,7 @@ function chooseAttributeQuote(attrValue, options) {
|
|
|
984
1075
|
*/
|
|
985
1076
|
function normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers) {
|
|
986
1077
|
const attrName = options.name(attr.name);
|
|
987
|
-
|
|
988
|
-
// under `caseSensitive`, as named otherwise (foreign names are named as written anyway)
|
|
989
|
-
const attrNameOut = options.namesAsWritten && options.name === lowercase ? attr.name : attrName;
|
|
1078
|
+
const attrNameOut = nameAttrOut(attr, attrName, options);
|
|
990
1079
|
const attrValue = attr.value;
|
|
991
1080
|
|
|
992
1081
|
// Entity decoding requires a lazy import—async only while that import is unresolved
|
|
@@ -1016,12 +1105,12 @@ function normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers) {
|
|
|
1016
1105
|
* @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
|
|
1017
1106
|
*/
|
|
1018
1107
|
function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, tag, tagOut, options, minifyHTML, markers) {
|
|
1019
|
-
if ((options.removeRedundantAttributes &&
|
|
1108
|
+
if (((options.removeRedundantAttributes &&
|
|
1020
1109
|
isAttributeRedundant(tag, attrName, attrValue ?? '', attrs, options)) ||
|
|
1021
1110
|
(options.removeDefaultTypeAttributes && attrName === 'type' && (
|
|
1022
1111
|
((tag === 'style' || tag === 'link') && isStyleLinkTypeAttribute(attrValue)) ||
|
|
1023
1112
|
(tag === 'script' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue))
|
|
1024
|
-
))) {
|
|
1113
|
+
))) && !isAttributeSelected(attrName, options)) {
|
|
1025
1114
|
return;
|
|
1026
1115
|
}
|
|
1027
1116
|
|
|
@@ -1047,8 +1136,7 @@ function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, ta
|
|
|
1047
1136
|
* @param {ProcessedOptions} options
|
|
1048
1137
|
*/
|
|
1049
1138
|
function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut, options) {
|
|
1050
|
-
if (options
|
|
1051
|
-
canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut)) {
|
|
1139
|
+
if (removesEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut)) {
|
|
1052
1140
|
return;
|
|
1053
1141
|
}
|
|
1054
1142
|
|
|
@@ -1210,6 +1298,8 @@ export {
|
|
|
1210
1298
|
isStyleElement,
|
|
1211
1299
|
isMetaWithoutEffect,
|
|
1212
1300
|
hasAttrName,
|
|
1301
|
+
nameAttrOut,
|
|
1302
|
+
removesEmptyAttribute,
|
|
1213
1303
|
|
|
1214
1304
|
// Cleaners
|
|
1215
1305
|
normalizeAttr,
|
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', 'textpath', 'time', 'tspan', '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', 'marquee', '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', 'textpath', 'time', 'tspan', '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']);
|
|
@@ -296,13 +303,14 @@ 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
|
|
|
303
310
|
const pBlockElements = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'search', 'section', 'table', 'ul']);
|
|
304
311
|
|
|
305
|
-
// Elements whose end tags close an open `p` in browsers,
|
|
312
|
+
// Elements whose end tags close an open `p`, `li`, `dd`, or `dt` in browsers,
|
|
313
|
+
// so that its end tag may be omitted before them
|
|
306
314
|
const pClosingParents = new Set(['address', 'applet', 'article', 'aside', 'blockquote', 'body', 'button', 'caption', 'center', 'dd', 'details', 'dialog', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'html', 'li', 'listing', 'main', 'marquee', 'menu', 'nav', 'object', 'ol', 'pre', 'search', 'section', 'summary', 'td', 'template', 'th', 'ul']);
|
|
307
315
|
|
|
308
316
|
const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
|
|
@@ -325,6 +333,9 @@ const looseElements = new Set(['head', 'colgroup', 'caption']);
|
|
|
325
333
|
|
|
326
334
|
const trailingElements = new Set(['dt', 'thead']);
|
|
327
335
|
|
|
336
|
+
// Special elements with optional end tags, which only `pClosingParents` end tags close
|
|
337
|
+
const specialOptionalEndTags = new Set(['p', 'li', 'dd', 'dt']);
|
|
338
|
+
|
|
328
339
|
const htmlElements = new Set(['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', 'bdi', 'bdo', 'bgsound', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'command', 'content', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe', 'image', 'img', 'input', 'ins', 'isindex', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', 'listing', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meta', 'meter', 'multicol', 'nav', 'nobr', 'noembed', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'param', 'picture', 'plaintext', 'pre', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'script', 'search', 'section', 'select', 'selectedcontent', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr', 'xmp']);
|
|
329
340
|
|
|
330
341
|
// Doctype identifiers that set quirks mode, lowercase for their case-insensitive comparison
|
|
@@ -432,16 +443,20 @@ export {
|
|
|
432
443
|
RE_LINE_BREAK_CR,
|
|
433
444
|
RE_LEGACY_ENTITIES,
|
|
434
445
|
RE_ESCAPE_LT,
|
|
446
|
+
RE_ATTR_SELECTOR,
|
|
447
|
+
RE_ENDS_COLLAPSIBLE,
|
|
435
448
|
RE_BASE_ELEMENT,
|
|
436
449
|
RE_ESCAPE_LT_RAW_TEXT,
|
|
437
450
|
RE_XML_ESCAPE_TEXT,
|
|
438
451
|
RE_XML_ESCAPE_ATTR,
|
|
439
452
|
RE_HTML_ENCODING,
|
|
440
453
|
RE_DOCTYPE,
|
|
454
|
+
RE_DOCTYPE_SEGMENT,
|
|
441
455
|
RE_ATTR_WS_CHECK,
|
|
442
456
|
RE_ATTR_WS_COLLAPSE,
|
|
443
457
|
RE_WS_CHAR,
|
|
444
458
|
RE_STYLE_ELEMENT,
|
|
459
|
+
RE_STYLE_TAG,
|
|
445
460
|
RE_EMPTY_ATTRIBUTE,
|
|
446
461
|
|
|
447
462
|
// Inline element sets
|
|
@@ -504,6 +519,7 @@ export {
|
|
|
504
519
|
compactElements,
|
|
505
520
|
looseElements,
|
|
506
521
|
trailingElements,
|
|
522
|
+
specialOptionalEndTags,
|
|
507
523
|
htmlElements,
|
|
508
524
|
|
|
509
525
|
// Doctype identifiers
|
package/src/lib/elements.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
tableSectionElements,
|
|
14
14
|
cellElements
|
|
15
15
|
} from './constants.js';
|
|
16
|
-
import { hasAttrName } from './attributes.js';
|
|
16
|
+
import { hasAttrName, nameAttrOut, removesEmptyAttribute } from './attributes.js';
|
|
17
17
|
|
|
18
18
|
/** @import { HTMLAttribute } from './attributes.js' */
|
|
19
19
|
/** @import { ProcessedOptions } from './options.js' */
|
|
@@ -95,15 +95,20 @@ function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
|
95
95
|
/**
|
|
96
96
|
* @param {string} tag
|
|
97
97
|
* @param {HTMLAttribute[]} attrs
|
|
98
|
+
* @param {ProcessedOptions} options
|
|
99
|
+
* @param {string} [tagOut] - As hooks get it
|
|
98
100
|
*/
|
|
99
|
-
function canRemoveElement(tag, attrs) {
|
|
101
|
+
function canRemoveElement(tag, attrs, options, tagOut = tag) {
|
|
100
102
|
// Elements with `id` attribute must never be removed—they serve as:
|
|
101
103
|
// - Navigation targets (skip links, URL fragments)
|
|
102
104
|
// - JavaScript selector targets (`getElementById`, `querySelector`)
|
|
103
105
|
// - CSS targets (`:target` pseudo-class, ID selectors)
|
|
104
106
|
// - Accessibility landmarks (ARIA references)
|
|
105
107
|
// - Portal mount points (React portals, etc.)
|
|
106
|
-
|
|
108
|
+
// An empty one that `removeEmptyAttributes` removes does none of this
|
|
109
|
+
const id = attrs.find(attr => attr.name.toLowerCase() === 'id');
|
|
110
|
+
const idName = id && options.name(id.name);
|
|
111
|
+
if (id && !(idName === 'id' && removesEmptyAttribute(tag, idName, id.value, options, tagOut, nameAttrOut(id, idName, options)))) {
|
|
107
112
|
return false;
|
|
108
113
|
}
|
|
109
114
|
|
|
@@ -124,9 +129,16 @@ function canRemoveElement(tag, attrs) {
|
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
switch (tag) {
|
|
132
|
+
// Without a `frameset`, browsers render what follows it
|
|
127
133
|
case 'canvas':
|
|
134
|
+
case 'frameset':
|
|
128
135
|
case 'textarea':
|
|
129
136
|
return false;
|
|
137
|
+
// Browsers create them anyway, but not with their attributes
|
|
138
|
+
case 'body':
|
|
139
|
+
case 'head':
|
|
140
|
+
case 'html':
|
|
141
|
+
return attrs.length === 0;
|
|
130
142
|
case 'audio':
|
|
131
143
|
case 'script':
|
|
132
144
|
case 'video':
|
package/src/lib/fragments.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* one per pattern, so each keeps its own flags.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { LRU } from './utils.js';
|
|
14
|
+
|
|
13
15
|
const RE_WHITESPACE = /\s/;
|
|
14
16
|
const RE_NAME_CHAR = /[\w$-]/;
|
|
15
17
|
|
|
@@ -149,22 +151,29 @@ function toClosingLiteral(pattern) {
|
|
|
149
151
|
return close;
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
// Prepared fragments outlive the call, as options mostly repeat their patterns; a scan
|
|
155
|
+
// only ever runs synchronously, so the `lastIndex` of their regexes is never shared
|
|
156
|
+
const fragmentCache = new LRU(100);
|
|
157
|
+
|
|
152
158
|
/**
|
|
153
159
|
* Prepare a fragment pattern for matching, by scanning where the shape allows it
|
|
154
160
|
* @param {RegExp} pattern
|
|
155
161
|
* @returns {DelimitedFragment | PatternFragment}
|
|
156
162
|
*/
|
|
157
163
|
function toFragment(pattern) {
|
|
158
|
-
const
|
|
159
|
-
|
|
164
|
+
const key = pattern.flags + '/' + pattern.source;
|
|
165
|
+
const cached = /** @type {DelimitedFragment | PatternFragment | undefined} */ (fragmentCache.get(key));
|
|
166
|
+
if (cached) return cached;
|
|
160
167
|
|
|
161
168
|
// `g` and `y` are ours to set, the rest belong to the pattern
|
|
162
169
|
const flags = pattern.flags.replace(/[gy]/g, '');
|
|
163
|
-
|
|
170
|
+
const fragment = toDelimitedFragment(pattern) ?? {
|
|
164
171
|
search: new RegExp(pattern.source, flags + 'g'),
|
|
165
172
|
anchored: new RegExp(pattern.source, flags + 'y'),
|
|
166
173
|
close: toClosingLiteral(pattern)
|
|
167
174
|
};
|
|
175
|
+
fragmentCache.set(key, fragment);
|
|
176
|
+
return fragment;
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
/**
|
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);
|