html-minifier 5.0.3 → 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 +1 -1
- 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 +2 -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/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 +3 -3
- package/src/htmlminifier.js +91 -41
- package/src/htmlparser.js +25 -30
- package/src/lib/attributes.js +33 -8
- package/src/lib/constants.js +7 -2
- package/src/lib/elements.js +8 -3
- package/src/lib/fragments.js +12 -3
- package/src/lib/unused-css.js +85 -68
- package/src/lib/utils.js +19 -0
- package/src/lib/whitespace.js +2 -4
package/src/htmlparser.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { isThenable, isQuirksDoctype, embedSource, findTagEnd } from './lib/utils.js';
|
|
8
|
+
import { isThenable, isQuirksDoctype, isWhitespaceCode, embedSource, findTagEnd } from './lib/utils.js';
|
|
9
9
|
import { endlessRawTextElements, escapableRawTextElements, formattingElements, genericRawTextElements, processingInstructionTag, RE_HTML_ENCODING, RE_WS_ONLY } from './lib/constants.js';
|
|
10
10
|
|
|
11
11
|
/** @import { HTMLAttribute } from './lib/attributes.js' */
|
|
@@ -172,13 +172,6 @@ function scanAsciiTagName(html, start) {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
// The characters `\s` matches, by code: Tab through carriage return, space, and the
|
|
176
|
-
// Unicode whitespace and line terminators
|
|
177
|
-
const isWhitespaceCode = (/** @type {number} */ code) =>
|
|
178
|
-
(code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
|
|
179
|
-
(code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
|
|
180
|
-
code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF;
|
|
181
|
-
|
|
182
175
|
/**
|
|
183
176
|
* @param {string} text - The input the tag stands in
|
|
184
177
|
* @param {number} nameEnd - Position right after the tag name
|
|
@@ -269,9 +262,6 @@ function findScriptEnd(text) {
|
|
|
269
262
|
return null;
|
|
270
263
|
}
|
|
271
264
|
|
|
272
|
-
// Cache for compiled attribute regexes per handler configuration
|
|
273
|
-
const attrRegexCache = new WeakMap();
|
|
274
|
-
|
|
275
265
|
/** @param {HTMLParserHandler} handler */
|
|
276
266
|
function buildAttrRegex(handler) {
|
|
277
267
|
const unquotedValue = handler.continueOnParseError ? singleAttrValueLenientUnquoted : singleAttrValues[2];
|
|
@@ -302,26 +292,32 @@ function buildAttrRegex(handler) {
|
|
|
302
292
|
return new RegExp('^\\s*' + pattern);
|
|
303
293
|
}
|
|
304
294
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
295
|
+
// Compiled attribute regexes, the sticky one (`y`) for matching at a position in the whole
|
|
296
|
+
// input: By handler where it holds custom patterns, else by the flags they depend on, as
|
|
297
|
+
// a handler lasts one parse only
|
|
298
|
+
/** @type {WeakMap<HTMLParserHandler, {attribute: RegExp, attributeY: RegExp}>} */
|
|
299
|
+
const attrRegexCache = new WeakMap();
|
|
300
|
+
/** @type {Map<number, {attribute: RegExp, attributeY: RegExp}>} */
|
|
301
|
+
const attrRegexPlainCache = new Map();
|
|
313
302
|
|
|
314
|
-
|
|
315
|
-
const
|
|
303
|
+
/** @param {unknown} value */
|
|
304
|
+
const isEmptyOption = value => !value || (Array.isArray(value) && !value.length);
|
|
316
305
|
|
|
317
306
|
/** @param {HTMLParserHandler} handler */
|
|
318
|
-
function
|
|
319
|
-
const
|
|
307
|
+
function getAttrRegexes(handler) {
|
|
308
|
+
const plain = isEmptyOption(handler.customAttrAssign) && isEmptyOption(handler.customAttrSurround);
|
|
309
|
+
// An empty `customAttrSurround` still shapes the pattern, if not what it matches
|
|
310
|
+
const key = (handler.continueOnParseError ? 1 : 0) + (handler.customAttrSurround ? 2 : 0);
|
|
311
|
+
const cached = plain ? attrRegexPlainCache.get(key) : attrRegexCache.get(handler);
|
|
320
312
|
if (cached) return cached;
|
|
321
|
-
const
|
|
322
|
-
//
|
|
323
|
-
const compiled = new RegExp(
|
|
324
|
-
|
|
313
|
+
const attribute = buildAttrRegex(handler);
|
|
314
|
+
// Without the `^` anchor, as stickiness anchors it
|
|
315
|
+
const compiled = { attribute, attributeY: new RegExp(attribute.source.slice(1), 'y') };
|
|
316
|
+
if (plain) {
|
|
317
|
+
attrRegexPlainCache.set(key, compiled);
|
|
318
|
+
} else {
|
|
319
|
+
attrRegexCache.set(handler, compiled);
|
|
320
|
+
}
|
|
325
321
|
return compiled;
|
|
326
322
|
}
|
|
327
323
|
|
|
@@ -377,8 +373,7 @@ export class HTMLParser {
|
|
|
377
373
|
// Lowercase counterpart of `lastTag`, kept in sync to avoid per-iteration lowercasing
|
|
378
374
|
/** @type {string} */
|
|
379
375
|
let lastTagLower = '';
|
|
380
|
-
const attribute =
|
|
381
|
-
const attributeY = getAttrRegexStickyForHandler(handler);
|
|
376
|
+
const { attribute, attributeY } = getAttrRegexes(handler);
|
|
382
377
|
// Index of the attribute name in a match, after each `customAttrSurround` pattern’s captures
|
|
383
378
|
const baseIndex = 1 + (handler.customAttrSurround ? handler.customAttrSurround.length * NCP : 0);
|
|
384
379
|
/** @type {string | undefined} */
|
|
@@ -1056,7 +1051,7 @@ export class HTMLParser {
|
|
|
1056
1051
|
break;
|
|
1057
1052
|
}
|
|
1058
1053
|
|
|
1059
|
-
const attrLen = attr[0].length;
|
|
1054
|
+
const attrLen = /** @type {string} */ (attr[0]).length;
|
|
1060
1055
|
currentPos += attrLen;
|
|
1061
1056
|
consumed += attrLen;
|
|
1062
1057
|
match.attrs.push(attr);
|
package/src/lib/attributes.js
CHANGED
|
@@ -697,6 +697,32 @@ function canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut = tag
|
|
|
697
697
|
return (tag === 'input' && attrName === 'value') || RE_EMPTY_ATTRIBUTE.test(attrName);
|
|
698
698
|
}
|
|
699
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
|
+
|
|
700
726
|
/**
|
|
701
727
|
* @param {string} name
|
|
702
728
|
* @param {HTMLAttribute[]} attrs
|
|
@@ -755,8 +781,9 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
|
|
|
755
781
|
const isEventAttr = isEventAttribute(attrName, options, attrNameOut);
|
|
756
782
|
|
|
757
783
|
// Special spaces (no-break space, hair space, etc.) stay, for consistency
|
|
758
|
-
// with `collapseWhitespace`
|
|
759
|
-
|
|
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) {
|
|
760
787
|
attrValue = collapseAttributeValue(attrValue);
|
|
761
788
|
}
|
|
762
789
|
|
|
@@ -1048,9 +1075,7 @@ function chooseAttributeQuote(attrValue, options) {
|
|
|
1048
1075
|
*/
|
|
1049
1076
|
function normalizeAttr(attr, attrs, tag, tagOut, options, minifyHTML, markers) {
|
|
1050
1077
|
const attrName = options.name(attr.name);
|
|
1051
|
-
|
|
1052
|
-
// under `caseSensitive`, as named otherwise (foreign names are named as written anyway)
|
|
1053
|
-
const attrNameOut = options.namesAsWritten && options.name === lowercase ? attr.name : attrName;
|
|
1078
|
+
const attrNameOut = nameAttrOut(attr, attrName, options);
|
|
1054
1079
|
const attrValue = attr.value;
|
|
1055
1080
|
|
|
1056
1081
|
// Entity decoding requires a lazy import—async only while that import is unresolved
|
|
@@ -1111,9 +1136,7 @@ function normalizeAttrContinue(attrName, attrNameOut, attrValue, attr, attrs, ta
|
|
|
1111
1136
|
* @param {ProcessedOptions} options
|
|
1112
1137
|
*/
|
|
1113
1138
|
function normalizeAttrFinish(attrName, attrNameOut, attrValue, attr, tag, tagOut, options) {
|
|
1114
|
-
if (options
|
|
1115
|
-
canDeleteEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut) &&
|
|
1116
|
-
!isAttributeSelected(attrName, options)) {
|
|
1139
|
+
if (removesEmptyAttribute(tag, attrName, attrValue, options, tagOut, attrNameOut)) {
|
|
1117
1140
|
return;
|
|
1118
1141
|
}
|
|
1119
1142
|
|
|
@@ -1275,6 +1298,8 @@ export {
|
|
|
1275
1298
|
isStyleElement,
|
|
1276
1299
|
isMetaWithoutEffect,
|
|
1277
1300
|
hasAttrName,
|
|
1301
|
+
nameAttrOut,
|
|
1302
|
+
removesEmptyAttribute,
|
|
1278
1303
|
|
|
1279
1304
|
// Cleaners
|
|
1280
1305
|
normalizeAttr,
|
package/src/lib/constants.js
CHANGED
|
@@ -66,7 +66,7 @@ const RE_EMPTY_ATTRIBUTE = new RegExp(
|
|
|
66
66
|
// Inline element sets for whitespace handling
|
|
67
67
|
|
|
68
68
|
// Non-empty elements that will maintain whitespace around them
|
|
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']);
|
|
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']);
|
|
70
70
|
|
|
71
71
|
// Non-empty elements that will maintain whitespace within them
|
|
72
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']);
|
|
@@ -309,7 +309,8 @@ const descriptionElements = new Set(['dt', 'dd']);
|
|
|
309
309
|
|
|
310
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']);
|
|
311
311
|
|
|
312
|
-
// 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
|
|
313
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']);
|
|
314
315
|
|
|
315
316
|
const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
|
|
@@ -332,6 +333,9 @@ const looseElements = new Set(['head', 'colgroup', 'caption']);
|
|
|
332
333
|
|
|
333
334
|
const trailingElements = new Set(['dt', 'thead']);
|
|
334
335
|
|
|
336
|
+
// Special elements with optional end tags, which only `pClosingParents` end tags close
|
|
337
|
+
const specialOptionalEndTags = new Set(['p', 'li', 'dd', 'dt']);
|
|
338
|
+
|
|
335
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']);
|
|
336
340
|
|
|
337
341
|
// Doctype identifiers that set quirks mode, lowercase for their case-insensitive comparison
|
|
@@ -515,6 +519,7 @@ export {
|
|
|
515
519
|
compactElements,
|
|
516
520
|
looseElements,
|
|
517
521
|
trailingElements,
|
|
522
|
+
specialOptionalEndTags,
|
|
518
523
|
htmlElements,
|
|
519
524
|
|
|
520
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
|
|
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/unused-css.js
CHANGED
|
@@ -41,6 +41,8 @@ const fragmentReferenceAttributes = new Set([
|
|
|
41
41
|
|
|
42
42
|
const attributePattern = /(?:^|[\s/])([-\w:.]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
43
43
|
const identifierPattern = /--[\w-]*|-?[A-Za-z_][\w-]*/g;
|
|
44
|
+
const whitespacePattern = /\s/;
|
|
45
|
+
const whitespaceRunPattern = /\s+/;
|
|
44
46
|
// SVG paints and filters reach elements by ID through `url(#gradient)`, in
|
|
45
47
|
// presentation attributes as well as in `style`
|
|
46
48
|
const fragmentURLPattern = /url\(\s*['"]?#([^)'"\s]+)/gi;
|
|
@@ -58,8 +60,8 @@ const cssIdentifierPattern = /(?<!\\)[.#]((?:[-_a-zA-Z]|\\[0-9a-fA-F]{1,6}[ \t\n
|
|
|
58
60
|
// name used there is off limits even when no element carries it as a class or ID
|
|
59
61
|
const reservedAtRulePattern = /@(?:-\w+-)?(?:keyframes|counter-style)\s+(-?[_a-zA-Z][\w-]*)/gi;
|
|
60
62
|
const escapePattern = /\\(?:([0-9a-fA-F]{1,6})[ \t\n]?|(.))/g;
|
|
61
|
-
//
|
|
62
|
-
const startTagPattern = /<([a-z][^\s/>]*)/
|
|
63
|
+
// Matches inside comments and scripts only add names
|
|
64
|
+
const startTagPattern = /<([a-z][^\s/>]*)/gi;
|
|
63
65
|
|
|
64
66
|
/**
|
|
65
67
|
* Resolve CSS escape sequences in an identifier.
|
|
@@ -83,20 +85,43 @@ function unescapeIdentifier(identifier) {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
|
|
88
|
+
* Add what `identifierPattern` matches in `text`
|
|
89
|
+
* @param {string} text
|
|
90
|
+
* @param {Set<string>} used
|
|
91
|
+
* @param {Set<string>} [names] - Also receives each identifier lowercased
|
|
92
|
+
* @param {Set<string>} [seen] - Identifiers added before, which are skipped
|
|
93
|
+
*/
|
|
94
|
+
function addIdentifiers(text, used, names, seen) {
|
|
95
|
+
for (const identifier of text.match(identifierPattern) ?? []) {
|
|
96
|
+
if (seen) {
|
|
97
|
+
if (seen.has(identifier)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
seen.add(identifier);
|
|
101
|
+
}
|
|
102
|
+
used.add(identifier);
|
|
103
|
+
names?.add(identifier.toLowerCase());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Add what splitting `text` at `/\s+/` yields, but empty strings
|
|
92
109
|
* @param {string} text
|
|
93
|
-
* @
|
|
110
|
+
* @param {Set<string>} used
|
|
94
111
|
*/
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
function addTokens(text, used) {
|
|
113
|
+
// Most values are a single token, which needs no array
|
|
114
|
+
if (!whitespacePattern.test(text)) {
|
|
115
|
+
if (text) {
|
|
116
|
+
used.add(text);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const token of text.split(whitespaceRunPattern)) {
|
|
121
|
+
if (token) {
|
|
122
|
+
used.add(token);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
100
125
|
}
|
|
101
126
|
|
|
102
127
|
/**
|
|
@@ -107,15 +132,21 @@ function foldCase(text) {
|
|
|
107
132
|
* quadratically over a document full of near-matches, and bounding it would make
|
|
108
133
|
* long start tags go unrecognized.
|
|
109
134
|
*
|
|
110
|
-
*
|
|
135
|
+
* Tag names are matched in ASCII case only, as browsers do, and on the markup itself,
|
|
136
|
+
* so offsets need no mapping back.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} html
|
|
111
139
|
* @param {string} tagName - Lowercase element name
|
|
112
140
|
* @returns {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>}
|
|
113
141
|
*/
|
|
114
|
-
function findRawTextElements(
|
|
142
|
+
function findRawTextElements(html, tagName) {
|
|
115
143
|
/** @type {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>} */
|
|
116
144
|
const found = [];
|
|
117
|
-
|
|
118
|
-
const
|
|
145
|
+
// Without the `u` flag, `i` folds no non-ASCII character into ASCII
|
|
146
|
+
const openTag = new RegExp('<' + tagName, 'gi');
|
|
147
|
+
const closeTag = new RegExp('</' + tagName, 'gi');
|
|
148
|
+
const lengthOpen = tagName.length + 1;
|
|
149
|
+
const lengthClose = tagName.length + 2;
|
|
119
150
|
// A tag name ends at whitespace, a slash, or the closing bracket—so `<styles>`
|
|
120
151
|
// and `</scriptfoo>` name different elements and must not match
|
|
121
152
|
const endsName = (/** @type {string} */ character) =>
|
|
@@ -123,17 +154,18 @@ function findRawTextElements(haystack, tagName) {
|
|
|
123
154
|
|
|
124
155
|
let cursor = 0;
|
|
125
156
|
for (;;) {
|
|
126
|
-
|
|
157
|
+
openTag.lastIndex = cursor;
|
|
158
|
+
const start = openTag.exec(html)?.index ?? -1;
|
|
127
159
|
if (start === -1) {
|
|
128
160
|
break;
|
|
129
161
|
}
|
|
130
|
-
if (!endsName(
|
|
131
|
-
cursor = start +
|
|
162
|
+
if (!endsName(html.charAt(start + lengthOpen))) {
|
|
163
|
+
cursor = start + lengthOpen;
|
|
132
164
|
continue;
|
|
133
165
|
}
|
|
134
166
|
// A quoted attribute value may hold a `>`, so the tag ends where the parser
|
|
135
167
|
// says it does, not at the next bracket
|
|
136
|
-
const startTagEnd = findTagEnd(
|
|
168
|
+
const startTagEnd = findTagEnd(html, start + lengthOpen);
|
|
137
169
|
if (startTagEnd === -1) {
|
|
138
170
|
break;
|
|
139
171
|
}
|
|
@@ -143,30 +175,31 @@ function findRawTextElements(haystack, tagName) {
|
|
|
143
175
|
let bodyEnd = -1;
|
|
144
176
|
let end = -1;
|
|
145
177
|
for (;;) {
|
|
146
|
-
|
|
178
|
+
closeTag.lastIndex = search;
|
|
179
|
+
const candidate = closeTag.exec(html)?.index ?? -1;
|
|
147
180
|
if (candidate === -1) {
|
|
148
181
|
break;
|
|
149
182
|
}
|
|
150
|
-
if (endsName(
|
|
151
|
-
const closeEnd = findTagEnd(
|
|
183
|
+
if (endsName(html.charAt(candidate + lengthClose))) {
|
|
184
|
+
const closeEnd = findTagEnd(html, candidate + lengthClose);
|
|
152
185
|
if (closeEnd !== -1) {
|
|
153
186
|
bodyEnd = candidate;
|
|
154
187
|
end = closeEnd + 1;
|
|
155
188
|
}
|
|
156
189
|
break;
|
|
157
190
|
}
|
|
158
|
-
search = candidate +
|
|
191
|
+
search = candidate + lengthClose;
|
|
159
192
|
}
|
|
160
193
|
|
|
161
194
|
const closed = bodyEnd !== -1;
|
|
162
195
|
found.push({
|
|
163
196
|
start,
|
|
164
197
|
bodyStart,
|
|
165
|
-
bodyEnd: closed ? bodyEnd :
|
|
166
|
-
end: closed ? end :
|
|
198
|
+
bodyEnd: closed ? bodyEnd : html.length,
|
|
199
|
+
end: closed ? end : html.length,
|
|
167
200
|
closed
|
|
168
201
|
});
|
|
169
|
-
cursor = closed ? end :
|
|
202
|
+
cursor = closed ? end : html.length;
|
|
170
203
|
}
|
|
171
204
|
|
|
172
205
|
return found;
|
|
@@ -189,39 +222,20 @@ function findRawTextElements(haystack, tagName) {
|
|
|
189
222
|
*/
|
|
190
223
|
function collectUsedSymbols(html, includeScripts, decode, elements) {
|
|
191
224
|
const used = new Set();
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
// would lose them.
|
|
200
|
-
const skipped = findRawTextElements(haystack, 'style')
|
|
225
|
+
|
|
226
|
+
// Style sheets are skipped rather than cut out, so offsets keep pointing into
|
|
227
|
+
// `html`. Only the body is skipped—the start tag carries ordinary attributes,
|
|
228
|
+
// and `<style id="theme">` could be what `#theme` refers to. An unclosed
|
|
229
|
+
// element is not skipped: Reading its contents as markup can only add symbols,
|
|
230
|
+
// whereas ignoring the rest of the document would lose them.
|
|
231
|
+
const skipped = findRawTextElements(html, 'style')
|
|
201
232
|
.filter(element => element.closed)
|
|
202
233
|
.map(element => ({ start: element.bodyStart, end: element.bodyEnd }));
|
|
203
234
|
|
|
204
235
|
if (elements) {
|
|
205
|
-
collectUsedElements(
|
|
236
|
+
collectUsedElements(html, skipped, elements);
|
|
206
237
|
}
|
|
207
238
|
|
|
208
|
-
const addIdentifiers = (/** @type {string} */ text, /** @type {Set<string> | undefined} */ names = undefined) => {
|
|
209
|
-
identifierPattern.lastIndex = 0;
|
|
210
|
-
let identifier;
|
|
211
|
-
while ((identifier = identifierPattern.exec(text))) {
|
|
212
|
-
used.add(identifier[0]);
|
|
213
|
-
names?.add(identifier[0].toLowerCase());
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
const addTokens = (/** @type {string} */ text) => {
|
|
218
|
-
for (const token of text.split(/\s+/)) {
|
|
219
|
-
if (token) {
|
|
220
|
-
used.add(token);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
};
|
|
224
|
-
|
|
225
239
|
// Both loops below walk forward, so one cursor over the skipped ranges suffices
|
|
226
240
|
let skipCursor = 0;
|
|
227
241
|
const isSkipped = (/** @type {number} */ index) => {
|
|
@@ -243,20 +257,20 @@ function collectUsedSymbols(html, includeScripts, decode, elements) {
|
|
|
243
257
|
// `class="used"` names the class `used`, so compare against the decoded value
|
|
244
258
|
const value = (decode && raw.indexOf('&') !== -1) ? decode(raw) : raw;
|
|
245
259
|
if (name === 'class' || name === 'id' || idReferenceAttributes.has(name)) {
|
|
246
|
-
addTokens(value);
|
|
260
|
+
addTokens(value, used);
|
|
247
261
|
} else if (name.startsWith('data-')) {
|
|
248
262
|
// Class names are commonly parked in `data-*` attributes for scripts to apply later
|
|
249
|
-
addIdentifiers(value);
|
|
250
|
-
addTokens(value);
|
|
263
|
+
addIdentifiers(value, used);
|
|
264
|
+
addTokens(value, used);
|
|
251
265
|
} else if (fragmentReferenceAttributes.has(name) && value.charAt(0) === '#') {
|
|
252
266
|
// Only a leading `#`: `href="/page#sec"` names a section of another document
|
|
253
|
-
addTokens(value.slice(1));
|
|
267
|
+
addTokens(value.slice(1), used);
|
|
254
268
|
}
|
|
255
269
|
if (value.indexOf('(') !== -1) {
|
|
256
270
|
fragmentURLPattern.lastIndex = 0;
|
|
257
271
|
let reference;
|
|
258
272
|
while ((reference = fragmentURLPattern.exec(value))) {
|
|
259
|
-
addTokens(reference[1] ?? '');
|
|
273
|
+
addTokens(reference[1] ?? '', used);
|
|
260
274
|
}
|
|
261
275
|
}
|
|
262
276
|
}
|
|
@@ -267,18 +281,21 @@ function collectUsedSymbols(html, includeScripts, decode, elements) {
|
|
|
267
281
|
// isn’t tracked here, both readings count where they differ. An unclosed
|
|
268
282
|
// `script` runs to the end of the document, as it does in a browser.
|
|
269
283
|
skipCursor = 0;
|
|
270
|
-
|
|
284
|
+
// Scripts repeat their identifiers, which need adding only once
|
|
285
|
+
/** @type {Set<string>} */
|
|
286
|
+
const identifiersScript = new Set();
|
|
287
|
+
for (const element of findRawTextElements(html, 'script')) {
|
|
271
288
|
if (isSkipped(element.start)) {
|
|
272
289
|
continue;
|
|
273
290
|
}
|
|
274
291
|
const body = html.slice(element.bodyStart, element.bodyEnd);
|
|
275
292
|
const decoded = (decode && body.indexOf('&') !== -1) ? decode(body) : body;
|
|
276
293
|
for (const text of decoded === body ? [body] : [body, decoded]) {
|
|
277
|
-
addIdentifiers(text, elements);
|
|
294
|
+
addIdentifiers(text, used, elements, identifiersScript);
|
|
278
295
|
stringLiteralPattern.lastIndex = 0;
|
|
279
296
|
let literal;
|
|
280
297
|
while ((literal = stringLiteralPattern.exec(text))) {
|
|
281
|
-
addTokens(literal[1] ?? literal[2] ?? literal[3] ?? '');
|
|
298
|
+
addTokens(literal[1] ?? literal[2] ?? literal[3] ?? '', used);
|
|
282
299
|
}
|
|
283
300
|
}
|
|
284
301
|
}
|
|
@@ -342,11 +359,11 @@ function isSafelisted(symbol, safelist) {
|
|
|
342
359
|
/**
|
|
343
360
|
* Collect the element names a document contains, including those the parser
|
|
344
361
|
* supplies when the markup leaves them out.
|
|
345
|
-
* @param {string}
|
|
362
|
+
* @param {string} html
|
|
346
363
|
* @param {Array<{start: number, end: number}>} skipped - Style sheet bodies, in document order
|
|
347
364
|
* @param {Set<string>} elements - Lowercase element names, added to
|
|
348
365
|
*/
|
|
349
|
-
function collectUsedElements(
|
|
366
|
+
function collectUsedElements(html, skipped, elements) {
|
|
350
367
|
elements.add('html');
|
|
351
368
|
elements.add('head');
|
|
352
369
|
elements.add('body');
|
|
@@ -354,7 +371,7 @@ function collectUsedElements(haystack, skipped, elements) {
|
|
|
354
371
|
let skipIndex = 0;
|
|
355
372
|
startTagPattern.lastIndex = 0;
|
|
356
373
|
let match;
|
|
357
|
-
while ((match = startTagPattern.exec(
|
|
374
|
+
while ((match = startTagPattern.exec(html))) {
|
|
358
375
|
while (skipIndex < skipped.length && (skipped[skipIndex]?.end ?? 0) <= match.index) {
|
|
359
376
|
skipIndex++;
|
|
360
377
|
}
|
|
@@ -362,7 +379,7 @@ function collectUsedElements(haystack, skipped, elements) {
|
|
|
362
379
|
if (element && match.index >= element.start) {
|
|
363
380
|
continue;
|
|
364
381
|
}
|
|
365
|
-
elements.add(match[1] ?? '');
|
|
382
|
+
elements.add((match[1] ?? '').toLowerCase());
|
|
366
383
|
}
|
|
367
384
|
}
|
|
368
385
|
|
package/src/lib/utils.js
CHANGED
|
@@ -133,6 +133,14 @@ function escapeXML(value, attribute = false) {
|
|
|
133
133
|
return value.replace(attribute ? RE_XML_ESCAPE_ATTR : RE_XML_ESCAPE_TEXT, escapeXMLMatch);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Script and style text as browsers read it, for code minifiers
|
|
138
|
+
* @param {string} text
|
|
139
|
+
*/
|
|
140
|
+
function replaceNUL(text) {
|
|
141
|
+
return text.indexOf('\0') === -1 ? text : text.replace(/\0/g, '\uFFFD');
|
|
142
|
+
}
|
|
143
|
+
|
|
136
144
|
/**
|
|
137
145
|
* Read text in foreign content as HTML does: CDATA sections hold text as written,
|
|
138
146
|
* while what surrounds them reads character references
|
|
@@ -938,6 +946,15 @@ function describeQuantifierRisk(pattern) {
|
|
|
938
946
|
: null;
|
|
939
947
|
}
|
|
940
948
|
|
|
949
|
+
// The characters `\s` matches, by code: tab through carriage return, space, and the
|
|
950
|
+
// Unicode whitespace and line terminators
|
|
951
|
+
/** @param {number} code */
|
|
952
|
+
function isWhitespaceCode(code) {
|
|
953
|
+
return (code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
|
|
954
|
+
(code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
|
|
955
|
+
code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF;
|
|
956
|
+
}
|
|
957
|
+
|
|
941
958
|
/**
|
|
942
959
|
* Find the index of the `>` that closes an opening tag, correctly skipping
|
|
943
960
|
* over quoted attribute values (which may contain `>`).
|
|
@@ -1011,10 +1028,12 @@ export {
|
|
|
1011
1028
|
hashContent,
|
|
1012
1029
|
uniqueId,
|
|
1013
1030
|
escapeXML,
|
|
1031
|
+
replaceNUL,
|
|
1014
1032
|
decodeForeignText,
|
|
1015
1033
|
identity,
|
|
1016
1034
|
isQuirksDoctype,
|
|
1017
1035
|
isThenable,
|
|
1036
|
+
isWhitespaceCode,
|
|
1018
1037
|
lowercase,
|
|
1019
1038
|
paramCase,
|
|
1020
1039
|
replaceAsync,
|
package/src/lib/whitespace.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
formControlElementsEither,
|
|
19
19
|
toEndTags
|
|
20
20
|
} from './constants.js';
|
|
21
|
+
import { isWhitespaceCode } from './utils.js';
|
|
21
22
|
|
|
22
23
|
const RE_ANY_WS_NBSP = /[ \n\r\t\f\xA0]/;
|
|
23
24
|
const RE_TAB_NBSP = /[\t\xA0]/;
|
|
@@ -53,10 +54,7 @@ function isWsOrNbsp(code) {
|
|
|
53
54
|
/** @param {string} str */
|
|
54
55
|
function isAllWhitespace(str) {
|
|
55
56
|
for (let i = 0; i < str.length; i++) {
|
|
56
|
-
|
|
57
|
-
if (!((code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
|
|
58
|
-
(code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
|
|
59
|
-
code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF)) {
|
|
57
|
+
if (!isWhitespaceCode(str.charCodeAt(i))) {
|
|
60
58
|
return false;
|
|
61
59
|
}
|
|
62
60
|
}
|