html-minifier-next 4.6.1 → 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,32 +223,31 @@ const result = await minify(html, {
223
223
 
224
224
  ## Minification comparison
225
225
 
226
- How does HTML Minifier Next compare to other minifiers, like [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).)
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
227
 
228
228
  <!-- Auto-generated benchmarks, don’t edit -->
229
- | Site | Original Size (KB) | HTML Minifier Next | htmlnano | @swc/html | minify-html | minimize | html­com­pressor.­com |
230
- | --- | --- | --- | --- | --- | --- | --- | --- |
231
- | [A List Apart](https://alistapart.com/) | 62 | **52** | 54 | 55 | 55 | 58 | 56 |
232
- | [Apple](https://www.apple.com/) | 190 | **146** | 166 | 169 | 172 | 175 | 172 |
233
- | [BBC](https://www.bbc.co.uk/) | 673 | **613** | 633 | 633 | 634 | 668 | n/a |
234
- | [Codeberg](https://codeberg.org/) | 33 | 29 | **27** | 30 | 30 | 30 | 30 |
235
- | [CSS-Tricks](https://css-tricks.com/) | 165 | **125** | 129 | 146 | 146 | 151 | 148 |
236
- | [ECMAScript](https://tc39.es/ecma262/) | 7238 | **6341** | 6561 | 6444 | 6567 | 6615 | n/a |
237
- | [EFF](https://www.eff.org/) | 54 | **46** | 49 | 47 | 47 | 49 | 49 |
238
- | [FAZ](https://www.faz.net/aktuell/) | 1609 | 1500 | **1431** | 1532 | 1544 | 1555 | n/a |
239
- | [Frontend Dogma](https://frontenddogma.com/) | 220 | **211** | 232 | 217 | 219 | 237 | 218 |
240
- | [Google](https://www.google.com/) | 18 | **17** | **17** | **17** | n/a | 18 | 18 |
241
- | [Ground News](https://ground.news/) | 2358 | **2067** | 2169 | 2199 | n/a | 2345 | n/a |
242
- | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | **147** | 153 | **147** | 149 | 155 | 148 |
243
- | [Leanpub](https://leanpub.com/) | 1348 | **1142** | 1149 | 1148 | n/a | 1343 | n/a |
244
- | [Mastodon](https://mastodon.social/explore) | 35 | **26** | 30 | 33 | 33 | 34 | 34 |
245
- | [MDN](https://developer.mozilla.org/en-US/) | 107 | **62** | 64 | 64 | n/a | 67 | 67 |
246
- | [Middle East Eye](https://www.middleeasteye.net/) | 224 | **197** | 204 | 202 | 202 | 204 | 205 |
247
- | [SitePoint](https://www.sitepoint.com/) | 492 | **350** | 426 | 465 | 472 | 488 | n/a |
248
- | [United Nations](https://www.un.org/en/) | 151 | **113** | 121 | 125 | 125 | 130 | 123 |
249
- | [W3C](https://www.w3.org/) | 50 | **36** | 38 | 38 | 38 | 40 | 38 |
250
-
251
- (Last updated: Dec 1, 2025)
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)
252
251
  <!-- End auto-generated -->
253
252
 
254
253
  ## Examples
@@ -390,6 +389,12 @@ ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]
390
389
 
391
390
  ## Running HTML Minifier Next
392
391
 
392
+ ### Local server
393
+
394
+ ```shell
395
+ npm run serve
396
+ ```
397
+
393
398
  ### Benchmarks
394
399
 
395
400
  Benchmarks for minified HTML:
@@ -400,12 +405,6 @@ npm install
400
405
  npm run benchmarks
401
406
  ```
402
407
 
403
- ### Local server
404
-
405
- ```shell
406
- npm run serve
407
- ```
408
-
409
408
  ## Acknowledgements
410
409
 
411
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',
@@ -704,18 +704,88 @@ function getPresetNames() {
704
704
  return Object.keys(presets);
705
705
  }
706
706
 
707
- 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
+ };
708
771
 
709
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
+ }
710
778
  // Non-breaking space is specifically handled inside the replacer function here:
711
- return str && str.replace(/[ \n\r\t\f\xA0]+/g, function (spaces) {
712
- 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 ');
713
781
  });
714
782
  }
715
783
 
716
784
  function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
717
785
  let lineBreakBefore = ''; let lineBreakAfter = '';
718
786
 
787
+ if (!str) return str;
788
+
719
789
  if (options.preserveLineBreaks) {
720
790
  str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
721
791
  lineBreakBefore = '\n';
@@ -733,7 +803,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
733
803
  if (conservative && spaces === '\t') {
734
804
  return '\t';
735
805
  }
736
- return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : '');
806
+ return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
737
807
  });
738
808
  }
739
809
 
@@ -744,7 +814,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
744
814
  if (conservative && spaces === '\t') {
745
815
  return '\t';
746
816
  }
747
- 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 ? ' ' : '');
748
818
  });
749
819
  }
750
820
 
@@ -776,7 +846,7 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
776
846
  }
777
847
 
778
848
  function isConditionalComment(text) {
779
- return /^\[if\s[^\]]+]|\[endif]$/.test(text);
849
+ return RE_CONDITIONAL_COMMENT.test(text);
780
850
  }
781
851
 
782
852
  function isIgnoredComment(text, options) {
@@ -798,12 +868,12 @@ function isEventAttribute(attrName, options) {
798
868
  }
799
869
  return false;
800
870
  }
801
- return /^on[a-z]{3,}$/.test(attrName);
871
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
802
872
  }
803
873
 
804
874
  function canRemoveAttributeQuotes(value) {
805
875
  // https://mathiasbynens.be/notes/unquoted-attribute-values
806
- return /^[^ \t\n\f\r"'`=<>]+$/.test(value);
876
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
807
877
  }
808
878
 
809
879
  function attributesInclude(attributes, attribute) {
@@ -1014,7 +1084,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
1014
1084
  } else if (attrName === 'style') {
1015
1085
  attrValue = trimWhitespace(attrValue);
1016
1086
  if (attrValue) {
1017
- if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
1087
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
1018
1088
  attrValue = attrValue.replace(/\s*;$/, ';');
1019
1089
  }
1020
1090
  attrValue = await options.minifyCSS(attrValue, 'inline');
@@ -1333,7 +1403,10 @@ async function normalizeAttr(attr, attrs, tag, options) {
1333
1403
  let attrValue = attr.value;
1334
1404
 
1335
1405
  if (options.decodeEntities && attrValue) {
1336
- attrValue = entities.decodeHTMLStrict(attrValue);
1406
+ // Fast path: only decode when entities are present
1407
+ if (attrValue.indexOf('&') !== -1) {
1408
+ attrValue = entities.decodeHTMLStrict(attrValue);
1409
+ }
1337
1410
  }
1338
1411
 
1339
1412
  if ((options.removeRedundantAttributes &&
@@ -1354,8 +1427,8 @@ async function normalizeAttr(attr, attrs, tag, options) {
1354
1427
  return;
1355
1428
  }
1356
1429
 
1357
- if (options.decodeEntities && attrValue) {
1358
- 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');
1359
1432
  }
1360
1433
 
1361
1434
  return {
@@ -1475,6 +1548,10 @@ const processOptions = (inputOptions) => {
1475
1548
  const lightningCssOptions = typeof option === 'object' ? option : {};
1476
1549
 
1477
1550
  options.minifyCSS = async function (text, type) {
1551
+ // Fast path: nothing to minify
1552
+ if (!text || !text.trim()) {
1553
+ return text;
1554
+ }
1478
1555
  text = await replaceAsync(
1479
1556
  text,
1480
1557
  /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
@@ -1493,10 +1570,20 @@ const processOptions = (inputOptions) => {
1493
1570
  }
1494
1571
  }
1495
1572
  );
1496
-
1573
+ // Cache key: wrapped content, type, options signature
1497
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);
1498
1580
 
1499
1581
  try {
1582
+ const cached = cssMinifyCache.get(cssKey);
1583
+ if (cached) {
1584
+ return cached;
1585
+ }
1586
+
1500
1587
  const result = lightningcss.transform({
1501
1588
  filename: 'input.css',
1502
1589
  code: Buffer.from(inputCSS),
@@ -1519,12 +1606,12 @@ const processOptions = (inputOptions) => {
1519
1606
 
1520
1607
  // Preserve if output is empty and input had template syntax or UIDs
1521
1608
  // This catches cases where Lightning CSS removed content that should be preserved
1522
- if (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) {
1523
- return text;
1524
- }
1609
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1525
1610
 
1526
- return outputCSS;
1611
+ cssMinifyCache.set(cssKey, finalOutput);
1612
+ return finalOutput;
1527
1613
  } catch (err) {
1614
+ cssMinifyCache.delete(cssKey);
1528
1615
  if (!options.continueOnMinifyError) {
1529
1616
  throw err;
1530
1617
  }
@@ -1550,10 +1637,39 @@ const processOptions = (inputOptions) => {
1550
1637
 
1551
1638
  terserOptions.parse.bare_returns = inline;
1552
1639
 
1640
+ let jsKey;
1553
1641
  try {
1554
- const result = await terser.minify(code, terserOptions);
1555
- 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;
1556
1671
  } catch (err) {
1672
+ if (jsKey) jsMinifyCache.delete(jsKey);
1557
1673
  if (!options.continueOnMinifyError) {
1558
1674
  throw err;
1559
1675
  }
@@ -2015,7 +2131,9 @@ async function minifyHTML(value, options, partialMarkup) {
2015
2131
  prevTag = prevTag === '' ? 'comment' : prevTag;
2016
2132
  nextTag = nextTag === '' ? 'comment' : nextTag;
2017
2133
  if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
2018
- text = entities.decodeHTML(text);
2134
+ if (text.indexOf('&') !== -1) {
2135
+ text = entities.decodeHTML(text);
2136
+ }
2019
2137
  }
2020
2138
  if (options.collapseWhitespace) {
2021
2139
  if (!stackNoTrimWhitespace.length) {
@@ -2089,11 +2207,16 @@ async function minifyHTML(value, options, partialMarkup) {
2089
2207
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
2090
2208
  if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
2091
2209
  // Escape any `&` symbols that start either:
2092
- // 1) a legacy named character reference (i.e. one that doesn't end with `;`)
2093
- // 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 `;`)
2094
2212
  // Note that `&` can be escaped as `&amp`, without the semi-colon.
2095
2213
  // https://mathiasbynens.be/notes/ambiguous-ampersands
2096
- 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
+ }
2097
2220
  }
2098
2221
  if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
2099
2222
  text = text.replace(uidPattern, function (match, prefix, index) {
@@ -39757,18 +39757,88 @@ function getPresetNames() {
39757
39757
  return Object.keys(presets);
39758
39758
  }
39759
39759
 
39760
- const trimWhitespace = str => str && str.replace(/^[ \n\r\t\f]+/, '').replace(/[ \n\r\t\f]+$/, '');
39760
+ // Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
39761
+ const RE_WS_START = /^[ \n\r\t\f]+/;
39762
+ const RE_WS_END = /[ \n\r\t\f]+$/;
39763
+ const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
39764
+ const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
39765
+ const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
39766
+ const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
39767
+ const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
39768
+ const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
39769
+ const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
39770
+ const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
39771
+ const RE_TRAILING_SEMICOLON = /;$/;
39772
+ const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
39773
+
39774
+ // Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
39775
+ function stableStringify(obj) {
39776
+ if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
39777
+ if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
39778
+ const keys = Object.keys(obj).sort();
39779
+ let out = '{';
39780
+ for (let i = 0; i < keys.length; i++) {
39781
+ const k = keys[i];
39782
+ out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
39783
+ }
39784
+ return out + '}';
39785
+ }
39786
+
39787
+ // Minimal LRU cache for strings and promises
39788
+ class LRU {
39789
+ constructor(limit = 200) {
39790
+ this.limit = limit;
39791
+ this.map = new Map();
39792
+ }
39793
+ get(key) {
39794
+ const v = this.map.get(key);
39795
+ if (v !== undefined) {
39796
+ this.map.delete(key);
39797
+ this.map.set(key, v);
39798
+ }
39799
+ return v;
39800
+ }
39801
+ set(key, value) {
39802
+ if (this.map.has(key)) this.map.delete(key);
39803
+ this.map.set(key, value);
39804
+ if (this.map.size > this.limit) {
39805
+ const first = this.map.keys().next().value;
39806
+ this.map.delete(first);
39807
+ }
39808
+ }
39809
+ delete(key) { this.map.delete(key); }
39810
+ }
39811
+
39812
+ // Per-process caches
39813
+ const jsMinifyCache = new LRU(200);
39814
+ const cssMinifyCache = new LRU(200);
39815
+
39816
+ const trimWhitespace = str => {
39817
+ if (!str) return str;
39818
+ // Fast path: if no whitespace at start or end, return early
39819
+ if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
39820
+ return str;
39821
+ }
39822
+ return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
39823
+ };
39761
39824
 
39762
39825
  function collapseWhitespaceAll(str) {
39826
+ if (!str) return str;
39827
+ // Fast path: if there are no common whitespace characters, return early
39828
+ if (!/[ \n\r\t\f\xA0]/.test(str)) {
39829
+ return str;
39830
+ }
39763
39831
  // Non-breaking space is specifically handled inside the replacer function here:
39764
- return str && str.replace(/[ \n\r\t\f\xA0]+/g, function (spaces) {
39765
- return spaces === '\t' ? '\t' : spaces.replace(/(^|\xA0+)[^\xA0]+/g, '$1 ');
39832
+ return str.replace(RE_ALL_WS_NBSP, function (spaces) {
39833
+ return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
39766
39834
  });
39767
39835
  }
39768
39836
 
39769
39837
  function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
39770
39838
  let lineBreakBefore = ''; let lineBreakAfter = '';
39771
39839
 
39840
+ if (!str) return str;
39841
+
39772
39842
  if (options.preserveLineBreaks) {
39773
39843
  str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
39774
39844
  lineBreakBefore = '\n';
@@ -39786,7 +39856,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
39786
39856
  if (conservative && spaces === '\t') {
39787
39857
  return '\t';
39788
39858
  }
39789
- return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : '');
39859
+ return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
39790
39860
  });
39791
39861
  }
39792
39862
 
@@ -39797,7 +39867,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
39797
39867
  if (conservative && spaces === '\t') {
39798
39868
  return '\t';
39799
39869
  }
39800
- return spaces.replace(/[^\xA0]+(\xA0+)/g, ' $1').replace(/[^\xA0]+$/, '') || (conservative ? ' ' : '');
39870
+ return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
39801
39871
  });
39802
39872
  }
39803
39873
 
@@ -39829,7 +39899,7 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
39829
39899
  }
39830
39900
 
39831
39901
  function isConditionalComment(text) {
39832
- return /^\[if\s[^\]]+]|\[endif]$/.test(text);
39902
+ return RE_CONDITIONAL_COMMENT.test(text);
39833
39903
  }
39834
39904
 
39835
39905
  function isIgnoredComment(text, options) {
@@ -39851,12 +39921,12 @@ function isEventAttribute(attrName, options) {
39851
39921
  }
39852
39922
  return false;
39853
39923
  }
39854
- return /^on[a-z]{3,}$/.test(attrName);
39924
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
39855
39925
  }
39856
39926
 
39857
39927
  function canRemoveAttributeQuotes(value) {
39858
39928
  // https://mathiasbynens.be/notes/unquoted-attribute-values
39859
- return /^[^ \t\n\f\r"'`=<>]+$/.test(value);
39929
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
39860
39930
  }
39861
39931
 
39862
39932
  function attributesInclude(attributes, attribute) {
@@ -40067,7 +40137,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
40067
40137
  } else if (attrName === 'style') {
40068
40138
  attrValue = trimWhitespace(attrValue);
40069
40139
  if (attrValue) {
40070
- if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
40140
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
40071
40141
  attrValue = attrValue.replace(/\s*;$/, ';');
40072
40142
  }
40073
40143
  attrValue = await options.minifyCSS(attrValue, 'inline');
@@ -40386,7 +40456,10 @@ async function normalizeAttr(attr, attrs, tag, options) {
40386
40456
  let attrValue = attr.value;
40387
40457
 
40388
40458
  if (options.decodeEntities && attrValue) {
40389
- attrValue = decodeHTMLStrict(attrValue);
40459
+ // Fast path: only decode when entities are present
40460
+ if (attrValue.indexOf('&') !== -1) {
40461
+ attrValue = decodeHTMLStrict(attrValue);
40462
+ }
40390
40463
  }
40391
40464
 
40392
40465
  if ((options.removeRedundantAttributes &&
@@ -40407,8 +40480,8 @@ async function normalizeAttr(attr, attrs, tag, options) {
40407
40480
  return;
40408
40481
  }
40409
40482
 
40410
- if (options.decodeEntities && attrValue) {
40411
- attrValue = attrValue.replace(/&(#?[0-9a-zA-Z]+;)/g, '&amp;$1');
40483
+ if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
40484
+ attrValue = attrValue.replace(RE_AMP_ENTITY, '&amp;$1');
40412
40485
  }
40413
40486
 
40414
40487
  return {
@@ -40528,6 +40601,10 @@ const processOptions = (inputOptions) => {
40528
40601
  const lightningCssOptions = typeof option === 'object' ? option : {};
40529
40602
 
40530
40603
  options.minifyCSS = async function (text, type) {
40604
+ // Fast path: nothing to minify
40605
+ if (!text || !text.trim()) {
40606
+ return text;
40607
+ }
40531
40608
  text = await replaceAsync(
40532
40609
  text,
40533
40610
  /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
@@ -40546,10 +40623,20 @@ const processOptions = (inputOptions) => {
40546
40623
  }
40547
40624
  }
40548
40625
  );
40549
-
40626
+ // Cache key: wrapped content, type, options signature
40550
40627
  const inputCSS = wrapCSS(text, type);
40628
+ const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
40629
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
40630
+ const cssKey = inputCSS.length > 2048
40631
+ ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
40632
+ : (inputCSS + '|' + type + '|' + cssSig);
40551
40633
 
40552
40634
  try {
40635
+ const cached = cssMinifyCache.get(cssKey);
40636
+ if (cached) {
40637
+ return cached;
40638
+ }
40639
+
40553
40640
  const result = transform({
40554
40641
  filename: 'input.css',
40555
40642
  code: Buffer.from(inputCSS),
@@ -40572,12 +40659,12 @@ const processOptions = (inputOptions) => {
40572
40659
 
40573
40660
  // Preserve if output is empty and input had template syntax or UIDs
40574
40661
  // This catches cases where Lightning CSS removed content that should be preserved
40575
- if (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) {
40576
- return text;
40577
- }
40662
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
40578
40663
 
40579
- return outputCSS;
40664
+ cssMinifyCache.set(cssKey, finalOutput);
40665
+ return finalOutput;
40580
40666
  } catch (err) {
40667
+ cssMinifyCache.delete(cssKey);
40581
40668
  if (!options.continueOnMinifyError) {
40582
40669
  throw err;
40583
40670
  }
@@ -40603,10 +40690,39 @@ const processOptions = (inputOptions) => {
40603
40690
 
40604
40691
  terserOptions.parse.bare_returns = inline;
40605
40692
 
40693
+ let jsKey;
40606
40694
  try {
40607
- const result = await minify$1(code, terserOptions);
40608
- return result.code.replace(/;$/, '');
40695
+ // Fast path: avoid invoking Terser for empty/whitespace-only content
40696
+ if (!code || !code.trim()) {
40697
+ return '';
40698
+ }
40699
+ // Cache key: content, inline, options signature (subset)
40700
+ const terserSig = stableStringify({
40701
+ compress: terserOptions.compress,
40702
+ mangle: terserOptions.mangle,
40703
+ ecma: terserOptions.ecma,
40704
+ toplevel: terserOptions.toplevel,
40705
+ module: terserOptions.module,
40706
+ keep_fnames: terserOptions.keep_fnames,
40707
+ format: terserOptions.format,
40708
+ cont: !!options.continueOnMinifyError,
40709
+ });
40710
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
40711
+ jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
40712
+ const cached = jsMinifyCache.get(jsKey);
40713
+ if (cached) {
40714
+ return await cached;
40715
+ }
40716
+ const inFlight = (async () => {
40717
+ const result = await minify$1(code, terserOptions);
40718
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
40719
+ })();
40720
+ jsMinifyCache.set(jsKey, inFlight);
40721
+ const resolved = await inFlight;
40722
+ jsMinifyCache.set(jsKey, resolved);
40723
+ return resolved;
40609
40724
  } catch (err) {
40725
+ if (jsKey) jsMinifyCache.delete(jsKey);
40610
40726
  if (!options.continueOnMinifyError) {
40611
40727
  throw err;
40612
40728
  }
@@ -41068,7 +41184,9 @@ async function minifyHTML(value, options, partialMarkup) {
41068
41184
  prevTag = prevTag === '' ? 'comment' : prevTag;
41069
41185
  nextTag = nextTag === '' ? 'comment' : nextTag;
41070
41186
  if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
41071
- text = decodeHTML(text);
41187
+ if (text.indexOf('&') !== -1) {
41188
+ text = decodeHTML(text);
41189
+ }
41072
41190
  }
41073
41191
  if (options.collapseWhitespace) {
41074
41192
  if (!stackNoTrimWhitespace.length) {
@@ -41142,11 +41260,16 @@ async function minifyHTML(value, options, partialMarkup) {
41142
41260
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
41143
41261
  if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
41144
41262
  // Escape any `&` symbols that start either:
41145
- // 1) a legacy named character reference (i.e. one that doesn't end with `;`)
41146
- // 2) or any other character reference (i.e. one that does end with `;`)
41263
+ // 1) a legacy named character reference (i.e., one that doesnt end with `;`)
41264
+ // 2) or any other character reference (i.e., one that does end with `;`)
41147
41265
  // Note that `&` can be escaped as `&amp`, without the semi-colon.
41148
41266
  // https://mathiasbynens.be/notes/ambiguous-ampersands
41149
- 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;');
41267
+ if (text.indexOf('&') !== -1) {
41268
+ 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');
41269
+ }
41270
+ if (text.indexOf('<') !== -1) {
41271
+ text = text.replace(/</g, '&lt;');
41272
+ }
41150
41273
  }
41151
41274
  if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
41152
41275
  text = text.replace(uidPattern, function (match, prefix, index) {
@@ -1 +1 @@
1
- {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AAu/CO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UAUS,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;;;;;;;;yBAOP,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;;wBAh1DkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
1
+ {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AAknDO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UAUS,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;;;;;;;;yBAOP,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;;wBA38DkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "relateurl": "^0.2.7",
14
14
  "terser": "^5.44.1"
15
15
  },
16
- "description": "Highly configurable, well-tested, JavaScript-based HTML minifier",
16
+ "description": "Highly configurable, well-tested, JavaScript-based HTML minifier (enhanced successor of HTML Minifier)",
17
17
  "devDependencies": {
18
18
  "@commitlint/cli": "^20.1.0",
19
19
  "@eslint/js": "^9.39.1",
@@ -84,5 +84,5 @@
84
84
  "test:watch": "node --test --watch tests/*.spec.js"
85
85
  },
86
86
  "type": "module",
87
- "version": "4.6.1"
87
+ "version": "4.7.0"
88
88
  }
@@ -7,18 +7,88 @@ import TokenChain from './tokenchain.js';
7
7
  import { replaceAsync } from './utils.js';
8
8
  import { presets, getPreset, getPresetNames } from './presets.js';
9
9
 
10
- const trimWhitespace = str => str && str.replace(/^[ \n\r\t\f]+/, '').replace(/[ \n\r\t\f]+$/, '');
10
+ // Hoisted, reusable RegExp patterns and tiny helpers to avoid repeated allocations in hot paths
11
+ const RE_WS_START = /^[ \n\r\t\f]+/;
12
+ const RE_WS_END = /[ \n\r\t\f]+$/;
13
+ const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
14
+ const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
15
+ const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
16
+ const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
17
+ const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
18
+ const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
19
+ const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
20
+ const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
21
+ const RE_TRAILING_SEMICOLON = /;$/;
22
+ const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
23
+
24
+ // Tiny stable stringify for options signatures (sorted keys, shallow, nested objects)
25
+ function stableStringify(obj) {
26
+ if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
27
+ if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
28
+ const keys = Object.keys(obj).sort();
29
+ let out = '{';
30
+ for (let i = 0; i < keys.length; i++) {
31
+ const k = keys[i];
32
+ out += JSON.stringify(k) + ':' + stableStringify(obj[k]) + (i < keys.length - 1 ? ',' : '');
33
+ }
34
+ return out + '}';
35
+ }
36
+
37
+ // Minimal LRU cache for strings and promises
38
+ class LRU {
39
+ constructor(limit = 200) {
40
+ this.limit = limit;
41
+ this.map = new Map();
42
+ }
43
+ get(key) {
44
+ const v = this.map.get(key);
45
+ if (v !== undefined) {
46
+ this.map.delete(key);
47
+ this.map.set(key, v);
48
+ }
49
+ return v;
50
+ }
51
+ set(key, value) {
52
+ if (this.map.has(key)) this.map.delete(key);
53
+ this.map.set(key, value);
54
+ if (this.map.size > this.limit) {
55
+ const first = this.map.keys().next().value;
56
+ this.map.delete(first);
57
+ }
58
+ }
59
+ delete(key) { this.map.delete(key); }
60
+ }
61
+
62
+ // Per-process caches
63
+ const jsMinifyCache = new LRU(200);
64
+ const cssMinifyCache = new LRU(200);
65
+
66
+ const trimWhitespace = str => {
67
+ if (!str) return str;
68
+ // Fast path: if no whitespace at start or end, return early
69
+ if (!/^[ \n\r\t\f]/.test(str) && !/[ \n\r\t\f]$/.test(str)) {
70
+ return str;
71
+ }
72
+ return str.replace(RE_WS_START, '').replace(RE_WS_END, '');
73
+ };
11
74
 
12
75
  function collapseWhitespaceAll(str) {
76
+ if (!str) return str;
77
+ // Fast path: if there are no common whitespace characters, return early
78
+ if (!/[ \n\r\t\f\xA0]/.test(str)) {
79
+ return str;
80
+ }
13
81
  // Non-breaking space is specifically handled inside the replacer function here:
14
- return str && str.replace(/[ \n\r\t\f\xA0]+/g, function (spaces) {
15
- return spaces === '\t' ? '\t' : spaces.replace(/(^|\xA0+)[^\xA0]+/g, '$1 ');
82
+ return str.replace(RE_ALL_WS_NBSP, function (spaces) {
83
+ return spaces === '\t' ? '\t' : spaces.replace(RE_NBSP_LEADING_GROUP, '$1 ');
16
84
  });
17
85
  }
18
86
 
19
87
  function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
20
88
  let lineBreakBefore = ''; let lineBreakAfter = '';
21
89
 
90
+ if (!str) return str;
91
+
22
92
  if (options.preserveLineBreaks) {
23
93
  str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function () {
24
94
  lineBreakBefore = '\n';
@@ -36,7 +106,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
36
106
  if (conservative && spaces === '\t') {
37
107
  return '\t';
38
108
  }
39
- return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : '');
109
+ return spaces.replace(/^[^\xA0]+/, '').replace(RE_NBSP_LEAD_GROUP, '$1 ') || (conservative ? ' ' : '');
40
110
  });
41
111
  }
42
112
 
@@ -47,7 +117,7 @@ function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
47
117
  if (conservative && spaces === '\t') {
48
118
  return '\t';
49
119
  }
50
- return spaces.replace(/[^\xA0]+(\xA0+)/g, ' $1').replace(/[^\xA0]+$/, '') || (conservative ? ' ' : '');
120
+ return spaces.replace(RE_NBSP_TRAILING_GROUP, ' $1').replace(RE_NBSP_TRAILING_STRIP, '') || (conservative ? ' ' : '');
51
121
  });
52
122
  }
53
123
 
@@ -79,7 +149,7 @@ function collapseWhitespaceSmart(str, prevTag, nextTag, options, inlineElements,
79
149
  }
80
150
 
81
151
  function isConditionalComment(text) {
82
- return /^\[if\s[^\]]+]|\[endif]$/.test(text);
152
+ return RE_CONDITIONAL_COMMENT.test(text);
83
153
  }
84
154
 
85
155
  function isIgnoredComment(text, options) {
@@ -101,12 +171,12 @@ function isEventAttribute(attrName, options) {
101
171
  }
102
172
  return false;
103
173
  }
104
- return /^on[a-z]{3,}$/.test(attrName);
174
+ return RE_EVENT_ATTR_DEFAULT.test(attrName);
105
175
  }
106
176
 
107
177
  function canRemoveAttributeQuotes(value) {
108
178
  // https://mathiasbynens.be/notes/unquoted-attribute-values
109
- return /^[^ \t\n\f\r"'`=<>]+$/.test(value);
179
+ return RE_CAN_REMOVE_ATTR_QUOTES.test(value);
110
180
  }
111
181
 
112
182
  function attributesInclude(attributes, attribute) {
@@ -317,7 +387,7 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
317
387
  } else if (attrName === 'style') {
318
388
  attrValue = trimWhitespace(attrValue);
319
389
  if (attrValue) {
320
- if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
390
+ if (attrValue.endsWith(';') && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
321
391
  attrValue = attrValue.replace(/\s*;$/, ';');
322
392
  }
323
393
  attrValue = await options.minifyCSS(attrValue, 'inline');
@@ -636,7 +706,10 @@ async function normalizeAttr(attr, attrs, tag, options) {
636
706
  let attrValue = attr.value;
637
707
 
638
708
  if (options.decodeEntities && attrValue) {
639
- attrValue = decodeHTMLStrict(attrValue);
709
+ // Fast path: only decode when entities are present
710
+ if (attrValue.indexOf('&') !== -1) {
711
+ attrValue = decodeHTMLStrict(attrValue);
712
+ }
640
713
  }
641
714
 
642
715
  if ((options.removeRedundantAttributes &&
@@ -657,8 +730,8 @@ async function normalizeAttr(attr, attrs, tag, options) {
657
730
  return;
658
731
  }
659
732
 
660
- if (options.decodeEntities && attrValue) {
661
- attrValue = attrValue.replace(/&(#?[0-9a-zA-Z]+;)/g, '&amp;$1');
733
+ if (options.decodeEntities && attrValue && attrValue.indexOf('&') !== -1) {
734
+ attrValue = attrValue.replace(RE_AMP_ENTITY, '&amp;$1');
662
735
  }
663
736
 
664
737
  return {
@@ -778,6 +851,10 @@ const processOptions = (inputOptions) => {
778
851
  const lightningCssOptions = typeof option === 'object' ? option : {};
779
852
 
780
853
  options.minifyCSS = async function (text, type) {
854
+ // Fast path: nothing to minify
855
+ if (!text || !text.trim()) {
856
+ return text;
857
+ }
781
858
  text = await replaceAsync(
782
859
  text,
783
860
  /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
@@ -796,10 +873,20 @@ const processOptions = (inputOptions) => {
796
873
  }
797
874
  }
798
875
  );
799
-
876
+ // Cache key: wrapped content, type, options signature
800
877
  const inputCSS = wrapCSS(text, type);
878
+ const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
879
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
880
+ const cssKey = inputCSS.length > 2048
881
+ ? (inputCSS.length + '|' + inputCSS.slice(0, 50) + inputCSS.slice(-50) + '|' + type + '|' + cssSig)
882
+ : (inputCSS + '|' + type + '|' + cssSig);
801
883
 
802
884
  try {
885
+ const cached = cssMinifyCache.get(cssKey);
886
+ if (cached) {
887
+ return cached;
888
+ }
889
+
803
890
  const result = transformCSS({
804
891
  filename: 'input.css',
805
892
  code: Buffer.from(inputCSS),
@@ -822,12 +909,12 @@ const processOptions = (inputOptions) => {
822
909
 
823
910
  // Preserve if output is empty and input had template syntax or UIDs
824
911
  // This catches cases where Lightning CSS removed content that should be preserved
825
- if (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) {
826
- return text;
827
- }
912
+ const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
828
913
 
829
- return outputCSS;
914
+ cssMinifyCache.set(cssKey, finalOutput);
915
+ return finalOutput;
830
916
  } catch (err) {
917
+ cssMinifyCache.delete(cssKey);
831
918
  if (!options.continueOnMinifyError) {
832
919
  throw err;
833
920
  }
@@ -853,10 +940,39 @@ const processOptions = (inputOptions) => {
853
940
 
854
941
  terserOptions.parse.bare_returns = inline;
855
942
 
943
+ let jsKey;
856
944
  try {
857
- const result = await terser(code, terserOptions);
858
- return result.code.replace(/;$/, '');
945
+ // Fast path: avoid invoking Terser for empty/whitespace-only content
946
+ if (!code || !code.trim()) {
947
+ return '';
948
+ }
949
+ // Cache key: content, inline, options signature (subset)
950
+ const terserSig = stableStringify({
951
+ compress: terserOptions.compress,
952
+ mangle: terserOptions.mangle,
953
+ ecma: terserOptions.ecma,
954
+ toplevel: terserOptions.toplevel,
955
+ module: terserOptions.module,
956
+ keep_fnames: terserOptions.keep_fnames,
957
+ format: terserOptions.format,
958
+ cont: !!options.continueOnMinifyError,
959
+ });
960
+ // For large inputs, use length and content fingerprint (first/last 50 chars) to prevent collisions
961
+ jsKey = (code.length > 2048 ? (code.length + '|' + code.slice(0, 50) + code.slice(-50) + '|') : (code + '|')) + (inline ? '1' : '0') + '|' + terserSig;
962
+ const cached = jsMinifyCache.get(jsKey);
963
+ if (cached) {
964
+ return await cached;
965
+ }
966
+ const inFlight = (async () => {
967
+ const result = await terser(code, terserOptions);
968
+ return result.code.replace(RE_TRAILING_SEMICOLON, '');
969
+ })();
970
+ jsMinifyCache.set(jsKey, inFlight);
971
+ const resolved = await inFlight;
972
+ jsMinifyCache.set(jsKey, resolved);
973
+ return resolved;
859
974
  } catch (err) {
975
+ if (jsKey) jsMinifyCache.delete(jsKey);
860
976
  if (!options.continueOnMinifyError) {
861
977
  throw err;
862
978
  }
@@ -1318,7 +1434,9 @@ async function minifyHTML(value, options, partialMarkup) {
1318
1434
  prevTag = prevTag === '' ? 'comment' : prevTag;
1319
1435
  nextTag = nextTag === '' ? 'comment' : nextTag;
1320
1436
  if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
1321
- text = decodeHTML(text);
1437
+ if (text.indexOf('&') !== -1) {
1438
+ text = decodeHTML(text);
1439
+ }
1322
1440
  }
1323
1441
  if (options.collapseWhitespace) {
1324
1442
  if (!stackNoTrimWhitespace.length) {
@@ -1392,11 +1510,16 @@ async function minifyHTML(value, options, partialMarkup) {
1392
1510
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
1393
1511
  if (options.decodeEntities && text && !specialContentTags.has(currentTag)) {
1394
1512
  // Escape any `&` symbols that start either:
1395
- // 1) a legacy named character reference (i.e. one that doesn't end with `;`)
1396
- // 2) or any other character reference (i.e. one that does end with `;`)
1513
+ // 1) a legacy named character reference (i.e., one that doesnt end with `;`)
1514
+ // 2) or any other character reference (i.e., one that does end with `;`)
1397
1515
  // Note that `&` can be escaped as `&amp`, without the semi-colon.
1398
1516
  // https://mathiasbynens.be/notes/ambiguous-ampersands
1399
- 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;');
1517
+ if (text.indexOf('&') !== -1) {
1518
+ 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');
1519
+ }
1520
+ if (text.indexOf('<') !== -1) {
1521
+ text = text.replace(/</g, '&lt;');
1522
+ }
1400
1523
  }
1401
1524
  if (uidPattern && options.collapseWhitespace && stackNoTrimWhitespace.length) {
1402
1525
  text = text.replace(uidPattern, function (match, prefix, index) {