html-minifier-next 6.2.7 → 6.2.9

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.
@@ -1,5 +1,3 @@
1
- // Imports
2
-
3
1
  import { HTMLParser, endTag } from './htmlparser.js';
4
2
  import TokenChain from './tokenchain.js';
5
3
  import { presets, getPreset, getPresetNames } from './presets.js';
@@ -56,241 +54,12 @@ import {
56
54
 
57
55
  import { processOptions } from './lib/options.js';
58
56
 
59
- // Lazy-load heavy dependencies only when needed
60
-
61
- let lightningCSSPromise;
62
- async function getLightningCSS() {
63
- if (!lightningCSSPromise) {
64
- lightningCSSPromise = import('lightningcss').then(m => m.transform);
65
- }
66
- return lightningCSSPromise;
67
- }
68
-
69
- let terserPromise;
70
- async function getTerser() {
71
- if (!terserPromise) {
72
- terserPromise = import('terser').then(m => m.minify);
73
- }
74
- return terserPromise;
75
- }
76
-
77
- let swcPromise;
78
- async function getSwc() {
79
- if (!swcPromise) {
80
- swcPromise = import('@swc/core')
81
- .then(m => m.default || m)
82
- .catch(() => {
83
- throw new Error(
84
- 'The swc minifier requires @swc/core to be installed.\n' +
85
- 'Install it with: npm install @swc/core'
86
- );
87
- });
88
- }
89
- return swcPromise;
90
- }
91
-
92
- let svgoPromise;
93
- async function getSvgo() {
94
- if (!svgoPromise) {
95
- svgoPromise = import('svgo').then(m => m.optimize);
96
- }
97
- return svgoPromise;
98
- }
99
-
100
- let decodeHTMLPromise;
101
- async function getDecodeHTML() {
102
- if (!decodeHTMLPromise) {
103
- decodeHTMLPromise = import('entities').then(m => m.decodeHTML);
104
- }
105
- return decodeHTMLPromise;
106
- }
107
-
108
- // Minification caches (initialized on first use with configurable sizes)
109
- let cssMinifyCache = null;
110
- let jsMinifyCache = null;
111
- let svgMinifyCache = null;
112
-
113
- // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
114
- const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
115
- const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
116
- const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
117
- const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
118
- const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
119
-
120
- // Pre-compiled patterns for buffer scanning
121
- const RE_START_TAG = /^<[^/!]/;
122
- const RE_END_TAG = /^<\//;
123
-
124
- // Pre-compiled patterns for `htmlmin:ignore` block content analysis
125
- const RE_HTML_COMMENT_START = /^\s*<!--/;
126
- const RE_CLOSING_TAG_START = /^\s*<\/([a-zA-Z][\w:-]*)/;
127
- const RE_LAST_HTML_TAG = /[\s\S]*<(\/?[a-zA-Z][\w:-]*)/;
128
-
129
- // HTML encoding types for annotation-xml (MathML)
130
- const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
131
-
132
- // Script merging
133
-
134
- /**
135
- * Find the index of the `>` that closes an opening tag, correctly skipping
136
- * over quoted attribute values (which may contain `>`).
137
- * @param {string} html
138
- * @param {number} pos - Start position (just after the tag name)
139
- * @returns {number} Index of the closing `>`, or -1 if not found
140
- */
141
- function findTagEnd(html, pos) {
142
- let i = pos;
143
- while (i < html.length) {
144
- const ch = html[i];
145
- if (ch === '>') return i;
146
- if (ch === '"' || ch === "'") {
147
- const q = ch;
148
- i++;
149
- while (i < html.length && html[i] !== q) i++;
150
- }
151
- i++;
152
- }
153
- return -1;
154
- }
155
-
156
- /**
157
- * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
158
- * Only merges scripts that are compatible:
159
- * - Both inline (no `src` attribute)
160
- * - Same `type` (or both default JavaScript)
161
- * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
162
- *
163
- * Uses a scanner rather than a regex to locate script boundaries, so literal
164
- * `</script>` strings inside script content are handled correctly per the HTML
165
- * spec (raw text ends at the first `</script>`).
166
- *
167
- * @param {string} html - The HTML string to process
168
- * @returns {string} HTML with consecutive scripts merged
169
- */
170
- function mergeConsecutiveScripts(html) {
171
- // Parse an attribute string into a name→value map
172
- const parseAttrs = (attrStr) => {
173
- const attrs = {};
174
- RE_SCRIPT_ATTRS.lastIndex = 0;
175
- let m;
176
- while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
177
- const name = m[1].toLowerCase();
178
- const value = m[2] ?? m[3] ?? m[4] ?? '';
179
- attrs[name] = value;
180
- }
181
- return attrs;
182
- };
183
-
184
- let changed = true;
185
-
186
- // Keep merging until no more changes (handles chains of 3+ scripts)
187
- while (changed) {
188
- changed = false;
189
- RE_SCRIPT_OPEN.lastIndex = 0;
190
- let m1;
191
-
192
- while ((m1 = RE_SCRIPT_OPEN.exec(html)) !== null) {
193
- // Use findTagEnd() to get the real closing '>', skipping quoted attribute values
194
- const tagEnd1 = findTagEnd(html, m1.index + 7);
195
- if (tagEnd1 === -1) break;
196
-
197
- const attrs1Str = html.slice(m1.index + 7, tagEnd1);
198
- const contentStart1 = tagEnd1 + 1;
199
-
200
- // Find end of this script’s content (first `</script>`—per HTML spec, raw text ends here)
201
- RE_SCRIPT_CLOSE.lastIndex = contentStart1;
202
- const close1 = RE_SCRIPT_CLOSE.exec(html);
203
- if (!close1) break;
204
-
205
- const content1 = html.slice(contentStart1, close1.index);
206
- const afterClose1 = close1.index + close1[0].length;
207
-
208
- // Skip optional whitespace and check for a consecutive <script> tag
209
- let i = afterClose1;
210
- while (i < html.length && (html[i] === ' ' || html[i] === '\t' || html[i] === '\n' || html[i] === '\r' || html[i] === '\f')) i++;
211
- if (html.slice(i, i + 7).toLowerCase() !== '<script' || (html[i + 7] !== '>' && !/\s/.test(html[i + 7]))) {
212
- RE_SCRIPT_OPEN.lastIndex = afterClose1;
213
- continue;
214
- }
215
-
216
- const tagStart2 = i;
217
- const tagEnd2 = findTagEnd(html, tagStart2 + 7);
218
- if (tagEnd2 === -1) break;
219
-
220
- const attrs2Str = html.slice(tagStart2 + 7, tagEnd2);
221
- const contentStart2 = tagEnd2 + 1;
222
-
223
- // Find end of second script’s content
224
- RE_SCRIPT_CLOSE.lastIndex = contentStart2;
225
- const close2 = RE_SCRIPT_CLOSE.exec(html);
226
- if (!close2) break;
227
-
228
- const content2 = html.slice(contentStart2, close2.index);
229
- const afterClose2 = close2.index + close2[0].length;
230
-
231
- const a1 = parseAttrs(attrs1Str);
232
- const a2 = parseAttrs(attrs2Str);
233
-
234
- // Check for `src`—cannot merge external scripts
235
- if ('src' in a1 || 'src' in a2) {
236
- RE_SCRIPT_OPEN.lastIndex = afterClose1;
237
- continue;
238
- }
239
-
240
- // Check `type` compatibility (both must be default JS)
241
- // Non-JS types (modules, JSON, etc.) must not be merged:
242
- // Module scripts have per-script lexical scope, and non-JS content (e.g., JSON)
243
- // is not concatenable; even identical non-JS types are incompatible
244
- const type1 = (a1.type || '').toLowerCase();
245
- const type2 = (a2.type || '').toLowerCase();
246
- if (!DEFAULT_JS_TYPES.has(type1) || !DEFAULT_JS_TYPES.has(type2)) {
247
- RE_SCRIPT_OPEN.lastIndex = afterClose1;
248
- continue;
249
- }
250
-
251
- // Check for conflicting boolean attributes
252
- let boolConflict = false;
253
- for (const attr of SCRIPT_BOOL_ATTRS) {
254
- if ((attr in a1) !== (attr in a2)) { boolConflict = true; break; }
255
- }
256
-
257
- // Check `nonce`—must be same or both absent
258
- if (boolConflict || a1.nonce !== a2.nonce) {
259
- RE_SCRIPT_OPEN.lastIndex = afterClose1;
260
- continue;
261
- }
262
-
263
- // Scripts are compatible—merge them
264
- changed = true;
265
-
266
- // Combine content—use semicolon normally, newline only for trailing `//` comments
267
- const c1 = content1.trim();
268
- const c2 = content2.trim();
269
- let mergedContent;
270
- if (c1 && c2) {
271
- // Check if last line of c1 contains `//` (single-line comment)
272
- // If so, use newline to terminate it; otherwise use semicolon (if not already present)
273
- const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
274
- const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
275
- mergedContent = c1 + separator + c2;
276
- } else {
277
- mergedContent = c1 || c2;
278
- }
279
-
280
- // Use first script’s attributes (they should be compatible)
281
- html = html.slice(0, m1.index) + `<script${attrs1Str}>${mergedContent}</script>` + html.slice(afterClose2);
282
- break; // Restart scanning (outer while loop)
283
- }
284
- }
285
-
286
- return html;
287
- }
288
-
289
57
  // Type definitions
290
58
 
291
59
  /**
292
60
  * @typedef {Object} HTMLAttribute
293
61
  * Representation of an attribute from the HTML parser.
62
+ * Internal counterpart: `HTMLAttribute` in `lib/attributes.js`—keep in sync.
294
63
  *
295
64
  * @prop {string} name
296
65
  * @prop {string} [value]
@@ -700,23 +469,275 @@ function mergeConsecutiveScripts(html) {
700
469
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
701
470
  *
702
471
  * Default: `false`
472
+ *
473
+ * @prop {boolean} [insideSVG] - Internal: Set when inside SVG/MathML context
474
+ * @prop {boolean} [insideForeignContent] - Internal: Set when inside SVG `foreignObject`
475
+ * @prop {Function} [parentName] - Internal: Preserved name function during namespace transitions
476
+ * @prop {Function} [htmlName] - Internal: HTML name function preserved from outer context
477
+ */
478
+
479
+ // Lazy-load heavy dependencies only when needed
480
+
481
+ /** @type {Promise<Function> | undefined} */
482
+ let lightningCSSPromise;
483
+ async function getLightningCSS() {
484
+ if (!lightningCSSPromise) {
485
+ lightningCSSPromise = import('lightningcss').then(m => m.transform);
486
+ }
487
+ return lightningCSSPromise;
488
+ }
489
+
490
+ /** @type {Promise<Function> | undefined} */
491
+ let terserPromise;
492
+ async function getTerser() {
493
+ if (!terserPromise) {
494
+ terserPromise = import('terser').then(m => m.minify);
495
+ }
496
+ return terserPromise;
497
+ }
498
+
499
+ /** @type {Promise<any> | undefined} */
500
+ let swcPromise;
501
+ async function getSwc() {
502
+ if (!swcPromise) {
503
+ swcPromise = import('@swc/core')
504
+ .then(m => m.default || m)
505
+ .catch(() => {
506
+ throw new Error(
507
+ 'The swc minifier requires @swc/core to be installed.\n' +
508
+ 'Install it with: npm install @swc/core'
509
+ );
510
+ });
511
+ }
512
+ return swcPromise;
513
+ }
514
+
515
+ /** @type {Promise<Function> | undefined} */
516
+ let svgoPromise;
517
+ async function getSvgo() {
518
+ if (!svgoPromise) {
519
+ svgoPromise = import('svgo').then(m => m.optimize);
520
+ }
521
+ return svgoPromise;
522
+ }
523
+
524
+ /** @type {Promise<Function> | undefined} */
525
+ let decodeHTMLPromise;
526
+ async function getDecodeHTML() {
527
+ if (!decodeHTMLPromise) {
528
+ decodeHTMLPromise = import('entities').then(m => m.decodeHTML);
529
+ }
530
+ return decodeHTMLPromise;
531
+ }
532
+
533
+ // Minification caches (initialized on first use with configurable sizes)
534
+ /** @type {LRU | null} */
535
+ let cssMinifyCache = null;
536
+ /** @type {LRU | null} */
537
+ let jsMinifyCache = null;
538
+ /** @type {LRU | null} */
539
+ let svgMinifyCache = null;
540
+
541
+ // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
542
+ const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
543
+ const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
544
+ const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
545
+ const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
546
+ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
547
+
548
+ // Pre-compiled patterns for buffer scanning
549
+ const RE_START_TAG = /^<[^/!]/;
550
+ const RE_END_TAG = /^<\//;
551
+
552
+ // Pre-compiled patterns for `htmlmin:ignore` block content analysis
553
+ const RE_HTML_COMMENT_START = /^\s*<!--/;
554
+ const RE_CLOSING_TAG_START = /^\s*<\/([a-zA-Z][\w:-]*)/;
555
+ const RE_LAST_HTML_TAG = /[\s\S]*<(\/?[a-zA-Z][\w:-]*)/;
556
+
557
+ // HTML encoding types for annotation-xml (MathML)
558
+ const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
559
+
560
+ // Script merging
561
+
562
+ /**
563
+ * Find the index of the `>` that closes an opening tag, correctly skipping
564
+ * over quoted attribute values (which may contain `>`).
565
+ * @param {string} html
566
+ * @param {number} pos - Start position (just after the tag name)
567
+ * @returns {number} Index of the closing `>`, or -1 if not found
703
568
  */
569
+ function findTagEnd(html, pos) {
570
+ let i = pos;
571
+ while (i < html.length) {
572
+ const ch = html[i];
573
+ if (ch === '>') return i;
574
+ if (ch === '"' || ch === "'") {
575
+ const q = ch;
576
+ i++;
577
+ while (i < html.length && html[i] !== q) i++;
578
+ }
579
+ i++;
580
+ }
581
+ return -1;
582
+ }
583
+
584
+ /**
585
+ * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
586
+ * Only merges scripts that are compatible:
587
+ * - Both inline (no `src` attribute)
588
+ * - Same `type` (or both default JavaScript)
589
+ * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
590
+ *
591
+ * Uses a scanner rather than a regex to locate script boundaries, so literal
592
+ * `</script>` strings inside script content are handled correctly per the HTML
593
+ * spec (raw text ends at the first `</script>`).
594
+ *
595
+ * @param {string} html - The HTML string to process
596
+ * @returns {string} HTML with consecutive scripts merged
597
+ */
598
+ function mergeConsecutiveScripts(html) {
599
+ // Parse an attribute string into a name→value map
600
+ const parseAttrs = (/** @type {string} */ attrStr) => {
601
+ /** @type {Record<string, string>} */
602
+ const attrs = {};
603
+ RE_SCRIPT_ATTRS.lastIndex = 0;
604
+ let m;
605
+ while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
606
+ const name = (m[1] ?? '').toLowerCase();
607
+ const value = m[2] ?? m[3] ?? m[4] ?? '';
608
+ attrs[name] = value;
609
+ }
610
+ return attrs;
611
+ };
612
+
613
+ let changed = true;
704
614
 
615
+ // Keep merging until no more changes (handles chains of 3+ scripts)
616
+ while (changed) {
617
+ changed = false;
618
+ RE_SCRIPT_OPEN.lastIndex = 0;
619
+ let m1;
620
+
621
+ while ((m1 = RE_SCRIPT_OPEN.exec(html)) !== null) {
622
+ // Use findTagEnd() to get the real closing '>', skipping quoted attribute values
623
+ const tagEnd1 = findTagEnd(html, m1.index + 7);
624
+ if (tagEnd1 === -1) break;
625
+
626
+ const attrs1Str = html.slice(m1.index + 7, tagEnd1);
627
+ const contentStart1 = tagEnd1 + 1;
628
+
629
+ // Find end of this script’s content (first `</script>`—per HTML spec, raw text ends here)
630
+ RE_SCRIPT_CLOSE.lastIndex = contentStart1;
631
+ const close1 = RE_SCRIPT_CLOSE.exec(html);
632
+ if (!close1) break;
633
+
634
+ const content1 = html.slice(contentStart1, close1.index);
635
+ const afterClose1 = close1.index + close1[0].length;
636
+
637
+ // Skip optional whitespace and check for a consecutive <script> tag
638
+ let i = afterClose1;
639
+ while (i < html.length && (html[i] === ' ' || html[i] === '\t' || html[i] === '\n' || html[i] === '\r' || html[i] === '\f')) i++;
640
+ if (html.slice(i, i + 7).toLowerCase() !== '<script' || (html.charAt(i + 7) !== '>' && !/\s/.test(html.charAt(i + 7)))) {
641
+ RE_SCRIPT_OPEN.lastIndex = afterClose1;
642
+ continue;
643
+ }
644
+
645
+ const tagStart2 = i;
646
+ const tagEnd2 = findTagEnd(html, tagStart2 + 7);
647
+ if (tagEnd2 === -1) break;
648
+
649
+ const attrs2Str = html.slice(tagStart2 + 7, tagEnd2);
650
+ const contentStart2 = tagEnd2 + 1;
651
+
652
+ // Find end of second script’s content
653
+ RE_SCRIPT_CLOSE.lastIndex = contentStart2;
654
+ const close2 = RE_SCRIPT_CLOSE.exec(html);
655
+ if (!close2) break;
656
+
657
+ const content2 = html.slice(contentStart2, close2.index);
658
+ const afterClose2 = close2.index + close2[0].length;
659
+
660
+ const a1 = parseAttrs(attrs1Str);
661
+ const a2 = parseAttrs(attrs2Str);
662
+
663
+ // Check for `src`—cannot merge external scripts
664
+ if ('src' in a1 || 'src' in a2) {
665
+ RE_SCRIPT_OPEN.lastIndex = afterClose1;
666
+ continue;
667
+ }
668
+
669
+ // Check `type` compatibility (both must be default JS)
670
+ // Non-JS types (modules, JSON, etc.) must not be merged:
671
+ // Module scripts have per-script lexical scope, and non-JS content (e.g., JSON)
672
+ // is not concatenable; even identical non-JS types are incompatible
673
+ const type1 = (a1.type || '').toLowerCase();
674
+ const type2 = (a2.type || '').toLowerCase();
675
+ if (!DEFAULT_JS_TYPES.has(type1) || !DEFAULT_JS_TYPES.has(type2)) {
676
+ RE_SCRIPT_OPEN.lastIndex = afterClose1;
677
+ continue;
678
+ }
679
+
680
+ // Check for conflicting boolean attributes
681
+ let boolConflict = false;
682
+ for (const attr of SCRIPT_BOOL_ATTRS) {
683
+ if ((attr in a1) !== (attr in a2)) { boolConflict = true; break; }
684
+ }
685
+
686
+ // Check `nonce`—must be same or both absent
687
+ if (boolConflict || a1.nonce !== a2.nonce) {
688
+ RE_SCRIPT_OPEN.lastIndex = afterClose1;
689
+ continue;
690
+ }
691
+
692
+ // Scripts are compatible—merge them
693
+ changed = true;
694
+
695
+ // Combine content—use semicolon normally, newline only for trailing `//` comments
696
+ const c1 = content1.trim();
697
+ const c2 = content2.trim();
698
+ let mergedContent;
699
+ if (c1 && c2) {
700
+ // Check if last line of c1 contains `//` (single-line comment)
701
+ // If so, use newline to terminate it; otherwise use semicolon (if not already present)
702
+ const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
703
+ const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
704
+ mergedContent = c1 + separator + c2;
705
+ } else {
706
+ mergedContent = c1 || c2;
707
+ }
708
+
709
+ // Use first script’s attributes (they should be compatible)
710
+ html = html.slice(0, m1.index) + `<script${attrs1Str}>${mergedContent}</script>` + html.slice(afterClose2);
711
+ break; // Restart scanning (outer while loop)
712
+ }
713
+ }
714
+
715
+ return html;
716
+ }
717
+
718
+ /**
719
+ * @param {string} value
720
+ * @param {MinifierOptions} options
721
+ * @param {string} uidIgnore
722
+ * @param {string} uidAttr
723
+ * @param {string[]} ignoredMarkupChunks
724
+ */
705
725
  async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
706
726
  const attrChains = options.sortAttributes && typeof options.sortAttributes !== 'function' && Object.create(null);
707
727
  const classChain = options.sortClassNames && typeof options.sortClassNames !== 'function' && new TokenChain();
728
+ const resolveName = /** @type {(name: string) => string} */ (options.name);
708
729
 
709
- function attrNames(attrs) {
730
+ function attrNames(/** @type {HTMLAttribute[]} */ attrs) {
710
731
  return attrs.map(function (attr) {
711
- return options.name(attr.name);
732
+ return resolveName(attr.name);
712
733
  });
713
734
  }
714
735
 
715
- function shouldSkipUID(token, uid) {
736
+ function shouldSkipUID(/** @type {string} */ token, /** @type {string} */ uid) {
716
737
  return !uid || token.indexOf(uid) === -1;
717
738
  }
718
739
 
719
- function shouldKeepToken(token) {
740
+ function shouldKeepToken(/** @type {string} */ token) {
720
741
  // Filter out any HTML comment tokens (UID placeholders)
721
742
  // These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
722
743
  if (token.startsWith('<!--') && token.endsWith('-->')) {
@@ -730,10 +751,13 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
730
751
  const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
731
752
  const whitespaceSplitPatternSort = /[ \n\f\r]+/;
732
753
 
733
- async function scan(input) {
734
- let currentTag, currentType;
754
+ async function scan(/** @type {string} */ input) {
755
+ /** @type {string} */
756
+ let currentTag;
757
+ /** @type {string | undefined} */
758
+ let currentType;
735
759
  const parser = new HTMLParser(input, {
736
- start: function (tag, attrs) {
760
+ start: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
737
761
  if (attrChains) {
738
762
  if (!attrChains[tag]) {
739
763
  attrChains[tag] = new TokenChain();
@@ -741,9 +765,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
741
765
  const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
742
766
  attrChains[tag].add(attrNamesList);
743
767
  }
744
- for (let i = 0, len = attrs.length; i < len; i++) {
745
- const attr = attrs[i];
746
- if (classChain && attr.value && options.name(attr.name) === 'class') {
768
+ for (const attr of attrs) {
769
+ if (classChain && attr.value && resolveName(attr.name) === 'class') {
747
770
  const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
748
771
  classChain.add(classes);
749
772
  } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
@@ -755,11 +778,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
755
778
  end: function () {
756
779
  currentTag = '';
757
780
  },
758
- chars: async function (text) {
781
+ chars: async function (/** @type {string} */ text) {
759
782
  // Only recursively scan HTML content, not JSON-LD or other non-HTML script types
760
783
  // `scan()` is for analyzing HTML attribute order, not for parsing JSON
761
784
  if (options.processScripts && specialContentElements.has(currentTag) &&
762
- options.processScripts.indexOf(currentType) > -1 &&
785
+ options.processScripts.indexOf(currentType ?? '') > -1 &&
763
786
  currentType === 'text/html') {
764
787
  await scan(text);
765
788
  }
@@ -818,7 +841,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
818
841
  // Expand UID tokens back to the original content for frequency analysis
819
842
  let expandedValue = value;
820
843
  if (uidReplacePattern) {
821
- expandedValue = value.replace(uidReplacePattern, function (match, index) {
844
+ expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
822
845
  return ignoredMarkupChunks[+index] || '';
823
846
  });
824
847
  // Reset `lastIndex` for pattern reuse
@@ -839,7 +862,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
839
862
  await scan(scanValue);
840
863
  } finally {
841
864
  // Restore original option
842
- options.continueOnParseError = originalContinueOnParseError;
865
+ if (originalContinueOnParseError !== undefined) {
866
+ options.continueOnParseError = originalContinueOnParseError;
867
+ } else {
868
+ delete options.continueOnParseError;
869
+ }
843
870
  }
844
871
  if (attrChains) {
845
872
  const attrSorters = Object.create(null);
@@ -849,14 +876,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
849
876
  // Memoize sorted attribute orders—attribute sets often repeat in templates
850
877
  const attrOrderCache = new LRU(500);
851
878
 
852
- options.sortAttributes = function (tag, attrs) {
879
+ options.sortAttributes = function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
853
880
  const sorter = attrSorters[tag];
854
881
  if (sorter) {
855
882
  const names = attrNames(attrs);
856
883
 
857
884
  // Create order-independent cache key from tag and sorted attribute names
858
885
  const cacheKey = tag + ':' + names.slice().sort().join(',');
859
- let sortedNames = attrOrderCache.get(cacheKey);
886
+ let sortedNames = /** @type {string[] | undefined} */ (attrOrderCache.get(cacheKey));
860
887
 
861
888
  if (sortedNames === undefined) {
862
889
  // Only sort if not in cache—need to clone names since sort mutates in place
@@ -866,10 +893,10 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
866
893
 
867
894
  // Apply the sorted order to `attrs`
868
895
  const attrMap = Object.create(null);
869
- names.forEach(function (name, index) {
896
+ names.forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
870
897
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
871
898
  });
872
- sortedNames.forEach(function (name, index) {
899
+ /** @type {string[]} */ (sortedNames).forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
873
900
  attrs[index] = attrMap[name].shift();
874
901
  });
875
902
  }
@@ -880,14 +907,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
880
907
  // Memoize `sortClassNames` results—class lists often repeat in templates
881
908
  const classNameCache = new LRU(500);
882
909
 
883
- options.sortClassNames = function (value) {
910
+ options.sortClassNames = function (/** @type {string} */ value) {
884
911
  // Fast path: Single class (no spaces) needs no sorting
885
912
  if (value.indexOf(' ') === -1) {
886
913
  return value;
887
914
  }
888
915
 
889
916
  // Check cache first
890
- const cached = classNameCache.get(value);
917
+ const cached = /** @type {string | undefined} */ (classNameCache.get(value));
891
918
  if (cached !== undefined) {
892
919
  return cached;
893
920
  }
@@ -896,13 +923,13 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
896
923
  // Fast path: Skip if no HTML comments (UID markers) present
897
924
  let expandedValue = value;
898
925
  if (uidReplacePattern && value.indexOf('<!--') !== -1) {
899
- expandedValue = value.replace(uidReplacePattern, function (match, index) {
926
+ expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
900
927
  return ignoredMarkupChunks[+index] || '';
901
928
  });
902
929
  // Reset `lastIndex` for pattern reuse
903
930
  uidReplacePattern.lastIndex = 0;
904
931
  }
905
- const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
932
+ const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(/** @type {string} */ cls) {
906
933
  return cls !== '';
907
934
  });
908
935
  const sorted = sorter.sort(classes);
@@ -931,29 +958,46 @@ async function minifyHTML(value, options, partialMarkup) {
931
958
  value = collapseWhitespace(value, options, true, true);
932
959
  }
933
960
 
961
+ const resolveName = /** @type {(name: string) => string} */ (options.name);
962
+ /** @type {HTMLAttribute[]} */
963
+ const emptyAttrs = [];
964
+
965
+ /** @type {string[]} */
934
966
  const buffer = [];
935
- let charsPrevTag;
967
+ /** @type {string} */
968
+ let charsPrevTag = '';
936
969
  let currentChars = '';
937
- let hasChars;
970
+ /** @type {boolean} */
971
+ let hasChars = false;
938
972
  let currentTag = '';
973
+ /** @type {Array<{name: string, value?: string, quote?: string, customAssign?: string, customOpen?: string, customClose?: string}>} */
939
974
  let currentAttrs = [];
975
+ /** @type {string[]} */
940
976
  const stackNoTrimWhitespace = [];
977
+ /** @type {string[]} */
941
978
  const stackNoCollapseWhitespace = [];
942
979
  let preTextareaDepth = 0; // Count of `pre`/`textarea` entries in `stackNoTrimWhitespace`
943
980
  let optionalStartTag = '';
944
981
  let optionalEndTag = '';
945
982
  let optionalEndTagEmitted = false;
983
+ /** @type {string[]} */
946
984
  const ignoredMarkupChunks = [];
985
+ /** @type {Array<RegExpExecArray | null>} */
947
986
  const ignoredCustomMarkupChunks = [];
987
+ /** @type {string | undefined} */
948
988
  let uidIgnore;
989
+ /** @type {RegExp | undefined} */
949
990
  let uidIgnorePlaceholderPattern;
991
+ /** @type {string | undefined} */
950
992
  let uidAttr;
993
+ /** @type {RegExp | undefined} */
951
994
  let uidPattern;
995
+ /** @type {RegExp | undefined} */
952
996
  let uidAttrLeadingPattern;
953
997
  // Create inline tags/text sets with custom elements
954
998
  const customElementsInput = options.inlineCustomElements ?? [];
955
999
  const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
956
- const normalizedCustomElements = customElementsArr.map(name => options.name(name));
1000
+ const normalizedCustomElements = customElementsArr.map(name => resolveName(name));
957
1001
  // Fast path: Reuse base sets if no custom elements
958
1002
  const inlineTextSet = normalizedCustomElements.length
959
1003
  ? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
@@ -963,6 +1007,7 @@ async function minifyHTML(value, options, partialMarkup) {
963
1007
  : inlineElementsToKeepWhitespaceAround;
964
1008
 
965
1009
  // Parse `removeEmptyElementsExcept` option
1010
+ /** @type {Array<{tag: string, attrs: {[x: string]: string | undefined} | null}>} */
966
1011
  let removeEmptyElementsExcept;
967
1012
  if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
968
1013
  if (options.log) {
@@ -970,7 +1015,7 @@ async function minifyHTML(value, options, partialMarkup) {
970
1015
  }
971
1016
  removeEmptyElementsExcept = [];
972
1017
  } else {
973
- removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
1018
+ removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept || [], /** @type {any} */ (options)) || [];
974
1019
  }
975
1020
 
976
1021
  // Temporarily replace ignored chunks with comments, so that there’s no need to worry what’s there;
@@ -1012,17 +1057,17 @@ async function minifyHTML(value, options, partialMarkup) {
1012
1057
  // This allows proper frequency analysis with access to ignored content via UID tokens
1013
1058
  if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
1014
1059
  (options.sortClassNames && typeof options.sortClassNames !== 'function')) {
1015
- await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
1060
+ await createSortFns(value, options, uidIgnore ?? '', uidAttr ?? '', ignoredMarkupChunks);
1016
1061
  }
1017
1062
 
1018
- const customFragments = options.ignoreCustomFragments.map(function (re) {
1063
+ const customFragments = (options.ignoreCustomFragments || []).map(function (/** @type {RegExp} */ re) {
1019
1064
  return re.source;
1020
1065
  });
1021
1066
  if (customFragments.length) {
1022
1067
  // Warn about potential ReDoS if custom fragments use unlimited quantifiers
1023
- for (let i = 0; i < customFragments.length; i++) {
1024
- if (/[*+]/.test(customFragments[i])) {
1025
- options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
1068
+ for (const fragment of customFragments) {
1069
+ if (/[*+]/.test(fragment)) {
1070
+ options.log?.('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
1026
1071
  break;
1027
1072
  }
1028
1073
  }
@@ -1031,60 +1076,68 @@ async function minifyHTML(value, options, partialMarkup) {
1031
1076
  const maxQuantifier = options.customFragmentQuantifierLimit || 200;
1032
1077
  const whitespacePattern = `\\s{0,${maxQuantifier}}`;
1033
1078
 
1034
- // Use bounded quantifiers to prevent ReDoS—this approach prevents exponential backtracking
1035
- const reCustomIgnore = new RegExp(
1036
- whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
1037
- 'g'
1038
- );
1039
- // Temporarily replace custom ignored fragments with unique attributes
1040
- value = value.replace(reCustomIgnore, function (match) {
1041
- if (!uidAttr) {
1042
- uidAttr = uniqueId(value);
1043
- uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1044
- uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
1045
-
1046
- if (options.minifyCSS) {
1047
- options.minifyCSS = (function (fn) {
1048
- return function (text, type) {
1049
- text = text.replace(uidPattern, function (match, prefix, index) {
1050
- const chunks = ignoredCustomMarkupChunks[+index];
1051
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
1052
- });
1053
-
1054
- return fn(text, type);
1055
- };
1056
- })(options.minifyCSS);
1057
- }
1079
+ // Fast path: The padded replace below is costly on large inputs because the
1080
+ // `\s{0,N}` padding makes the regex engine consume and backtrack at every
1081
+ // whitespace run; since the padding is optional and the fragment group requires
1082
+ // at least one match, a single unpadded probe that finds nothing proves the
1083
+ // replace would be a no-op—so skip it entirely
1084
+ const reFragmentProbe = new RegExp('(?:' + customFragments.join('|') + ')');
1085
+ if (reFragmentProbe.test(value)) {
1086
+ // Use bounded quantifiers to prevent ReDoS—this approach prevents exponential backtracking
1087
+ const reCustomIgnore = new RegExp(
1088
+ whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
1089
+ 'g'
1090
+ );
1091
+ // Temporarily replace custom ignored fragments with unique attributes
1092
+ value = value.replace(reCustomIgnore, function (match) {
1093
+ if (!uidAttr) {
1094
+ uidAttr = uniqueId(value);
1095
+ uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1096
+ uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
1097
+
1098
+ if (options.minifyCSS !== identity) {
1099
+ /** @type {any} */ (options).minifyCSS = (function (/** @type {Function} */ fn) {
1100
+ return function (/** @type {string} */ text, /** @type {string} */ type) {
1101
+ text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1102
+ const chunks = ignoredCustomMarkupChunks[+index];
1103
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1104
+ });
1105
+
1106
+ return fn(text, type);
1107
+ };
1108
+ })(/** @type {Function} */ (options.minifyCSS));
1109
+ }
1058
1110
 
1059
- if (options.minifyJS) {
1060
- options.minifyJS = (function (fn) {
1061
- return function (text, inline, isModule) {
1062
- return fn(text.replace(uidPattern, function (match, prefix, index) {
1063
- const chunks = ignoredCustomMarkupChunks[+index];
1064
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
1065
- }), inline, isModule);
1066
- };
1067
- })(options.minifyJS);
1111
+ if (options.minifyJS !== identity) {
1112
+ options.minifyJS = (function (/** @type {Function} */ fn) {
1113
+ return function (/** @type {string} */ text, /** @type {boolean} */ inline, /** @type {boolean} */ isModule) {
1114
+ return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1115
+ const chunks = ignoredCustomMarkupChunks[+index];
1116
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1117
+ }), inline, isModule);
1118
+ };
1119
+ })(/** @type {Function} */ (options.minifyJS));
1120
+ }
1068
1121
  }
1069
- }
1070
1122
 
1071
- const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
1072
- ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
1073
- return '\t' + token + '\t';
1074
- });
1123
+ const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
1124
+ ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
1125
+ return '\t' + token + '\t';
1126
+ });
1127
+ }
1075
1128
  }
1076
1129
 
1077
- function canCollapseWhitespace(tag, attrs) {
1078
- return options.canCollapseWhitespace(tag, attrs, defaultCanCollapseWhitespace);
1130
+ function canCollapseWhitespace(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
1131
+ return /** @type {Function} */ (options.canCollapseWhitespace)(tag, attrs, defaultCanCollapseWhitespace);
1079
1132
  }
1080
1133
 
1081
- function canTrimWhitespace(tag, attrs) {
1082
- return options.canTrimWhitespace(tag, attrs, defaultCanTrimWhitespace);
1134
+ function canTrimWhitespace(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
1135
+ return /** @type {Function} */ (options.canTrimWhitespace)(tag, attrs, defaultCanTrimWhitespace);
1083
1136
  }
1084
1137
 
1085
1138
  function removeStartTag() {
1086
1139
  let index = buffer.length - 1;
1087
- while (index > 0 && !RE_START_TAG.test(buffer[index])) {
1140
+ while (index > 0 && !RE_START_TAG.test(buffer[index] ?? '')) {
1088
1141
  index--;
1089
1142
  }
1090
1143
  buffer.length = Math.max(0, index);
@@ -1092,20 +1145,20 @@ async function minifyHTML(value, options, partialMarkup) {
1092
1145
 
1093
1146
  function removeEndTag() {
1094
1147
  let index = buffer.length - 1;
1095
- while (index > 0 && !RE_END_TAG.test(buffer[index])) {
1148
+ while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
1096
1149
  index--;
1097
1150
  }
1098
1151
  buffer.length = Math.max(0, index);
1099
1152
  }
1100
1153
 
1101
1154
  // Look for trailing whitespaces, bypass any inline tags
1102
- function trimTrailingWhitespace(index, nextTag) {
1103
- for (let endTag = null; index >= 0 && canTrimWhitespace(endTag); index--) {
1104
- const str = buffer[index];
1155
+ function trimTrailingWhitespace(/** @type {number} */ index, /** @type {string} */ nextTag) {
1156
+ for (let endTag = ''; index >= 0 && canTrimWhitespace(endTag, emptyAttrs); index--) {
1157
+ const str = buffer[index] ?? '';
1105
1158
  const match = str.match(/^<\/([\w:-]+)>$/);
1106
1159
  if (match) {
1107
- endTag = match[1];
1108
- } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, [], [], options, inlineElements, inlineTextSet))) {
1160
+ endTag = match[1] ?? '';
1161
+ } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, '', nextTag, emptyAttrs, emptyAttrs, options, inlineElements, inlineTextSet))) {
1109
1162
  break;
1110
1163
  }
1111
1164
  }
@@ -1114,10 +1167,10 @@ async function minifyHTML(value, options, partialMarkup) {
1114
1167
  // Look for trailing whitespaces from previously processed text
1115
1168
  // which may not be trimmed due to a following comment or an empty
1116
1169
  // element which has now been removed
1117
- function squashTrailingWhitespace(nextTag) {
1170
+ function squashTrailingWhitespace(/** @type {string} */ nextTag) {
1118
1171
  let charsIndex = buffer.length - 1;
1119
1172
  if (buffer.length > 1) {
1120
- const item = buffer[buffer.length - 1];
1173
+ const item = buffer[buffer.length - 1] ?? '';
1121
1174
  if (/^(?:<!|$)/.test(item) && (!uidIgnore || item.indexOf(uidIgnore) === -1)) {
1122
1175
  charsIndex--;
1123
1176
  }
@@ -1126,6 +1179,7 @@ async function minifyHTML(value, options, partialMarkup) {
1126
1179
  }
1127
1180
 
1128
1181
  // SVG subtree capture: When SVGO is active, record buffer positions for post-processing
1182
+ /** @type {Array<{start: number, end: number}>} */
1129
1183
  const svgBlocks = []; // Array of { start, end } buffer indices
1130
1184
  let svgBufferStartIndex = -1;
1131
1185
  let svgDepth = 0;
@@ -1138,7 +1192,7 @@ async function minifyHTML(value, options, partialMarkup) {
1138
1192
  // Compute `nextTag` only when whitespace collapse features require it
1139
1193
  wantsNextTag: !!(options.collapseWhitespace || options.collapseInlineTagWhitespace || options.conservativeCollapse),
1140
1194
 
1141
- start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
1195
+ start: async function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ unary, /** @type {string} */ unarySlash, /** @type {boolean} */ autoGenerated) {
1142
1196
  const lowerTag = tag.toLowerCase();
1143
1197
  if (lowerTag === 'svg' || lowerTag === 'math') {
1144
1198
  options = Object.create(options);
@@ -1160,21 +1214,21 @@ async function minifyHTML(value, options, partialMarkup) {
1160
1214
  // Note: The element itself is in SVG/MathML namespace, only its children are HTML
1161
1215
  let useParentNameForTag = false;
1162
1216
  if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
1163
- (lowerTag === 'annotation-xml' && attrs.some(a => a.name.toLowerCase() === 'encoding' &&
1164
- RE_HTML_ENCODING.test(a.value))))) {
1165
- const parentName = options.name;
1217
+ (lowerTag === 'annotation-xml' && attrs.some((/** @type {HTMLAttribute} */ a) => a.name.toLowerCase() === 'encoding' &&
1218
+ RE_HTML_ENCODING.test(a.value ?? ''))))) {
1219
+ const parentName = /** @type {(name: string) => string} */ (options.name);
1166
1220
  options = Object.create(options);
1167
1221
  options.caseSensitive = false;
1168
1222
  options.keepClosingSlash = false;
1169
1223
  options.parentName = parentName; // Preserve for the element tag itself
1170
- options.name = options.htmlName || lowercase;
1224
+ options.name = /** @type {(name: string) => string} */ (options.htmlName ?? lowercase);
1171
1225
  options.insideForeignContent = false;
1172
1226
  // Note: `removeAttributeQuotes`, `removeTagWhitespace`, and `decodeEntities`
1173
1227
  // stay disabled (inherited from SVG context) because the entire SVG block
1174
1228
  // must be valid XML for SVGO processing
1175
1229
  useParentNameForTag = true;
1176
1230
  }
1177
- tag = (useParentNameForTag ? options.parentName : options.name)(tag);
1231
+ tag = /** @type {(name: string) => string} */ (useParentNameForTag ? options.parentName : options.name)(tag);
1178
1232
  currentTag = tag;
1179
1233
  charsPrevTag = tag;
1180
1234
  if (!inlineTextSet.has(tag)) {
@@ -1240,10 +1294,10 @@ async function minifyHTML(value, options, partialMarkup) {
1240
1294
  // Remove duplicate attributes (per HTML spec, first occurrence wins)
1241
1295
  // Duplicate attributes result in invalid HTML
1242
1296
  // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
1243
- deduplicateAttributes(attrs, options.caseSensitive);
1297
+ deduplicateAttributes(attrs, Boolean(options.caseSensitive));
1244
1298
 
1245
1299
  if (options.sortAttributes) {
1246
- options.sortAttributes(tag, attrs);
1300
+ /** @type {Function} */ (options.sortAttributes)(tag, attrs);
1247
1301
  }
1248
1302
 
1249
1303
  const attrResults = attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML));
@@ -1273,7 +1327,7 @@ async function minifyHTML(value, options, partialMarkup) {
1273
1327
  currentTag = '';
1274
1328
  }
1275
1329
  },
1276
- end: function (tag, attrs, autoGenerated) {
1330
+ end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated) {
1277
1331
  const lowerTag = tag.toLowerCase();
1278
1332
  // Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
1279
1333
  if (lowerTag === 'svg' || lowerTag === 'math') {
@@ -1282,7 +1336,7 @@ async function minifyHTML(value, options, partialMarkup) {
1282
1336
  !options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
1283
1337
  options = Object.getPrototypeOf(options);
1284
1338
  }
1285
- tag = options.name(tag);
1339
+ tag = /** @type {Function} */ (options.name)(tag);
1286
1340
 
1287
1341
  // Check if current tag is in a whitespace stack
1288
1342
  if (options.collapseWhitespace) {
@@ -1328,7 +1382,7 @@ async function minifyHTML(value, options, partialMarkup) {
1328
1382
  let preserve = false;
1329
1383
  if (removeEmptyElementsExcept.length) {
1330
1384
  // Normalize attribute names for comparison with specs
1331
- const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
1385
+ const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
1332
1386
  preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
1333
1387
  }
1334
1388
 
@@ -1375,7 +1429,7 @@ async function minifyHTML(value, options, partialMarkup) {
1375
1429
  }
1376
1430
  }
1377
1431
  },
1378
- chars: function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
1432
+ chars: function (/** @type {string} */ text, /** @type {string} */ prevTag, /** @type {string} */ nextTag, /** @type {HTMLAttribute[]} */ prevAttrs, /** @type {HTMLAttribute[]} */ nextAttrs) {
1379
1433
  prevTag = prevTag === '' ? 'comment' : prevTag;
1380
1434
  nextTag = nextTag === '' ? 'comment' : nextTag;
1381
1435
  prevAttrs = prevAttrs || [];
@@ -1391,7 +1445,7 @@ async function minifyHTML(value, options, partialMarkup) {
1391
1445
  const needsMinifyCSS = options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
1392
1446
 
1393
1447
  // Whitespace collapsing phase (sync); captures `prevTag`/`nextTag`/`prevAttrs`/`nextAttrs` from outer scope
1394
- function charsCollapse(text) {
1448
+ function charsCollapse(/** @type {string} */ text) {
1395
1449
  // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
1396
1450
  // This removes trailing newlines often added by template engines before closing tags
1397
1451
  // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
@@ -1413,7 +1467,7 @@ async function minifyHTML(value, options, partialMarkup) {
1413
1467
  // to avoid side effects on the `inlineTextSet` branch below
1414
1468
  let effectivePrevTag = prevTag;
1415
1469
  if (prevTag === 'comment') {
1416
- const prevComment = buffer[buffer.length - 1];
1470
+ const prevComment = buffer[buffer.length - 1] ?? '';
1417
1471
  if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
1418
1472
  if (!prevComment) {
1419
1473
  prevTag = charsPrevTag;
@@ -1421,7 +1475,7 @@ async function minifyHTML(value, options, partialMarkup) {
1421
1475
  }
1422
1476
  if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
1423
1477
  const charsIndex = buffer.length - 2;
1424
- buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
1478
+ buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
1425
1479
  text = trailingSpaces + text;
1426
1480
  return '';
1427
1481
  });
@@ -1432,13 +1486,14 @@ async function minifyHTML(value, options, partialMarkup) {
1432
1486
  // when `nextTag` is `comment` (another UID placeholder), `commentFinalize` handles it
1433
1487
  const match = prevComment.match(uidIgnorePlaceholderPattern);
1434
1488
  if (match) {
1435
- const idx = +match[1];
1489
+ const idx = +(match[1] ?? '');
1436
1490
  if (idx < ignoredMarkupChunks.length) {
1437
1491
  const content = ignoredMarkupChunks[idx];
1438
1492
  const lastTagMatch = content && RE_LAST_HTML_TAG.exec(content);
1439
1493
  if (lastTagMatch) {
1440
- const isClose = lastTagMatch[1].charAt(0) === '/';
1441
- const tagName = options.name(isClose ? lastTagMatch[1].slice(1) : lastTagMatch[1]);
1494
+ const group = lastTagMatch[1] ?? '';
1495
+ const isClose = group.charAt(0) === '/';
1496
+ const tagName = resolveName(isClose ? group.slice(1) : group);
1442
1497
  effectivePrevTag = isClose ? '/' + tagName : tagName;
1443
1498
  }
1444
1499
  }
@@ -1449,13 +1504,13 @@ async function minifyHTML(value, options, partialMarkup) {
1449
1504
  if (prevTag === '/nobr' || prevTag === 'wbr') {
1450
1505
  if (/^\s/.test(text)) {
1451
1506
  let tagIndex = buffer.length - 1;
1452
- while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
1507
+ while (tagIndex > 0 && (buffer[tagIndex] ?? '').lastIndexOf('<' + prevTag) !== 0) {
1453
1508
  tagIndex--;
1454
1509
  }
1455
1510
  trimTrailingWhitespace(tagIndex - 1, 'br');
1456
1511
  }
1457
1512
  } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
1458
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
1513
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars), false);
1459
1514
  }
1460
1515
  }
1461
1516
  if (prevTag || nextTag) {
@@ -1475,18 +1530,18 @@ async function minifyHTML(value, options, partialMarkup) {
1475
1530
  }
1476
1531
 
1477
1532
  // Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
1478
- function charsFinalize(text) {
1533
+ function charsFinalize(/** @type {string} */ text) {
1479
1534
  if (options.removeOptionalTags && text) {
1480
1535
  // UID-attr tokens are padded with `\t`, which would falsely look like leading whitespace;
1481
1536
  // resolve single-token text to its actual content for the space/comment checks below
1482
1537
  let effectiveText = text;
1483
- if (uidAttrLeadingPattern && text.includes(uidAttr)) {
1538
+ if (uidAttrLeadingPattern && uidAttr && text.includes(uidAttr)) {
1484
1539
  const uidMatch = uidAttrLeadingPattern.exec(text);
1485
1540
  if (uidMatch) {
1486
- const idx = +uidMatch[1];
1541
+ const idx = +(uidMatch[1] ?? 0);
1487
1542
  const chunks = idx < ignoredCustomMarkupChunks.length ? ignoredCustomMarkupChunks[idx] : null;
1488
1543
  if (chunks != null) {
1489
- effectiveText = chunks[0];
1544
+ effectiveText = chunks[0] ?? '';
1490
1545
  }
1491
1546
  }
1492
1547
  }
@@ -1522,8 +1577,8 @@ async function minifyHTML(value, options, partialMarkup) {
1522
1577
  }
1523
1578
  }
1524
1579
  if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
1525
- text = text.replace(uidPattern, function (match, prefix, index) {
1526
- return ignoredCustomMarkupChunks[+index][0];
1580
+ text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1581
+ return ignoredCustomMarkupChunks[+index]?.[0] ?? match;
1527
1582
  });
1528
1583
  }
1529
1584
  currentChars += text;
@@ -1549,20 +1604,20 @@ async function minifyHTML(value, options, partialMarkup) {
1549
1604
  text = await processScript(text, options, currentAttrs, minifyHTML);
1550
1605
  }
1551
1606
  if (needsMinifyJS) {
1552
- text = await options.minifyJS(text, false, isModuleScript);
1607
+ text = await /** @type {Function} */ (options.minifyJS)(text, false, isModuleScript);
1553
1608
  }
1554
1609
  if (needsMinifyCSS) {
1555
- text = await options.minifyCSS(text);
1610
+ text = await /** @type {Function} */ (options.minifyCSS)(text);
1556
1611
  }
1557
1612
  charsFinalize(text);
1558
1613
  })();
1559
1614
  },
1560
- comment: function (text, nonStandard) {
1615
+ comment: function (/** @type {string} */ text, /** @type {boolean} */ nonStandard) {
1561
1616
  const prefix = nonStandard ? '<!' : '<!--';
1562
1617
  const suffix = nonStandard ? '>' : '-->';
1563
1618
 
1564
1619
  // Finalization phase (sync): Optional tag handling, `htmlmin:ignore` whitespace collapsing, buffer push
1565
- function commentFinalize(comment) {
1620
+ function commentFinalize(/** @type {string} */ comment) {
1566
1621
  if (options.removeOptionalTags && comment) {
1567
1622
  if (uidIgnorePlaceholderPattern) {
1568
1623
  const match = uidIgnorePlaceholderPattern.exec(comment);
@@ -1571,11 +1626,12 @@ async function minifyHTML(value, options, partialMarkup) {
1571
1626
  // if there’s a pending optional end tag and the ignored content isn’t itself
1572
1627
  // a comment (which per the HTML spec prevents omission), resolve it now,
1573
1628
  // before the UID is pushed to the buffer
1574
- const idx = +match[1];
1629
+ const idx = +(match[1] ?? 0);
1575
1630
  const content = idx < ignoredMarkupChunks.length ? ignoredMarkupChunks[idx] : null;
1576
1631
  if (optionalEndTag && optionalEndTagEmitted && content != null && !/^\s*<!--/.test(content)) {
1577
1632
  const firstTagMatch = content.match(/^\s*<([a-zA-Z][^\s/>]*)/);
1578
- const firstTag = firstTagMatch ? options.name(firstTagMatch[1]) : '';
1633
+ const firstTagGroup = firstTagMatch?.[1] ?? '';
1634
+ const firstTag = firstTagGroup ? resolveName(firstTagGroup) : '';
1579
1635
  if (canRemovePrecedingTag(optionalEndTag, firstTag)) {
1580
1636
  removeEndTag();
1581
1637
  }
@@ -1603,8 +1659,8 @@ async function minifyHTML(value, options, partialMarkup) {
1603
1659
  const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
1604
1660
 
1605
1661
  if (currentMatch && prevMatch) {
1606
- const currentIndex = +currentMatch[1];
1607
- const prevIndex = +prevMatch[1];
1662
+ const currentIndex = +(currentMatch[1] ?? 0);
1663
+ const prevIndex = +(prevMatch[1] ?? 0);
1608
1664
 
1609
1665
  // Defensive bounds check to ensure indices are valid
1610
1666
  if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
@@ -1627,13 +1683,13 @@ async function minifyHTML(value, options, partialMarkup) {
1627
1683
  // Collapse if both sides are element/closing tags or HTML comments, and neither is inline
1628
1684
  if ((currentTagMatch || currentIsHtmlComment || currentClosingTagMatch) &&
1629
1685
  (prevTagMatch || prevIsHtmlComment || prevClosingTagMatch)) {
1630
- const currentTag = currentTagMatch ? options.name(currentTagMatch[1])
1631
- : currentClosingTagMatch ? options.name(currentClosingTagMatch[1]) : null;
1632
- const prevTag = prevTagMatch ? options.name(prevTagMatch[1])
1633
- : prevClosingTagMatch ? options.name(prevClosingTagMatch[1]) : null;
1686
+ const currentTag = currentTagMatch ? resolveName(currentTagMatch[1] ?? '')
1687
+ : currentClosingTagMatch ? resolveName(currentClosingTagMatch[1] ?? '') : null;
1688
+ const prevTag = prevTagMatch ? resolveName(prevTagMatch[1] ?? '')
1689
+ : prevClosingTagMatch ? resolveName(prevClosingTagMatch[1] ?? '') : null;
1634
1690
 
1635
1691
  // Don’t collapse between inline elements (HTML comments count as non-inline)
1636
- if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
1692
+ if (!inlineElements.has(currentTag ?? '') && !inlineElements.has(prevTag ?? '')) {
1637
1693
  // Collapse whitespace respecting context rules
1638
1694
  let collapsedText = prevText;
1639
1695
 
@@ -1668,7 +1724,7 @@ async function minifyHTML(value, options, partialMarkup) {
1668
1724
  }
1669
1725
 
1670
1726
  if (options.removeComments) {
1671
- if (isIgnoredComment(text, options)) {
1727
+ if (isIgnoredComment(text, /** @type {any} */ (options))) {
1672
1728
  text = prefix + text + suffix;
1673
1729
  } else {
1674
1730
  text = '';
@@ -1678,7 +1734,7 @@ async function minifyHTML(value, options, partialMarkup) {
1678
1734
  }
1679
1735
  commentFinalize(text);
1680
1736
  },
1681
- doctype: function (doctype) {
1737
+ doctype: function (/** @type {string} */ doctype) {
1682
1738
  buffer.push(options.useShortDoctype
1683
1739
  ? '<!doctype' +
1684
1740
  (options.removeTagWhitespace ? '' : ' ') + 'html>'
@@ -1693,11 +1749,12 @@ async function minifyHTML(value, options, partialMarkup) {
1693
1749
  if (options.minifySVG && svgBlocks.length) {
1694
1750
  const optimized = await Promise.all(
1695
1751
  svgBlocks.map(({ start, end }) =>
1696
- options.minifySVG(buffer.slice(start, end).join(''))
1752
+ /** @type {Function} */ (options.minifySVG)(buffer.slice(start, end).join(''))
1697
1753
  )
1698
1754
  );
1699
1755
  for (let i = svgBlocks.length - 1; i >= 0; i--) {
1700
- buffer.splice(svgBlocks[i].start, svgBlocks[i].end - svgBlocks[i].start, optimized[i]);
1756
+ const block = svgBlocks[i];
1757
+ if (block) buffer.splice(block.start, block.end - block.start, optimized[i] ?? '');
1701
1758
  }
1702
1759
  }
1703
1760
 
@@ -1717,9 +1774,9 @@ async function minifyHTML(value, options, partialMarkup) {
1717
1774
  }
1718
1775
 
1719
1776
  return joinResultSegments(buffer, options, uidPattern
1720
- ? function (str) {
1721
- return str.replace(uidPattern, function (match, prefix, index, suffix) {
1722
- let chunk = ignoredCustomMarkupChunks[+index][0];
1777
+ ? function (/** @type {string} */ str) {
1778
+ return str.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string} */ index, /** @type {string} */ suffix) {
1779
+ let chunk = ignoredCustomMarkupChunks[+index]?.[0] ?? match;
1723
1780
  if (options.collapseWhitespace) {
1724
1781
  if (prefix !== '\t') {
1725
1782
  chunk = prefix + chunk;
@@ -1736,14 +1793,20 @@ async function minifyHTML(value, options, partialMarkup) {
1736
1793
  });
1737
1794
  }
1738
1795
  : identity, uidIgnore
1739
- ? function (str) {
1740
- return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
1741
- return ignoredMarkupChunks[+index];
1796
+ ? function (/** @type {string} */ str) {
1797
+ return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (/** @type {string} */ _match, /** @type {string} */ index) {
1798
+ return ignoredMarkupChunks[+index] ?? '';
1742
1799
  });
1743
1800
  }
1744
1801
  : identity);
1745
1802
  }
1746
1803
 
1804
+ /**
1805
+ * @param {string[]} results
1806
+ * @param {MinifierOptions} options
1807
+ * @param {Function} restoreCustom
1808
+ * @param {Function} restoreIgnore
1809
+ */
1747
1810
  function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
1748
1811
  let str;
1749
1812
  const maxLineLength = options.maxLineLength;
@@ -1753,15 +1816,16 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
1753
1816
  let line = ''; const lines = [];
1754
1817
  while (results.length) {
1755
1818
  const len = line.length;
1756
- const end = results[0].indexOf('\n');
1757
- const isClosingTag = Boolean(results[0].match(endTag));
1819
+ const cur = results[0] ?? '';
1820
+ const end = cur.indexOf('\n');
1821
+ const isClosingTag = Boolean(cur.match(endTag));
1758
1822
  const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
1759
1823
 
1760
1824
  if (end < 0) {
1761
- line += restoreIgnore(restoreCustom(results.shift()));
1825
+ line += restoreIgnore(restoreCustom(results.shift() ?? ''));
1762
1826
  } else {
1763
- line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
1764
- results[0] = results[0].slice(end + 1);
1827
+ line += restoreIgnore(restoreCustom(cur.slice(0, end)));
1828
+ results[0] = cur.slice(end + 1);
1765
1829
  }
1766
1830
  if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
1767
1831
  lines.push(line.slice(0, len));
@@ -1791,13 +1855,14 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
1791
1855
  * - The first call’s options determine the cache sizes for subsequent calls
1792
1856
  * - Invalid values (NaN, Infinity) fall back to the default size (500); values below `1` are clamped to `1`
1793
1857
  */
1858
+ /** @param {MinifierOptions} options */
1794
1859
  function initCaches(options) {
1795
1860
  // Only create caches once (on first call)—sizes are locked after this
1796
1861
  if (!cssMinifyCache) {
1797
1862
  const defaultSize = 500;
1798
1863
 
1799
1864
  // Helper to parse env var—returns parsed number (including 0) or undefined if absent, invalid, or negative
1800
- const parseEnvCacheSize = (envVar) => {
1865
+ const parseEnvCacheSize = (/** @type {string | undefined} */ envVar) => {
1801
1866
  if (envVar === undefined) return undefined;
1802
1867
  const parsed = Number(envVar);
1803
1868
  if (Number.isNaN(parsed) || !Number.isFinite(parsed) || parsed < 0) {
@@ -1807,7 +1872,7 @@ function initCaches(options) {
1807
1872
  };
1808
1873
 
1809
1874
  // Sanitize a cache size: Non-finite/NaN falls back to `defaultSize`; otherwise clamped to min 1 and floored
1810
- const sanitizeSize = (size) => Number.isFinite(size) ? Math.max(1, Math.floor(size)) : defaultSize;
1875
+ const sanitizeSize = (/** @type {number} */ size) => Number.isFinite(size) ? Math.max(1, Math.floor(size)) : defaultSize;
1811
1876
 
1812
1877
  // Get cache sizes with precedence: Options > env > default
1813
1878
  const cssSize = options.cacheCSS !== undefined ? options.cacheCSS
@@ -1845,7 +1910,9 @@ export const minify = async function (value, options) {
1845
1910
  getTerser,
1846
1911
  getSwc,
1847
1912
  getSvgo,
1848
- ...caches
1913
+ cssMinifyCache: caches.cssMinifyCache ?? undefined,
1914
+ jsMinifyCache: caches.jsMinifyCache ?? undefined,
1915
+ svgMinifyCache: caches.svgMinifyCache ?? undefined
1849
1916
  });
1850
1917
  let result = await minifyHTML(value, options);
1851
1918
 
@@ -1854,7 +1921,7 @@ export const minify = async function (value, options) {
1854
1921
  result = mergeConsecutiveScripts(result);
1855
1922
  }
1856
1923
 
1857
- options.log('minified in: ' + (Date.now() - start) + 'ms');
1924
+ /** @type {Function} */ (options.log)('minified in: ' + (Date.now() - start) + 'ms');
1858
1925
  return result;
1859
1926
  };
1860
1927