html-minifier-next 7.5.2 → 7.6.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,
@@ -437,6 +439,20 @@ import { processOptions } from './lib/options.js';
437
439
  *
438
440
  * Default: `false`
439
441
  *
442
+ * @prop {boolean | {safelist?: Array<string | RegExp>, scripts?: boolean}} [removeUnusedCSS]
443
+ * **Note that this can change how a document renders!**
444
+ *
445
+ * Remove rules from `style` elements whose class or ID selectors the document
446
+ * never references. Requires `minifyCSS` (removal runs through Lightning CSS)
447
+ * and has no effect on `style` or `media` attributes.
448
+ *
449
+ * Class names and IDs are collected from the markup, from `data-*` attributes,
450
+ * and—unless `scripts` is set to `false`—from inline `script` elements. Names
451
+ * that only ever appear in external scripts cannot be detected; list those
452
+ * under `safelist` (strings or regular expressions) to keep them.
453
+ *
454
+ * Default: `false`
455
+ *
440
456
  * @prop {boolean | ((tag: string, attrs: HTMLAttribute[]) => void)} [sortAttributes]
441
457
  * When true, enables sorting of attributes. If a function is provided it
442
458
  * will be used as a custom attribute sorter, which should mutate `attrs`
@@ -570,28 +586,6 @@ const RE_FOREIGN_ELEMENT_PROBE = /<(?:svg|math)[\s/>]/i;
570
586
 
571
587
  // Script merging
572
588
 
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
589
  /**
596
590
  * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
597
591
  * Only merges scripts that are compatible:
@@ -910,23 +904,23 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
910
904
  // Memoize `sortClassNames` results—class lists often repeat in templates
911
905
  const classNameCache = new LRU(500);
912
906
 
913
- options.sortClassNames = function (/** @type {string} */ value) {
907
+ options.sortClassNames = function (/** @type {string} */ classNames) {
914
908
  // Fast path: Single class (no spaces) needs no sorting
915
- if (value.indexOf(' ') === -1) {
916
- return value;
909
+ if (classNames.indexOf(' ') === -1) {
910
+ return classNames;
917
911
  }
918
912
 
919
913
  // Check cache first
920
- const cached = /** @type {string | undefined} */ (classNameCache.get(value));
914
+ const cached = /** @type {string | undefined} */ (classNameCache.get(classNames));
921
915
  if (cached !== undefined) {
922
916
  return cached;
923
917
  }
924
918
 
925
919
  // Expand UID tokens back to original content before sorting
926
920
  // Fast path: Skip if no HTML comments (UID markers) present
927
- let expandedValue = value;
928
- if (uidReplacePattern && value.indexOf('<!--') !== -1) {
929
- expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
921
+ let expandedValue = classNames;
922
+ if (uidReplacePattern && classNames.indexOf('<!--') !== -1) {
923
+ expandedValue = classNames.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
930
924
  return ignoredMarkupChunks[+index] || '';
931
925
  });
932
926
  // Reset `lastIndex` for pattern reuse
@@ -939,7 +933,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
939
933
  const result = sorted.join(' ');
940
934
 
941
935
  // Cache the result
942
- classNameCache.set(value, result);
936
+ classNameCache.set(classNames, result);
943
937
  return result;
944
938
  };
945
939
  }
@@ -1092,13 +1086,13 @@ async function minifyHTML(value, options, partialMarkup) {
1092
1086
 
1093
1087
  if (options.minifyCSS !== identity) {
1094
1088
  options.minifyCSS = (function (/** @type {ProcessedOptions['minifyCSS']} */ fn) {
1095
- return function (/** @type {string} */ text, /** @type {string | undefined} */ type) {
1089
+ return function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {ProcessedOptions['cssContext']} */ context) {
1096
1090
  text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1097
1091
  const chunks = ignoredCustomMarkupChunks[+index];
1098
1092
  return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1099
1093
  });
1100
1094
 
1101
- return fn(text, type);
1095
+ return fn(text, type, context);
1102
1096
  };
1103
1097
  })(options.minifyCSS);
1104
1098
  }
@@ -1148,11 +1142,11 @@ async function minifyHTML(value, options, partialMarkup) {
1148
1142
 
1149
1143
  // Look for trailing whitespaces, bypass any inline tags
1150
1144
  function trimTrailingWhitespace(/** @type {number} */ index, /** @type {string} */ nextTag) {
1151
- for (let endTag = ''; index >= 0 && canTrimWhitespace(endTag, emptyAttrs); index--) {
1145
+ for (let prevTag = ''; index >= 0 && canTrimWhitespace(prevTag, emptyAttrs); index--) {
1152
1146
  const str = buffer[index] ?? '';
1153
1147
  const match = str.match(/^<\/([\w:-]+)>$/);
1154
1148
  if (match) {
1155
- endTag = match[1] ?? '';
1149
+ prevTag = match[1] ?? '';
1156
1150
  } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, '', nextTag, emptyAttrs, emptyAttrs, options, inlineElements, inlineTextSet))) {
1157
1151
  break;
1158
1152
  }
@@ -1398,13 +1392,13 @@ async function minifyHTML(value, options, partialMarkup) {
1398
1392
  // Collapse if both sides are element/closing tags or HTML comments, and neither is inline
1399
1393
  if ((currentTagMatch || currentIsHtmlComment || currentClosingTagMatch) &&
1400
1394
  (prevTagMatch || prevIsHtmlComment || prevClosingTagMatch)) {
1401
- const currentTag = currentTagMatch ? resolveName(currentTagMatch[1] ?? '')
1395
+ const currentTagName = currentTagMatch ? resolveName(currentTagMatch[1] ?? '')
1402
1396
  : currentClosingTagMatch ? resolveName(currentClosingTagMatch[1] ?? '') : null;
1403
- const prevTag = prevTagMatch ? resolveName(prevTagMatch[1] ?? '')
1397
+ const prevTagName = prevTagMatch ? resolveName(prevTagMatch[1] ?? '')
1404
1398
  : prevClosingTagMatch ? resolveName(prevClosingTagMatch[1] ?? '') : null;
1405
1399
 
1406
1400
  // Don’t collapse between inline elements (HTML comments count as non-inline)
1407
- if (!inlineElements.has(currentTag ?? '') && !inlineElements.has(prevTag ?? '')) {
1401
+ if (!inlineElements.has(currentTagName ?? '') && !inlineElements.has(prevTagName ?? '')) {
1408
1402
  // Collapse whitespace respecting context rules
1409
1403
  let collapsedText = prevText;
1410
1404
 
@@ -1758,7 +1752,7 @@ async function minifyHTML(value, options, partialMarkup) {
1758
1752
  text = await options.minifyJS(text, false, isModuleScript);
1759
1753
  }
1760
1754
  if (needsMinifyCSS) {
1761
- text = await options.minifyCSS(text);
1755
+ text = await options.minifyCSS(text, undefined, options.cssContext);
1762
1756
  }
1763
1757
  charsFinalize(text);
1764
1758
  })();
@@ -2094,6 +2088,25 @@ export const minify = async function (value, options) {
2094
2088
  }
2095
2089
  // Work on a shallow copy so per-call reassignments don’t reach the cached base
2096
2090
  const processedOptions = /** @type {ProcessedOptions} */ ({ ...processedBase });
2091
+
2092
+ // Warnings are deduplicated per document, so the state has to live on the per-call
2093
+ // copy; the `minifyCSS` closure hangs off the memoized base, shared across calls
2094
+ processedOptions.cssContext = { warned: new Set() };
2095
+
2096
+ // Unused-CSS removal needs the whole document’s symbols before the first `style`
2097
+ // element is minified, so collect them upfront from the raw input. A document
2098
+ // without a style sheet has nothing to remove from, and scanning it would be work
2099
+ // spent on an empty result.
2100
+ if (processedOptions.removeUnusedCSS && RE_STYLE_ELEMENT.test(value)) {
2101
+ processedOptions.cssContext.usedSymbols = collectUsedSymbols(
2102
+ value,
2103
+ processedOptions.removeUnusedCSS.scripts,
2104
+ value.indexOf('&') !== -1
2105
+ ? /** @type {(text: string) => string} */ (await getDecodeHTML())
2106
+ : undefined
2107
+ );
2108
+ }
2109
+
2097
2110
  let result = await minifyHTML(value, processedOptions);
2098
2111
 
2099
2112
  // Post-processing: Merge consecutive inline scripts if enabled
package/src/htmlparser.js CHANGED
@@ -154,7 +154,7 @@ function buildAttrRegex(handler) {
154
154
 
155
155
  /** @param {HTMLParserHandler} handler */
156
156
  function getAttrRegexForHandler(handler) {
157
- let cached = attrRegexCache.get(handler);
157
+ const cached = attrRegexCache.get(handler);
158
158
  if (cached) return cached;
159
159
  const compiled = buildAttrRegex(handler);
160
160
  attrRegexCache.set(handler, compiled);
@@ -166,7 +166,7 @@ const attrRegexStickyCache = new WeakMap();
166
166
 
167
167
  /** @param {HTMLParserHandler} handler */
168
168
  function getAttrRegexStickyForHandler(handler) {
169
- let cached = attrRegexStickyCache.get(handler);
169
+ const cached = attrRegexStickyCache.get(handler);
170
170
  if (cached) return cached;
171
171
  const nonSticky = getAttrRegexForHandler(handler);
172
172
  // Derive sticky version: Remove `^` anchor, add `y` flag
@@ -657,12 +657,12 @@ export class HTMLParser {
657
657
 
658
658
  // `needle` must already be lowercase
659
659
  function findTagInCurrentTable(/** @type {string} */ needle) {
660
- let pos;
661
- for (pos = stack.length - 1; pos >= 0; pos--) {
662
- const entry = stack[pos];
660
+ let stackIndex;
661
+ for (stackIndex = stack.length - 1; stackIndex >= 0; stackIndex--) {
662
+ const entry = stack[stackIndex];
663
663
  const currentTag = entry?.lowerTag;
664
664
  if (currentTag === needle) {
665
- return pos;
665
+ return stackIndex;
666
666
  }
667
667
  // Stop searching if hitting a table boundary
668
668
  if (currentTag === 'table') {
@@ -672,25 +672,25 @@ export class HTMLParser {
672
672
  return -1;
673
673
  }
674
674
 
675
- function parseEndTagAt(/** @type {number} */ pos) {
676
- // Close all open elements up to `pos` (mirrors `parseEndTag`’s core branch);
675
+ function parseEndTagAt(/** @type {number} */ stackIndex) {
676
+ // Close all open elements up to `stackIndex` (mirrors `parseEndTag`’s core branch);
677
677
  // `end` handlers are synchronous—invoked without awaiting, as in `parseEndTag`
678
- for (let i = stack.length - 1; i >= pos; i--) {
678
+ for (let i = stack.length - 1; i >= stackIndex; i--) {
679
679
  const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
680
680
  if (handler.end) {
681
681
  handler.end(entry.tag, entry.attrs, true);
682
682
  }
683
683
  }
684
- stack.length = pos;
685
- lastTag = pos ? (stack[pos - 1]?.tag ?? '') : '';
686
- lastTagLower = pos ? (stack[pos - 1]?.lowerTag ?? '') : '';
684
+ stack.length = stackIndex;
685
+ lastTag = stackIndex ? (stack[stackIndex - 1]?.tag ?? '') : '';
686
+ lastTagLower = stackIndex ? (stack[stackIndex - 1]?.lowerTag ?? '') : '';
687
687
  }
688
688
 
689
689
  function closeIfFoundInCurrentTable(/** @type {string} */ tagName) {
690
- const pos = findTagInCurrentTable(tagName);
691
- if (pos >= 0) {
690
+ const stackIndex = findTagInCurrentTable(tagName);
691
+ if (stackIndex >= 0) {
692
692
  // Close at the specific index to avoid re-searching
693
- parseEndTagAt(pos);
693
+ parseEndTagAt(stackIndex);
694
694
  return true;
695
695
  }
696
696
  return false;
@@ -812,38 +812,38 @@ export class HTMLParser {
812
812
 
813
813
  // `needle` must already be lowercase
814
814
  function findTag(/** @type {string} */ needle) {
815
- let pos;
816
- for (pos = stack.length - 1; pos >= 0; pos--) {
817
- if (stack[pos]?.lowerTag === needle) {
815
+ let stackIndex;
816
+ for (stackIndex = stack.length - 1; stackIndex >= 0; stackIndex--) {
817
+ if (stack[stackIndex]?.lowerTag === needle) {
818
818
  break;
819
819
  }
820
820
  }
821
- return pos;
821
+ return stackIndex;
822
822
  }
823
823
 
824
824
  async function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
825
- let pos;
825
+ let stackIndex;
826
826
  const lowerTagName = tagName ? tagName.toLowerCase() : '';
827
827
 
828
828
  // Find the closest opened tag of the same type
829
829
  if (tagName) {
830
- pos = findTag(lowerTagName);
830
+ stackIndex = findTag(lowerTagName);
831
831
  } else { // If no tag name is provided, clean shop
832
- pos = 0;
832
+ stackIndex = 0;
833
833
  }
834
834
 
835
- if (pos >= 0) {
835
+ if (stackIndex >= 0) {
836
836
  // Close all the open elements, up the stack
837
- for (let i = stack.length - 1; i >= pos; i--) {
837
+ for (let i = stack.length - 1; i >= stackIndex; i--) {
838
838
  if (handler.end) {
839
- handler.end(stack[i]?.tag, stack[i]?.attrs, i > pos || !tag);
839
+ handler.end(stack[i]?.tag, stack[i]?.attrs, i > stackIndex || !tag);
840
840
  }
841
841
  }
842
842
 
843
843
  // Remove the open elements from the stack
844
- stack.length = pos;
845
- lastTag = pos ? (stack[pos - 1]?.tag ?? '') : '';
846
- lastTagLower = pos ? (stack[pos - 1]?.lowerTag ?? '') : '';
844
+ stack.length = stackIndex;
845
+ lastTag = stackIndex ? (stack[stackIndex - 1]?.tag ?? '') : '';
846
+ lastTagLower = stackIndex ? (stack[stackIndex - 1]?.lowerTag ?? '') : '';
847
847
  } else if (handler.partialMarkup && tagName) {
848
848
  // In partial markup mode, preserve stray end tags
849
849
  if (handler.end) {
@@ -470,7 +470,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
470
470
  attrValue = attrValue.replace(/\s*;$/, ';');
471
471
  }
472
472
  const originalAttrValue = attrValue;
473
- const cssResult = options.minifyCSS(attrValue, 'inline');
473
+ const cssResult = options.minifyCSS(attrValue, 'inline', options.cssContext);
474
474
  if (isThenable(cssResult)) {
475
475
  return cssResult
476
476
  .then((/** @type {string} */ minified) => {
@@ -487,7 +487,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
487
487
  }
488
488
  // Sync path (`minifyCSS` disabled—identity function)
489
489
  if (cssResult && /^(?:[a-z-]+:[;\s]*)+$/i.test(cssResult)) return '';
490
- return cssResult != null ? cssResult : attrValue;
490
+ return cssResult ?? attrValue;
491
491
  }
492
492
  return attrValue;
493
493
  }
@@ -581,7 +581,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
581
581
  return attrValue;
582
582
  }
583
583
  const originalAttrValue = attrValue;
584
- const cssResult = options.minifyCSS(attrValue, 'media');
584
+ const cssResult = options.minifyCSS(attrValue, 'media', options.cssContext);
585
585
  if (isThenable(cssResult)) {
586
586
  return cssResult.catch((/** @type {Error} */ err) => {
587
587
  if (!options.continueOnMinifyError) throw err;
@@ -589,7 +589,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
589
589
  return originalAttrValue;
590
590
  });
591
591
  }
592
- return cssResult != null ? cssResult : attrValue;
592
+ return cssResult ?? attrValue;
593
593
  }
594
594
 
595
595
  if (tag === 'iframe' && attrName === 'srcdoc') {
@@ -635,7 +635,7 @@ function chooseAttributeQuote(attrValue, options) {
635
635
  */
636
636
  function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
637
637
  const attrName = options.name(attr.name);
638
- let attrValue = attr.value;
638
+ const attrValue = attr.value;
639
639
 
640
640
  // Entity decoding requires a lazy import—async only when `&` is present
641
641
  if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
@@ -16,6 +16,7 @@ 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;
19
20
 
20
21
  // Inline element sets for whitespace handling
21
22
 
@@ -192,6 +193,7 @@ export {
192
193
  RE_ATTR_WS_CHECK,
193
194
  RE_ATTR_WS_COLLAPSE,
194
195
  RE_ATTR_WS_TRIM,
196
+ RE_STYLE_ELEMENT,
195
197
  // Inline element sets
196
198
  inlineElementsToKeepWhitespaceAround,
197
199
  inlineElementsToKeepWhitespaceWithin,
@@ -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));
@@ -104,15 +104,15 @@ const optionDefinitions = {
104
104
  },
105
105
  minifyCSS: {
106
106
  description: 'Minify CSS in `style` elements and attributes (uses Lightning CSS)',
107
- type: 'json'
107
+ type: 'jsonObject'
108
108
  },
109
109
  minifyJS: {
110
110
  description: 'Minify JavaScript in `script` elements and event attributes (uses Terser or SWC; pass `{"engine": "swc"}` for SWC)',
111
- type: 'json'
111
+ type: 'jsonObject'
112
112
  },
113
113
  minifySVG: {
114
114
  description: 'Minify SVG elements (uses SVGO)',
115
- type: 'json'
115
+ type: 'jsonObject'
116
116
  },
117
117
  minifyURLs: {
118
118
  description: 'Minify URLs in various attributes',
@@ -178,6 +178,10 @@ const optionDefinitions = {
178
178
  description: 'Remove space between attributes whenever possible; note that this will result in invalid HTML',
179
179
  type: 'boolean'
180
180
  },
181
+ removeUnusedCSS: {
182
+ description: 'Remove rules from `style` elements whose class or ID selectors the document never references (requires `--minify-css`); note that class names only applied by external scripts cannot be detected—use `{"safelist": […]}` for those',
183
+ type: 'jsonObject'
184
+ },
181
185
  sortAttributes: {
182
186
  description: 'Sort attributes by frequency',
183
187
  type: 'boolean'