html-minifier-next 4.11.0 → 4.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -236,34 +236,34 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
236
236
  | Site | Original Size (KB) | [HTML Minifier Next](https://github.com/j9t/html-minifier-next)<br>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-next)](https://socket.dev/npm/package/html-minifier-next) | [HTML Minifier Terser](https://github.com/terser/html-minifier-terser)<br>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-terser)](https://socket.dev/npm/package/html-minifier-terser) | [htmlnano](https://github.com/posthtml/htmlnano)<br>[![npm last update](https://img.shields.io/npm/last-update/htmlnano)](https://socket.dev/npm/package/htmlnano) | [@swc/html](https://github.com/swc-project/swc)<br>[![npm last update](https://img.shields.io/npm/last-update/@swc/html)](https://socket.dev/npm/package/@swc/html) | [minify-html](https://github.com/wilsonzlin/minify-html)<br>[![npm last update](https://img.shields.io/npm/last-update/@minify-html/node)](https://socket.dev/npm/package/@minify-html/node) | [minimize](https://github.com/Swaagie/minimize)<br>[![npm last update](https://img.shields.io/npm/last-update/minimize)](https://socket.dev/npm/package/minimize) | [html­com­pressor.­com](https://htmlcompressor.com/) |
237
237
  | --- | --- | --- | --- | --- | --- | --- | --- | --- |
238
238
  | [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
239
- | [Apple](https://www.apple.com/) | 260 | **203** | 204 | 231 | 235 | 236 | 238 | 238 |
240
- | [BBC](https://www.bbc.co.uk/) | 720 | **655** | 665 | 677 | 677 | 678 | 714 | n/a |
241
- | [CERN](https://home.cern/) | 156 | **87** | **87** | 95 | 95 | 95 | 97 | 100 |
242
- | [CSS-Tricks](https://css-tricks.com/) | 163 | 122 | **120** | 128 | 143 | 144 | 149 | 145 |
243
- | [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6341** | **6341** | 6561 | 6444 | 6567 | 6614 | n/a |
239
+ | [Apple](https://www.apple.com/) | 266 | **206** | n/a | 236 | 239 | 240 | 242 | 243 |
240
+ | [BBC](https://www.bbc.co.uk/) | 697 | **634** | n/a | 655 | 655 | 656 | 691 | n/a |
241
+ | [CERN](https://home.cern/) | 156 | **87** | n/a | 95 | 95 | 95 | 97 | 100 |
242
+ | [CSS-Tricks](https://css-tricks.com/) | 163 | **122** | n/a | 128 | 143 | 144 | 149 | 145 |
243
+ | [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6342** | **6342** | 6561 | 6444 | 6567 | 6614 | n/a |
244
244
  | [EDRi](https://edri.org/) | 80 | **59** | 60 | 70 | 70 | 71 | 75 | 73 |
245
- | [EFF](https://www.eff.org/) | 56 | **48** | **48** | 50 | 49 | 49 | 51 | 51 |
245
+ | [EFF](https://www.eff.org/) | 55 | **47** | n/a | 50 | 48 | 49 | 50 | 50 |
246
246
  | [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | **30** | 32 | 32 | 32 | 32 | 32 |
247
- | [FAZ](https://www.faz.net/aktuell/) | 1562 | 1455 | 1460 | **1400** | 1487 | 1498 | 1509 | n/a |
247
+ | [FAZ](https://www.faz.net/aktuell/) | 1604 | 1494 | 1499 | **1437** | 1527 | 1539 | 1550 | n/a |
248
248
  | [French Tech](https://lafrenchtech.gouv.fr/) | 152 | **121** | 122 | 125 | 125 | 125 | 132 | 126 |
249
- | [Frontend Dogma](https://frontenddogma.com/) | 222 | **213** | 215 | 236 | 221 | 222 | 241 | 222 |
249
+ | [Frontend Dogma](https://frontenddogma.com/) | 223 | **213** | 215 | 236 | 221 | 223 | 241 | 222 |
250
250
  | [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
251
- | [Ground News](https://ground.news/) | 2339 | **2052** | 2056 | 2151 | 2179 | 2182 | 2326 | n/a |
252
- | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | **147** | 153 | **147** | 149 | 155 | 149 |
251
+ | [Ground News](https://ground.news/) | 1481 | **1259** | n/a | 1361 | 1386 | 1392 | 1468 | n/a |
252
+ | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | n/a | 153 | **147** | 149 | 155 | 149 |
253
253
  | [Igalia](https://www.igalia.com/) | 50 | **33** | **33** | 36 | 36 | 36 | 37 | 36 |
254
- | [Leanpub](https://leanpub.com/) | 1204 | **997** | **997** | 1004 | 1003 | 1001 | 1198 | n/a |
255
- | [Mastodon](https://mastodon.social/explore) | 37 | **27** | **27** | 32 | 34 | 35 | 35 | 35 |
256
- | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | **62** | 64 | 65 | 65 | 68 | 68 |
257
- | [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** | 196 | 202 | 200 | 200 | 202 | 203 |
258
- | [Nielsen Norman Group](https://www.nngroup.com/) | 84 | 71 | 71 | **53** | 71 | 73 | 74 | 73 |
254
+ | [Leanpub](https://leanpub.com/) | 1521 | **1287** | **1287** | 1294 | 1293 | 1290 | 1516 | n/a |
255
+ | [Mastodon](https://mastodon.social/explore) | 37 | **27** | n/a | 32 | 34 | 35 | 35 | 35 |
256
+ | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | n/a | 64 | 65 | 65 | 68 | 68 |
257
+ | [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** | **195** | 202 | 200 | 200 | 202 | 203 |
258
+ | [Nielsen Norman Group](https://www.nngroup.com/) | 86 | 74 | 74 | **55** | 74 | 75 | 77 | 76 |
259
259
  | [SitePoint](https://www.sitepoint.com/) | 485 | **354** | **354** | 425 | 459 | 464 | 481 | n/a |
260
260
  | [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 38 | 38 | 39 | 39 |
261
- | [TPGi](https://www.tpgi.com/) | 175 | **159** | 161 | 160 | 164 | 165 | 172 | 171 |
262
- | [United Nations](https://www.un.org/en/) | 151 | **112** | 114 | 121 | 125 | 124 | 130 | 123 |
261
+ | [TPGi](https://www.tpgi.com/) | 175 | **159** | n/a | 160 | 164 | 166 | 172 | 171 |
262
+ | [United Nations](https://www.un.org/en/) | 150 | **112** | 113 | 120 | 124 | 124 | 129 | 122 |
263
263
  | [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
264
- | **Average processing time** | | 302 ms (26/26) | 356 ms (26/26) | 178 ms (26/26) | 59 ms (26/26) | **16 ms (26/26)** | 329 ms (26/26) | 1468 ms (20/26) |
264
+ | **Average processing time** | | 478 ms (26/26) | 735 ms (16/26) | 174 ms (26/26) | 57 ms (26/26) | **17 ms (26/26)** | 318 ms (26/26) | 1536 ms (20/26) |
265
265
 
266
- (Last updated: Dec 16, 2025)
266
+ (Last updated: Dec 17, 2025)
267
267
  <!-- End auto-generated -->
268
268
 
269
269
  ## Examples
@@ -1640,7 +1640,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
1640
1640
  return options.minifyCSS(attrValue, 'media');
1641
1641
  } else if (tag === 'iframe' && attrName === 'srcdoc') {
1642
1642
  // Recursively minify HTML content within srcdoc attribute
1643
- // Fast-path: skip if nothing would change
1643
+ // Fast-path: Skip if nothing would change
1644
1644
  if (!shouldMinifyInnerHTML(options)) {
1645
1645
  return attrValue;
1646
1646
  }
@@ -2072,7 +2072,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2072
2072
 
2073
2073
  if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
2074
2074
  ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
2075
+ // Determine the appropriate quote character
2075
2076
  if (!options.preventAttributesEscaping) {
2077
+ // Normal mode: choose quotes and escape
2076
2078
  if (typeof options.quoteCharacter === 'undefined') {
2077
2079
  // Count quotes in a single pass instead of two regex operations
2078
2080
  let apos = 0, quot = 0;
@@ -2089,6 +2091,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2089
2091
  } else {
2090
2092
  attrValue = attrValue.replace(/'/g, '&#39;');
2091
2093
  }
2094
+ } else {
2095
+ // `preventAttributesEscaping` mode: choose safe quotes but don’t escape
2096
+ // EXCEPT when both quote types are present—then escape to prevent invalid HTML
2097
+ const hasDoubleQuote = attrValue.indexOf('"') !== -1;
2098
+ const hasSingleQuote = attrValue.indexOf("'") !== -1;
2099
+
2100
+ if (hasDoubleQuote && hasSingleQuote) {
2101
+ // Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
2102
+ // Choose the quote type with fewer occurrences and escape the other
2103
+ if (typeof options.quoteCharacter === 'undefined') {
2104
+ let apos = 0, quot = 0;
2105
+ for (let i = 0; i < attrValue.length; i++) {
2106
+ if (attrValue[i] === "'") apos++;
2107
+ else if (attrValue[i] === '"') quot++;
2108
+ }
2109
+ attrQuote = apos < quot ? '\'' : '"';
2110
+ } else {
2111
+ attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
2112
+ }
2113
+ if (attrQuote === '"') {
2114
+ attrValue = attrValue.replace(/"/g, '&#34;');
2115
+ } else {
2116
+ attrValue = attrValue.replace(/'/g, '&#39;');
2117
+ }
2118
+ } else if (typeof options.quoteCharacter === 'undefined') {
2119
+ // Single or no quote type: Choose safe quote delimiter
2120
+ if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
2121
+ attrQuote = "'";
2122
+ } else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
2123
+ attrQuote = '"';
2124
+ } else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
2125
+ // `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
2126
+ // Set a safe default based on the value’s content
2127
+ if (hasSingleQuote && !hasDoubleQuote) {
2128
+ attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
2129
+ } else if (hasDoubleQuote && !hasSingleQuote) {
2130
+ attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
2131
+ } else {
2132
+ attrQuote = '"'; // No quotes in value, default to double quotes
2133
+ }
2134
+ }
2135
+ } else {
2136
+ attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
2137
+ }
2092
2138
  }
2093
2139
  emittedAttrValue = attrQuote + attrValue + attrQuote;
2094
2140
  if (!isLast && !options.removeTagWhitespace) {
@@ -2353,7 +2399,7 @@ function uniqueId(value) {
2353
2399
 
2354
2400
  const specialContentTags = new Set(['script', 'style']);
2355
2401
 
2356
- async function createSortFns(value, options, uidIgnore, uidAttr) {
2402
+ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
2357
2403
  const attrChains = options.sortAttributes && Object.create(null);
2358
2404
  const classChain = options.sortClassName && new TokenChain();
2359
2405
 
@@ -2367,10 +2413,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2367
2413
  return !uid || token.indexOf(uid) === -1;
2368
2414
  }
2369
2415
 
2370
- function shouldSkipUIDs(token) {
2416
+ function shouldKeepToken(token) {
2417
+ // Filter out any HTML comment tokens (UID placeholders)
2418
+ // These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
2419
+ if (token.startsWith('<!--') && token.endsWith('-->')) {
2420
+ return false;
2421
+ }
2371
2422
  return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
2372
2423
  }
2373
2424
 
2425
+ // Pre-compile regex patterns for reuse (performance optimization)
2426
+ // These must be declared before scan() since scan uses them
2427
+ const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
2428
+ const whitespaceSplitPatternSort = /[ \n\f\r]+/;
2429
+
2374
2430
  async function scan(input) {
2375
2431
  let currentTag, currentType;
2376
2432
  const parser = new HTMLParser(input, {
@@ -2379,12 +2435,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2379
2435
  if (!attrChains[tag]) {
2380
2436
  attrChains[tag] = new TokenChain();
2381
2437
  }
2382
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
2438
+ const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
2439
+ attrChains[tag].add(attrNamesList);
2383
2440
  }
2384
2441
  for (let i = 0, len = attrs.length; i < len; i++) {
2385
2442
  const attr = attrs[i];
2386
2443
  if (classChain && attr.value && options.name(attr.name) === 'class') {
2387
- classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
2444
+ const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
2445
+ classChain.add(classes);
2388
2446
  } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
2389
2447
  currentTag = tag;
2390
2448
  currentType = attr.value;
@@ -2404,19 +2462,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2404
2462
  }
2405
2463
  },
2406
2464
  // We never need `nextTag` information in this scan
2407
- wantsNextTag: false
2465
+ wantsNextTag: false,
2466
+ // Continue on parse errors during analysis pass
2467
+ continueOnParseError: options.continueOnParseError
2408
2468
  });
2409
2469
 
2410
- await parser.parse();
2470
+ try {
2471
+ await parser.parse();
2472
+ } catch (e) {
2473
+ // If parsing fails during analysis pass, just skip it—we’ll still have
2474
+ // partial frequency data from what we could parse
2475
+ if (!options.continueOnParseError) {
2476
+ throw e;
2477
+ }
2478
+ }
2411
2479
  }
2412
2480
 
2413
- const log = options.log;
2414
- options.log = identity;
2415
- options.sortAttributes = false;
2416
- options.sortClassName = false;
2417
- const firstPassOutput = await minifyHTML(value, options);
2418
- await scan(firstPassOutput);
2419
- options.log = log;
2481
+ // For the first pass, create a copy of options and disable aggressive minification.
2482
+ // Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
2483
+ // This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
2484
+ // Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
2485
+ const firstPassOptions = Object.assign({}, options, {
2486
+ // Disable sorting for the analysis pass
2487
+ sortAttributes: false,
2488
+ sortClassName: false,
2489
+ // Disable aggressive minification that doesn’t affect attribute analysis
2490
+ collapseWhitespace: false,
2491
+ removeAttributeQuotes: false,
2492
+ removeTagWhitespace: false,
2493
+ decodeEntities: false,
2494
+ processScripts: false,
2495
+ // Keep `ignoreCustomFragments` to handle template syntax correctly
2496
+ // This is safe because `createSortFns` is now called before UID markers are added
2497
+ // Continue on parse errors during analysis (e.g., template syntax)
2498
+ continueOnParseError: true,
2499
+ log: identity
2500
+ });
2501
+
2502
+ // Temporarily enable `continueOnParseError` for the `scan()` function call below.
2503
+ // Note: `firstPassOptions` already has `continueOnParseError: true` for the minifyHTML call.
2504
+ const originalContinueOnParseError = options.continueOnParseError;
2505
+ options.continueOnParseError = true;
2506
+
2507
+ // Pre-compile regex patterns for UID replacement and custom fragments
2508
+ const uidReplacePattern = uidIgnore && ignoredMarkupChunks
2509
+ ? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
2510
+ : null;
2511
+ const customFragmentPattern = options.ignoreCustomFragments && options.ignoreCustomFragments.length > 0
2512
+ ? new RegExp('(' + options.ignoreCustomFragments.map(re => re.source).join('|') + ')', 'g')
2513
+ : null;
2514
+
2515
+ try {
2516
+ // Expand UID tokens back to original content for frequency analysis
2517
+ let expandedValue = value;
2518
+ if (uidReplacePattern) {
2519
+ expandedValue = value.replace(uidReplacePattern, function (match, index) {
2520
+ return ignoredMarkupChunks[+index] || '';
2521
+ });
2522
+ // Reset `lastIndex` for pattern reuse
2523
+ uidReplacePattern.lastIndex = 0;
2524
+ }
2525
+
2526
+ // First pass minification applies attribute transformations
2527
+ // like removeStyleLinkTypeAttributes for accurate frequency analysis
2528
+ const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
2529
+
2530
+ // For frequency analysis, we need to remove custom fragments temporarily
2531
+ // because HTML comments in opening tags prevent proper attribute parsing.
2532
+ // We remove them with a space to preserve attribute boundaries.
2533
+ let scanValue = firstPassOutput;
2534
+ if (customFragmentPattern) {
2535
+ scanValue = firstPassOutput.replace(customFragmentPattern, ' ');
2536
+ }
2537
+
2538
+ await scan(scanValue);
2539
+ } finally {
2540
+ // Restore original option
2541
+ options.continueOnParseError = originalContinueOnParseError;
2542
+ }
2420
2543
  if (attrChains) {
2421
2544
  const attrSorters = Object.create(null);
2422
2545
  for (const tag in attrChains) {
@@ -2430,7 +2553,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2430
2553
  names.forEach(function (name, index) {
2431
2554
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
2432
2555
  });
2433
- sorter.sort(names).forEach(function (name, index) {
2556
+ const sorted = sorter.sort(names);
2557
+ sorted.forEach(function (name, index) {
2434
2558
  attrs[index] = attrMap[name].shift();
2435
2559
  });
2436
2560
  }
@@ -2439,7 +2563,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2439
2563
  if (classChain) {
2440
2564
  const sorter = classChain.createSorter();
2441
2565
  options.sortClassName = function (value) {
2442
- return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
2566
+ // Expand UID tokens back to original content before sorting
2567
+ // Fast path: Skip if no HTML comments (UID markers) present
2568
+ let expandedValue = value;
2569
+ if (uidReplacePattern && value.indexOf('<!--') !== -1) {
2570
+ expandedValue = value.replace(uidReplacePattern, function (match, index) {
2571
+ return ignoredMarkupChunks[+index] || '';
2572
+ });
2573
+ // Reset `lastIndex` for pattern reuse
2574
+ uidReplacePattern.lastIndex = 0;
2575
+ }
2576
+ const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
2577
+ return cls !== '';
2578
+ });
2579
+ const sorted = sorter.sort(classes);
2580
+ return sorted.join(' ');
2443
2581
  };
2444
2582
  }
2445
2583
  }
@@ -2520,6 +2658,13 @@ async function minifyHTML(value, options, partialMarkup) {
2520
2658
  return token;
2521
2659
  });
2522
2660
 
2661
+ // Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
2662
+ // This allows proper frequency analysis with access to ignored content via UID tokens
2663
+ if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
2664
+ (options.sortClassName && typeof options.sortClassName !== 'function')) {
2665
+ await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
2666
+ }
2667
+
2523
2668
  const customFragments = options.ignoreCustomFragments.map(function (re) {
2524
2669
  return re.source;
2525
2670
  });
@@ -2578,11 +2723,6 @@ async function minifyHTML(value, options, partialMarkup) {
2578
2723
  });
2579
2724
  }
2580
2725
 
2581
- if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
2582
- (options.sortClassName && typeof options.sortClassName !== 'function')) {
2583
- await createSortFns(value, options, uidIgnore, uidAttr);
2584
- }
2585
-
2586
2726
  function _canCollapseWhitespace(tag, attrs) {
2587
2727
  return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
2588
2728
  }
@@ -6782,7 +6782,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
6782
6782
  return options.minifyCSS(attrValue, 'media');
6783
6783
  } else if (tag === 'iframe' && attrName === 'srcdoc') {
6784
6784
  // Recursively minify HTML content within srcdoc attribute
6785
- // Fast-path: skip if nothing would change
6785
+ // Fast-path: Skip if nothing would change
6786
6786
  if (!shouldMinifyInnerHTML(options)) {
6787
6787
  return attrValue;
6788
6788
  }
@@ -7214,7 +7214,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
7214
7214
 
7215
7215
  if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
7216
7216
  ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
7217
+ // Determine the appropriate quote character
7217
7218
  if (!options.preventAttributesEscaping) {
7219
+ // Normal mode: choose quotes and escape
7218
7220
  if (typeof options.quoteCharacter === 'undefined') {
7219
7221
  // Count quotes in a single pass instead of two regex operations
7220
7222
  let apos = 0, quot = 0;
@@ -7231,6 +7233,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
7231
7233
  } else {
7232
7234
  attrValue = attrValue.replace(/'/g, '&#39;');
7233
7235
  }
7236
+ } else {
7237
+ // `preventAttributesEscaping` mode: choose safe quotes but don’t escape
7238
+ // EXCEPT when both quote types are present—then escape to prevent invalid HTML
7239
+ const hasDoubleQuote = attrValue.indexOf('"') !== -1;
7240
+ const hasSingleQuote = attrValue.indexOf("'") !== -1;
7241
+
7242
+ if (hasDoubleQuote && hasSingleQuote) {
7243
+ // Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
7244
+ // Choose the quote type with fewer occurrences and escape the other
7245
+ if (typeof options.quoteCharacter === 'undefined') {
7246
+ let apos = 0, quot = 0;
7247
+ for (let i = 0; i < attrValue.length; i++) {
7248
+ if (attrValue[i] === "'") apos++;
7249
+ else if (attrValue[i] === '"') quot++;
7250
+ }
7251
+ attrQuote = apos < quot ? '\'' : '"';
7252
+ } else {
7253
+ attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
7254
+ }
7255
+ if (attrQuote === '"') {
7256
+ attrValue = attrValue.replace(/"/g, '&#34;');
7257
+ } else {
7258
+ attrValue = attrValue.replace(/'/g, '&#39;');
7259
+ }
7260
+ } else if (typeof options.quoteCharacter === 'undefined') {
7261
+ // Single or no quote type: Choose safe quote delimiter
7262
+ if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
7263
+ attrQuote = "'";
7264
+ } else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
7265
+ attrQuote = '"';
7266
+ } else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
7267
+ // `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
7268
+ // Set a safe default based on the value’s content
7269
+ if (hasSingleQuote && !hasDoubleQuote) {
7270
+ attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
7271
+ } else if (hasDoubleQuote && !hasSingleQuote) {
7272
+ attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
7273
+ } else {
7274
+ attrQuote = '"'; // No quotes in value, default to double quotes
7275
+ }
7276
+ }
7277
+ } else {
7278
+ attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
7279
+ }
7234
7280
  }
7235
7281
  emittedAttrValue = attrQuote + attrValue + attrQuote;
7236
7282
  if (!isLast && !options.removeTagWhitespace) {
@@ -7495,7 +7541,7 @@ function uniqueId(value) {
7495
7541
 
7496
7542
  const specialContentTags = new Set(['script', 'style']);
7497
7543
 
7498
- async function createSortFns(value, options, uidIgnore, uidAttr) {
7544
+ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
7499
7545
  const attrChains = options.sortAttributes && Object.create(null);
7500
7546
  const classChain = options.sortClassName && new TokenChain();
7501
7547
 
@@ -7509,10 +7555,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7509
7555
  return !uid || token.indexOf(uid) === -1;
7510
7556
  }
7511
7557
 
7512
- function shouldSkipUIDs(token) {
7558
+ function shouldKeepToken(token) {
7559
+ // Filter out any HTML comment tokens (UID placeholders)
7560
+ // These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
7561
+ if (token.startsWith('<!--') && token.endsWith('-->')) {
7562
+ return false;
7563
+ }
7513
7564
  return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
7514
7565
  }
7515
7566
 
7567
+ // Pre-compile regex patterns for reuse (performance optimization)
7568
+ // These must be declared before scan() since scan uses them
7569
+ const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
7570
+ const whitespaceSplitPatternSort = /[ \n\f\r]+/;
7571
+
7516
7572
  async function scan(input) {
7517
7573
  let currentTag, currentType;
7518
7574
  const parser = new HTMLParser(input, {
@@ -7521,12 +7577,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7521
7577
  if (!attrChains[tag]) {
7522
7578
  attrChains[tag] = new TokenChain();
7523
7579
  }
7524
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
7580
+ const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
7581
+ attrChains[tag].add(attrNamesList);
7525
7582
  }
7526
7583
  for (let i = 0, len = attrs.length; i < len; i++) {
7527
7584
  const attr = attrs[i];
7528
7585
  if (classChain && attr.value && options.name(attr.name) === 'class') {
7529
- classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
7586
+ const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
7587
+ classChain.add(classes);
7530
7588
  } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
7531
7589
  currentTag = tag;
7532
7590
  currentType = attr.value;
@@ -7546,19 +7604,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7546
7604
  }
7547
7605
  },
7548
7606
  // We never need `nextTag` information in this scan
7549
- wantsNextTag: false
7607
+ wantsNextTag: false,
7608
+ // Continue on parse errors during analysis pass
7609
+ continueOnParseError: options.continueOnParseError
7550
7610
  });
7551
7611
 
7552
- await parser.parse();
7612
+ try {
7613
+ await parser.parse();
7614
+ } catch (e) {
7615
+ // If parsing fails during analysis pass, just skip it—we’ll still have
7616
+ // partial frequency data from what we could parse
7617
+ if (!options.continueOnParseError) {
7618
+ throw e;
7619
+ }
7620
+ }
7553
7621
  }
7554
7622
 
7555
- const log = options.log;
7556
- options.log = identity;
7557
- options.sortAttributes = false;
7558
- options.sortClassName = false;
7559
- const firstPassOutput = await minifyHTML(value, options);
7560
- await scan(firstPassOutput);
7561
- options.log = log;
7623
+ // For the first pass, create a copy of options and disable aggressive minification.
7624
+ // Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
7625
+ // This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
7626
+ // Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
7627
+ const firstPassOptions = Object.assign({}, options, {
7628
+ // Disable sorting for the analysis pass
7629
+ sortAttributes: false,
7630
+ sortClassName: false,
7631
+ // Disable aggressive minification that doesn’t affect attribute analysis
7632
+ collapseWhitespace: false,
7633
+ removeAttributeQuotes: false,
7634
+ removeTagWhitespace: false,
7635
+ decodeEntities: false,
7636
+ processScripts: false,
7637
+ // Keep `ignoreCustomFragments` to handle template syntax correctly
7638
+ // This is safe because `createSortFns` is now called before UID markers are added
7639
+ // Continue on parse errors during analysis (e.g., template syntax)
7640
+ continueOnParseError: true,
7641
+ log: identity
7642
+ });
7643
+
7644
+ // Temporarily enable `continueOnParseError` for the `scan()` function call below.
7645
+ // Note: `firstPassOptions` already has `continueOnParseError: true` for the minifyHTML call.
7646
+ const originalContinueOnParseError = options.continueOnParseError;
7647
+ options.continueOnParseError = true;
7648
+
7649
+ // Pre-compile regex patterns for UID replacement and custom fragments
7650
+ const uidReplacePattern = uidIgnore && ignoredMarkupChunks
7651
+ ? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
7652
+ : null;
7653
+ const customFragmentPattern = options.ignoreCustomFragments && options.ignoreCustomFragments.length > 0
7654
+ ? new RegExp('(' + options.ignoreCustomFragments.map(re => re.source).join('|') + ')', 'g')
7655
+ : null;
7656
+
7657
+ try {
7658
+ // Expand UID tokens back to original content for frequency analysis
7659
+ let expandedValue = value;
7660
+ if (uidReplacePattern) {
7661
+ expandedValue = value.replace(uidReplacePattern, function (match, index) {
7662
+ return ignoredMarkupChunks[+index] || '';
7663
+ });
7664
+ // Reset `lastIndex` for pattern reuse
7665
+ uidReplacePattern.lastIndex = 0;
7666
+ }
7667
+
7668
+ // First pass minification applies attribute transformations
7669
+ // like removeStyleLinkTypeAttributes for accurate frequency analysis
7670
+ const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
7671
+
7672
+ // For frequency analysis, we need to remove custom fragments temporarily
7673
+ // because HTML comments in opening tags prevent proper attribute parsing.
7674
+ // We remove them with a space to preserve attribute boundaries.
7675
+ let scanValue = firstPassOutput;
7676
+ if (customFragmentPattern) {
7677
+ scanValue = firstPassOutput.replace(customFragmentPattern, ' ');
7678
+ }
7679
+
7680
+ await scan(scanValue);
7681
+ } finally {
7682
+ // Restore original option
7683
+ options.continueOnParseError = originalContinueOnParseError;
7684
+ }
7562
7685
  if (attrChains) {
7563
7686
  const attrSorters = Object.create(null);
7564
7687
  for (const tag in attrChains) {
@@ -7572,7 +7695,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7572
7695
  names.forEach(function (name, index) {
7573
7696
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
7574
7697
  });
7575
- sorter.sort(names).forEach(function (name, index) {
7698
+ const sorted = sorter.sort(names);
7699
+ sorted.forEach(function (name, index) {
7576
7700
  attrs[index] = attrMap[name].shift();
7577
7701
  });
7578
7702
  }
@@ -7581,7 +7705,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7581
7705
  if (classChain) {
7582
7706
  const sorter = classChain.createSorter();
7583
7707
  options.sortClassName = function (value) {
7584
- return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
7708
+ // Expand UID tokens back to original content before sorting
7709
+ // Fast path: Skip if no HTML comments (UID markers) present
7710
+ let expandedValue = value;
7711
+ if (uidReplacePattern && value.indexOf('<!--') !== -1) {
7712
+ expandedValue = value.replace(uidReplacePattern, function (match, index) {
7713
+ return ignoredMarkupChunks[+index] || '';
7714
+ });
7715
+ // Reset `lastIndex` for pattern reuse
7716
+ uidReplacePattern.lastIndex = 0;
7717
+ }
7718
+ const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
7719
+ return cls !== '';
7720
+ });
7721
+ const sorted = sorter.sort(classes);
7722
+ return sorted.join(' ');
7585
7723
  };
7586
7724
  }
7587
7725
  }
@@ -7662,6 +7800,13 @@ async function minifyHTML(value, options, partialMarkup) {
7662
7800
  return token;
7663
7801
  });
7664
7802
 
7803
+ // Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
7804
+ // This allows proper frequency analysis with access to ignored content via UID tokens
7805
+ if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
7806
+ (options.sortClassName && typeof options.sortClassName !== 'function')) {
7807
+ await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
7808
+ }
7809
+
7665
7810
  const customFragments = options.ignoreCustomFragments.map(function (re) {
7666
7811
  return re.source;
7667
7812
  });
@@ -7720,11 +7865,6 @@ async function minifyHTML(value, options, partialMarkup) {
7720
7865
  });
7721
7866
  }
7722
7867
 
7723
- if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
7724
- (options.sortClassName && typeof options.sortClassName !== 'function')) {
7725
- await createSortFns(value, options, uidIgnore, uidAttr);
7726
- }
7727
-
7728
7868
  function _canCollapseWhitespace(tag, attrs) {
7729
7869
  return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
7730
7870
  }
@@ -1 +1 @@
1
- {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AA84EO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UAr3ES,MAAM;YACN,MAAM;YACN,MAAM;mBACN,MAAM;iBACN,MAAM;kBACN,MAAM;;;;;;;;;;;;;4BAQN,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;wBAMjG,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,SAAS,EAAE,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;;oBAMhH,OAAO;;;;;;;;;kCAON,OAAO;;;;;;;;gCAQR,OAAO;;;;;;;;kCAOP,OAAO;;;;;;;;yBAOP,OAAO;;;;;;;;2BAOP,OAAO;;;;;;;;4BAOP,OAAO;;;;;;;2BAOP,OAAO;;;;;;;;uBAMP,MAAM,EAAE;;;;;;yBAOR,MAAM;;;;;;yBAKN,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;;;;;;;4BAKlB,MAAM,EAAE;;;;;;;oCAMR,MAAM;;;;;;;qBAMN,OAAO;;;;;;;YAMP,OAAO;;;;;;;;2BAMP,MAAM,EAAE;;;;;;;;;4BAOR,MAAM,EAAE;;;;;;;+BAQR,OAAO;;;;;;;2BAMP,SAAS,CAAC,MAAM,CAAC;;;;;;uBAMjB,OAAO;;;;;;;;UAKP,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI;;;;;;;;qBAO1B,MAAM;;;;;;;oBAON,MAAM;;;;;;;;;;gBAMN,OAAO,GAAG,OAAO,CAAC,OAAO,cAAc,EAAE,gBAAgB,CAAC,OAAO,cAAc,EAAE,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;eAS9J,OAAO,GAAG,OAAO,QAAQ,EAAE,aAAa,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;iBASzG,OAAO,GAAG,MAAM,GAAG,OAAO,WAAW,EAAE,OAAO,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;WAS7F,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM;;;;;;;+BAOxB,OAAO;;;;;;;;;;oBAMP,OAAO;;;;;;;;yBASP,OAAO;;;;;;;gCAOP,OAAO;;;;;;;;iCAMP,OAAO;;;;;;;;;;qBAOP,MAAM,EAAE;;;;;;;qBASR,IAAI,GAAG,GAAG;;;;;;;4BAMV,OAAO;;;;;;;;qBAMP,OAAO;;;;;;;;;4BAOP,OAAO,GAAG,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;;;;;;;;0BAQtD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;gCAOP,MAAM,EAAE;;;;;;;;yBAyBR,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;iCAOP,OAAO;;;;;;;oCAMP,OAAO;;;;;;;;;;0BAMP,OAAO;;;;;;;;;qBASP,OAAO,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;;;;;;;;;oBAQzD,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;;;;;;;;0BAQrC,OAAO;;;;;;;sBAOP,OAAO;;wBA3YkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
package/package.json CHANGED
@@ -84,5 +84,5 @@
84
84
  "test:watch": "node --test --watch tests/*.spec.js"
85
85
  },
86
86
  "type": "module",
87
- "version": "4.11.0"
87
+ "version": "4.11.1"
88
88
  }
@@ -840,7 +840,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
840
840
  return options.minifyCSS(attrValue, 'media');
841
841
  } else if (tag === 'iframe' && attrName === 'srcdoc') {
842
842
  // Recursively minify HTML content within srcdoc attribute
843
- // Fast-path: skip if nothing would change
843
+ // Fast-path: Skip if nothing would change
844
844
  if (!shouldMinifyInnerHTML(options)) {
845
845
  return attrValue;
846
846
  }
@@ -1272,7 +1272,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1272
1272
 
1273
1273
  if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
1274
1274
  ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
1275
+ // Determine the appropriate quote character
1275
1276
  if (!options.preventAttributesEscaping) {
1277
+ // Normal mode: choose quotes and escape
1276
1278
  if (typeof options.quoteCharacter === 'undefined') {
1277
1279
  // Count quotes in a single pass instead of two regex operations
1278
1280
  let apos = 0, quot = 0;
@@ -1289,6 +1291,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1289
1291
  } else {
1290
1292
  attrValue = attrValue.replace(/'/g, '&#39;');
1291
1293
  }
1294
+ } else {
1295
+ // `preventAttributesEscaping` mode: choose safe quotes but don’t escape
1296
+ // EXCEPT when both quote types are present—then escape to prevent invalid HTML
1297
+ const hasDoubleQuote = attrValue.indexOf('"') !== -1;
1298
+ const hasSingleQuote = attrValue.indexOf("'") !== -1;
1299
+
1300
+ if (hasDoubleQuote && hasSingleQuote) {
1301
+ // Both quote types present: `preventAttributesEscaping` is ignored to ensure valid HTML
1302
+ // Choose the quote type with fewer occurrences and escape the other
1303
+ if (typeof options.quoteCharacter === 'undefined') {
1304
+ let apos = 0, quot = 0;
1305
+ for (let i = 0; i < attrValue.length; i++) {
1306
+ if (attrValue[i] === "'") apos++;
1307
+ else if (attrValue[i] === '"') quot++;
1308
+ }
1309
+ attrQuote = apos < quot ? '\'' : '"';
1310
+ } else {
1311
+ attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
1312
+ }
1313
+ if (attrQuote === '"') {
1314
+ attrValue = attrValue.replace(/"/g, '&#34;');
1315
+ } else {
1316
+ attrValue = attrValue.replace(/'/g, '&#39;');
1317
+ }
1318
+ } else if (typeof options.quoteCharacter === 'undefined') {
1319
+ // Single or no quote type: Choose safe quote delimiter
1320
+ if (attrQuote === '"' && hasDoubleQuote && !hasSingleQuote) {
1321
+ attrQuote = "'";
1322
+ } else if (attrQuote === "'" && hasSingleQuote && !hasDoubleQuote) {
1323
+ attrQuote = '"';
1324
+ } else if (attrQuote !== '"' && attrQuote !== "'" && attrQuote !== '') {
1325
+ // `attrQuote` is invalid/undefined (not `"`, `'`, or empty string)
1326
+ // Set a safe default based on the value’s content
1327
+ if (hasSingleQuote && !hasDoubleQuote) {
1328
+ attrQuote = '"'; // Value has single quotes, use double quotes as delimiter
1329
+ } else if (hasDoubleQuote && !hasSingleQuote) {
1330
+ attrQuote = "'"; // Value has double quotes, use single quotes as delimiter
1331
+ } else {
1332
+ attrQuote = '"'; // No quotes in value, default to double quotes
1333
+ }
1334
+ }
1335
+ } else {
1336
+ attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
1337
+ }
1292
1338
  }
1293
1339
  emittedAttrValue = attrQuote + attrValue + attrQuote;
1294
1340
  if (!isLast && !options.removeTagWhitespace) {
@@ -1553,7 +1599,7 @@ function uniqueId(value) {
1553
1599
 
1554
1600
  const specialContentTags = new Set(['script', 'style']);
1555
1601
 
1556
- async function createSortFns(value, options, uidIgnore, uidAttr) {
1602
+ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
1557
1603
  const attrChains = options.sortAttributes && Object.create(null);
1558
1604
  const classChain = options.sortClassName && new TokenChain();
1559
1605
 
@@ -1567,10 +1613,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1567
1613
  return !uid || token.indexOf(uid) === -1;
1568
1614
  }
1569
1615
 
1570
- function shouldSkipUIDs(token) {
1616
+ function shouldKeepToken(token) {
1617
+ // Filter out any HTML comment tokens (UID placeholders)
1618
+ // These are temporary markers created by `htmlmin:ignore` and `ignoreCustomFragments`
1619
+ if (token.startsWith('<!--') && token.endsWith('-->')) {
1620
+ return false;
1621
+ }
1571
1622
  return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
1572
1623
  }
1573
1624
 
1625
+ // Pre-compile regex patterns for reuse (performance optimization)
1626
+ // These must be declared before scan() since scan uses them
1627
+ const whitespaceSplitPatternScan = /[ \t\n\f\r]+/;
1628
+ const whitespaceSplitPatternSort = /[ \n\f\r]+/;
1629
+
1574
1630
  async function scan(input) {
1575
1631
  let currentTag, currentType;
1576
1632
  const parser = new HTMLParser(input, {
@@ -1579,12 +1635,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1579
1635
  if (!attrChains[tag]) {
1580
1636
  attrChains[tag] = new TokenChain();
1581
1637
  }
1582
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
1638
+ const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
1639
+ attrChains[tag].add(attrNamesList);
1583
1640
  }
1584
1641
  for (let i = 0, len = attrs.length; i < len; i++) {
1585
1642
  const attr = attrs[i];
1586
1643
  if (classChain && attr.value && options.name(attr.name) === 'class') {
1587
- classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
1644
+ const classes = trimWhitespace(attr.value).split(whitespaceSplitPatternScan).filter(shouldKeepToken);
1645
+ classChain.add(classes);
1588
1646
  } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
1589
1647
  currentTag = tag;
1590
1648
  currentType = attr.value;
@@ -1604,19 +1662,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1604
1662
  }
1605
1663
  },
1606
1664
  // We never need `nextTag` information in this scan
1607
- wantsNextTag: false
1665
+ wantsNextTag: false,
1666
+ // Continue on parse errors during analysis pass
1667
+ continueOnParseError: options.continueOnParseError
1608
1668
  });
1609
1669
 
1610
- await parser.parse();
1670
+ try {
1671
+ await parser.parse();
1672
+ } catch (e) {
1673
+ // If parsing fails during analysis pass, just skip it—we’ll still have
1674
+ // partial frequency data from what we could parse
1675
+ if (!options.continueOnParseError) {
1676
+ throw e;
1677
+ }
1678
+ }
1611
1679
  }
1612
1680
 
1613
- const log = options.log;
1614
- options.log = identity;
1615
- options.sortAttributes = false;
1616
- options.sortClassName = false;
1617
- const firstPassOutput = await minifyHTML(value, options);
1618
- await scan(firstPassOutput);
1619
- options.log = log;
1681
+ // For the first pass, create a copy of options and disable aggressive minification.
1682
+ // Keep attribute transformations (like `removeStyleLinkTypeAttributes`) for accurate analysis.
1683
+ // This is safe because `createSortFns` is called before custom fragment UID markers (uidAttr) are added.
1684
+ // Note: `htmlmin:ignore` UID markers (uidIgnore) already exist and are expanded for analysis.
1685
+ const firstPassOptions = Object.assign({}, options, {
1686
+ // Disable sorting for the analysis pass
1687
+ sortAttributes: false,
1688
+ sortClassName: false,
1689
+ // Disable aggressive minification that doesn’t affect attribute analysis
1690
+ collapseWhitespace: false,
1691
+ removeAttributeQuotes: false,
1692
+ removeTagWhitespace: false,
1693
+ decodeEntities: false,
1694
+ processScripts: false,
1695
+ // Keep `ignoreCustomFragments` to handle template syntax correctly
1696
+ // This is safe because `createSortFns` is now called before UID markers are added
1697
+ // Continue on parse errors during analysis (e.g., template syntax)
1698
+ continueOnParseError: true,
1699
+ log: identity
1700
+ });
1701
+
1702
+ // Temporarily enable `continueOnParseError` for the `scan()` function call below.
1703
+ // Note: `firstPassOptions` already has `continueOnParseError: true` for the minifyHTML call.
1704
+ const originalContinueOnParseError = options.continueOnParseError;
1705
+ options.continueOnParseError = true;
1706
+
1707
+ // Pre-compile regex patterns for UID replacement and custom fragments
1708
+ const uidReplacePattern = uidIgnore && ignoredMarkupChunks
1709
+ ? new RegExp('<!--' + uidIgnore + '(\\d+)-->', 'g')
1710
+ : null;
1711
+ const customFragmentPattern = options.ignoreCustomFragments && options.ignoreCustomFragments.length > 0
1712
+ ? new RegExp('(' + options.ignoreCustomFragments.map(re => re.source).join('|') + ')', 'g')
1713
+ : null;
1714
+
1715
+ try {
1716
+ // Expand UID tokens back to original content for frequency analysis
1717
+ let expandedValue = value;
1718
+ if (uidReplacePattern) {
1719
+ expandedValue = value.replace(uidReplacePattern, function (match, index) {
1720
+ return ignoredMarkupChunks[+index] || '';
1721
+ });
1722
+ // Reset `lastIndex` for pattern reuse
1723
+ uidReplacePattern.lastIndex = 0;
1724
+ }
1725
+
1726
+ // First pass minification applies attribute transformations
1727
+ // like removeStyleLinkTypeAttributes for accurate frequency analysis
1728
+ const firstPassOutput = await minifyHTML(expandedValue, firstPassOptions);
1729
+
1730
+ // For frequency analysis, we need to remove custom fragments temporarily
1731
+ // because HTML comments in opening tags prevent proper attribute parsing.
1732
+ // We remove them with a space to preserve attribute boundaries.
1733
+ let scanValue = firstPassOutput;
1734
+ if (customFragmentPattern) {
1735
+ scanValue = firstPassOutput.replace(customFragmentPattern, ' ');
1736
+ }
1737
+
1738
+ await scan(scanValue);
1739
+ } finally {
1740
+ // Restore original option
1741
+ options.continueOnParseError = originalContinueOnParseError;
1742
+ }
1620
1743
  if (attrChains) {
1621
1744
  const attrSorters = Object.create(null);
1622
1745
  for (const tag in attrChains) {
@@ -1630,7 +1753,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1630
1753
  names.forEach(function (name, index) {
1631
1754
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
1632
1755
  });
1633
- sorter.sort(names).forEach(function (name, index) {
1756
+ const sorted = sorter.sort(names);
1757
+ sorted.forEach(function (name, index) {
1634
1758
  attrs[index] = attrMap[name].shift();
1635
1759
  });
1636
1760
  }
@@ -1639,7 +1763,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1639
1763
  if (classChain) {
1640
1764
  const sorter = classChain.createSorter();
1641
1765
  options.sortClassName = function (value) {
1642
- return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
1766
+ // Expand UID tokens back to original content before sorting
1767
+ // Fast path: Skip if no HTML comments (UID markers) present
1768
+ let expandedValue = value;
1769
+ if (uidReplacePattern && value.indexOf('<!--') !== -1) {
1770
+ expandedValue = value.replace(uidReplacePattern, function (match, index) {
1771
+ return ignoredMarkupChunks[+index] || '';
1772
+ });
1773
+ // Reset `lastIndex` for pattern reuse
1774
+ uidReplacePattern.lastIndex = 0;
1775
+ }
1776
+ const classes = expandedValue.split(whitespaceSplitPatternSort).filter(function(cls) {
1777
+ return cls !== '';
1778
+ });
1779
+ const sorted = sorter.sort(classes);
1780
+ return sorted.join(' ');
1643
1781
  };
1644
1782
  }
1645
1783
  }
@@ -1720,6 +1858,13 @@ async function minifyHTML(value, options, partialMarkup) {
1720
1858
  return token;
1721
1859
  });
1722
1860
 
1861
+ // Create sort functions after `htmlmin:ignore` processing but before custom fragment UID markers
1862
+ // This allows proper frequency analysis with access to ignored content via UID tokens
1863
+ if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
1864
+ (options.sortClassName && typeof options.sortClassName !== 'function')) {
1865
+ await createSortFns(value, options, uidIgnore, null, ignoredMarkupChunks);
1866
+ }
1867
+
1723
1868
  const customFragments = options.ignoreCustomFragments.map(function (re) {
1724
1869
  return re.source;
1725
1870
  });
@@ -1778,11 +1923,6 @@ async function minifyHTML(value, options, partialMarkup) {
1778
1923
  });
1779
1924
  }
1780
1925
 
1781
- if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
1782
- (options.sortClassName && typeof options.sortClassName !== 'function')) {
1783
- await createSortFns(value, options, uidIgnore, uidAttr);
1784
- }
1785
-
1786
1926
  function _canCollapseWhitespace(tag, attrs) {
1787
1927
  return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
1788
1928
  }