html-minifier-next 4.10.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
@@ -139,10 +139,11 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
139
139
  | Option (config/CLI) | Description | Default |
140
140
  | --- | --- | --- |
141
141
  | `caseSensitive`<br>`--case-sensitive` | Treat attributes in case-sensitive manner (useful for custom HTML elements) | `false` |
142
+ | `collapseAttributeWhitespace`<br>`--collapse-attribute-whitespace` | Trim and collapse whitespace characters within attribute values | `false` |
142
143
  | `collapseBooleanAttributes`<br>`--collapse-boolean-attributes` | [Omit attribute values from boolean attributes](https://perfectionkills.com/experimenting-with-html-minifier#collapse_boolean_attributes) | `false` |
143
144
  | `collapseInlineTagWhitespace`<br>`--collapse-inline-tag-whitespace` | Don’t leave any spaces between `display: inline;` elements when collapsing—use with `collapseWhitespace: true` | `false` |
144
145
  | `collapseWhitespace`<br>`--collapse-whitespace` | [Collapse whitespace that contributes to text nodes in a document tree](https://perfectionkills.com/experimenting-with-html-minifier#collapse_whitespace) | `false` |
145
- | `conservativeCollapse`<br>`--conservative-collapse` | Always collapse to 1 space (never remove it entirely)—use with `collapseWhitespace: true` | `false` |
146
+ | `conservativeCollapse`<br>`--conservative-collapse` | Always collapse to one space (never remove it entirely)—use with `collapseWhitespace: true` | `false` |
146
147
  | `continueOnMinifyError`<br>`--no-continue-on-minify-error` | Continue on minification errors; when `false`, minification errors throw and abort processing | `true` |
147
148
  | `continueOnParseError`<br>`--continue-on-parse-error` | [Handle parse errors](https://html.spec.whatwg.org/multipage/parsing.html#parse-errors) instead of aborting | `false` |
148
149
  | `customAttrAssign`<br>`--custom-attr-assign` | Arrays of regexes that allow to support custom attribute assign expressions (e.g., `<div flex?="{{mode != cover}}"></div>`) | `[]` |
@@ -235,34 +236,34 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
235
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/) |
236
237
  | --- | --- | --- | --- | --- | --- | --- | --- | --- |
237
238
  | [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
238
- | [Apple](https://www.apple.com/) | 260 | **203** | 204 | 231 | 235 | 236 | 238 | 238 |
239
- | [BBC](https://www.bbc.co.uk/) | 720 | **655** | 665 | 677 | 677 | 678 | 714 | n/a |
240
- | [CERN](https://home.cern/) | 156 | **87** | **87** | 95 | 95 | 95 | 97 | 100 |
241
- | [CSS-Tricks](https://css-tricks.com/) | 163 | 122 | **120** | 128 | 143 | 144 | 149 | 145 |
242
- | [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 |
243
244
  | [EDRi](https://edri.org/) | 80 | **59** | 60 | 70 | 70 | 71 | 75 | 73 |
244
- | [EFF](https://www.eff.org/) | 56 | **48** | **48** | 50 | 49 | 49 | 51 | 51 |
245
+ | [EFF](https://www.eff.org/) | 55 | **47** | n/a | 50 | 48 | 49 | 50 | 50 |
245
246
  | [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | **30** | 32 | 32 | 32 | 32 | 32 |
246
- | [FAZ](https://www.faz.net/aktuell/) | 1562 | 1455 | 1460 | **1400** | 1487 | 1498 | 1509 | n/a |
247
+ | [FAZ](https://www.faz.net/aktuell/) | 1604 | 1494 | 1499 | **1437** | 1527 | 1539 | 1550 | n/a |
247
248
  | [French Tech](https://lafrenchtech.gouv.fr/) | 152 | **121** | 122 | 125 | 125 | 125 | 132 | 126 |
248
- | [Frontend Dogma](https://frontenddogma.com/) | 222 | **213** | 215 | 236 | 221 | 222 | 241 | 222 |
249
+ | [Frontend Dogma](https://frontenddogma.com/) | 223 | **213** | 215 | 236 | 221 | 223 | 241 | 222 |
249
250
  | [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
250
- | [Ground News](https://ground.news/) | 2339 | **2052** | 2056 | 2151 | 2179 | 2182 | 2326 | n/a |
251
- | [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 |
252
253
  | [Igalia](https://www.igalia.com/) | 50 | **33** | **33** | 36 | 36 | 36 | 37 | 36 |
253
- | [Leanpub](https://leanpub.com/) | 1204 | **997** | **997** | 1004 | 1003 | 1001 | 1198 | n/a |
254
- | [Mastodon](https://mastodon.social/explore) | 37 | **27** | **27** | 32 | 34 | 35 | 35 | 35 |
255
- | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | **62** | 64 | 65 | 65 | 68 | 68 |
256
- | [Middle East Eye](https://www.middleeasteye.net/) | 222 | **195** | 196 | 202 | 200 | 200 | 202 | 203 |
257
- | [Nielsen Norman Group](https://www.nngroup.com/) | 84 | 71 | 71 | **53** | 71 | 73 | 74 | 73 |
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 |
258
259
  | [SitePoint](https://www.sitepoint.com/) | 485 | **354** | **354** | 425 | 459 | 464 | 481 | n/a |
259
260
  | [TetraLogical](https://tetralogical.com/) | 44 | 38 | 38 | **35** | 38 | 38 | 39 | 39 |
260
- | [TPGi](https://www.tpgi.com/) | 175 | **159** | 161 | 160 | 164 | 165 | 172 | 171 |
261
- | [United Nations](https://www.un.org/en/) | 151 | **112** | 114 | 121 | 125 | 124 | 130 | 123 |
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 |
262
263
  | [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
263
- | **Average processing time** | | 302 ms (26/26) | 356 ms (26/26) | 178 ms (26/26) | 59 ms (26/26) | **16 ms (26/26)** | 329 ms (26/26) | 1468 ms (20/26) |
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) |
264
265
 
265
- (Last updated: Dec 16, 2025)
266
+ (Last updated: Dec 17, 2025)
266
267
  <!-- End auto-generated -->
267
268
 
268
269
  ## Examples
package/cli.js CHANGED
@@ -117,17 +117,18 @@ const parseValidInt = (optionName) => (value) => {
117
117
 
118
118
  const mainOptions = {
119
119
  caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)',
120
+ collapseAttributeWhitespace: 'Trim and collapse whitespace characters within attribute values',
120
121
  collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
121
- customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
122
122
  collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with “--collapse-whitespace”',
123
123
  collapseWhitespace: 'Collapse whitespace that contributes to text nodes in a document tree',
124
- conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)—use with “--collapse-whitespace”',
124
+ conservativeCollapse: 'Always collapse to one space (never remove it entirely)—use with “--collapse-whitespace”',
125
125
  continueOnMinifyError: 'Abort on minification errors',
126
126
  continueOnParseError: 'Handle parse errors instead of aborting',
127
127
  customAttrAssign: ['Arrays of regexes that allow to support custom attribute assign expressions (e.g., “<div flex?="{{mode != cover}}"></div>”)', parseJSONRegExpArray],
128
128
  customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g., /ng-class/)', parseRegExp],
129
129
  customAttrSurround: ['Arrays of regexes that allow to support custom attribute surround expressions (e.g., “<input {{#if value}}checked="checked"{{/if}}>”)', parseJSONRegExpArray],
130
130
  customEventAttributes: ['Arrays of regexes that allow to support custom event attributes for minifyJS (e.g., “ng-click”)', parseJSONRegExpArray],
131
+ customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
131
132
  decodeEntities: 'Use direct Unicode characters whenever possible',
132
133
  html5: 'Don’t parse input according to the HTML specification (not recommended for modern HTML)',
133
134
  ignoreCustomComments: ['Array of regexes that allow to ignore certain comments, when matched', parseJSONRegExpArray],
@@ -761,6 +761,7 @@ const presets = {
761
761
  useShortDoctype: true
762
762
  },
763
763
  comprehensive: {
764
+ // @@ Add `collapseAttributeWhitespace: true` (also add to preset in demo)
764
765
  caseSensitive: true,
765
766
  collapseBooleanAttributes: true,
766
767
  collapseInlineTagWhitespace: true,
@@ -860,6 +861,14 @@ async function getTerser() {
860
861
  *
861
862
  * Default: `false`
862
863
  *
864
+ * @prop {boolean} [collapseAttributeWhitespace]
865
+ * Collapse multiple whitespace characters within attribute values into a
866
+ * single space. Also trims leading and trailing whitespace from attribute
867
+ * values. Applied as an early normalization step before special attribute
868
+ * handlers (CSS minification, class sorting, etc.) run.
869
+ *
870
+ * Default: `false`
871
+ *
863
872
  * @prop {boolean} [collapseBooleanAttributes]
864
873
  * Collapse boolean attributes to their name only (for example
865
874
  * `disabled="disabled"` → `disabled`).
@@ -1545,6 +1554,12 @@ function isSrcset(attrName, tag) {
1545
1554
  }
1546
1555
 
1547
1556
  async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
1557
+ // Apply early whitespace normalization if enabled
1558
+ // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
1559
+ if (options.collapseAttributeWhitespace) {
1560
+ attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
1561
+ }
1562
+
1548
1563
  if (isEventAttribute(attrName, options)) {
1549
1564
  attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
1550
1565
  return options.minifyJS(attrValue, true);
@@ -1625,7 +1640,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
1625
1640
  return options.minifyCSS(attrValue, 'media');
1626
1641
  } else if (tag === 'iframe' && attrName === 'srcdoc') {
1627
1642
  // Recursively minify HTML content within srcdoc attribute
1628
- // Fast-path: skip if nothing would change
1643
+ // Fast-path: Skip if nothing would change
1629
1644
  if (!shouldMinifyInnerHTML(options)) {
1630
1645
  return attrValue;
1631
1646
  }
@@ -2057,7 +2072,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2057
2072
 
2058
2073
  if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
2059
2074
  ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
2075
+ // Determine the appropriate quote character
2060
2076
  if (!options.preventAttributesEscaping) {
2077
+ // Normal mode: choose quotes and escape
2061
2078
  if (typeof options.quoteCharacter === 'undefined') {
2062
2079
  // Count quotes in a single pass instead of two regex operations
2063
2080
  let apos = 0, quot = 0;
@@ -2074,6 +2091,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
2074
2091
  } else {
2075
2092
  attrValue = attrValue.replace(/'/g, '&#39;');
2076
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
+ }
2077
2138
  }
2078
2139
  emittedAttrValue = attrQuote + attrValue + attrQuote;
2079
2140
  if (!isLast && !options.removeTagWhitespace) {
@@ -2338,7 +2399,7 @@ function uniqueId(value) {
2338
2399
 
2339
2400
  const specialContentTags = new Set(['script', 'style']);
2340
2401
 
2341
- async function createSortFns(value, options, uidIgnore, uidAttr) {
2402
+ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
2342
2403
  const attrChains = options.sortAttributes && Object.create(null);
2343
2404
  const classChain = options.sortClassName && new TokenChain();
2344
2405
 
@@ -2352,10 +2413,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2352
2413
  return !uid || token.indexOf(uid) === -1;
2353
2414
  }
2354
2415
 
2355
- 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
+ }
2356
2422
  return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
2357
2423
  }
2358
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
+
2359
2430
  async function scan(input) {
2360
2431
  let currentTag, currentType;
2361
2432
  const parser = new HTMLParser(input, {
@@ -2364,12 +2435,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2364
2435
  if (!attrChains[tag]) {
2365
2436
  attrChains[tag] = new TokenChain();
2366
2437
  }
2367
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
2438
+ const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
2439
+ attrChains[tag].add(attrNamesList);
2368
2440
  }
2369
2441
  for (let i = 0, len = attrs.length; i < len; i++) {
2370
2442
  const attr = attrs[i];
2371
2443
  if (classChain && attr.value && options.name(attr.name) === 'class') {
2372
- 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);
2373
2446
  } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
2374
2447
  currentTag = tag;
2375
2448
  currentType = attr.value;
@@ -2389,19 +2462,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2389
2462
  }
2390
2463
  },
2391
2464
  // We never need `nextTag` information in this scan
2392
- wantsNextTag: false
2465
+ wantsNextTag: false,
2466
+ // Continue on parse errors during analysis pass
2467
+ continueOnParseError: options.continueOnParseError
2393
2468
  });
2394
2469
 
2395
- 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
+ }
2396
2479
  }
2397
2480
 
2398
- const log = options.log;
2399
- options.log = identity;
2400
- options.sortAttributes = false;
2401
- options.sortClassName = false;
2402
- const firstPassOutput = await minifyHTML(value, options);
2403
- await scan(firstPassOutput);
2404
- 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
+ }
2405
2543
  if (attrChains) {
2406
2544
  const attrSorters = Object.create(null);
2407
2545
  for (const tag in attrChains) {
@@ -2415,7 +2553,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2415
2553
  names.forEach(function (name, index) {
2416
2554
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
2417
2555
  });
2418
- sorter.sort(names).forEach(function (name, index) {
2556
+ const sorted = sorter.sort(names);
2557
+ sorted.forEach(function (name, index) {
2419
2558
  attrs[index] = attrMap[name].shift();
2420
2559
  });
2421
2560
  }
@@ -2424,7 +2563,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
2424
2563
  if (classChain) {
2425
2564
  const sorter = classChain.createSorter();
2426
2565
  options.sortClassName = function (value) {
2427
- 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(' ');
2428
2581
  };
2429
2582
  }
2430
2583
  }
@@ -2505,6 +2658,13 @@ async function minifyHTML(value, options, partialMarkup) {
2505
2658
  return token;
2506
2659
  });
2507
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
+
2508
2668
  const customFragments = options.ignoreCustomFragments.map(function (re) {
2509
2669
  return re.source;
2510
2670
  });
@@ -2563,11 +2723,6 @@ async function minifyHTML(value, options, partialMarkup) {
2563
2723
  });
2564
2724
  }
2565
2725
 
2566
- if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
2567
- (options.sortClassName && typeof options.sortClassName !== 'function')) {
2568
- await createSortFns(value, options, uidIgnore, uidAttr);
2569
- }
2570
-
2571
2726
  function _canCollapseWhitespace(tag, attrs) {
2572
2727
  return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
2573
2728
  }
@@ -5903,6 +5903,7 @@ const presets = {
5903
5903
  useShortDoctype: true
5904
5904
  },
5905
5905
  comprehensive: {
5906
+ // @@ Add `collapseAttributeWhitespace: true` (also add to preset in demo)
5906
5907
  caseSensitive: true,
5907
5908
  collapseBooleanAttributes: true,
5908
5909
  collapseInlineTagWhitespace: true,
@@ -6002,6 +6003,14 @@ async function getTerser() {
6002
6003
  *
6003
6004
  * Default: `false`
6004
6005
  *
6006
+ * @prop {boolean} [collapseAttributeWhitespace]
6007
+ * Collapse multiple whitespace characters within attribute values into a
6008
+ * single space. Also trims leading and trailing whitespace from attribute
6009
+ * values. Applied as an early normalization step before special attribute
6010
+ * handlers (CSS minification, class sorting, etc.) run.
6011
+ *
6012
+ * Default: `false`
6013
+ *
6005
6014
  * @prop {boolean} [collapseBooleanAttributes]
6006
6015
  * Collapse boolean attributes to their name only (for example
6007
6016
  * `disabled="disabled"` → `disabled`).
@@ -6687,6 +6696,12 @@ function isSrcset(attrName, tag) {
6687
6696
  }
6688
6697
 
6689
6698
  async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
6699
+ // Apply early whitespace normalization if enabled
6700
+ // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
6701
+ if (options.collapseAttributeWhitespace) {
6702
+ attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
6703
+ }
6704
+
6690
6705
  if (isEventAttribute(attrName, options)) {
6691
6706
  attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
6692
6707
  return options.minifyJS(attrValue, true);
@@ -6767,7 +6782,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
6767
6782
  return options.minifyCSS(attrValue, 'media');
6768
6783
  } else if (tag === 'iframe' && attrName === 'srcdoc') {
6769
6784
  // Recursively minify HTML content within srcdoc attribute
6770
- // Fast-path: skip if nothing would change
6785
+ // Fast-path: Skip if nothing would change
6771
6786
  if (!shouldMinifyInnerHTML(options)) {
6772
6787
  return attrValue;
6773
6788
  }
@@ -7199,7 +7214,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
7199
7214
 
7200
7215
  if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
7201
7216
  ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
7217
+ // Determine the appropriate quote character
7202
7218
  if (!options.preventAttributesEscaping) {
7219
+ // Normal mode: choose quotes and escape
7203
7220
  if (typeof options.quoteCharacter === 'undefined') {
7204
7221
  // Count quotes in a single pass instead of two regex operations
7205
7222
  let apos = 0, quot = 0;
@@ -7216,6 +7233,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
7216
7233
  } else {
7217
7234
  attrValue = attrValue.replace(/'/g, '&#39;');
7218
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
+ }
7219
7280
  }
7220
7281
  emittedAttrValue = attrQuote + attrValue + attrQuote;
7221
7282
  if (!isLast && !options.removeTagWhitespace) {
@@ -7480,7 +7541,7 @@ function uniqueId(value) {
7480
7541
 
7481
7542
  const specialContentTags = new Set(['script', 'style']);
7482
7543
 
7483
- async function createSortFns(value, options, uidIgnore, uidAttr) {
7544
+ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
7484
7545
  const attrChains = options.sortAttributes && Object.create(null);
7485
7546
  const classChain = options.sortClassName && new TokenChain();
7486
7547
 
@@ -7494,10 +7555,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7494
7555
  return !uid || token.indexOf(uid) === -1;
7495
7556
  }
7496
7557
 
7497
- 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
+ }
7498
7564
  return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
7499
7565
  }
7500
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
+
7501
7572
  async function scan(input) {
7502
7573
  let currentTag, currentType;
7503
7574
  const parser = new HTMLParser(input, {
@@ -7506,12 +7577,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7506
7577
  if (!attrChains[tag]) {
7507
7578
  attrChains[tag] = new TokenChain();
7508
7579
  }
7509
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
7580
+ const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
7581
+ attrChains[tag].add(attrNamesList);
7510
7582
  }
7511
7583
  for (let i = 0, len = attrs.length; i < len; i++) {
7512
7584
  const attr = attrs[i];
7513
7585
  if (classChain && attr.value && options.name(attr.name) === 'class') {
7514
- 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);
7515
7588
  } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
7516
7589
  currentTag = tag;
7517
7590
  currentType = attr.value;
@@ -7531,19 +7604,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7531
7604
  }
7532
7605
  },
7533
7606
  // We never need `nextTag` information in this scan
7534
- wantsNextTag: false
7607
+ wantsNextTag: false,
7608
+ // Continue on parse errors during analysis pass
7609
+ continueOnParseError: options.continueOnParseError
7535
7610
  });
7536
7611
 
7537
- 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
+ }
7538
7621
  }
7539
7622
 
7540
- const log = options.log;
7541
- options.log = identity;
7542
- options.sortAttributes = false;
7543
- options.sortClassName = false;
7544
- const firstPassOutput = await minifyHTML(value, options);
7545
- await scan(firstPassOutput);
7546
- 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
+ }
7547
7685
  if (attrChains) {
7548
7686
  const attrSorters = Object.create(null);
7549
7687
  for (const tag in attrChains) {
@@ -7557,7 +7695,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7557
7695
  names.forEach(function (name, index) {
7558
7696
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
7559
7697
  });
7560
- sorter.sort(names).forEach(function (name, index) {
7698
+ const sorted = sorter.sort(names);
7699
+ sorted.forEach(function (name, index) {
7561
7700
  attrs[index] = attrMap[name].shift();
7562
7701
  });
7563
7702
  }
@@ -7566,7 +7705,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
7566
7705
  if (classChain) {
7567
7706
  const sorter = classChain.createSorter();
7568
7707
  options.sortClassName = function (value) {
7569
- 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(' ');
7570
7723
  };
7571
7724
  }
7572
7725
  }
@@ -7647,6 +7800,13 @@ async function minifyHTML(value, options, partialMarkup) {
7647
7800
  return token;
7648
7801
  });
7649
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
+
7650
7810
  const customFragments = options.ignoreCustomFragments.map(function (re) {
7651
7811
  return re.source;
7652
7812
  });
@@ -7705,11 +7865,6 @@ async function minifyHTML(value, options, partialMarkup) {
7705
7865
  });
7706
7866
  }
7707
7867
 
7708
- if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
7709
- (options.sortClassName && typeof options.sortClassName !== 'function')) {
7710
- await createSortFns(value, options, uidIgnore, uidAttr);
7711
- }
7712
-
7713
7868
  function _canCollapseWhitespace(tag, attrs) {
7714
7869
  return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
7715
7870
  }
@@ -44,6 +44,15 @@ export type MinifierOptions = {
44
44
  * Default: `false`
45
45
  */
46
46
  caseSensitive?: boolean;
47
+ /**
48
+ * Collapse multiple whitespace characters within attribute values into a
49
+ * single space. Also trims leading and trailing whitespace from attribute
50
+ * values. Applied as an early normalization step before special attribute
51
+ * handlers (CSS minification, class sorting, etc.) run.
52
+ *
53
+ * Default: `false`
54
+ */
55
+ collapseAttributeWhitespace?: boolean;
47
56
  /**
48
57
  * Collapse boolean attributes to their name only (for example
49
58
  * `disabled="disabled"` → `disabled`).
@@ -1 +1 @@
1
- {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AAovEO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UA3tES,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;;;;;;;;gCAOP,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;;wBAnYkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
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"}
@@ -1 +1 @@
1
- {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../../src/presets.js"],"names":[],"mappings":"AAgDA;;;;GAIG;AACH,gCAHW,MAAM,GACJ,MAAM,GAAC,IAAI,CAMvB;AAED;;;GAGG;AACH,kCAFa,MAAM,EAAE,CAIpB"}
1
+ {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../../src/presets.js"],"names":[],"mappings":"AAiDA;;;;GAIG;AACH,gCAHW,MAAM,GACJ,MAAM,GAAC,IAAI,CAMvB;AAED;;;GAGG;AACH,kCAFa,MAAM,EAAE,CAIpB"}
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.10.0"
87
+ "version": "4.11.1"
88
88
  }
@@ -61,6 +61,14 @@ async function getTerser() {
61
61
  *
62
62
  * Default: `false`
63
63
  *
64
+ * @prop {boolean} [collapseAttributeWhitespace]
65
+ * Collapse multiple whitespace characters within attribute values into a
66
+ * single space. Also trims leading and trailing whitespace from attribute
67
+ * values. Applied as an early normalization step before special attribute
68
+ * handlers (CSS minification, class sorting, etc.) run.
69
+ *
70
+ * Default: `false`
71
+ *
64
72
  * @prop {boolean} [collapseBooleanAttributes]
65
73
  * Collapse boolean attributes to their name only (for example
66
74
  * `disabled="disabled"` → `disabled`).
@@ -746,6 +754,12 @@ function isSrcset(attrName, tag) {
746
754
  }
747
755
 
748
756
  async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTMLSelf) {
757
+ // Apply early whitespace normalization if enabled
758
+ // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
759
+ if (options.collapseAttributeWhitespace) {
760
+ attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
761
+ }
762
+
749
763
  if (isEventAttribute(attrName, options)) {
750
764
  attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
751
765
  return options.minifyJS(attrValue, true);
@@ -826,7 +840,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
826
840
  return options.minifyCSS(attrValue, 'media');
827
841
  } else if (tag === 'iframe' && attrName === 'srcdoc') {
828
842
  // Recursively minify HTML content within srcdoc attribute
829
- // Fast-path: skip if nothing would change
843
+ // Fast-path: Skip if nothing would change
830
844
  if (!shouldMinifyInnerHTML(options)) {
831
845
  return attrValue;
832
846
  }
@@ -1258,7 +1272,9 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1258
1272
 
1259
1273
  if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
1260
1274
  ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
1275
+ // Determine the appropriate quote character
1261
1276
  if (!options.preventAttributesEscaping) {
1277
+ // Normal mode: choose quotes and escape
1262
1278
  if (typeof options.quoteCharacter === 'undefined') {
1263
1279
  // Count quotes in a single pass instead of two regex operations
1264
1280
  let apos = 0, quot = 0;
@@ -1275,6 +1291,50 @@ function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
1275
1291
  } else {
1276
1292
  attrValue = attrValue.replace(/'/g, '&#39;');
1277
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
+ }
1278
1338
  }
1279
1339
  emittedAttrValue = attrQuote + attrValue + attrQuote;
1280
1340
  if (!isLast && !options.removeTagWhitespace) {
@@ -1539,7 +1599,7 @@ function uniqueId(value) {
1539
1599
 
1540
1600
  const specialContentTags = new Set(['script', 'style']);
1541
1601
 
1542
- async function createSortFns(value, options, uidIgnore, uidAttr) {
1602
+ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupChunks) {
1543
1603
  const attrChains = options.sortAttributes && Object.create(null);
1544
1604
  const classChain = options.sortClassName && new TokenChain();
1545
1605
 
@@ -1553,10 +1613,20 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1553
1613
  return !uid || token.indexOf(uid) === -1;
1554
1614
  }
1555
1615
 
1556
- 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
+ }
1557
1622
  return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
1558
1623
  }
1559
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
+
1560
1630
  async function scan(input) {
1561
1631
  let currentTag, currentType;
1562
1632
  const parser = new HTMLParser(input, {
@@ -1565,12 +1635,14 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1565
1635
  if (!attrChains[tag]) {
1566
1636
  attrChains[tag] = new TokenChain();
1567
1637
  }
1568
- attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
1638
+ const attrNamesList = attrNames(attrs).filter(shouldKeepToken);
1639
+ attrChains[tag].add(attrNamesList);
1569
1640
  }
1570
1641
  for (let i = 0, len = attrs.length; i < len; i++) {
1571
1642
  const attr = attrs[i];
1572
1643
  if (classChain && attr.value && options.name(attr.name) === 'class') {
1573
- 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);
1574
1646
  } else if (options.processScripts && attr.name.toLowerCase() === 'type') {
1575
1647
  currentTag = tag;
1576
1648
  currentType = attr.value;
@@ -1590,19 +1662,84 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1590
1662
  }
1591
1663
  },
1592
1664
  // We never need `nextTag` information in this scan
1593
- wantsNextTag: false
1665
+ wantsNextTag: false,
1666
+ // Continue on parse errors during analysis pass
1667
+ continueOnParseError: options.continueOnParseError
1594
1668
  });
1595
1669
 
1596
- 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
+ }
1597
1679
  }
1598
1680
 
1599
- const log = options.log;
1600
- options.log = identity;
1601
- options.sortAttributes = false;
1602
- options.sortClassName = false;
1603
- const firstPassOutput = await minifyHTML(value, options);
1604
- await scan(firstPassOutput);
1605
- 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
+ }
1606
1743
  if (attrChains) {
1607
1744
  const attrSorters = Object.create(null);
1608
1745
  for (const tag in attrChains) {
@@ -1616,7 +1753,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1616
1753
  names.forEach(function (name, index) {
1617
1754
  (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
1618
1755
  });
1619
- sorter.sort(names).forEach(function (name, index) {
1756
+ const sorted = sorter.sort(names);
1757
+ sorted.forEach(function (name, index) {
1620
1758
  attrs[index] = attrMap[name].shift();
1621
1759
  });
1622
1760
  }
@@ -1625,7 +1763,21 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1625
1763
  if (classChain) {
1626
1764
  const sorter = classChain.createSorter();
1627
1765
  options.sortClassName = function (value) {
1628
- 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(' ');
1629
1781
  };
1630
1782
  }
1631
1783
  }
@@ -1706,6 +1858,13 @@ async function minifyHTML(value, options, partialMarkup) {
1706
1858
  return token;
1707
1859
  });
1708
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
+
1709
1868
  const customFragments = options.ignoreCustomFragments.map(function (re) {
1710
1869
  return re.source;
1711
1870
  });
@@ -1764,11 +1923,6 @@ async function minifyHTML(value, options, partialMarkup) {
1764
1923
  });
1765
1924
  }
1766
1925
 
1767
- if ((options.sortAttributes && typeof options.sortAttributes !== 'function') ||
1768
- (options.sortClassName && typeof options.sortClassName !== 'function')) {
1769
- await createSortFns(value, options, uidIgnore, uidAttr);
1770
- }
1771
-
1772
1926
  function _canCollapseWhitespace(tag, attrs) {
1773
1927
  return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
1774
1928
  }
package/src/presets.js CHANGED
@@ -22,6 +22,7 @@ export const presets = {
22
22
  useShortDoctype: true
23
23
  },
24
24
  comprehensive: {
25
+ // @@ Add `collapseAttributeWhitespace: true` (also add to preset in demo)
25
26
  caseSensitive: true,
26
27
  collapseBooleanAttributes: true,
27
28
  collapseInlineTagWhitespace: true,