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.
@@ -4,14 +4,18 @@ Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  // Stringify for options signatures (sorted keys, shallow, nested objects)
6
6
 
7
+ /**
8
+ * @param {unknown} obj
9
+ * @returns {string}
10
+ */
7
11
  function stableStringify(obj) {
8
12
  if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
9
13
  if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
10
14
  const keys = Object.keys(obj).sort();
11
15
  let out = '{';
12
16
  for (let i = 0; i < keys.length; i++) {
13
- const k = keys[i];
14
- out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
17
+ const k = keys[i] ?? '';
18
+ out += JSON.stringify(k) + ':' + stableStringify(/** @type {Record<string, unknown>} */ (obj)[k]) + (i < keys.length - 1 ? ',' : '');
15
19
  }
16
20
  return out + '}';
17
21
  }
@@ -21,8 +25,10 @@ function stableStringify(obj) {
21
25
  class LRU {
22
26
  constructor(limit = 200) {
23
27
  this.limit = limit;
28
+ /** @type {Map<string, unknown>} */
24
29
  this.map = new Map();
25
30
  }
31
+ /** @param {string} key */
26
32
  get(key) {
27
33
  if (this.map.has(key)) {
28
34
  const v = this.map.get(key);
@@ -32,19 +38,25 @@ class LRU {
32
38
  }
33
39
  return undefined;
34
40
  }
41
+ /**
42
+ * @param {string} key
43
+ * @param {unknown} value
44
+ */
35
45
  set(key, value) {
36
46
  if (this.map.has(key)) this.map.delete(key);
37
47
  this.map.set(key, value);
38
48
  if (this.map.size > this.limit) {
39
49
  const first = this.map.keys().next().value;
40
- this.map.delete(first);
50
+ if (first !== undefined) this.map.delete(first);
41
51
  }
42
52
  }
53
+ /** @param {string} key */
43
54
  delete(key) { this.map.delete(key); }
44
55
  }
45
56
 
46
57
  // FNV-1a 32-bit hash for large-input cache keys
47
58
 
59
+ /** @param {string} str */
48
60
  function hashContent(str) {
49
61
  let hash = 2166136261;
50
62
  for (let i = 0; i < str.length; i++) {
@@ -56,6 +68,7 @@ function hashContent(str) {
56
68
 
57
69
  // Unique ID generator
58
70
 
71
+ /** @param {string} value */
59
72
  function uniqueId(value) {
60
73
  let id;
61
74
  do {
@@ -66,14 +79,17 @@ function uniqueId(value) {
66
79
 
67
80
  // Identity and transform functions
68
81
 
82
+ /** @param {string} value */
69
83
  function identity(value) {
70
84
  return value;
71
85
  }
72
86
 
87
+ /** @param {unknown} value */
73
88
  function isThenable(value) {
74
- return value != null && typeof value === 'object' && typeof value.then === 'function';
89
+ return value != null && typeof value === 'object' && typeof /** @type {any} */ (value).then === 'function';
75
90
  }
76
91
 
92
+ /** @param {string} value */
77
93
  function lowercase(value) {
78
94
  return value.toLowerCase();
79
95
  }
@@ -88,25 +104,28 @@ function lowercase(value) {
88
104
  * @returns {Promise<string>} Processed string
89
105
  */
90
106
  async function replaceAsync(str, regex, asyncFn) {
107
+ /** @type {Promise<string>[]} */
91
108
  const promises = [];
92
109
 
93
- str.replace(regex, (match, ...args) => {
110
+ str.replace(regex, /** @returns {string} */ (match, ...args) => {
94
111
  const promise = asyncFn(match, ...args);
95
112
  promises.push(promise);
113
+ return match;
96
114
  });
97
115
 
98
116
  const data = await Promise.all(promises);
99
- return str.replace(regex, () => data.shift());
117
+ return str.replace(regex, () => data.shift() ?? '');
100
118
  }
101
119
 
102
120
  // String patterns to RegExp conversion (for JSON config support)
103
121
 
122
+ /** @param {string | RegExp} value */
104
123
  function parseRegExp(value) {
105
124
  if (typeof value === 'string') {
106
125
  if (!value) return undefined; // Empty string = not configured
107
126
  const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
108
127
  if (match) {
109
- return new RegExp(match[1], match[2]);
128
+ return new RegExp(match[1] ?? '', match[2] ?? '');
110
129
  }
111
130
  return new RegExp(value);
112
131
  }
@@ -121,6 +140,25 @@ function parseRegExp(value) {
121
140
  */
122
141
 
123
142
 
143
+ /** @import { HTMLAttribute } from './lib/attributes.js' */
144
+
145
+ // Type definitions
146
+
147
+ /**
148
+ * @typedef {{
149
+ * start?: Function,
150
+ * end?: Function,
151
+ * chars?: Function,
152
+ * comment?: Function,
153
+ * doctype?: Function,
154
+ * continueOnParseError?: boolean | undefined,
155
+ * partialMarkup?: boolean | undefined,
156
+ * wantsNextTag?: boolean | undefined,
157
+ * customAttrSurround?: RegExp[][] | undefined,
158
+ * customAttrAssign?: RegExp[] | undefined
159
+ * }} HTMLParserHandler
160
+ */
161
+
124
162
  /*
125
163
  * Use like so:
126
164
  *
@@ -133,7 +171,8 @@ function parseRegExp(value) {
133
171
  */
134
172
 
135
173
  class CaseInsensitiveSet extends Set {
136
- has(str) {
174
+ /** @override */
175
+ has(/** @type {string} */ str) {
137
176
  return super.has(str.toLowerCase());
138
177
  }
139
178
  }
@@ -168,8 +207,9 @@ const startTagOpen = new RegExp('^<' + qnameCapture);
168
207
  const endTag = new RegExp('^</' + qnameCapture + '[^>]*>');
169
208
 
170
209
  let IS_REGEX_CAPTURING_BROKEN = false;
171
- 'x'.replace(/x(.)?/g, function (m, g) {
210
+ 'x'.replace(/x(.)?/g, function (/** @type {string} */ m, /** @type {string | undefined} */ g) {
172
211
  IS_REGEX_CAPTURING_BROKEN = g === '';
212
+ return m;
173
213
  });
174
214
 
175
215
  // Empty elements
@@ -203,6 +243,11 @@ const attrRegexCache = new WeakMap();
203
243
 
204
244
  // O(n) helper: Strip all occurrences of `open…close` delimiters, keeping inner content
205
245
  // Used instead of a regex replace to avoid O(n²) behavior on adversarial inputs
246
+ /**
247
+ * @param {string} str
248
+ * @param {string} open
249
+ * @param {string} close
250
+ */
206
251
  function stripDelimited(str, open, close) {
207
252
  let result = '';
208
253
  let i = 0;
@@ -218,6 +263,7 @@ function stripDelimited(str, open, close) {
218
263
  return result;
219
264
  }
220
265
 
266
+ /** @param {HTMLParserHandler} handler */
221
267
  function buildAttrRegex(handler) {
222
268
  const unquotedValue = handler.continueOnParseError ? singleAttrValueLenientUnquoted : singleAttrValues[2];
223
269
  const attrValues = [singleAttrValues[0], singleAttrValues[1], unquotedValue];
@@ -225,12 +271,19 @@ function buildAttrRegex(handler) {
225
271
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
226
272
  '[ \\t\\n\\f\\r]*(?:' + attrValues.join('|') + '))?';
227
273
  if (handler.customAttrSurround) {
274
+ if (!Array.isArray(handler.customAttrSurround)) {
275
+ throw new Error('`customAttrSurround` must be an array of `[RegExp, RegExp]` pairs');
276
+ }
228
277
  const attrClauses = [];
229
278
  for (let i = handler.customAttrSurround.length - 1; i >= 0; i--) {
279
+ const pair = handler.customAttrSurround[i];
280
+ if (!Array.isArray(pair) || !(pair[0] instanceof RegExp) || !(pair[1] instanceof RegExp)) {
281
+ throw new Error('`customAttrSurround` entries must be `[RegExp, RegExp]` pairs');
282
+ }
230
283
  attrClauses[i] = '(?:' +
231
- '(' + handler.customAttrSurround[i][0].source + ')\\s*' +
284
+ '(' + pair[0].source + ')\\s*' +
232
285
  pattern +
233
- '\\s*(' + handler.customAttrSurround[i][1].source + ')' +
286
+ '\\s*(' + pair[1].source + ')' +
234
287
  ')';
235
288
  }
236
289
  attrClauses.push('(?:' + pattern + ')');
@@ -239,6 +292,7 @@ function buildAttrRegex(handler) {
239
292
  return new RegExp('^\\s*' + pattern);
240
293
  }
241
294
 
295
+ /** @param {HTMLParserHandler} handler */
242
296
  function getAttrRegexForHandler(handler) {
243
297
  let cached = attrRegexCache.get(handler);
244
298
  if (cached) return cached;
@@ -250,6 +304,7 @@ function getAttrRegexForHandler(handler) {
250
304
  // Cache for sticky attribute regexes (`y` flag for position-based matching on full string)
251
305
  const attrRegexStickyCache = new WeakMap();
252
306
 
307
+ /** @param {HTMLParserHandler} handler */
253
308
  function getAttrRegexStickyForHandler(handler) {
254
309
  let cached = attrRegexStickyCache.get(handler);
255
310
  if (cached) return cached;
@@ -260,6 +315,7 @@ function getAttrRegexStickyForHandler(handler) {
260
315
  return compiled;
261
316
  }
262
317
 
318
+ /** @param {HTMLParserHandler} handler */
263
319
  function joinSingleAttrAssigns(handler) {
264
320
  return singleAttrAssigns.concat(
265
321
  handler.customAttrAssign || []
@@ -272,6 +328,10 @@ function joinSingleAttrAssigns(handler) {
272
328
  const NCP = 7;
273
329
 
274
330
  class HTMLParser {
331
+ /**
332
+ * @param {string} html
333
+ * @param {HTMLParserHandler} handler
334
+ */
275
335
  constructor(html, handler) {
276
336
  this.html = html;
277
337
  this.handler = handler;
@@ -282,12 +342,21 @@ class HTMLParser {
282
342
  const fullHtml = this.html;
283
343
  const fullLength = fullHtml.length;
284
344
 
285
- const stack = []; let lastTag;
345
+ /** @type {Array<{tag: string, lowerTag: string, attrs: HTMLAttribute[]}>} */
346
+ const stack = [];
347
+ /** @type {string} */
348
+ let lastTag = '';
286
349
  // Use cached attribute regex for this handler configuration
287
350
  const attribute = getAttrRegexForHandler(handler);
288
351
  const attributeY = getAttrRegexStickyForHandler(handler);
289
- let prevTag = undefined, nextTag = undefined;
290
- let prevAttrs = [], nextAttrs = [];
352
+ /** @type {string | undefined} */
353
+ let prevTag;
354
+ /** @type {string | undefined} */
355
+ let nextTag;
356
+ /** @type {HTMLAttribute[]} */
357
+ let prevAttrs = [];
358
+ /** @type {HTMLAttribute[]} */
359
+ let nextAttrs = [];
291
360
 
292
361
  // Sticky regex versions for position-based matching (avoids string slicing)
293
362
  const startTagOpenY = new RegExp(startTagOpen.source.slice(1), 'y');
@@ -307,10 +376,10 @@ class HTMLParser {
307
376
  let lastPos;
308
377
 
309
378
  // Helper to advance position
310
- const advance = (n) => { pos += n; };
379
+ const advance = (/** @type {number} */ n) => { pos += n; };
311
380
 
312
381
  // Lazy line/column calculation—only compute on actual errors
313
- const getLineColumn = (position) => {
382
+ const getLineColumn = (/** @type {number} */ position) => {
314
383
  let line = 1;
315
384
  let column = 1;
316
385
  for (let i = 0; i < position; i++) {
@@ -325,7 +394,7 @@ class HTMLParser {
325
394
  };
326
395
 
327
396
  // Helper to safely extract substring when needed for stacked tag content
328
- const sliceFromPos = (startPos) => {
397
+ const sliceFromPos = (/** @type {number} */ startPos) => {
329
398
  return fullHtml.slice(startPos);
330
399
  };
331
400
 
@@ -353,9 +422,9 @@ class HTMLParser {
353
422
  const endTagMatch = cachedNextEndTag.match;
354
423
  cachedNextStartTag = null;
355
424
  cachedNextEndTag = null;
356
- advance(endTagMatch[0].length);
357
- await parseEndTag(endTagMatch[0], endTagMatch[1]);
358
- prevTag = '/' + endTagMatch[1].toLowerCase();
425
+ advance((endTagMatch[0] ?? '').length);
426
+ await parseEndTag(endTagMatch[0] ?? '', endTagMatch[1] ?? '');
427
+ prevTag = '/' + (endTagMatch[1] ?? '').toLowerCase();
359
428
  prevAttrs = [];
360
429
  continue;
361
430
  }
@@ -413,9 +482,9 @@ class HTMLParser {
413
482
  endTagY.lastIndex = pos;
414
483
  const endTagMatch = endTagY.exec(fullHtml);
415
484
  if (endTagMatch) {
416
- advance(endTagMatch[0].length);
417
- await parseEndTag(endTagMatch[0], endTagMatch[1]);
418
- prevTag = '/' + endTagMatch[1].toLowerCase();
485
+ advance((endTagMatch[0] ?? '').length);
486
+ await parseEndTag(endTagMatch[0] ?? '', endTagMatch[1] ?? '');
487
+ prevTag = '/' + (endTagMatch[1] ?? '').toLowerCase();
419
488
  prevAttrs = [];
420
489
  continue;
421
490
  }
@@ -473,12 +542,12 @@ class HTMLParser {
473
542
  } else {
474
543
  const stackedTag = lastTag.toLowerCase();
475
544
  // Use pre-compiled regex for common tags (`script`, `style`, `noscript`) to avoid regex creation overhead
476
- const reStackedTag = preCompiledStackedTags[stackedTag] || reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)\\x3c/' + stackedTag + '[^>]*>', 'i'));
545
+ const reStackedTag = /** @type {Record<string, RegExp>} */ (preCompiledStackedTags)[stackedTag] || /** @type {Record<string, RegExp>} */ (reCache)[stackedTag] || (/** @type {Record<string, RegExp>} */ (reCache)[stackedTag] = new RegExp('([\\s\\S]*?)\\x3c/' + stackedTag + '[^>]*>', 'i'));
477
546
 
478
547
  const remaining = sliceFromPos(pos);
479
548
  const m = reStackedTag.exec(remaining);
480
549
  if (m && m.index === 0) {
481
- let text = m[1];
550
+ let text = m[1] ?? '';
482
551
  if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') {
483
552
  text = stripDelimited(stripDelimited(text, '<!--', '-->'), '<![CDATA[', ']]>');
484
553
  }
@@ -518,40 +587,39 @@ class HTMLParser {
518
587
  const CONTEXT_BEFORE = 50;
519
588
  const startPos = Math.max(0, pos - CONTEXT_BEFORE);
520
589
  const snippet = fullHtml.slice(startPos, startPos + 200).replace(/\n/g, ' ');
521
- throw new Error(
522
- `Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${fullHtml.length > startPos + 200 ? '…' : ''}`
523
- );
590
+ throw new Error(`Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${fullHtml.length > startPos + 200 ? '…' : ''}`);
524
591
  }
525
592
  }
526
593
 
527
594
  if (!handler.partialMarkup) {
528
595
  // Clean up any remaining tags
529
- await parseEndTag();
596
+ await parseEndTag('', '');
530
597
  }
531
598
 
532
599
  // Helper to extract minimal attribute info (name/value pairs) from raw attribute matches
533
600
  // Used for whitespace collapsing logic—doesn’t need full processing
534
- function extractAttrInfo(rawAttrs) {
601
+ function extractAttrInfo(/** @type {Array<Array<string | undefined>>} */ rawAttrs) {
535
602
  if (!rawAttrs || !rawAttrs.length) return [];
536
603
 
537
604
  const numCustomParts = handler.customAttrSurround ? handler.customAttrSurround.length * NCP : 0;
538
605
  const baseIndex = 1 + numCustomParts;
539
606
 
540
- return rawAttrs.map(args => {
607
+ return /** @type {HTMLAttribute[]} */ (rawAttrs.map((/** @type {Array<string | undefined>} */ args) => {
541
608
  // Extract attribute name (always at `baseIndex`)
542
609
  const name = args[baseIndex];
543
610
  // Extract value from double-quoted (`baseIndex + 2`), single-quoted (`baseIndex + 3`), or unquoted (`baseIndex + 4`)
544
611
  const value = args[baseIndex + 2] ?? args[baseIndex + 3] ?? args[baseIndex + 4];
545
- return { name: name?.toLowerCase(), value };
546
- }).filter(attr => attr.name); // Filter out invalid entries
612
+ return { name: name?.toLowerCase() ?? '', value };
613
+ }).filter(attr => attr.name)); // Filter out invalid entries
547
614
  }
548
615
 
549
- function parseStartTag(startPos) {
616
+ function parseStartTag(/** @type {number} */ startPos) {
550
617
  startTagOpenY.lastIndex = startPos;
551
618
  const start = startTagOpenY.exec(fullHtml);
552
619
  if (start) {
620
+ /** @type {{tagName: string, attrs: Array<Array<string|undefined>>, advance: number, unarySlash?: string}} */
553
621
  const match = {
554
- tagName: start[1],
622
+ tagName: start[1] ?? '',
555
623
  attrs: [],
556
624
  advance: 0
557
625
  };
@@ -694,19 +762,21 @@ class HTMLParser {
694
762
  startTagCloseY.lastIndex = currentPos;
695
763
  end = startTagCloseY.exec(fullHtml);
696
764
  if (end) {
697
- match.unarySlash = end[1];
698
- consumed += end[0].length;
765
+ match.unarySlash = end[1] ?? '';
766
+ consumed += (end[0] ?? '').length;
699
767
  match.advance = consumed;
700
768
  return match;
701
769
  }
702
770
  }
771
+ return undefined;
703
772
  }
704
773
 
705
- function findTagInCurrentTable(tagName) {
774
+ function findTagInCurrentTable(/** @type {string} */ tagName) {
706
775
  let pos;
707
776
  const needle = tagName.toLowerCase();
708
777
  for (pos = stack.length - 1; pos >= 0; pos--) {
709
- const currentTag = stack[pos].lowerTag;
778
+ const entry = stack[pos];
779
+ const currentTag = entry?.lowerTag;
710
780
  if (currentTag === needle) {
711
781
  return pos;
712
782
  }
@@ -718,18 +788,19 @@ class HTMLParser {
718
788
  return -1;
719
789
  }
720
790
 
721
- async function parseEndTagAt(pos) {
791
+ async function parseEndTagAt(/** @type {number} */ pos) {
722
792
  // Close all open elements up to `pos` (mirrors `parseEndTag`’s core branch)
723
793
  for (let i = stack.length - 1; i >= pos; i--) {
794
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
724
795
  if (handler.end) {
725
- await handler.end(stack[i].tag, stack[i].attrs, true);
796
+ await handler.end(entry.tag, entry.attrs, true);
726
797
  }
727
798
  }
728
799
  stack.length = pos;
729
- lastTag = pos && stack[pos - 1].tag;
800
+ lastTag = pos ? (stack[pos - 1]?.tag ?? '') : '';
730
801
  }
731
802
 
732
- async function closeIfFoundInCurrentTable(tagName) {
803
+ async function closeIfFoundInCurrentTable(/** @type {string} */ tagName) {
733
804
  const pos = findTagInCurrentTable(tagName);
734
805
  if (pos >= 0) {
735
806
  // Close at the specific index to avoid re-searching
@@ -739,7 +810,7 @@ class HTMLParser {
739
810
  return false;
740
811
  }
741
812
 
742
- async function handleStartTag(match) {
813
+ async function handleStartTag(/** @type {{tagName: string, attrs: Array<Array<string | undefined>>, advance: number, unarySlash?: string}} */ match) {
743
814
  const tagName = match.tagName;
744
815
  let unarySlash = match.unarySlash;
745
816
 
@@ -781,17 +852,18 @@ class HTMLParser {
781
852
 
782
853
  const unary = empty.has(tagName) || (tagName === 'html' && lastTag === 'head') || !!unarySlash;
783
854
 
784
- const attrs = match.attrs.map(function (args) {
855
+ const attrs = /** @type {HTMLAttribute[]} */ (match.attrs.map(function (/** @type {Array<string | undefined>} */ args) {
856
+ /** @type {string | undefined} */
785
857
  let name, value, customOpen, customClose, customAssign, quote;
786
858
 
787
859
  // Hackish workaround for Firefox bug, https://bugzilla.mozilla.org/show_bug.cgi?id=369778
788
- if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
860
+ if (IS_REGEX_CAPTURING_BROKEN && (args[0] ?? '').indexOf('""') === -1) {
789
861
  if (args[3] === '') { delete args[3]; }
790
862
  if (args[4] === '') { delete args[4]; }
791
863
  if (args[5] === '') { delete args[5]; }
792
864
  }
793
865
 
794
- function populate(index) {
866
+ function populate(/** @type {number} */ index) {
795
867
  customAssign = args[index];
796
868
  value = args[index + 1];
797
869
  if (typeof value !== 'undefined') {
@@ -802,7 +874,7 @@ class HTMLParser {
802
874
  return '\'';
803
875
  }
804
876
  value = args[index + 3];
805
- if (typeof value === 'undefined' && fillAttrs.has(name)) {
877
+ if (typeof value === 'undefined' && name && fillAttrs.has(name)) {
806
878
  value = name;
807
879
  }
808
880
  return '';
@@ -826,14 +898,14 @@ class HTMLParser {
826
898
  }
827
899
 
828
900
  return {
829
- name,
901
+ name: name ?? '',
830
902
  value,
831
903
  customAssign: customAssign || '=',
832
904
  customOpen: customOpen || '',
833
905
  customClose: customClose || '',
834
906
  quote: quote || ''
835
907
  };
836
- });
908
+ }));
837
909
 
838
910
  if (!unary) {
839
911
  stack.push({ tag: tagName, lowerTag: tagName.toLowerCase(), attrs });
@@ -849,18 +921,18 @@ class HTMLParser {
849
921
  }
850
922
  }
851
923
 
852
- function findTag(tagName) {
924
+ function findTag(/** @type {string} */ tagName) {
853
925
  let pos;
854
926
  const needle = tagName.toLowerCase();
855
927
  for (pos = stack.length - 1; pos >= 0; pos--) {
856
- if (stack[pos].lowerTag === needle) {
928
+ if (stack[pos]?.lowerTag === needle) {
857
929
  break;
858
930
  }
859
931
  }
860
932
  return pos;
861
933
  }
862
934
 
863
- async function parseEndTag(tag, tagName) {
935
+ async function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
864
936
  let pos;
865
937
 
866
938
  // Find the closest opened tag of the same type
@@ -874,13 +946,13 @@ class HTMLParser {
874
946
  // Close all the open elements, up the stack
875
947
  for (let i = stack.length - 1; i >= pos; i--) {
876
948
  if (handler.end) {
877
- handler.end(stack[i].tag, stack[i].attrs, i > pos || !tag);
949
+ handler.end(stack[i]?.tag, stack[i]?.attrs, i > pos || !tag);
878
950
  }
879
951
  }
880
952
 
881
953
  // Remove the open elements from the stack
882
954
  stack.length = pos;
883
- lastTag = pos && stack[pos - 1].tag;
955
+ lastTag = pos ? (stack[pos - 1]?.tag ?? '') : '';
884
956
  } else if (handler.partialMarkup && tagName) {
885
957
  // In partial markup mode, preserve stray end tags
886
958
  if (handler.end) {
@@ -903,19 +975,31 @@ class HTMLParser {
903
975
  }
904
976
 
905
977
  class Sorter {
978
+ constructor() {
979
+ /** @type {string[]} */
980
+ this.keys = [];
981
+ /** @type {Map<string, Sorter>} */
982
+ this.sorterMap = new Map();
983
+ }
984
+
985
+ /**
986
+ * @param {string[]} tokens
987
+ * @param {number} fromIndex
988
+ * @returns {string[]}
989
+ */
906
990
  sort(tokens, fromIndex = 0) {
907
- for (let i = 0, len = this.keys.length; i < len; i++) {
908
- const token = this.keys[i];
991
+ for (const token of this.keys) {
909
992
 
910
993
  // Single pass: Count matches and collect non-matches
911
994
  let matchCount = 0;
912
995
  const others = [];
913
996
 
914
997
  for (let j = fromIndex; j < tokens.length; j++) {
915
- if (tokens[j] === token) {
998
+ const t = /** @type {string} */ (tokens[j]);
999
+ if (t === token) {
916
1000
  matchCount++;
917
1001
  } else {
918
- others.push(tokens[j]);
1002
+ others.push(t);
919
1003
  }
920
1004
  }
921
1005
 
@@ -925,12 +1009,12 @@ class Sorter {
925
1009
  for (let j = 0; j < matchCount; j++) {
926
1010
  tokens[writeIdx++] = token;
927
1011
  }
928
- for (let j = 0; j < others.length; j++) {
929
- tokens[writeIdx++] = others[j];
1012
+ for (const other of others) {
1013
+ tokens[writeIdx++] = other;
930
1014
  }
931
1015
 
932
1016
  const newFromIndex = fromIndex + matchCount;
933
- return this.sorterMap.get(token).sort(tokens, newFromIndex);
1017
+ return this.sorterMap.get(token)?.sort(tokens, newFromIndex) ?? tokens;
934
1018
  }
935
1019
  }
936
1020
  return tokens;
@@ -939,22 +1023,24 @@ class Sorter {
939
1023
 
940
1024
  class TokenChain {
941
1025
  constructor() {
942
- // Use map instead of object properties for better performance
1026
+ /** @type {Map<string, {arrays: string[][], processed: number}>} */
943
1027
  this.map = new Map();
944
1028
  }
945
1029
 
1030
+ /** @param {string[]} tokens */
946
1031
  add(tokens) {
947
1032
  tokens.forEach((token) => {
948
- if (!this.map.has(token)) {
949
- this.map.set(token, { arrays: [], processed: 0 });
1033
+ let entry = this.map.get(token);
1034
+ if (!entry) {
1035
+ entry = { arrays: [], processed: 0 };
1036
+ this.map.set(token, entry);
950
1037
  }
951
- this.map.get(token).arrays.push(tokens);
1038
+ entry.arrays.push(tokens);
952
1039
  });
953
1040
  }
954
1041
 
955
1042
  createSorter() {
956
1043
  const sorter = new Sorter();
957
- sorter.sorterMap = new Map();
958
1044
 
959
1045
  // Convert map entries to array and sort by frequency (descending), then alphabetically
960
1046
  const entries = Array.from(this.map.entries()).sort((a, b) => {
@@ -967,18 +1053,17 @@ class TokenChain {
967
1053
  return a[0].localeCompare(b[0]);
968
1054
  });
969
1055
 
970
- sorter.keys = [];
971
-
972
1056
  entries.forEach(([token, data]) => {
973
1057
  if (data.processed < data.arrays.length) {
974
1058
  const chain = new TokenChain();
975
1059
 
976
1060
  data.arrays.forEach((tokens) => {
977
1061
  // Build new array without the current token instead of splicing
1062
+ /** @type {string[]} */
978
1063
  const filtered = [];
979
- for (let i = 0; i < tokens.length; i++) {
980
- if (tokens[i] !== token) {
981
- filtered.push(tokens[i]);
1064
+ for (const t of tokens) {
1065
+ if (t !== token) {
1066
+ filtered.push(t);
982
1067
  }
983
1068
  }
984
1069
 
@@ -1053,7 +1138,7 @@ const presets = {
1053
1138
  function getPreset(name) {
1054
1139
  if (!name) return null;
1055
1140
  const normalizedName = name.toLowerCase();
1056
- return presets[normalizedName] || null;
1141
+ return /** @type {Record<string, object>} */ (presets)[normalizedName] || null;
1057
1142
  }
1058
1143
 
1059
1144
  /**
@@ -1238,11 +1323,9 @@ const reEmptyAttribute = new RegExp(
1238
1323
 
1239
1324
  const specialContentElements = new Set(['script', 'style']);
1240
1325
 
1241
- // Imports
1242
-
1243
-
1244
1326
  // Trim whitespace
1245
1327
 
1328
+ /** @param {string} str */
1246
1329
  const trimWhitespace = str => {
1247
1330
  if (!str) return str;
1248
1331
  // Fast path: If no whitespace at start or end, return early
@@ -1254,6 +1337,7 @@ const trimWhitespace = str => {
1254
1337
 
1255
1338
  // Collapse all whitespace
1256
1339
 
1340
+ /** @param {string} str */
1257
1341
  function collapseWhitespaceAll(str) {
1258
1342
  if (!str) return str;
1259
1343
  // Fast path: If there are no common whitespace characters, return early
@@ -1261,7 +1345,7 @@ function collapseWhitespaceAll(str) {
1261
1345
  return str;
1262
1346
  }
1263
1347
  // No-break space is specifically handled inside the replacer function here:
1264
- return str.replace(RE_ALL_WS_NBSP, function (spaces) {
1348
+ return str.replace(RE_ALL_WS_NBSP, function (/** @param {string} spaces */ spaces) {
1265
1349
  // Preserve standalone tabs
1266
1350
  if (spaces === '\t') return '\t';
1267
1351
  // Fast path: No no-break space, common case—just collapse to single space
@@ -1274,7 +1358,14 @@ function collapseWhitespaceAll(str) {
1274
1358
 
1275
1359
  // Collapse whitespace with options
1276
1360
 
1277
- function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1361
+ /**
1362
+ * @param {string} str
1363
+ * @param {{preserveLineBreaks?: boolean | undefined, conservativeCollapse?: boolean | undefined}} options
1364
+ * @param {boolean} trimLeft
1365
+ * @param {boolean} trimRight
1366
+ * @param {boolean} [collapseAll]
1367
+ */
1368
+ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll = false) {
1278
1369
  let lineBreakBefore = ''; let lineBreakAfter = '';
1279
1370
 
1280
1371
  if (!str) return str;
@@ -1294,7 +1385,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1294
1385
  // (avoids polynomial backtracking with end-anchored lazy quantifiers)
1295
1386
  const WS_CHARS = ' \n\r\t\f';
1296
1387
  let leadEnd = 0;
1297
- while (leadEnd < str.length && WS_CHARS.includes(str[leadEnd])) {
1388
+ while (leadEnd < str.length && WS_CHARS.includes(str.charAt(leadEnd))) {
1298
1389
  leadEnd++;
1299
1390
  }
1300
1391
  if (leadEnd > 0) {
@@ -1305,7 +1396,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1305
1396
  }
1306
1397
  }
1307
1398
  let trailStart = str.length;
1308
- while (trailStart > 0 && WS_CHARS.includes(str[trailStart - 1])) {
1399
+ while (trailStart > 0 && WS_CHARS.includes(str.charAt(trailStart - 1))) {
1309
1400
  trailStart--;
1310
1401
  }
1311
1402
  if (trailStart < str.length) {
@@ -1319,7 +1410,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1319
1410
 
1320
1411
  if (trimLeft) {
1321
1412
  // No-break space is specifically handled inside the replacer function
1322
- str = str.replace(/^[ \n\r\t\f\xA0]+/, function (spaces) {
1413
+ str = str.replace(/^[ \n\r\t\f\xA0]+/, function (/** @type {string} */ spaces) {
1323
1414
  const conservative = !lineBreakBefore && options.conservativeCollapse;
1324
1415
  if (conservative && spaces === '\t') {
1325
1416
  return '\t';
@@ -1332,7 +1423,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1332
1423
  // Find trailing whitespace boundary manually (avoids polynomial backtracking
1333
1424
  // with `/[ \n\r\t\f\xA0]+$/` on strings with long internal whitespace runs)
1334
1425
  let end = str.length;
1335
- while (end > 0 && ' \n\r\t\f\xA0'.includes(str[end - 1])) {
1426
+ while (end > 0 && ' \n\r\t\f\xA0'.includes(str.charAt(end - 1))) {
1336
1427
  end--;
1337
1428
  }
1338
1429
  if (end < str.length) {
@@ -1363,14 +1454,24 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
1363
1454
 
1364
1455
  // Collapse whitespace smartly based on surrounding tags
1365
1456
 
1457
+ /**
1458
+ * @param {string} str
1459
+ * @param {string} prevTag
1460
+ * @param {string} nextTag
1461
+ * @param {Array<{name: string, value?: string}>} prevAttrs
1462
+ * @param {Array<{name: string, value?: string}>} nextAttrs
1463
+ * @param {{preserveLineBreaks?: boolean, conservativeCollapse?: boolean, collapseInlineTagWhitespace?: boolean}} options
1464
+ * @param {Set<string>} inlineElements
1465
+ * @param {Set<string>} inlineTextSet
1466
+ */
1366
1467
  function collapseWhitespaceSmart(str, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet) {
1367
1468
  const prevTagName = prevTag && (prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag);
1368
1469
  const nextTagName = nextTag && (nextTag.charAt(0) === '/' ? nextTag.slice(1) : nextTag);
1369
1470
 
1370
1471
  // Helper: Check if an input element has `type="hidden"`
1371
- const isHiddenInput = (tagName, attrs) => {
1472
+ const isHiddenInput = (/** @type {string} */ tagName, /** @type {Array<{name: string, value?: string}>} */ attrs) => {
1372
1473
  if (tagName !== 'input' || !attrs || !attrs.length) return false;
1373
- const typeAttr = attrs.find(attr => attr.name === 'type');
1474
+ const typeAttr = attrs.find((/** @type {{name: string, value?: string}} */ attr) => attr.name === 'type');
1374
1475
  return typeAttr && typeAttr.value === 'hidden';
1375
1476
  };
1376
1477
 
@@ -1434,7 +1535,7 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, prevAttrs, nextAttrs, op
1434
1535
  }
1435
1536
  }
1436
1537
 
1437
- return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
1538
+ return collapseWhitespace(str, options, Boolean(trimLeft), Boolean(trimRight), Boolean(prevTag && nextTag));
1438
1539
  }
1439
1540
 
1440
1541
  // Collapse/trim whitespace for given tag
@@ -1442,10 +1543,12 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, prevAttrs, nextAttrs, op
1442
1543
  const noCollapseWhitespaceTags = new Set(['script', 'style', 'pre', 'textarea']);
1443
1544
  const noTrimWhitespaceTags = new Set(['pre', 'textarea']);
1444
1545
 
1546
+ /** @param {string} tag */
1445
1547
  function canCollapseWhitespace(tag) {
1446
1548
  return !noCollapseWhitespaceTags.has(tag);
1447
1549
  }
1448
1550
 
1551
+ /** @param {string} tag */
1449
1552
  function canTrimWhitespace(tag) {
1450
1553
  return !noTrimWhitespaceTags.has(tag);
1451
1554
  }
@@ -1606,13 +1709,14 @@ function createUrlMinifier(site) {
1606
1709
  };
1607
1710
  }
1608
1711
 
1609
- // Imports
1610
-
1611
-
1612
1712
  // CSS processing
1613
1713
 
1614
1714
  // Wrap CSS declarations for inline styles and media queries
1615
1715
  // This ensures proper context for CSS minification
1716
+ /**
1717
+ * @param {string} text
1718
+ * @param {string} type
1719
+ */
1616
1720
  function wrapCSS(text, type) {
1617
1721
  switch (type) {
1618
1722
  case 'inline':
@@ -1624,6 +1728,10 @@ function wrapCSS(text, type) {
1624
1728
  }
1625
1729
  }
1626
1730
 
1731
+ /**
1732
+ * @param {string} text
1733
+ * @param {string} type
1734
+ */
1627
1735
  function unwrapCSS(text, type) {
1628
1736
  let matches;
1629
1737
  switch (type) {
@@ -1634,11 +1742,15 @@ function unwrapCSS(text, type) {
1634
1742
  matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
1635
1743
  break;
1636
1744
  }
1637
- return matches ? matches[1] : text;
1745
+ return matches ? matches[1] ?? text : text;
1638
1746
  }
1639
1747
 
1640
1748
  // Script processing
1641
1749
 
1750
+ /**
1751
+ * @param {string} text
1752
+ * @param {{continueOnMinifyError?: boolean, log?: Function}} options
1753
+ */
1642
1754
  function minifyJson(text, options) {
1643
1755
  try {
1644
1756
  return JSON.stringify(JSON.parse(text));
@@ -1652,11 +1764,11 @@ function minifyJson(text, options) {
1652
1764
  }
1653
1765
  }
1654
1766
 
1767
+ /** @param {Array<{name: string, value?: string}>} attrs */
1655
1768
  function hasJsonScriptType(attrs) {
1656
- for (let i = 0, len = attrs.length; i < len; i++) {
1657
- const attrName = attrs[i].name.toLowerCase();
1658
- if (attrName === 'type') {
1659
- const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
1769
+ for (const attr of attrs) {
1770
+ if (attr.name.toLowerCase() === 'type') {
1771
+ const attrValue = trimWhitespace((attr.value || '').split(/;/, 2)[0] ?? '').toLowerCase();
1660
1772
  if (jsonScriptTypes.has(attrValue)) {
1661
1773
  return true;
1662
1774
  }
@@ -1665,18 +1777,24 @@ function hasJsonScriptType(attrs) {
1665
1777
  return false;
1666
1778
  }
1667
1779
 
1780
+ /**
1781
+ * @param {string} text
1782
+ * @param {{continueOnMinifyError?: boolean, log?: Function, processScripts?: string[]}} options
1783
+ * @param {Array<{name: string, value?: string | undefined}>} currentAttrs
1784
+ * @param {Function} minifyHTML
1785
+ */
1668
1786
  async function processScript(text, options, currentAttrs, minifyHTML) {
1669
- for (let i = 0, len = currentAttrs.length; i < len; i++) {
1670
- const attrName = currentAttrs[i].name.toLowerCase();
1787
+ for (const attr of currentAttrs) {
1788
+ const attrName = attr.name.toLowerCase();
1671
1789
  if (attrName === 'type') {
1672
- const rawValue = currentAttrs[i].value;
1673
- const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
1790
+ const rawValue = attr.value;
1791
+ const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0] ?? '').toLowerCase();
1674
1792
  // Minify JSON script types automatically
1675
1793
  if (jsonScriptTypes.has(normalizedValue)) {
1676
1794
  return minifyJson(text, options);
1677
1795
  }
1678
1796
  // Process custom script types if specified
1679
- if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
1797
+ if (options.processScripts && rawValue && options.processScripts.indexOf(rawValue) > -1) {
1680
1798
  return await minifyHTML(text, options);
1681
1799
  }
1682
1800
  }
@@ -1700,11 +1818,17 @@ const optionDefaults = {
1700
1818
  includeAutoGeneratedTags: false
1701
1819
  };
1702
1820
 
1703
- // Imports
1821
+ // Type definitions
1822
+
1823
+ /**
1824
+ * @typedef {Record<string, any>} MinifierOptions
1825
+ */
1704
1826
 
1827
+ // @@ Extract a `ProcessedOptions` typedef (with `name`, `log`, `canCollapseWhitespace`, `canTrimWhitespace`,`minifyCSS`, `minifyJS`, `minifyURLs` typed as required, non-optional) to replace the current `Record<string, any>` escape hatch and eliminate the `/** @type {Function} */` casts in htmlminifier.js
1705
1828
 
1706
1829
  // Helper functions
1707
1830
 
1831
+ /** @param {MinifierOptions} options */
1708
1832
  function shouldMinifyInnerHTML(options) {
1709
1833
  return Boolean(
1710
1834
  options.collapseWhitespace ||
@@ -1720,18 +1844,12 @@ function shouldMinifyInnerHTML(options) {
1720
1844
  // Main options processor
1721
1845
 
1722
1846
  /**
1723
- * @param {Partial<MinifierOptions>} inputOptions - User-provided options
1724
- * @param {Object} deps - Dependencies from htmlminifier.js
1725
- * @param {Function} deps.getLightningCSS - Function to lazily load Lightning CSS
1726
- * @param {Function} deps.getTerser - Function to lazily load Terser
1727
- * @param {Function} deps.getSwc - Function to lazily load @swc/core
1728
- * @param {Function} deps.getSvgo - Function to lazily load SVGO
1729
- * @param {LRU} deps.cssMinifyCache - CSS minification cache
1730
- * @param {LRU} deps.jsMinifyCache - JS minification cache
1731
- * @param {LRU} deps.svgMinifyCache - SVG minification cache
1847
+ * @param {MinifierOptions} inputOptions - User-provided options
1848
+ * @param {{getLightningCSS?: Function | undefined, getTerser?: Function | undefined, getSwc?: Function | undefined, getSvgo?: Function | undefined, cssMinifyCache?: LRU | undefined, jsMinifyCache?: LRU | undefined, svgMinifyCache?: LRU | undefined}} [deps] - Dependencies from htmlminifier.js
1732
1849
  * @returns {MinifierOptions} Normalized options with defaults applied
1733
1850
  */
1734
1851
  const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getSvgo, cssMinifyCache, jsMinifyCache, svgMinifyCache } = {}) => {
1852
+ /** @type {MinifierOptions} */
1735
1853
  const options = {
1736
1854
  name: lowercase,
1737
1855
  canCollapseWhitespace,
@@ -1744,13 +1862,13 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1744
1862
  minifySVG: null
1745
1863
  };
1746
1864
 
1747
- const parseRegExpArray = (arr) => {
1748
- return Array.isArray(arr) ? arr.map(parseRegExp) : arr;
1865
+ const parseRegExpArray = (/** @type {unknown} */ arr) => {
1866
+ return Array.isArray(arr) ? arr.map(parseRegExp) : [];
1749
1867
  };
1750
1868
 
1751
1869
  // Helper for nested arrays (e.g., `customAttrSurround: [[start, end], …]`)
1752
- const parseNestedRegExpArray = (arr) => {
1753
- if (!Array.isArray(arr)) return arr;
1870
+ const parseNestedRegExpArray = (/** @type {unknown} */ arr) => {
1871
+ if (!Array.isArray(arr)) return [];
1754
1872
  return arr.map(item => {
1755
1873
  // If item is an array (a pair), recursively convert each element
1756
1874
  if (Array.isArray(item)) {
@@ -1791,13 +1909,16 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1791
1909
  options.log = option;
1792
1910
  }
1793
1911
  } else if (key === 'minifyCSS' && typeof option !== 'function') {
1794
- if (!option) {
1912
+ if (!option || !getLightningCSS || !cssMinifyCache) {
1795
1913
  return;
1796
1914
  }
1797
1915
 
1798
1916
  const lightningCssOptions = typeof option === 'object' ? option : {};
1917
+ // Capture to preserve TypeScript narrowing across the async closure boundary below
1918
+ const cssLoader = getLightningCSS;
1919
+ const cssCache = cssMinifyCache;
1799
1920
 
1800
- options.minifyCSS = async function (text, type) {
1921
+ options.minifyCSS = async function (/** @type {string} */ text, /** @type {string} */ type) {
1801
1922
  // Fast path: Nothing to minify
1802
1923
  if (!text || !text.trim()) {
1803
1924
  return text;
@@ -1809,7 +1930,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1809
1930
  text = await replaceAsync(
1810
1931
  text,
1811
1932
  /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
1812
- async function (match, prefix, dq, sq, unq, suffix) {
1933
+ async function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string | undefined} */ dq, /** @type {string | undefined} */ sq, /** @type {string | undefined} */ unq, /** @type {string} */ suffix) {
1813
1934
  const quote = dq != null ? '"' : (sq != null ? "'" : '');
1814
1935
  const url = dq ?? sq ?? unq ?? '';
1815
1936
  try {
@@ -1834,8 +1955,8 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1834
1955
  : (inputCSS + '|' + type + '|' + cssSig);
1835
1956
 
1836
1957
  try {
1837
- const cached = cssMinifyCache.get(cssKey);
1838
- if (cached) {
1958
+ const cached = cssCache.get(cssKey);
1959
+ if (cached !== undefined) {
1839
1960
  // Support both resolved values and in-flight promises
1840
1961
  return await cached;
1841
1962
  }
@@ -1843,7 +1964,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1843
1964
  // In-flight promise caching: Prevent duplicate concurrent minifications
1844
1965
  // of the same CSS content (same pattern as JS minification)
1845
1966
  const inFlight = (async () => {
1846
- const transformCSS = await getLightningCSS();
1967
+ const transformCSS = await cssLoader();
1847
1968
  // Note: `Buffer.from()` is required—Lightning CSS API expects Uint8Array
1848
1969
  const result = transformCSS({
1849
1970
  filename: 'input.css',
@@ -1870,12 +1991,12 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1870
1991
  return (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1871
1992
  })();
1872
1993
 
1873
- cssMinifyCache.set(cssKey, inFlight);
1994
+ cssCache.set(cssKey, inFlight);
1874
1995
  const resolved = await inFlight;
1875
- cssMinifyCache.set(cssKey, resolved);
1996
+ cssCache.set(cssKey, resolved);
1876
1997
  return resolved;
1877
1998
  } catch (err) {
1878
- cssMinifyCache.delete(cssKey);
1999
+ cssCache.delete(cssKey);
1879
2000
  if (!options.continueOnMinifyError) {
1880
2001
  throw err;
1881
2002
  }
@@ -1884,10 +2005,15 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1884
2005
  }
1885
2006
  };
1886
2007
  } else if (key === 'minifyJS' && typeof option !== 'function') {
1887
- if (!option) {
2008
+ if (!option || !getTerser || !getSwc || !jsMinifyCache) {
1888
2009
  return;
1889
2010
  }
1890
2011
 
2012
+ // Capture to preserve TypeScript narrowing across the async closure boundary below
2013
+ const loadTerser = getTerser;
2014
+ const loadSwc = getSwc;
2015
+ const jsCache = jsMinifyCache;
2016
+
1891
2017
  // Parse configuration
1892
2018
  const config = typeof option === 'object' ? option : {};
1893
2019
  const engine = (config.engine || 'terser').toLowerCase();
@@ -1922,7 +2048,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1922
2048
  cont: !!options.continueOnMinifyError
1923
2049
  });
1924
2050
 
1925
- options.minifyJS = async function (text, inline, isModule) {
2051
+ options.minifyJS = async function (/** @type {string} */ text, /** @type {boolean} */ inline, /** @type {boolean} */ isModule) {
1926
2052
  const start = text.match(/^\s*<!--.*/);
1927
2053
  const code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
1928
2054
 
@@ -1944,8 +2070,8 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1944
2070
  jsKey = (code.length > 2048 ? (hashContent(code) + '|') : (code + '|'))
1945
2071
  + (inline ? '1' : '0') + '|' + (isModule ? 'm' : '') + '|' + useEngine + '|' + optsSig;
1946
2072
 
1947
- const cached = jsMinifyCache.get(jsKey);
1948
- if (cached) {
2073
+ const cached = jsCache.get(jsKey);
2074
+ if (cached !== undefined) {
1949
2075
  return await cached;
1950
2076
  }
1951
2077
 
@@ -1961,11 +2087,11 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1961
2087
  },
1962
2088
  ...(isModule ? { module: true } : {}) // Overrides user options: module detection takes precedence for `<script type=module>`
1963
2089
  };
1964
- const terser = await getTerser();
2090
+ const terser = await loadTerser();
1965
2091
  const result = await terser(code, terserCallOptions);
1966
2092
  return result.code.replace(RE_TRAILING_SEMICOLON, '');
1967
2093
  } else if (useEngine === 'swc') {
1968
- const swc = await getSwc();
2094
+ const swc = await loadSwc();
1969
2095
  // `swc.minify()` takes compress and mangle directly as options
1970
2096
  const result = await swc.minify(code, {
1971
2097
  compress: true,
@@ -1978,12 +2104,12 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1978
2104
  throw new Error(`Unknown JS minifier engine: ${useEngine}`);
1979
2105
  })();
1980
2106
 
1981
- jsMinifyCache.set(jsKey, inFlight);
2107
+ jsCache.set(jsKey, inFlight);
1982
2108
  const resolved = await inFlight;
1983
- jsMinifyCache.set(jsKey, resolved);
2109
+ jsCache.set(jsKey, resolved);
1984
2110
  return resolved;
1985
2111
  } catch (err) {
1986
- if (jsKey) jsMinifyCache.delete(jsKey);
2112
+ if (jsKey) jsCache.delete(jsKey);
1987
2113
  if (!options.continueOnMinifyError) {
1988
2114
  throw err;
1989
2115
  }
@@ -2009,7 +2135,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
2009
2135
  // Create instance-specific cache (results depend on site configuration)
2010
2136
  const instanceCache = new LRU(500);
2011
2137
 
2012
- options.minifyURLs = function (text) {
2138
+ options.minifyURLs = function (/** @type {string} */ text) {
2013
2139
  // Fast-path: Skip if text doesn’t look like a URL that needs processing
2014
2140
  // Only process if contains URL-like characters (`/`, `:`, `#`, `?`) or spaces that need encoding
2015
2141
  if (!/[/:?#\s]/.test(text)) {
@@ -2036,10 +2162,14 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
2036
2162
  }
2037
2163
  };
2038
2164
  } else if (key === 'minifySVG' && typeof option !== 'function') {
2039
- if (!option) {
2165
+ if (!option || !getSvgo || !svgMinifyCache) {
2040
2166
  return;
2041
2167
  }
2042
2168
 
2169
+ // Capture to preserve TypeScript narrowing across the async closure boundary below
2170
+ const loadSvgo = getSvgo;
2171
+ const svgCache = svgMinifyCache;
2172
+
2043
2173
  const svgoOptions = typeof option === 'object' ? option : {};
2044
2174
 
2045
2175
  // Pre-compute option signature for cache keys
@@ -2048,7 +2178,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
2048
2178
  cont: !!options.continueOnMinifyError
2049
2179
  });
2050
2180
 
2051
- options.minifySVG = async function (svgContent) {
2181
+ options.minifySVG = async function (/** @type {string} */ svgContent) {
2052
2182
  if (!svgContent || !svgContent.trim()) {
2053
2183
  return svgContent;
2054
2184
  }
@@ -2059,23 +2189,23 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
2059
2189
  : (svgContent + '|' + svgSig);
2060
2190
 
2061
2191
  try {
2062
- const cached = svgMinifyCache.get(svgKey);
2063
- if (cached) {
2192
+ const cached = svgCache.get(svgKey);
2193
+ if (cached !== undefined) {
2064
2194
  return await cached;
2065
2195
  }
2066
2196
 
2067
2197
  const inFlight = (async () => {
2068
- const optimize = await getSvgo();
2198
+ const optimize = await loadSvgo();
2069
2199
  const result = optimize(svgContent, svgoOptions);
2070
2200
  return result.data;
2071
2201
  })();
2072
2202
 
2073
- svgMinifyCache.set(svgKey, inFlight);
2203
+ svgCache.set(svgKey, inFlight);
2074
2204
  const resolved = await inFlight;
2075
- svgMinifyCache.set(svgKey, resolved);
2205
+ svgCache.set(svgKey, resolved);
2076
2206
  return resolved;
2077
2207
  } catch (err) {
2078
- svgMinifyCache.delete(svgKey);
2208
+ svgCache.delete(svgKey);
2079
2209
  if (!options.continueOnMinifyError) {
2080
2210
  throw err;
2081
2211
  }
@@ -2099,11 +2229,16 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
2099
2229
  return options;
2100
2230
  };
2101
2231
 
2102
- // Imports
2232
+ // Type definitions
2103
2233
 
2234
+ /**
2235
+ * @typedef {{ name: string, value?: string | undefined, quote?: string, customAssign?: string, customOpen?: string, customClose?: string }} HTMLAttribute
2236
+ * Internal counterpart of the public typedef in `htmlminifier.js`—keep in sync.
2237
+ */
2104
2238
 
2105
2239
  // Lazy-load entities (used for `decodeEntities` and event-handler attribute decode before `minifyJS`)
2106
2240
 
2241
+ /** @type {Promise<Function> | undefined} */
2107
2242
  let decodeHTMLStrictPromise;
2108
2243
  async function getDecodeHTMLStrict() {
2109
2244
  if (!decodeHTMLStrictPromise) {
@@ -2114,20 +2249,30 @@ async function getDecodeHTMLStrict() {
2114
2249
 
2115
2250
  // Validators
2116
2251
 
2252
+ /**
2253
+ * @param {string} text
2254
+ * @param {{ignoreCustomComments: RegExp[]}} options
2255
+ */
2117
2256
  function isIgnoredComment(text, options) {
2118
- for (let i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
2119
- if (options.ignoreCustomComments[i].test(text)) {
2257
+ // @@ Optimize: `Array.isArray(options.ignoreCustomComments)` runs on every comment node; it could be eliminated once `parseRegExpArray` is tightened to coerce non-arrays to `[]` at setup time
2258
+ if (!Array.isArray(options.ignoreCustomComments)) return false;
2259
+ for (const pattern of options.ignoreCustomComments) {
2260
+ if (pattern.test(text)) {
2120
2261
  return true;
2121
2262
  }
2122
2263
  }
2123
2264
  return false;
2124
2265
  }
2125
2266
 
2267
+ /**
2268
+ * @param {string} attrName
2269
+ * @param {{customEventAttributes?: RegExp[]}} options
2270
+ */
2126
2271
  function isEventAttribute(attrName, options) {
2127
2272
  const patterns = options.customEventAttributes;
2128
2273
  if (patterns) {
2129
- for (let i = patterns.length; i--;) {
2130
- if (patterns[i].test(attrName)) {
2274
+ for (const pattern of patterns) {
2275
+ if (pattern.test(attrName)) {
2131
2276
  return true;
2132
2277
  }
2133
2278
  }
@@ -2136,14 +2281,19 @@ function isEventAttribute(attrName, options) {
2136
2281
  return RE_EVENT_ATTR_DEFAULT.test(attrName);
2137
2282
  }
2138
2283
 
2284
+ /** @param {string} value */
2139
2285
  function canRemoveAttributeQuotes(value) {
2140
2286
  // https://mathiasbynens.be/notes/unquoted-attribute-values
2141
2287
  return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
2142
2288
  }
2143
2289
 
2290
+ /**
2291
+ * @param {HTMLAttribute[]} attributes
2292
+ * @param {string} attribute
2293
+ */
2144
2294
  function attributesInclude(attributes, attribute) {
2145
- for (let i = attributes.length; i--;) {
2146
- if (attributes[i].name.toLowerCase() === attribute) {
2295
+ for (const attr of attributes) {
2296
+ if (attr.name.toLowerCase() === attribute) {
2147
2297
  return true;
2148
2298
  }
2149
2299
  }
@@ -2154,9 +2304,9 @@ function attributesInclude(attributes, attribute) {
2154
2304
  * Remove duplicate attributes from an attribute list.
2155
2305
  * Per HTML spec, when an attribute appears multiple times, the first occurrence wins.
2156
2306
  * Duplicate attributes result in invalid HTML, so only the first is kept.
2157
- * @param {Array} attrs - Array of attribute objects with `name` property
2307
+ * @param {HTMLAttribute[]} attrs - Array of attribute objects with `name` property
2158
2308
  * @param {boolean} caseSensitive - Whether to compare names case-sensitively (for XML/SVG)
2159
- * @returns {Array} Deduplicated attribute array (modifies in place and returns)
2309
+ * @returns {HTMLAttribute[]} Deduplicated attribute array (modifies in place and returns)
2160
2310
  */
2161
2311
  function deduplicateAttributes(attrs, caseSensitive) {
2162
2312
  if (attrs.length < 2) {
@@ -2166,8 +2316,7 @@ function deduplicateAttributes(attrs, caseSensitive) {
2166
2316
  const seen = new Set();
2167
2317
  let writeIndex = 0;
2168
2318
 
2169
- for (let i = 0; i < attrs.length; i++) {
2170
- const attr = attrs[i];
2319
+ for (const attr of attrs) {
2171
2320
  const key = caseSensitive ? attr.name : attr.name.toLowerCase();
2172
2321
 
2173
2322
  if (!seen.has(key)) {
@@ -2180,6 +2329,12 @@ function deduplicateAttributes(attrs, caseSensitive) {
2180
2329
  return attrs;
2181
2330
  }
2182
2331
 
2332
+ /**
2333
+ * @param {string} tag
2334
+ * @param {string} attrName
2335
+ * @param {string} attrValue
2336
+ * @param {HTMLAttribute[]} attrs
2337
+ */
2183
2338
  function isAttributeRedundant(tag, attrName, attrValue, attrs) {
2184
2339
  // Fast-path: Check if this element–attribute combination can possibly be redundant
2185
2340
  // before doing expensive string operations
@@ -2213,32 +2368,35 @@ function isAttributeRedundant(tag, attrName, attrValue, attrs) {
2213
2368
  }
2214
2369
 
2215
2370
  // Check general defaults
2216
- if (hasGeneralDefault && generalDefaults[attrName] === attrValue) {
2371
+ if (hasGeneralDefault && /** @type {Record<string, string>} */ (generalDefaults)[attrName] === attrValue) {
2217
2372
  return true;
2218
2373
  }
2219
2374
 
2220
2375
  // Check tag-specific defaults
2221
- return tagHasDefaults && tagDefaults[tag][attrName] === attrValue;
2376
+ return tagHasDefaults && /** @type {Record<string, string>} */ (/** @type {Record<string, unknown>} */ (tagDefaults)[tag])[attrName] === attrValue;
2222
2377
  }
2223
2378
 
2224
2379
  function isScriptTypeAttribute(attrValue = '') {
2225
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
2380
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0] ?? '').toLowerCase();
2226
2381
  return attrValue === '' || executableScriptsMimetypes.has(attrValue);
2227
2382
  }
2228
2383
 
2229
2384
  function keepScriptTypeAttribute(attrValue = '') {
2230
- attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
2385
+ attrValue = trimWhitespace(attrValue.split(/;/, 2)[0] ?? '').toLowerCase();
2231
2386
  return keepScriptsMimetypes.has(attrValue);
2232
2387
  }
2233
2388
 
2389
+ /**
2390
+ * @param {string} tag
2391
+ * @param {HTMLAttribute[]} attrs
2392
+ */
2234
2393
  function isExecutableScript(tag, attrs) {
2235
2394
  if (tag !== 'script') {
2236
2395
  return false;
2237
2396
  }
2238
- for (let i = 0, len = attrs.length; i < len; i++) {
2239
- const attrName = attrs[i].name.toLowerCase();
2240
- if (attrName === 'type') {
2241
- return isScriptTypeAttribute(attrs[i].value);
2397
+ for (const attr of attrs) {
2398
+ if (attr.name.toLowerCase() === 'type') {
2399
+ return isScriptTypeAttribute(attr.value);
2242
2400
  }
2243
2401
  }
2244
2402
  return true;
@@ -2249,23 +2407,30 @@ function isStyleLinkTypeAttribute(attrValue = '') {
2249
2407
  return attrValue === '' || attrValue === 'text/css';
2250
2408
  }
2251
2409
 
2410
+ /**
2411
+ * @param {string} tag
2412
+ * @param {HTMLAttribute[]} attrs
2413
+ */
2252
2414
  function isStyleElement(tag, attrs) {
2253
2415
  if (tag !== 'style') {
2254
2416
  return false;
2255
2417
  }
2256
- for (let i = 0, len = attrs.length; i < len; i++) {
2257
- const attrName = attrs[i].name.toLowerCase();
2258
- if (attrName === 'type') {
2259
- return isStyleLinkTypeAttribute(attrs[i].value);
2418
+ for (const attr of attrs) {
2419
+ if (attr.name.toLowerCase() === 'type') {
2420
+ return isStyleLinkTypeAttribute(attr.value);
2260
2421
  }
2261
2422
  }
2262
2423
  return true;
2263
2424
  }
2264
2425
 
2426
+ /**
2427
+ * @param {string} attrName
2428
+ * @param {string} attrValue
2429
+ */
2265
2430
  function isBooleanAttribute(attrName, attrValue) {
2266
2431
  return isSimpleBoolean.has(attrName) ||
2267
2432
  (attrName === 'draggable' && !isBooleanValue.has(attrValue)) ||
2268
- (collapsibleValues.has(attrName) && collapsibleValues.get(attrName).has(attrValue));
2433
+ (collapsibleValues.has(attrName) && collapsibleValues.get(attrName)?.has(attrValue));
2269
2434
  }
2270
2435
 
2271
2436
  const uriTypeAttributes = new Map([
@@ -2285,6 +2450,10 @@ const uriTypeAttributes = new Map([
2285
2450
  ['script', new Set(['src', 'for'])]
2286
2451
  ]);
2287
2452
 
2453
+ /**
2454
+ * @param {string} attrName
2455
+ * @param {string} tag
2456
+ */
2288
2457
  function isUriTypeAttribute(attrName, tag) {
2289
2458
  const set = uriTypeAttributes.get(tag);
2290
2459
  return set ? set.has(attrName) : false;
@@ -2304,55 +2473,87 @@ const numberTypeAttributes = new Map([
2304
2473
  ['td', new Set(['rowspan', 'colspan'])]
2305
2474
  ]);
2306
2475
 
2476
+ /**
2477
+ * @param {string} attrName
2478
+ * @param {string} tag
2479
+ */
2307
2480
  function isNumberTypeAttribute(attrName, tag) {
2308
2481
  const set = numberTypeAttributes.get(tag);
2309
2482
  return set ? set.has(attrName) : false;
2310
2483
  }
2311
2484
 
2485
+ /**
2486
+ * @param {string} tag
2487
+ * @param {HTMLAttribute[]} attrs
2488
+ * @param {string} value
2489
+ */
2312
2490
  function isLinkType(tag, attrs, value) {
2313
2491
  if (tag !== 'link') return false;
2314
2492
  const needle = String(value).toLowerCase();
2315
- for (let i = 0; i < attrs.length; i++) {
2316
- if (attrs[i].name.toLowerCase() === 'rel') {
2317
- const tokens = String(attrs[i].value).toLowerCase().split(/\s+/);
2493
+ for (const attr of attrs) {
2494
+ if (attr.name.toLowerCase() === 'rel') {
2495
+ const tokens = String(attr.value).toLowerCase().split(/\s+/);
2318
2496
  if (tokens.includes(needle)) return true;
2319
2497
  }
2320
2498
  }
2321
2499
  return false;
2322
2500
  }
2323
2501
 
2502
+ /**
2503
+ * @param {string} tag
2504
+ * @param {HTMLAttribute[]} attrs
2505
+ * @param {string} attrName
2506
+ */
2324
2507
  function isMediaQuery(tag, attrs, attrName) {
2325
2508
  return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleElement(tag, attrs));
2326
2509
  }
2327
2510
 
2511
+ /**
2512
+ * @param {string} attrName
2513
+ * @param {string} tag
2514
+ */
2328
2515
  function isSrcset(attrName, tag) {
2329
2516
  return attrName === 'srcset' && srcsetElements.has(tag);
2330
2517
  }
2331
2518
 
2519
+ /**
2520
+ * @param {string} tag
2521
+ * @param {HTMLAttribute[]} attrs
2522
+ */
2332
2523
  function isMetaViewport(tag, attrs) {
2333
2524
  if (tag !== 'meta') {
2334
2525
  return false;
2335
2526
  }
2336
- for (let i = 0, len = attrs.length; i < len; i++) {
2337
- if (attrs[i].name.toLowerCase() === 'name' && attrs[i].value.toLowerCase() === 'viewport') {
2527
+ for (const attr of attrs) {
2528
+ if (attr.name.toLowerCase() === 'name' && (attr.value || '').toLowerCase() === 'viewport') {
2338
2529
  return true;
2339
2530
  }
2340
2531
  }
2341
2532
  return false;
2342
2533
  }
2343
2534
 
2535
+ /**
2536
+ * @param {string} tag
2537
+ * @param {HTMLAttribute[]} attrs
2538
+ */
2344
2539
  function isContentSecurityPolicy(tag, attrs) {
2345
2540
  if (tag !== 'meta') {
2346
2541
  return false;
2347
2542
  }
2348
- for (let i = 0, len = attrs.length; i < len; i++) {
2349
- if (attrs[i].name.toLowerCase() === 'http-equiv' && attrs[i].value.toLowerCase() === 'content-security-policy') {
2543
+ for (const attr of attrs) {
2544
+ if (attr.name.toLowerCase() === 'http-equiv' && (attr.value || '').toLowerCase() === 'content-security-policy') {
2350
2545
  return true;
2351
2546
  }
2352
2547
  }
2353
2548
  return false;
2354
2549
  }
2355
2550
 
2551
+ /**
2552
+ * @param {string} tag
2553
+ * @param {string} attrName
2554
+ * @param {string | undefined} attrValue
2555
+ * @param {{removeEmptyAttributes?: boolean | Function}} options
2556
+ */
2356
2557
  function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
2357
2558
  const isValueEmpty = !attrValue || attrValue.trim() === '';
2358
2559
  if (!isValueEmpty) {
@@ -2364,9 +2565,13 @@ function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
2364
2565
  return (tag === 'input' && attrName === 'value') || reEmptyAttribute.test(attrName);
2365
2566
  }
2366
2567
 
2568
+ /**
2569
+ * @param {string} name
2570
+ * @param {HTMLAttribute[]} attrs
2571
+ */
2367
2572
  function hasAttrName(name, attrs) {
2368
- for (let i = attrs.length - 1; i >= 0; i--) {
2369
- if (attrs[i].name === name) {
2573
+ for (const attr of attrs) {
2574
+ if (attr.name === name) {
2370
2575
  return true;
2371
2576
  }
2372
2577
  }
@@ -2381,6 +2586,14 @@ const valueWhitespaceExemptElements = new Set(['button', 'data', 'input', 'optio
2381
2586
 
2382
2587
  // Returns the cleaned attribute value directly (sync) or as a Promise (async);
2383
2588
  // callers must handle both cases—use `isThenable()` to distinguish
2589
+ /**
2590
+ * @param {string} tag
2591
+ * @param {string} attrName
2592
+ * @param {string} attrValue
2593
+ * @param {Record<string, any>} options
2594
+ * @param {HTMLAttribute[]} attrs
2595
+ * @param {Function} minifyHTMLSelf
2596
+ */
2384
2597
  function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
2385
2598
  const isEventAttr = isEventAttribute(attrName, options);
2386
2599
 
@@ -2404,9 +2617,9 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
2404
2617
  return getDecodeHTMLStrict().then(decode => {
2405
2618
  const decoded = decode(attrValue);
2406
2619
  const result = options.minifyJS(decoded, true);
2407
- const reEncode = v => (v && v.indexOf('&') !== -1) ? v.replace(RE_AMP_ENTITY, '&amp;$1') : v;
2620
+ const reEncode = (/** @type {string} */ v) => (v && v.indexOf('&') !== -1) ? v.replace(RE_AMP_ENTITY, '&amp;$1') : v;
2408
2621
  if (isThenable(result)) {
2409
- return result.then(reEncode, err => {
2622
+ return result.then(reEncode, (/** @type {Error} */ err) => {
2410
2623
  if (!options.continueOnMinifyError) throw err;
2411
2624
  options.log && options.log(err);
2412
2625
  return attrValue;
@@ -2417,7 +2630,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
2417
2630
  }
2418
2631
  const result = options.minifyJS(attrValue, true);
2419
2632
  if (isThenable(result)) {
2420
- return result.catch(err => {
2633
+ return result.catch((/** @type {Error} */ err) => {
2421
2634
  if (!options.continueOnMinifyError) throw err;
2422
2635
  options.log && options.log(err);
2423
2636
  return attrValue;
@@ -2444,8 +2657,8 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
2444
2657
  const result = options.minifyURLs(attrValue);
2445
2658
  if (isThenable(result)) {
2446
2659
  return result
2447
- .then(out => typeof out === 'string' ? out : attrValue)
2448
- .catch(err => {
2660
+ .then((/** @type {unknown} */ out) => typeof out === 'string' ? out : attrValue)
2661
+ .catch((/** @type {Error} */ err) => {
2449
2662
  if (!options.continueOnMinifyError) throw err;
2450
2663
  options.log && options.log(err);
2451
2664
  return attrValue;
@@ -2468,13 +2681,13 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
2468
2681
  const cssResult = options.minifyCSS(attrValue, 'inline');
2469
2682
  if (isThenable(cssResult)) {
2470
2683
  return cssResult
2471
- .then(minified => {
2684
+ .then((/** @type {string} */ minified) => {
2472
2685
  // After minification, check if CSS consists entirely of invalid properties (no values)
2473
2686
  // I.e., `color:` or `margin:;padding:` should be treated as empty
2474
2687
  if (minified && /^(?:[a-z-]+:[;\s]*)+$/i.test(minified)) return '';
2475
2688
  return minified;
2476
2689
  })
2477
- .catch(err => {
2690
+ .catch((/** @type {Error} */ err) => {
2478
2691
  if (!options.continueOnMinifyError) throw err;
2479
2692
  options.log && options.log(err);
2480
2693
  return originalAttrValue;
@@ -2496,8 +2709,9 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
2496
2709
  const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
2497
2710
  if (match) {
2498
2711
  url = url.slice(0, -match[0].length);
2499
- const num = +match[1].slice(0, -1);
2500
- const suffix = match[1].slice(-1);
2712
+ const group = match[1] ?? '';
2713
+ const num = +group.slice(0, -1);
2714
+ const suffix = group.slice(-1);
2501
2715
  if (num !== 1 || suffix !== 'x') {
2502
2716
  descriptor = ' ' + num + suffix;
2503
2717
  }
@@ -2505,8 +2719,8 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
2505
2719
  const out = options.minifyURLs(url);
2506
2720
  if (isThenable(out)) {
2507
2721
  return out
2508
- .then(result => (typeof result === 'string' ? result : url) + descriptor)
2509
- .catch(err => {
2722
+ .then((/** @type {unknown} */ result) => (typeof result === 'string' ? result : url) + descriptor)
2723
+ .catch((/** @type {Error} */ err) => {
2510
2724
  if (!options.continueOnMinifyError) throw err;
2511
2725
  options.log && options.log(err);
2512
2726
  return url + descriptor;
@@ -2551,7 +2765,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
2551
2765
  const originalAttrValue = attrValue;
2552
2766
  const cssResult = options.minifyCSS(attrValue, 'media');
2553
2767
  if (isThenable(cssResult)) {
2554
- return cssResult.catch(err => {
2768
+ return cssResult.catch((/** @type {Error} */ err) => {
2555
2769
  if (!options.continueOnMinifyError) throw err;
2556
2770
  options.log && options.log(err);
2557
2771
  return originalAttrValue;
@@ -2575,7 +2789,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
2575
2789
  /**
2576
2790
  * Choose appropriate quote character for an attribute value
2577
2791
  * @param {string} attrValue - The attribute value
2578
- * @param {Object} options - Minifier options
2792
+ * @param {{quoteCharacter?: string}} options - Minifier options
2579
2793
  * @returns {string} The chosen quote character (`"` or `'`)
2580
2794
  */
2581
2795
  function chooseAttributeQuote(attrValue, options) {
@@ -2594,6 +2808,13 @@ function chooseAttributeQuote(attrValue, options) {
2594
2808
 
2595
2809
  // Returns the normalized attribute object directly (sync) or as a Promise (async);
2596
2810
  // callers must handle both cases—use `isThenable()` to distinguish
2811
+ /**
2812
+ * @param {HTMLAttribute} attr
2813
+ * @param {HTMLAttribute[]} attrs
2814
+ * @param {string} tag
2815
+ * @param {Record<string, any>} options
2816
+ * @param {Function} minifyHTML
2817
+ */
2597
2818
  function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2598
2819
  const attrName = options.name(attr.name);
2599
2820
  let attrValue = attr.value;
@@ -2609,9 +2830,18 @@ function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2609
2830
  }
2610
2831
 
2611
2832
  // Internal: Handles attribute normalization after entity decoding (if any)
2833
+ /**
2834
+ * @param {string} attrName
2835
+ * @param {string | undefined} attrValue
2836
+ * @param {HTMLAttribute} attr
2837
+ * @param {HTMLAttribute[]} attrs
2838
+ * @param {string} tag
2839
+ * @param {Record<string, any>} options
2840
+ * @param {Function} minifyHTML
2841
+ */
2612
2842
  function normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, minifyHTML) {
2613
2843
  if ((options.removeRedundantAttributes &&
2614
- isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
2844
+ isAttributeRedundant(tag, attrName, attrValue ?? '', attrs)) ||
2615
2845
  (options.removeScriptTypeAttributes && tag === 'script' &&
2616
2846
  attrName === 'type' && isScriptTypeAttribute(attrValue) && !keepScriptTypeAttribute(attrValue)) ||
2617
2847
  (options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
@@ -2622,7 +2852,7 @@ function normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, m
2622
2852
  if (attrValue) {
2623
2853
  const cleaned = cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
2624
2854
  if (isThenable(cleaned)) {
2625
- return cleaned.then(v => normalizeAttrFinish(attrName, v, attr, tag, options));
2855
+ return cleaned.then((/** @type {string | undefined} */ v) => normalizeAttrFinish(attrName, v, attr, tag, options));
2626
2856
  }
2627
2857
  return normalizeAttrFinish(attrName, cleaned, attr, tag, options);
2628
2858
  }
@@ -2631,6 +2861,13 @@ function normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, m
2631
2861
  }
2632
2862
 
2633
2863
  // Internal: Final checks and result assembly after value cleaning
2864
+ /**
2865
+ * @param {string} attrName
2866
+ * @param {string | undefined} attrValue
2867
+ * @param {HTMLAttribute} attr
2868
+ * @param {string} tag
2869
+ * @param {Record<string, any>} options
2870
+ */
2634
2871
  function normalizeAttrFinish(attrName, attrValue, attr, tag, options) {
2635
2872
  if (options.removeEmptyAttributes &&
2636
2873
  canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
@@ -2648,6 +2885,13 @@ function normalizeAttrFinish(attrName, attrValue, attr, tag, options) {
2648
2885
  };
2649
2886
  }
2650
2887
 
2888
+ /**
2889
+ * @param {{name: string, value?: string, attr: HTMLAttribute}} normalized
2890
+ * @param {string | boolean | undefined} hasUnarySlash
2891
+ * @param {Record<string, any>} options
2892
+ * @param {boolean} isLast
2893
+ * @param {string | undefined} uidAttr
2894
+ */
2651
2895
  function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2652
2896
  const attrName = normalized.name;
2653
2897
  let attrValue = normalized.value;
@@ -2659,7 +2903,7 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2659
2903
  // Determine if need to add/keep quotes
2660
2904
  const shouldAddQuotes = typeof attrValue !== 'undefined' && (
2661
2905
  // If `removeAttributeQuotes` is enabled, add quotes only if they can’t be removed
2662
- (options.removeAttributeQuotes && (attrValue.indexOf(uidAttr) !== -1 || !canRemoveAttributeQuotes(attrValue))) ||
2906
+ (options.removeAttributeQuotes && ((uidAttr ? attrValue.indexOf(uidAttr) !== -1 : false) || !canRemoveAttributeQuotes(attrValue))) ||
2663
2907
  // If `removeAttributeQuotes` is not enabled, preserve original quote style or add quotes if value requires them
2664
2908
  (!options.removeAttributeQuotes && (attrQuote !== '' || !canRemoveAttributeQuotes(attrValue) ||
2665
2909
  // Special case: With `removeTagWhitespace`, unquoted values that aren’t last will have space added,
@@ -2668,6 +2912,7 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2668
2912
  );
2669
2913
 
2670
2914
  if (shouldAddQuotes) {
2915
+ attrValue = attrValue ?? '';
2671
2916
  // Determine the appropriate quote character
2672
2917
  if (!options.preventAttributesEscaping) {
2673
2918
  // Normal mode: Choose optimal quote type to minimize escaping
@@ -2769,11 +3014,20 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2769
3014
  return attr.customOpen + attrFragment + attr.customClose;
2770
3015
  }
2771
3016
 
2772
- // Imports
3017
+ /** @import { HTMLAttribute } from './attributes.js' */
3018
+
3019
+ // Type definitions
2773
3020
 
3021
+ /**
3022
+ * @typedef {{ name: (str: string) => string, log?: Function }} MinifierOptions
3023
+ */
2774
3024
 
2775
3025
  // Tag omission rules
2776
3026
 
3027
+ /**
3028
+ * @param {string} optionalStartTag
3029
+ * @param {string} tag
3030
+ */
2777
3031
  function canRemoveParentTag(optionalStartTag, tag) {
2778
3032
  switch (optionalStartTag) {
2779
3033
  case 'html':
@@ -2789,6 +3043,10 @@ function canRemoveParentTag(optionalStartTag, tag) {
2789
3043
  return false;
2790
3044
  }
2791
3045
 
3046
+ /**
3047
+ * @param {string} optionalEndTag
3048
+ * @param {string} tag
3049
+ */
2792
3050
  function isStartTagMandatory(optionalEndTag, tag) {
2793
3051
  switch (tag) {
2794
3052
  case 'colgroup':
@@ -2799,6 +3057,10 @@ function isStartTagMandatory(optionalEndTag, tag) {
2799
3057
  return false;
2800
3058
  }
2801
3059
 
3060
+ /**
3061
+ * @param {string} optionalEndTag
3062
+ * @param {string} tag
3063
+ */
2802
3064
  function canRemovePrecedingTag(optionalEndTag, tag) {
2803
3065
  switch (optionalEndTag) {
2804
3066
  case 'html':
@@ -2838,6 +3100,10 @@ function canRemovePrecedingTag(optionalEndTag, tag) {
2838
3100
 
2839
3101
  // Element removal logic
2840
3102
 
3103
+ /**
3104
+ * @param {string} tag
3105
+ * @param {HTMLAttribute[]} attrs
3106
+ */
2841
3107
  function canRemoveElement(tag, attrs) {
2842
3108
  // Elements with `id` attribute must never be removed—they serve as:
2843
3109
  // - Navigation targets (skip links, URL fragments)
@@ -2905,20 +3171,21 @@ function parseElementSpec(str, options) {
2905
3171
  return null;
2906
3172
  }
2907
3173
 
2908
- const tag = options.name(match[1]);
2909
- const attrString = match[2];
3174
+ const tag = options.name(match[1] ?? '');
3175
+ const attrString = match[2] ?? '';
2910
3176
 
2911
3177
  if (!attrString.trim()) {
2912
3178
  return { tag, attrs: null };
2913
3179
  }
2914
3180
 
2915
3181
  // Parse attributes from string
3182
+ /** @type {{[key: string]: string | undefined}} */
2916
3183
  const attrs = {};
2917
3184
  const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
2918
3185
  let attrMatch;
2919
3186
 
2920
3187
  while ((attrMatch = attrRegex.exec(attrString))) {
2921
- const attrName = options.name(attrMatch[1]);
3188
+ const attrName = options.name(attrMatch[1] ?? '');
2922
3189
  const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
2923
3190
  // Boolean attributes have no value (undefined)
2924
3191
  attrs[attrName] = attrValue;
@@ -2940,7 +3207,7 @@ function parseRemoveEmptyElementsExcept(input, options) {
2940
3207
  return [];
2941
3208
  }
2942
3209
 
2943
- return input.map(item => {
3210
+ return /** @type {Array<{tag: string, attrs: {[x: string]: string | undefined} | null}>} */ (input.map(item => {
2944
3211
  if (typeof item === 'string') {
2945
3212
  const spec = parseElementSpec(item, options);
2946
3213
  if (!spec && options.log) {
@@ -2952,7 +3219,7 @@ function parseRemoveEmptyElementsExcept(input, options) {
2952
3219
  options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
2953
3220
  }
2954
3221
  return null;
2955
- }).filter(Boolean);
3222
+ }).filter(Boolean));
2956
3223
  }
2957
3224
 
2958
3225
  /**
@@ -2995,261 +3262,29 @@ function shouldPreserveEmptyElement(tag, attrs, preserveList) {
2995
3262
  return false;
2996
3263
  }
2997
3264
 
2998
- // Imports
2999
-
3000
-
3001
- // Lazy-load heavy dependencies only when needed
3002
-
3003
- let lightningCSSPromise;
3004
- async function getLightningCSS() {
3005
- if (!lightningCSSPromise) {
3006
- lightningCSSPromise = import('lightningcss').then(m => m.transform);
3007
- }
3008
- return lightningCSSPromise;
3009
- }
3010
-
3011
- let terserPromise;
3012
- async function getTerser() {
3013
- if (!terserPromise) {
3014
- terserPromise = import('terser').then(m => m.minify);
3015
- }
3016
- return terserPromise;
3017
- }
3018
-
3019
- let swcPromise;
3020
- async function getSwc() {
3021
- if (!swcPromise) {
3022
- swcPromise = import('@swc/core')
3023
- .then(m => m.default || m)
3024
- .catch(() => {
3025
- throw new Error(
3026
- 'The swc minifier requires @swc/core to be installed.\n' +
3027
- 'Install it with: npm install @swc/core'
3028
- );
3029
- });
3030
- }
3031
- return swcPromise;
3032
- }
3033
-
3034
- let svgoPromise;
3035
- async function getSvgo() {
3036
- if (!svgoPromise) {
3037
- svgoPromise = import('svgo').then(m => m.optimize);
3038
- }
3039
- return svgoPromise;
3040
- }
3041
-
3042
- let decodeHTMLPromise;
3043
- async function getDecodeHTML() {
3044
- if (!decodeHTMLPromise) {
3045
- decodeHTMLPromise = import('entities').then(m => m.decodeHTML);
3046
- }
3047
- return decodeHTMLPromise;
3048
- }
3049
-
3050
- // Minification caches (initialized on first use with configurable sizes)
3051
- let cssMinifyCache = null;
3052
- let jsMinifyCache = null;
3053
- let svgMinifyCache = null;
3054
-
3055
- // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
3056
- const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
3057
- const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
3058
- const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
3059
- const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
3060
- const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
3061
-
3062
- // Pre-compiled patterns for buffer scanning
3063
- const RE_START_TAG = /^<[^/!]/;
3064
- const RE_END_TAG = /^<\//;
3065
-
3066
- // Pre-compiled patterns for `htmlmin:ignore` block content analysis
3067
- const RE_HTML_COMMENT_START = /^\s*<!--/;
3068
- const RE_CLOSING_TAG_START = /^\s*<\/([a-zA-Z][\w:-]*)/;
3069
- const RE_LAST_HTML_TAG = /[\s\S]*<(\/?[a-zA-Z][\w:-]*)/;
3070
-
3071
- // HTML encoding types for annotation-xml (MathML)
3072
- const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
3073
-
3074
- // Script merging
3265
+ // Type definitions
3075
3266
 
3076
3267
  /**
3077
- * Find the index of the `>` that closes an opening tag, correctly skipping
3078
- * over quoted attribute values (which may contain `>`).
3079
- * @param {string} html
3080
- * @param {number} pos - Start position (just after the tag name)
3081
- * @returns {number} Index of the closing `>`, or -1 if not found
3268
+ * @typedef {Object} HTMLAttribute
3269
+ * Representation of an attribute from the HTML parser.
3270
+ * Internal counterpart: `HTMLAttribute` in `lib/attributes.js`—keep in sync.
3271
+ *
3272
+ * @prop {string} name
3273
+ * @prop {string} [value]
3274
+ * @prop {string} [quote]
3275
+ * @prop {string} [customAssign]
3276
+ * @prop {string} [customOpen]
3277
+ * @prop {string} [customClose]
3082
3278
  */
3083
- function findTagEnd(html, pos) {
3084
- let i = pos;
3085
- while (i < html.length) {
3086
- const ch = html[i];
3087
- if (ch === '>') return i;
3088
- if (ch === '"' || ch === "'") {
3089
- const q = ch;
3090
- i++;
3091
- while (i < html.length && html[i] !== q) i++;
3092
- }
3093
- i++;
3094
- }
3095
- return -1;
3096
- }
3097
3279
 
3098
3280
  /**
3099
- * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
3100
- * Only merges scripts that are compatible:
3101
- * - Both inline (no `src` attribute)
3102
- * - Same `type` (or both default JavaScript)
3103
- * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
3281
+ * @typedef {Object} MinifierOptions
3282
+ * Options that control how HTML is minified. All of these are optional
3283
+ * and usually default to a disabled/safe value unless noted.
3104
3284
  *
3105
- * Uses a scanner rather than a regex to locate script boundaries, so literal
3106
- * `</script>` strings inside script content are handled correctly per the HTML
3107
- * spec (raw text ends at the first `</script>`).
3108
- *
3109
- * @param {string} html - The HTML string to process
3110
- * @returns {string} HTML with consecutive scripts merged
3111
- */
3112
- function mergeConsecutiveScripts(html) {
3113
- // Parse an attribute string into a name→value map
3114
- const parseAttrs = (attrStr) => {
3115
- const attrs = {};
3116
- RE_SCRIPT_ATTRS.lastIndex = 0;
3117
- let m;
3118
- while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
3119
- const name = m[1].toLowerCase();
3120
- const value = m[2] ?? m[3] ?? m[4] ?? '';
3121
- attrs[name] = value;
3122
- }
3123
- return attrs;
3124
- };
3125
-
3126
- let changed = true;
3127
-
3128
- // Keep merging until no more changes (handles chains of 3+ scripts)
3129
- while (changed) {
3130
- changed = false;
3131
- RE_SCRIPT_OPEN.lastIndex = 0;
3132
- let m1;
3133
-
3134
- while ((m1 = RE_SCRIPT_OPEN.exec(html)) !== null) {
3135
- // Use findTagEnd() to get the real closing '>', skipping quoted attribute values
3136
- const tagEnd1 = findTagEnd(html, m1.index + 7);
3137
- if (tagEnd1 === -1) break;
3138
-
3139
- const attrs1Str = html.slice(m1.index + 7, tagEnd1);
3140
- const contentStart1 = tagEnd1 + 1;
3141
-
3142
- // Find end of this script’s content (first `</script>`—per HTML spec, raw text ends here)
3143
- RE_SCRIPT_CLOSE.lastIndex = contentStart1;
3144
- const close1 = RE_SCRIPT_CLOSE.exec(html);
3145
- if (!close1) break;
3146
-
3147
- const content1 = html.slice(contentStart1, close1.index);
3148
- const afterClose1 = close1.index + close1[0].length;
3149
-
3150
- // Skip optional whitespace and check for a consecutive <script> tag
3151
- let i = afterClose1;
3152
- while (i < html.length && (html[i] === ' ' || html[i] === '\t' || html[i] === '\n' || html[i] === '\r' || html[i] === '\f')) i++;
3153
- if (html.slice(i, i + 7).toLowerCase() !== '<script' || (html[i + 7] !== '>' && !/\s/.test(html[i + 7]))) {
3154
- RE_SCRIPT_OPEN.lastIndex = afterClose1;
3155
- continue;
3156
- }
3157
-
3158
- const tagStart2 = i;
3159
- const tagEnd2 = findTagEnd(html, tagStart2 + 7);
3160
- if (tagEnd2 === -1) break;
3161
-
3162
- const attrs2Str = html.slice(tagStart2 + 7, tagEnd2);
3163
- const contentStart2 = tagEnd2 + 1;
3164
-
3165
- // Find end of second script’s content
3166
- RE_SCRIPT_CLOSE.lastIndex = contentStart2;
3167
- const close2 = RE_SCRIPT_CLOSE.exec(html);
3168
- if (!close2) break;
3169
-
3170
- const content2 = html.slice(contentStart2, close2.index);
3171
- const afterClose2 = close2.index + close2[0].length;
3172
-
3173
- const a1 = parseAttrs(attrs1Str);
3174
- const a2 = parseAttrs(attrs2Str);
3175
-
3176
- // Check for `src`—cannot merge external scripts
3177
- if ('src' in a1 || 'src' in a2) {
3178
- RE_SCRIPT_OPEN.lastIndex = afterClose1;
3179
- continue;
3180
- }
3181
-
3182
- // Check `type` compatibility (both must be default JS)
3183
- // Non-JS types (modules, JSON, etc.) must not be merged:
3184
- // Module scripts have per-script lexical scope, and non-JS content (e.g., JSON)
3185
- // is not concatenable; even identical non-JS types are incompatible
3186
- const type1 = (a1.type || '').toLowerCase();
3187
- const type2 = (a2.type || '').toLowerCase();
3188
- if (!DEFAULT_JS_TYPES.has(type1) || !DEFAULT_JS_TYPES.has(type2)) {
3189
- RE_SCRIPT_OPEN.lastIndex = afterClose1;
3190
- continue;
3191
- }
3192
-
3193
- // Check for conflicting boolean attributes
3194
- let boolConflict = false;
3195
- for (const attr of SCRIPT_BOOL_ATTRS) {
3196
- if ((attr in a1) !== (attr in a2)) { boolConflict = true; break; }
3197
- }
3198
-
3199
- // Check `nonce`—must be same or both absent
3200
- if (boolConflict || a1.nonce !== a2.nonce) {
3201
- RE_SCRIPT_OPEN.lastIndex = afterClose1;
3202
- continue;
3203
- }
3204
-
3205
- // Scripts are compatible—merge them
3206
- changed = true;
3207
-
3208
- // Combine content—use semicolon normally, newline only for trailing `//` comments
3209
- const c1 = content1.trim();
3210
- const c2 = content2.trim();
3211
- let mergedContent;
3212
- if (c1 && c2) {
3213
- // Check if last line of c1 contains `//` (single-line comment)
3214
- // If so, use newline to terminate it; otherwise use semicolon (if not already present)
3215
- const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
3216
- const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
3217
- mergedContent = c1 + separator + c2;
3218
- } else {
3219
- mergedContent = c1 || c2;
3220
- }
3221
-
3222
- // Use first script’s attributes (they should be compatible)
3223
- html = html.slice(0, m1.index) + `<script${attrs1Str}>${mergedContent}</script>` + html.slice(afterClose2);
3224
- break; // Restart scanning (outer while loop)
3225
- }
3226
- }
3227
-
3228
- return html;
3229
- }
3230
-
3231
- // Type definitions
3232
-
3233
- /**
3234
- * @typedef {Object} HTMLAttribute
3235
- * Representation of an attribute from the HTML parser.
3236
- *
3237
- * @prop {string} name
3238
- * @prop {string} [value]
3239
- * @prop {string} [quote]
3240
- * @prop {string} [customAssign]
3241
- * @prop {string} [customOpen]
3242
- * @prop {string} [customClose]
3243
- */
3244
-
3245
- /**
3246
- * @typedef {Object} MinifierOptions
3247
- * Options that control how HTML is minified. All of these are optional
3248
- * and usually default to a disabled/safe value unless noted.
3249
- *
3250
- * @prop {(tag: string, attrs: HTMLAttribute[], canCollapseWhitespace: (tag: string) => boolean) => boolean} [canCollapseWhitespace]
3251
- * Predicate that determines whether whitespace inside a given element
3252
- * can be collapsed.
3285
+ * @prop {(tag: string, attrs: HTMLAttribute[], canCollapseWhitespace: (tag: string) => boolean) => boolean} [canCollapseWhitespace]
3286
+ * Predicate that determines whether whitespace inside a given element
3287
+ * can be collapsed.
3253
3288
  *
3254
3289
  * Default: Built-in `canCollapseWhitespace` function
3255
3290
  *
@@ -3642,23 +3677,275 @@ function mergeConsecutiveScripts(html) {
3642
3677
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype
3643
3678
  *
3644
3679
  * Default: `false`
3680
+ *
3681
+ * @prop {boolean} [insideSVG] - Internal: Set when inside SVG/MathML context
3682
+ * @prop {boolean} [insideForeignContent] - Internal: Set when inside SVG `foreignObject`
3683
+ * @prop {Function} [parentName] - Internal: Preserved name function during namespace transitions
3684
+ * @prop {Function} [htmlName] - Internal: HTML name function preserved from outer context
3645
3685
  */
3646
3686
 
3687
+ // Lazy-load heavy dependencies only when needed
3688
+
3689
+ /** @type {Promise<Function> | undefined} */
3690
+ let lightningCSSPromise;
3691
+ async function getLightningCSS() {
3692
+ if (!lightningCSSPromise) {
3693
+ lightningCSSPromise = import('lightningcss').then(m => m.transform);
3694
+ }
3695
+ return lightningCSSPromise;
3696
+ }
3697
+
3698
+ /** @type {Promise<Function> | undefined} */
3699
+ let terserPromise;
3700
+ async function getTerser() {
3701
+ if (!terserPromise) {
3702
+ terserPromise = import('terser').then(m => m.minify);
3703
+ }
3704
+ return terserPromise;
3705
+ }
3706
+
3707
+ /** @type {Promise<any> | undefined} */
3708
+ let swcPromise;
3709
+ async function getSwc() {
3710
+ if (!swcPromise) {
3711
+ swcPromise = import('@swc/core')
3712
+ .then(m => m.default || m)
3713
+ .catch(() => {
3714
+ throw new Error(
3715
+ 'The swc minifier requires @swc/core to be installed.\n' +
3716
+ 'Install it with: npm install @swc/core'
3717
+ );
3718
+ });
3719
+ }
3720
+ return swcPromise;
3721
+ }
3722
+
3723
+ /** @type {Promise<Function> | undefined} */
3724
+ let svgoPromise;
3725
+ async function getSvgo() {
3726
+ if (!svgoPromise) {
3727
+ svgoPromise = import('svgo').then(m => m.optimize);
3728
+ }
3729
+ return svgoPromise;
3730
+ }
3731
+
3732
+ /** @type {Promise<Function> | undefined} */
3733
+ let decodeHTMLPromise;
3734
+ async function getDecodeHTML() {
3735
+ if (!decodeHTMLPromise) {
3736
+ decodeHTMLPromise = import('entities').then(m => m.decodeHTML);
3737
+ }
3738
+ return decodeHTMLPromise;
3739
+ }
3740
+
3741
+ // Minification caches (initialized on first use with configurable sizes)
3742
+ /** @type {LRU | null} */
3743
+ let cssMinifyCache = null;
3744
+ /** @type {LRU | null} */
3745
+ let jsMinifyCache = null;
3746
+ /** @type {LRU | null} */
3747
+ let svgMinifyCache = null;
3748
+
3749
+ // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
3750
+ const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
3751
+ const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
3752
+ const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
3753
+ const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
3754
+ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
3755
+
3756
+ // Pre-compiled patterns for buffer scanning
3757
+ const RE_START_TAG = /^<[^/!]/;
3758
+ const RE_END_TAG = /^<\//;
3759
+
3760
+ // Pre-compiled patterns for `htmlmin:ignore` block content analysis
3761
+ const RE_HTML_COMMENT_START = /^\s*<!--/;
3762
+ const RE_CLOSING_TAG_START = /^\s*<\/([a-zA-Z][\w:-]*)/;
3763
+ const RE_LAST_HTML_TAG = /[\s\S]*<(\/?[a-zA-Z][\w:-]*)/;
3764
+
3765
+ // HTML encoding types for annotation-xml (MathML)
3766
+ const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
3767
+
3768
+ // Script merging
3769
+
3770
+ /**
3771
+ * Find the index of the `>` that closes an opening tag, correctly skipping
3772
+ * over quoted attribute values (which may contain `>`).
3773
+ * @param {string} html
3774
+ * @param {number} pos - Start position (just after the tag name)
3775
+ * @returns {number} Index of the closing `>`, or -1 if not found
3776
+ */
3777
+ function findTagEnd(html, pos) {
3778
+ let i = pos;
3779
+ while (i < html.length) {
3780
+ const ch = html[i];
3781
+ if (ch === '>') return i;
3782
+ if (ch === '"' || ch === "'") {
3783
+ const q = ch;
3784
+ i++;
3785
+ while (i < html.length && html[i] !== q) i++;
3786
+ }
3787
+ i++;
3788
+ }
3789
+ return -1;
3790
+ }
3791
+
3792
+ /**
3793
+ * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
3794
+ * Only merges scripts that are compatible:
3795
+ * - Both inline (no `src` attribute)
3796
+ * - Same `type` (or both default JavaScript)
3797
+ * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
3798
+ *
3799
+ * Uses a scanner rather than a regex to locate script boundaries, so literal
3800
+ * `</script>` strings inside script content are handled correctly per the HTML
3801
+ * spec (raw text ends at the first `</script>`).
3802
+ *
3803
+ * @param {string} html - The HTML string to process
3804
+ * @returns {string} HTML with consecutive scripts merged
3805
+ */
3806
+ function mergeConsecutiveScripts(html) {
3807
+ // Parse an attribute string into a name→value map
3808
+ const parseAttrs = (/** @type {string} */ attrStr) => {
3809
+ /** @type {Record<string, string>} */
3810
+ const attrs = {};
3811
+ RE_SCRIPT_ATTRS.lastIndex = 0;
3812
+ let m;
3813
+ while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
3814
+ const name = (m[1] ?? '').toLowerCase();
3815
+ const value = m[2] ?? m[3] ?? m[4] ?? '';
3816
+ attrs[name] = value;
3817
+ }
3818
+ return attrs;
3819
+ };
3820
+
3821
+ let changed = true;
3822
+
3823
+ // Keep merging until no more changes (handles chains of 3+ scripts)
3824
+ while (changed) {
3825
+ changed = false;
3826
+ RE_SCRIPT_OPEN.lastIndex = 0;
3827
+ let m1;
3828
+
3829
+ while ((m1 = RE_SCRIPT_OPEN.exec(html)) !== null) {
3830
+ // Use findTagEnd() to get the real closing '>', skipping quoted attribute values
3831
+ const tagEnd1 = findTagEnd(html, m1.index + 7);
3832
+ if (tagEnd1 === -1) break;
3833
+
3834
+ const attrs1Str = html.slice(m1.index + 7, tagEnd1);
3835
+ const contentStart1 = tagEnd1 + 1;
3836
+
3837
+ // Find end of this script’s content (first `</script>`—per HTML spec, raw text ends here)
3838
+ RE_SCRIPT_CLOSE.lastIndex = contentStart1;
3839
+ const close1 = RE_SCRIPT_CLOSE.exec(html);
3840
+ if (!close1) break;
3841
+
3842
+ const content1 = html.slice(contentStart1, close1.index);
3843
+ const afterClose1 = close1.index + close1[0].length;
3844
+
3845
+ // Skip optional whitespace and check for a consecutive <script> tag
3846
+ let i = afterClose1;
3847
+ while (i < html.length && (html[i] === ' ' || html[i] === '\t' || html[i] === '\n' || html[i] === '\r' || html[i] === '\f')) i++;
3848
+ if (html.slice(i, i + 7).toLowerCase() !== '<script' || (html.charAt(i + 7) !== '>' && !/\s/.test(html.charAt(i + 7)))) {
3849
+ RE_SCRIPT_OPEN.lastIndex = afterClose1;
3850
+ continue;
3851
+ }
3852
+
3853
+ const tagStart2 = i;
3854
+ const tagEnd2 = findTagEnd(html, tagStart2 + 7);
3855
+ if (tagEnd2 === -1) break;
3856
+
3857
+ const attrs2Str = html.slice(tagStart2 + 7, tagEnd2);
3858
+ const contentStart2 = tagEnd2 + 1;
3859
+
3860
+ // Find end of second script’s content
3861
+ RE_SCRIPT_CLOSE.lastIndex = contentStart2;
3862
+ const close2 = RE_SCRIPT_CLOSE.exec(html);
3863
+ if (!close2) break;
3864
+
3865
+ const content2 = html.slice(contentStart2, close2.index);
3866
+ const afterClose2 = close2.index + close2[0].length;
3867
+
3868
+ const a1 = parseAttrs(attrs1Str);
3869
+ const a2 = parseAttrs(attrs2Str);
3870
+
3871
+ // Check for `src`—cannot merge external scripts
3872
+ if ('src' in a1 || 'src' in a2) {
3873
+ RE_SCRIPT_OPEN.lastIndex = afterClose1;
3874
+ continue;
3875
+ }
3876
+
3877
+ // Check `type` compatibility (both must be default JS)
3878
+ // Non-JS types (modules, JSON, etc.) must not be merged:
3879
+ // Module scripts have per-script lexical scope, and non-JS content (e.g., JSON)
3880
+ // is not concatenable; even identical non-JS types are incompatible
3881
+ const type1 = (a1.type || '').toLowerCase();
3882
+ const type2 = (a2.type || '').toLowerCase();
3883
+ if (!DEFAULT_JS_TYPES.has(type1) || !DEFAULT_JS_TYPES.has(type2)) {
3884
+ RE_SCRIPT_OPEN.lastIndex = afterClose1;
3885
+ continue;
3886
+ }
3887
+
3888
+ // Check for conflicting boolean attributes
3889
+ let boolConflict = false;
3890
+ for (const attr of SCRIPT_BOOL_ATTRS) {
3891
+ if ((attr in a1) !== (attr in a2)) { boolConflict = true; break; }
3892
+ }
3893
+
3894
+ // Check `nonce`—must be same or both absent
3895
+ if (boolConflict || a1.nonce !== a2.nonce) {
3896
+ RE_SCRIPT_OPEN.lastIndex = afterClose1;
3897
+ continue;
3898
+ }
3899
+
3900
+ // Scripts are compatible—merge them
3901
+ changed = true;
3902
+
3903
+ // Combine content—use semicolon normally, newline only for trailing `//` comments
3904
+ const c1 = content1.trim();
3905
+ const c2 = content2.trim();
3906
+ let mergedContent;
3907
+ if (c1 && c2) {
3908
+ // Check if last line of c1 contains `//` (single-line comment)
3909
+ // If so, use newline to terminate it; otherwise use semicolon (if not already present)
3910
+ const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
3911
+ const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
3912
+ mergedContent = c1 + separator + c2;
3913
+ } else {
3914
+ mergedContent = c1 || c2;
3915
+ }
3916
+
3917
+ // Use first script’s attributes (they should be compatible)
3918
+ html = html.slice(0, m1.index) + `<script${attrs1Str}>${mergedContent}</script>` + html.slice(afterClose2);
3919
+ break; // Restart scanning (outer while loop)
3920
+ }
3921
+ }
3922
+
3923
+ return html;
3924
+ }
3925
+
3926
+ /**
3927
+ * @param {string} value
3928
+ * @param {MinifierOptions} options
3929
+ * @param {string} uidIgnore
3930
+ * @param {string} uidAttr
3931
+ * @param {string[]} ignoredMarkupChunks
3932
+ */
3647
3933
  async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
3648
3934
  const attrChains = options.sortAttributes && typeof options.sortAttributes !== 'function' && Object.create(null);
3649
3935
  const classChain = options.sortClassNames && typeof options.sortClassNames !== 'function' && new TokenChain();
3936
+ const resolveName = /** @type {(name: string) => string} */ (options.name);
3650
3937
 
3651
- function attrNames(attrs) {
3938
+ function attrNames(/** @type {HTMLAttribute[]} */ attrs) {
3652
3939
  return attrs.map(function (attr) {
3653
- return options.name(attr.name);
3940
+ return resolveName(attr.name);
3654
3941
  });
3655
3942
  }
3656
3943
 
3657
- function shouldSkipUID(token, uid) {
3944
+ function shouldSkipUID(/** @type {string} */ token, /** @type {string} */ uid) {
3658
3945
  return !uid || token.indexOf(uid) === -1;
3659
3946
  }
3660
3947
 
3661
- function shouldKeepToken(token) {
3948
+ function shouldKeepToken(/** @type {string} */ token) {
3662
3949
  // Filter out any HTML comment tokens (UID placeholders)
3663
3950
  // These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
3664
3951
  if (token.startsWith('<!--') && token.endsWith('-->')) {
@@ -3672,10 +3959,13 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3672
3959
  const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
3673
3960
  const whitespaceSplitPatternSort = /[ \n\f\r]+/;
3674
3961
 
3675
- async function scan(input) {
3676
- let currentTag, currentType;
3962
+ async function scan(/** @type {string} */ input) {
3963
+ /** @type {string} */
3964
+ let currentTag;
3965
+ /** @type {string | undefined} */
3966
+ let currentType;
3677
3967
  const parser = new HTMLParser(input, {
3678
- start: function (tag, attrs) {
3968
+ start: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
3679
3969
  if (attrChains) {
3680
3970
  if (!attrChains[tag]) {
3681
3971
  attrChains[tag] = new TokenChain();
@@ -3683,9 +3973,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3683
3973
  const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
3684
3974
  attrChains[tag].add(attrNamesList);
3685
3975
  }
3686
- for (let i = 0, len = attrs.length; i < len; i++) {
3687
- const attr = attrs[i];
3688
- if (classChain && attr.value && options.name(attr.name) === 'class') {
3976
+ for (const attr of attrs) {
3977
+ if (classChain && attr.value && resolveName(attr.name) === 'class') {
3689
3978
  const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
3690
3979
  classChain.add(classes);
3691
3980
  } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
@@ -3697,11 +3986,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3697
3986
  end: function () {
3698
3987
  currentTag = '';
3699
3988
  },
3700
- chars: async function (text) {
3989
+ chars: async function (/** @type {string} */ text) {
3701
3990
  // Only recursively scan HTML content, not JSON-LD or other non-HTML script types
3702
3991
  // `scan()` is for analyzing HTML attribute order, not for parsing JSON
3703
3992
  if (options.processScripts && specialContentElements.has(currentTag) &&
3704
- options.processScripts.indexOf(currentType) > -1 &&
3993
+ options.processScripts.indexOf(currentType ?? '') > -1 &&
3705
3994
  currentType === 'text/html') {
3706
3995
  await scan(text);
3707
3996
  }
@@ -3760,7 +4049,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3760
4049
  // Expand UID tokens back to the original content for frequency analysis
3761
4050
  let expandedValue = value;
3762
4051
  if (uidReplacePattern) {
3763
- expandedValue = value.replace(uidReplacePattern, function (match, index) {
4052
+ expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
3764
4053
  return ignoredMarkupChunks[+index] || '';
3765
4054
  });
3766
4055
  // Reset `lastIndex` for pattern reuse
@@ -3781,7 +4070,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3781
4070
  await scan(scanValue);
3782
4071
  } finally {
3783
4072
  // Restore original option
3784
- options.continueOnParseError = originalContinueOnParseError;
4073
+ if (originalContinueOnParseError !== undefined) {
4074
+ options.continueOnParseError = originalContinueOnParseError;
4075
+ } else {
4076
+ delete options.continueOnParseError;
4077
+ }
3785
4078
  }
3786
4079
  if (attrChains) {
3787
4080
  const attrSorters = Object.create(null);
@@ -3791,14 +4084,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3791
4084
  // Memoize sorted attribute orders—attribute sets often repeat in templates
3792
4085
  const attrOrderCache = new LRU(500);
3793
4086
 
3794
- options.sortAttributes = function (tag, attrs) {
4087
+ options.sortAttributes = function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
3795
4088
  const sorter = attrSorters[tag];
3796
4089
  if (sorter) {
3797
4090
  const names = attrNames(attrs);
3798
4091
 
3799
4092
  // Create order-independent cache key from tag and sorted attribute names
3800
4093
  const cacheKey = tag + ':' + names.slice().sort().join(',');
3801
- let sortedNames = attrOrderCache.get(cacheKey);
4094
+ let sortedNames = /** @type {string[] | undefined} */ (attrOrderCache.get(cacheKey));
3802
4095
 
3803
4096
  if (sortedNames === undefined) {
3804
4097
  // Only sort if not in cache—need to clone names since sort mutates in place
@@ -3808,10 +4101,10 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3808
4101
 
3809
4102
  // Apply the sorted order to `attrs`
3810
4103
  const attrMap = Object.create(null);
3811
- names.forEach(function (name, index) {
4104
+ names.forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
3812
4105
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
3813
4106
  });
3814
- sortedNames.forEach(function (name, index) {
4107
+ /** @type {string[]} */ (sortedNames).forEach(function (/** @type {string} */ name, /** @type {number} */ index) {
3815
4108
  attrs[index] = attrMap[name].shift();
3816
4109
  });
3817
4110
  }
@@ -3822,14 +4115,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3822
4115
  // Memoize `sortClassNames` results—class lists often repeat in templates
3823
4116
  const classNameCache = new LRU(500);
3824
4117
 
3825
- options.sortClassNames = function (value) {
4118
+ options.sortClassNames = function (/** @type {string} */ value) {
3826
4119
  // Fast path: Single class (no spaces) needs no sorting
3827
4120
  if (value.indexOf(' ') === -1) {
3828
4121
  return value;
3829
4122
  }
3830
4123
 
3831
4124
  // Check cache first
3832
- const cached = classNameCache.get(value);
4125
+ const cached = /** @type {string | undefined} */ (classNameCache.get(value));
3833
4126
  if (cached !== undefined) {
3834
4127
  return cached;
3835
4128
  }
@@ -3838,13 +4131,13 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3838
4131
  // Fast path: Skip if no HTML comments (UID markers) present
3839
4132
  let expandedValue = value;
3840
4133
  if (uidReplacePattern && value.indexOf('<!--') !== -1) {
3841
- expandedValue = value.replace(uidReplacePattern, function (match, index) {
4134
+ expandedValue = value.replace(uidReplacePattern, function (/** @type {string} */ _match, /** @type {string} */ index) {
3842
4135
  return ignoredMarkupChunks[+index] || '';
3843
4136
  });
3844
4137
  // Reset `lastIndex` for pattern reuse
3845
4138
  uidReplacePattern.lastIndex = 0;
3846
4139
  }
3847
- const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
4140
+ const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(/** @type {string} */ cls) {
3848
4141
  return cls !== '';
3849
4142
  });
3850
4143
  const sorted = sorter.sort(classes);
@@ -3873,29 +4166,46 @@ async function minifyHTML(value, options, partialMarkup) {
3873
4166
  value = collapseWhitespace(value, options, true, true);
3874
4167
  }
3875
4168
 
4169
+ const resolveName = /** @type {(name: string) => string} */ (options.name);
4170
+ /** @type {HTMLAttribute[]} */
4171
+ const emptyAttrs = [];
4172
+
4173
+ /** @type {string[]} */
3876
4174
  const buffer = [];
3877
- let charsPrevTag;
4175
+ /** @type {string} */
4176
+ let charsPrevTag = '';
3878
4177
  let currentChars = '';
3879
- let hasChars;
4178
+ /** @type {boolean} */
4179
+ let hasChars = false;
3880
4180
  let currentTag = '';
4181
+ /** @type {Array<{name: string, value?: string, quote?: string, customAssign?: string, customOpen?: string, customClose?: string}>} */
3881
4182
  let currentAttrs = [];
4183
+ /** @type {string[]} */
3882
4184
  const stackNoTrimWhitespace = [];
4185
+ /** @type {string[]} */
3883
4186
  const stackNoCollapseWhitespace = [];
3884
4187
  let preTextareaDepth = 0; // Count of `pre`/`textarea` entries in `stackNoTrimWhitespace`
3885
4188
  let optionalStartTag = '';
3886
4189
  let optionalEndTag = '';
3887
4190
  let optionalEndTagEmitted = false;
4191
+ /** @type {string[]} */
3888
4192
  const ignoredMarkupChunks = [];
4193
+ /** @type {Array<RegExpExecArray | null>} */
3889
4194
  const ignoredCustomMarkupChunks = [];
4195
+ /** @type {string | undefined} */
3890
4196
  let uidIgnore;
4197
+ /** @type {RegExp | undefined} */
3891
4198
  let uidIgnorePlaceholderPattern;
4199
+ /** @type {string | undefined} */
3892
4200
  let uidAttr;
4201
+ /** @type {RegExp | undefined} */
3893
4202
  let uidPattern;
4203
+ /** @type {RegExp | undefined} */
3894
4204
  let uidAttrLeadingPattern;
3895
4205
  // Create inline tags/text sets with custom elements
3896
4206
  const customElementsInput = options.inlineCustomElements ?? [];
3897
4207
  const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
3898
- const normalizedCustomElements = customElementsArr.map(name => options.name(name));
4208
+ const normalizedCustomElements = customElementsArr.map(name => resolveName(name));
3899
4209
  // Fast path: Reuse base sets if no custom elements
3900
4210
  const inlineTextSet = normalizedCustomElements.length
3901
4211
  ? new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements])
@@ -3905,6 +4215,7 @@ async function minifyHTML(value, options, partialMarkup) {
3905
4215
  : inlineElementsToKeepWhitespaceAround;
3906
4216
 
3907
4217
  // Parse `removeEmptyElementsExcept` option
4218
+ /** @type {Array<{tag: string, attrs: {[x: string]: string | undefined} | null}>} */
3908
4219
  let removeEmptyElementsExcept;
3909
4220
  if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
3910
4221
  if (options.log) {
@@ -3912,7 +4223,7 @@ async function minifyHTML(value, options, partialMarkup) {
3912
4223
  }
3913
4224
  removeEmptyElementsExcept = [];
3914
4225
  } else {
3915
- removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
4226
+ removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept || [], /** @type {any} */ (options)) || [];
3916
4227
  }
3917
4228
 
3918
4229
  // Temporarily replace ignored chunks with comments, so that there’s no need to worry what’s there;
@@ -3954,17 +4265,17 @@ async function minifyHTML(value, options, partialMarkup) {
3954
4265
  // This allows proper frequency analysis with access to ignored content via UID tokens
3955
4266
  if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
3956
4267
  (options.sortClassNames && typeof options.sortClassNames !== 'function')) {
3957
- await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
4268
+ await createSortFns(value, options, uidIgnore ?? '', uidAttr ?? '', ignoredMarkupChunks);
3958
4269
  }
3959
4270
 
3960
- const customFragments = options.ignoreCustomFragments.map(function (re) {
4271
+ const customFragments = (options.ignoreCustomFragments || []).map(function (/** @type {RegExp} */ re) {
3961
4272
  return re.source;
3962
4273
  });
3963
4274
  if (customFragments.length) {
3964
4275
  // Warn about potential ReDoS if custom fragments use unlimited quantifiers
3965
- for (let i = 0; i < customFragments.length; i++) {
3966
- if (/[*+]/.test(customFragments[i])) {
3967
- options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
4276
+ for (const fragment of customFragments) {
4277
+ if (/[*+]/.test(fragment)) {
4278
+ options.log?.('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
3968
4279
  break;
3969
4280
  }
3970
4281
  }
@@ -3992,28 +4303,28 @@ async function minifyHTML(value, options, partialMarkup) {
3992
4303
  uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
3993
4304
  uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
3994
4305
 
3995
- if (options.minifyCSS) {
3996
- options.minifyCSS = (function (fn) {
3997
- return function (text, type) {
3998
- text = text.replace(uidPattern, function (match, prefix, index) {
4306
+ if (options.minifyCSS !== identity) {
4307
+ /** @type {any} */ (options).minifyCSS = (function (/** @type {Function} */ fn) {
4308
+ return function (/** @type {string} */ text, /** @type {string} */ type) {
4309
+ text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
3999
4310
  const chunks = ignoredCustomMarkupChunks[+index];
4000
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
4311
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
4001
4312
  });
4002
4313
 
4003
4314
  return fn(text, type);
4004
4315
  };
4005
- })(options.minifyCSS);
4316
+ })(/** @type {Function} */ (options.minifyCSS));
4006
4317
  }
4007
4318
 
4008
- if (options.minifyJS) {
4009
- options.minifyJS = (function (fn) {
4010
- return function (text, inline, isModule) {
4011
- return fn(text.replace(uidPattern, function (match, prefix, index) {
4319
+ if (options.minifyJS !== identity) {
4320
+ options.minifyJS = (function (/** @type {Function} */ fn) {
4321
+ return function (/** @type {string} */ text, /** @type {boolean} */ inline, /** @type {boolean} */ isModule) {
4322
+ return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
4012
4323
  const chunks = ignoredCustomMarkupChunks[+index];
4013
- return chunks[1] + uidAttr + index + uidAttr + chunks[2];
4324
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
4014
4325
  }), inline, isModule);
4015
4326
  };
4016
- })(options.minifyJS);
4327
+ })(/** @type {Function} */ (options.minifyJS));
4017
4328
  }
4018
4329
  }
4019
4330
 
@@ -4024,17 +4335,17 @@ async function minifyHTML(value, options, partialMarkup) {
4024
4335
  }
4025
4336
  }
4026
4337
 
4027
- function canCollapseWhitespace$1(tag, attrs) {
4028
- return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
4338
+ function canCollapseWhitespace$1(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
4339
+ return /** @type {Function} */ (options.canCollapseWhitespace)(tag, attrs, canCollapseWhitespace);
4029
4340
  }
4030
4341
 
4031
- function canTrimWhitespace$1(tag, attrs) {
4032
- return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
4342
+ function canTrimWhitespace$1(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
4343
+ return /** @type {Function} */ (options.canTrimWhitespace)(tag, attrs, canTrimWhitespace);
4033
4344
  }
4034
4345
 
4035
4346
  function removeStartTag() {
4036
4347
  let index = buffer.length - 1;
4037
- while (index > 0 && !RE_START_TAG.test(buffer[index])) {
4348
+ while (index > 0 && !RE_START_TAG.test(buffer[index] ?? '')) {
4038
4349
  index--;
4039
4350
  }
4040
4351
  buffer.length = Math.max(0, index);
@@ -4042,20 +4353,20 @@ async function minifyHTML(value, options, partialMarkup) {
4042
4353
 
4043
4354
  function removeEndTag() {
4044
4355
  let index = buffer.length - 1;
4045
- while (index > 0 && !RE_END_TAG.test(buffer[index])) {
4356
+ while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
4046
4357
  index--;
4047
4358
  }
4048
4359
  buffer.length = Math.max(0, index);
4049
4360
  }
4050
4361
 
4051
4362
  // Look for trailing whitespaces, bypass any inline tags
4052
- function trimTrailingWhitespace(index, nextTag) {
4053
- for (let endTag = null; index >= 0 && canTrimWhitespace$1(endTag); index--) {
4054
- const str = buffer[index];
4363
+ function trimTrailingWhitespace(/** @type {number} */ index, /** @type {string} */ nextTag) {
4364
+ for (let endTag = ''; index >= 0 && canTrimWhitespace$1(endTag, emptyAttrs); index--) {
4365
+ const str = buffer[index] ?? '';
4055
4366
  const match = str.match(/^<\/([\w:-]+)>$/);
4056
4367
  if (match) {
4057
- endTag = match[1];
4058
- } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, [], [], options, inlineElements, inlineTextSet))) {
4368
+ endTag = match[1] ?? '';
4369
+ } else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, '', nextTag, emptyAttrs, emptyAttrs, options, inlineElements, inlineTextSet))) {
4059
4370
  break;
4060
4371
  }
4061
4372
  }
@@ -4064,10 +4375,10 @@ async function minifyHTML(value, options, partialMarkup) {
4064
4375
  // Look for trailing whitespaces from previously processed text
4065
4376
  // which may not be trimmed due to a following comment or an empty
4066
4377
  // element which has now been removed
4067
- function squashTrailingWhitespace(nextTag) {
4378
+ function squashTrailingWhitespace(/** @type {string} */ nextTag) {
4068
4379
  let charsIndex = buffer.length - 1;
4069
4380
  if (buffer.length > 1) {
4070
- const item = buffer[buffer.length - 1];
4381
+ const item = buffer[buffer.length - 1] ?? '';
4071
4382
  if (/^(?:<!|$)/.test(item) && (!uidIgnore || item.indexOf(uidIgnore) === -1)) {
4072
4383
  charsIndex--;
4073
4384
  }
@@ -4076,6 +4387,7 @@ async function minifyHTML(value, options, partialMarkup) {
4076
4387
  }
4077
4388
 
4078
4389
  // SVG subtree capture: When SVGO is active, record buffer positions for post-processing
4390
+ /** @type {Array<{start: number, end: number}>} */
4079
4391
  const svgBlocks = []; // Array of { start, end } buffer indices
4080
4392
  let svgBufferStartIndex = -1;
4081
4393
  let svgDepth = 0;
@@ -4088,7 +4400,7 @@ async function minifyHTML(value, options, partialMarkup) {
4088
4400
  // Compute `nextTag` only when whitespace collapse features require it
4089
4401
  wantsNextTag: !!(options.collapseWhitespace || options.collapseInlineTagWhitespace || options.conservativeCollapse),
4090
4402
 
4091
- start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
4403
+ start: async function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ unary, /** @type {string} */ unarySlash, /** @type {boolean} */ autoGenerated) {
4092
4404
  const lowerTag = tag.toLowerCase();
4093
4405
  if (lowerTag === 'svg' || lowerTag === 'math') {
4094
4406
  options = Object.create(options);
@@ -4110,21 +4422,21 @@ async function minifyHTML(value, options, partialMarkup) {
4110
4422
  // Note: The element itself is in SVG/MathML namespace, only its children are HTML
4111
4423
  let useParentNameForTag = false;
4112
4424
  if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
4113
- (lowerTag === 'annotation-xml' && attrs.some(a => a.name.toLowerCase() === 'encoding' &&
4114
- RE_HTML_ENCODING.test(a.value))))) {
4115
- const parentName = options.name;
4425
+ (lowerTag === 'annotation-xml' && attrs.some((/** @type {HTMLAttribute} */ a) => a.name.toLowerCase() === 'encoding' &&
4426
+ RE_HTML_ENCODING.test(a.value ?? ''))))) {
4427
+ const parentName = /** @type {(name: string) => string} */ (options.name);
4116
4428
  options = Object.create(options);
4117
4429
  options.caseSensitive = false;
4118
4430
  options.keepClosingSlash = false;
4119
4431
  options.parentName = parentName; // Preserve for the element tag itself
4120
- options.name = options.htmlName || lowercase;
4432
+ options.name = /** @type {(name: string) => string} */ (options.htmlName ?? lowercase);
4121
4433
  options.insideForeignContent = false;
4122
4434
  // Note: `removeAttributeQuotes`, `removeTagWhitespace`, and `decodeEntities`
4123
4435
  // stay disabled (inherited from SVG context) because the entire SVG block
4124
4436
  // must be valid XML for SVGO processing
4125
4437
  useParentNameForTag = true;
4126
4438
  }
4127
- tag = (useParentNameForTag ? options.parentName : options.name)(tag);
4439
+ tag = /** @type {(name: string) => string} */ (useParentNameForTag ? options.parentName : options.name)(tag);
4128
4440
  currentTag = tag;
4129
4441
  charsPrevTag = tag;
4130
4442
  if (!inlineTextSet.has(tag)) {
@@ -4190,10 +4502,10 @@ async function minifyHTML(value, options, partialMarkup) {
4190
4502
  // Remove duplicate attributes (per HTML spec, first occurrence wins)
4191
4503
  // Duplicate attributes result in invalid HTML
4192
4504
  // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
4193
- deduplicateAttributes(attrs, options.caseSensitive);
4505
+ deduplicateAttributes(attrs, Boolean(options.caseSensitive));
4194
4506
 
4195
4507
  if (options.sortAttributes) {
4196
- options.sortAttributes(tag, attrs);
4508
+ /** @type {Function} */ (options.sortAttributes)(tag, attrs);
4197
4509
  }
4198
4510
 
4199
4511
  const attrResults = attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML));
@@ -4223,7 +4535,7 @@ async function minifyHTML(value, options, partialMarkup) {
4223
4535
  currentTag = '';
4224
4536
  }
4225
4537
  },
4226
- end: function (tag, attrs, autoGenerated) {
4538
+ end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated) {
4227
4539
  const lowerTag = tag.toLowerCase();
4228
4540
  // Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
4229
4541
  if (lowerTag === 'svg' || lowerTag === 'math') {
@@ -4232,7 +4544,7 @@ async function minifyHTML(value, options, partialMarkup) {
4232
4544
  !options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
4233
4545
  options = Object.getPrototypeOf(options);
4234
4546
  }
4235
- tag = options.name(tag);
4547
+ tag = /** @type {Function} */ (options.name)(tag);
4236
4548
 
4237
4549
  // Check if current tag is in a whitespace stack
4238
4550
  if (options.collapseWhitespace) {
@@ -4278,7 +4590,7 @@ async function minifyHTML(value, options, partialMarkup) {
4278
4590
  let preserve = false;
4279
4591
  if (removeEmptyElementsExcept.length) {
4280
4592
  // Normalize attribute names for comparison with specs
4281
- const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
4593
+ const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
4282
4594
  preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
4283
4595
  }
4284
4596
 
@@ -4325,7 +4637,7 @@ async function minifyHTML(value, options, partialMarkup) {
4325
4637
  }
4326
4638
  }
4327
4639
  },
4328
- chars: function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
4640
+ chars: function (/** @type {string} */ text, /** @type {string} */ prevTag, /** @type {string} */ nextTag, /** @type {HTMLAttribute[]} */ prevAttrs, /** @type {HTMLAttribute[]} */ nextAttrs) {
4329
4641
  prevTag = prevTag === '' ? 'comment' : prevTag;
4330
4642
  nextTag = nextTag === '' ? 'comment' : nextTag;
4331
4643
  prevAttrs = prevAttrs || [];
@@ -4341,7 +4653,7 @@ async function minifyHTML(value, options, partialMarkup) {
4341
4653
  const needsMinifyCSS = options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
4342
4654
 
4343
4655
  // Whitespace collapsing phase (sync); captures `prevTag`/`nextTag`/`prevAttrs`/`nextAttrs` from outer scope
4344
- function charsCollapse(text) {
4656
+ function charsCollapse(/** @type {string} */ text) {
4345
4657
  // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
4346
4658
  // This removes trailing newlines often added by template engines before closing tags
4347
4659
  // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
@@ -4363,7 +4675,7 @@ async function minifyHTML(value, options, partialMarkup) {
4363
4675
  // to avoid side effects on the `inlineTextSet` branch below
4364
4676
  let effectivePrevTag = prevTag;
4365
4677
  if (prevTag === 'comment') {
4366
- const prevComment = buffer[buffer.length - 1];
4678
+ const prevComment = buffer[buffer.length - 1] ?? '';
4367
4679
  if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
4368
4680
  if (!prevComment) {
4369
4681
  prevTag = charsPrevTag;
@@ -4371,7 +4683,7 @@ async function minifyHTML(value, options, partialMarkup) {
4371
4683
  }
4372
4684
  if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
4373
4685
  const charsIndex = buffer.length - 2;
4374
- buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
4686
+ buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
4375
4687
  text = trailingSpaces + text;
4376
4688
  return '';
4377
4689
  });
@@ -4382,13 +4694,14 @@ async function minifyHTML(value, options, partialMarkup) {
4382
4694
  // when `nextTag` is `comment` (another UID placeholder), `commentFinalize` handles it
4383
4695
  const match = prevComment.match(uidIgnorePlaceholderPattern);
4384
4696
  if (match) {
4385
- const idx = +match[1];
4697
+ const idx = +(match[1] ?? '');
4386
4698
  if (idx < ignoredMarkupChunks.length) {
4387
4699
  const content = ignoredMarkupChunks[idx];
4388
4700
  const lastTagMatch = content && RE_LAST_HTML_TAG.exec(content);
4389
4701
  if (lastTagMatch) {
4390
- const isClose = lastTagMatch[1].charAt(0) === '/';
4391
- const tagName = options.name(isClose ? lastTagMatch[1].slice(1) : lastTagMatch[1]);
4702
+ const group = lastTagMatch[1] ?? '';
4703
+ const isClose = group.charAt(0) === '/';
4704
+ const tagName = resolveName(isClose ? group.slice(1) : group);
4392
4705
  effectivePrevTag = isClose ? '/' + tagName : tagName;
4393
4706
  }
4394
4707
  }
@@ -4399,13 +4712,13 @@ async function minifyHTML(value, options, partialMarkup) {
4399
4712
  if (prevTag === '/nobr' || prevTag === 'wbr') {
4400
4713
  if (/^\s/.test(text)) {
4401
4714
  let tagIndex = buffer.length - 1;
4402
- while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
4715
+ while (tagIndex > 0 && (buffer[tagIndex] ?? '').lastIndexOf('<' + prevTag) !== 0) {
4403
4716
  tagIndex--;
4404
4717
  }
4405
4718
  trimTrailingWhitespace(tagIndex - 1, 'br');
4406
4719
  }
4407
4720
  } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
4408
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
4721
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars), false);
4409
4722
  }
4410
4723
  }
4411
4724
  if (prevTag || nextTag) {
@@ -4425,18 +4738,18 @@ async function minifyHTML(value, options, partialMarkup) {
4425
4738
  }
4426
4739
 
4427
4740
  // Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
4428
- function charsFinalize(text) {
4741
+ function charsFinalize(/** @type {string} */ text) {
4429
4742
  if (options.removeOptionalTags && text) {
4430
4743
  // UID-attr tokens are padded with `\t`, which would falsely look like leading whitespace;
4431
4744
  // resolve single-token text to its actual content for the space/comment checks below
4432
4745
  let effectiveText = text;
4433
- if (uidAttrLeadingPattern && text.includes(uidAttr)) {
4746
+ if (uidAttrLeadingPattern && uidAttr && text.includes(uidAttr)) {
4434
4747
  const uidMatch = uidAttrLeadingPattern.exec(text);
4435
4748
  if (uidMatch) {
4436
- const idx = +uidMatch[1];
4749
+ const idx = +(uidMatch[1] ?? 0);
4437
4750
  const chunks = idx < ignoredCustomMarkupChunks.length ? ignoredCustomMarkupChunks[idx] : null;
4438
4751
  if (chunks != null) {
4439
- effectiveText = chunks[0];
4752
+ effectiveText = chunks[0] ?? '';
4440
4753
  }
4441
4754
  }
4442
4755
  }
@@ -4472,8 +4785,8 @@ async function minifyHTML(value, options, partialMarkup) {
4472
4785
  }
4473
4786
  }
4474
4787
  if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
4475
- text = text.replace(uidPattern, function (match, prefix, index) {
4476
- return ignoredCustomMarkupChunks[+index][0];
4788
+ text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ _prefix, /** @type {string} */ index) {
4789
+ return ignoredCustomMarkupChunks[+index]?.[0] ?? match;
4477
4790
  });
4478
4791
  }
4479
4792
  currentChars += text;
@@ -4499,20 +4812,20 @@ async function minifyHTML(value, options, partialMarkup) {
4499
4812
  text = await processScript(text, options, currentAttrs, minifyHTML);
4500
4813
  }
4501
4814
  if (needsMinifyJS) {
4502
- text = await options.minifyJS(text, false, isModuleScript);
4815
+ text = await /** @type {Function} */ (options.minifyJS)(text, false, isModuleScript);
4503
4816
  }
4504
4817
  if (needsMinifyCSS) {
4505
- text = await options.minifyCSS(text);
4818
+ text = await /** @type {Function} */ (options.minifyCSS)(text);
4506
4819
  }
4507
4820
  charsFinalize(text);
4508
4821
  })();
4509
4822
  },
4510
- comment: function (text, nonStandard) {
4823
+ comment: function (/** @type {string} */ text, /** @type {boolean} */ nonStandard) {
4511
4824
  const prefix = nonStandard ? '<!' : '<!--';
4512
4825
  const suffix = nonStandard ? '>' : '-->';
4513
4826
 
4514
4827
  // Finalization phase (sync): Optional tag handling, `htmlmin:ignore` whitespace collapsing, buffer push
4515
- function commentFinalize(comment) {
4828
+ function commentFinalize(/** @type {string} */ comment) {
4516
4829
  if (options.removeOptionalTags && comment) {
4517
4830
  if (uidIgnorePlaceholderPattern) {
4518
4831
  const match = uidIgnorePlaceholderPattern.exec(comment);
@@ -4521,11 +4834,12 @@ async function minifyHTML(value, options, partialMarkup) {
4521
4834
  // if there’s a pending optional end tag and the ignored content isn’t itself
4522
4835
  // a comment (which per the HTML spec prevents omission), resolve it now,
4523
4836
  // before the UID is pushed to the buffer
4524
- const idx = +match[1];
4837
+ const idx = +(match[1] ?? 0);
4525
4838
  const content = idx < ignoredMarkupChunks.length ? ignoredMarkupChunks[idx] : null;
4526
4839
  if (optionalEndTag && optionalEndTagEmitted && content != null && !/^\s*<!--/.test(content)) {
4527
4840
  const firstTagMatch = content.match(/^\s*<([a-zA-Z][^\s/>]*)/);
4528
- const firstTag = firstTagMatch ? options.name(firstTagMatch[1]) : '';
4841
+ const firstTagGroup = firstTagMatch?.[1] ?? '';
4842
+ const firstTag = firstTagGroup ? resolveName(firstTagGroup) : '';
4529
4843
  if (canRemovePrecedingTag(optionalEndTag, firstTag)) {
4530
4844
  removeEndTag();
4531
4845
  }
@@ -4553,8 +4867,8 @@ async function minifyHTML(value, options, partialMarkup) {
4553
4867
  const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
4554
4868
 
4555
4869
  if (currentMatch && prevMatch) {
4556
- const currentIndex = +currentMatch[1];
4557
- const prevIndex = +prevMatch[1];
4870
+ const currentIndex = +(currentMatch[1] ?? 0);
4871
+ const prevIndex = +(prevMatch[1] ?? 0);
4558
4872
 
4559
4873
  // Defensive bounds check to ensure indices are valid
4560
4874
  if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
@@ -4577,13 +4891,13 @@ async function minifyHTML(value, options, partialMarkup) {
4577
4891
  // Collapse if both sides are element/closing tags or HTML comments, and neither is inline
4578
4892
  if ((currentTagMatch || currentIsHtmlComment || currentClosingTagMatch) &&
4579
4893
  (prevTagMatch || prevIsHtmlComment || prevClosingTagMatch)) {
4580
- const currentTag = currentTagMatch ? options.name(currentTagMatch[1])
4581
- : currentClosingTagMatch ? options.name(currentClosingTagMatch[1]) : null;
4582
- const prevTag = prevTagMatch ? options.name(prevTagMatch[1])
4583
- : prevClosingTagMatch ? options.name(prevClosingTagMatch[1]) : null;
4894
+ const currentTag = currentTagMatch ? resolveName(currentTagMatch[1] ?? '')
4895
+ : currentClosingTagMatch ? resolveName(currentClosingTagMatch[1] ?? '') : null;
4896
+ const prevTag = prevTagMatch ? resolveName(prevTagMatch[1] ?? '')
4897
+ : prevClosingTagMatch ? resolveName(prevClosingTagMatch[1] ?? '') : null;
4584
4898
 
4585
4899
  // Don’t collapse between inline elements (HTML comments count as non-inline)
4586
- if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
4900
+ if (!inlineElements.has(currentTag ?? '') && !inlineElements.has(prevTag ?? '')) {
4587
4901
  // Collapse whitespace respecting context rules
4588
4902
  let collapsedText = prevText;
4589
4903
 
@@ -4618,7 +4932,7 @@ async function minifyHTML(value, options, partialMarkup) {
4618
4932
  }
4619
4933
 
4620
4934
  if (options.removeComments) {
4621
- if (isIgnoredComment(text, options)) {
4935
+ if (isIgnoredComment(text, /** @type {any} */ (options))) {
4622
4936
  text = prefix + text + suffix;
4623
4937
  } else {
4624
4938
  text = '';
@@ -4628,7 +4942,7 @@ async function minifyHTML(value, options, partialMarkup) {
4628
4942
  }
4629
4943
  commentFinalize(text);
4630
4944
  },
4631
- doctype: function (doctype) {
4945
+ doctype: function (/** @type {string} */ doctype) {
4632
4946
  buffer.push(options.useShortDoctype
4633
4947
  ? '<!doctype' +
4634
4948
  (options.removeTagWhitespace ? '' : ' ') + 'html>'
@@ -4643,11 +4957,12 @@ async function minifyHTML(value, options, partialMarkup) {
4643
4957
  if (options.minifySVG && svgBlocks.length) {
4644
4958
  const optimized = await Promise.all(
4645
4959
  svgBlocks.map(({ start, end }) =>
4646
- options.minifySVG(buffer.slice(start, end).join(''))
4960
+ /** @type {Function} */ (options.minifySVG)(buffer.slice(start, end).join(''))
4647
4961
  )
4648
4962
  );
4649
4963
  for (let i = svgBlocks.length - 1; i >= 0; i--) {
4650
- buffer.splice(svgBlocks[i].start, svgBlocks[i].end - svgBlocks[i].start, optimized[i]);
4964
+ const block = svgBlocks[i];
4965
+ if (block) buffer.splice(block.start, block.end - block.start, optimized[i] ?? '');
4651
4966
  }
4652
4967
  }
4653
4968
 
@@ -4667,9 +4982,9 @@ async function minifyHTML(value, options, partialMarkup) {
4667
4982
  }
4668
4983
 
4669
4984
  return joinResultSegments(buffer, options, uidPattern
4670
- ? function (str) {
4671
- return str.replace(uidPattern, function (match, prefix, index, suffix) {
4672
- let chunk = ignoredCustomMarkupChunks[+index][0];
4985
+ ? function (/** @type {string} */ str) {
4986
+ return str.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string} */ index, /** @type {string} */ suffix) {
4987
+ let chunk = ignoredCustomMarkupChunks[+index]?.[0] ?? match;
4673
4988
  if (options.collapseWhitespace) {
4674
4989
  if (prefix !== '\t') {
4675
4990
  chunk = prefix + chunk;
@@ -4686,14 +5001,20 @@ async function minifyHTML(value, options, partialMarkup) {
4686
5001
  });
4687
5002
  }
4688
5003
  : identity, uidIgnore
4689
- ? function (str) {
4690
- return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (match, index) {
4691
- return ignoredMarkupChunks[+index];
5004
+ ? function (/** @type {string} */ str) {
5005
+ return str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function (/** @type {string} */ _match, /** @type {string} */ index) {
5006
+ return ignoredMarkupChunks[+index] ?? '';
4692
5007
  });
4693
5008
  }
4694
5009
  : identity);
4695
5010
  }
4696
5011
 
5012
+ /**
5013
+ * @param {string[]} results
5014
+ * @param {MinifierOptions} options
5015
+ * @param {Function} restoreCustom
5016
+ * @param {Function} restoreIgnore
5017
+ */
4697
5018
  function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
4698
5019
  let str;
4699
5020
  const maxLineLength = options.maxLineLength;
@@ -4703,15 +5024,16 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
4703
5024
  let line = ''; const lines = [];
4704
5025
  while (results.length) {
4705
5026
  const len = line.length;
4706
- const end = results[0].indexOf('\n');
4707
- const isClosingTag = Boolean(results[0].match(endTag));
5027
+ const cur = results[0] ?? '';
5028
+ const end = cur.indexOf('\n');
5029
+ const isClosingTag = Boolean(cur.match(endTag));
4708
5030
  const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
4709
5031
 
4710
5032
  if (end < 0) {
4711
- line += restoreIgnore(restoreCustom(results.shift()));
5033
+ line += restoreIgnore(restoreCustom(results.shift() ?? ''));
4712
5034
  } else {
4713
- line += restoreIgnore(restoreCustom(results[0].slice(0, end)));
4714
- results[0] = results[0].slice(end + 1);
5035
+ line += restoreIgnore(restoreCustom(cur.slice(0, end)));
5036
+ results[0] = cur.slice(end + 1);
4715
5037
  }
4716
5038
  if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
4717
5039
  lines.push(line.slice(0, len));
@@ -4741,13 +5063,14 @@ function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
4741
5063
  * - The first call’s options determine the cache sizes for subsequent calls
4742
5064
  * - Invalid values (NaN, Infinity) fall back to the default size (500); values below `1` are clamped to `1`
4743
5065
  */
5066
+ /** @param {MinifierOptions} options */
4744
5067
  function initCaches(options) {
4745
5068
  // Only create caches once (on first call)—sizes are locked after this
4746
5069
  if (!cssMinifyCache) {
4747
5070
  const defaultSize = 500;
4748
5071
 
4749
5072
  // Helper to parse env var—returns parsed number (including 0) or undefined if absent, invalid, or negative
4750
- const parseEnvCacheSize = (envVar) => {
5073
+ const parseEnvCacheSize = (/** @type {string | undefined} */ envVar) => {
4751
5074
  if (envVar === undefined) return undefined;
4752
5075
  const parsed = Number(envVar);
4753
5076
  if (Number.isNaN(parsed) || !Number.isFinite(parsed) || parsed < 0) {
@@ -4757,7 +5080,7 @@ function initCaches(options) {
4757
5080
  };
4758
5081
 
4759
5082
  // Sanitize a cache size: Non-finite/NaN falls back to `defaultSize`; otherwise clamped to min 1 and floored
4760
- const sanitizeSize = (size) => Number.isFinite(size) ? Math.max(1, Math.floor(size)) : defaultSize;
5083
+ const sanitizeSize = (/** @type {number} */ size) => Number.isFinite(size) ? Math.max(1, Math.floor(size)) : defaultSize;
4761
5084
 
4762
5085
  // Get cache sizes with precedence: Options > env > default
4763
5086
  const cssSize = options.cacheCSS !== undefined ? options.cacheCSS
@@ -4795,7 +5118,9 @@ const minify = async function (value, options) {
4795
5118
  getTerser,
4796
5119
  getSwc,
4797
5120
  getSvgo,
4798
- ...caches
5121
+ cssMinifyCache: caches.cssMinifyCache ?? undefined,
5122
+ jsMinifyCache: caches.jsMinifyCache ?? undefined,
5123
+ svgMinifyCache: caches.svgMinifyCache ?? undefined
4799
5124
  });
4800
5125
  let result = await minifyHTML(value, options);
4801
5126
 
@@ -4804,7 +5129,7 @@ const minify = async function (value, options) {
4804
5129
  result = mergeConsecutiveScripts(result);
4805
5130
  }
4806
5131
 
4807
- options.log('minified in: ' + (Date.now() - start) + 'ms');
5132
+ /** @type {Function} */ (options.log)('minified in: ' + (Date.now() - start) + 'ms');
4808
5133
  return result;
4809
5134
  };
4810
5135