html-minifier-next 4.17.1 → 4.18.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
@@ -61,7 +61,7 @@ You can use a configuration file to specify options. The file can be either JSON
61
61
 
62
62
  **JavaScript module configuration example:**
63
63
 
64
- ```js
64
+ ```javascript
65
65
  module.exports = {
66
66
  collapseWhitespace: true,
67
67
  removeComments: true,
@@ -74,7 +74,7 @@ module.exports = {
74
74
 
75
75
  ESM with Node.js ≥16.14:
76
76
 
77
- ```js
77
+ ```javascript
78
78
  import { minify } from 'html-minifier-next';
79
79
 
80
80
  const result = await minify('<p title="example" id="moo">foo</p>', {
@@ -86,7 +86,7 @@ console.log(result); // “<p title=example id=moo>foo”
86
86
 
87
87
  CommonJS:
88
88
 
89
- ```js
89
+ ```javascript
90
90
  const { minify, getPreset } = require('html-minifier-next');
91
91
 
92
92
  (async () => {
@@ -152,6 +152,7 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
152
152
  | `keepClosingSlash`<br>`--keep-closing-slash` | Keep the trailing slash on void elements | `false` |
153
153
  | `maxInputLength`<br>`--max-input-length` | Maximum input length to prevent ReDoS attacks (disabled by default) | `undefined` |
154
154
  | `maxLineLength`<br>`--max-line-length` | Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points | `undefined` |
155
+ | `mergeScripts`<br>`--merge-scripts` | Merge consecutive inline `script` elements into one (only merges compatible scripts with same `type`, matching `async`/`defer`/`nomodule`/`nonce`) | `false` |
155
156
  | `minifyCSS`<br>`--minify-css` | Minify CSS in `style` elements and attributes (uses [Lightning CSS](https://lightningcss.dev/)) | `false` (could be `true`, `Object`, `Function(text, type)`) |
156
157
  | `minifyJS`<br>`--minify-js` | Minify JavaScript in `script` elements and event attributes (uses [Terser](https://github.com/terser/terser) or [SWC](https://swc.rs/)) | `false` (could be `true`, `Object`, `Function(text, inline)`) |
157
158
  | `minifySVG`<br>`--minify-svg` | Minify SVG elements and attributes (numeric precision, default attributes, colors) | `false` (could be `true`, `Object`) |
@@ -188,7 +189,7 @@ When `minifyCSS` is set to `true`, HTML Minifier Next uses [Lightning CSS](https
188
189
 
189
190
  You can pass Lightning CSS configuration options by providing an object:
190
191
 
191
- ```js
192
+ ```javascript
192
193
  const result = await minify(html, {
193
194
  minifyCSS: {
194
195
  targets: {
@@ -211,7 +212,7 @@ Available Lightning CSS options when passed as an object:
211
212
 
212
213
  For advanced usage, you can also pass a function:
213
214
 
214
- ```js
215
+ ```javascript
215
216
  const result = await minify(html, {
216
217
  minifyCSS: function(text, type) {
217
218
  // `text`: CSS string to minify
@@ -227,7 +228,7 @@ When `minifyJS` is set to `true`, HTML Minifier Next uses [Terser](https://githu
227
228
 
228
229
  You can choose between different JS minifiers using the `engine` field:
229
230
 
230
- ```js
231
+ ```javascript
231
232
  const result = await minify(html, {
232
233
  minifyJS: {
233
234
  engine: 'swc', // Use SWC for faster minification
@@ -253,7 +254,7 @@ npm i @swc/core
253
254
 
254
255
  You can pass engine-specific configuration options:
255
256
 
256
- ```js
257
+ ```javascript
257
258
  // Using Terser with custom options
258
259
  const result = await minify(html, {
259
260
  minifyJS: {
@@ -273,7 +274,7 @@ const result = await minify(html, {
273
274
 
274
275
  For advanced usage, you can also pass a function:
275
276
 
276
- ```js
277
+ ```javascript
277
278
  const result = await minify(html, {
278
279
  minifyJS: function(text, inline) {
279
280
  // `text`: JavaScript string to minify
@@ -287,7 +288,7 @@ const result = await minify(html, {
287
288
 
288
289
  When `minifySVG` is set to `true`, HTML Minifier Next applies SVG-specific optimizations to SVG elements and their attributes. These optimizations are lightweight, fast, and safe:
289
290
 
290
- ```js
291
+ ```javascript
291
292
  const result = await minify(html, {
292
293
  minifySVG: true // Enable with default settings
293
294
  });
@@ -313,7 +314,7 @@ What gets optimized:
313
314
 
314
315
  You can customize the optimization behavior by providing an options object:
315
316
 
316
- ```js
317
+ ```javascript
317
318
  const result = await minify(html, {
318
319
  minifySVG: {
319
320
  precision: 2, // Use 2 decimal places instead of 3
@@ -344,38 +345,38 @@ How does HTML Minifier Next compare to other minifiers? (All minification with t
344
345
  | 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/) |
345
346
  | --- | --- | --- | --- | --- | --- | --- | --- |
346
347
  | [A List Apart](https://alistapart.com/) | 59 | **49** | 51 | 52 | 51 | 54 | 52 |
347
- | [Apple](https://www.apple.com/) | 259 | **201** | 230 | 234 | 234 | 236 | 237 |
348
- | [BBC](https://www.bbc.co.uk/) | 647 | **586** | 608 | 608 | 609 | 642 | n/a |
349
- | [CERN](https://home.cern/) | 151 | **83** | 91 | 91 | 91 | 93 | 96 |
350
- | [CSS-Tricks](https://css-tricks.com/) | 161 | **119** | 127 | 142 | 142 | 147 | 144 |
348
+ | [Apple](https://www.apple.com/) | 124 | **108** | 115 | 116 | 118 | 119 | 119 |
349
+ | [BBC](https://www.bbc.co.uk/) | 618 | **561** | 580 | 581 | 582 | 613 | n/a |
350
+ | [CERN](https://home.cern/) | 151 | **82** | 90 | 90 | 91 | 93 | 95 |
351
+ | [CSS-Tricks](https://css-tricks.com/) | 161 | **119** | 126 | 141 | 142 | 147 | 143 |
351
352
  | [ECMAScript](https://tc39.es/ecma262/) | 7250 | **6401** | 6573 | 6455 | 6578 | 6626 | n/a |
352
353
  | [EDRi](https://edri.org/) | 80 | **59** | 70 | 70 | 71 | 75 | 73 |
353
- | [EFF](https://www.eff.org/) | 55 | **46** | 49 | 48 | 48 | 50 | 50 |
354
+ | [EFF](https://www.eff.org/) | 54 | **45** | 49 | 47 | 48 | 49 | 49 |
354
355
  | [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | 32 | 32 | 32 | 32 | 32 |
355
- | [FAZ](https://www.faz.net/aktuell/) | 1579 | 1468 | **1416** | 1503 | 1515 | 1526 | n/a |
356
+ | [FAZ](https://www.faz.net/aktuell/) | 1595 | 1456 | **1428** | 1519 | 1530 | 1540 | n/a |
356
357
  | [French Tech](https://lafrenchtech.gouv.fr/) | 153 | **122** | 126 | 126 | 126 | 132 | 127 |
357
- | [Frontend Dogma](https://frontenddogma.com/) | 227 | **219** | 240 | 225 | 227 | 245 | 226 |
358
+ | [Frontend Dogma](https://frontenddogma.com/) | 228 | **220** | 242 | 227 | 228 | 247 | 228 |
358
359
  | [Google](https://www.google.com/) | 18 | **16** | 17 | 17 | 17 | 18 | 18 |
359
- | [Ground News](https://ground.news/) | 2626 | **2321** | 2417 | 2444 | 2446 | 2613 | n/a |
360
+ | [Ground News](https://ground.news/) | 2212 | **1945** | 2040 | 2068 | 2069 | 2198 | n/a |
360
361
  | [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | 148 | 153 | **147** | 149 | 155 | 149 |
361
- | [Igalia](https://www.igalia.com/) | 48 | **33** | 35 | 35 | 35 | 36 | 36 |
362
- | [Leanpub](https://leanpub.com/) | 245 | **214** | 228 | 228 | 229 | 241 | 242 |
363
- | [Mastodon](https://mastodon.social/explore) | 37 | **28** | 32 | 35 | 35 | 36 | 36 |
362
+ | [Igalia](https://www.igalia.com/) | 49 | **33** | 36 | 35 | 36 | 36 | 36 |
363
+ | [Leanpub](https://leanpub.com/) | 250 | **218** | 233 | 233 | 234 | 245 | 247 |
364
+ | [Mastodon](https://mastodon.social/explore) | 38 | **28** | 32 | 35 | 35 | 36 | 36 |
364
365
  | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | 64 | 65 | 65 | 68 | 68 |
365
- | [Middle East Eye](https://www.middleeasteye.net/) | 222 | **196** | 202 | 200 | 200 | 202 | 202 |
366
- | [Mistral AI](https://mistral.ai/) | 356 | **313** | 318 | 322 | 323 | 352 | n/a |
367
- | [Mozilla](https://www.mozilla.org/) | 45 | **31** | 35 | 34 | 34 | 35 | 35 |
366
+ | [Middle East Eye](https://www.middleeasteye.net/) | 221 | **195** | 201 | 200 | 199 | 201 | 202 |
367
+ | [Mistral AI](https://mistral.ai/) | 364 | **319** | 326 | 329 | 330 | 360 | n/a |
368
+ | [Mozilla](https://www.mozilla.org/) | 54 | **36** | 42 | 42 | 41 | 43 | 43 |
368
369
  | [Nielsen Norman Group](https://www.nngroup.com/) | 93 | 70 | **57** | 75 | 77 | 78 | 77 |
369
- | [SitePoint](https://www.sitepoint.com/) | 478 | **347** | 419 | 452 | 457 | 475 | n/a |
370
+ | [SitePoint](https://www.sitepoint.com/) | 483 | **352** | 424 | 457 | 462 | 480 | n/a |
370
371
  | [Startup-Verband](https://startupverband.de/) | 43 | **30** | 31 | **30** | 31 | 31 | 31 |
371
- | [TetraLogical](https://tetralogical.com/) | 44 | 39 | **36** | 38 | 39 | 39 | 39 |
372
- | [TPGi](https://www.tpgi.com/) | 175 | **159** | 160 | 164 | 166 | 172 | 172 |
373
- | [United Nations](https://www.un.org/en/) | 152 | **113** | 122 | 126 | 126 | 131 | 124 |
372
+ | [TetraLogical](https://tetralogical.com/) | 44 | 38 | **36** | 38 | 39 | 39 | 39 |
373
+ | [TPGi](https://www.tpgi.com/) | 173 | **157** | 159 | 163 | 164 | 170 | 170 |
374
+ | [United Nations](https://www.un.org/en/) | 151 | **112** | 121 | 125 | 125 | 130 | 123 |
374
375
  | [Vivaldi](https://vivaldi.com/) | 93 | **74** | n/a | 79 | 81 | 84 | 82 |
375
- | [W3C](https://www.w3.org/) | 50 | **36** | 39 | 38 | 38 | 41 | 39 |
376
- | **Average processing time** | | 97 ms (30/30) | 157 ms (29/30) | 51 ms (30/30) | **15 ms (30/30)** | 289 ms (30/30) | 1288 ms (24/30) |
376
+ | [W3C](https://www.w3.org/) | 51 | **36** | 39 | 38 | 38 | 41 | 39 |
377
+ | **Average processing time** | | 98 ms (30/30) | 152 ms (29/30) | 48 ms (30/30) | **14 ms (30/30)** | 274 ms (30/30) | 1437 ms (24/30) |
377
378
 
378
- (Last updated: Jan 8, 2026)
379
+ (Last updated: Jan 19, 2026)
379
380
  <!-- End auto-generated -->
380
381
 
381
382
  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.
package/cli.js CHANGED
@@ -144,6 +144,7 @@ const mainOptions = {
144
144
  keepClosingSlash: 'Keep the trailing slash on void elements',
145
145
  maxInputLength: ['Maximum input length to prevent ReDoS attacks', parseValidInt('maxInputLength')],
146
146
  maxLineLength: ['Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points', parseValidInt('maxLineLength')],
147
+ mergeScripts: 'Merge consecutive inline `script` elements into one',
147
148
  minifyCSS: ['Minify CSS in `style` elements and attributes (uses Lightning CSS)', parseJSON],
148
149
  minifyJS: ['Minify JavaScript in `script` elements and event attributes (uses Terser or SWC; pass `{"engine": "swc"}` for SWC)', parseJSON],
149
150
  minifySVG: ['Minify SVG elements and attributes (numeric precision, default attributes, colors)', parseJSON],
@@ -781,6 +781,7 @@ const presets = {
781
781
  collapseWhitespace: true,
782
782
  continueOnParseError: true,
783
783
  decodeEntities: true,
784
+ mergeScripts: true,
784
785
  minifyCSS: true,
785
786
  minifyJS: true,
786
787
  minifySVG: true,
@@ -999,6 +1000,11 @@ const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autop
999
1000
 
1000
1001
  const isBooleanValue = new Set(['true', 'false']);
1001
1002
 
1003
+ // Attributes where empty value can be collapsed to just the attribute name
1004
+ // `crossorigin=""` → `crossorigin` (empty string equals anonymous mode)
1005
+ // `contenteditable=""` → `contenteditable` (empty string equals `true`)
1006
+ const emptyCollapsible = new Set(['crossorigin', 'contenteditable']);
1007
+
1002
1008
  // `srcset` elements
1003
1009
 
1004
1010
  const srcsetElements = new Set(['img', 'source']);
@@ -1448,7 +1454,8 @@ function minifyNumber(num, precision = 3) {
1448
1454
  const fixed = parsed.toFixed(precision);
1449
1455
  const trimmed = fixed.replace(/\.?0+$/, '');
1450
1456
 
1451
- const result = trimmed || '0';
1457
+ // Remove leading zero before decimal point (e.g., `0.5` → `.5`, `-0.3` → `-.3`)
1458
+ const result = (trimmed || '0').replace(/^(-?)0\./, '$1.');
1452
1459
  numberCache.set(cacheKey, result);
1453
1460
  return result;
1454
1461
  }
@@ -1468,17 +1475,23 @@ function minifyPathData(pathData, precision = 3) {
1468
1475
  });
1469
1476
 
1470
1477
  // Remove unnecessary spaces around path commands
1471
- // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
1472
- // `M 10 20` `M10 20`, `L -5 -3` → `L-5-3`
1473
- result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
1478
+ // Safe to remove space after a command letter when it’s followed by a number
1479
+ // (which may be negative or start with a decimal point)
1480
+ // `M 10 20` → `M10 20`, `L -5 -3` → `L-5-3`, `M .5 .3` → `M.5.3`
1481
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\.?\d)/g, '$1');
1474
1482
 
1475
1483
  // Safe to remove space before command letter when preceded by a number
1476
- // `0 L` → `0L`, `20 M` → `20M`
1477
- result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
1484
+ // `0 L` → `0L`, `20 M` → `20M`, `.5 L` → `.5L`
1485
+ result = result.replace(/([\d.])\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
1478
1486
 
1479
1487
  // Safe to remove space before negative number when preceded by a number
1480
- // `10 -20` → `10-20` (numbers are separated by the minus sign)
1481
- result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
1488
+ // `10 -20` → `10-20`, `.5 -.3` → `.5-.3` (minus sign is always a separator)
1489
+ result = result.replace(/([\d.])\s+(-)/g, '$1$2');
1490
+
1491
+ // Safe to remove space between two decimal numbers (decimal point acts as separator)
1492
+ // `.5 .3` → `.5.3` (only when previous char is `.`, indicating a complete decimal)
1493
+ // Note: `0 .3` must not become `0.3` (that would change two numbers into one)
1494
+ result = result.replace(/(\.\d*)\s+(\.)/g, '$1$2');
1482
1495
 
1483
1496
  return result;
1484
1497
  }
@@ -2255,7 +2268,9 @@ function isStyleElement(tag, attrs) {
2255
2268
  }
2256
2269
 
2257
2270
  function isBooleanAttribute(attrName, attrValue) {
2258
- return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
2271
+ return isSimpleBoolean.has(attrName) ||
2272
+ (attrName === 'draggable' && !isBooleanValue.has(attrValue)) ||
2273
+ (attrValue === '' && emptyCollapsible.has(attrName));
2259
2274
  }
2260
2275
 
2261
2276
  function isUriTypeAttribute(attrName, tag) {
@@ -2933,11 +2948,126 @@ async function getSwc() {
2933
2948
  }
2934
2949
 
2935
2950
  // Minification caches
2936
-
2937
2951
  const cssMinifyCache = new LRU(500);
2938
2952
  const jsMinifyCache = new LRU(500);
2939
2953
  const urlMinifyCache = new LRU(500);
2940
2954
 
2955
+ // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
2956
+ const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
2957
+ const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
2958
+ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
2959
+
2960
+ // Pre-compiled patterns for buffer scanning
2961
+ const RE_START_TAG = /^<[^/!]/;
2962
+ const RE_END_TAG = /^<\//;
2963
+
2964
+ // Script merging
2965
+
2966
+ /**
2967
+ * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
2968
+ * Only merges scripts that are compatible:
2969
+ * - Both inline (no `src` attribute)
2970
+ * - Same `type` (or both default JavaScript)
2971
+ * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
2972
+ *
2973
+ * Limitation: This function uses regex-based matching (`pattern` variable below),
2974
+ * which can produce incorrect results if a script’s content contains a literal
2975
+ * `</script>` string (e.g., `document.write('<script>…</script>')`). In valid
2976
+ * HTML, such strings should be escaped as `<\/script>` or split like
2977
+ * `'</scr' + 'ipt>'`, so this limitation rarely affects real-world code. The
2978
+ * earlier `minifyJS` step (if enabled) typically handles this escaping already.
2979
+ *
2980
+ * @param {string} html - The HTML string to process
2981
+ * @returns {string} HTML with consecutive scripts merged
2982
+ */
2983
+ function mergeConsecutiveScripts(html) {
2984
+ // `pattern`: Regex to match consecutive `</script>` followed by `<script…>`.
2985
+ // See function JSDoc above for known limitations with literal `</script>` in content.
2986
+ // Captures:
2987
+ // 1. first script attrs
2988
+ // 2. first script content
2989
+ // 3. whitespace between
2990
+ // 4. second script attrs
2991
+ // 5. second script content
2992
+ const pattern = /<script([^>]*)>([\s\S]*?)<\/script>([\s]*)<script([^>]*)>([\s\S]*?)<\/script>/gi;
2993
+
2994
+ let result = html;
2995
+ let changed = true;
2996
+
2997
+ // Keep merging until no more changes (handles chains of 3+ scripts)
2998
+ while (changed) {
2999
+ changed = false;
3000
+ result = result.replace(pattern, (match, attrs1, content1, whitespace, attrs2, content2) => {
3001
+ // Parse attributes from both script tags (uses pre-compiled RE_SCRIPT_ATTRS)
3002
+ const parseAttrs = (attrStr) => {
3003
+ const attrs = {};
3004
+ RE_SCRIPT_ATTRS.lastIndex = 0; // Reset for reuse
3005
+ let m;
3006
+ while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
3007
+ const name = m[1].toLowerCase();
3008
+ const value = m[2] ?? m[3] ?? m[4] ?? '';
3009
+ attrs[name] = value;
3010
+ }
3011
+ return attrs;
3012
+ };
3013
+
3014
+ const a1 = parseAttrs(attrs1);
3015
+ const a2 = parseAttrs(attrs2);
3016
+
3017
+ // Check for `src`—cannot merge external scripts
3018
+ if ('src' in a1 || 'src' in a2) {
3019
+ return match;
3020
+ }
3021
+
3022
+ // Check `type` compatibility (both must be same, or both default JS)
3023
+ const type1 = a1.type || '';
3024
+ const type2 = a2.type || '';
3025
+
3026
+ if (DEFAULT_JS_TYPES.has(type1) && DEFAULT_JS_TYPES.has(type2)) ; else if (type1 === type2) ; else {
3027
+ // Incompatible types
3028
+ return match;
3029
+ }
3030
+
3031
+ // Check for conflicting boolean attributes (uses pre-compiled SCRIPT_BOOL_ATTRS)
3032
+ for (const attr of SCRIPT_BOOL_ATTRS) {
3033
+ const has1 = attr in a1;
3034
+ const has2 = attr in a2;
3035
+ if (has1 !== has2) {
3036
+ // One has it, one doesn't - incompatible
3037
+ return match;
3038
+ }
3039
+ }
3040
+
3041
+ // Check `nonce`—must be same or both absent
3042
+ if (a1.nonce !== a2.nonce) {
3043
+ return match;
3044
+ }
3045
+
3046
+ // Scripts are compatible—merge them
3047
+ changed = true;
3048
+
3049
+ // Combine content—use semicolon normally, newline only for trailing `//` comments
3050
+ const c1 = content1.trim();
3051
+ const c2 = content2.trim();
3052
+ let mergedContent;
3053
+ if (c1 && c2) {
3054
+ // Check if last line of c1 contains `//` (single-line comment)
3055
+ // If so, use newline to terminate it; otherwise use semicolon (if not already present)
3056
+ const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
3057
+ const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
3058
+ mergedContent = c1 + separator + c2;
3059
+ } else {
3060
+ mergedContent = c1 || c2;
3061
+ }
3062
+
3063
+ // Use first script’s attributes (they should be compatible)
3064
+ return `<script${attrs1}>${mergedContent}</script>`;
3065
+ });
3066
+ }
3067
+
3068
+ return result;
3069
+ }
3070
+
2941
3071
  // Type definitions
2942
3072
 
2943
3073
  /**
@@ -3118,6 +3248,13 @@ const urlMinifyCache = new LRU(500);
3118
3248
  *
3119
3249
  * Default: No limit
3120
3250
  *
3251
+ * @prop {boolean} [mergeScripts]
3252
+ * When true, consecutive inline `<script>` elements are merged into one.
3253
+ * Only merges compatible scripts (same `type`, matching `async`/`defer`/
3254
+ * `nomodule`/`nonce` attributes). Does not merge external scripts (with `src`).
3255
+ *
3256
+ * Default: `false`
3257
+ *
3121
3258
  * @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
3122
3259
  * When true, enables CSS minification for inline `<style>` tags or
3123
3260
  * `style` attributes. If an object is provided, it is passed to
@@ -3696,7 +3833,7 @@ async function minifyHTML(value, options, partialMarkup) {
3696
3833
 
3697
3834
  function removeStartTag() {
3698
3835
  let index = buffer.length - 1;
3699
- while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
3836
+ while (index > 0 && !RE_START_TAG.test(buffer[index])) {
3700
3837
  index--;
3701
3838
  }
3702
3839
  buffer.length = Math.max(0, index);
@@ -3704,7 +3841,7 @@ async function minifyHTML(value, options, partialMarkup) {
3704
3841
 
3705
3842
  function removeEndTag() {
3706
3843
  let index = buffer.length - 1;
3707
- while (index > 0 && !/^<\//.test(buffer[index])) {
3844
+ while (index > 0 && !RE_END_TAG.test(buffer[index])) {
3708
3845
  index--;
3709
3846
  }
3710
3847
  buffer.length = Math.max(0, index);
@@ -3929,6 +4066,20 @@ async function minifyHTML(value, options, partialMarkup) {
3929
4066
  text = entities.decodeHTML(text);
3930
4067
  }
3931
4068
  }
4069
+ // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
4070
+ // This removes trailing newlines often added by template engines before closing tags
4071
+ // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
4072
+ if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
4073
+ const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
4074
+ if (stackNoTrimWhitespace.includes('pre') || stackNoTrimWhitespace.includes('textarea')) {
4075
+ // Trim trailing whitespace only if it ends with a single newline (not multiple)
4076
+ // Multiple newlines are likely intentional formatting, single newline is often a template artifact
4077
+ // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
4078
+ if (nextTag && nextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
4079
+ text = text.replace(/(?:\r\n|\r|\n)[ \t]*$/, '');
4080
+ }
4081
+ }
4082
+ }
3932
4083
  if (options.collapseWhitespace) {
3933
4084
  if (!stackNoTrimWhitespace.length) {
3934
4085
  if (prevTag === 'comment') {
@@ -4001,8 +4152,8 @@ async function minifyHTML(value, options, partialMarkup) {
4001
4152
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
4002
4153
  if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
4003
4154
  // Escape any `&` symbols that start either:
4004
- // 1) a legacy-named character reference (i.e., one that doesn’t end with `;`)
4005
- // 2) or any other character reference (i.e., one that does end with `;`)
4155
+ // 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
4156
+ // 2. or any other character reference (i.e., one that does end with `;`)
4006
4157
  // Note that `&` can be escaped as `&amp`, without the semicolon.
4007
4158
  // https://mathiasbynens.be/notes/ambiguous-ampersands
4008
4159
  if (text.indexOf('&') !== -1) {
@@ -4221,7 +4372,13 @@ const minify = async function (value, options) {
4221
4372
  jsMinifyCache,
4222
4373
  urlMinifyCache
4223
4374
  });
4224
- const result = await minifyHTML(value, options);
4375
+ let result = await minifyHTML(value, options);
4376
+
4377
+ // Post-processing: Merge consecutive inline scripts if enabled
4378
+ if (options.mergeScripts) {
4379
+ result = mergeConsecutiveScripts(result);
4380
+ }
4381
+
4225
4382
  options.log('minified in: ' + (Date.now() - start) + 'ms');
4226
4383
  return result;
4227
4384
  };
@@ -3393,6 +3393,7 @@ const presets = {
3393
3393
  collapseWhitespace: true,
3394
3394
  continueOnParseError: true,
3395
3395
  decodeEntities: true,
3396
+ mergeScripts: true,
3396
3397
  minifyCSS: true,
3397
3398
  minifyJS: true,
3398
3399
  minifySVG: true,
@@ -3611,6 +3612,11 @@ const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autop
3611
3612
 
3612
3613
  const isBooleanValue = new Set(['true', 'false']);
3613
3614
 
3615
+ // Attributes where empty value can be collapsed to just the attribute name
3616
+ // `crossorigin=""` → `crossorigin` (empty string equals anonymous mode)
3617
+ // `contenteditable=""` → `contenteditable` (empty string equals `true`)
3618
+ const emptyCollapsible = new Set(['crossorigin', 'contenteditable']);
3619
+
3614
3620
  // `srcset` elements
3615
3621
 
3616
3622
  const srcsetElements = new Set(['img', 'source']);
@@ -6590,7 +6596,8 @@ function minifyNumber(num, precision = 3) {
6590
6596
  const fixed = parsed.toFixed(precision);
6591
6597
  const trimmed = fixed.replace(/\.?0+$/, '');
6592
6598
 
6593
- const result = trimmed || '0';
6599
+ // Remove leading zero before decimal point (e.g., `0.5` → `.5`, `-0.3` → `-.3`)
6600
+ const result = (trimmed || '0').replace(/^(-?)0\./, '$1.');
6594
6601
  numberCache.set(cacheKey, result);
6595
6602
  return result;
6596
6603
  }
@@ -6610,17 +6617,23 @@ function minifyPathData(pathData, precision = 3) {
6610
6617
  });
6611
6618
 
6612
6619
  // Remove unnecessary spaces around path commands
6613
- // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
6614
- // `M 10 20` `M10 20`, `L -5 -3` → `L-5-3`
6615
- result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
6620
+ // Safe to remove space after a command letter when it’s followed by a number
6621
+ // (which may be negative or start with a decimal point)
6622
+ // `M 10 20` → `M10 20`, `L -5 -3` → `L-5-3`, `M .5 .3` → `M.5.3`
6623
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\.?\d)/g, '$1');
6616
6624
 
6617
6625
  // Safe to remove space before command letter when preceded by a number
6618
- // `0 L` → `0L`, `20 M` → `20M`
6619
- result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
6626
+ // `0 L` → `0L`, `20 M` → `20M`, `.5 L` → `.5L`
6627
+ result = result.replace(/([\d.])\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
6620
6628
 
6621
6629
  // Safe to remove space before negative number when preceded by a number
6622
- // `10 -20` → `10-20` (numbers are separated by the minus sign)
6623
- result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
6630
+ // `10 -20` → `10-20`, `.5 -.3` → `.5-.3` (minus sign is always a separator)
6631
+ result = result.replace(/([\d.])\s+(-)/g, '$1$2');
6632
+
6633
+ // Safe to remove space between two decimal numbers (decimal point acts as separator)
6634
+ // `.5 .3` → `.5.3` (only when previous char is `.`, indicating a complete decimal)
6635
+ // Note: `0 .3` must not become `0.3` (that would change two numbers into one)
6636
+ result = result.replace(/(\.\d*)\s+(\.)/g, '$1$2');
6624
6637
 
6625
6638
  return result;
6626
6639
  }
@@ -7397,7 +7410,9 @@ function isStyleElement(tag, attrs) {
7397
7410
  }
7398
7411
 
7399
7412
  function isBooleanAttribute(attrName, attrValue) {
7400
- return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
7413
+ return isSimpleBoolean.has(attrName) ||
7414
+ (attrName === 'draggable' && !isBooleanValue.has(attrValue)) ||
7415
+ (attrValue === '' && emptyCollapsible.has(attrName));
7401
7416
  }
7402
7417
 
7403
7418
  function isUriTypeAttribute(attrName, tag) {
@@ -8075,11 +8090,126 @@ async function getSwc() {
8075
8090
  }
8076
8091
 
8077
8092
  // Minification caches
8078
-
8079
8093
  const cssMinifyCache = new LRU(500);
8080
8094
  const jsMinifyCache = new LRU(500);
8081
8095
  const urlMinifyCache = new LRU(500);
8082
8096
 
8097
+ // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
8098
+ const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
8099
+ const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
8100
+ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
8101
+
8102
+ // Pre-compiled patterns for buffer scanning
8103
+ const RE_START_TAG = /^<[^/!]/;
8104
+ const RE_END_TAG = /^<\//;
8105
+
8106
+ // Script merging
8107
+
8108
+ /**
8109
+ * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
8110
+ * Only merges scripts that are compatible:
8111
+ * - Both inline (no `src` attribute)
8112
+ * - Same `type` (or both default JavaScript)
8113
+ * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
8114
+ *
8115
+ * Limitation: This function uses regex-based matching (`pattern` variable below),
8116
+ * which can produce incorrect results if a script’s content contains a literal
8117
+ * `</script>` string (e.g., `document.write('<script>…</script>')`). In valid
8118
+ * HTML, such strings should be escaped as `<\/script>` or split like
8119
+ * `'</scr' + 'ipt>'`, so this limitation rarely affects real-world code. The
8120
+ * earlier `minifyJS` step (if enabled) typically handles this escaping already.
8121
+ *
8122
+ * @param {string} html - The HTML string to process
8123
+ * @returns {string} HTML with consecutive scripts merged
8124
+ */
8125
+ function mergeConsecutiveScripts(html) {
8126
+ // `pattern`: Regex to match consecutive `</script>` followed by `<script…>`.
8127
+ // See function JSDoc above for known limitations with literal `</script>` in content.
8128
+ // Captures:
8129
+ // 1. first script attrs
8130
+ // 2. first script content
8131
+ // 3. whitespace between
8132
+ // 4. second script attrs
8133
+ // 5. second script content
8134
+ const pattern = /<script([^>]*)>([\s\S]*?)<\/script>([\s]*)<script([^>]*)>([\s\S]*?)<\/script>/gi;
8135
+
8136
+ let result = html;
8137
+ let changed = true;
8138
+
8139
+ // Keep merging until no more changes (handles chains of 3+ scripts)
8140
+ while (changed) {
8141
+ changed = false;
8142
+ result = result.replace(pattern, (match, attrs1, content1, whitespace, attrs2, content2) => {
8143
+ // Parse attributes from both script tags (uses pre-compiled RE_SCRIPT_ATTRS)
8144
+ const parseAttrs = (attrStr) => {
8145
+ const attrs = {};
8146
+ RE_SCRIPT_ATTRS.lastIndex = 0; // Reset for reuse
8147
+ let m;
8148
+ while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
8149
+ const name = m[1].toLowerCase();
8150
+ const value = m[2] ?? m[3] ?? m[4] ?? '';
8151
+ attrs[name] = value;
8152
+ }
8153
+ return attrs;
8154
+ };
8155
+
8156
+ const a1 = parseAttrs(attrs1);
8157
+ const a2 = parseAttrs(attrs2);
8158
+
8159
+ // Check for `src`—cannot merge external scripts
8160
+ if ('src' in a1 || 'src' in a2) {
8161
+ return match;
8162
+ }
8163
+
8164
+ // Check `type` compatibility (both must be same, or both default JS)
8165
+ const type1 = a1.type || '';
8166
+ const type2 = a2.type || '';
8167
+
8168
+ if (DEFAULT_JS_TYPES.has(type1) && DEFAULT_JS_TYPES.has(type2)) ; else if (type1 === type2) ; else {
8169
+ // Incompatible types
8170
+ return match;
8171
+ }
8172
+
8173
+ // Check for conflicting boolean attributes (uses pre-compiled SCRIPT_BOOL_ATTRS)
8174
+ for (const attr of SCRIPT_BOOL_ATTRS) {
8175
+ const has1 = attr in a1;
8176
+ const has2 = attr in a2;
8177
+ if (has1 !== has2) {
8178
+ // One has it, one doesn't - incompatible
8179
+ return match;
8180
+ }
8181
+ }
8182
+
8183
+ // Check `nonce`—must be same or both absent
8184
+ if (a1.nonce !== a2.nonce) {
8185
+ return match;
8186
+ }
8187
+
8188
+ // Scripts are compatible—merge them
8189
+ changed = true;
8190
+
8191
+ // Combine content—use semicolon normally, newline only for trailing `//` comments
8192
+ const c1 = content1.trim();
8193
+ const c2 = content2.trim();
8194
+ let mergedContent;
8195
+ if (c1 && c2) {
8196
+ // Check if last line of c1 contains `//` (single-line comment)
8197
+ // If so, use newline to terminate it; otherwise use semicolon (if not already present)
8198
+ const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
8199
+ const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
8200
+ mergedContent = c1 + separator + c2;
8201
+ } else {
8202
+ mergedContent = c1 || c2;
8203
+ }
8204
+
8205
+ // Use first script’s attributes (they should be compatible)
8206
+ return `<script${attrs1}>${mergedContent}</script>`;
8207
+ });
8208
+ }
8209
+
8210
+ return result;
8211
+ }
8212
+
8083
8213
  // Type definitions
8084
8214
 
8085
8215
  /**
@@ -8260,6 +8390,13 @@ const urlMinifyCache = new LRU(500);
8260
8390
  *
8261
8391
  * Default: No limit
8262
8392
  *
8393
+ * @prop {boolean} [mergeScripts]
8394
+ * When true, consecutive inline `<script>` elements are merged into one.
8395
+ * Only merges compatible scripts (same `type`, matching `async`/`defer`/
8396
+ * `nomodule`/`nonce` attributes). Does not merge external scripts (with `src`).
8397
+ *
8398
+ * Default: `false`
8399
+ *
8263
8400
  * @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
8264
8401
  * When true, enables CSS minification for inline `<style>` tags or
8265
8402
  * `style` attributes. If an object is provided, it is passed to
@@ -8838,7 +8975,7 @@ async function minifyHTML(value, options, partialMarkup) {
8838
8975
 
8839
8976
  function removeStartTag() {
8840
8977
  let index = buffer.length - 1;
8841
- while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
8978
+ while (index > 0 && !RE_START_TAG.test(buffer[index])) {
8842
8979
  index--;
8843
8980
  }
8844
8981
  buffer.length = Math.max(0, index);
@@ -8846,7 +8983,7 @@ async function minifyHTML(value, options, partialMarkup) {
8846
8983
 
8847
8984
  function removeEndTag() {
8848
8985
  let index = buffer.length - 1;
8849
- while (index > 0 && !/^<\//.test(buffer[index])) {
8986
+ while (index > 0 && !RE_END_TAG.test(buffer[index])) {
8850
8987
  index--;
8851
8988
  }
8852
8989
  buffer.length = Math.max(0, index);
@@ -9071,6 +9208,20 @@ async function minifyHTML(value, options, partialMarkup) {
9071
9208
  text = decodeHTML(text);
9072
9209
  }
9073
9210
  }
9211
+ // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
9212
+ // This removes trailing newlines often added by template engines before closing tags
9213
+ // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
9214
+ if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
9215
+ const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
9216
+ if (stackNoTrimWhitespace.includes('pre') || stackNoTrimWhitespace.includes('textarea')) {
9217
+ // Trim trailing whitespace only if it ends with a single newline (not multiple)
9218
+ // Multiple newlines are likely intentional formatting, single newline is often a template artifact
9219
+ // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
9220
+ if (nextTag && nextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
9221
+ text = text.replace(/(?:\r\n|\r|\n)[ \t]*$/, '');
9222
+ }
9223
+ }
9224
+ }
9074
9225
  if (options.collapseWhitespace) {
9075
9226
  if (!stackNoTrimWhitespace.length) {
9076
9227
  if (prevTag === 'comment') {
@@ -9143,8 +9294,8 @@ async function minifyHTML(value, options, partialMarkup) {
9143
9294
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
9144
9295
  if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
9145
9296
  // Escape any `&` symbols that start either:
9146
- // 1) a legacy-named character reference (i.e., one that doesn’t end with `;`)
9147
- // 2) or any other character reference (i.e., one that does end with `;`)
9297
+ // 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
9298
+ // 2. or any other character reference (i.e., one that does end with `;`)
9148
9299
  // Note that `&` can be escaped as `&amp`, without the semicolon.
9149
9300
  // https://mathiasbynens.be/notes/ambiguous-ampersands
9150
9301
  if (text.indexOf('&') !== -1) {
@@ -9363,7 +9514,13 @@ const minify$1 = async function (value, options) {
9363
9514
  jsMinifyCache,
9364
9515
  urlMinifyCache
9365
9516
  });
9366
- const result = await minifyHTML(value, options);
9517
+ let result = await minifyHTML(value, options);
9518
+
9519
+ // Post-processing: Merge consecutive inline scripts if enabled
9520
+ if (options.mergeScripts) {
9521
+ result = mergeConsecutiveScripts(result);
9522
+ }
9523
+
9367
9524
  options.log('minified in: ' + (Date.now() - start) + 'ms');
9368
9525
  return result;
9369
9526
  };
@@ -208,6 +208,14 @@ export type MinifierOptions = {
208
208
  * Default: No limit
209
209
  */
210
210
  maxLineLength?: number;
211
+ /**
212
+ * When true, consecutive inline `<script>` elements are merged into one.
213
+ * Only merges compatible scripts (same `type`, matching `async`/`defer`/
214
+ * `nomodule`/`nonce` attributes). Does not merge external scripts (with `src`).
215
+ *
216
+ * Default: `false`
217
+ */
218
+ mergeScripts?: boolean;
211
219
  /**
212
220
  * When true, enables CSS minification for inline `<style>` tags or
213
221
  * `style` attributes. If an object is provided, it is passed to
@@ -1 +1 @@
1
- {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../../../src/lib/attributes.js"],"names":[],"mappings":"AA0BA,yDAEC;AAED,mEAOC;AAED,uEAWC;AAED,8DAGC;AAED,4EAOC;AAED,mGAuCC;AAED,mEAGC;AAED,qEAGC;AAED,kEAWC;AAED,sEAGC;AAED,8DAWC;AAED,2EAEC;AAED,qEAaC;AAED,wEAUC;AAED,sEAUC;AAED,2EAEC;AAED,2DAEC;AAED,8DAUC;AAED,uEAUC;AAED,oGASC;AAED,4DAOC;AAID,0IAgJC;AAsBD;;;;GAwCC;AAED,6GAuHC"}
1
+ {"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../../../src/lib/attributes.js"],"names":[],"mappings":"AA2BA,yDAEC;AAED,mEAOC;AAED,uEAWC;AAED,8DAGC;AAED,4EAOC;AAED,mGAuCC;AAED,mEAGC;AAED,qEAGC;AAED,kEAWC;AAED,sEAGC;AAED,8DAWC;AAED,2EAIC;AAED,qEAaC;AAED,wEAUC;AAED,sEAUC;AAED,2EAEC;AAED,2DAEC;AAED,8DAUC;AAED,uEAUC;AAED,oGASC;AAED,4DAOC;AAID,0IAgJC;AAsBD;;;;GAwCC;AAED,6GAuHC"}
@@ -79,6 +79,7 @@ export const keepScriptsMimetypes: Set<string>;
79
79
  export const jsonScriptTypes: Set<string>;
80
80
  export const isSimpleBoolean: Set<string>;
81
81
  export const isBooleanValue: Set<string>;
82
+ export const emptyCollapsible: Set<string>;
82
83
  export const srcsetElements: Set<string>;
83
84
  export const optionalStartTags: Set<string>;
84
85
  export const optionalEndTags: Set<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/lib/constants.js"],"names":[],"mappings":"AAEA,iCAAoC;AACpC,+BAAkC;AAClC,oCAA2C;AAC3C,2CAAmD;AACnD,wCAA8C;AAC9C,4CAAkD;AAClD,4CAA2C;AAC3C,4CAA0D;AAC1D,2CAA8C;AAC9C,+CAA0D;AAC1D,2CAAmC;AACnC,mCAA4C;AAC5C,wCAAwqB;AACxqB,kCAA0B;AAC1B,sCAAuC;AACvC,yCAA4C;AAC5C,qCAAuD;AACvD,sCAAmE;AAKnE,+DAAgb;AAGhb,+DAA6O;AAG7O,yDAAmF;AAGnF,8CAA8G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0C9G,qDAWG;AAEH,+CAEG;AAcH,0CAUG;AApBH,0CAAwhB;AAExhB,yCAAkD;AAIlD,yCAAkD;AAuBlD,4CAAiF;AAEjF,0CAAoM;AAEpM,yCAA4F;AAE5F,8CAAkD;AAElD,yCAAiT;AAEjT,0CAA0F;AAE1F,6CAA8D;AAE9D,gDAAqD;AAErD,yCAAuD;AAEvD,+CAAyD;AAEzD,+CAAkE;AAElE,uCAA2C;AAE3C,2CAA2D;AAE3D,0CAAkD;AAElD,wCAA+D;AAE/D,2CAAkD;AAElD,uCAAmxC;AAInxC,sCAEsD;AAItD,iDAA4D"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/lib/constants.js"],"names":[],"mappings":"AAEA,iCAAoC;AACpC,+BAAkC;AAClC,oCAA2C;AAC3C,2CAAmD;AACnD,wCAA8C;AAC9C,4CAAkD;AAClD,4CAA2C;AAC3C,4CAA0D;AAC1D,2CAA8C;AAC9C,+CAA0D;AAC1D,2CAAmC;AACnC,mCAA4C;AAC5C,wCAAwqB;AACxqB,kCAA0B;AAC1B,sCAAuC;AACvC,yCAA4C;AAC5C,qCAAuD;AACvD,sCAAmE;AAKnE,+DAAgb;AAGhb,+DAA6O;AAG7O,yDAAmF;AAGnF,8CAA8G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0C9G,qDAWG;AAEH,+CAEG;AAmBH,0CAUG;AAzBH,0CAAwhB;AAExhB,yCAAkD;AAKlD,2CAAqE;AAIrE,yCAAkD;AAuBlD,4CAAiF;AAEjF,0CAAoM;AAEpM,yCAA4F;AAE5F,8CAAkD;AAElD,yCAAiT;AAEjT,0CAA0F;AAE1F,6CAA8D;AAE9D,gDAAqD;AAErD,yCAAuD;AAEvD,+CAAyD;AAEzD,+CAAkE;AAElE,uCAA2C;AAE3C,2CAA2D;AAE3D,0CAAkD;AAElD,wCAA+D;AAE/D,2CAAkD;AAElD,uCAAmxC;AAInxC,sCAEsD;AAItD,iDAA4D"}
@@ -1 +1 @@
1
- {"version":3,"file":"svg.d.ts","sourceRoot":"","sources":["../../../src/lib/svg.js"],"names":[],"mappings":"AAmVA;;;;;;GAMG;AACH,8CALW,MAAM,SACN,MAAM,kBAEJ,MAAM,CA0BlB;AAED;;;;;;;GAOG;AACH,8CANW,MAAM,QACN,MAAM,SACN,MAAM,kBAEJ,OAAO,CAanB;AAED;;;;GAIG;AACH,6DAkBC"}
1
+ {"version":3,"file":"svg.d.ts","sourceRoot":"","sources":["../../../src/lib/svg.js"],"names":[],"mappings":"AA0VA;;;;;;GAMG;AACH,8CALW,MAAM,SACN,MAAM,kBAEJ,MAAM,CA0BlB;AAED;;;;;;;GAOG;AACH,8CANW,MAAM,QACN,MAAM,SACN,MAAM,kBAEJ,OAAO,CAanB;AAED;;;;GAIG;AACH,6DAkBC"}
@@ -34,6 +34,7 @@ export namespace presets {
34
34
  export { continueOnParseError_1 as continueOnParseError };
35
35
  let decodeEntities_1: boolean;
36
36
  export { decodeEntities_1 as decodeEntities };
37
+ export let mergeScripts: boolean;
37
38
  export let minifyCSS: boolean;
38
39
  export let minifyJS: boolean;
39
40
  export let minifySVG: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../../src/presets.js"],"names":[],"mappings":"AAgDA;;;;GAIG;AACH,gCAHW,MAAM,GACJ,MAAM,GAAC,IAAI,CAMvB;AAED;;;GAGG;AACH,kCAFa,MAAM,EAAE,CAIpB"}
1
+ {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../../src/presets.js"],"names":[],"mappings":"AAiDA;;;;GAIG;AACH,gCAHW,MAAM,GACJ,MAAM,GAAC,IAAI,CAMvB;AAED;;;GAGG;AACH,kCAFa,MAAM,EAAE,CAIpB"}
package/package.json CHANGED
@@ -21,12 +21,12 @@
21
21
  "@rollup/plugin-json": "^6.1.0",
22
22
  "@rollup/plugin-node-resolve": "^16.0.3",
23
23
  "@rollup/plugin-terser": "^0.4.4",
24
- "@swc/core": "^1.15.7",
24
+ "@swc/core": "^1.15.8",
25
25
  "eslint": "^9.39.2",
26
- "rollup": "^4.54.0",
26
+ "rollup": "^4.55.1",
27
27
  "rollup-plugin-polyfill-node": "^0.13.0",
28
28
  "typescript": "^5.9.3",
29
- "vite": "^7.3.0"
29
+ "vite": "^7.3.1"
30
30
  },
31
31
  "exports": {
32
32
  ".": {
@@ -98,5 +98,5 @@
98
98
  },
99
99
  "type": "module",
100
100
  "types": "./dist/types/htmlminifier.d.ts",
101
- "version": "4.17.1"
101
+ "version": "4.18.0"
102
102
  }
@@ -92,11 +92,130 @@ async function getSwc() {
92
92
  }
93
93
 
94
94
  // Minification caches
95
-
96
95
  const cssMinifyCache = new LRU(500);
97
96
  const jsMinifyCache = new LRU(500);
98
97
  const urlMinifyCache = new LRU(500);
99
98
 
99
+ // Pre-compiled patterns for script merging (avoid repeated allocation in hot path)
100
+ const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
101
+ const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
102
+ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
103
+
104
+ // Pre-compiled patterns for buffer scanning
105
+ const RE_START_TAG = /^<[^/!]/;
106
+ const RE_END_TAG = /^<\//;
107
+
108
+ // Script merging
109
+
110
+ /**
111
+ * Merge consecutive inline script tags into one (`mergeConsecutiveScripts`).
112
+ * Only merges scripts that are compatible:
113
+ * - Both inline (no `src` attribute)
114
+ * - Same `type` (or both default JavaScript)
115
+ * - No conflicting attributes (`async`, `defer`, `nomodule`, different `nonce`)
116
+ *
117
+ * Limitation: This function uses regex-based matching (`pattern` variable below),
118
+ * which can produce incorrect results if a script’s content contains a literal
119
+ * `</script>` string (e.g., `document.write('<script>…</script>')`). In valid
120
+ * HTML, such strings should be escaped as `<\/script>` or split like
121
+ * `'</scr' + 'ipt>'`, so this limitation rarely affects real-world code. The
122
+ * earlier `minifyJS` step (if enabled) typically handles this escaping already.
123
+ *
124
+ * @param {string} html - The HTML string to process
125
+ * @returns {string} HTML with consecutive scripts merged
126
+ */
127
+ function mergeConsecutiveScripts(html) {
128
+ // `pattern`: Regex to match consecutive `</script>` followed by `<script…>`.
129
+ // See function JSDoc above for known limitations with literal `</script>` in content.
130
+ // Captures:
131
+ // 1. first script attrs
132
+ // 2. first script content
133
+ // 3. whitespace between
134
+ // 4. second script attrs
135
+ // 5. second script content
136
+ const pattern = /<script([^>]*)>([\s\S]*?)<\/script>([\s]*)<script([^>]*)>([\s\S]*?)<\/script>/gi;
137
+
138
+ let result = html;
139
+ let changed = true;
140
+
141
+ // Keep merging until no more changes (handles chains of 3+ scripts)
142
+ while (changed) {
143
+ changed = false;
144
+ result = result.replace(pattern, (match, attrs1, content1, whitespace, attrs2, content2) => {
145
+ // Parse attributes from both script tags (uses pre-compiled RE_SCRIPT_ATTRS)
146
+ const parseAttrs = (attrStr) => {
147
+ const attrs = {};
148
+ RE_SCRIPT_ATTRS.lastIndex = 0; // Reset for reuse
149
+ let m;
150
+ while ((m = RE_SCRIPT_ATTRS.exec(attrStr)) !== null) {
151
+ const name = m[1].toLowerCase();
152
+ const value = m[2] ?? m[3] ?? m[4] ?? '';
153
+ attrs[name] = value;
154
+ }
155
+ return attrs;
156
+ };
157
+
158
+ const a1 = parseAttrs(attrs1);
159
+ const a2 = parseAttrs(attrs2);
160
+
161
+ // Check for `src`—cannot merge external scripts
162
+ if ('src' in a1 || 'src' in a2) {
163
+ return match;
164
+ }
165
+
166
+ // Check `type` compatibility (both must be same, or both default JS)
167
+ const type1 = a1.type || '';
168
+ const type2 = a2.type || '';
169
+
170
+ if (DEFAULT_JS_TYPES.has(type1) && DEFAULT_JS_TYPES.has(type2)) {
171
+ // Both are default JavaScript—compatible
172
+ } else if (type1 === type2) {
173
+ // Same explicit type—compatible
174
+ } else {
175
+ // Incompatible types
176
+ return match;
177
+ }
178
+
179
+ // Check for conflicting boolean attributes (uses pre-compiled SCRIPT_BOOL_ATTRS)
180
+ for (const attr of SCRIPT_BOOL_ATTRS) {
181
+ const has1 = attr in a1;
182
+ const has2 = attr in a2;
183
+ if (has1 !== has2) {
184
+ // One has it, one doesn't - incompatible
185
+ return match;
186
+ }
187
+ }
188
+
189
+ // Check `nonce`—must be same or both absent
190
+ if (a1.nonce !== a2.nonce) {
191
+ return match;
192
+ }
193
+
194
+ // Scripts are compatible—merge them
195
+ changed = true;
196
+
197
+ // Combine content—use semicolon normally, newline only for trailing `//` comments
198
+ const c1 = content1.trim();
199
+ const c2 = content2.trim();
200
+ let mergedContent;
201
+ if (c1 && c2) {
202
+ // Check if last line of c1 contains `//` (single-line comment)
203
+ // If so, use newline to terminate it; otherwise use semicolon (if not already present)
204
+ const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
205
+ const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
206
+ mergedContent = c1 + separator + c2;
207
+ } else {
208
+ mergedContent = c1 || c2;
209
+ }
210
+
211
+ // Use first script’s attributes (they should be compatible)
212
+ return `<script${attrs1}>${mergedContent}</script>`;
213
+ });
214
+ }
215
+
216
+ return result;
217
+ }
218
+
100
219
  // Type definitions
101
220
 
102
221
  /**
@@ -277,6 +396,13 @@ const urlMinifyCache = new LRU(500);
277
396
  *
278
397
  * Default: No limit
279
398
  *
399
+ * @prop {boolean} [mergeScripts]
400
+ * When true, consecutive inline `<script>` elements are merged into one.
401
+ * Only merges compatible scripts (same `type`, matching `async`/`defer`/
402
+ * `nomodule`/`nonce` attributes). Does not merge external scripts (with `src`).
403
+ *
404
+ * Default: `false`
405
+ *
280
406
  * @prop {boolean | Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> | ((text: string, type?: string) => Promise<string> | string)} [minifyCSS]
281
407
  * When true, enables CSS minification for inline `<style>` tags or
282
408
  * `style` attributes. If an object is provided, it is passed to
@@ -855,7 +981,7 @@ async function minifyHTML(value, options, partialMarkup) {
855
981
 
856
982
  function removeStartTag() {
857
983
  let index = buffer.length - 1;
858
- while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
984
+ while (index > 0 && !RE_START_TAG.test(buffer[index])) {
859
985
  index--;
860
986
  }
861
987
  buffer.length = Math.max(0, index);
@@ -863,7 +989,7 @@ async function minifyHTML(value, options, partialMarkup) {
863
989
 
864
990
  function removeEndTag() {
865
991
  let index = buffer.length - 1;
866
- while (index > 0 && !/^<\//.test(buffer[index])) {
992
+ while (index > 0 && !RE_END_TAG.test(buffer[index])) {
867
993
  index--;
868
994
  }
869
995
  buffer.length = Math.max(0, index);
@@ -1088,6 +1214,20 @@ async function minifyHTML(value, options, partialMarkup) {
1088
1214
  text = decodeHTML(text);
1089
1215
  }
1090
1216
  }
1217
+ // Trim outermost newline-based whitespace inside `pre`/`textarea` elements
1218
+ // This removes trailing newlines often added by template engines before closing tags
1219
+ // Only trims single trailing newlines (multiple newlines are likely intentional formatting)
1220
+ if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
1221
+ const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
1222
+ if (stackNoTrimWhitespace.includes('pre') || stackNoTrimWhitespace.includes('textarea')) {
1223
+ // Trim trailing whitespace only if it ends with a single newline (not multiple)
1224
+ // Multiple newlines are likely intentional formatting, single newline is often a template artifact
1225
+ // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
1226
+ if (nextTag && nextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
1227
+ text = text.replace(/(?:\r\n|\r|\n)[ \t]*$/, '');
1228
+ }
1229
+ }
1230
+ }
1091
1231
  if (options.collapseWhitespace) {
1092
1232
  if (!stackNoTrimWhitespace.length) {
1093
1233
  if (prevTag === 'comment') {
@@ -1160,8 +1300,8 @@ async function minifyHTML(value, options, partialMarkup) {
1160
1300
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
1161
1301
  if (options.decodeEntities && text && !specialContentElements.has(currentTag)) {
1162
1302
  // Escape any `&` symbols that start either:
1163
- // 1) a legacy-named character reference (i.e., one that doesn’t end with `;`)
1164
- // 2) or any other character reference (i.e., one that does end with `;`)
1303
+ // 1. a legacy-named character reference (i.e., one that doesn’t end with `;`)
1304
+ // 2. or any other character reference (i.e., one that does end with `;`)
1165
1305
  // Note that `&` can be escaped as `&amp`, without the semicolon.
1166
1306
  // https://mathiasbynens.be/notes/ambiguous-ampersands
1167
1307
  if (text.indexOf('&') !== -1) {
@@ -1380,7 +1520,13 @@ export const minify = async function (value, options) {
1380
1520
  jsMinifyCache,
1381
1521
  urlMinifyCache
1382
1522
  });
1383
- const result = await minifyHTML(value, options);
1523
+ let result = await minifyHTML(value, options);
1524
+
1525
+ // Post-processing: Merge consecutive inline scripts if enabled
1526
+ if (options.mergeScripts) {
1527
+ result = mergeConsecutiveScripts(result);
1528
+ }
1529
+
1384
1530
  options.log('minified in: ' + (Date.now() - start) + 'ms');
1385
1531
  return result;
1386
1532
  };
@@ -15,6 +15,7 @@ import {
15
15
  keepScriptsMimetypes,
16
16
  isSimpleBoolean,
17
17
  isBooleanValue,
18
+ emptyCollapsible,
18
19
  srcsetElements,
19
20
  reEmptyAttribute
20
21
  } from './constants.js';
@@ -147,7 +148,9 @@ function isStyleElement(tag, attrs) {
147
148
  }
148
149
 
149
150
  function isBooleanAttribute(attrName, attrValue) {
150
- return isSimpleBoolean.has(attrName) || (attrName === 'draggable' && !isBooleanValue.has(attrValue));
151
+ return isSimpleBoolean.has(attrName) ||
152
+ (attrName === 'draggable' && !isBooleanValue.has(attrValue)) ||
153
+ (attrValue === '' && emptyCollapsible.has(attrName));
151
154
  }
152
155
 
153
156
  function isUriTypeAttribute(attrName, tag) {
@@ -96,6 +96,11 @@ const isSimpleBoolean = new Set(['allowfullscreen', 'async', 'autofocus', 'autop
96
96
 
97
97
  const isBooleanValue = new Set(['true', 'false']);
98
98
 
99
+ // Attributes where empty value can be collapsed to just the attribute name
100
+ // `crossorigin=""` → `crossorigin` (empty string equals anonymous mode)
101
+ // `contenteditable=""` → `contenteditable` (empty string equals `true`)
102
+ const emptyCollapsible = new Set(['crossorigin', 'contenteditable']);
103
+
99
104
  // `srcset` elements
100
105
 
101
106
  const srcsetElements = new Set(['img', 'source']);
@@ -206,6 +211,7 @@ export {
206
211
  // Boolean sets
207
212
  isSimpleBoolean,
208
213
  isBooleanValue,
214
+ emptyCollapsible,
209
215
 
210
216
  // Misc
211
217
  srcsetElements,
package/src/lib/svg.js CHANGED
@@ -115,7 +115,8 @@ function minifyNumber(num, precision = 3) {
115
115
  const fixed = parsed.toFixed(precision);
116
116
  const trimmed = fixed.replace(/\.?0+$/, '');
117
117
 
118
- const result = trimmed || '0';
118
+ // Remove leading zero before decimal point (e.g., `0.5` → `.5`, `-0.3` → `-.3`)
119
+ const result = (trimmed || '0').replace(/^(-?)0\./, '$1.');
119
120
  numberCache.set(cacheKey, result);
120
121
  return result;
121
122
  }
@@ -135,17 +136,23 @@ function minifyPathData(pathData, precision = 3) {
135
136
  });
136
137
 
137
138
  // Remove unnecessary spaces around path commands
138
- // Safe to remove space after a command letter when it’s followed by a number (which may be negative)
139
- // `M 10 20` `M10 20`, `L -5 -3` → `L-5-3`
140
- result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\d)/g, '$1');
139
+ // Safe to remove space after a command letter when it’s followed by a number
140
+ // (which may be negative or start with a decimal point)
141
+ // `M 10 20` → `M10 20`, `L -5 -3` → `L-5-3`, `M .5 .3` → `M.5.3`
142
+ result = result.replace(/([MLHVCSQTAZmlhvcsqtaz])\s+(?=-?\.?\d)/g, '$1');
141
143
 
142
144
  // Safe to remove space before command letter when preceded by a number
143
- // `0 L` → `0L`, `20 M` → `20M`
144
- result = result.replace(/(\d)\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
145
+ // `0 L` → `0L`, `20 M` → `20M`, `.5 L` → `.5L`
146
+ result = result.replace(/([\d.])\s+([MLHVCSQTAZmlhvcsqtaz])/g, '$1$2');
145
147
 
146
148
  // Safe to remove space before negative number when preceded by a number
147
- // `10 -20` → `10-20` (numbers are separated by the minus sign)
148
- result = result.replace(/(\d)\s+(-\d)/g, '$1$2');
149
+ // `10 -20` → `10-20`, `.5 -.3` → `.5-.3` (minus sign is always a separator)
150
+ result = result.replace(/([\d.])\s+(-)/g, '$1$2');
151
+
152
+ // Safe to remove space between two decimal numbers (decimal point acts as separator)
153
+ // `.5 .3` → `.5.3` (only when previous char is `.`, indicating a complete decimal)
154
+ // Note: `0 .3` must not become `0.3` (that would change two numbers into one)
155
+ result = result.replace(/(\.\d*)\s+(\.)/g, '$1$2');
149
156
 
150
157
  return result;
151
158
  }
package/src/presets.js CHANGED
@@ -27,6 +27,7 @@ export const presets = {
27
27
  collapseWhitespace: true,
28
28
  continueOnParseError: true,
29
29
  decodeEntities: true,
30
+ mergeScripts: true,
30
31
  minifyCSS: true,
31
32
  minifyJS: true,
32
33
  minifySVG: true,