html-minifier-next 5.1.2 → 5.1.3

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
  *
@@ -232,7 +333,8 @@ class HTMLParser {
232
333
 
233
334
  if (commentEnd >= 0) {
234
335
  if (handler.comment) {
235
- await handler.comment(fullHtml.substring(pos + 4, commentEnd));
336
+ const result = handler.comment(fullHtml.substring(pos + 4, commentEnd));
337
+ if (isThenable(result)) await result;
236
338
  }
237
339
  advance(commentEnd + 3 - pos);
238
340
  prevTag = '';
@@ -248,7 +350,8 @@ class HTMLParser {
248
350
 
249
351
  if (conditionalEnd >= 0) {
250
352
  if (handler.comment) {
251
- await handler.comment(fullHtml.substring(pos + 2, conditionalEnd + 1), true /* Non-standard */);
353
+ const result = handler.comment(fullHtml.substring(pos + 2, conditionalEnd + 1), true /* Non-standard */);
354
+ if (isThenable(result)) await result;
252
355
  }
253
356
  advance(conditionalEnd + 2 - pos);
254
357
  prevTag = '';
@@ -326,7 +429,8 @@ class HTMLParser {
326
429
  }
327
430
 
328
431
  if (handler.chars) {
329
- await handler.chars(text, prevTag, nextTag, prevAttrs, nextAttrs);
432
+ const result = handler.chars(text, prevTag, nextTag, prevAttrs, nextAttrs);
433
+ if (isThenable(result)) await result;
330
434
  }
331
435
  prevTag = '';
332
436
  prevAttrs = [];
@@ -345,7 +449,8 @@ class HTMLParser {
345
449
  .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
346
450
  }
347
451
  if (handler.chars) {
348
- await handler.chars(text);
452
+ const result = handler.chars(text);
453
+ if (isThenable(result)) await result;
349
454
  }
350
455
  // Advance HTML past the matched special tag content and its closing tag
351
456
  advance(m[0].length);
@@ -353,7 +458,8 @@ class HTMLParser {
353
458
  } else {
354
459
  // No closing tag found; to avoid infinite loop, break similarly to previous behavior
355
460
  if (handler.continueOnParseError && handler.chars && pos < fullLength) {
356
- await handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
461
+ const result = handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
462
+ if (isThenable(result)) await result;
357
463
  advance(1);
358
464
  } else {
359
465
  break;
@@ -365,7 +471,8 @@ class HTMLParser {
365
471
  if (handler.continueOnParseError) {
366
472
  // Skip the problematic character and continue
367
473
  if (handler.chars) {
368
- await handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
474
+ const result = handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
475
+ if (isThenable(result)) await result;
369
476
  }
370
477
  advance(1);
371
478
  prevTag = '';
@@ -913,106 +1020,6 @@ function getPresetNames() {
913
1020
  return Object.keys(presets);
914
1021
  }
915
1022
 
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
1023
  // Regex patterns (to avoid repeated allocations in hot paths)
1017
1024
 
1018
1025
  const RE_WS_START = /^[ \n\r\t\f]+/;
@@ -1665,7 +1672,7 @@ function shouldMinifyInnerHTML(options) {
1665
1672
  options.removeComments ||
1666
1673
  options.removeOptionalTags ||
1667
1674
  options.minifyJS !== identity ||
1668
- options.minifyCSS !== identityAsync ||
1675
+ options.minifyCSS !== identity ||
1669
1676
  options.minifyURLs !== identity ||
1670
1677
  options.minifySVG
1671
1678
  );
@@ -1692,7 +1699,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1692
1699
  canTrimWhitespace,
1693
1700
  ...optionDefaults,
1694
1701
  log: identity,
1695
- minifyCSS: identityAsync,
1702
+ minifyCSS: identity,
1696
1703
  minifyJS: identity,
1697
1704
  minifyURLs: identity,
1698
1705
  minifySVG: null
@@ -2330,7 +2337,9 @@ function hasAttrName(name, attrs) {
2330
2337
 
2331
2338
  // Cleaners
2332
2339
 
2333
- async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
2340
+ // Returns the cleaned attribute value directly (sync) or as a Promise (async);
2341
+ // callers must handle both cases—use `isThenable()` to distinguish
2342
+ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
2334
2343
  // Apply early whitespace normalization if enabled
2335
2344
  // Preserves special spaces (no-break space, hair space, etc.) for consistency with `collapseWhitespace`
2336
2345
  if (options.collapseAttributeWhitespace) {
@@ -2345,16 +2354,18 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2345
2354
 
2346
2355
  if (isEventAttribute(attrName, options)) {
2347
2356
  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;
2357
+ const result = options.minifyJS(attrValue, true);
2358
+ if (isThenable(result)) {
2359
+ return result.catch(err => {
2360
+ if (!options.continueOnMinifyError) throw err;
2361
+ options.log && options.log(err);
2362
+ return attrValue;
2363
+ });
2356
2364
  }
2357
- } else if (attrName === 'class') {
2365
+ return result;
2366
+ }
2367
+
2368
+ if (attrName === 'class') {
2358
2369
  attrValue = trimWhitespace(attrValue);
2359
2370
  if (options.sortClassNames) {
2360
2371
  attrValue = options.sortClassNames(attrValue);
@@ -2362,47 +2373,63 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2362
2373
  attrValue = collapseWhitespaceAll(attrValue);
2363
2374
  }
2364
2375
  return attrValue;
2365
- } else if (isUriTypeAttribute(attrName, tag)) {
2376
+ }
2377
+
2378
+ if (isUriTypeAttribute(attrName, tag)) {
2366
2379
  attrValue = trimWhitespace(attrValue);
2367
2380
  if (isLinkType(tag, attrs, 'canonical')) {
2368
2381
  return attrValue;
2369
2382
  }
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;
2383
+ const result = options.minifyURLs(attrValue);
2384
+ if (isThenable(result)) {
2385
+ return result
2386
+ .then(out => typeof out === 'string' ? out : attrValue)
2387
+ .catch(err => {
2388
+ if (!options.continueOnMinifyError) throw err;
2389
+ options.log && options.log(err);
2390
+ return attrValue;
2391
+ });
2379
2392
  }
2380
- } else if (isNumberTypeAttribute(attrName, tag)) {
2393
+ return typeof result === 'string' ? result : attrValue;
2394
+ }
2395
+
2396
+ if (isNumberTypeAttribute(attrName, tag)) {
2381
2397
  return trimWhitespace(attrValue);
2382
- } else if (attrName === 'style') {
2398
+ }
2399
+
2400
+ if (attrName === 'style') {
2383
2401
  attrValue = trimWhitespace(attrValue);
2384
2402
  if (attrValue) {
2385
2403
  if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
2386
2404
  attrValue = attrValue.replace(/\s*;$/, ';');
2387
2405
  }
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);
2406
+ const originalAttrValue = attrValue;
2407
+ const cssResult = options.minifyCSS(attrValue, 'inline');
2408
+ if (isThenable(cssResult)) {
2409
+ return cssResult
2410
+ .then(minified => {
2411
+ // After minification, check if CSS consists entirely of invalid properties (no values)
2412
+ // I.e., `color:` or `margin:;padding:` should be treated as empty
2413
+ if (minified && /^(?:[a-z-]+:[;\s]*)+$/i.test(minified)) return '';
2414
+ return minified;
2415
+ })
2416
+ .catch(err => {
2417
+ if (!options.continueOnMinifyError) throw err;
2418
+ options.log && options.log(err);
2419
+ return originalAttrValue;
2420
+ });
2400
2421
  }
2422
+ // Sync path (`minifyCSS` disabled—identity function)
2423
+ if (cssResult && /^(?:[a-z-]+:[;\s]*)+$/i.test(cssResult)) return '';
2424
+ return cssResult != null ? cssResult : attrValue;
2401
2425
  }
2402
2426
  return attrValue;
2403
- } else if (isSrcset(attrName, tag)) {
2427
+ }
2428
+
2429
+ if (isSrcset(attrName, tag)) {
2404
2430
  // 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) {
2431
+ const candidates = trimWhitespace(attrValue).split(/\s*,\s*/);
2432
+ const processed = candidates.map(candidate => {
2406
2433
  let url = candidate;
2407
2434
  let descriptor = '';
2408
2435
  const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
@@ -2414,47 +2441,65 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2414
2441
  descriptor = ' ' + num + suffix;
2415
2442
  }
2416
2443
  }
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;
2444
+ const out = options.minifyURLs(url);
2445
+ if (isThenable(out)) {
2446
+ return out
2447
+ .then(result => (typeof result === 'string' ? result : url) + descriptor)
2448
+ .catch(err => {
2449
+ if (!options.continueOnMinifyError) throw err;
2450
+ options.log && options.log(err);
2451
+ return url + descriptor;
2452
+ });
2426
2453
  }
2427
- }))).join(', ');
2428
- } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
2429
- attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
2454
+ return (typeof out === 'string' ? out : url) + descriptor;
2455
+ });
2456
+ if (processed.some(isThenable)) {
2457
+ return Promise.all(processed).then(results => results.join(', '));
2458
+ }
2459
+ return processed.join(', ');
2460
+ }
2461
+
2462
+ if (isMetaViewport(tag, attrs) && attrName === 'content') {
2463
+ return attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
2430
2464
  // 0.90000 → 0.9
2431
2465
  // 1.0 → 1
2432
2466
  // 1.0001 → 1.0001 (unchanged)
2433
2467
  return (+numString).toString();
2434
2468
  });
2435
- } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
2469
+ }
2470
+
2471
+ if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
2436
2472
  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)) {
2473
+ }
2474
+
2475
+ if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
2476
+ return trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
2477
+ }
2478
+
2479
+ if (tag === 'script' && attrName === 'type') {
2480
+ return trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
2481
+ }
2482
+
2483
+ if (isMediaQuery(tag, attrs, attrName)) {
2442
2484
  attrValue = trimWhitespace(attrValue);
2443
2485
  // Only minify actual media queries (those with features in parentheses)
2444
2486
  // Skip simple media types like `all`, `screen`, `print` which are already minimal
2445
2487
  if (!/[()]/.test(attrValue)) {
2446
2488
  return attrValue;
2447
2489
  }
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;
2490
+ const originalAttrValue = attrValue;
2491
+ const cssResult = options.minifyCSS(attrValue, 'media');
2492
+ if (isThenable(cssResult)) {
2493
+ return cssResult.catch(err => {
2494
+ if (!options.continueOnMinifyError) throw err;
2495
+ options.log && options.log(err);
2496
+ return originalAttrValue;
2497
+ });
2456
2498
  }
2457
- } else if (tag === 'iframe' && attrName === 'srcdoc') {
2499
+ return cssResult != null ? cssResult : attrValue;
2500
+ }
2501
+
2502
+ if (tag === 'iframe' && attrName === 'srcdoc') {
2458
2503
  // Recursively minify HTML content within `srcdoc` attribute
2459
2504
  // Fast-path: Skip if nothing would change
2460
2505
  if (!shouldMinifyInnerHTML(options)) {
@@ -2462,6 +2507,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2462
2507
  }
2463
2508
  return minifyHTMLSelf(attrValue, options, true);
2464
2509
  }
2510
+
2465
2511
  return attrValue;
2466
2512
  }
2467
2513
 
@@ -2485,17 +2531,24 @@ function chooseAttributeQuote(attrValue, options) {
2485
2531
  return apos < quot ? '\'' : '"';
2486
2532
  }
2487
2533
 
2488
- async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2534
+ // Returns the normalized attribute object directly (sync) or as a Promise (async);
2535
+ // callers must handle both cases—use `isThenable()` to distinguish
2536
+ function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2489
2537
  const attrName = options.name(attr.name);
2490
2538
  let attrValue = attr.value;
2491
2539
 
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
- }
2540
+ // Entity decoding requires a lazy import—async only when `&` is present
2541
+ if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
2542
+ return getDecodeHTMLStrict().then(decode => {
2543
+ return normalizeAttrContinue(attrName, decode(attrValue), attr, attrs, tag, options, minifyHTML);
2544
+ });
2497
2545
  }
2498
2546
 
2547
+ return normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, minifyHTML);
2548
+ }
2549
+
2550
+ // Internal: Handles attribute normalization after entity decoding (if any)
2551
+ function normalizeAttrContinue(attrName, attrValue, attr, attrs, tag, options, minifyHTML) {
2499
2552
  if ((options.removeRedundantAttributes &&
2500
2553
  isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
2501
2554
  (options.removeScriptTypeAttributes && tag === 'script' &&
@@ -2506,9 +2559,18 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2506
2559
  }
2507
2560
 
2508
2561
  if (attrValue) {
2509
- attrValue = await cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
2562
+ const cleaned = cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTML);
2563
+ if (isThenable(cleaned)) {
2564
+ return cleaned.then(v => normalizeAttrFinish(attrName, v, attr, tag, options));
2565
+ }
2566
+ return normalizeAttrFinish(attrName, cleaned, attr, tag, options);
2510
2567
  }
2511
2568
 
2569
+ return normalizeAttrFinish(attrName, attrValue, attr, tag, options);
2570
+ }
2571
+
2572
+ // Internal: Final checks and result assembly after value cleaning
2573
+ function normalizeAttrFinish(attrName, attrValue, attr, tag, options) {
2512
2574
  if (options.removeEmptyAttributes &&
2513
2575
  canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
2514
2576
  return;
@@ -3734,31 +3796,33 @@ async function minifyHTML(value, options, partialMarkup) {
3734
3796
  let removeEmptyElementsExcept;
3735
3797
  if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
3736
3798
  if (options.log) {
3737
- options.log('Warning: removeEmptyElementsExcept option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
3799
+ options.log('Warning: `removeEmptyElementsExcept` option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
3738
3800
  }
3739
3801
  removeEmptyElementsExcept = [];
3740
3802
  } else {
3741
3803
  removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
3742
3804
  }
3743
3805
 
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 = [];
3806
+ // Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there;
3807
+ // for all we care there might be completely-horribly-broken-alien-non-html-emoji-cthulhu-filled content
3808
+ if (value.indexOf('<!-- htmlmin:ignore -->') !== -1) {
3809
+ value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
3810
+ if (!uidIgnore) {
3811
+ uidIgnore = uniqueId(value);
3812
+ const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
3813
+ uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
3814
+ if (options.ignoreCustomComments) {
3815
+ options.ignoreCustomComments = options.ignoreCustomComments.slice();
3816
+ } else {
3817
+ options.ignoreCustomComments = [];
3818
+ }
3819
+ options.ignoreCustomComments.push(pattern);
3755
3820
  }
3756
- options.ignoreCustomComments.push(pattern);
3757
- }
3758
- const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
3759
- ignoredMarkupChunks.push(group1);
3760
- return token;
3761
- });
3821
+ const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
3822
+ ignoredMarkupChunks.push(group1);
3823
+ return token;
3824
+ });
3825
+ }
3762
3826
 
3763
3827
  // Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
3764
3828
  // This allows proper frequency analysis with access to ignored content via UID tokens
@@ -3993,9 +4057,8 @@ async function minifyHTML(value, options, partialMarkup) {
3993
4057
  options.sortAttributes(tag, attrs);
3994
4058
  }
3995
4059
 
3996
- const normalizedAttrs = await Promise.all(
3997
- attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML))
3998
- );
4060
+ const attrResults = attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML));
4061
+ const normalizedAttrs = attrResults.some(isThenable) ? await Promise.all(attrResults) : attrResults;
3999
4062
  const parts = [];
4000
4063
  let isLast = true;
4001
4064
  for (let i = normalizedAttrs.length - 1; i >= 0; i--) {
@@ -4123,207 +4186,225 @@ async function minifyHTML(value, options, partialMarkup) {
4123
4186
  }
4124
4187
  }
4125
4188
  },
4126
- chars: async function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
4189
+ chars: function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
4127
4190
  prevTag = prevTag === '' ? 'comment' : prevTag;
4128
4191
  nextTag = nextTag === '' ? 'comment' : nextTag;
4129
4192
  prevAttrs = prevAttrs || [];
4130
4193
  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]*$/, '');
4194
+
4195
+ // Detect whether any async work is actually needed for this text node
4196
+ const needsDecode = options.decodeEntities && text && !specialContentElements.has(currentTag) && text.indexOf('&') !== -1;
4197
+ const needsProcessScript = specialContentElements.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs));
4198
+ const needsMinifyJS = options.minifyJS !== identity && isExecutableScript(currentTag, currentAttrs);
4199
+ const needsMinifyCSS = options.minifyCSS !== identity && isStyleElement(currentTag, currentAttrs);
4200
+
4201
+ // Whitespace collapsing phase (sync); captures `prevTag`/`nextTag`/`prevAttrs`/`nextAttrs` from outer scope
4202
+ function charsCollapse(text) {
4203
+ // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
4204
+ // This removes trailing newlines often added by template engines before closing tags
4205
+ // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
4206
+ if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
4207
+ const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
4208
+ if (preTextareaDepth > 0) {
4209
+ // Trim trailing whitespace only if it ends with a single newline (not multiple)
4210
+ // Multiple newlines are likely intentional formatting, single newline is often a template artifact
4211
+ // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
4212
+ if (nextTag && nextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
4213
+ text = text.replace(/(?:\r\n|\r|\n)[ \t]*$/, '');
4214
+ }
4147
4215
  }
4148
4216
  }
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
- });
4217
+ if (options.collapseWhitespace) {
4218
+ if (!stackNoTrimWhitespace.length) {
4219
+ if (prevTag === 'comment') {
4220
+ const prevComment = buffer[buffer.length - 1];
4221
+ if (!uidIgnore || prevComment.indexOf(uidIgnore) === -1) {
4222
+ if (!prevComment) {
4223
+ prevTag = charsPrevTag;
4224
+ }
4225
+ if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
4226
+ const charsIndex = buffer.length - 2;
4227
+ buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
4228
+ text = trailingSpaces + text;
4229
+ return '';
4230
+ });
4231
+ }
4164
4232
  }
4165
4233
  }
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--;
4234
+ if (prevTag) {
4235
+ if (prevTag === '/nobr' || prevTag === 'wbr') {
4236
+ if (/^\s/.test(text)) {
4237
+ let tagIndex = buffer.length - 1;
4238
+ while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
4239
+ tagIndex--;
4240
+ }
4241
+ trimTrailingWhitespace(tagIndex - 1, 'br');
4173
4242
  }
4174
- trimTrailingWhitespace(tagIndex - 1, 'br');
4243
+ } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
4244
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
4175
4245
  }
4176
- } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
4177
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
4246
+ }
4247
+ if (prevTag || nextTag) {
4248
+ text = collapseWhitespaceSmart(text, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet);
4249
+ } else {
4250
+ text = collapseWhitespace(text, options, true, true);
4251
+ }
4252
+ if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
4253
+ trimTrailingWhitespace(buffer.length - 1, nextTag);
4178
4254
  }
4179
4255
  }
4180
- if (prevTag || nextTag) {
4181
- text = collapseWhitespaceSmart(text, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet);
4182
- } else {
4183
- text = collapseWhitespace(text, options, true, true);
4256
+ if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
4257
+ text = collapseWhitespace(text, options, false, false, true);
4184
4258
  }
4185
- if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
4186
- trimTrailingWhitespace(buffer.length - 1, nextTag);
4259
+ }
4260
+ return text;
4261
+ }
4262
+
4263
+ // Finalization phase (sync): Optional tag handling, entity re-encoding, buffer push
4264
+ function charsFinalize(text) {
4265
+ if (options.removeOptionalTags && text) {
4266
+ // `<html>` may be omitted if first thing inside is not a comment
4267
+ // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
4268
+ if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
4269
+ removeStartTag();
4270
+ }
4271
+ optionalStartTag = '';
4272
+ // `</html>` or `</body>` may be omitted if not followed by comment
4273
+ // `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
4274
+ if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text)))) {
4275
+ removeEndTag();
4276
+ }
4277
+ // Don’t reset `optionalEndTag` if text is only whitespace and will be collapsed (not conservatively)
4278
+ if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
4279
+ optionalEndTag = '';
4280
+ optionalEndTagEmitted = false;
4187
4281
  }
4188
4282
  }
4189
- if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
4190
- text = collapseWhitespace(text, options, false, false, true);
4283
+ charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
4284
+ if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
4285
+ // Escape any `&` symbols that start either:
4286
+ // 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
4287
+ // 2. or any other character reference (i.e., one that does end with `;`)
4288
+ // Note that `&` can be escaped as `&amp`, without the semicolon.
4289
+ // https://mathiasbynens.be/notes/ambiguous-ampersands
4290
+ if (text.indexOf('&') !== -1) {
4291
+ text = text.replace(RE_LEGACY_ENTITIES, '&amp$1');
4292
+ }
4293
+ if (text.indexOf('<') !== -1) {
4294
+ text = text.replace(RE_ESCAPE_LT, '&lt;');
4295
+ }
4191
4296
  }
4297
+ if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
4298
+ text = text.replace(uidPattern, function (match, prefix, index) {
4299
+ return ignoredCustomMarkupChunks[+index][0];
4300
+ });
4301
+ }
4302
+ currentChars += text;
4303
+ if (text) {
4304
+ hasChars = true;
4305
+ }
4306
+ buffer.push(text);
4192
4307
  }
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);
4308
+
4309
+ // Fast path: All work is sync—skip async machinery entirely
4310
+ if (!needsDecode && !needsProcessScript && !needsMinifyJS && !needsMinifyCSS) {
4311
+ charsFinalize(charsCollapse(text));
4312
+ return;
4201
4313
  }
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();
4314
+
4315
+ // Slow path: At least one async step required
4316
+ return (async () => {
4317
+ if (needsDecode) {
4318
+ text = (await getDecodeHTML())(text);
4213
4319
  }
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;
4320
+ text = charsCollapse(text);
4321
+ if (needsProcessScript) {
4322
+ text = await processScript(text, options, currentAttrs, minifyHTML);
4218
4323
  }
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');
4324
+ if (needsMinifyJS) {
4325
+ text = await options.minifyJS(text);
4229
4326
  }
4230
- if (text.indexOf('<') !== -1) {
4231
- text = text.replace(RE_ESCAPE_LT, '&lt;');
4327
+ if (needsMinifyCSS) {
4328
+ text = await options.minifyCSS(text);
4232
4329
  }
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);
4330
+ charsFinalize(text);
4331
+ })();
4244
4332
  },
4245
- comment: async function (text, nonStandard) {
4333
+ comment: function (text, nonStandard) {
4246
4334
  const prefix = nonStandard ? '<!' : '<!--';
4247
4335
  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 = '';
4336
+
4337
+ // Finalization phase (sync): Optional tag handling, `htmlmin:ignore` whitespace collapsing, buffer push
4338
+ function commentFinalize(comment) {
4339
+ if (options.removeOptionalTags && comment) {
4340
+ // Preceding comments suppress tag omissions
4341
+ optionalStartTag = '';
4342
+ optionalEndTag = '';
4343
+ optionalEndTagEmitted = false;
4255
4344
  }
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
4345
 
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 = '';
4346
+ // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
4347
+ if (options.collapseWhitespace && comment && uidIgnorePlaceholderPattern) {
4348
+ if (uidIgnorePlaceholderPattern.test(comment)) {
4349
+ // Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
4350
+ if (buffer.length >= 2) {
4351
+ const prevText = buffer[buffer.length - 1];
4352
+ const prevComment = buffer[buffer.length - 2];
4353
+
4354
+ // Check if previous item is whitespace-only and item before that is ignore-placeholder
4355
+ if (prevText && /^\s+$/.test(prevText) && prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
4356
+ // Extract the index from both placeholders to check their content
4357
+ const currentMatch = comment.match(uidIgnorePlaceholderPattern);
4358
+ const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
4359
+
4360
+ if (currentMatch && prevMatch) {
4361
+ const currentIndex = +currentMatch[1];
4362
+ const prevIndex = +prevMatch[1];
4363
+
4364
+ // Defensive bounds check to ensure indices are valid
4365
+ if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
4366
+ const currentContent = ignoredMarkupChunks[currentIndex];
4367
+ const prevContent = ignoredMarkupChunks[prevIndex];
4368
+
4369
+ // Only collapse whitespace if both blocks contain HTML (start with `<`)
4370
+ // Don’t collapse if either contains plain text, as that would change meaning
4371
+ // Note: This check will match HTML comments (`<!-- … -->`), but the tag name
4372
+ // regex below requires starting with a letter, so comments are intentionally
4373
+ // excluded by the `currentTagMatch && prevTagMatch` guard
4374
+ if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
4375
+ // Extract tag names from the HTML content (excludes comments, processing instructions, etc.)
4376
+ const currentTagMatch = currentContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
4377
+ const prevTagMatch = prevContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
4378
+
4379
+ // Only collapse if both matched valid element tags (not comments/text)
4380
+ // and both tags are block-level (inline elements need whitespace preserved)
4381
+ if (currentTagMatch && prevTagMatch) {
4382
+ const currentTag = options.name(currentTagMatch[1]);
4383
+ const prevTag = options.name(prevTagMatch[1]);
4384
+
4385
+ // Don’t collapse between inline elements
4386
+ if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
4387
+ // Collapse whitespace respecting context rules
4388
+ let collapsedText = prevText;
4389
+
4390
+ // Apply `collapseWhitespace` with appropriate context
4391
+ if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
4392
+ // Not in pre or other no-collapse context
4393
+ if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
4394
+ // Preserve line break as single newline
4395
+ collapsedText = '\n';
4396
+ } else if (options.conservativeCollapse) {
4397
+ // Conservative mode: Keep single space
4398
+ collapsedText = ' ';
4399
+ } else {
4400
+ // Aggressive mode: Remove all whitespace
4401
+ collapsedText = '';
4402
+ }
4322
4403
  }
4323
- }
4324
4404
 
4325
- // Replace the whitespace in buffer
4326
- buffer[buffer.length - 1] = collapsedText;
4405
+ // Replace the whitespace in buffer
4406
+ buffer[buffer.length - 1] = collapsedText;
4407
+ }
4327
4408
  }
4328
4409
  }
4329
4410
  }
@@ -4332,9 +4413,27 @@ async function minifyHTML(value, options, partialMarkup) {
4332
4413
  }
4333
4414
  }
4334
4415
  }
4416
+
4417
+ buffer.push(comment);
4335
4418
  }
4336
4419
 
4337
- buffer.push(text);
4420
+ // Only conditional comments require async work (recursive minification)
4421
+ if (isConditionalComment(text)) {
4422
+ return cleanConditionalComment(text, options, minifyHTML).then(cleaned => {
4423
+ commentFinalize(prefix + cleaned + suffix);
4424
+ });
4425
+ }
4426
+
4427
+ if (options.removeComments) {
4428
+ if (isIgnoredComment(text, options)) {
4429
+ text = '<!--' + text + '-->';
4430
+ } else {
4431
+ text = '';
4432
+ }
4433
+ } else {
4434
+ text = prefix + text + suffix;
4435
+ }
4436
+ commentFinalize(text);
4338
4437
  },
4339
4438
  doctype: function (doctype) {
4340
4439
  buffer.push(options.useShortDoctype