html-minifier-next 4.9.1 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,6 +112,8 @@ HTML Minifier Next provides presets for common use cases. Presets are pre-config
112
112
  * `conservative`: Safe minification suitable for most projects. Includes whitespace collapsing, comment removal, and doctype normalization.
113
113
  * `comprehensive`: Aggressive minification for maximum file size reduction. Includes relevant conservative options plus attribute quote removal, optional tag removal, and more.
114
114
 
115
+ To review the specific options set, [presets.js](https://github.com/j9t/html-minifier-next/blob/main/src/presets.js) lists them in an accessible manner.
116
+
115
117
  **Using presets:**
116
118
 
117
119
  ```bash
@@ -233,30 +235,34 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
233
235
  | Site | Original Size (KB) | [HTML Minifier Next](https://github.com/j9t/html-minifier-next)<br>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-next)](https://socket.dev/npm/package/html-minifier-next) | [HTML Minifier Terser](https://github.com/terser/html-minifier-terser)<br>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-terser)](https://socket.dev/npm/package/html-minifier-terser) | [htmlnano](https://github.com/posthtml/htmlnano)<br>[![npm last update](https://img.shields.io/npm/last-update/htmlnano)](https://socket.dev/npm/package/htmlnano) | [@swc/html](https://github.com/swc-project/swc)<br>[![npm last update](https://img.shields.io/npm/last-update/@swc/html)](https://socket.dev/npm/package/@swc/html) | [minify-html](https://github.com/wilsonzlin/minify-html)<br>[![npm last update](https://img.shields.io/npm/last-update/@minify-html/node)](https://socket.dev/npm/package/@minify-html/node) | [minimize](https://github.com/Swaagie/minimize)<br>[![npm last update](https://img.shields.io/npm/last-update/minimize)](https://socket.dev/npm/package/minimize) | [html­com­pressor.­com](https://htmlcompressor.com/) |
234
236
  | --- | --- | --- | --- | --- | --- | --- | --- | --- |
235
237
  | [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
236
- | [Apple](https://www.apple.com/) | 260 | **204** | **204** | 232 | 235 | 236 | 238 | 239 |
237
- | [BBC](https://www.bbc.co.uk/) | 803 | **683** | 693 | 746 | 757 | 760 | 796 | n/a |
238
- | [CSS-Tricks](https://css-tricks.com/) | 161 | 121 | **119** | 126 | 142 | 142 | 147 | 143 |
238
+ | [Apple](https://www.apple.com/) | 260 | **203** | 204 | 231 | 235 | 236 | 238 | 238 |
239
+ | [BBC](https://www.bbc.co.uk/) | 720 | **655** | 665 | 677 | 677 | 678 | 714 | n/a |
240
+ | [CERN](https://home.cern/) | 156 | **87** | **87** | 95 | 95 | 95 | 97 | 100 |
241
+ | [CSS-Tricks](https://css-tricks.com/) | 163 | 122 | **120** | 128 | 143 | 144 | 149 | 145 |
239
242
  | [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6341** | **6341** | 6561 | 6444 | 6567 | 6614 | n/a |
240
- | [EFF](https://www.eff.org/) | 56 | **47** | 48 | 50 | 49 | 49 | 51 | 51 |
241
- | [FAZ](https://www.faz.net/aktuell/) | 1602 | 1492 | 1497 | **1435** | 1525 | 1537 | 1548 | n/a |
242
- | [Frontend Dogma](https://frontenddogma.com/) | 222 | **212** | 213 | 234 | 219 | 221 | 240 | 221 |
243
+ | [EDRi](https://edri.org/) | 80 | **59** | 60 | 70 | 70 | 71 | 75 | 73 |
244
+ | [EFF](https://www.eff.org/) | 56 | **48** | **48** | 50 | 49 | 49 | 51 | 51 |
245
+ | [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | **30** | 32 | 32 | 32 | 32 | 32 |
246
+ | [FAZ](https://www.faz.net/aktuell/) | 1562 | 1455 | 1460 | **1400** | 1487 | 1498 | 1509 | n/a |
247
+ | [French Tech](https://lafrenchtech.gouv.fr/) | 152 | **121** | 122 | 125 | 125 | 125 | 132 | 126 |
248
+ | [Frontend Dogma](https://frontenddogma.com/) | 222 | **213** | 215 | 236 | 221 | 222 | 241 | 222 |
243
249
  | [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
244
- | [Ground News](https://ground.news/) | 1576 | **1353** | 1356 | 1450 | 1473 | 1478 | 1563 | n/a |
250
+ | [Ground News](https://ground.news/) | 2339 | **2052** | 2056 | 2151 | 2179 | 2182 | 2326 | n/a |
245
251
  | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | **147** | 153 | **147** | 149 | 155 | 149 |
246
- | [Igalia](https://www.igalia.com/) | 49 | **33** | **33** | 35 | 35 | 35 | 36 | 36 |
247
- | [Leanpub](https://leanpub.com/) | 2036 | **1755** | **1755** | 1762 | 1761 | 1759 | 2031 | n/a |
248
- | [Mastodon](https://mastodon.social/explore) | 36 | **27** | **27** | 31 | 34 | 34 | 35 | 35 |
249
- | [MDN](https://developer.mozilla.org/en-US/) | 107 | **61** | **61** | 63 | 63 | 64 | 66 | 67 |
250
- | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **195** | 196 | 203 | 201 | 200 | 202 | 203 |
251
- | [Nielsen Norman Group](https://www.nngroup.com/) | 84 | 72 | 72 | **53** | 72 | 73 | 74 | 73 |
252
- | [SitePoint](https://www.sitepoint.com/) | 487 | **346** | **346** | 424 | 461 | 466 | 484 | n/a |
252
+ | [Igalia](https://www.igalia.com/) | 50 | **33** | **33** | 36 | 36 | 36 | 37 | 36 |
253
+ | [Leanpub](https://leanpub.com/) | 1204 | **997** | **997** | 1004 | 1003 | 1001 | 1198 | n/a |
254
+ | [Mastodon](https://mastodon.social/explore) | 37 | **27** | **27** | 32 | 34 | 35 | 35 | 35 |
255
+ | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | **62** | 64 | 65 | 65 | 68 | 68 |
256
+ | [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** | 196 | 202 | 200 | 200 | 202 | 203 |
257
+ | [Nielsen Norman Group](https://www.nngroup.com/) | 84 | 71 | 71 | **53** | 71 | 73 | 74 | 73 |
258
+ | [SitePoint](https://www.sitepoint.com/) | 485 | **354** | **354** | 425 | 459 | 464 | 481 | n/a |
253
259
  | [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 38 | 38 | 39 | 39 |
254
- | [TPGi](https://www.tpgi.com/) | 175 | **160** | 162 | **160** | 165 | 166 | 172 | 172 |
255
- | [United Nations](https://www.un.org/en/) | 150 | **112** | 113 | 120 | 124 | 124 | 129 | 122 |
256
- | [W3C](https://www.w3.org/) | 50 | **35** | 36 | 38 | 38 | 38 | 40 | 38 |
257
- | **Average processing time** | | 338 ms (22/22) | 385 ms (22/22) | 191 ms (22/22) | 70 ms (22/22) | **18 ms (22/22)** | 365 ms (22/22) | 1403 ms (16/22) |
260
+ | [TPGi](https://www.tpgi.com/) | 175 | **159** | 161 | 160 | 164 | 165 | 172 | 171 |
261
+ | [United Nations](https://www.un.org/en/) | 151 | **112** | 114 | 121 | 125 | 124 | 130 | 123 |
262
+ | [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
263
+ | **Average processing time** | | 302 ms (26/26) | 356 ms (26/26) | 178 ms (26/26) | 59 ms (26/26) | **16 ms (26/26)** | 329 ms (26/26) | 1468 ms (20/26) |
258
264
 
259
- (Last updated: Dec 12, 2025)
265
+ (Last updated: Dec 16, 2025)
260
266
  <!-- End auto-generated -->
261
267
 
262
268
  ## Examples
@@ -5,18 +5,6 @@ Object.defineProperty(exports, '__esModule', { value: true });
5
5
  var entities = require('entities');
6
6
  var RelateURL = require('relateurl');
7
7
 
8
- async function replaceAsync(str, regex, asyncFn) {
9
- const promises = [];
10
-
11
- str.replace(regex, (match, ...args) => {
12
- const promise = asyncFn(match, ...args);
13
- promises.push(promise);
14
- });
15
-
16
- const data = await Promise.all(promises);
17
- return str.replace(regex, () => data.shift());
18
- }
19
-
20
8
  /*!
21
9
  * HTML Parser By John Resig (ejohn.org)
22
10
  * Modified by Juriy “kangax” Zaytsev
@@ -24,6 +12,15 @@ async function replaceAsync(str, regex, asyncFn) {
24
12
  * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
25
13
  */
26
14
 
15
+ /*
16
+ * // Use like so:
17
+ * HTMLParser(htmlString, {
18
+ * start: function(tag, attrs, unary) {},
19
+ * end: function(tag) {},
20
+ * chars: function(text) {},
21
+ * comment: function(text) {}
22
+ * });
23
+ */
27
24
 
28
25
  class CaseInsensitiveSet extends Set {
29
26
  has(str) {
@@ -92,6 +89,9 @@ const preCompiledStackedTags = {
92
89
  'noscript': /([\s\S]*?)<\/noscript[^>]*>/i
93
90
  };
94
91
 
92
+ // Cache for compiled attribute regexes per handler configuration
93
+ const attrRegexCache = new WeakMap();
94
+
95
95
  function attrForHandler(handler) {
96
96
  let pattern = singleAttrIdentifier.source +
97
97
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
@@ -129,22 +129,47 @@ class HTMLParser {
129
129
  }
130
130
 
131
131
  async parse() {
132
- let html = this.html;
133
132
  const handler = this.handler;
133
+ const fullHtml = this.html;
134
+ const fullLength = fullHtml.length;
134
135
 
135
136
  const stack = []; let lastTag;
136
- const attribute = attrForHandler(handler);
137
- let last, prevTag = undefined, nextTag = undefined;
138
-
139
- // Track position for better error messages
140
- let position = 0;
141
- const getLineColumn = (pos) => {
142
- const lines = this.html.slice(0, pos).split('\n');
143
- return { line: lines.length, column: lines[lines.length - 1].length + 1 };
137
+ // Use cached attribute regex if available
138
+ let attribute = attrRegexCache.get(handler);
139
+ if (!attribute) {
140
+ attribute = attrForHandler(handler);
141
+ attrRegexCache.set(handler, attribute);
142
+ }
143
+ let prevTag = undefined, nextTag = undefined;
144
+
145
+ // Index-based parsing
146
+ let pos = 0;
147
+ let lastPos;
148
+
149
+ // Helper to get remaining HTML from current position
150
+ const remaining = () => fullHtml.slice(pos);
151
+
152
+ // Helper to advance position
153
+ const advance = (n) => { pos += n; };
154
+
155
+ // Lazy line/column calculation—only compute on actual errors
156
+ const getLineColumn = (position) => {
157
+ let line = 1;
158
+ let column = 1;
159
+ for (let i = 0; i < position; i++) {
160
+ if (fullHtml[i] === '\n') {
161
+ line++;
162
+ column = 1;
163
+ } else {
164
+ column++;
165
+ }
166
+ }
167
+ return { line, column };
144
168
  };
145
169
 
146
- while (html) {
147
- last = html;
170
+ while (pos < fullLength) {
171
+ lastPos = pos;
172
+ const html = remaining();
148
173
  // Make sure we’re not in a `script` or `style` element
149
174
  if (!lastTag || !special.has(lastTag)) {
150
175
  let textEnd = html.indexOf('<');
@@ -157,7 +182,7 @@ class HTMLParser {
157
182
  if (handler.comment) {
158
183
  await handler.comment(html.substring(4, commentEnd));
159
184
  }
160
- html = html.substring(commentEnd + 3);
185
+ advance(commentEnd + 3);
161
186
  prevTag = '';
162
187
  continue;
163
188
  }
@@ -171,7 +196,7 @@ class HTMLParser {
171
196
  if (handler.comment) {
172
197
  await handler.comment(html.substring(2, conditionalEnd + 1), true /* non-standard */);
173
198
  }
174
- html = html.substring(conditionalEnd + 2);
199
+ advance(conditionalEnd + 2);
175
200
  prevTag = '';
176
201
  continue;
177
202
  }
@@ -183,7 +208,7 @@ class HTMLParser {
183
208
  if (handler.doctype) {
184
209
  handler.doctype(doctypeMatch[0]);
185
210
  }
186
- html = html.substring(doctypeMatch[0].length);
211
+ advance(doctypeMatch[0].length);
187
212
  prevTag = '';
188
213
  continue;
189
214
  }
@@ -191,8 +216,8 @@ class HTMLParser {
191
216
  // End tag
192
217
  const endTagMatch = html.match(endTag);
193
218
  if (endTagMatch) {
194
- html = html.substring(endTagMatch[0].length);
195
- await replaceAsync(endTagMatch[0], endTag, parseEndTag);
219
+ advance(endTagMatch[0].length);
220
+ await parseEndTag(endTagMatch[0], endTagMatch[1]);
196
221
  prevTag = '/' + endTagMatch[1].toLowerCase();
197
222
  continue;
198
223
  }
@@ -200,7 +225,7 @@ class HTMLParser {
200
225
  // Start tag
201
226
  const startTagMatch = parseStartTag(html);
202
227
  if (startTagMatch) {
203
- html = startTagMatch.rest;
228
+ advance(startTagMatch.advance);
204
229
  await handleStartTag(startTagMatch);
205
230
  prevTag = startTagMatch.tagName.toLowerCase();
206
231
  continue;
@@ -215,18 +240,19 @@ class HTMLParser {
215
240
  let text;
216
241
  if (textEnd >= 0) {
217
242
  text = html.substring(0, textEnd);
218
- html = html.substring(textEnd);
243
+ advance(textEnd);
219
244
  } else {
220
245
  text = html;
221
- html = '';
246
+ advance(html.length);
222
247
  }
223
248
 
224
249
  // Next tag
225
- let nextTagMatch = parseStartTag(html);
250
+ const nextHtml = remaining();
251
+ let nextTagMatch = parseStartTag(nextHtml);
226
252
  if (nextTagMatch) {
227
253
  nextTag = nextTagMatch.tagName;
228
254
  } else {
229
- nextTagMatch = html.match(endTag);
255
+ nextTagMatch = nextHtml.match(endTag);
230
256
  if (nextTagMatch) {
231
257
  nextTag = '/' + nextTagMatch[1];
232
258
  } else {
@@ -243,45 +269,50 @@ class HTMLParser {
243
269
  // Use pre-compiled regex for common tags (`script`, `style`, `noscript`) to avoid regex creation overhead
244
270
  const reStackedTag = preCompiledStackedTags[stackedTag] || reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)</' + stackedTag + '[^>]*>', 'i'));
245
271
 
246
- html = await replaceAsync(html, reStackedTag, async (_, text) => {
272
+ const m = reStackedTag.exec(html);
273
+ if (m) {
274
+ let text = m[1];
247
275
  if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') {
248
276
  text = text
249
277
  .replace(/<!--([\s\S]*?)-->/g, '$1')
250
278
  .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
251
279
  }
252
-
253
280
  if (handler.chars) {
254
281
  await handler.chars(text);
255
282
  }
256
-
257
- return '';
258
- });
259
-
260
- await parseEndTag('</' + stackedTag + '>', stackedTag);
283
+ // Advance HTML past the matched special tag content and its closing tag
284
+ advance(m.index + m[0].length);
285
+ await parseEndTag('</' + stackedTag + '>', stackedTag);
286
+ } else {
287
+ // No closing tag found; to avoid infinite loop, break similarly to previous behavior
288
+ if (handler.continueOnParseError && handler.chars && html) {
289
+ await handler.chars(html[0], prevTag, '');
290
+ advance(1);
291
+ } else {
292
+ break;
293
+ }
294
+ }
261
295
  }
262
296
 
263
- if (html === last) {
297
+ if (pos === lastPos) {
264
298
  if (handler.continueOnParseError) {
265
299
  // Skip the problematic character and continue
266
300
  if (handler.chars) {
267
- await handler.chars(html[0], prevTag, '');
301
+ await handler.chars(fullHtml[pos], prevTag, '');
268
302
  }
269
- html = html.substring(1);
270
- position++;
303
+ advance(1);
271
304
  prevTag = '';
272
305
  continue;
273
306
  }
274
- const loc = getLineColumn(position);
275
- // Include some context before the error position so the snippet contains
276
- // the offending markup plus preceding characters (e.g. "invalid<tag").
307
+ const loc = getLineColumn(pos);
308
+ // Include some context before the error position so the snippet contains the offending markup plus preceding characters (e.g., “invalid<tag”)
277
309
  const CONTEXT_BEFORE = 50;
278
- const startPos = Math.max(0, position - CONTEXT_BEFORE);
279
- const snippet = this.html.slice(startPos, startPos + 200).replace(/\n/g, ' ');
310
+ const startPos = Math.max(0, pos - CONTEXT_BEFORE);
311
+ const snippet = fullHtml.slice(startPos, startPos + 200).replace(/\n/g, ' ');
280
312
  throw new Error(
281
- `Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${this.html.length > startPos + 200 ? '…' : ''}`
313
+ `Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${fullHtml.length > startPos + 200 ? '…' : ''}`
282
314
  );
283
315
  }
284
- position = this.html.length - html.length;
285
316
  }
286
317
 
287
318
  if (!handler.partialMarkup) {
@@ -294,9 +325,11 @@ class HTMLParser {
294
325
  if (start) {
295
326
  const match = {
296
327
  tagName: start[1],
297
- attrs: []
328
+ attrs: [],
329
+ advance: 0
298
330
  };
299
- input = input.slice(start[0].length);
331
+ let consumed = start[0].length;
332
+ input = input.slice(consumed);
300
333
  let end, attr;
301
334
 
302
335
  // Safety limit: max length of input to check for attributes
@@ -346,7 +379,9 @@ class HTMLParser {
346
379
  } else {
347
380
  attr[baseIndex + 3] = value; // Single-quoted value
348
381
  }
349
- input = input.slice(fullAttr.length);
382
+ const attrLen = fullAttr.length;
383
+ input = input.slice(attrLen);
384
+ consumed += attrLen;
350
385
  match.attrs.push(attr);
351
386
  continue;
352
387
  }
@@ -363,7 +398,9 @@ class HTMLParser {
363
398
  break;
364
399
  }
365
400
 
366
- input = input.slice(attr[0].length);
401
+ const attrLen = attr[0].length;
402
+ input = input.slice(attrLen);
403
+ consumed += attrLen;
367
404
  match.attrs.push(attr);
368
405
  }
369
406
 
@@ -371,7 +408,8 @@ class HTMLParser {
371
408
  end = input.match(startTagClose);
372
409
  if (end) {
373
410
  match.unarySlash = end[1];
374
- match.rest = input.slice(end[0].length);
411
+ consumed += end[0].length;
412
+ match.advance = consumed;
375
413
  return match;
376
414
  }
377
415
  }
@@ -381,7 +419,7 @@ class HTMLParser {
381
419
  let pos;
382
420
  const needle = tagName.toLowerCase();
383
421
  for (pos = stack.length - 1; pos >= 0; pos--) {
384
- const currentTag = stack[pos].tag.toLowerCase();
422
+ const currentTag = stack[pos].lowerTag;
385
423
  if (currentTag === needle) {
386
424
  return pos;
387
425
  }
@@ -435,7 +473,7 @@ class HTMLParser {
435
473
  }
436
474
  if (tagName === 'col' && findTag('colgroup') < 0) {
437
475
  lastTag = 'colgroup';
438
- stack.push({ tag: lastTag, attrs: [] });
476
+ stack.push({ tag: lastTag, lowerTag: 'colgroup', attrs: [] });
439
477
  if (handler.start) {
440
478
  await handler.start(lastTag, [], false, '');
441
479
  }
@@ -514,7 +552,7 @@ class HTMLParser {
514
552
  });
515
553
 
516
554
  if (!unary) {
517
- stack.push({ tag: tagName, attrs });
555
+ stack.push({ tag: tagName, lowerTag: tagName.toLowerCase(), attrs });
518
556
  lastTag = tagName;
519
557
  unarySlash = '';
520
558
  }
@@ -528,7 +566,7 @@ class HTMLParser {
528
566
  let pos;
529
567
  const needle = tagName.toLowerCase();
530
568
  for (pos = stack.length - 1; pos >= 0; pos--) {
531
- if (stack[pos].tag.toLowerCase() === needle) {
569
+ if (stack[pos].lowerTag === needle) {
532
570
  break;
533
571
  }
534
572
  }
@@ -580,21 +618,40 @@ class HTMLParser {
580
618
  class Sorter {
581
619
  sort(tokens, fromIndex = 0) {
582
620
  for (let i = 0, len = this.keys.length; i < len; i++) {
583
- const key = this.keys[i];
584
- const token = key.slice(1);
621
+ const token = this.keys[i];
622
+
623
+ // Build position map for this token to avoid repeated `indexOf`
624
+ const positions = [];
625
+ for (let j = fromIndex; j < tokens.length; j++) {
626
+ if (tokens[j] === token) {
627
+ positions.push(j);
628
+ }
629
+ }
585
630
 
586
- let index = tokens.indexOf(token, fromIndex);
631
+ if (positions.length > 0) {
632
+ // Build new array with tokens in sorted order instead of splicing
633
+ const result = [];
587
634
 
588
- if (index !== -1) {
589
- do {
590
- if (index !== fromIndex) {
591
- tokens.splice(index, 1);
592
- tokens.splice(fromIndex, 0, token);
635
+ // Add all instances of the current token first
636
+ for (let j = 0; j < positions.length; j++) {
637
+ result.push(token);
638
+ }
639
+
640
+ // Add other tokens, skipping positions where current token was
641
+ const posSet = new Set(positions);
642
+ for (let j = fromIndex; j < tokens.length; j++) {
643
+ if (!posSet.has(j)) {
644
+ result.push(tokens[j]);
593
645
  }
594
- fromIndex++;
595
- } while ((index = tokens.indexOf(token, fromIndex)) !== -1);
646
+ }
647
+
648
+ // Copy sorted portion back to tokens array
649
+ for (let j = 0; j < result.length; j++) {
650
+ tokens[fromIndex + j] = result[j];
651
+ }
596
652
 
597
- return this[key].sort(tokens, fromIndex);
653
+ const newFromIndex = fromIndex + positions.length;
654
+ return this.sorterMap.get(token).sort(tokens, newFromIndex);
598
655
  }
599
656
  }
600
657
  return tokens;
@@ -602,48 +659,84 @@ class Sorter {
602
659
  }
603
660
 
604
661
  class TokenChain {
662
+ constructor() {
663
+ // Use Map instead of object properties for better performance
664
+ this.map = new Map();
665
+ }
666
+
605
667
  add(tokens) {
606
668
  tokens.forEach((token) => {
607
- const key = '$' + token;
608
- if (!this[key]) {
609
- this[key] = [];
610
- this[key].processed = 0;
669
+ if (!this.map.has(token)) {
670
+ this.map.set(token, { arrays: [], processed: 0 });
611
671
  }
612
- this[key].push(tokens);
672
+ this.map.get(token).arrays.push(tokens);
613
673
  });
614
674
  }
615
675
 
616
676
  createSorter() {
617
677
  const sorter = new Sorter();
678
+ sorter.sorterMap = new Map();
679
+
680
+ // Convert Map entries to array and sort
681
+ const entries = Array.from(this.map.entries()).sort((a, b) => {
682
+ const m = a[1].arrays.length;
683
+ const n = b[1].arrays.length;
684
+ // Sort by length descending (larger first)
685
+ const lengthDiff = n - m;
686
+ if (lengthDiff !== 0) return lengthDiff;
687
+ // If lengths equal, sort by key ascending
688
+ return a[0].localeCompare(b[0]);
689
+ });
618
690
 
619
- sorter.keys = Object.keys(this).sort((j, k) => {
620
- const m = this[j].length;
621
- const n = this[k].length;
622
- return m < n ? 1 : m > n ? -1 : j < k ? -1 : j > k ? 1 : 0;
623
- }).filter((key) => {
624
- if (this[key].processed < this[key].length) {
625
- const token = key.slice(1);
691
+ sorter.keys = [];
692
+
693
+ entries.forEach(([token, data]) => {
694
+ if (data.processed < data.arrays.length) {
626
695
  const chain = new TokenChain();
627
696
 
628
- this[key].forEach((tokens) => {
629
- let index;
630
- while ((index = tokens.indexOf(token)) !== -1) {
631
- tokens.splice(index, 1);
697
+ data.arrays.forEach((tokens) => {
698
+ // Build new array without the current token instead of splicing
699
+ const filtered = [];
700
+ for (let i = 0; i < tokens.length; i++) {
701
+ if (tokens[i] !== token) {
702
+ filtered.push(tokens[i]);
703
+ }
632
704
  }
633
- tokens.forEach((token) => {
634
- this['$' + token].processed++;
705
+
706
+ // Mark remaining tokens as processed
707
+ filtered.forEach((t) => {
708
+ const tData = this.map.get(t);
709
+ if (tData) {
710
+ tData.processed++;
711
+ }
635
712
  });
636
- chain.add(tokens.slice(0));
713
+
714
+ if (filtered.length > 0) {
715
+ chain.add(filtered);
716
+ }
637
717
  });
638
- sorter[key] = chain.createSorter();
639
- return true;
718
+
719
+ sorter.keys.push(token);
720
+ sorter.sorterMap.set(token, chain.createSorter());
640
721
  }
641
- return false;
642
722
  });
723
+
643
724
  return sorter;
644
725
  }
645
726
  }
646
727
 
728
+ async function replaceAsync(str, regex, asyncFn) {
729
+ const promises = [];
730
+
731
+ str.replace(regex, (match, ...args) => {
732
+ const promise = asyncFn(match, ...args);
733
+ promises.push(promise);
734
+ });
735
+
736
+ const data = await Promise.all(promises);
737
+ return str.replace(regex, () => data.shift());
738
+ }
739
+
647
740
  /**
648
741
  * Preset configurations for HTML Minifier Next
649
742
  *
@@ -2294,7 +2387,9 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2294
2387
  currentType === 'text/html') {
2295
2388
  await scan(text);
2296
2389
  }
2297
- }
2390
+ },
2391
+ // We never need `nextTag` information in this scan
2392
+ wantsNextTag: false
2298
2393
  });
2299
2394
 
2300
2395
  await parser.parse();
@@ -2530,6 +2625,8 @@ async function minifyHTML(value, options, partialMarkup) {
2530
2625
  customAttrAssign: options.customAttrAssign,
2531
2626
  customAttrSurround: options.customAttrSurround,
2532
2627
  html5: options.html5,
2628
+ // Compute `nextTag` only when whitespace collapse features require it
2629
+ wantsNextTag: !!(options.collapseWhitespace || options.collapseInlineTagWhitespace || options.conservativeCollapse),
2533
2630
 
2534
2631
  start: async function (tag, attrs, unary, unarySlash, autoGenerated) {
2535
2632
  if (tag.toLowerCase() === 'svg') {