html-minifier-next 4.16.0 → 4.16.2

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
@@ -358,37 +358,38 @@ How does HTML Minifier Next compare to other minifiers? (All minification with t
358
358
  | Site | Original Size (KB) | [HTML Minifier Next](https://github.com/j9t/html-minifier-next) ([config](https://github.com/j9t/html-minifier-next/blob/main/benchmarks/html-minifier.json))<br>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-next)](https://socket.dev/npm/package/html-minifier-next) | [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/) |
359
359
  | --- | --- | --- | --- | --- | --- | --- | --- |
360
360
  | [A List Apart](https://alistapart.com/) | 59 | **50** | 51 | 52 | 51 | 54 | 52 |
361
- | [Apple](https://www.apple.com/) | 260 | **203** | 231 | 235 | 236 | 238 | 238 |
362
- | [BBC](https://www.bbc.co.uk/) | 704 | **641** | 661 | 661 | 662 | 698 | n/a |
361
+ | [Apple](https://www.apple.com/) | 211 | **176** | 187 | 189 | 190 | 191 | 192 |
362
+ | [BBC](https://www.bbc.co.uk/) | 674 | **613** | 633 | 633 | 634 | 669 | n/a |
363
363
  | [CERN](https://home.cern/) | 152 | **83** | 91 | 91 | 91 | 93 | 96 |
364
- | [CSS-Tricks](https://css-tricks.com/) | 162 | **119** | 128 | 143 | 143 | 148 | 145 |
364
+ | [CSS-Tricks](https://css-tricks.com/) | 162 | **119** | 127 | 143 | 143 | 148 | 144 |
365
365
  | [ECMAScript](https://tc39.es/ecma262/) | 7250 | **6401** | 6573 | 6455 | 6578 | 6626 | n/a |
366
366
  | [EDRi](https://edri.org/) | 80 | **59** | 70 | 70 | 71 | 75 | 73 |
367
- | [EFF](https://www.eff.org/) | 54 | **45** | 48 | 47 | 48 | 49 | 49 |
367
+ | [EFF](https://www.eff.org/) | 54 | **45** | 49 | 47 | 48 | 49 | 49 |
368
368
  | [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | 32 | 32 | 32 | 32 | 32 |
369
- | [FAZ](https://www.faz.net/aktuell/) | 1537 | 1429 | **1378** | 1462 | 1473 | 1484 | n/a |
369
+ | [FAZ](https://www.faz.net/aktuell/) | 1570 | 1458 | **1405** | 1494 | 1505 | 1516 | n/a |
370
370
  | [French Tech](https://lafrenchtech.gouv.fr/) | 153 | **122** | 126 | 126 | 126 | 132 | 127 |
371
371
  | [Frontend Dogma](https://frontenddogma.com/) | 225 | **217** | 238 | 224 | 225 | 244 | 225 |
372
372
  | [Google](https://www.google.com/) | 18 | **16** | 17 | 17 | 17 | 18 | 18 |
373
- | [Ground News](https://ground.news/) | 2437 | **2150** | 2246 | 2272 | 2275 | 2424 | n/a |
373
+ | [Ground News](https://ground.news/) | 2259 | **1978** | 2077 | 2107 | 2109 | 2246 | n/a |
374
374
  | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | 148 | 153 | **147** | 149 | 155 | 149 |
375
- | [Igalia](https://www.igalia.com/) | 50 | **33** | 36 | 36 | 36 | 37 | 36 |
376
- | [Leanpub](https://leanpub.com/) | 233 | **203** | 217 | 217 | 218 | 228 | 230 |
375
+ | [Igalia](https://www.igalia.com/) | 50 | **33** | 36 | 36 | 36 | 37 | 37 |
376
+ | [Leanpub](https://leanpub.com/) | 235 | **205** | 220 | 219 | 220 | 230 | 232 |
377
377
  | [Mastodon](https://mastodon.social/explore) | 37 | **28** | 32 | 35 | 35 | 36 | 36 |
378
- | [MDN](https://developer.mozilla.org/en-US/) | 106 | **60** | 62 | 63 | 63 | 66 | 66 |
379
- | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **197** | 203 | 201 | 201 | 203 | 204 |
378
+ | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | 64 | 65 | 65 | 68 | 68 |
379
+ | [Middle East Eye](https://www.middleeasteye.net/) | 223 | **196** | 203 | 201 | 201 | 202 | 203 |
380
380
  | [Mistral AI](https://mistral.ai/) | 361 | **319** | 324 | 326 | 327 | 357 | n/a |
381
381
  | [Mozilla](https://www.mozilla.org/) | 45 | **31** | 34 | 34 | 34 | 35 | 35 |
382
382
  | [Nielsen Norman Group](https://www.nngroup.com/) | 86 | 68 | **55** | 74 | 75 | 77 | 76 |
383
383
  | [SitePoint](https://www.sitepoint.com/) | 482 | **351** | 422 | 456 | 460 | 478 | n/a |
384
384
  | [Startup-Verband](https://startupverband.de/) | 42 | **29** | 30 | 30 | 30 | 31 | 30 |
385
- | [TPGi](https://www.tpgi.com/) | 175 | **159** | 160 | 164 | 166 | 172 | 172 |
385
+ | [TetraLogical](https://tetralogical.com/) | 44 | 38 | **35** | 38 | 39 | 39 | 39 |
386
+ | [TPGi](https://www.tpgi.com/) | 174 | **158** | 159 | 163 | 165 | 171 | 171 |
386
387
  | [United Nations](https://www.un.org/en/) | 152 | **112** | 121 | 125 | 125 | 130 | 123 |
387
388
  | [Vivaldi](https://vivaldi.com/) | 92 | **74** | n/a | 79 | 81 | 83 | 81 |
388
389
  | [W3C](https://www.w3.org/) | 50 | **36** | 39 | 38 | 38 | 41 | 39 |
389
- | **Average processing time** | | 132 ms (29/29) | 169 ms (28/29) | 54 ms (29/29) | **14 ms (29/29)** | 293 ms (29/29) | 1355 ms (23/29) |
390
+ | **Average processing time** | | 120 ms (30/30) | 143 ms (29/30) | 49 ms (30/30) | **13 ms (30/30)** | 270 ms (30/30) | 1252 ms (24/30) |
390
391
 
391
- (Last updated: Dec 26, 2025)
392
+ (Last updated: Dec 27, 2025)
392
393
  <!-- End auto-generated -->
393
394
 
394
395
  Notes: Minimize does not minify CSS and JS. [HTML Minifier Terser](https://github.com/terser/html-minifier-terser) is currently not included due to issues around whitespace collapsing and removal of code using modern CSS features, issues which appeared to distort the data.
@@ -624,37 +624,29 @@ class Sorter {
624
624
  for (let i = 0, len = this.keys.length; i < len; i++) {
625
625
  const token = this.keys[i];
626
626
 
627
- // Build position map for this token to avoid repeated `indexOf`
628
- const positions = [];
627
+ // Single pass: Count matches and collect non-matches
628
+ let matchCount = 0;
629
+ const others = [];
630
+
629
631
  for (let j = fromIndex; j < tokens.length; j++) {
630
632
  if (tokens[j] === token) {
631
- positions.push(j);
633
+ matchCount++;
634
+ } else {
635
+ others.push(tokens[j]);
632
636
  }
633
637
  }
634
638
 
635
- if (positions.length > 0) {
636
- // Build new array with tokens in sorted order instead of splicing
637
- const result = [];
638
-
639
- // Add all instances of the current token first
640
- for (let j = 0; j < positions.length; j++) {
641
- result.push(token);
642
- }
643
-
644
- // Add other tokens, skipping positions where current token was
645
- const posSet = new Set(positions);
646
- for (let j = fromIndex; j < tokens.length; j++) {
647
- if (!posSet.has(j)) {
648
- result.push(tokens[j]);
649
- }
639
+ if (matchCount > 0) {
640
+ // Rebuild: `matchCount` instances of token first, then others
641
+ let writeIdx = fromIndex;
642
+ for (let j = 0; j < matchCount; j++) {
643
+ tokens[writeIdx++] = token;
650
644
  }
651
-
652
- // Copy sorted portion back to tokens array
653
- for (let j = 0; j < result.length; j++) {
654
- tokens[fromIndex + j] = result[j];
645
+ for (let j = 0; j < others.length; j++) {
646
+ tokens[writeIdx++] = others[j];
655
647
  }
656
648
 
657
- const newFromIndex = fromIndex + positions.length;
649
+ const newFromIndex = fromIndex + matchCount;
658
650
  return this.sorterMap.get(token).sort(tokens, newFromIndex);
659
651
  }
660
652
  }
@@ -1772,24 +1764,30 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
1772
1764
  if (!text || !text.trim()) {
1773
1765
  return text;
1774
1766
  }
1775
- text = await replaceAsync(
1776
- text,
1777
- /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
1778
- async function (match, prefix, dq, sq, unq, suffix) {
1779
- const quote = dq != null ? '"' : (sq != null ? "'" : '');
1780
- const url = dq ?? sq ?? unq ?? '';
1781
- try {
1782
- const out = await options.minifyURLs(url);
1783
- return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
1784
- } catch (err) {
1785
- if (!options.continueOnMinifyError) {
1786
- throw err;
1767
+
1768
+ // Optimization: Only process URLs if minification is enabled (not identity function)
1769
+ // This avoids expensive `replaceAsync` when URL minification is disabled
1770
+ if (options.minifyURLs !== identity) {
1771
+ text = await replaceAsync(
1772
+ text,
1773
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
1774
+ async function (match, prefix, dq, sq, unq, suffix) {
1775
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
1776
+ const url = dq ?? sq ?? unq ?? '';
1777
+ try {
1778
+ const out = await options.minifyURLs(url);
1779
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
1780
+ } catch (err) {
1781
+ if (!options.continueOnMinifyError) {
1782
+ throw err;
1783
+ }
1784
+ options.log && options.log(err);
1785
+ return match;
1787
1786
  }
1788
- options.log && options.log(err);
1789
- return match;
1790
1787
  }
1791
- }
1792
- );
1788
+ );
1789
+ }
1790
+
1793
1791
  // Cache key: Wrapped content, type, options signature
1794
1792
  const inputCSS = wrapCSS(text, type);
1795
1793
  const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
@@ -1801,36 +1799,44 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
1801
1799
  try {
1802
1800
  const cached = cssMinifyCache.get(cssKey);
1803
1801
  if (cached) {
1804
- return cached;
1802
+ // Support both resolved values and in-flight promises
1803
+ return await cached;
1805
1804
  }
1806
1805
 
1807
- const transformCSS = await getLightningCSS();
1808
- const result = transformCSS({
1809
- filename: 'input.css',
1810
- code: Buffer.from(inputCSS),
1811
- minify: true,
1812
- errorRecovery: !!options.continueOnMinifyError,
1813
- ...lightningCssOptions
1814
- });
1815
-
1816
- const outputCSS = unwrapCSS(result.code.toString(), type);
1817
-
1818
- // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
1819
- // This preserves:
1820
- // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
1821
- // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
1822
- // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
1823
- const isCDATA = text.includes('<![CDATA[');
1824
- const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
1825
- const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
1826
- const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
1827
-
1828
- // Preserve if output is empty and input had template syntax or UIDs
1829
- // This catches cases where Lightning CSS removed content that should be preserved
1830
- const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1806
+ // In-flight promise caching: Prevent duplicate concurrent minifications
1807
+ // of the same CSS content (same pattern as JS minification)
1808
+ const inFlight = (async () => {
1809
+ const transformCSS = await getLightningCSS();
1810
+ // Note: `Buffer.from()` is required—Lightning CSS API expects Uint8Array
1811
+ const result = transformCSS({
1812
+ filename: 'input.css',
1813
+ code: Buffer.from(inputCSS),
1814
+ minify: true,
1815
+ errorRecovery: !!options.continueOnMinifyError,
1816
+ ...lightningCssOptions
1817
+ });
1818
+
1819
+ const outputCSS = unwrapCSS(result.code.toString(), type);
1820
+
1821
+ // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
1822
+ // This preserves:
1823
+ // 1. Template code like `<?php ?>`, `<%= ?>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
1824
+ // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
1825
+ // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
1826
+ const isCDATA = text.includes('<![CDATA[');
1827
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
1828
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
1829
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
1830
+
1831
+ // Preserve if output is empty and input had template syntax or UIDs
1832
+ // This catches cases where Lightning CSS removed content that should be preserved
1833
+ return (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
1834
+ })();
1831
1835
 
1832
- cssMinifyCache.set(cssKey, finalOutput);
1833
- return finalOutput;
1836
+ cssMinifyCache.set(cssKey, inFlight);
1837
+ const resolved = await inFlight;
1838
+ cssMinifyCache.set(cssKey, resolved);
1839
+ return resolved;
1834
1840
  } catch (err) {
1835
1841
  cssMinifyCache.delete(cssKey);
1836
1842
  if (!options.continueOnMinifyError) {
@@ -2210,7 +2216,15 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
2210
2216
  // Apply early whitespace normalization if enabled
2211
2217
  // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
2212
2218
  if (options.collapseAttributeWhitespace) {
2213
- attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
2219
+ // Single-pass: Trim leading/trailing whitespace and collapse internal whitespace to single space
2220
+ attrValue = attrValue.replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$|[ \n\r\t\f]+/g, function(match, offset, str) {
2221
+ // Leading whitespace (`offset === 0`)
2222
+ if (offset === 0) return '';
2223
+ // Trailing whitespace (match ends at string end)
2224
+ if (offset + match.length === str.length) return '';
2225
+ // Internal whitespace
2226
+ return ' ';
2227
+ });
2214
2228
  }
2215
2229
 
2216
2230
  if (isEventAttribute(attrName, options)) {
@@ -2740,8 +2754,8 @@ async function getSwc() {
2740
2754
 
2741
2755
  // Minification caches
2742
2756
 
2743
- const cssMinifyCache = new LRU(200);
2744
- const jsMinifyCache = new LRU(200);
2757
+ const cssMinifyCache = new LRU(500);
2758
+ const jsMinifyCache = new LRU(500);
2745
2759
 
2746
2760
  // Type definitions
2747
2761
 
@@ -3284,7 +3298,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3284
3298
  attrSorters[tag] = attrChains[tag].createSorter();
3285
3299
  }
3286
3300
  // Memoize sorted attribute orders—attribute sets often repeat in templates
3287
- const attrOrderCache = new LRU(200);
3301
+ const attrOrderCache = new LRU(500);
3288
3302
 
3289
3303
  options.sortAttributes = function (tag, attrs) {
3290
3304
  const sorter = attrSorters[tag];
@@ -3315,7 +3329,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
3315
3329
  if (classChain) {
3316
3330
  const sorter = classChain.createSorter();
3317
3331
  // Memoize `sortClassName` results—class lists often repeat in templates
3318
- const classNameCache = new LRU(200);
3332
+ const classNameCache = new LRU(500);
3319
3333
 
3320
3334
  options.sortClassName = function (value) {
3321
3335
  // Fast path: Single class (no spaces) needs no sorting
@@ -3236,37 +3236,29 @@ class Sorter {
3236
3236
  for (let i = 0, len = this.keys.length; i < len; i++) {
3237
3237
  const token = this.keys[i];
3238
3238
 
3239
- // Build position map for this token to avoid repeated `indexOf`
3240
- const positions = [];
3239
+ // Single pass: Count matches and collect non-matches
3240
+ let matchCount = 0;
3241
+ const others = [];
3242
+
3241
3243
  for (let j = fromIndex; j < tokens.length; j++) {
3242
3244
  if (tokens[j] === token) {
3243
- positions.push(j);
3245
+ matchCount++;
3246
+ } else {
3247
+ others.push(tokens[j]);
3244
3248
  }
3245
3249
  }
3246
3250
 
3247
- if (positions.length > 0) {
3248
- // Build new array with tokens in sorted order instead of splicing
3249
- const result = [];
3250
-
3251
- // Add all instances of the current token first
3252
- for (let j = 0; j < positions.length; j++) {
3253
- result.push(token);
3251
+ if (matchCount > 0) {
3252
+ // Rebuild: `matchCount` instances of token first, then others
3253
+ let writeIdx = fromIndex;
3254
+ for (let j = 0; j < matchCount; j++) {
3255
+ tokens[writeIdx++] = token;
3254
3256
  }
3255
-
3256
- // Add other tokens, skipping positions where current token was
3257
- const posSet = new Set(positions);
3258
- for (let j = fromIndex; j < tokens.length; j++) {
3259
- if (!posSet.has(j)) {
3260
- result.push(tokens[j]);
3261
- }
3262
- }
3263
-
3264
- // Copy sorted portion back to tokens array
3265
- for (let j = 0; j < result.length; j++) {
3266
- tokens[fromIndex + j] = result[j];
3257
+ for (let j = 0; j < others.length; j++) {
3258
+ tokens[writeIdx++] = others[j];
3267
3259
  }
3268
3260
 
3269
- const newFromIndex = fromIndex + positions.length;
3261
+ const newFromIndex = fromIndex + matchCount;
3270
3262
  return this.sorterMap.get(token).sort(tokens, newFromIndex);
3271
3263
  }
3272
3264
  }
@@ -6914,24 +6906,30 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
6914
6906
  if (!text || !text.trim()) {
6915
6907
  return text;
6916
6908
  }
6917
- text = await replaceAsync(
6918
- text,
6919
- /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
6920
- async function (match, prefix, dq, sq, unq, suffix) {
6921
- const quote = dq != null ? '"' : (sq != null ? "'" : '');
6922
- const url = dq ?? sq ?? unq ?? '';
6923
- try {
6924
- const out = await options.minifyURLs(url);
6925
- return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
6926
- } catch (err) {
6927
- if (!options.continueOnMinifyError) {
6928
- throw err;
6909
+
6910
+ // Optimization: Only process URLs if minification is enabled (not identity function)
6911
+ // This avoids expensive `replaceAsync` when URL minification is disabled
6912
+ if (options.minifyURLs !== identity) {
6913
+ text = await replaceAsync(
6914
+ text,
6915
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
6916
+ async function (match, prefix, dq, sq, unq, suffix) {
6917
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
6918
+ const url = dq ?? sq ?? unq ?? '';
6919
+ try {
6920
+ const out = await options.minifyURLs(url);
6921
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
6922
+ } catch (err) {
6923
+ if (!options.continueOnMinifyError) {
6924
+ throw err;
6925
+ }
6926
+ options.log && options.log(err);
6927
+ return match;
6929
6928
  }
6930
- options.log && options.log(err);
6931
- return match;
6932
6929
  }
6933
- }
6934
- );
6930
+ );
6931
+ }
6932
+
6935
6933
  // Cache key: Wrapped content, type, options signature
6936
6934
  const inputCSS = wrapCSS(text, type);
6937
6935
  const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
@@ -6943,36 +6941,44 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
6943
6941
  try {
6944
6942
  const cached = cssMinifyCache.get(cssKey);
6945
6943
  if (cached) {
6946
- return cached;
6944
+ // Support both resolved values and in-flight promises
6945
+ return await cached;
6947
6946
  }
6948
6947
 
6949
- const transformCSS = await getLightningCSS();
6950
- const result = transformCSS({
6951
- filename: 'input.css',
6952
- code: Buffer.from(inputCSS),
6953
- minify: true,
6954
- errorRecovery: !!options.continueOnMinifyError,
6955
- ...lightningCssOptions
6956
- });
6957
-
6958
- const outputCSS = unwrapCSS(result.code.toString(), type);
6959
-
6960
- // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
6961
- // This preserves:
6962
- // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
6963
- // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
6964
- // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
6965
- const isCDATA = text.includes('<![CDATA[');
6966
- const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
6967
- const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
6968
- const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
6948
+ // In-flight promise caching: Prevent duplicate concurrent minifications
6949
+ // of the same CSS content (same pattern as JS minification)
6950
+ const inFlight = (async () => {
6951
+ const transformCSS = await getLightningCSS();
6952
+ // Note: `Buffer.from()` is required—Lightning CSS API expects Uint8Array
6953
+ const result = transformCSS({
6954
+ filename: 'input.css',
6955
+ code: Buffer.from(inputCSS),
6956
+ minify: true,
6957
+ errorRecovery: !!options.continueOnMinifyError,
6958
+ ...lightningCssOptions
6959
+ });
6969
6960
 
6970
- // Preserve if output is empty and input had template syntax or UIDs
6971
- // This catches cases where Lightning CSS removed content that should be preserved
6972
- const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
6961
+ const outputCSS = unwrapCSS(result.code.toString(), type);
6962
+
6963
+ // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
6964
+ // This preserves:
6965
+ // 1. Template code like `<?php ?>`, `<%= ?>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
6966
+ // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
6967
+ // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
6968
+ const isCDATA = text.includes('<![CDATA[');
6969
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
6970
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
6971
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
6972
+
6973
+ // Preserve if output is empty and input had template syntax or UIDs
6974
+ // This catches cases where Lightning CSS removed content that should be preserved
6975
+ return (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
6976
+ })();
6973
6977
 
6974
- cssMinifyCache.set(cssKey, finalOutput);
6975
- return finalOutput;
6978
+ cssMinifyCache.set(cssKey, inFlight);
6979
+ const resolved = await inFlight;
6980
+ cssMinifyCache.set(cssKey, resolved);
6981
+ return resolved;
6976
6982
  } catch (err) {
6977
6983
  cssMinifyCache.delete(cssKey);
6978
6984
  if (!options.continueOnMinifyError) {
@@ -7352,7 +7358,15 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
7352
7358
  // Apply early whitespace normalization if enabled
7353
7359
  // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
7354
7360
  if (options.collapseAttributeWhitespace) {
7355
- attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
7361
+ // Single-pass: Trim leading/trailing whitespace and collapse internal whitespace to single space
7362
+ attrValue = attrValue.replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$|[ \n\r\t\f]+/g, function(match, offset, str) {
7363
+ // Leading whitespace (`offset === 0`)
7364
+ if (offset === 0) return '';
7365
+ // Trailing whitespace (match ends at string end)
7366
+ if (offset + match.length === str.length) return '';
7367
+ // Internal whitespace
7368
+ return ' ';
7369
+ });
7356
7370
  }
7357
7371
 
7358
7372
  if (isEventAttribute(attrName, options)) {
@@ -7882,8 +7896,8 @@ async function getSwc() {
7882
7896
 
7883
7897
  // Minification caches
7884
7898
 
7885
- const cssMinifyCache = new LRU(200);
7886
- const jsMinifyCache = new LRU(200);
7899
+ const cssMinifyCache = new LRU(500);
7900
+ const jsMinifyCache = new LRU(500);
7887
7901
 
7888
7902
  // Type definitions
7889
7903
 
@@ -8426,7 +8440,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
8426
8440
  attrSorters[tag] = attrChains[tag].createSorter();
8427
8441
  }
8428
8442
  // Memoize sorted attribute orders—attribute sets often repeat in templates
8429
- const attrOrderCache = new LRU(200);
8443
+ const attrOrderCache = new LRU(500);
8430
8444
 
8431
8445
  options.sortAttributes = function (tag, attrs) {
8432
8446
  const sorter = attrSorters[tag];
@@ -8457,7 +8471,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
8457
8471
  if (classChain) {
8458
8472
  const sorter = classChain.createSorter();
8459
8473
  // Memoize `sortClassName` results—class lists often repeat in templates
8460
- const classNameCache = new LRU(200);
8474
+ const classNameCache = new LRU(500);
8461
8475
 
8462
8476
  options.sortClassName = function (value) {
8463
8477
  // Fast path: Single class (no spaces) needs no sorting
@@ -1 +1 @@
1
- {"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../../../src/lib/attributes.js"],"names":[],"mappings":"AAuBA,yDAEC;AAED,mEAOC;AAED,uEAWC;AAED,8DAGC;AAED,4EAOC;AAED,mGAqBC;AAED,mEAGC;AAED,qEAGC;AAED,kEAWC;AAED,sEAGC;AAED,4DAWC;AAED,2EAEC;AAED,qEAaC;AAED,wEAUC;AAED,sEAUC;AAED,2EAEC;AAED,2DAEC;AAED,8DAUC;AAED,uEAUC;AAED,oGASC;AAED,4DAOC;AAID,0IA0IC;AAsBD;;;;GAwCC;AAED,6GA4EC"}
1
+ {"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../../../src/lib/attributes.js"],"names":[],"mappings":"AAuBA,yDAEC;AAED,mEAOC;AAED,uEAWC;AAED,8DAGC;AAED,4EAOC;AAED,mGAqBC;AAED,mEAGC;AAED,qEAGC;AAED,kEAWC;AAED,sEAGC;AAED,4DAWC;AAED,2EAEC;AAED,qEAaC;AAED,wEAUC;AAED,sEAUC;AAED,2EAEC;AAED,2DAEC;AAED,8DAUC;AAED,uEAUC;AAED,oGASC;AAED,4DAOC;AAID,0IAkJC;AAsBD;;;;GAwCC;AAED,6GA4EC"}
@@ -1 +1 @@
1
- {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../../src/lib/options.js"],"names":[],"mappings":"AAWA,6DAUC;AAID;;;;;;;;;GASG;AACH,6CATW,OAAO,CAAC,eAAe,CAAC,0EAEhC;IAAuB,eAAe;IACf,SAAS;IACT,MAAM;CAA2B,GAG9C,eAAe,CA6S3B"}
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../../src/lib/options.js"],"names":[],"mappings":"AAWA,6DAUC;AAID;;;;;;;;;GASG;AACH,6CATW,OAAO,CAAC,eAAe,CAAC,0EAEhC;IAAuB,eAAe;IACf,SAAS;IACT,MAAM;CAA2B,GAG9C,eAAe,CA2T3B"}
@@ -1 +1 @@
1
- {"version":3,"file":"tokenchain.d.ts","sourceRoot":"","sources":["../../src/tokenchain.js"],"names":[],"mappings":";AA2CA;IAGI,mBAAoB;IAGtB,uBAOC;IAED,uBAiDC;CACF;AA5GD;IACE,2CAuCC;CACF"}
1
+ {"version":3,"file":"tokenchain.d.ts","sourceRoot":"","sources":["../../src/tokenchain.js"],"names":[],"mappings":";AAmCA;IAGI,mBAAoB;IAGtB,uBAOC;IAED,uBAiDC;CACF;AApGD;IACE,2CA+BC;CACF"}
package/package.json CHANGED
@@ -93,5 +93,5 @@
93
93
  "test:watch": "node --test --watch tests/*.spec.js"
94
94
  },
95
95
  "type": "module",
96
- "version": "4.16.0"
96
+ "version": "4.16.2"
97
97
  }
@@ -91,8 +91,8 @@ async function getSwc() {
91
91
 
92
92
  // Minification caches
93
93
 
94
- const cssMinifyCache = new LRU(200);
95
- const jsMinifyCache = new LRU(200);
94
+ const cssMinifyCache = new LRU(500);
95
+ const jsMinifyCache = new LRU(500);
96
96
 
97
97
  // Type definitions
98
98
 
@@ -635,7 +635,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
635
635
  attrSorters[tag] = attrChains[tag].createSorter();
636
636
  }
637
637
  // Memoize sorted attribute orders—attribute sets often repeat in templates
638
- const attrOrderCache = new LRU(200);
638
+ const attrOrderCache = new LRU(500);
639
639
 
640
640
  options.sortAttributes = function (tag, attrs) {
641
641
  const sorter = attrSorters[tag];
@@ -666,7 +666,7 @@ async function createSortFns(value, options, uidIgnore, uidAttr, ignoredMarkupCh
666
666
  if (classChain) {
667
667
  const sorter = classChain.createSorter();
668
668
  // Memoize `sortClassName` results—class lists often repeat in templates
669
- const classNameCache = new LRU(200);
669
+ const classNameCache = new LRU(500);
670
670
 
671
671
  options.sortClassName = function (value) {
672
672
  // Fast path: Single class (no spaces) needs no sorting
@@ -226,7 +226,15 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
226
226
  // Apply early whitespace normalization if enabled
227
227
  // Preserves special spaces (non-breaking space, hair space, etc.) for consistency with `collapseWhitespace`
228
228
  if (options.collapseAttributeWhitespace) {
229
- attrValue = attrValue.replace(/[ \n\r\t\f]+/g, ' ').replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$/g, '');
229
+ // Single-pass: Trim leading/trailing whitespace and collapse internal whitespace to single space
230
+ attrValue = attrValue.replace(/^[ \n\r\t\f]+|[ \n\r\t\f]+$|[ \n\r\t\f]+/g, function(match, offset, str) {
231
+ // Leading whitespace (`offset === 0`)
232
+ if (offset === 0) return '';
233
+ // Trailing whitespace (match ends at string end)
234
+ if (offset + match.length === str.length) return '';
235
+ // Internal whitespace
236
+ return ' ';
237
+ });
230
238
  }
231
239
 
232
240
  if (isEventAttribute(attrName, options)) {
@@ -106,24 +106,30 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
106
106
  if (!text || !text.trim()) {
107
107
  return text;
108
108
  }
109
- text = await replaceAsync(
110
- text,
111
- /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
112
- async function (match, prefix, dq, sq, unq, suffix) {
113
- const quote = dq != null ? '"' : (sq != null ? "'" : '');
114
- const url = dq ?? sq ?? unq ?? '';
115
- try {
116
- const out = await options.minifyURLs(url);
117
- return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
118
- } catch (err) {
119
- if (!options.continueOnMinifyError) {
120
- throw err;
109
+
110
+ // Optimization: Only process URLs if minification is enabled (not identity function)
111
+ // This avoids expensive `replaceAsync` when URL minification is disabled
112
+ if (options.minifyURLs !== identity) {
113
+ text = await replaceAsync(
114
+ text,
115
+ /(url\s*\(\s*)(?:"([^"]*)"|'([^']*)'|([^\s)]+))(\s*\))/ig,
116
+ async function (match, prefix, dq, sq, unq, suffix) {
117
+ const quote = dq != null ? '"' : (sq != null ? "'" : '');
118
+ const url = dq ?? sq ?? unq ?? '';
119
+ try {
120
+ const out = await options.minifyURLs(url);
121
+ return prefix + quote + (typeof out === 'string' ? out : url) + quote + suffix;
122
+ } catch (err) {
123
+ if (!options.continueOnMinifyError) {
124
+ throw err;
125
+ }
126
+ options.log && options.log(err);
127
+ return match;
121
128
  }
122
- options.log && options.log(err);
123
- return match;
124
129
  }
125
- }
126
- );
130
+ );
131
+ }
132
+
127
133
  // Cache key: Wrapped content, type, options signature
128
134
  const inputCSS = wrapCSS(text, type);
129
135
  const cssSig = stableStringify({ type, opts: lightningCssOptions, cont: !!options.continueOnMinifyError });
@@ -135,36 +141,44 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
135
141
  try {
136
142
  const cached = cssMinifyCache.get(cssKey);
137
143
  if (cached) {
138
- return cached;
144
+ // Support both resolved values and in-flight promises
145
+ return await cached;
139
146
  }
140
147
 
141
- const transformCSS = await getLightningCSS();
142
- const result = transformCSS({
143
- filename: 'input.css',
144
- code: Buffer.from(inputCSS),
145
- minify: true,
146
- errorRecovery: !!options.continueOnMinifyError,
147
- ...lightningCssOptions
148
- });
149
-
150
- const outputCSS = unwrapCSS(result.code.toString(), type);
151
-
152
- // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
153
- // This preserves:
154
- // 1. Template code like `<?php ?>`, `<%= %>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
155
- // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
156
- // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
157
- const isCDATA = text.includes('<![CDATA[');
158
- const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
159
- const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
160
- const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
161
-
162
- // Preserve if output is empty and input had template syntax or UIDs
163
- // This catches cases where Lightning CSS removed content that should be preserved
164
- const finalOutput = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
165
-
166
- cssMinifyCache.set(cssKey, finalOutput);
167
- return finalOutput;
148
+ // In-flight promise caching: Prevent duplicate concurrent minifications
149
+ // of the same CSS content (same pattern as JS minification)
150
+ const inFlight = (async () => {
151
+ const transformCSS = await getLightningCSS();
152
+ // Note: `Buffer.from()` is required—Lightning CSS API expects Uint8Array
153
+ const result = transformCSS({
154
+ filename: 'input.css',
155
+ code: Buffer.from(inputCSS),
156
+ minify: true,
157
+ errorRecovery: !!options.continueOnMinifyError,
158
+ ...lightningCssOptions
159
+ });
160
+
161
+ const outputCSS = unwrapCSS(result.code.toString(), type);
162
+
163
+ // If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
164
+ // This preserves:
165
+ // 1. Template code like `<?php ?>`, `<%= ?>`, `{{ }}`, etc. (contain `<` or `>` but not `CDATA`)
166
+ // 2. UIDs representing custom fragments (only lowercase letters and digits, no spaces)
167
+ // CDATA sections, HTML entities, and other invalid CSS are allowed to be removed
168
+ const isCDATA = text.includes('<![CDATA[');
169
+ const uidPattern = /[a-z0-9]{10,}/; // UIDs are long alphanumeric strings
170
+ const hasUID = uidPattern.test(text) && !isCDATA; // Exclude CDATA from UID detection
171
+ const looksLikeTemplate = (text.includes('<') || text.includes('>')) && !isCDATA;
172
+
173
+ // Preserve if output is empty and input had template syntax or UIDs
174
+ // This catches cases where Lightning CSS removed content that should be preserved
175
+ return (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
176
+ })();
177
+
178
+ cssMinifyCache.set(cssKey, inFlight);
179
+ const resolved = await inFlight;
180
+ cssMinifyCache.set(cssKey, resolved);
181
+ return resolved;
168
182
  } catch (err) {
169
183
  cssMinifyCache.delete(cssKey);
170
184
  if (!options.continueOnMinifyError) {
package/src/tokenchain.js CHANGED
@@ -3,37 +3,29 @@ class Sorter {
3
3
  for (let i = 0, len = this.keys.length; i < len; i++) {
4
4
  const token = this.keys[i];
5
5
 
6
- // Build position map for this token to avoid repeated `indexOf`
7
- const positions = [];
6
+ // Single pass: Count matches and collect non-matches
7
+ let matchCount = 0;
8
+ const others = [];
9
+
8
10
  for (let j = fromIndex; j < tokens.length; j++) {
9
11
  if (tokens[j] === token) {
10
- positions.push(j);
12
+ matchCount++;
13
+ } else {
14
+ others.push(tokens[j]);
11
15
  }
12
16
  }
13
17
 
14
- if (positions.length > 0) {
15
- // Build new array with tokens in sorted order instead of splicing
16
- const result = [];
17
-
18
- // Add all instances of the current token first
19
- for (let j = 0; j < positions.length; j++) {
20
- result.push(token);
21
- }
22
-
23
- // Add other tokens, skipping positions where current token was
24
- const posSet = new Set(positions);
25
- for (let j = fromIndex; j < tokens.length; j++) {
26
- if (!posSet.has(j)) {
27
- result.push(tokens[j]);
28
- }
18
+ if (matchCount > 0) {
19
+ // Rebuild: `matchCount` instances of token first, then others
20
+ let writeIdx = fromIndex;
21
+ for (let j = 0; j < matchCount; j++) {
22
+ tokens[writeIdx++] = token;
29
23
  }
30
-
31
- // Copy sorted portion back to tokens array
32
- for (let j = 0; j < result.length; j++) {
33
- tokens[fromIndex + j] = result[j];
24
+ for (let j = 0; j < others.length; j++) {
25
+ tokens[writeIdx++] = others[j];
34
26
  }
35
27
 
36
- const newFromIndex = fromIndex + positions.length;
28
+ const newFromIndex = fromIndex + matchCount;
37
29
  return this.sorterMap.get(token).sort(tokens, newFromIndex);
38
30
  }
39
31
  }