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/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
- /** @param {HTMLParserHandler} handler */
306
- function getAttrRegexForHandler(handler) {
307
- const cached = attrRegexCache.get(handler);
308
- if (cached) return cached;
309
- const compiled = buildAttrRegex(handler);
310
- attrRegexCache.set(handler, compiled);
311
- return compiled;
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
- // Cache for sticky attribute regexes (`y` flag for position-based matching on full string)
315
- const attrRegexStickyCache = new WeakMap();
303
+ /** @param {unknown} value */
304
+ const isEmptyOption = value => !value || (Array.isArray(value) && !value.length);
316
305
 
317
306
  /** @param {HTMLParserHandler} handler */
318
- function getAttrRegexStickyForHandler(handler) {
319
- const cached = attrRegexStickyCache.get(handler);
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 nonSticky = getAttrRegexForHandler(handler);
322
- // Derive sticky version: Remove `^` anchor, add `y` flag
323
- const compiled = new RegExp(nonSticky.source.slice(1), 'y');
324
- attrRegexStickyCache.set(handler, compiled);
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 = getAttrRegexForHandler(handler);
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);
@@ -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
- if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !isEventAttr) {
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
- // The name to write and to hand to hooks: as in the source for HTML names
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.removeEmptyAttributes &&
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,
@@ -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, so that `</p>` may be omitted before them
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
@@ -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
- if (hasAttrName('id', attrs)) {
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
 
@@ -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 delimited = toDelimitedFragment(pattern);
159
- if (delimited) return delimited;
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
- return {
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
  /**
@@ -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
- // Run over case-folded markup; matches inside comments and scripts only add names
62
- const startTagPattern = /<([a-z][^\s/>]*)/g;
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
- * Lowercase for matching without disturbing offsets.
87
- *
88
- * `toLowerCase()` can change a string's length—U+0130 becomes two code units—which
89
- * would misalign every offset taken from the result. Folding just ASCII preserves
90
- * length, and tag names are ASCII anyway.
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
- * @returns {string} Same length as `text`
110
+ * @param {Set<string>} used
94
111
  */
95
- function foldCase(text) {
96
- const lowercased = text.toLowerCase();
97
- return lowercased.length === text.length
98
- ? lowercased
99
- : text.replace(/[A-Z]+/g, uppercase => uppercase.toLowerCase());
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
- * @param {string} haystack - Case-folded markup, as returned by `foldCase`
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(haystack, tagName) {
142
+ function findRawTextElements(html, tagName) {
115
143
  /** @type {Array<{start: number, bodyStart: number, bodyEnd: number, end: number, closed: boolean}>} */
116
144
  const found = [];
117
- const openTag = '<' + tagName;
118
- const closeTag = '</' + tagName;
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
- const start = haystack.indexOf(openTag, cursor);
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(haystack.charAt(start + openTag.length))) {
131
- cursor = start + openTag.length;
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(haystack, start + openTag.length);
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
- const candidate = haystack.indexOf(closeTag, search);
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(haystack.charAt(candidate + closeTag.length))) {
151
- const closeEnd = findTagEnd(haystack, candidate + closeTag.length);
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 + closeTag.length;
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 : haystack.length,
166
- end: closed ? end : haystack.length,
198
+ bodyEnd: closed ? bodyEnd : html.length,
199
+ end: closed ? end : html.length,
167
200
  closed
168
201
  });
169
- cursor = closed ? end : haystack.length;
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
- const haystack = foldCase(html);
193
-
194
- // Style sheets are skipped rather than cut out, so both scans share one folded
195
- // copy and offsets keep pointing into `html`. Only the body is skipped—the start
196
- // tag carries ordinary attributes, and `<style id="theme">` could be what
197
- // `#theme` refers to. An unclosed element is not skipped: Reading its contents
198
- // as markup can only add symbols, whereas ignoring the rest of the document
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(haystack, skipped, elements);
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="us&#101;d"` 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
- for (const element of findRawTextElements(haystack, 'script')) {
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} haystack - Case-folded markup, as returned by `foldCase`
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(haystack, skipped, elements) {
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(haystack))) {
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,
@@ -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
- const code = str.charCodeAt(i);
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
  }