html-minifier-next 4.6.0 → 4.7.0

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
@@ -1,6 +1,6 @@
1
1
  # HTML Minifier Next
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/html-minifier-next.svg)](https://www.npmjs.com/package/html-minifier-next) [![Build status](https://github.com/j9t/html-minifier-next/workflows/Tests/badge.svg)](https://github.com/j9t/html-minifier-next/actions)
3
+ [![npm version](https://img.shields.io/npm/v/html-minifier-next.svg)](https://www.npmjs.com/package/html-minifier-next) [![Build status](https://github.com/j9t/html-minifier-next/workflows/Tests/badge.svg)](https://github.com/j9t/html-minifier-next/actions) [![Socket](https://badge.socket.dev/npm/package/html-minifier-next)](https://socket.dev/npm/package/html-minifier-next)
4
4
 
5
5
  HTML Minifier Next (HMN) is a highly **configurable, well-tested, JavaScript-based HTML minifier**.
6
6
 
@@ -146,7 +146,7 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
146
146
  | `customEventAttributes`<br>`--custom-event-attributes` | Arrays of regexes that allow to support custom event attributes for `minifyJS` (e.g., `ng-click`) | `[ /^on[a-z]{3,}$/ ]` |
147
147
  | `customFragmentQuantifierLimit`<br>`--custom-fragment-quantifier-limit` | Set maximum quantifier limit for custom fragments to prevent ReDoS attacks | `200` |
148
148
  | `decodeEntities`<br>`--decode-entities` | Use direct Unicode characters whenever possible | `false` |
149
- | `html5`<br>`--no-html5` | Parse input according to the HTML specification; when `false`, uses a more lenient parser | `true` |
149
+ | `html5`<br>`--no-html5` | Parse input according to the HTML specification; when `false`, enforces legacy inline/block nesting rules that may restructure modern HTML | `true` |
150
150
  | `ignoreCustomComments`<br>`--ignore-custom-comments` | Array of regexes that allow to ignore certain comments, when matched | `[ /^!/, /^\s*#/ ]` |
151
151
  | `ignoreCustomFragments`<br>`--ignore-custom-fragments` | Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`, etc.) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` |
152
152
  | `includeAutoGeneratedTags`<br>`--no-include-auto-generated-tags` | Insert elements generated by HTML parser; when `false`, omits auto-generated tags | `true` |
@@ -223,29 +223,32 @@ const result = await minify(html, {
223
223
 
224
224
  ## Minification comparison
225
225
 
226
- How does HTML Minifier Next compare to other solutions, like [minimize](https://github.com/Swaagie/minimize), [htmlcompressor.com](http://htmlcompressor.com/), [htmlnano](https://github.com/posthtml/htmlnano), and [minify-html](https://github.com/wilsonzlin/minify-html)? (All with the most aggressive settings, though without [hyper-optimization](https://meiert.com/blog/the-ways-of-writing-html/#toc-hyper-optimized).)
227
-
228
- | Site | Original Size (KB) | HTML Minifier Next | minimize | html­compressor.com | htmlnano | minify-html |
229
- | --- | --- | --- | --- | --- | --- | --- |
230
- | [A List Apart](https://alistapart.com/) | 62 | **52** | 58 | 56 | 54 | 55 |
231
- | [Amazon](https://www.amazon.com/) | 822 | **735** | 806 | n/a | n/a | n/a |
232
- | [Apple](https://www.apple.com/) | 210 | **166** | 195 | 192 | 186 | 191 |
233
- | [BBC](https://www.bbc.co.uk/) | 698 | **632** | 692 | n/a | 655 | 656 |
234
- | [CSS-Tricks](https://css-tricks.com/) | 163 | **124** | 149 | 146 | 127 | 145 |
235
- | [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6342** | 6615 | n/a | 6561 | 6567 |
236
- | [EFF](https://www.eff.org/) | 54 | **46** | 49 | 49 | 49 | 47 |
237
- | [FAZ](https://www.faz.net/aktuell/) | 1860 | **1737** | 1775 | n/a | n/a | 1779 |
238
- | [Frontend Dogma](https://frontenddogma.com/) | 218 | **209** | 235 | 216 | 230 | 217 |
239
- | [Google](https://www.google.com/) | 18 | **17** | 18 | 18 | **17** | n/a |
240
- | [Ground News](https://ground.news/) | 1827 | **1585** | 1814 | n/a | 1679 | n/a |
241
- | [HTML](https://html.spec.whatwg.org/multipage/) | 149 | **147** | 155 | 148 | 153 | 149 |
242
- | [Leanpub](https://leanpub.com/) | 1161 | **974** | 1155 | n/a | 981 | n/a |
243
- | [Mastodon](https://mastodon.social/explore) | 35 | **26** | 34 | 34 | 30 | 33 |
244
- | [MDN](https://developer.mozilla.org/en-US/) | 107 | **62** | 67 | 68 | 64 | n/a |
245
- | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **196** | 203 | 203 | 203 | 200 |
246
- | [SitePoint](https://www.sitepoint.com/) | 494 | **353** | 491 | n/a | 429 | 474 |
247
- | [United Nations](https://www.un.org/en/) | 152 | **113** | 131 | 124 | 122 | 126 |
248
- | [W3C](https://www.w3.org/) | 50 | **36** | 41 | 39 | 39 | 39 |
226
+ How does HTML Minifier Next compare to other minifiers, like [HTML Minifier Terser](https://github.com/terser/html-minifier-terser), [htmlnano](https://github.com/posthtml/htmlnano), [@swc/html](https://github.com/swc-project/swc), [minify-html](https://github.com/wilsonzlin/minify-html), [minimize](https://github.com/Swaagie/minimize), and [htmlcompressor.com](https://htmlcompressor.com/)? (All with the most aggressive settings, though without [hyper-optimization](https://meiert.com/blog/the-ways-of-writing-html/#toc-hyper-optimized). Minimize does not minify CSS or JS.)
227
+
228
+ <!-- Auto-generated benchmarks, don’t edit -->
229
+ | Site | Original Size (KB) | HTML Minifier Next | HTML Minifier Terser | htmlnano | @swc/html | minify-html | minimize | html­com­pressor.­com |
230
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- |
231
+ | [A List Apart](https://alistapart.com/) | 59 | **49** | 50 | 51 | 52 | 51 | 54 | 52 |
232
+ | [Apple](https://www.apple.com/) | 185 | **144** | **144** | 163 | 166 | 167 | 171 | 169 |
233
+ | [BBC](https://www.bbc.co.uk/) | 712 | **646** | 656 | 668 | 669 | 670 | 706 | n/a |
234
+ | [CSS-Tricks](https://css-tricks.com/) | 161 | 121 | **119** | 127 | 142 | 142 | 147 | 144 |
235
+ | [ECMAScript](https://tc39.es/ecma262/) | 7237 | **6341** | **6341** | 6561 | 6444 | 6567 | 6614 | n/a |
236
+ | [EFF](https://www.eff.org/) | 55 | **47** | **47** | 49 | 48 | 49 | 50 | 50 |
237
+ | [FAZ](https://www.faz.net/aktuell/) | 1530 | 1425 | 1430 | **1375** | 1456 | 1467 | 1478 | n/a |
238
+ | [Frontend Dogma](https://frontenddogma.com/) | 221 | **212** | **212** | 233 | 218 | 220 | 238 | 219 |
239
+ | [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | **17** | **17** | 18 | 18 |
240
+ | [Ground News](https://ground.news/) | 1503 | **1287** | 1290 | 1385 | 1407 | 1413 | 1490 | n/a |
241
+ | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | **147** | 153 | **147** | 149 | 155 | 149 |
242
+ | [Leanpub](https://leanpub.com/) | 1723 | **1472** | **1472** | 1479 | 1478 | 1473 | 1718 | n/a |
243
+ | [Mastodon](https://mastodon.social/explore) | 35 | **26** | **26** | 30 | 33 | 33 | 34 | 34 |
244
+ | [MDN](https://developer.mozilla.org/en-US/) | 107 | **62** | **62** | 64 | 64 | 65 | 67 | 67 |
245
+ | [SitePoint](https://www.sitepoint.com/) | 508 | **366** | **366** | 444 | 482 | 486 | 504 | n/a |
246
+ | [United Nations](https://www.un.org/en/) | 153 | **114** | 116 | 123 | 127 | 126 | 132 | 125 |
247
+ | [W3C](https://www.w3.org/) | 50 | **36** | **36** | 38 | 38 | 38 | 40 | 38 |
248
+ | **Average processing time** | | 388 ms (17/17) | 434 ms (17/17) | 219 ms (17/17) | 83 ms (17/17) | **21 ms (17/17)** | 455 ms (17/17) | 1348 ms (11/17) |
249
+
250
+ (Last updated: Dec 6, 2025)
251
+ <!-- End auto-generated -->
249
252
 
250
253
  ## Examples
251
254
 
@@ -386,6 +389,12 @@ ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]
386
389
 
387
390
  ## Running HTML Minifier Next
388
391
 
392
+ ### Local server
393
+
394
+ ```shell
395
+ npm run serve
396
+ ```
397
+
389
398
  ### Benchmarks
390
399
 
391
400
  Benchmarks for minified HTML:
@@ -396,12 +405,6 @@ npm install
396
405
  npm run benchmarks
397
406
  ```
398
407
 
399
- ### Local server
400
-
401
- ```shell
402
- npm run serve
403
- ```
404
-
405
408
  ## Acknowledgements
406
409
 
407
410
  With many thanks to all the previous authors of HTML Minifier, especially [Juriy Zaytsev](https://github.com/kangax), and to everyone who helped make this new edition better, particularly [Daniel Ruf](https://github.com/DanielRuf) and [Jonas Geiler](https://github.com/jonasgeiler).
package/cli.js CHANGED
@@ -129,7 +129,7 @@ const mainOptions = {
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
131
  decodeEntities: 'Use direct Unicode characters whenever possible',
132
- html5: 'Don’t parse input according to the HTML specification',
132
+ html5: 'Don’t parse input according to the HTML specification (not recommended for modern HTML)',
133
133
  ignoreCustomComments: ['Array of regexes that allow to ignore certain comments, when matched', parseJSONRegExpArray],
134
134
  ignoreCustomFragments: ['Array of regexes that allow to ignore certain fragments, when matched (e.g., “<?php … ?>”, “{{ … }}”)', parseJSONRegExpArray],
135
135
  includeAutoGeneratedTags: 'Don’t insert elements generated by HTML parser',
@@ -113,6 +113,9 @@ function joinSingleAttrAssigns(handler) {
113
113
  }).join('|');
114
114
  }
115
115
 
116
+ // Number of captured parts per `customAttrSurround` pattern
117
+ const NCP = 7;
118
+
116
119
  class HTMLParser {
117
120
  constructor(html, handler) {
118
121
  this.html = html;
@@ -125,7 +128,15 @@ class HTMLParser {
125
128
 
126
129
  const stack = []; let lastTag;
127
130
  const attribute = attrForHandler(handler);
128
- let last, prevTag, nextTag;
131
+ let last, prevTag = undefined, nextTag = undefined;
132
+
133
+ // Track position for better error messages
134
+ let position = 0;
135
+ const getLineColumn = (pos) => {
136
+ const lines = this.html.slice(0, pos).split('\n');
137
+ return { line: lines.length, column: lines[lines.length - 1].length + 1 };
138
+ };
139
+
129
140
  while (html) {
130
141
  last = html;
131
142
  // Make sure we’re not in a `script` or `style` element
@@ -243,8 +254,27 @@ class HTMLParser {
243
254
  }
244
255
 
245
256
  if (html === last) {
246
- throw new Error('Parse Error: ' + html);
257
+ if (handler.continueOnParseError) {
258
+ // Skip the problematic character and continue
259
+ if (handler.chars) {
260
+ await handler.chars(html[0], prevTag, '');
261
+ }
262
+ html = html.substring(1);
263
+ position++;
264
+ prevTag = '';
265
+ continue;
266
+ }
267
+ const loc = getLineColumn(position);
268
+ // Include some context before the error position so the snippet contains
269
+ // the offending markup plus preceding characters (e.g. "invalid<tag").
270
+ const CONTEXT_BEFORE = 50;
271
+ const startPos = Math.max(0, position - CONTEXT_BEFORE);
272
+ const snippet = this.html.slice(startPos, startPos + 200).replace(/\n/g, ' ');
273
+ throw new Error(
274
+ `Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${this.html.length > startPos + 200 ? '…' : ''}`
275
+ );
247
276
  }
277
+ position = this.html.length - html.length;
248
278
  }
249
279
 
250
280
  if (!handler.partialMarkup) {
@@ -261,10 +291,77 @@ class HTMLParser {
261
291
  };
262
292
  input = input.slice(start[0].length);
263
293
  let end, attr;
264
- while (!(end = input.match(startTagClose)) && (attr = input.match(attribute))) {
294
+
295
+ // Safety limit: max length of input to check for attributes
296
+ // Protects against catastrophic backtracking on massive attribute values
297
+ const MAX_ATTR_PARSE_LENGTH = 20000; // 20 KB should be enough for any reasonable tag
298
+
299
+ while (true) {
300
+ // Check for closing tag first
301
+ end = input.match(startTagClose);
302
+ if (end) {
303
+ break;
304
+ }
305
+
306
+ // Limit the input length we pass to the regex to prevent catastrophic backtracking
307
+ const isLimited = input.length > MAX_ATTR_PARSE_LENGTH;
308
+ const searchInput = isLimited ? input.slice(0, MAX_ATTR_PARSE_LENGTH) : input;
309
+
310
+ attr = searchInput.match(attribute);
311
+
312
+ // If we limited the input and got a match, check if the value might be truncated
313
+ if (attr && isLimited) {
314
+ // Check if the attribute value extends beyond our search window
315
+ const attrEnd = attr[0].length;
316
+ // If the match ends near the limit, the value might be truncated
317
+ if (attrEnd > MAX_ATTR_PARSE_LENGTH - 100) {
318
+ // Manually extract this attribute to handle potentially huge value
319
+ const manualMatch = input.match(/^\s*([^\s"'<>/=]+)\s*=\s*/);
320
+ if (manualMatch) {
321
+ const quoteChar = input[manualMatch[0].length];
322
+ if (quoteChar === '"' || quoteChar === "'") {
323
+ const closeQuote = input.indexOf(quoteChar, manualMatch[0].length + 1);
324
+ if (closeQuote !== -1) {
325
+ const fullAttr = input.slice(0, closeQuote + 1);
326
+ const numCustomParts = handler.customAttrSurround
327
+ ? handler.customAttrSurround.length * NCP
328
+ : 0;
329
+ const baseIndex = 1 + numCustomParts;
330
+
331
+ attr = [];
332
+ attr[0] = fullAttr;
333
+ attr[baseIndex] = manualMatch[1]; // Attribute name
334
+ attr[baseIndex + 1] = '='; // customAssign (falls back to “=” for huge attributes)
335
+ const value = input.slice(manualMatch[0].length + 1, closeQuote);
336
+ // Place value at correct index based on quote type
337
+ if (quoteChar === '"') {
338
+ attr[baseIndex + 2] = value; // Double-quoted value
339
+ } else {
340
+ attr[baseIndex + 3] = value; // Single-quoted value
341
+ }
342
+ input = input.slice(fullAttr.length);
343
+ match.attrs.push(attr);
344
+ continue;
345
+ }
346
+ }
347
+ // Note: Unquoted attribute values are intentionally not handled here.
348
+ // Per HTML spec, unquoted values cannot contain spaces or special chars,
349
+ // making a 20 KB+ unquoted value practically impossible. If encountered,
350
+ // it’s malformed HTML and using the truncated regex match is acceptable.
351
+ }
352
+ }
353
+ }
354
+
355
+ if (!attr) {
356
+ break;
357
+ }
358
+
265
359
  input = input.slice(attr[0].length);
266
360
  match.attrs.push(attr);
267
361
  }
362
+
363
+ // Check for closing tag
364
+ end = input.match(startTagClose);
268
365
  if (end) {
269
366
  match.unarySlash = end[1];
270
367
  match.rest = input.slice(end[0].length);
@@ -357,7 +454,6 @@ class HTMLParser {
357
454
 
358
455
  const attrs = match.attrs.map(function (args) {
359
456
  let name, value, customOpen, customClose, customAssign, quote;
360
- const ncp = 7; // Number of captured parts, scalar
361
457
 
362
458
  // Hackish workaround for FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
363
459
  if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
@@ -385,7 +481,7 @@ class HTMLParser {
385
481
 
386
482
  let j = 1;
387
483
  if (handler.customAttrSurround) {
388
- for (let i = 0, l = handler.customAttrSurround.length; i < l; i++, j += ncp) {
484
+ for (let i = 0, l = handler.customAttrSurround.length; i < l; i++, j += NCP) {
389
485
  name = args[j + 1];
390
486
  if (name) {
391
487
  quote = populate(j + 2);
@@ -608,18 +704,88 @@ function getPresetNames() {
608
704
  return Object.keys(presets);
609
705
  }
610
706
 
611
- const trimWhitespace = str => str && str.replace(/^[ \n\r\t\f]+/, '').replace(/[ \n\r\t\f]+$/, '');
707
+ // Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
708
+ const RE_WS_START = /^[ \n\r\t\f]+/;
709
+ const RE_WS_END = /[ \n\r\t\f]+$/;
710
+ const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
711
+ const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
712
+ const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
713
+ const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
714
+ const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
715
+ const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
716
+ const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
717
+ const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
718
+ const RE_TRAILING_SEMICOLON = /;$/;
719
+ const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
720
+
721
+ // Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
722
+ function stableStringify(obj) {
723
+ if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
724
+ if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
725
+ const keys = Object.keys(obj).sort();
726
+ let out = '{';
727
+ for (let i = 0; i < keys.length; i++) {
728
+ const k = keys[i];
729
+ out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
730
+ }
731
+ return out + '}';
732
+ }
733
+
734
+ // Minimal LRU cache for strings and promises
735
+ class LRU {
736
+ constructor(limit = 200) {
737
+ this.limit = limit;
738
+ this.map = new Map();
739
+ }
740
+ get(key) {
741
+ const v = this.map.get(key);
742
+ if (v !== undefined) {
743
+ this.map.delete(key);
744
+ this.map.set(key, v);
745
+ }
746
+ return v;
747
+ }
748
+ set(key, value) {
749
+ if (this.map.has(key)) this.map.delete(key);
750
+ this.map.set(key, value);
751
+ if (this.map.size > this.limit) {
752
+ const first = this.map.keys().next().value;
753
+ this.map.delete(first);
754
+ }
755
+ }
756
+ delete(key) { this.map.delete(key); }
757
+ }
758
+
759
+ // Per-process caches
760
+ const jsMinifyCache = new LRU(200);
761
+ const cssMinifyCache = new LRU(200);
762
+
763
+ const trimWhitespace = str => {
764
+ if (!str) return str;
765
+ // Fast path: if no whitespace at start or end, return early
766
+ if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
767
+ return str;
768
+ }
769
+ return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
770
+ };
612
771
 
613
772
  function collapseWhitespaceAll(str) {
773
+ if (!str) return str;
774
+ // Fast path: if there are no common whitespace characters, return early
775
+ if (!/[ \n\r\t\f\xA0]/.test(str)) {
776
+ return str;
777
+ }
614
778
  // Non-breaking space is specifically handled inside the replacer function here:
615
- return str && str.replace(/[ \n\r\t\f\xA0]+/g, function (spaces) {
616
- return spaces === '\t' ? '\t' : spaces.replace(/(^|\xA0+)[^\xA0]+/g, '$1 ');
779
+ return str.replace(RE_ALL_WS_NBSP, function (spaces) {
780
+ return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
617
781
  });
618
782
  }
619
783
 
620
784
  function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
621
785
  let lineBreakBefore = ''; let lineBreakAfter = '';
622
786
 
787
+ if (!str) return str;
788
+
623
789
  if (options.preserveLineBreaks) {
624
790
  str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
625
791
  lineBreakBefore = '\n';
@@ -637,7 +803,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
637
803
  if (conservative && spaces === '\t') {
638
804
  return '\t';
639
805
  }
640
- return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : '');
806
+ return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
641
807
  });
642
808
  }
643
809
 
@@ -648,7 +814,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
648
814
  if (conservative && spaces === '\t') {
649
815
  return '\t';
650
816
  }
651
- return spaces.replace(/[^\xA0]+(\xA0+)/g, ' $1').replace(/[^\xA0]+$/, '') || (conservative ? ' ' : '');
817
+ return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
652
818
  });
653
819
  }
654
820
 
@@ -680,7 +846,7 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
680
846
  }
681
847
 
682
848
  function isConditionalComment(text) {
683
- return /^\[if\s[^\]]+]|\[endif]$/.test(text);
849
+ return RE_CONDITIONAL_COMMENT.test(text);
684
850
  }
685
851
 
686
852
  function isIgnoredComment(text, options) {
@@ -702,12 +868,12 @@ function isEventAttribute(attrName, options) {
702
868
  }
703
869
  return false;
704
870
  }
705
- return /^on[a-z]{3,}$/.test(attrName);
871
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
706
872
  }
707
873
 
708
874
  function canRemoveAttributeQuotes(value) {
709
875
  // https://mathiasbynens.be/notes/unquoted-attribute-values
710
- return /^[^ \t\n\f\r"'`=<>]+$/.test(value);
876
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
711
877
  }
712
878
 
713
879
  function attributesInclude(attributes, attribute) {
@@ -918,7 +1084,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
918
1084
  } else if (attrName === 'style') {
919
1085
  attrValue = trimWhitespace(attrValue);
920
1086
  if (attrValue) {
921
- if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
1087
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
922
1088
  attrValue = attrValue.replace(/\s*;$/, ';');
923
1089
  }
924
1090
  attrValue = await options.minifyCSS(attrValue, 'inline');
@@ -1237,7 +1403,10 @@ async function normalizeAttr(attr, attrs, tag, options) {
1237
1403
  let attrValue = attr.value;
1238
1404
 
1239
1405
  if (options.decodeEntities && attrValue) {
1240
- attrValue = entities.decodeHTMLStrict(attrValue);
1406
+ // Fast path: only decode when entities are present
1407
+ if (attrValue.indexOf('&') !== -1) {
1408
+ attrValue = entities.decodeHTMLStrict(attrValue);
1409
+ }
1241
1410
  }
1242
1411
 
1243
1412
  if ((options.removeRedundantAttributes &&
@@ -1258,8 +1427,8 @@ async function normalizeAttr(attr, attrs, tag, options) {
1258
1427
  return;
1259
1428
  }
1260
1429
 
1261
- if (options.decodeEntities && attrValue) {
1262
- attrValue = attrValue.replace(/&(#?[0-9a-zA-Z]+;)/g, '&amp;$1');
1430
+ if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
1431
+ attrValue = attrValue.replace(RE_AMP_ENTITY, '&amp;$1');
1263
1432
  }
1264
1433
 
1265
1434
  return {
@@ -1379,6 +1548,10 @@ const processOptions = (inputOptions) => {
1379
1548
  const lightningCssOptions = typeof option === 'object' ? option : {};
1380
1549
 
1381
1550
  options.minifyCSS = async function (text, type) {
1551
+ // Fast path: nothing to minify
1552
+ if (!text || !text.trim()) {
1553
+ return text;
1554
+ }
1382
1555
  text = await replaceAsync(
1383
1556
  text,
1384
1557
  /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
@@ -1397,10 +1570,20 @@ const processOptions = (inputOptions) => {
1397
1570
  }
1398
1571
  }
1399
1572
  );
1400
-
1573
+ // Cache key: wrapped content, type, options signature
1401
1574
  const inputCSS = wrapCSS(text, type);
1575
+ const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
1576
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1577
+ const cssKey = inputCSS.length > 2048
1578
+ ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
1579
+ : (inputCSS + '|' + type + '|' + cssSig);
1402
1580
 
1403
1581
  try {
1582
+ const cached = cssMinifyCache.get(cssKey);
1583
+ if (cached) {
1584
+ return cached;
1585
+ }
1586
+
1404
1587
  const result = lightningcss.transform({
1405
1588
  filename: 'input.css',
1406
1589
  code: Buffer.from(inputCSS),
@@ -1423,12 +1606,12 @@ const processOptions = (inputOptions) => {
1423
1606
 
1424
1607
  // Preserve if output is empty and input had template syntax or UIDs
1425
1608
  // This catches cases where Lightning CSS removed content that should be preserved
1426
- if (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) {
1427
- return text;
1428
- }
1609
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1429
1610
 
1430
- return outputCSS;
1611
+ cssMinifyCache.set(cssKey, finalOutput);
1612
+ return finalOutput;
1431
1613
  } catch (err) {
1614
+ cssMinifyCache.delete(cssKey);
1432
1615
  if (!options.continueOnMinifyError) {
1433
1616
  throw err;
1434
1617
  }
@@ -1454,10 +1637,39 @@ const processOptions = (inputOptions) => {
1454
1637
 
1455
1638
  terserOptions.parse.bare_returns = inline;
1456
1639
 
1640
+ let jsKey;
1457
1641
  try {
1458
- const result = await terser.minify(code, terserOptions);
1459
- return result.code.replace(/;$/, '');
1642
+ // Fast path: avoid invoking Terser for empty/whitespace-only content
1643
+ if (!code || !code.trim()) {
1644
+ return '';
1645
+ }
1646
+ // Cache key: content, inline, options signature (subset)
1647
+ const terserSig = stableStringify({
1648
+ compress: terserOptions.compress,
1649
+ mangle: terserOptions.mangle,
1650
+ ecma: terserOptions.ecma,
1651
+ toplevel: terserOptions.toplevel,
1652
+ module: terserOptions.module,
1653
+ keep_fnames: terserOptions.keep_fnames,
1654
+ format: terserOptions.format,
1655
+ cont: !!options.continueOnMinifyError,
1656
+ });
1657
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
1658
+ jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
1659
+ const cached = jsMinifyCache.get(jsKey);
1660
+ if (cached) {
1661
+ return await cached;
1662
+ }
1663
+ const inFlight = (async () => {
1664
+ const result = await terser.minify(code, terserOptions);
1665
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
1666
+ })();
1667
+ jsMinifyCache.set(jsKey, inFlight);
1668
+ const resolved = await inFlight;
1669
+ jsMinifyCache.set(jsKey, resolved);
1670
+ return resolved;
1460
1671
  } catch (err) {
1672
+ if (jsKey) jsMinifyCache.delete(jsKey);
1461
1673
  if (!options.continueOnMinifyError) {
1462
1674
  throw err;
1463
1675
  }
@@ -1548,8 +1760,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1548
1760
  currentTag = '';
1549
1761
  },
1550
1762
  chars: async function (text) {
1763
+ // Only recursively scan HTML content, not JSON-LD or other non-HTML script types
1764
+ // `scan()` is for analyzing HTML attribute order, not for parsing JSON
1551
1765
  if (options.processScripts && specialContentTags.has(currentTag) &&
1552
- options.processScripts.indexOf(currentType) > -1) {
1766
+ options.processScripts.indexOf(currentType) > -1 &&
1767
+ currentType === 'text/html') {
1553
1768
  await scan(text);
1554
1769
  }
1555
1770
  }
@@ -1562,7 +1777,8 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
1562
1777
  options.log = identity;
1563
1778
  options.sortAttributes = false;
1564
1779
  options.sortClassName = false;
1565
- await scan(await minifyHTML(value, options));
1780
+ const firstPassOutput = await minifyHTML(value, options);
1781
+ await scan(firstPassOutput);
1566
1782
  options.log = log;
1567
1783
  if (attrChains) {
1568
1784
  const attrSorters = Object.create(null);
@@ -1915,7 +2131,9 @@ async function minifyHTML(value, options, partialMarkup) {
1915
2131
  prevTag = prevTag === '' ? 'comment' : prevTag;
1916
2132
  nextTag = nextTag === '' ? 'comment' : nextTag;
1917
2133
  if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
1918
- text = entities.decodeHTML(text);
2134
+ if (text.indexOf('&') !== -1) {
2135
+ text = entities.decodeHTML(text);
2136
+ }
1919
2137
  }
1920
2138
  if (options.collapseWhitespace) {
1921
2139
  if (!stackNoTrimWhitespace.length) {
@@ -1989,11 +2207,16 @@ async function minifyHTML(value, options, partialMarkup) {
1989
2207
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
1990
2208
  if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
1991
2209
  // Escape any `&` symbols that start either:
1992
- // 1) a legacy named character reference (i.e. one that doesn't end with `;`)
1993
- // 2) or any other character reference (i.e. one that does end with `;`)
2210
+ // 1) a legacy named character reference (i.e., one that doesnt end with `;`)
2211
+ // 2) or any other character reference (i.e., one that does end with `;`)
1994
2212
  // Note that `&` can be escaped as `&amp`, without the semi-colon.
1995
2213
  // https://mathiasbynens.be/notes/ambiguous-ampersands
1996
- text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&amp$1').replace(/</g, '&lt;');
2214
+ if (text.indexOf('&') !== -1) {
2215
+ text = text.replace(/&((?:Iacute|aacute|uacute|plusmn|Otilde|otilde|agrave|Agrave|Yacute|yacute|Oslash|oslash|atilde|Atilde|brvbar|ccedil|Ccedil|Ograve|curren|divide|eacute|Eacute|ograve|Oacute|egrave|Egrave|Ugrave|frac12|frac14|frac34|ugrave|oacute|iacute|Ntilde|ntilde|Uacute|middot|igrave|Igrave|iquest|Aacute|cedil|laquo|micro|iexcl|Icirc|icirc|acirc|Ucirc|Ecirc|ocirc|Ocirc|ecirc|ucirc|Aring|aring|AElig|aelig|acute|pound|raquo|Acirc|times|THORN|szlig|thorn|COPY|auml|ordf|ordm|Uuml|macr|uuml|Auml|ouml|Ouml|para|nbsp|euml|quot|QUOT|Euml|yuml|cent|sect|copy|sup1|sup2|sup3|iuml|Iuml|ETH|shy|reg|not|yen|amp|AMP|REG|uml|eth|deg|gt|GT|LT|lt)(?!;)|(?:#?[0-9a-zA-Z]+;))/g, '&amp$1');
2216
+ }
2217
+ if (text.indexOf('<') !== -1) {
2218
+ text = text.replace(/</g, '&lt;');
2219
+ }
1997
2220
  }
1998
2221
  if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
1999
2222
  text = text.replace(uidPattern, function (match, prefix, index) {