html-minifier 5.0.2 → 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 +7 -4
- 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.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/options.d.ts +1 -0
- package/dist/types/lib/options.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/htmlminifier.js +131 -30
- package/src/htmlparser.js +309 -24
- package/src/lib/attributes.js +77 -12
- package/src/lib/constants.js +14 -3
- package/src/lib/elements.js +7 -0
- 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
|
}
|
|
@@ -658,6 +716,15 @@ const collapseAttributeWhitespaceExempt = new Set(['pattern', 'placeholder', 'ti
|
|
|
658
716
|
// `value` whitespace matters only on form-submission and machine-readable elements
|
|
659
717
|
const valueWhitespaceExemptElements = new Set(['button', 'data', 'input', 'option', 'param']);
|
|
660
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
|
+
|
|
661
728
|
/**
|
|
662
729
|
* Drop repeated names from a whitespace-separated class list, keeping the first of each.
|
|
663
730
|
* @param {string} value
|
|
@@ -690,10 +757,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
|
|
|
690
757
|
// Special spaces (no-break space, hair space, etc.) stay, for consistency
|
|
691
758
|
// with `collapseWhitespace`
|
|
692
759
|
if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !isEventAttr) {
|
|
693
|
-
|
|
694
|
-
if (RE_ATTR_WS_CHECK.test(attrValue)) {
|
|
695
|
-
attrValue = trimWhitespace(attrValue).replace(RE_ATTR_WS_COLLAPSE, ' ');
|
|
696
|
-
}
|
|
760
|
+
attrValue = collapseAttributeValue(attrValue);
|
|
697
761
|
}
|
|
698
762
|
|
|
699
763
|
if (isEventAttr) {
|
|
@@ -1016,12 +1080,12 @@ function normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers) {
|
|
|
1016
1080
|
* @param {string[]} [markers] - What custom fragment and `htmlmin:ignore` placeholders hold
|
|
1017
1081
|
*/
|
|
1018
1082
|
function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, tag, tagOut, options, minifyHTML, markers) {
|
|
1019
|
-
if ((options.removeRedundantAttributes &&
|
|
1083
|
+
if (((options.removeRedundantAttributes &&
|
|
1020
1084
|
isAttributeRedundant(tag, attrName, attrValue ?? '', attrs, options)) ||
|
|
1021
1085
|
(options.removeDefaultTypeAttributes && attrName === 'type' && (
|
|
1022
1086
|
((tag === 'style' || tag === 'link') && isStyleLinkTypeAttribute(attrValue)) ||
|
|
1023
1087
|
(tag === 'script' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue))
|
|
1024
|
-
))) {
|
|
1088
|
+
))) && !isAttributeSelected(attrName, options)) {
|
|
1025
1089
|
return;
|
|
1026
1090
|
}
|
|
1027
1091
|
|
|
@@ -1048,7 +1112,8 @@ function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, ta
|
|
|
1048
1112
|
*/
|
|
1049
1113
|
function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut, options) {
|
|
1050
1114
|
if (options.removeEmptyAttributes &&
|
|
1051
|
-
canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut)
|
|
1115
|
+
canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut) &&
|
|
1116
|
+
!isAttributeSelected(attrName, options)) {
|
|
1052
1117
|
return;
|
|
1053
1118
|
}
|
|
1054
1119
|
|
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)))$');
|
|
@@ -62,11 +69,11 @@ const RE_EMPTY_ATTRIBUTE = new RegExp(
|
|
|
62
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', '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,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/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);
|