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 +32 -31
- package/cli.js +1 -0
- package/dist/htmlminifier.cjs +172 -15
- package/dist/htmlminifier.esm.bundle.js +172 -15
- package/dist/types/htmlminifier.d.ts +8 -0
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +1 -0
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/svg.d.ts.map +1 -1
- package/dist/types/presets.d.ts +1 -0
- package/dist/types/presets.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/htmlminifier.js +152 -6
- package/src/lib/attributes.js +4 -1
- package/src/lib/constants.js +6 -0
- package/src/lib/svg.js +15 -8
- package/src/presets.js +1 -0
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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>[](https://socket.dev/npm/package/html-minifier-next) | [htmlnano](https://github.com/posthtml/htmlnano)<br>[](https://socket.dev/npm/package/htmlnano) | [@swc/html](https://github.com/swc-project/swc)<br>[](https://socket.dev/npm/package/@swc/html) | [minify-html](https://github.com/wilsonzlin/minify-html)<br>[](https://socket.dev/npm/package/@minify-html/node) | [minimize](https://github.com/Swaagie/minimize)<br>[](https://socket.dev/npm/package/minimize) | [htmlcompressor.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/) |
|
|
348
|
-
| [BBC](https://www.bbc.co.uk/) |
|
|
349
|
-
| [CERN](https://home.cern/) | 151 | **
|
|
350
|
-
| [CSS-Tricks](https://css-tricks.com/) | 161 | **119** |
|
|
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/) |
|
|
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/) |
|
|
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/) |
|
|
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/) |
|
|
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/) |
|
|
362
|
-
| [Leanpub](https://leanpub.com/) |
|
|
363
|
-
| [Mastodon](https://mastodon.social/explore) |
|
|
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/) |
|
|
366
|
-
| [Mistral AI](https://mistral.ai/) |
|
|
367
|
-
| [Mozilla](https://www.mozilla.org/) |
|
|
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/) |
|
|
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 |
|
|
372
|
-
| [TPGi](https://www.tpgi.com/) |
|
|
373
|
-
| [United Nations](https://www.un.org/en/) |
|
|
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/) |
|
|
376
|
-
| **Average processing time** | |
|
|
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
|
|
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],
|
package/dist/htmlminifier.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1472
|
-
//
|
|
1473
|
-
|
|
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` (
|
|
1481
|
-
result = result.replace(/(\d)\s+(
|
|
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) ||
|
|
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 &&
|
|
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 &&
|
|
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
|
|
4005
|
-
// 2
|
|
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 `&`, 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
|
-
|
|
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
|
-
|
|
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
|
|
6614
|
-
//
|
|
6615
|
-
|
|
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` (
|
|
6623
|
-
result = result.replace(/(\d)\s+(
|
|
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) ||
|
|
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 &&
|
|
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 &&
|
|
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
|
|
9147
|
-
// 2
|
|
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 `&`, 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
|
-
|
|
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":"
|
|
1
|
+
{"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AAw+CO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAqB3B;;;;;;;;;;;;UA3xCS,MAAM;YACN,MAAM;YACN,MAAM;mBACN,MAAM;iBACN,MAAM;kBACN,MAAM;;;;;;;;;;;;;4BAQN,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;wBAMjG,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,SAAS,EAAE,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;;oBAMhH,OAAO;;;;;;;;;kCAON,OAAO;;;;;;;;gCAQR,OAAO;;;;;;;;kCAOP,OAAO;;;;;;;;yBAOP,OAAO;;;;;;;;2BAOP,OAAO;;;;;;;;4BAOP,OAAO;;;;;;;2BAOP,OAAO;;;;;;;;uBAMP,MAAM,EAAE;;;;;;yBAOR,MAAM;;;;;;yBAKN,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;;;;;;;4BAKlB,MAAM,EAAE;;;;;;;oCAMR,MAAM;;;;;;;qBAMN,OAAO;;;;;;;YAMP,OAAO;;;;;;;;2BAMP,MAAM,EAAE;;;;;;;;;4BAOR,MAAM,EAAE;;;;;;;+BAQR,OAAO;;;;;;;2BAMP,SAAS,CAAC,MAAM,CAAC;;;;;;uBAMjB,OAAO;;;;;;;;UAKP,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI;;;;;;;;qBAO1B,MAAM;;;;;;;oBAON,MAAM;;;;;;;;mBAMN,OAAO;;;;;;;;;;gBAOP,OAAO,GAAG,OAAO,CAAC,OAAO,cAAc,EAAE,gBAAgB,CAAC,OAAO,cAAc,EAAE,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;;;;;eAS9J,OAAO,GAAG,OAAO,QAAQ,EAAE,aAAa,GAAG;QAAC,MAAM,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAC,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;iBAa3J,OAAO,GAAG,MAAM,GAAG,OAAO,WAAW,EAAE,OAAO,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;;gBAS7F,OAAO,GAAG;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,OAAO,CAAA;KAAC;;;;;;;;WAUhF,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM;;;;;;;+BAOxB,OAAO;;;;;;;;;;oBAMP,OAAO;;;;;;;;yBASP,OAAO;;;;;;;gCAOP,OAAO;;;;;;;;iCAMP,OAAO;;;;;;;;;;qBAOP,MAAM,EAAE;;;;;;;qBASR,IAAI,GAAG,GAAG;;;;;;;4BAMV,OAAO;;;;;;;;qBAMP,OAAO;;;;;;;;;4BAOP,OAAO,GAAG,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;;;;;;;;0BAQtD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;gCAOP,MAAM,EAAE;;;;;;;;yBAyBR,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;iCAOP,OAAO;;;;;;;oCAMP,OAAO;;;;;;;;;;0BAMP,OAAO;;;;;;;;;qBASP,OAAO,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;;;;;;;;;oBAQzD,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;;;;;;;;0BAQrC,OAAO;;;;;;;sBAOP,OAAO;;wBAjmBkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attributes.d.ts","sourceRoot":"","sources":["../../../src/lib/attributes.js"],"names":[],"mappings":"
|
|
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;
|
|
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":"
|
|
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"}
|
package/dist/types/presets.d.ts
CHANGED
|
@@ -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":"
|
|
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.
|
|
24
|
+
"@swc/core": "^1.15.8",
|
|
25
25
|
"eslint": "^9.39.2",
|
|
26
|
-
"rollup": "^4.
|
|
26
|
+
"rollup": "^4.55.1",
|
|
27
27
|
"rollup-plugin-polyfill-node": "^0.13.0",
|
|
28
28
|
"typescript": "^5.9.3",
|
|
29
|
-
"vite": "^7.3.
|
|
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.
|
|
101
|
+
"version": "4.18.0"
|
|
102
102
|
}
|
package/src/htmlminifier.js
CHANGED
|
@@ -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 &&
|
|
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 &&
|
|
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
|
|
1164
|
-
// 2
|
|
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 `&`, 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
|
-
|
|
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
|
};
|
package/src/lib/attributes.js
CHANGED
|
@@ -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) ||
|
|
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) {
|
package/src/lib/constants.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
139
|
-
//
|
|
140
|
-
|
|
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` (
|
|
148
|
-
result = result.replace(/(\d)\s+(
|
|
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
|
}
|