html-minifier-next 7.5.3 → 8.0.0

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.
@@ -2,11 +2,13 @@ import { HTMLParser, endTag } from './htmlparser.js';
2
2
  import TokenChain from './tokenchain.js';
3
3
  import { presets, getPreset, getPresetNames } from './presets.js';
4
4
 
5
- import { LRU, identity, isThenable, lowercase, uniqueId } from './lib/utils.js';
5
+ import { LRU, findTagEnd, identity, isThenable, lowercase, uniqueId } from './lib/utils.js';
6
+ import { collectUsedSymbols } from './lib/unused-css.js';
6
7
 
7
8
  import {
8
9
  RE_LEGACY_ENTITIES,
9
10
  RE_ESCAPE_LT,
11
+ RE_STYLE_ELEMENT,
10
12
  inlineElementsToKeepWhitespaceAround,
11
13
  inlineElementsToKeepWhitespaceWithin,
12
14
  specialContentElements,
@@ -53,6 +55,7 @@ import {
53
55
  } from './lib/content.js';
54
56
 
55
57
  import { processOptions } from './lib/options.js';
58
+ import { toFragment, replaceCustomFragments } from './lib/fragments.js';
56
59
 
57
60
  /** @import { ProcessedOptions } from './lib/options.js' */
58
61
 
@@ -61,7 +64,7 @@ import { processOptions } from './lib/options.js';
61
64
  /**
62
65
  * @typedef {Object} HTMLAttribute
63
66
  * Representation of an attribute from the HTML parser.
64
- * Internal counterpart: `HTMLAttribute` in `lib/attributes.js`—keep in sync.
67
+ * Internal counterpart: `HTMLAttribute` in lib/attributes.js—keep in sync.
65
68
  *
66
69
  * @prop {string} name
67
70
  * @prop {string} [value]
@@ -194,11 +197,13 @@ import { processOptions } from './lib/options.js';
194
197
  *
195
198
  * Default: `[/^on[a-z]{3,}$/]`
196
199
  *
197
- * @prop {number} [customFragmentQuantifierLimit]
198
- * Limits the quantifier used when building a safe regex for custom
199
- * fragments to avoid ReDoS. See source use for details.
200
+ * @prop {boolean} [strictCustomFragments]
201
+ * Rejects `ignoreCustomFragments` patterns that risk catastrophic backtracking,
202
+ * rather than warning about them: those that compound quantifiers or alternation
203
+ * (`(a+)+`, `(a|b)*`), those that repeat the same unbounded atom twice in a row
204
+ * (`.*.*`), and those too long or too deeply nested to analyze at all.
200
205
  *
201
- * Default: `200`
206
+ * Default: `false`
202
207
  *
203
208
  * @prop {boolean} [decodeEntities]
204
209
  * When true, decodes HTML entities in text and attributes before
@@ -437,6 +442,20 @@ import { processOptions } from './lib/options.js';
437
442
  *
438
443
  * Default: `false`
439
444
  *
445
+ * @prop {boolean | {safelist?: Array<string | RegExp>, scripts?: boolean}} [removeUnusedCSS]
446
+ * **Note that this can change how a document renders!**
447
+ *
448
+ * Remove rules from `style` elements whose class or ID selectors the document
449
+ * never references. Requires `minifyCSS` (removal runs through Lightning CSS)
450
+ * and has no effect on `style` or `media` attributes.
451
+ *
452
+ * Class names and IDs are collected from the markup, from `data-*` attributes,
453
+ * and—unless `scripts` is set to `false`—from inline `script` elements. Names
454
+ * that only ever appear in external scripts cannot be detected; list those
455
+ * under `safelist` (strings or regular expressions) to keep them.
456
+ *
457
+ * Default: `false`
458
+ *
440
459
  * @prop {boolean | ((tag: string, attrs: HTMLAttribute[]) => void)} [sortAttributes]
441
460
  * When true, enables sorting of attributes. If a function is provided it
442
461
  * will be used as a custom attribute sorter, which should mutate `attrs`
@@ -570,28 +589,6 @@ const RE_FOREIGN_ELEMENT_PROBE = /<(?:svg|math)[\s/>]/i;
570
589
 
571
590
  // Script merging
572
591
 
573
- /**
574
- * Find the index of the `>` that closes an opening tag, correctly skipping
575
- * over quoted attribute values (which may contain `>`).
576
- * @param {string} html
577
- * @param {number} pos - Start position (just after the tag name)
578
- * @returns {number} Index of the closing `>`, or -1 if not found
579
- */
580
- function findTagEnd(html, pos) {
581
- let i = pos;
582
- while (i < html.length) {
583
- const ch = html[i];
584
- if (ch === '>') return i;
585
- if (ch === '"' || ch === "'") {
586
- const q = ch;
587
- i++;
588
- while (i < html.length && html[i] !== q) i++;
589
- }
590
- i++;
591
- }
592
- return -1;
593
- }
594
-
595
592
  /**
596
593
  * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
597
594
  * Only merges scripts that are compatible:
@@ -832,13 +829,12 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
832
829
  const originalContinueOnParseError = options.continueOnParseError;
833
830
  options.continueOnParseError = true;
834
831
 
835
- // Pre-compile regex patterns for UID replacement and custom fragments
832
+ // Pre-compile the UID replacement pattern, and read the fragments the way the
833
+ // main path does, so each keeps its own flags
836
834
  const uidReplacePattern = uidIgnore && ignoredMarkupChunks
837
835
  ? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
838
836
  : null;
839
- const customFragmentPattern = options.ignoreCustomFragments && options.ignoreCustomFragments.length > 0
840
- ? new RegExp('(' + options.ignoreCustomFragments.map(re => re.source).join('|') + ')', 'g')
841
- : null;
837
+ const customFragments = (options.ignoreCustomFragments || []).map(toFragment);
842
838
 
843
839
  try {
844
840
  // Expand UID tokens back to the original content for frequency analysis
@@ -858,8 +854,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
858
854
  // because HTML comments in opening tags prevent proper attribute parsing;
859
855
  // removed with a space to preserve attribute boundaries
860
856
  let scanValue = firstPassOutput;
861
- if (customFragmentPattern) {
862
- scanValue = firstPassOutput.replace(customFragmentPattern, ' ');
857
+ if (customFragments.length) {
858
+ scanValue = replaceCustomFragments(firstPassOutput, customFragments, () => ' ');
863
859
  }
864
860
 
865
861
  await scan(scanValue);
@@ -1063,63 +1059,46 @@ async function minifyHTML(value, options, partialMarkup) {
1063
1059
  await createSortFns(value, options, uidIgnore ?? '', uidAttr ?? '', ignoredMarkupChunks);
1064
1060
  }
1065
1061
 
1066
- const customFragments = (options.ignoreCustomFragments || []).map(function (/** @type {RegExp} */ re) {
1067
- return re.source;
1068
- });
1062
+ const customFragments = (options.ignoreCustomFragments || []).map(toFragment);
1069
1063
  if (customFragments.length) {
1070
- // Safe approach: Use bounded quantifiers instead of unlimited ones to prevent ReDoS
1071
- const maxQuantifier = options.customFragmentQuantifierLimit || 200;
1072
- const whitespacePattern = `\\s{0,${maxQuantifier}}`;
1073
-
1074
- // Fast path: The padded replace below is costly on large inputs because the
1075
- // `\s{0,N}` padding makes the regex engine consume and backtrack at every
1076
- // whitespace run; since the padding is optional and the fragment group requires
1077
- // at least one match, a single unpadded probe that finds nothing proves the
1078
- // replace would be a no-op—so skip it entirely
1079
- const reFragmentProbe = new RegExp('(?:' + customFragments.join('|') + ')');
1080
- if (reFragmentProbe.test(value)) {
1081
- // Use bounded quantifiers to prevent ReDoS—this approach prevents exponential backtracking
1082
- const reCustomIgnore = new RegExp(
1083
- whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
1084
- 'g'
1085
- );
1086
- // Temporarily replace custom ignored fragments with unique attributes
1087
- value = value.replace(reCustomIgnore, function (match) {
1088
- if (!uidAttr) {
1089
- uidAttr = uniqueId(value);
1090
- uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1091
- uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
1092
-
1093
- if (options.minifyCSS !== identity) {
1094
- options.minifyCSS = (function (/** @type {ProcessedOptions['minifyCSS']} */ fn) {
1095
- return function (/** @type {string} */ text, /** @type {string | undefined} */ type) {
1096
- text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1097
- const chunks = ignoredCustomMarkupChunks[+index];
1098
- return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1099
- });
1100
-
1101
- return fn(text, type);
1102
- };
1103
- })(options.minifyCSS);
1104
- }
1064
+ // Temporarily replace custom ignored fragments with unique attributes
1065
+ const replaceFragment = function (/** @type {string} */ match) {
1066
+ if (!uidAttr) {
1067
+ uidAttr = uniqueId(value);
1068
+ uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1069
+ uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
1070
+
1071
+ if (options.minifyCSS !== identity) {
1072
+ options.minifyCSS = (function (/** @type {ProcessedOptions['minifyCSS']} */ fn) {
1073
+ return function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {ProcessedOptions['cssContext']} */ context) {
1074
+ text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1075
+ const chunks = ignoredCustomMarkupChunks[+index];
1076
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1077
+ });
1105
1078
 
1106
- if (options.minifyJS !== identity) {
1107
- options.minifyJS = (function (/** @type {ProcessedOptions['minifyJS']} */ fn) {
1108
- return function (/** @type {string} */ text, /** @type {boolean | undefined} */ inline, /** @type {boolean | undefined} */ isModule) {
1109
- return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1110
- const chunks = ignoredCustomMarkupChunks[+index];
1111
- return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1112
- }), inline, isModule);
1113
- };
1114
- })(options.minifyJS);
1115
- }
1079
+ return fn(text, type, context);
1080
+ };
1081
+ })(options.minifyCSS);
1116
1082
  }
1117
1083
 
1118
- const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
1119
- ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
1120
- return '\t' + token + '\t';
1121
- });
1122
- }
1084
+ if (options.minifyJS !== identity) {
1085
+ options.minifyJS = (function (/** @type {ProcessedOptions['minifyJS']} */ fn) {
1086
+ return function (/** @type {string} */ text, /** @type {boolean | undefined} */ inline, /** @type {boolean | undefined} */ isModule) {
1087
+ return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1088
+ const chunks = ignoredCustomMarkupChunks[+index];
1089
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1090
+ }), inline, isModule);
1091
+ };
1092
+ })(options.minifyJS);
1093
+ }
1094
+ }
1095
+
1096
+ const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
1097
+ ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
1098
+ return '\t' + token + '\t';
1099
+ };
1100
+
1101
+ value = replaceCustomFragments(value, customFragments, replaceFragment);
1123
1102
  }
1124
1103
 
1125
1104
  function canCollapseWhitespace(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
@@ -1758,7 +1737,7 @@ async function minifyHTML(value, options, partialMarkup) {
1758
1737
  text = await options.minifyJS(text, false, isModuleScript);
1759
1738
  }
1760
1739
  if (needsMinifyCSS) {
1761
- text = await options.minifyCSS(text);
1740
+ text = await options.minifyCSS(text, undefined, options.cssContext);
1762
1741
  }
1763
1742
  charsFinalize(text);
1764
1743
  })();
@@ -2094,6 +2073,25 @@ export const minify = async function (value, options) {
2094
2073
  }
2095
2074
  // Work on a shallow copy so per-call reassignments don’t reach the cached base
2096
2075
  const processedOptions = /** @type {ProcessedOptions} */ ({ ...processedBase });
2076
+
2077
+ // Warnings are deduplicated per document, so the state has to live on the per-call
2078
+ // copy; the `minifyCSS` closure hangs off the memoized base, shared across calls
2079
+ processedOptions.cssContext = { warned: new Set() };
2080
+
2081
+ // Unused-CSS removal needs the whole document’s symbols before the first `style`
2082
+ // element is minified, so collect them upfront from the raw input. A document
2083
+ // without a style sheet has nothing to remove from, and scanning it would be work
2084
+ // spent on an empty result.
2085
+ if (processedOptions.removeUnusedCSS && RE_STYLE_ELEMENT.test(value)) {
2086
+ processedOptions.cssContext.usedSymbols = collectUsedSymbols(
2087
+ value,
2088
+ processedOptions.removeUnusedCSS.scripts,
2089
+ value.indexOf('&') !== -1
2090
+ ? /** @type {(text: string) => string} */ (await getDecodeHTML())
2091
+ : undefined
2092
+ );
2093
+ }
2094
+
2097
2095
  let result = await minifyHTML(value, processedOptions);
2098
2096
 
2099
2097
  // Post-processing: Merge consecutive inline scripts if enabled
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 } from './lib/utils.js';
8
+ import { isThenable, embedSource } from './lib/utils.js';
9
9
 
10
10
  /** @import { HTMLAttribute } from './lib/attributes.js' */
11
11
 
@@ -141,9 +141,9 @@ function buildAttrRegex(handler) {
141
141
  throw new Error('`customAttrSurround` entries must be `[RegExp, RegExp]` pairs');
142
142
  }
143
143
  attrClauses[i] = '(?:' +
144
- '(' + pair[0].source + ')\\s*' +
144
+ '(' + embedSource(pair[0]) + ')\\s*' +
145
145
  pattern +
146
- '\\s*(' + pair[1].source + ')' +
146
+ '\\s*(' + embedSource(pair[1]) + ')' +
147
147
  ')';
148
148
  }
149
149
  attrClauses.push('(?:' + pattern + ')');
@@ -180,7 +180,7 @@ function joinSingleAttrAssigns(handler) {
180
180
  return singleAttrAssigns.concat(
181
181
  handler.customAttrAssign || []
182
182
  ).map(function (assign) {
183
- return '(?:' + assign.source + ')';
183
+ return '(?:' + embedSource(assign) + ')';
184
184
  }).join('|');
185
185
  }
186
186
 
@@ -13,10 +13,12 @@ import {
13
13
  isBooleanValue,
14
14
  collapsibleValues,
15
15
  srcsetElements,
16
- reEmptyAttribute
16
+ RE_EMPTY_ATTRIBUTE,
17
+ RE_STYLE_ELEMENT
17
18
  } from './constants.js';
18
19
  import { trimWhitespace, collapseWhitespaceAll } from './whitespace.js';
19
20
  import { shouldMinifyInnerHTML } from './options.js';
21
+ import { collectUsedSymbols } from './unused-css.js';
20
22
  import { identity, isThenable } from './utils.js';
21
23
 
22
24
  /** @import { ProcessedOptions } from './options.js' */
@@ -25,10 +27,11 @@ import { identity, isThenable } from './utils.js';
25
27
 
26
28
  /**
27
29
  * @typedef {{ name: string, value?: string | undefined, quote?: string, customAssign?: string, customOpen?: string, customClose?: string }} HTMLAttribute
28
- * Internal counterpart of the public typedef in `htmlminifier.js`—keep in sync.
30
+ * Internal counterpart of the public typedef in htmlminifier.js—keep in sync.
29
31
  */
30
32
 
31
- // Lazy-load entities (used for `decodeEntities` and event-handler attribute decode before `minifyJS`)
33
+ // Lazy-load entities (used for `decodeEntities`, event-handler attribute
34
+ // decoding before `minifyJS`, and `srcdoc` decoding/re-encoding)
32
35
 
33
36
  /** @type {Promise<Function> | undefined} */
34
37
  let decodeHTMLStrictPromise;
@@ -39,6 +42,15 @@ async function getDecodeHTMLStrict() {
39
42
  return decodeHTMLStrictPromise;
40
43
  }
41
44
 
45
+ /** @type {Promise<Function> | undefined} */
46
+ let escapeAttributePromise;
47
+ async function getEscapeAttribute() {
48
+ if (!escapeAttributePromise) {
49
+ escapeAttributePromise = import('entities').then(m => m.escapeAttribute);
50
+ }
51
+ return escapeAttributePromise;
52
+ }
53
+
42
54
  // Validators
43
55
 
44
56
  /**
@@ -353,7 +365,7 @@ function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
353
365
  if (typeof options.removeEmptyAttributes === 'function') {
354
366
  return options.removeEmptyAttributes(attrName, tag);
355
367
  }
356
- return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
368
+ return (tag === 'input' && attrName === 'value') || RE_EMPTY_ATTRIBUTE.test(attrName);
357
369
  }
358
370
 
359
371
  /**
@@ -470,7 +482,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
470
482
  attrValue = attrValue.replace(/\s*;$/, ';');
471
483
  }
472
484
  const originalAttrValue = attrValue;
473
- const cssResult = options.minifyCSS(attrValue, 'inline');
485
+ const cssResult = options.minifyCSS(attrValue, 'inline', options.cssContext);
474
486
  if (isThenable(cssResult)) {
475
487
  return cssResult
476
488
  .then((/** @type {string} */ minified) => {
@@ -581,7 +593,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
581
593
  return attrValue;
582
594
  }
583
595
  const originalAttrValue = attrValue;
584
- const cssResult = options.minifyCSS(attrValue, 'media');
596
+ const cssResult = options.minifyCSS(attrValue, 'media', options.cssContext);
585
597
  if (isThenable(cssResult)) {
586
598
  return cssResult.catch((/** @type {Error} */ err) => {
587
599
  if (!options.continueOnMinifyError) throw err;
@@ -593,17 +605,62 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
593
605
  }
594
606
 
595
607
  if (tag === 'iframe' && attrName === 'srcdoc') {
596
- // Recursively minify HTML content within `srcdoc` attribute
597
608
  // Fast-path: Skip if nothing would change
598
609
  if (!shouldMinifyInnerHTML(options)) {
599
610
  return attrValue;
600
611
  }
601
- return minifyHTMLSelf(attrValue, options, true);
612
+ return minifySrcdoc(attrValue, options, minifyHTMLSelf);
602
613
  }
603
614
 
604
615
  return attrValue;
605
616
  }
606
617
 
618
+ /**
619
+ * Recursively minify the document an `iframe srcdoc` attribute holds.
620
+ *
621
+ * Browsers resolve character references before parsing `srcdoc`, so an
622
+ * entity-encoded document is decoded first (unless `decodeEntities` already
623
+ * did) and re-encoded afterwards—but only then, so a literal value passes
624
+ * through byte-identical.
625
+ *
626
+ * @param {string} attrValue
627
+ * @param {ProcessedOptions} options
628
+ * @param {Function} minifyHTMLSelf
629
+ * @returns {Promise<string>}
630
+ */
631
+ async function minifySrcdoc(attrValue, options, minifyHTMLSelf) {
632
+ let markup = attrValue;
633
+ let wasEncoded = false;
634
+ if (!options.decodeEntities && attrValue.indexOf('&') !== -1) {
635
+ const decode = await getDecodeHTMLStrict();
636
+ markup = decode(attrValue);
637
+ wasEncoded = markup !== attrValue;
638
+ }
639
+
640
+ let srcdocOptions = options;
641
+ // The inner document sees none of the parent’s markup, so its style sheets
642
+ // minify against the symbols the `srcdoc` content references itself
643
+ if (options.removeUnusedCSS && RE_STYLE_ELEMENT.test(markup)) {
644
+ const decode = markup.indexOf('&') !== -1 ? await getDecodeHTMLStrict() : undefined;
645
+ srcdocOptions = {
646
+ ...options,
647
+ cssContext: {
648
+ warned: options.cssContext ? options.cssContext.warned : new Set(),
649
+ usedSymbols: collectUsedSymbols(markup, options.removeUnusedCSS.scripts, /** @type {((text: string) => string) | undefined} */ (decode))
650
+ }
651
+ };
652
+ }
653
+
654
+ try {
655
+ const minified = await minifyHTMLSelf(markup, srcdocOptions, true);
656
+ return wasEncoded ? (await getEscapeAttribute())(minified) : minified;
657
+ } catch (err) {
658
+ if (!options.continueOnMinifyError) throw err;
659
+ options.log && options.log(/** @type {Error} */ (err));
660
+ return attrValue;
661
+ }
662
+ }
663
+
607
664
  /**
608
665
  * Choose appropriate quote character for an attribute value
609
666
  * @param {string} attrValue - The attribute value
@@ -16,6 +16,10 @@ const RE_ESCAPE_LT = /</g;
16
16
  const RE_ATTR_WS_CHECK = /[ \n\r\t\f]/;
17
17
  const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
18
18
  const RE_ATTR_WS_TRIM = /^[ \n\r\t\f]+|[ \n\r\t\f]+$/g;
19
+ const RE_STYLE_ELEMENT = /<style[\s/>]/i;
20
+ const RE_EMPTY_ATTRIBUTE = new RegExp(
21
+ '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
22
+ '?:down|up|over|move|out)|key(?:press|down|up)))$');
19
23
 
20
24
  // Inline element sets for whitespace handling
21
25
 
@@ -162,12 +166,6 @@ const trailingElements = new Set(['dt', 'thead']);
162
166
 
163
167
  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']);
164
168
 
165
- // Empty attribute regex
166
-
167
- const reEmptyAttribute = new RegExp(
168
- '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
169
- '?:down|up|over|move|out)|key(?:press|down|up)))$');
170
-
171
169
  // Special content elements
172
170
 
173
171
  const specialContentElements = new Set(['script', 'style']);
@@ -192,6 +190,9 @@ export {
192
190
  RE_ATTR_WS_CHECK,
193
191
  RE_ATTR_WS_COLLAPSE,
194
192
  RE_ATTR_WS_TRIM,
193
+ RE_STYLE_ELEMENT,
194
+ RE_EMPTY_ATTRIBUTE,
195
+
195
196
  // Inline element sets
196
197
  inlineElementsToKeepWhitespaceAround,
197
198
  inlineElementsToKeepWhitespaceWithin,
@@ -234,7 +235,6 @@ export {
234
235
  trailingElements,
235
236
  htmlElements,
236
237
 
237
- // Regex
238
- reEmptyAttribute,
238
+ // Special content elements
239
239
  specialContentElements
240
240
  };
@@ -137,7 +137,7 @@ function canRemoveElement(tag, attrs) {
137
137
  }
138
138
 
139
139
  /**
140
- * @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
140
+ * @param {string} str - Tag name or HTML-like element spec (e.g., `td` or `<span aria-hidden='true'>`)
141
141
  * @param {ProcessedOptions} options - Options object for name normalization
142
142
  * @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
143
143
  */
@@ -203,12 +203,12 @@ function parseRemoveEmptyElementsExcept(input, options) {
203
203
  if (typeof item === 'string') {
204
204
  const spec = parseElementSpec(item, options);
205
205
  if (!spec && options.log) {
206
- options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: “' + item + '”');
206
+ options.log('Warning: Unable to parse `removeEmptyElementsExcept` specification: “' + item + '”');
207
207
  }
208
208
  return spec;
209
209
  }
210
210
  if (options.log) {
211
- options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
211
+ options.log('Warning: `removeEmptyElementsExcept` specification must be a string, received: ' + typeof item);
212
212
  }
213
213
  return null;
214
214
  }).filter(Boolean));