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.
@@ -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
- const isLegacyAttr = (tag === 'script' && (attrName === 'language' || attrName === 'charset')) || (tag === 'a' && attrName === 'name');
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
- if (options.collapseAttributeWhitespace && !collapseAttributeWhitespaceExempt.has(attrName) && !(attrName === 'value' && valueWhitespaceExemptElements.has(tag)) && !isEventAttr) {
693
- // Fast path: Only process if whitespace exists (avoids regex overhead on clean values)
694
- if (RE_ATTR_WS_CHECK.test(attrValue)) {
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
- // The name to write and to hand to hooks: as in the source for HTML names
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.removeEmptyAttributes &&
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,
@@ -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, 'bdi', 'bdo', 'cite', 'code', 'dfn', 'label', 'math', 'q', 'ruby']);
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(['meta', 'link', 'script', 'style', 'template', 'noscript']);
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, 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
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
@@ -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
 
@@ -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':
@@ -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
  /**
@@ -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`) or custom-fragment UIDs (long lowercase alphanumeric runs), keep the
523
- // original; CDATA sections, entities, and other invalid CSS may go
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);