html-minifier-next 6.2.8 → 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
703
477
  */
704
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
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;
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
  }
@@ -1050,28 +1095,28 @@ async function minifyHTML(value, options, partialMarkup) {
1050
1095
  uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1051
1096
  uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
1052
1097
 
1053
- if (options.minifyCSS) {
1054
- options.minifyCSS = (function (fn) {
1055
- return function (text, type) {
1056
- text = text.replace(uidPattern, function (match, prefix, index) {
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) {
1057
1102
  const chunks = ignoredCustomMarkupChunks[+index];
1058
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
1103
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1059
1104
  });
1060
1105
 
1061
1106
  return fn(text, type);
1062
1107
  };
1063
- })(options.minifyCSS);
1108
+ })(/** @type {Function} */ (options.minifyCSS));
1064
1109
  }
1065
1110
 
1066
- if (options.minifyJS) {
1067
- options.minifyJS = (function (fn) {
1068
- return function (text, inline, isModule) {
1069
- return fn(text.replace(uidPattern, function (match, prefix, index) {
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) {
1070
1115
  const chunks = ignoredCustomMarkupChunks[+index];
1071
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
1116
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1072
1117
  }), inline, isModule);
1073
1118
  };
1074
- })(options.minifyJS);
1119
+ })(/** @type {Function} */ (options.minifyJS));
1075
1120
  }
1076
1121
  }
1077
1122
 
@@ -1082,17 +1127,17 @@ async function minifyHTML(value, options, partialMarkup) {
1082
1127
  }
1083
1128
  }
1084
1129
 
1085
- function canCollapseWhitespace(tag, attrs) {
1086
- return options.canCollapseWhitespace(tag, attrs, defaultCanCollapseWhitespace);
1130
+ function canCollapseWhitespace(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
1131
+ return /** @type {Function} */ (options.canCollapseWhitespace)(tag, attrs, defaultCanCollapseWhitespace);
1087
1132
  }
1088
1133
 
1089
- function canTrimWhitespace(tag, attrs) {
1090
- return options.canTrimWhitespace(tag, attrs, defaultCanTrimWhitespace);
1134
+ function canTrimWhitespace(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
1135
+ return /** @type {Function} */ (options.canTrimWhitespace)(tag, attrs, defaultCanTrimWhitespace);
1091
1136
  }
1092
1137
 
1093
1138
  function removeStartTag() {
1094
1139
  let index = buffer.length - 1;
1095
- while (index > 0 && !RE_START_TAG.test(buffer[index])) {
1140
+ while (index > 0 && !RE_START_TAG.test(buffer[index] ?? '')) {
1096
1141
  index--;
1097
1142
  }
1098
1143
  buffer.length = Math.max(0, index);
@@ -1100,20 +1145,20 @@ async function minifyHTML(value, options, partialMarkup) {
1100
1145
 
1101
1146
  function removeEndTag() {
1102
1147
  let index = buffer.length - 1;
1103
- while (index > 0 && !RE_END_TAG.test(buffer[index])) {
1148
+ while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
1104
1149
  index--;
1105
1150
  }
1106
1151
  buffer.length = Math.max(0, index);
1107
1152
  }
1108
1153
 
1109
1154
  // Look for trailing whitespaces, bypass any inline tags
1110
- function trimTrailingWhitespace(index, nextTag) {
1111
- for (let endTag = null; index >= 0 && canTrimWhitespace(endTag); index--) {
1112
- 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] ?? '';
1113
1158
  const match = str.match(/^<\/([\w:-]+)>$/);
1114
1159
  if (match) {
1115
- endTag = match[1];
1116
- } 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))) {
1117
1162
  break;
1118
1163
  }
1119
1164
  }
@@ -1122,10 +1167,10 @@ async function minifyHTML(value, options, partialMarkup) {
1122
1167
  // Look for trailing whitespaces from previously processed text
1123
1168
  // which may not be trimmed due to a following comment or an empty
1124
1169
  // element which has now been removed
1125
- function squashTrailingWhitespace(nextTag) {
1170
+ function squashTrailingWhitespace(/** @type {string} */ nextTag) {
1126
1171
  let charsIndex = buffer.length - 1;
1127
1172
  if (buffer.length > 1) {
1128
- const item = buffer[buffer.length - 1];
1173
+ const item = buffer[buffer.length - 1] ?? '';
1129
1174
  if (/^(?:<!|$)/.test(item) && (!uidIgnore || item.indexOf(uidIgnore) === -1)) {
1130
1175
  charsIndex--;
1131
1176
  }
@@ -1134,6 +1179,7 @@ async function minifyHTML(value, options, partialMarkup) {
1134
1179
  }
1135
1180
 
1136
1181
  // SVG subtree capture: When SVGO is active, record buffer positions for post-processing
1182
+ /** @type {Array<{start: number, end: number}>} */
1137
1183
  const svgBlocks = []; // Array of { start, end } buffer indices
1138
1184
  let svgBufferStartIndex = -1;
1139
1185
  let svgDepth = 0;
@@ -1146,7 +1192,7 @@ async function minifyHTML(value, options, partialMarkup) {
1146
1192
  // Compute `nextTag` only when whitespace collapse features require it
1147
1193
  wantsNextTag: !!(options.collapseWhitespace || options.collapseInlineTagWhitespace || options.conservativeCollapse),
1148
1194
 
1149
- 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) {
1150
1196
  const lowerTag = tag.toLowerCase();
1151
1197
  if (lowerTag === 'svg' || lowerTag === 'math') {
1152
1198
  options = Object.create(options);
@@ -1168,21 +1214,21 @@ async function minifyHTML(value, options, partialMarkup) {
1168
1214
  // Note: The element itself is in SVG/MathML namespace, only its children are HTML
1169
1215
  let useParentNameForTag = false;
1170
1216
  if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
1171
- (lowerTag === 'annotation-xml' && attrs.some(a => a.name.toLowerCase() === 'encoding' &&
1172
- RE_HTML_ENCODING.test(a.value))))) {
1173
- 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);
1174
1220
  options = Object.create(options);
1175
1221
  options.caseSensitive = false;
1176
1222
  options.keepClosingSlash = false;
1177
1223
  options.parentName = parentName; // Preserve for the element tag itself
1178
- options.name = options.htmlName || lowercase;
1224
+ options.name = /** @type {(name: string) => string} */ (options.htmlName ?? lowercase);
1179
1225
  options.insideForeignContent = false;
1180
1226
  // Note: `removeAttributeQuotes`, `removeTagWhitespace`, and `decodeEntities`
1181
1227
  // stay disabled (inherited from SVG context) because the entire SVG block
1182
1228
  // must be valid XML for SVGO processing
1183
1229
  useParentNameForTag = true;
1184
1230
  }
1185
- tag = (useParentNameForTag ? options.parentName : options.name)(tag);
1231
+ tag = /** @type {(name: string) => string} */ (useParentNameForTag ? options.parentName : options.name)(tag);
1186
1232
  currentTag = tag;
1187
1233
  charsPrevTag = tag;
1188
1234
  if (!inlineTextSet.has(tag)) {
@@ -1248,10 +1294,10 @@ async function minifyHTML(value, options, partialMarkup) {
1248
1294
  // Remove duplicate attributes (per HTML spec, first occurrence wins)
1249
1295
  // Duplicate attributes result in invalid HTML
1250
1296
  // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
1251
- deduplicateAttributes(attrs, options.caseSensitive);
1297
+ deduplicateAttributes(attrs, Boolean(options.caseSensitive));
1252
1298
 
1253
1299
  if (options.sortAttributes) {
1254
- options.sortAttributes(tag, attrs);
1300
+ /** @type {Function} */ (options.sortAttributes)(tag, attrs);
1255
1301
  }
1256
1302
 
1257
1303
  const attrResults = attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML));
@@ -1281,7 +1327,7 @@ async function minifyHTML(value, options, partialMarkup) {
1281
1327
  currentTag = '';
1282
1328
  }
1283
1329
  },
1284
- end: function (tag, attrs, autoGenerated) {
1330
+ end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated) {
1285
1331
  const lowerTag = tag.toLowerCase();
1286
1332
  // Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
1287
1333
  if (lowerTag === 'svg' || lowerTag === 'math') {
@@ -1290,7 +1336,7 @@ async function minifyHTML(value, options, partialMarkup) {
1290
1336
  !options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
1291
1337
  options = Object.getPrototypeOf(options);
1292
1338
  }
1293
- tag = options.name(tag);
1339
+ tag = /** @type {Function} */ (options.name)(tag);
1294
1340
 
1295
1341
  // Check if current tag is in a whitespace stack
1296
1342
  if (options.collapseWhitespace) {
@@ -1336,7 +1382,7 @@ async function minifyHTML(value, options, partialMarkup) {
1336
1382
  let preserve = false;
1337
1383
  if (removeEmptyElementsExcept.length) {
1338
1384
  // Normalize attribute names for comparison with specs
1339
- const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
1385
+ const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
1340
1386
  preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
1341
1387
  }
1342
1388
 
@@ -1383,7 +1429,7 @@ async function minifyHTML(value, options, partialMarkup) {
1383
1429
  }
1384
1430
  }
1385
1431
  },
1386
- 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) {
1387
1433
  prevTag = prevTag === '' ? 'comment' : prevTag;
1388
1434
  nextTag = nextTag === '' ? 'comment' : nextTag;
1389
1435
  prevAttrs = prevAttrs || [];
@@ -1399,7 +1445,7 @@ async function minifyHTML(value, options, partialMarkup) {
1399
1445
  const needsMinifyCSS = options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
1400
1446
 
1401
1447
  // Whitespace collapsing phase (sync); captures `prevTag`/`nextTag`/`prevAttrs`/`nextAttrs` from outer scope
1402
- function charsCollapse(text) {
1448
+ function charsCollapse(/** @type {string} */ text) {
1403
1449
  // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
1404
1450
  // This removes trailing newlines often added by template engines before closing tags
1405
1451
  // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
@@ -1421,7 +1467,7 @@ async function minifyHTML(value, options, partialMarkup) {
1421
1467
  // to avoid side effects on the `inlineTextSet` branch below
1422
1468
  let effectivePrevTag = prevTag;
1423
1469
  if (prevTag === 'comment') {
1424
- const prevComment = buffer[buffer.length - 1];
1470
+ const prevComment = buffer[buffer.length - 1] ?? '';
1425
1471
  if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
1426
1472
  if (!prevComment) {
1427
1473
  prevTag = charsPrevTag;
@@ -1429,7 +1475,7 @@ async function minifyHTML(value, options, partialMarkup) {
1429
1475
  }
1430
1476
  if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
1431
1477
  const charsIndex = buffer.length - 2;
1432
- buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
1478
+ buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
1433
1479
  text = trailingSpaces + text;
1434
1480
  return '';
1435
1481
  });
@@ -1440,13 +1486,14 @@ async function minifyHTML(value, options, partialMarkup) {
1440
1486
  // when `nextTag` is `comment` (another UID placeholder), `commentFinalize` handles it
1441
1487
  const match = prevComment.match(uidIgnorePlaceholderPattern);
1442
1488
  if (match) {
1443
- const idx = +match[1];
1489
+ const idx = +(match[1] ?? '');
1444
1490
  if (idx < ignoredMarkupChunks.length) {
1445
1491
  const content = ignoredMarkupChunks[idx];
1446
1492
  const lastTagMatch = content && RE_LAST_HTML_TAG.exec(content);
1447
1493
  if (lastTagMatch) {
1448
- const isClose = lastTagMatch[1].charAt(0) === '/';
1449
- 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);
1450
1497
  effectivePrevTag = isClose ? '/' + tagName : tagName;
1451
1498
  }
1452
1499
  }
@@ -1457,13 +1504,13 @@ async function minifyHTML(value, options, partialMarkup) {
1457
1504
  if (prevTag === '/nobr' || prevTag === 'wbr') {
1458
1505
  if (/^\s/.test(text)) {
1459
1506
  let tagIndex = buffer.length - 1;
1460
- while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
1507
+ while (tagIndex > 0 && (buffer[tagIndex] ?? '').lastIndexOf('<' + prevTag) !== 0) {
1461
1508
  tagIndex--;
1462
1509
  }
1463
1510
  trimTrailingWhitespace(tagIndex - 1, 'br');
1464
1511
  }
1465
1512
  } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
1466
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
1513
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars), false);
1467
1514
  }
1468
1515
  }
1469
1516
  if (prevTag || nextTag) {
@@ -1483,18 +1530,18 @@ async function minifyHTML(value, options, partialMarkup) {
1483
1530
  }
1484
1531
 
1485
1532
  // Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
1486
- function charsFinalize(text) {
1533
+ function charsFinalize(/** @type {string} */ text) {
1487
1534
  if (options.removeOptionalTags && text) {
1488
1535
  // UID-attr tokens are padded with `\t`, which would falsely look like leading whitespace;
1489
1536
  // resolve single-token text to its actual content for the space/comment checks below
1490
1537
  let effectiveText = text;
1491
- if (uidAttrLeadingPattern && text.includes(uidAttr)) {
1538
+ if (uidAttrLeadingPattern && uidAttr && text.includes(uidAttr)) {
1492
1539
  const uidMatch = uidAttrLeadingPattern.exec(text);
1493
1540
  if (uidMatch) {
1494
- const idx = +uidMatch[1];
1541
+ const idx = +(uidMatch[1] ?? 0);
1495
1542
  const chunks = idx < ignoredCustomMarkupChunks.length ? ignoredCustomMarkupChunks[idx] : null;
1496
1543
  if (chunks != null) {
1497
- effectiveText = chunks[0];
1544
+ effectiveText = chunks[0] ?? '';
1498
1545
  }
1499
1546
  }
1500
1547
  }
@@ -1530,8 +1577,8 @@ async function minifyHTML(value, options, partialMarkup) {
1530
1577
  }
1531
1578
  }
1532
1579
  if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
1533
- text = text.replace(uidPattern, function (match, prefix, index) {
1534
- 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;
1535
1582
  });
1536
1583
  }
1537
1584
  currentChars += text;
@@ -1557,20 +1604,20 @@ async function minifyHTML(value, options, partialMarkup) {
1557
1604
  text = await processScript(text, options, currentAttrs, minifyHTML);
1558
1605
  }
1559
1606
  if (needsMinifyJS) {
1560
- text = await options.minifyJS(text, false, isModuleScript);
1607
+ text = await /** @type {Function} */ (options.minifyJS)(text, false, isModuleScript);
1561
1608
  }
1562
1609
  if (needsMinifyCSS) {
1563
- text = await options.minifyCSS(text);
1610
+ text = await /** @type {Function} */ (options.minifyCSS)(text);
1564
1611
  }
1565
1612
  charsFinalize(text);
1566
1613
  })();
1567
1614
  },
1568
- comment: function (text, nonStandard) {
1615
+ comment: function (/** @type {string} */ text, /** @type {boolean} */ nonStandard) {
1569
1616
  const prefix = nonStandard ? '<!' : '<!--';
1570
1617
  const suffix = nonStandard ? '>' : '-->';
1571
1618
 
1572
1619
  // Finalization phase (sync): Optional tag handling, `htmlmin:ignore` whitespace collapsing, buffer push
1573
- function commentFinalize(comment) {
1620
+ function commentFinalize(/** @type {string} */ comment) {
1574
1621
  if (options.removeOptionalTags && comment) {
1575
1622
  if (uidIgnorePlaceholderPattern) {
1576
1623
  const match = uidIgnorePlaceholderPattern.exec(comment);
@@ -1579,11 +1626,12 @@ async function minifyHTML(value, options, partialMarkup) {
1579
1626
  // if there’s a pending optional end tag and the ignored content isn’t itself
1580
1627
  // a comment (which per the HTML spec prevents omission), resolve it now,
1581
1628
  // before the UID is pushed to the buffer
1582
- const idx = +match[1];
1629
+ const idx = +(match[1] ?? 0);
1583
1630
  const content = idx < ignoredMarkupChunks.length ? ignoredMarkupChunks[idx] : null;
1584
1631
  if (optionalEndTag && optionalEndTagEmitted && content != null && !/^\s*<!--/.test(content)) {
1585
1632
  const firstTagMatch = content.match(/^\s*<([a-zA-Z][^\s/>]*)/);
1586
- const firstTag = firstTagMatch ? options.name(firstTagMatch[1]) : '';
1633
+ const firstTagGroup = firstTagMatch?.[1] ?? '';
1634
+ const firstTag = firstTagGroup ? resolveName(firstTagGroup) : '';
1587
1635
  if (canRemovePrecedingTag(optionalEndTag, firstTag)) {
1588
1636
  removeEndTag();
1589
1637
  }
@@ -1611,8 +1659,8 @@ async function minifyHTML(value, options, partialMarkup) {
1611
1659
  const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
1612
1660
 
1613
1661
  if (currentMatch && prevMatch) {
1614
- const currentIndex = +currentMatch[1];
1615
- const prevIndex = +prevMatch[1];
1662
+ const currentIndex = +(currentMatch[1] ?? 0);
1663
+ const prevIndex = +(prevMatch[1] ?? 0);
1616
1664
 
1617
1665
  // Defensive bounds check to ensure indices are valid
1618
1666
  if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
@@ -1635,13 +1683,13 @@ async function minifyHTML(value, options, partialMarkup) {
1635
1683
  // Collapse if both sides are element/closing tags or HTML comments, and neither is inline
1636
1684
  if ((currentTagMatch || currentIsHtmlComment || currentClosingTagMatch) &&
1637
1685
  (prevTagMatch || prevIsHtmlComment || prevClosingTagMatch)) {
1638
- const currentTag = currentTagMatch ? options.name(currentTagMatch[1])
1639
- : currentClosingTagMatch ? options.name(currentClosingTagMatch[1]) : null;
1640
- const prevTag = prevTagMatch ? options.name(prevTagMatch[1])
1641
- : 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;
1642
1690
 
1643
1691
  // Don’t collapse between inline elements (HTML comments count as non-inline)
1644
- if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
1692
+ if (!inlineElements.has(currentTag ?? '') && !inlineElements.has(prevTag ?? '')) {
1645
1693
  // Collapse whitespace respecting context rules
1646
1694
  let collapsedText = prevText;
1647
1695
 
@@ -1676,7 +1724,7 @@ async function minifyHTML(value, options, partialMarkup) {
1676
1724
  }
1677
1725
 
1678
1726
  if (options.removeComments) {
1679
- if (isIgnoredComment(text, options)) {
1727
+ if (isIgnoredComment(text, /** @type {any} */ (options))) {
1680
1728
  text = prefix + text + suffix;
1681
1729
  } else {
1682
1730
  text = '';
@@ -1686,7 +1734,7 @@ async function minifyHTML(value, options, partialMarkup) {
1686
1734
  }
1687
1735
  commentFinalize(text);
1688
1736
  },
1689
- doctype: function (doctype) {
1737
+ doctype: function (/** @type {string} */ doctype) {
1690
1738
  buffer.push(options.useShortDoctype
1691
1739
  ? '<!doctype' +
1692
1740
  (options.removeTagWhitespace ? '' : ' ') + 'html>'
@@ -1701,11 +1749,12 @@ async function minifyHTML(value, options, partialMarkup) {
1701
1749
  if (options.minifySVG && svgBlocks.length) {
1702
1750
  const optimized = await Promise.all(
1703
1751
  svgBlocks.map(({ start, end }) =>
1704
- options.minifySVG(buffer.slice(start, end).join(''))
1752
+ /** @type {Function} */ (options.minifySVG)(buffer.slice(start, end).join(''))
1705
1753
  )
1706
1754
  );
1707
1755
  for (let i = svgBlocks.length - 1; i >= 0; i--) {
1708
- 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] ?? '');
1709
1758
  }
1710
1759
  }
1711
1760
 
@@ -1725,9 +1774,9 @@ async function minifyHTML(value, options, partialMarkup) {
1725
1774
  }
1726
1775
 
1727
1776
  return joinResultSegments(buffer, options, uidPattern
1728
- ? function (str) {
1729
- return str.replace(uidPattern, function (match, prefix, index, suffix) {
1730
- 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;
1731
1780
  if (options.collapseWhitespace) {
1732
1781
  if (prefix !== '\t') {
1733
1782
  chunk = prefix + chunk;
@@ -1744,14 +1793,20 @@ async function minifyHTML(value, options, partialMarkup) {
1744
1793
  });
1745
1794
  }
1746
1795
  : identity, uidIgnore
1747
- ? function (str) {
1748
- return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
1749
- 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] ?? '';
1750
1799
  });
1751
1800
  }
1752
1801
  : identity);
1753
1802
  }
1754
1803
 
1804
+ /**
1805
+ * @param {string[]} results
1806
+ * @param {MinifierOptions} options
1807
+ * @param {Function} restoreCustom
1808
+ * @param {Function} restoreIgnore
1809
+ */
1755
1810
  function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
1756
1811
  let str;
1757
1812
  const maxLineLength = options.maxLineLength;
@@ -1761,15 +1816,16 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
1761
1816
  let line = ''; const lines = [];
1762
1817
  while (results.length) {
1763
1818
  const len = line.length;
1764
- const end = results[0].indexOf('\n');
1765
- 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));
1766
1822
  const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
1767
1823
 
1768
1824
  if (end < 0) {
1769
- line += restoreIgnore(restoreCustom(results.shift()));
1825
+ line += restoreIgnore(restoreCustom(results.shift() ?? ''));
1770
1826
  } else {
1771
- line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
1772
- results[0] = results[0].slice(end + 1);
1827
+ line += restoreIgnore(restoreCustom(cur.slice(0, end)));
1828
+ results[0] = cur.slice(end + 1);
1773
1829
  }
1774
1830
  if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
1775
1831
  lines.push(line.slice(0, len));
@@ -1799,13 +1855,14 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
1799
1855
  * - The first call’s options determine the cache sizes for subsequent calls
1800
1856
  * - Invalid values (NaN, Infinity) fall back to the default size (500); values below `1` are clamped to `1`
1801
1857
  */
1858
+ /** @param {MinifierOptions} options */
1802
1859
  function initCaches(options) {
1803
1860
  // Only create caches once (on first call)—sizes are locked after this
1804
1861
  if (!cssMinifyCache) {
1805
1862
  const defaultSize = 500;
1806
1863
 
1807
1864
  // Helper to parse env var—returns parsed number (including 0) or undefined if absent, invalid, or negative
1808
- const parseEnvCacheSize = (envVar) => {
1865
+ const parseEnvCacheSize = (/** @type {string | undefined} */ envVar) => {
1809
1866
  if (envVar === undefined) return undefined;
1810
1867
  const parsed = Number(envVar);
1811
1868
  if (Number.isNaN(parsed) || !Number.isFinite(parsed) || parsed < 0) {
@@ -1815,7 +1872,7 @@ function initCaches(options) {
1815
1872
  };
1816
1873
 
1817
1874
  // Sanitize a cache size: Non-finite/NaN falls back to `defaultSize`; otherwise clamped to min 1 and floored
1818
- 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;
1819
1876
 
1820
1877
  // Get cache sizes with precedence: Options > env > default
1821
1878
  const cssSize = options.cacheCSS !== undefined ? options.cacheCSS
@@ -1853,7 +1910,9 @@ export const minify = async function (value, options) {
1853
1910
  getTerser,
1854
1911
  getSwc,
1855
1912
  getSvgo,
1856
- ...caches
1913
+ cssMinifyCache: caches.cssMinifyCache ?? undefined,
1914
+ jsMinifyCache: caches.jsMinifyCache ?? undefined,
1915
+ svgMinifyCache: caches.svgMinifyCache ?? undefined
1857
1916
  });
1858
1917
  let result = await minifyHTML(value, options);
1859
1918
 
@@ -1862,7 +1921,7 @@ export const minify = async function (value, options) {
1862
1921
  result = mergeConsecutiveScripts(result);
1863
1922
  }
1864
1923
 
1865
- options.log('minified in: ' + (Date.now() - start) + 'ms');
1924
+ /** @type {Function} */ (options.log)('minified in: ' + (Date.now() - start) + 'ms');
1866
1925
  return result;
1867
1926
  };
1868
1927