html-minifier-next 5.1.1 → 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]+/;
@@ -1385,12 +1392,15 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, prevAttrs, nextAttrs, op
1385
1392
 
1386
1393
  // Collapse/trim whitespace for given tag
1387
1394
 
1395
+ const noCollapseWsTags = new Set(['script', 'style', 'pre', 'textarea']);
1396
+ const noTrimWsTags = new Set(['pre', 'textarea']);
1397
+
1388
1398
  function canCollapseWhitespace(tag) {
1389
- return !/^(?:script|style|pre|textarea)$/.test(tag);
1399
+ return !noCollapseWsTags.has(tag);
1390
1400
  }
1391
1401
 
1392
1402
  function canTrimWhitespace(tag) {
1393
- return !/^(?:pre|textarea)$/.test(tag);
1403
+ return !noTrimWsTags.has(tag);
1394
1404
  }
1395
1405
 
1396
1406
  /**
@@ -1662,7 +1672,7 @@ function shouldMinifyInnerHTML(options) {
1662
1672
  options.removeComments ||
1663
1673
  options.removeOptionalTags ||
1664
1674
  options.minifyJS !== identity ||
1665
- options.minifyCSS !== identityAsync ||
1675
+ options.minifyCSS !== identity ||
1666
1676
  options.minifyURLs !== identity ||
1667
1677
  options.minifySVG
1668
1678
  );
@@ -1689,7 +1699,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
1689
1699
  canTrimWhitespace,
1690
1700
  ...optionDefaults,
1691
1701
  log: identity,
1692
- minifyCSS: identityAsync,
1702
+ minifyCSS: identity,
1693
1703
  minifyJS: identity,
1694
1704
  minifyURLs: identity,
1695
1705
  minifySVG: null
@@ -2220,31 +2230,45 @@ function isBooleanAttribute(attrName, attrValue) {
2220
2230
  (attrValue === '' && emptyCollapsible.has(attrName));
2221
2231
  }
2222
2232
 
2233
+ const uriTypeAttributes = new Map([
2234
+ ['a', new Set(['href'])],
2235
+ ['area', new Set(['href'])],
2236
+ ['link', new Set(['href'])],
2237
+ ['base', new Set(['href'])],
2238
+ ['img', new Set(['src', 'longdesc', 'usemap'])],
2239
+ ['object', new Set(['classid', 'codebase', 'data', 'usemap'])],
2240
+ ['q', new Set(['cite'])],
2241
+ ['blockquote', new Set(['cite'])],
2242
+ ['ins', new Set(['cite'])],
2243
+ ['del', new Set(['cite'])],
2244
+ ['form', new Set(['action'])],
2245
+ ['input', new Set(['src', 'usemap'])],
2246
+ ['head', new Set(['profile'])],
2247
+ ['script', new Set(['src', 'for'])]
2248
+ ]);
2249
+
2223
2250
  function isUriTypeAttribute(attrName, tag) {
2224
- return (
2225
- (/^(?:a|area|link|base)$/.test(tag) && attrName === 'href') ||
2226
- (tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName)) ||
2227
- (tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName)) ||
2228
- (tag === 'q' && attrName === 'cite') ||
2229
- (tag === 'blockquote' && attrName === 'cite') ||
2230
- ((tag === 'ins' || tag === 'del') && attrName === 'cite') ||
2231
- (tag === 'form' && attrName === 'action') ||
2232
- (tag === 'input' && (attrName === 'src' || attrName === 'usemap')) ||
2233
- (tag === 'head' && attrName === 'profile') ||
2234
- (tag === 'script' && (attrName === 'src' || attrName === 'for'))
2235
- );
2251
+ const set = uriTypeAttributes.get(tag);
2252
+ return set ? set.has(attrName) : false;
2236
2253
  }
2237
2254
 
2255
+ const numberTypeAttributes = new Map([
2256
+ ['a', new Set(['tabindex'])],
2257
+ ['area', new Set(['tabindex'])],
2258
+ ['object', new Set(['tabindex'])],
2259
+ ['button', new Set(['tabindex'])],
2260
+ ['input', new Set(['maxlength', 'tabindex'])],
2261
+ ['select', new Set(['size', 'tabindex'])],
2262
+ ['textarea', new Set(['rows', 'cols', 'tabindex'])],
2263
+ ['colgroup', new Set(['span'])],
2264
+ ['col', new Set(['span'])],
2265
+ ['th', new Set(['rowspan', 'colspan'])],
2266
+ ['td', new Set(['rowspan', 'colspan'])]
2267
+ ]);
2268
+
2238
2269
  function isNumberTypeAttribute(attrName, tag) {
2239
- return (
2240
- (/^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex') ||
2241
- (tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex')) ||
2242
- (tag === 'select' && (attrName === 'size' || attrName === 'tabindex')) ||
2243
- (tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName)) ||
2244
- (tag === 'colgroup' && attrName === 'span') ||
2245
- (tag === 'col' && attrName === 'span') ||
2246
- ((tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan'))
2247
- );
2270
+ const set = numberTypeAttributes.get(tag);
2271
+ return set ? set.has(attrName) : false;
2248
2272
  }
2249
2273
 
2250
2274
  function isLinkType(tag, attrs, value) {
@@ -2313,7 +2337,9 @@ function hasAttrName(name, attrs) {
2313
2337
 
2314
2338
  // Cleaners
2315
2339
 
2316
- 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) {
2317
2343
  // Apply early whitespace normalization if enabled
2318
2344
  // Preserves special spaces (no-break space, hair space, etc.) for consistency with `collapseWhitespace`
2319
2345
  if (options.collapseAttributeWhitespace) {
@@ -2328,16 +2354,18 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2328
2354
 
2329
2355
  if (isEventAttribute(attrName, options)) {
2330
2356
  attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
2331
- try {
2332
- return await options.minifyJS(attrValue, true);
2333
- } catch (err) {
2334
- if (!options.continueOnMinifyError) {
2335
- throw err;
2336
- }
2337
- options.log && options.log(err);
2338
- 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
+ });
2339
2364
  }
2340
- } else if (attrName === 'class') {
2365
+ return result;
2366
+ }
2367
+
2368
+ if (attrName === 'class') {
2341
2369
  attrValue = trimWhitespace(attrValue);
2342
2370
  if (options.sortClassNames) {
2343
2371
  attrValue = options.sortClassNames(attrValue);
@@ -2345,47 +2373,63 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2345
2373
  attrValue = collapseWhitespaceAll(attrValue);
2346
2374
  }
2347
2375
  return attrValue;
2348
- } else if (isUriTypeAttribute(attrName, tag)) {
2376
+ }
2377
+
2378
+ if (isUriTypeAttribute(attrName, tag)) {
2349
2379
  attrValue = trimWhitespace(attrValue);
2350
2380
  if (isLinkType(tag, attrs, 'canonical')) {
2351
2381
  return attrValue;
2352
2382
  }
2353
- try {
2354
- const out = await options.minifyURLs(attrValue);
2355
- return typeof out === 'string' ? out : attrValue;
2356
- } catch (err) {
2357
- if (!options.continueOnMinifyError) {
2358
- throw err;
2359
- }
2360
- options.log && options.log(err);
2361
- 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
+ });
2362
2392
  }
2363
- } else if (isNumberTypeAttribute(attrName, tag)) {
2393
+ return typeof result === 'string' ? result : attrValue;
2394
+ }
2395
+
2396
+ if (isNumberTypeAttribute(attrName, tag)) {
2364
2397
  return trimWhitespace(attrValue);
2365
- } else if (attrName === 'style') {
2398
+ }
2399
+
2400
+ if (attrName === 'style') {
2366
2401
  attrValue = trimWhitespace(attrValue);
2367
2402
  if (attrValue) {
2368
2403
  if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
2369
2404
  attrValue = attrValue.replace(/\s*;$/, ';');
2370
2405
  }
2371
- try {
2372
- attrValue = await options.minifyCSS(attrValue, 'inline');
2373
- // After minification, check if CSS consists entirely of invalid properties (no values)
2374
- // I.e., `color:` or `margin:;padding:` should be treated as empty
2375
- if (attrValue && /^(?:[a-z-]+:[;\s]*)+$/i.test(attrValue)) {
2376
- attrValue = '';
2377
- }
2378
- } catch (err) {
2379
- if (!options.continueOnMinifyError) {
2380
- throw err;
2381
- }
2382
- 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
+ });
2383
2421
  }
2422
+ // Sync path (`minifyCSS` disabled—identity function)
2423
+ if (cssResult && /^(?:[a-z-]+:[;\s]*)+$/i.test(cssResult)) return '';
2424
+ return cssResult != null ? cssResult : attrValue;
2384
2425
  }
2385
2426
  return attrValue;
2386
- } else if (isSrcset(attrName, tag)) {
2427
+ }
2428
+
2429
+ if (isSrcset(attrName, tag)) {
2387
2430
  // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
2388
- 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 => {
2389
2433
  let url = candidate;
2390
2434
  let descriptor = '';
2391
2435
  const match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
@@ -2397,47 +2441,65 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2397
2441
  descriptor = ' ' + num + suffix;
2398
2442
  }
2399
2443
  }
2400
- try {
2401
- const out = await options.minifyURLs(url);
2402
- return (typeof out === 'string' ? out : url) + descriptor;
2403
- } catch (err) {
2404
- if (!options.continueOnMinifyError) {
2405
- throw err;
2406
- }
2407
- options.log && options.log(err);
2408
- 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
+ });
2409
2453
  }
2410
- }))).join(', ');
2411
- } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
2412
- 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) {
2413
2464
  // 0.90000 → 0.9
2414
2465
  // 1.0 → 1
2415
2466
  // 1.0001 → 1.0001 (unchanged)
2416
2467
  return (+numString).toString();
2417
2468
  });
2418
- } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
2469
+ }
2470
+
2471
+ if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
2419
2472
  return collapseWhitespaceAll(attrValue);
2420
- } else if (options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
2421
- attrValue = trimWhitespace(attrValue.replace(/ ?[\n\r]+ ?/g, '').replace(/\s{2,}/g, options.conservativeCollapse ? ' ' : ''));
2422
- } else if (tag === 'script' && attrName === 'type') {
2423
- attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
2424
- } 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)) {
2425
2484
  attrValue = trimWhitespace(attrValue);
2426
2485
  // Only minify actual media queries (those with features in parentheses)
2427
2486
  // Skip simple media types like `all`, `screen`, `print` which are already minimal
2428
2487
  if (!/[()]/.test(attrValue)) {
2429
2488
  return attrValue;
2430
2489
  }
2431
- try {
2432
- return await options.minifyCSS(attrValue, 'media');
2433
- } catch (err) {
2434
- if (!options.continueOnMinifyError) {
2435
- throw err;
2436
- }
2437
- options.log && options.log(err);
2438
- 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
+ });
2439
2498
  }
2440
- } else if (tag === 'iframe' && attrName === 'srcdoc') {
2499
+ return cssResult != null ? cssResult : attrValue;
2500
+ }
2501
+
2502
+ if (tag === 'iframe' && attrName === 'srcdoc') {
2441
2503
  // Recursively minify HTML content within `srcdoc` attribute
2442
2504
  // Fast-path: Skip if nothing would change
2443
2505
  if (!shouldMinifyInnerHTML(options)) {
@@ -2445,6 +2507,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2445
2507
  }
2446
2508
  return minifyHTMLSelf(attrValue, options, true);
2447
2509
  }
2510
+
2448
2511
  return attrValue;
2449
2512
  }
2450
2513
 
@@ -2468,17 +2531,24 @@ function chooseAttributeQuote(attrValue, options) {
2468
2531
  return apos < quot ? '\'' : '"';
2469
2532
  }
2470
2533
 
2471
- 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) {
2472
2537
  const attrName = options.name(attr.name);
2473
2538
  let attrValue = attr.value;
2474
2539
 
2475
- if (options.decodeEntities && attrValue) {
2476
- // Fast path: Only decode when entities are present
2477
- if (attrValue.indexOf('&') !== -1) {
2478
- attrValue = (await getDecodeHTMLStrict())(attrValue);
2479
- }
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
+ });
2480
2545
  }
2481
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) {
2482
2552
  if ((options.removeRedundantAttributes &&
2483
2553
  isAttributeRedundant(tag, attrName, attrValue, attrs)) ||
2484
2554
  (options.removeScriptTypeAttributes && tag === 'script' &&
@@ -2489,9 +2559,18 @@ async function normalizeAttr(attr, attrs, tag, options, minifyHTML) {
2489
2559
  }
2490
2560
 
2491
2561
  if (attrValue) {
2492
- 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);
2493
2567
  }
2494
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) {
2495
2574
  if (options.removeEmptyAttributes &&
2496
2575
  canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
2497
2576
  return;
@@ -3691,6 +3770,7 @@ async function minifyHTML(value, options, partialMarkup) {
3691
3770
  let currentAttrs = [];
3692
3771
  const stackNoTrimWhitespace = [];
3693
3772
  const stackNoCollapseWhitespace = [];
3773
+ let preTextareaDepth = 0; // Count of `pre`/`textarea` entries in `stackNoTrimWhitespace`
3694
3774
  let optionalStartTag = '';
3695
3775
  let optionalEndTag = '';
3696
3776
  let optionalEndTagEmitted = false;
@@ -3716,31 +3796,33 @@ async function minifyHTML(value, options, partialMarkup) {
3716
3796
  let removeEmptyElementsExcept;
3717
3797
  if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
3718
3798
  if (options.log) {
3719
- 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);
3720
3800
  }
3721
3801
  removeEmptyElementsExcept = [];
3722
3802
  } else {
3723
3803
  removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
3724
3804
  }
3725
3805
 
3726
- // Temporarily replace ignored chunks with comments, so that we don’t have to worry what’s there.
3727
- // For all we care there might be completely-horribly-broken-alien-non-html-emoji-cthulhu-filled content
3728
- value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function (match, group1) {
3729
- if (!uidIgnore) {
3730
- uidIgnore = uniqueId(value);
3731
- const pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
3732
- uidIgnorePlaceholderPattern = new RegExp('^<!--' + uidIgnore + '(\\d+)-->$');
3733
- if (options.ignoreCustomComments) {
3734
- options.ignoreCustomComments = options.ignoreCustomComments.slice();
3735
- } else {
3736
- 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);
3737
3820
  }
3738
- options.ignoreCustomComments.push(pattern);
3739
- }
3740
- const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
3741
- ignoredMarkupChunks.push(group1);
3742
- return token;
3743
- });
3821
+ const token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
3822
+ ignoredMarkupChunks.push(group1);
3823
+ return token;
3824
+ });
3825
+ }
3744
3826
 
3745
3827
  // Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
3746
3828
  // This allows proper frequency analysis with access to ignored content via UID tokens
@@ -3851,7 +3933,7 @@ async function minifyHTML(value, options, partialMarkup) {
3851
3933
  let charsIndex = buffer.length - 1;
3852
3934
  if (buffer.length > 1) {
3853
3935
  const item = buffer[buffer.length - 1];
3854
- if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
3936
+ if (/^(?:<!|$)/.test(item) && (!uidIgnore || item.indexOf(uidIgnore) === -1)) {
3855
3937
  charsIndex--;
3856
3938
  }
3857
3939
  }
@@ -3945,6 +4027,7 @@ async function minifyHTML(value, options, partialMarkup) {
3945
4027
  if (!unary) {
3946
4028
  if (!canTrimWhitespace$1(tag, attrs) || stackNoTrimWhitespace.length) {
3947
4029
  stackNoTrimWhitespace.push(tag);
4030
+ if (tag === 'pre' || tag === 'textarea') preTextareaDepth++;
3948
4031
  }
3949
4032
  if (!canCollapseWhitespace$1(tag, attrs) || stackNoCollapseWhitespace.length) {
3950
4033
  stackNoCollapseWhitespace.push(tag);
@@ -3974,11 +4057,13 @@ async function minifyHTML(value, options, partialMarkup) {
3974
4057
  options.sortAttributes(tag, attrs);
3975
4058
  }
3976
4059
 
4060
+ const attrResults = attrs.map(attr => normalizeAttr(attr, attrs, tag, options, minifyHTML));
4061
+ const normalizedAttrs = attrResults.some(isThenable) ? await Promise.all(attrResults) : attrResults;
3977
4062
  const parts = [];
3978
- for (let i = attrs.length, isLast = true; --i >= 0;) {
3979
- const normalized = await normalizeAttr(attrs[i], attrs, tag, options, minifyHTML);
3980
- if (normalized) {
3981
- parts.push(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
4063
+ let isLast = true;
4064
+ for (let i = normalizedAttrs.length - 1; i >= 0; i--) {
4065
+ if (normalizedAttrs[i]) {
4066
+ parts.push(buildAttr(normalizedAttrs[i], hasUnarySlash, options, isLast, uidAttr));
3982
4067
  isLast = false;
3983
4068
  }
3984
4069
  }
@@ -4014,6 +4099,7 @@ async function minifyHTML(value, options, partialMarkup) {
4014
4099
  if (options.collapseWhitespace) {
4015
4100
  if (stackNoTrimWhitespace.length) {
4016
4101
  if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
4102
+ if (tag === 'pre' || tag === 'textarea') preTextareaDepth--;
4017
4103
  stackNoTrimWhitespace.pop();
4018
4104
  }
4019
4105
  } else {
@@ -4100,207 +4186,225 @@ async function minifyHTML(value, options, partialMarkup) {
4100
4186
  }
4101
4187
  }
4102
4188
  },
4103
- chars: async function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
4189
+ chars: function (text, prevTag, nextTag, prevAttrs, nextAttrs) {
4104
4190
  prevTag = prevTag === '' ? 'comment' : prevTag;
4105
4191
  nextTag = nextTag === '' ? 'comment' : nextTag;
4106
4192
  prevAttrs = prevAttrs || [];
4107
4193
  nextAttrs = nextAttrs || [];
4108
- if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
4109
- if (text.indexOf('&') !== -1) {
4110
- text = (await getDecodeHTML())(text);
4111
- }
4112
- }
4113
- // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
4114
- // This removes trailing newlines often added by template engines before closing tags
4115
- // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
4116
- if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
4117
- const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
4118
- if (stackNoTrimWhitespace.includes('pre') || stackNoTrimWhitespace.includes('textarea')) {
4119
- // Trim trailing whitespace only if it ends with a single newline (not multiple)
4120
- // Multiple newlines are likely intentional formatting, single newline is often a template artifact
4121
- // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
4122
- if (nextTag && nextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
4123
- 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
+ }
4124
4215
  }
4125
4216
  }
4126
- }
4127
- if (options.collapseWhitespace) {
4128
- if (!stackNoTrimWhitespace.length) {
4129
- if (prevTag === 'comment') {
4130
- const prevComment = buffer[buffer.length - 1];
4131
- if (prevComment.indexOf(uidIgnore) === -1) {
4132
- if (!prevComment) {
4133
- prevTag = charsPrevTag;
4134
- }
4135
- if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
4136
- const charsIndex = buffer.length - 2;
4137
- buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function (trailingSpaces) {
4138
- text = trailingSpaces + text;
4139
- return '';
4140
- });
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
+ }
4141
4232
  }
4142
4233
  }
4143
- }
4144
- if (prevTag) {
4145
- if (prevTag === '/nobr' || prevTag === 'wbr') {
4146
- if (/^\s/.test(text)) {
4147
- let tagIndex = buffer.length - 1;
4148
- while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
4149
- 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');
4150
4242
  }
4151
- trimTrailingWhitespace(tagIndex - 1, 'br');
4243
+ } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
4244
+ text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
4152
4245
  }
4153
- } else if (inlineTextSet.has(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
4154
- 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);
4155
4254
  }
4156
4255
  }
4157
- if (prevTag || nextTag) {
4158
- text = collapseWhitespaceSmart(text, prevTag, nextTag, prevAttrs, nextAttrs, options, inlineElements, inlineTextSet);
4159
- } else {
4160
- text = collapseWhitespace(text, options, true, true);
4256
+ if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
4257
+ text = collapseWhitespace(text, options, false, false, true);
4258
+ }
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;
4161
4281
  }
4162
- if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
4163
- trimTrailingWhitespace(buffer.length - 1, nextTag);
4282
+ }
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;');
4164
4295
  }
4165
4296
  }
4166
- if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
4167
- text = collapseWhitespace(text, options, false, false, true);
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;
4168
4305
  }
4306
+ buffer.push(text);
4169
4307
  }
4170
- if (specialContentElements.has(currentTag) && (options.processScripts || hasJsonScriptType(currentAttrs))) {
4171
- text = await processScript(text, options, currentAttrs, minifyHTML);
4172
- }
4173
- if (isExecutableScript(currentTag, currentAttrs)) {
4174
- text = await options.minifyJS(text);
4175
- }
4176
- if (isStyleElement(currentTag, currentAttrs)) {
4177
- 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;
4178
4313
  }
4179
- if (options.removeOptionalTags && text) {
4180
- // `<html>` may be omitted if first thing inside is not a comment
4181
- // `<body>` may be omitted if first thing inside is not space, comment, `<meta>`, `<link>`, `<script>`, `<style>`, or `<template>`
4182
- if (optionalStartTag === 'html' || (optionalStartTag === 'body' && !/^\s/.test(text))) {
4183
- removeStartTag();
4314
+
4315
+ // Slow path: At least one async step required
4316
+ return (async () => {
4317
+ if (needsDecode) {
4318
+ text = (await getDecodeHTML())(text);
4184
4319
  }
4185
- optionalStartTag = '';
4186
- // `</html>` or `</body>` may be omitted if not followed by comment
4187
- // `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
4188
- if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text)))) {
4189
- removeEndTag();
4320
+ text = charsCollapse(text);
4321
+ if (needsProcessScript) {
4322
+ text = await processScript(text, options, currentAttrs, minifyHTML);
4190
4323
  }
4191
- // Don't reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
4192
- if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
4193
- optionalEndTag = '';
4194
- optionalEndTagEmitted = false;
4195
- }
4196
- }
4197
- charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
4198
- if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
4199
- // Escape any `&` symbols that start either:
4200
- // 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
4201
- // 2. or any other character reference (i.e., one that does end with `;`)
4202
- // Note that `&` can be escaped as `&amp`, without the semicolon.
4203
- // https://mathiasbynens.be/notes/ambiguous-ampersands
4204
- if (text.indexOf('&') !== -1) {
4205
- text = text.replace(RE_LEGACY_ENTITIES, '&amp$1');
4324
+ if (needsMinifyJS) {
4325
+ text = await options.minifyJS(text);
4206
4326
  }
4207
- if (text.indexOf('<') !== -1) {
4208
- text = text.replace(RE_ESCAPE_LT, '&lt;');
4327
+ if (needsMinifyCSS) {
4328
+ text = await options.minifyCSS(text);
4209
4329
  }
4210
- }
4211
- if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
4212
- text = text.replace(uidPattern, function (match, prefix, index) {
4213
- return ignoredCustomMarkupChunks[+index][0];
4214
- });
4215
- }
4216
- currentChars += text;
4217
- if (text) {
4218
- hasChars = true;
4219
- }
4220
- buffer.push(text);
4330
+ charsFinalize(text);
4331
+ })();
4221
4332
  },
4222
- comment: async function (text, nonStandard) {
4333
+ comment: function (text, nonStandard) {
4223
4334
  const prefix = nonStandard ? '<!' : '<!--';
4224
4335
  const suffix = nonStandard ? '>' : '-->';
4225
- if (isConditionalComment(text)) {
4226
- text = prefix + await cleanConditionalComment(text, options, minifyHTML) + suffix;
4227
- } else if (options.removeComments) {
4228
- if (isIgnoredComment(text, options)) {
4229
- text = '<!--' + text + '-->';
4230
- } else {
4231
- 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;
4232
4344
  }
4233
- } else {
4234
- text = prefix + text + suffix;
4235
- }
4236
- if (options.removeOptionalTags && text) {
4237
- // Preceding comments suppress tag omissions
4238
- optionalStartTag = '';
4239
- optionalEndTag = '';
4240
- optionalEndTagEmitted = false;
4241
- }
4242
4345
 
4243
- // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
4244
- if (options.collapseWhitespace && text && uidIgnorePlaceholderPattern) {
4245
- if (uidIgnorePlaceholderPattern.test(text)) {
4246
- // Check if previous buffer items are: [ignore-placeholder, whitespace-only text]
4247
- if (buffer.length >= 2) {
4248
- const prevText = buffer[buffer.length - 1];
4249
- const prevComment = buffer[buffer.length - 2];
4250
-
4251
- // Check if previous item is whitespace-only and item before that is ignore-placeholder
4252
- if (prevText && /^\s+$/.test(prevText) && prevComment && uidIgnorePlaceholderPattern.test(prevComment)) {
4253
- // Extract the index from both placeholders to check their content
4254
- const currentMatch = text.match(uidIgnorePlaceholderPattern);
4255
- const prevMatch = prevComment.match(uidIgnorePlaceholderPattern);
4256
-
4257
- if (currentMatch && prevMatch) {
4258
- const currentIndex = +currentMatch[1];
4259
- const prevIndex = +prevMatch[1];
4260
-
4261
- // Defensive bounds check to ensure indices are valid
4262
- if (currentIndex < ignoredMarkupChunks.length && prevIndex < ignoredMarkupChunks.length) {
4263
- const currentContent = ignoredMarkupChunks[currentIndex];
4264
- const prevContent = ignoredMarkupChunks[prevIndex];
4265
-
4266
- // Only collapse whitespace if both blocks contain HTML (start with `<`)
4267
- // Don’t collapse if either contains plain text, as that would change meaning
4268
- // Note: This check will match HTML comments (`<!-- … -->`), but the tag name
4269
- // regex below requires starting with a letter, so comments are intentionally
4270
- // excluded by the `currentTagMatch && prevTagMatch` guard
4271
- if (currentContent && prevContent && /^\s*</.test(currentContent) && /^\s*</.test(prevContent)) {
4272
- // Extract tag names from the HTML content (excludes comments, processing instructions, etc.)
4273
- const currentTagMatch = currentContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
4274
- const prevTagMatch = prevContent.match(/^\s*<([a-zA-Z][\w:-]*)/);
4275
-
4276
- // Only collapse if both matched valid element tags (not comments/text)
4277
- // and both tags are block-level (inline elements need whitespace preserved)
4278
- if (currentTagMatch && prevTagMatch) {
4279
- const currentTag = options.name(currentTagMatch[1]);
4280
- const prevTag = options.name(prevTagMatch[1]);
4281
-
4282
- // Don’t collapse between inline elements
4283
- if (!inlineElements.has(currentTag) && !inlineElements.has(prevTag)) {
4284
- // Collapse whitespace respecting context rules
4285
- let collapsedText = prevText;
4286
-
4287
- // Apply `collapseWhitespace` with appropriate context
4288
- if (!stackNoTrimWhitespace.length && !stackNoCollapseWhitespace.length) {
4289
- // Not in pre or other no-collapse context
4290
- if (options.preserveLineBreaks && /[\n\r]/.test(prevText)) {
4291
- // Preserve line break as single newline
4292
- collapsedText = '\n';
4293
- } else if (options.conservativeCollapse) {
4294
- // Conservative mode: keep single space
4295
- collapsedText = ' ';
4296
- } else {
4297
- // Aggressive mode: remove all whitespace
4298
- 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
+ }
4299
4403
  }
4300
- }
4301
4404
 
4302
- // Replace the whitespace in buffer
4303
- buffer[buffer.length - 1] = collapsedText;
4405
+ // Replace the whitespace in buffer
4406
+ buffer[buffer.length - 1] = collapsedText;
4407
+ }
4304
4408
  }
4305
4409
  }
4306
4410
  }
@@ -4309,9 +4413,27 @@ async function minifyHTML(value, options, partialMarkup) {
4309
4413
  }
4310
4414
  }
4311
4415
  }
4416
+
4417
+ buffer.push(comment);
4418
+ }
4419
+
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
+ });
4312
4425
  }
4313
4426
 
4314
- buffer.push(text);
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);
4315
4437
  },
4316
4438
  doctype: function (doctype) {
4317
4439
  buffer.push(options.useShortDoctype