html-minifier-next 5.1.2 → 5.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,106 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
+ // Stringify for options signatures (sorted keys, shallow, nested objects)
6
+
7
+ function stableStringify(obj) {
8
+ if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
9
+ if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
10
+ const keys = Object.keys(obj).sort();
11
+ let out = '{';
12
+ 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 ? ',' : '');
15
+ }
16
+ return out + '}';
17
+ }
18
+
19
+ // LRU cache for strings and promises
20
+
21
+ class LRU {
22
+ constructor(limit = 200) {
23
+ this.limit = limit;
24
+ this.map = new Map();
25
+ }
26
+ get(key) {
27
+ if (this.map.has(key)) {
28
+ const v = this.map.get(key);
29
+ this.map.delete(key);
30
+ this.map.set(key, v);
31
+ return v;
32
+ }
33
+ return undefined;
34
+ }
35
+ set(key, value) {
36
+ if (this.map.has(key)) this.map.delete(key);
37
+ this.map.set(key, value);
38
+ if (this.map.size > this.limit) {
39
+ const first = this.map.keys().next().value;
40
+ this.map.delete(first);
41
+ }
42
+ }
43
+ delete(key) { this.map.delete(key); }
44
+ }
45
+
46
+ // Unique ID generator
47
+
48
+ function uniqueId(value) {
49
+ let id;
50
+ do {
51
+ id = 'u' + crypto.randomUUID().replace(/-/g, '');
52
+ } while (~value.indexOf(id));
53
+ return id;
54
+ }
55
+
56
+ // Identity and transform functions
57
+
58
+ function identity(value) {
59
+ return value;
60
+ }
61
+
62
+ function isThenable(value) {
63
+ return value != null && typeof value === 'object' && typeof value.then === 'function';
64
+ }
65
+
66
+ function lowercase(value) {
67
+ return value.toLowerCase();
68
+ }
69
+
70
+ // Replace async helper
71
+
72
+ /**
73
+ * Asynchronously replace matches in a string
74
+ * @param {string} str - Input string
75
+ * @param {RegExp} regex - Regular expression with global flag
76
+ * @param {Function} asyncFn - Async function to process each match
77
+ * @returns {Promise<string>} Processed string
78
+ */
79
+ async function replaceAsync(str, regex, asyncFn) {
80
+ const promises = [];
81
+
82
+ str.replace(regex, (match, ...args) => {
83
+ const promise = asyncFn(match, ...args);
84
+ promises.push(promise);
85
+ });
86
+
87
+ const data = await Promise.all(promises);
88
+ return str.replace(regex, () => data.shift());
89
+ }
90
+
91
+ // String patterns to RegExp conversion (for JSON config support)
92
+
93
+ function parseRegExp(value) {
94
+ if (typeof value === 'string') {
95
+ if (!value) return undefined; // Empty string = not configured
96
+ const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
97
+ if (match) {
98
+ return new RegExp(match[1], match[2]);
99
+ }
100
+ return new RegExp(value);
101
+ }
102
+ return value;
103
+ }
104
+
5
105
  /*
6
106
  * HTML Parser By John Resig (ejohn.org)
7
107
  * Modified by Juriy “kangax” Zaytsev
@@ -9,6 +109,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
9
109
  * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
10
110
  */
11
111
 
112
+
12
113
  /*
13
114
  * Use like so:
14
115
  *
@@ -84,6 +185,23 @@ const preCompiledStackedTags = {
84
185
  // Cache for compiled attribute regexes per handler configuration
85
186
  const attrRegexCache = new WeakMap();
86
187
 
188
+ // O(n) helper: Strip all occurrences of `open…close` delimiters, keeping inner content
189
+ // Used instead of a regex replace to avoid O(n²) behavior on adversarial inputs
190
+ function stripDelimited(str, open, close) {
191
+ let result = '';
192
+ let i = 0;
193
+ while (i < str.length) {
194
+ const start = str.indexOf(open, i);
195
+ if (start === -1) { result += str.slice(i); break; }
196
+ result += str.slice(i, start);
197
+ const end = str.indexOf(close, start + open.length);
198
+ if (end === -1) { result += str.slice(start); break; }
199
+ result += str.slice(start + open.length, end);
200
+ i = end + close.length;
201
+ }
202
+ return result;
203
+ }
204
+
87
205
  function buildAttrRegex(handler) {
88
206
  let pattern = singleAttrIdentifier.source +
89
207
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
@@ -155,9 +273,10 @@ class HTMLParser {
155
273
 
156
274
  // Sticky regex versions for position-based matching (avoids string slicing)
157
275
  const startTagOpenY = new RegExp(startTagOpen.source.slice(1), 'y');
276
+ // `\s*` with sticky flag is O(n) at worst—no retry from different positions possible
158
277
  const startTagCloseY = /\s*(\/?)>/y;
159
278
  const endTagY = new RegExp(endTag.source.slice(1), 'y');
160
- const doctypeY = /<!DOCTYPE\s?[^>]+>/iy;
279
+ const doctypeY = /<!DOCTYPE[^<>]+>/iy;
161
280
  const commentTestY = /<!--/y;
162
281
  const conditionalTestY = /<!\[/y;
163
282
 
@@ -232,7 +351,8 @@ class HTMLParser {
232
351
 
233
352
  if (commentEnd >= 0) {
234
353
  if (handler.comment) {
235
- await handler.comment(fullHtml.substring(pos + 4, commentEnd));
354
+ const result = handler.comment(fullHtml.substring(pos + 4, commentEnd));
355
+ if (isThenable(result)) await result;
236
356
  }
237
357
  advance(commentEnd + 3 - pos);
238
358
  prevTag = '';
@@ -248,7 +368,8 @@ class HTMLParser {
248
368
 
249
369
  if (conditionalEnd >= 0) {
250
370
  if (handler.comment) {
251
- await handler.comment(fullHtml.substring(pos + 2, conditionalEnd + 1), true /* Non-standard */);
371
+ const result = handler.comment(fullHtml.substring(pos + 2, conditionalEnd + 1), true /* Non-standard */);
372
+ if (isThenable(result)) await result;
252
373
  }
253
374
  advance(conditionalEnd + 2 - pos);
254
375
  prevTag = '';
@@ -326,7 +447,8 @@ class HTMLParser {
326
447
  }
327
448
 
328
449
  if (handler.chars) {
329
- await handler.chars(text, prevTag, nextTag, prevAttrs, nextAttrs);
450
+ const result = handler.chars(text, prevTag, nextTag, prevAttrs, nextAttrs);
451
+ if (isThenable(result)) await result;
330
452
  }
331
453
  prevTag = '';
332
454
  prevAttrs = [];
@@ -340,12 +462,11 @@ class HTMLParser {
340
462
  if (m && m.index === 0) {
341
463
  let text = m[1];
342
464
  if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') {
343
- text = text
344
- .replace(/<!--([\s\S]*?)-->/g, '$1')
345
- .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
465
+ text = stripDelimited(stripDelimited(text, '<!--', '-->'), '<![CDATA[', ']]>');
346
466
  }
347
467
  if (handler.chars) {
348
- await handler.chars(text);
468
+ const result = handler.chars(text);
469
+ if (isThenable(result)) await result;
349
470
  }
350
471
  // Advance HTML past the matched special tag content and its closing tag
351
472
  advance(m[0].length);
@@ -353,7 +474,8 @@ class HTMLParser {
353
474
  } else {
354
475
  // No closing tag found; to avoid infinite loop, break similarly to previous behavior
355
476
  if (handler.continueOnParseError && handler.chars && pos < fullLength) {
356
- await handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
477
+ const result = handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
478
+ if (isThenable(result)) await result;
357
479
  advance(1);
358
480
  } else {
359
481
  break;
@@ -365,7 +487,8 @@ class HTMLParser {
365
487
  if (handler.continueOnParseError) {
366
488
  // Skip the problematic character and continue
367
489
  if (handler.chars) {
368
- await handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
490
+ const result = handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
491
+ if (isThenable(result)) await result;
369
492
  }
370
493
  advance(1);
371
494
  prevTag = '';
@@ -913,106 +1036,6 @@ function getPresetNames() {
913
1036
  return Object.keys(presets);
914
1037
  }
915
1038
 
916
- // Stringify for options signatures (sorted keys, shallow, nested objects)
917
-
918
- function stableStringify(obj) {
919
- if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
920
- if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
921
- const keys = Object.keys(obj).sort();
922
- let out = '{';
923
- for (let i = 0; i < keys.length; i++) {
924
- const k = keys[i];
925
- out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
926
- }
927
- return out + '}';
928
- }
929
-
930
- // LRU cache for strings and promises
931
-
932
- class LRU {
933
- constructor(limit = 200) {
934
- this.limit = limit;
935
- this.map = new Map();
936
- }
937
- get(key) {
938
- if (this.map.has(key)) {
939
- const v = this.map.get(key);
940
- this.map.delete(key);
941
- this.map.set(key, v);
942
- return v;
943
- }
944
- return undefined;
945
- }
946
- set(key, value) {
947
- if (this.map.has(key)) this.map.delete(key);
948
- this.map.set(key, value);
949
- if (this.map.size > this.limit) {
950
- const first = this.map.keys().next().value;
951
- this.map.delete(first);
952
- }
953
- }
954
- delete(key) { this.map.delete(key); }
955
- }
956
-
957
- // Unique ID generator
958
-
959
- function uniqueId(value) {
960
- let id;
961
- do {
962
- id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
963
- } while (~value.indexOf(id));
964
- return id;
965
- }
966
-
967
- // Identity and transform functions
968
-
969
- function identity(value) {
970
- return value;
971
- }
972
-
973
- function identityAsync(value) {
974
- return Promise.resolve(value);
975
- }
976
-
977
- function lowercase(value) {
978
- return value.toLowerCase();
979
- }
980
-
981
- // Replace async helper
982
-
983
- /**
984
- * Asynchronously replace matches in a string
985
- * @param {string} str - Input string
986
- * @param {RegExp} regex - Regular expression with global flag
987
- * @param {Function} asyncFn - Async function to process each match
988
- * @returns {Promise<string>} Processed string
989
- */
990
- async function replaceAsync(str, regex, asyncFn) {
991
- const promises = [];
992
-
993
- str.replace(regex, (match, ...args) => {
994
- const promise = asyncFn(match, ...args);
995
- promises.push(promise);
996
- });
997
-
998
- const data = await Promise.all(promises);
999
- return str.replace(regex, () => data.shift());
1000
- }
1001
-
1002
- // String patterns to RegExp conversion (for JSON config support)
1003
-
1004
- function parseRegExp(value) {
1005
- if (typeof value === 'string') {
1006
- if (!value) return undefined; // Empty string = not configured
1007
- const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
1008
- if (match) {
1009
- return new RegExp(match[1], match[2]);
1010
- }
1011
- return new RegExp(value);
1012
- }
1013
- return value;
1014
- }
1015
-
1016
1039
  // Regex patterns (to avoid repeated allocations in hot paths)
1017
1040
 
1018
1041
  const RE_WS_START = /^[ \n\r\t\f]+/;
@@ -1665,7 +1688,7 @@ function shouldMinifyInnerHTML(options) {
1665
1688
  options.removeComments ||
1666
1689
  options.removeOptionalTags ||
1667
1690
  options.minifyJS !== identity ||
1668
- options.minifyCSS !== identityAsync ||
1691
+ options.minifyCSS !== identity ||
1669
1692
  options.minifyURLs !== identity ||
1670
1693
  options.minifySVG
1671
1694
  );
@@ -1692,7 +1715,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1692
1715
  canTrimWhitespace,
1693
1716
  ...optionDefaults,
1694
1717
  log: identity,
1695
- minifyCSS: identityAsync,
1718
+ minifyCSS: identity,
1696
1719
  minifyJS: identity,
1697
1720
  minifyURLs: identity,
1698
1721
  minifySVG: null
@@ -2330,7 +2353,9 @@ function hasAttrName(name, attrs) {
2330
2353
 
2331
2354
  // Cleaners
2332
2355
 
2333
- async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
2356
+ // Returns the cleaned attribute value directly (sync) or as a Promise (async);
2357
+ // callers must handle both cases—use `isThenable()` to distinguish
2358
+ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
2334
2359
  // Apply early whitespace normalization if enabled
2335
2360
  // Preserves special spaces (no-break space, hair space, etc.) for consistency with `collapseWhitespace`
2336
2361
  if (options.collapseAttributeWhitespace) {
@@ -2345,16 +2370,18 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2345
2370
 
2346
2371
  if (isEventAttribute(attrName, options)) {
2347
2372
  attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
2348
- try {
2349
- return await options.minifyJS(attrValue, true);
2350
- } catch (err) {
2351
- if (!options.continueOnMinifyError) {
2352
- throw err;
2353
- }
2354
- options.log && options.log(err);
2355
- return attrValue;
2373
+ const result = options.minifyJS(attrValue, true);
2374
+ if (isThenable(result)) {
2375
+ return result.catch(err => {
2376
+ if (!options.continueOnMinifyError) throw err;
2377
+ options.log && options.log(err);
2378
+ return attrValue;
2379
+ });
2356
2380
  }
2357
- } else if (attrName === 'class') {
2381
+ return result;
2382
+ }
2383
+
2384
+ if (attrName === 'class') {
2358
2385
  attrValue = trimWhitespace(attrValue);
2359
2386
  if (options.sortClassNames) {
2360
2387
  attrValue = options.sortClassNames(attrValue);
@@ -2362,47 +2389,63 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2362
2389
  attrValue = collapseWhitespaceAll(attrValue);
2363
2390
  }
2364
2391
  return attrValue;
2365
- } else if (isUriTypeAttribute(attrName, tag)) {
2392
+ }
2393
+
2394
+ if (isUriTypeAttribute(attrName, tag)) {
2366
2395
  attrValue = trimWhitespace(attrValue);
2367
2396
  if (isLinkType(tag, attrs, 'canonical')) {
2368
2397
  return attrValue;
2369
2398
  }
2370
- try {
2371
- const out = await options.minifyURLs(attrValue);
2372
- return typeof out === 'string' ? out : attrValue;
2373
- } catch (err) {
2374
- if (!options.continueOnMinifyError) {
2375
- throw err;
2376
- }
2377
- options.log && options.log(err);
2378
- return attrValue;
2399
+ const result = options.minifyURLs(attrValue);
2400
+ if (isThenable(result)) {
2401
+ return result
2402
+ .then(out => typeof out === 'string' ? out : attrValue)
2403
+ .catch(err => {
2404
+ if (!options.continueOnMinifyError) throw err;
2405
+ options.log && options.log(err);
2406
+ return attrValue;
2407
+ });
2379
2408
  }
2380
- } else if (isNumberTypeAttribute(attrName, tag)) {
2409
+ return typeof result === 'string' ? result : attrValue;
2410
+ }
2411
+
2412
+ if (isNumberTypeAttribute(attrName, tag)) {
2381
2413
  return trimWhitespace(attrValue);
2382
- } else if (attrName === 'style') {
2414
+ }
2415
+
2416
+ if (attrName === 'style') {
2383
2417
  attrValue = trimWhitespace(attrValue);
2384
2418
  if (attrValue) {
2385
2419
  if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
2386
2420
  attrValue = attrValue.replace(/\s*;$/, ';');
2387
2421
  }
2388
- try {
2389
- attrValue = await options.minifyCSS(attrValue, 'inline');
2390
- // After minification, check if CSS consists entirely of invalid properties (no values)
2391
- // I.e., `color:` or `margin:;padding:` should be treated as empty
2392
- if (attrValue && /^(?:[a-z-]+:[;\s]*)+$/i.test(attrValue)) {
2393
- attrValue = '';
2394
- }
2395
- } catch (err) {
2396
- if (!options.continueOnMinifyError) {
2397
- throw err;
2398
- }
2399
- options.log && options.log(err);
2422
+ const originalAttrValue = attrValue;
2423
+ const cssResult = options.minifyCSS(attrValue, 'inline');
2424
+ if (isThenable(cssResult)) {
2425
+ return cssResult
2426
+ .then(minified => {
2427
+ // After minification, check if CSS consists entirely of invalid properties (no values)
2428
+ // I.e., `color:` or `margin:;padding:` should be treated as empty
2429
+ if (minified && /^(?:[a-z-]+:[;\s]*)+$/i.test(minified)) return '';
2430
+ return minified;
2431
+ })
2432
+ .catch(err => {
2433
+ if (!options.continueOnMinifyError) throw err;
2434
+ options.log && options.log(err);
2435
+ return originalAttrValue;
2436
+ });
2400
2437
  }
2438
+ // Sync path (`minifyCSS` disabled—identity function)
2439
+ if (cssResult && /^(?:[a-z-]+:[;\s]*)+$/i.test(cssResult)) return '';
2440
+ return cssResult != null ? cssResult : attrValue;
2401
2441
  }
2402
2442
  return attrValue;
2403
- } else if (isSrcset(attrName, tag)) {
2443
+ }
2444
+
2445
+ if (isSrcset(attrName, tag)) {
2404
2446
  // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
2405
- attrValue = (await Promise.all(trimWhitespace(attrValue).split(/\s*,\s*/).map(async function (candidate) {
2447
+ const candidates = trimWhitespace(attrValue).split(/\s*,\s*/);
2448
+ const processed = candidates.map(candidate => {
2406
2449
  let url = candidate;
2407
2450
  let descriptor = '';
2408
2451
  const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
@@ -2414,47 +2457,65 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2414
2457
  descriptor = ' ' + num + suffix;
2415
2458
  }
2416
2459
  }
2417
- try {
2418
- const out = await options.minifyURLs(url);
2419
- return (typeof out === 'string' ? out : url) + descriptor;
2420
- } catch (err) {
2421
- if (!options.continueOnMinifyError) {
2422
- throw err;
2423
- }
2424
- options.log && options.log(err);
2425
- return url + descriptor;
2460
+ const out = options.minifyURLs(url);
2461
+ if (isThenable(out)) {
2462
+ return out
2463
+ .then(result => (typeof result === 'string' ? result : url) + descriptor)
2464
+ .catch(err => {
2465
+ if (!options.continueOnMinifyError) throw err;
2466
+ options.log && options.log(err);
2467
+ return url + descriptor;
2468
+ });
2426
2469
  }
2427
- }))).join(', ');
2428
- } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
2429
- attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
2470
+ return (typeof out === 'string' ? out : url) + descriptor;
2471
+ });
2472
+ if (processed.some(isThenable)) {
2473
+ return Promise.all(processed).then(results => results.join(', '));
2474
+ }
2475
+ return processed.join(', ');
2476
+ }
2477
+
2478
+ if (isMetaViewport(tag, attrs) && attrName === 'content') {
2479
+ return attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
2430
2480
  // 0.90000 → 0.9
2431
2481
  // 1.0 → 1
2432
2482
  // 1.0001 → 1.0001 (unchanged)
2433
2483
  return (+numString).toString();
2434
2484
  });
2435
- } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
2485
+ }
2486
+
2487
+ if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
2436
2488
  return collapseWhitespaceAll(attrValue);
2437
- } else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
2438
- attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
2439
- } else if (tag === 'script' && attrName === 'type') {
2440
- attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
2441
- } else if (isMediaQuery(tag, attrs, attrName)) {
2489
+ }
2490
+
2491
+ if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
2492
+ return trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
2493
+ }
2494
+
2495
+ if (tag === 'script' && attrName === 'type') {
2496
+ return trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
2497
+ }
2498
+
2499
+ if (isMediaQuery(tag, attrs, attrName)) {
2442
2500
  attrValue = trimWhitespace(attrValue);
2443
2501
  // Only minify actual media queries (those with features in parentheses)
2444
2502
  // Skip simple media types like `all`, `screen`, `print` which are already minimal
2445
2503
  if (!/[()]/.test(attrValue)) {
2446
2504
  return attrValue;
2447
2505
  }
2448
- try {
2449
- return await options.minifyCSS(attrValue, 'media');
2450
- } catch (err) {
2451
- if (!options.continueOnMinifyError) {
2452
- throw err;
2453
- }
2454
- options.log && options.log(err);
2455
- return attrValue;
2506
+ const originalAttrValue = attrValue;
2507
+ const cssResult = options.minifyCSS(attrValue, 'media');
2508
+ if (isThenable(cssResult)) {
2509
+ return cssResult.catch(err => {
2510
+ if (!options.continueOnMinifyError) throw err;
2511
+ options.log && options.log(err);
2512
+ return originalAttrValue;
2513
+ });
2456
2514
  }
2457
- } else if (tag === 'iframe' && attrName === 'srcdoc') {
2515
+ return cssResult != null ? cssResult : attrValue;
2516
+ }
2517
+
2518
+ if (tag === 'iframe' && attrName === 'srcdoc') {
2458
2519
  // Recursively minify HTML content within `srcdoc` attribute
2459
2520
  // Fast-path: Skip if nothing would change
2460
2521
  if (!shouldMinifyInnerHTML(options)) {
@@ -2462,6 +2523,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2462
2523
  }
2463
2524
  return minifyHTMLSelf(attrValue, options, true);
2464
2525
  }
2526
+
2465
2527
  return attrValue;
2466
2528
  }
2467
2529
 
@@ -2485,17 +2547,24 @@ function chooseAttributeQuote(attrValue, options) {
2485
2547
  return apos < quot ? '\'' : '"';
2486
2548
  }
2487
2549
 
2488
- async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2550
+ // Returns the normalized attribute object directly (sync) or as a Promise (async);
2551
+ // callers must handle both cases—use `isThenable()` to distinguish
2552
+ function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2489
2553
  const attrName = options.name(attr.name);
2490
2554
  let attrValue = attr.value;
2491
2555
 
2492
- if (options.decodeEntities && attrValue) {
2493
- // Fast path: Only decode when entities are present
2494
- if (attrValue.indexOf('&') !== -1) {
2495
- attrValue = (await getDecodeHTMLStrict())(attrValue);
2496
- }
2556
+ // Entity decoding requires a lazy import—async only when `&` is present
2557
+ if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
2558
+ return getDecodeHTMLStrict().then(decode => {
2559
+ return normalizeAttrContinue(attrName, decode(attrValue), attr, attrs, tag, options, minifyHTML);
2560
+ });
2497
2561
  }
2498
2562
 
2563
+ return normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, minifyHTML);
2564
+ }
2565
+
2566
+ // Internal: Handles attribute normalization after entity decoding (if any)
2567
+ function normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, minifyHTML) {
2499
2568
  if ((options.removeRedundantAttributes &&
2500
2569
  isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
2501
2570
  (options.removeScriptTypeAttributes && tag === 'script' &&
@@ -2506,9 +2575,18 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2506
2575
  }
2507
2576
 
2508
2577
  if (attrValue) {
2509
- attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
2578
+ const cleaned = cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
2579
+ if (isThenable(cleaned)) {
2580
+ return cleaned.then(v => normalizeAttrFinish(attrName, v, attr, tag, options));
2581
+ }
2582
+ return normalizeAttrFinish(attrName, cleaned, attr, tag, options);
2510
2583
  }
2511
2584
 
2585
+ return normalizeAttrFinish(attrName, attrValue, attr, tag, options);
2586
+ }
2587
+
2588
+ // Internal: Final checks and result assembly after value cleaning
2589
+ function normalizeAttrFinish(attrName, attrValue, attr, tag, options) {
2512
2590
  if (options.removeEmptyAttributes &&
2513
2591
  canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
2514
2592
  return;
@@ -3734,31 +3812,47 @@ async function minifyHTML(value, options, partialMarkup) {
3734
3812
  let removeEmptyElementsExcept;
3735
3813
  if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
3736
3814
  if (options.log) {
3737
- options.log('Warning: removeEmptyElementsExcept option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
3815
+ options.log('Warning: `removeEmptyElementsExcept` option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
3738
3816
  }
3739
3817
  removeEmptyElementsExcept = [];
3740
3818
  } else {
3741
3819
  removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
3742
3820
  }
3743
3821
 
3744
- // Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there.
3745
- // For all we care there might be completely-horribly-broken-alien-non-html-emoji-cthulhu-filled content
3746
- value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
3747
- if (!uidIgnore) {
3748
- uidIgnore = uniqueId(value);
3749
- const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
3750
- uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
3751
- if (options.ignoreCustomComments) {
3752
- options.ignoreCustomComments = options.ignoreCustomComments.slice();
3753
- } else {
3754
- options.ignoreCustomComments = [];
3822
+ // Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there;
3823
+ // for all we care there might be completely-horribly-broken-alien-non-html-emoji-cthulhu-filled content
3824
+ if (value.indexOf('<!-- htmlmin:ignore -->') !== -1) {
3825
+ // Use `indexOf`-based O(n) loop instead of a global regex with [\s\S]*? to avoid O(n²)
3826
+ // backtracking on adversarial HTML with many `<!--` prefixes but no closing marker
3827
+ const ignoreMarker = '<!-- htmlmin:ignore -->';
3828
+ const ignoreMarkerLen = ignoreMarker.length;
3829
+ let ignoreResult = '';
3830
+ let ignorePos = 0;
3831
+ while (ignorePos < value.length) {
3832
+ const ignoreStart = value.indexOf(ignoreMarker, ignorePos);
3833
+ if (ignoreStart === -1) { ignoreResult += value.slice(ignorePos); break; }
3834
+ ignoreResult += value.slice(ignorePos, ignoreStart);
3835
+ const ignoreEnd = value.indexOf(ignoreMarker, ignoreStart + ignoreMarkerLen);
3836
+ if (ignoreEnd === -1) { ignoreResult += value.slice(ignoreStart); break; }
3837
+ const group1 = value.slice(ignoreStart + ignoreMarkerLen, ignoreEnd);
3838
+ if (!uidIgnore) {
3839
+ uidIgnore = uniqueId(value);
3840
+ const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
3841
+ uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
3842
+ if (options.ignoreCustomComments) {
3843
+ options.ignoreCustomComments = options.ignoreCustomComments.slice();
3844
+ } else {
3845
+ options.ignoreCustomComments = [];
3846
+ }
3847
+ options.ignoreCustomComments.push(pattern);
3755
3848
  }
3756
- options.ignoreCustomComments.push(pattern);
3849
+ const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
3850
+ ignoredMarkupChunks.push(group1);
3851
+ ignoreResult += token;
3852
+ ignorePos = ignoreEnd + ignoreMarkerLen;
3757
3853
  }
3758
- const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
3759
- ignoredMarkupChunks.push(group1);
3760
- return token;
3761
- });
3854
+ value = ignoreResult;
3855
+ }
3762
3856
 
3763
3857
  // Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
3764
3858
  // This allows proper frequency analysis with access to ignored content via UID tokens
@@ -3993,9 +4087,8 @@ async function minifyHTML(value, options, partialMarkup) {
3993
4087
  options.sortAttributes(tag, attrs);
3994
4088
  }
3995
4089
 
3996
- const normalizedAttrs = await Promise.all(
3997
- attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML))
3998
- );
4090
+ const attrResults = attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML));
4091
+ const normalizedAttrs = attrResults.some(isThenable) ? await Promise.all(attrResults) : attrResults;
3999
4092
  const parts = [];
4000
4093
  let isLast = true;
4001
4094
  for (let i = normalizedAttrs.length - 1; i >= 0; i--) {
@@ -4123,207 +4216,225 @@ async function minifyHTML(value, options, partialMarkup) {
4123
4216
  }
4124
4217
  }
4125
4218
  },
4126
- chars: async function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
4219
+ chars: function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
4127
4220
  prevTag = prevTag === '' ? 'comment' : prevTag;
4128
4221
  nextTag = nextTag === '' ? 'comment' : nextTag;
4129
4222
  prevAttrs = prevAttrs || [];
4130
4223
  nextAttrs = nextAttrs || [];
4131
- if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
4132
- if (text.indexOf('&') !== -1) {
4133
- text = (await getDecodeHTML())(text);
4134
- }
4135
- }
4136
- // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
4137
- // This removes trailing newlines often added by template engines before closing tags
4138
- // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
4139
- if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
4140
- const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
4141
- if (preTextareaDepth > 0) {
4142
- // Trim trailing whitespace only if it ends with a single newline (not multiple)
4143
- // Multiple newlines are likely intentional formatting, single newline is often a template artifact
4144
- // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
4145
- if (nextTag && nextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
4146
- text = text.replace(/(?:\r\n|\r|\n)[ \t]*$/, '');
4224
+
4225
+ // Detect whether any async work is actually needed for this text node
4226
+ const needsDecode = options.decodeEntities && text && !specialContentElements.has(currentTag) && text.indexOf('&') !== -1;
4227
+ const needsProcessScript = specialContentElements.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs));
4228
+ const needsMinifyJS = options.minifyJS !== identity && isExecutableScript(currentTag, currentAttrs);
4229
+ const needsMinifyCSS = options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
4230
+
4231
+ // Whitespace collapsing phase (sync); captures `prevTag`/`nextTag`/`prevAttrs`/`nextAttrs` from outer scope
4232
+ function charsCollapse(text) {
4233
+ // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
4234
+ // This removes trailing newlines often added by template engines before closing tags
4235
+ // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
4236
+ if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
4237
+ const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
4238
+ if (preTextareaDepth > 0) {
4239
+ // Trim trailing whitespace only if it ends with a single newline (not multiple)
4240
+ // Multiple newlines are likely intentional formatting, single newline is often a template artifact
4241
+ // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
4242
+ if (nextTag && nextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
4243
+ text = text.replace(/(?:\r\n|\r|\n)[ \t]*$/, '');
4244
+ }
4147
4245
  }
4148
4246
  }
4149
- }
4150
- if (options.collapseWhitespace) {
4151
- if (!stackNoTrimWhitespace.length) {
4152
- if (prevTag === 'comment') {
4153
- const prevComment = buffer[buffer.length - 1];
4154
- if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
4155
- if (!prevComment) {
4156
- prevTag = charsPrevTag;
4157
- }
4158
- if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
4159
- const charsIndex = buffer.length - 2;
4160
- buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
4161
- text = trailingSpaces + text;
4162
- return '';
4163
- });
4247
+ if (options.collapseWhitespace) {
4248
+ if (!stackNoTrimWhitespace.length) {
4249
+ if (prevTag === 'comment') {
4250
+ const prevComment = buffer[buffer.length - 1];
4251
+ if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
4252
+ if (!prevComment) {
4253
+ prevTag = charsPrevTag;
4254
+ }
4255
+ if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
4256
+ const charsIndex = buffer.length - 2;
4257
+ buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
4258
+ text = trailingSpaces + text;
4259
+ return '';
4260
+ });
4261
+ }
4164
4262
  }
4165
4263
  }
4166
- }
4167
- if (prevTag) {
4168
- if (prevTag === '/nobr' || prevTag === 'wbr') {
4169
- if (/^\s/.test(text)) {
4170
- let tagIndex = buffer.length - 1;
4171
- while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
4172
- tagIndex--;
4264
+ if (prevTag) {
4265
+ if (prevTag === '/nobr' || prevTag === 'wbr') {
4266
+ if (/^\s/.test(text)) {
4267
+ let tagIndex = buffer.length - 1;
4268
+ while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
4269
+ tagIndex--;
4270
+ }
4271
+ trimTrailingWhitespace(tagIndex - 1, 'br');
4173
4272
  }
4174
- trimTrailingWhitespace(tagIndex - 1, 'br');
4273
+ } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
4274
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
4175
4275
  }
4176
- } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
4177
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
4276
+ }
4277
+ if (prevTag || nextTag) {
4278
+ text = collapseWhitespaceSmart(text, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet);
4279
+ } else {
4280
+ text = collapseWhitespace(text, options, true, true);
4281
+ }
4282
+ if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
4283
+ trimTrailingWhitespace(buffer.length - 1, nextTag);
4178
4284
  }
4179
4285
  }
4180
- if (prevTag || nextTag) {
4181
- text = collapseWhitespaceSmart(text, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet);
4182
- } else {
4183
- text = collapseWhitespace(text, options, true, true);
4286
+ if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
4287
+ text = collapseWhitespace(text, options, false, false, true);
4184
4288
  }
4185
- if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
4186
- trimTrailingWhitespace(buffer.length - 1, nextTag);
4289
+ }
4290
+ return text;
4291
+ }
4292
+
4293
+ // Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
4294
+ function charsFinalize(text) {
4295
+ if (options.removeOptionalTags && text) {
4296
+ // `<html>` may be omitted if first thing inside is not a comment
4297
+ // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
4298
+ if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
4299
+ removeStartTag();
4300
+ }
4301
+ optionalStartTag = '';
4302
+ // `</html>` or `</body>` may be omitted if not followed by comment
4303
+ // `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
4304
+ if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text)))) {
4305
+ removeEndTag();
4306
+ }
4307
+ // Don’t reset `optionalEndTag` if text is only whitespace and will be collapsed (not conservatively)
4308
+ if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
4309
+ optionalEndTag = '';
4310
+ optionalEndTagEmitted = false;
4187
4311
  }
4188
4312
  }
4189
- if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
4190
- text = collapseWhitespace(text, options, false, false, true);
4313
+ charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
4314
+ if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
4315
+ // Escape any `&` symbols that start either:
4316
+ // 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
4317
+ // 2. or any other character reference (i.e., one that does end with `;`)
4318
+ // Note that `&` can be escaped as `&amp`, without the semicolon.
4319
+ // https://mathiasbynens.be/notes/ambiguous-ampersands
4320
+ if (text.indexOf('&') !== -1) {
4321
+ text = text.replace(RE_LEGACY_ENTITIES, '&amp$1');
4322
+ }
4323
+ if (text.indexOf('<') !== -1) {
4324
+ text = text.replace(RE_ESCAPE_LT, '&lt;');
4325
+ }
4191
4326
  }
4327
+ if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
4328
+ text = text.replace(uidPattern, function (match, prefix, index) {
4329
+ return ignoredCustomMarkupChunks[+index][0];
4330
+ });
4331
+ }
4332
+ currentChars += text;
4333
+ if (text) {
4334
+ hasChars = true;
4335
+ }
4336
+ buffer.push(text);
4192
4337
  }
4193
- if (specialContentElements.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
4194
- text = await processScript(text, options, currentAttrs, minifyHTML);
4195
- }
4196
- if (isExecutableScript(currentTag, currentAttrs)) {
4197
- text = await options.minifyJS(text);
4198
- }
4199
- if (isStyleElement(currentTag, currentAttrs)) {
4200
- text = await options.minifyCSS(text);
4338
+
4339
+ // Fast path: All work is sync—skip async machinery entirely
4340
+ if (!needsDecode && !needsProcessScript && !needsMinifyJS && !needsMinifyCSS) {
4341
+ charsFinalize(charsCollapse(text));
4342
+ return;
4201
4343
  }
4202
- if (options.removeOptionalTags && text) {
4203
- // `<html>` may be omitted if first thing inside is not a comment
4204
- // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
4205
- if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
4206
- removeStartTag();
4207
- }
4208
- optionalStartTag = '';
4209
- // `</html>` or `</body>` may be omitted if not followed by comment
4210
- // `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
4211
- if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text)))) {
4212
- removeEndTag();
4344
+
4345
+ // Slow path: At least one async step required
4346
+ return (async () => {
4347
+ if (needsDecode) {
4348
+ text = (await getDecodeHTML())(text);
4213
4349
  }
4214
- // Don't reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
4215
- if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
4216
- optionalEndTag = '';
4217
- optionalEndTagEmitted = false;
4350
+ text = charsCollapse(text);
4351
+ if (needsProcessScript) {
4352
+ text = await processScript(text, options, currentAttrs, minifyHTML);
4218
4353
  }
4219
- }
4220
- charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
4221
- if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
4222
- // Escape any `&` symbols that start either:
4223
- // 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
4224
- // 2. or any other character reference (i.e., one that does end with `;`)
4225
- // Note that `&` can be escaped as `&amp`, without the semicolon.
4226
- // https://mathiasbynens.be/notes/ambiguous-ampersands
4227
- if (text.indexOf('&') !== -1) {
4228
- text = text.replace(RE_LEGACY_ENTITIES, '&amp$1');
4354
+ if (needsMinifyJS) {
4355
+ text = await options.minifyJS(text);
4229
4356
  }
4230
- if (text.indexOf('<') !== -1) {
4231
- text = text.replace(RE_ESCAPE_LT, '&lt;');
4357
+ if (needsMinifyCSS) {
4358
+ text = await options.minifyCSS(text);
4232
4359
  }
4233
- }
4234
- if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
4235
- text = text.replace(uidPattern, function (match, prefix, index) {
4236
- return ignoredCustomMarkupChunks[+index][0];
4237
- });
4238
- }
4239
- currentChars += text;
4240
- if (text) {
4241
- hasChars = true;
4242
- }
4243
- buffer.push(text);
4360
+ charsFinalize(text);
4361
+ })();
4244
4362
  },
4245
- comment: async function (text, nonStandard) {
4363
+ comment: function (text, nonStandard) {
4246
4364
  const prefix = nonStandard ? '<!' : '<!--';
4247
4365
  const suffix = nonStandard ? '>' : '-->';
4248
- if (isConditionalComment(text)) {
4249
- text = prefix + await cleanConditionalComment(text, options, minifyHTML) + suffix;
4250
- } else if (options.removeComments) {
4251
- if (isIgnoredComment(text, options)) {
4252
- text = '<!--' + text + '-->';
4253
- } else {
4254
- text = '';
4366
+
4367
+ // Finalization phase (sync): Optional tag handling, `htmlmin:ignore` whitespace collapsing, buffer push
4368
+ function commentFinalize(comment) {
4369
+ if (options.removeOptionalTags && comment) {
4370
+ // Preceding comments suppress tag omissions
4371
+ optionalStartTag = '';
4372
+ optionalEndTag = '';
4373
+ optionalEndTagEmitted = false;
4255
4374
  }
4256
- } else {
4257
- text = prefix + text + suffix;
4258
- }
4259
- if (options.removeOptionalTags && text) {
4260
- // Preceding comments suppress tag omissions
4261
- optionalStartTag = '';
4262
- optionalEndTag = '';
4263
- optionalEndTagEmitted = false;
4264
- }
4265
4375
 
4266
- // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
4267
- if (options.collapseWhitespace && text && uidIgnorePlaceholderPattern) {
4268
- if (uidIgnorePlaceholderPattern.test(text)) {
4269
- // Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
4270
- if (buffer.length >= 2) {
4271
- const prevText = buffer[buffer.length - 1];
4272
- const prevComment = buffer[buffer.length - 2];
4273
-
4274
- // Check if previous item is whitespace-only and item before that is ignore-placeholder
4275
- if (prevText && /^\s+$/.test(prevText) && prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
4276
- // Extract the index from both placeholders to check their content
4277
- const currentMatch = text.match(uidIgnorePlaceholderPattern);
4278
- const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
4279
-
4280
- if (currentMatch && prevMatch) {
4281
- const currentIndex = +currentMatch[1];
4282
- const prevIndex = +prevMatch[1];
4283
-
4284
- // Defensive bounds check to ensure indices are valid
4285
- if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
4286
- const currentContent = ignoredMarkupChunks[currentIndex];
4287
- const prevContent = ignoredMarkupChunks[prevIndex];
4288
-
4289
- // Only collapse whitespace if both blocks contain HTML (start with `<`)
4290
- // Don’t collapse if either contains plain text, as that would change meaning
4291
- // Note: This check will match HTML comments (`<!-- … -->`), but the tag name
4292
- // regex below requires starting with a letter, so comments are intentionally
4293
- // excluded by the `currentTagMatch && prevTagMatch` guard
4294
- if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
4295
- // Extract tag names from the HTML content (excludes comments, processing instructions, etc.)
4296
- const currentTagMatch = currentContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
4297
- const prevTagMatch = prevContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
4298
-
4299
- // Only collapse if both matched valid element tags (not comments/text)
4300
- // and both tags are block-level (inline elements need whitespace preserved)
4301
- if (currentTagMatch && prevTagMatch) {
4302
- const currentTag = options.name(currentTagMatch[1]);
4303
- const prevTag = options.name(prevTagMatch[1]);
4304
-
4305
- // Don’t collapse between inline elements
4306
- if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
4307
- // Collapse whitespace respecting context rules
4308
- let collapsedText = prevText;
4309
-
4310
- // Apply `collapseWhitespace` with appropriate context
4311
- if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
4312
- // Not in pre or other no-collapse context
4313
- if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
4314
- // Preserve line break as single newline
4315
- collapsedText = '\n';
4316
- } else if (options.conservativeCollapse) {
4317
- // Conservative mode: keep single space
4318
- collapsedText = ' ';
4319
- } else {
4320
- // Aggressive mode: remove all whitespace
4321
- collapsedText = '';
4376
+ // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
4377
+ if (options.collapseWhitespace && comment && uidIgnorePlaceholderPattern) {
4378
+ if (uidIgnorePlaceholderPattern.test(comment)) {
4379
+ // Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
4380
+ if (buffer.length >= 2) {
4381
+ const prevText = buffer[buffer.length - 1];
4382
+ const prevComment = buffer[buffer.length - 2];
4383
+
4384
+ // Check if previous item is whitespace-only and item before that is ignore-placeholder
4385
+ if (prevText && /^\s+$/.test(prevText) && prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
4386
+ // Extract the index from both placeholders to check their content
4387
+ const currentMatch = comment.match(uidIgnorePlaceholderPattern);
4388
+ const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
4389
+
4390
+ if (currentMatch && prevMatch) {
4391
+ const currentIndex = +currentMatch[1];
4392
+ const prevIndex = +prevMatch[1];
4393
+
4394
+ // Defensive bounds check to ensure indices are valid
4395
+ if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
4396
+ const currentContent = ignoredMarkupChunks[currentIndex];
4397
+ const prevContent = ignoredMarkupChunks[prevIndex];
4398
+
4399
+ // Only collapse whitespace if both blocks contain HTML (start with `<`)
4400
+ // Don’t collapse if either contains plain text, as that would change meaning
4401
+ // Note: This check will match HTML comments (`<!-- … -->`), but the tag name
4402
+ // regex below requires starting with a letter, so comments are intentionally
4403
+ // excluded by the `currentTagMatch && prevTagMatch` guard
4404
+ if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
4405
+ // Extract tag names from the HTML content (excludes comments, processing instructions, etc.)
4406
+ const currentTagMatch = currentContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
4407
+ const prevTagMatch = prevContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
4408
+
4409
+ // Only collapse if both matched valid element tags (not comments/text)
4410
+ // and both tags are block-level (inline elements need whitespace preserved)
4411
+ if (currentTagMatch && prevTagMatch) {
4412
+ const currentTag = options.name(currentTagMatch[1]);
4413
+ const prevTag = options.name(prevTagMatch[1]);
4414
+
4415
+ // Don’t collapse between inline elements
4416
+ if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
4417
+ // Collapse whitespace respecting context rules
4418
+ let collapsedText = prevText;
4419
+
4420
+ // Apply `collapseWhitespace` with appropriate context
4421
+ if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
4422
+ // Not in pre or other no-collapse context
4423
+ if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
4424
+ // Preserve line break as single newline
4425
+ collapsedText = '\n';
4426
+ } else if (options.conservativeCollapse) {
4427
+ // Conservative mode: Keep single space
4428
+ collapsedText = ' ';
4429
+ } else {
4430
+ // Aggressive mode: Remove all whitespace
4431
+ collapsedText = '';
4432
+ }
4322
4433
  }
4323
- }
4324
4434
 
4325
- // Replace the whitespace in buffer
4326
- buffer[buffer.length - 1] = collapsedText;
4435
+ // Replace the whitespace in buffer
4436
+ buffer[buffer.length - 1] = collapsedText;
4437
+ }
4327
4438
  }
4328
4439
  }
4329
4440
  }
@@ -4332,9 +4443,27 @@ async function minifyHTML(value, options, partialMarkup) {
4332
4443
  }
4333
4444
  }
4334
4445
  }
4446
+
4447
+ buffer.push(comment);
4335
4448
  }
4336
4449
 
4337
- buffer.push(text);
4450
+ // Only conditional comments require async work (recursive minification)
4451
+ if (isConditionalComment(text)) {
4452
+ return cleanConditionalComment(text, options, minifyHTML).then(cleaned => {
4453
+ commentFinalize(prefix + cleaned + suffix);
4454
+ });
4455
+ }
4456
+
4457
+ if (options.removeComments) {
4458
+ if (isIgnoredComment(text, options)) {
4459
+ text = '<!--' + text + '-->';
4460
+ } else {
4461
+ text = '';
4462
+ }
4463
+ } else {
4464
+ text = prefix + text + suffix;
4465
+ }
4466
+ commentFinalize(text);
4338
4467
  },
4339
4468
  doctype: function (doctype) {
4340
4469
  buffer.push(options.useShortDoctype