html-minifier-next 5.0.1 → 5.0.3
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 +25 -25
- package/cli.js +26 -67
- package/dist/htmlminifier.cjs +107 -24
- package/dist/htmlminifier.esm.bundle.js +107 -24
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +9 -0
- package/dist/types/lib/attributes.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts.map +1 -1
- package/dist/types/lib/option-definitions.d.ts +293 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts.map +1 -1
- package/dist/types/lib/utils.d.ts +2 -0
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/htmlminifier.js +33 -4
- package/src/lib/attributes.js +32 -1
- package/src/lib/elements.js +10 -0
- package/src/lib/option-definitions.js +207 -0
- package/src/lib/options.js +4 -22
- package/src/lib/utils.js +22 -2
package/README.md
CHANGED
|
@@ -402,39 +402,39 @@ How does HTML Minifier Next compare to other minifiers? (All minification with t
|
|
|
402
402
|
<!-- Auto-generated benchmarks, don’t edit -->
|
|
403
403
|
| 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/) |
|
|
404
404
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
405
|
-
| [A List Apart](https://alistapart.com/) |
|
|
406
|
-
| [Apple](https://www.apple.com/) |
|
|
407
|
-
| [BBC](https://www.bbc.co.uk/) |
|
|
408
|
-
| [CERN](https://home.cern/) |
|
|
409
|
-
| [CSS-Tricks](https://css-tricks.com/) |
|
|
410
|
-
| [ECMAScript](https://tc39.es/ecma262/) | 7261 | **
|
|
411
|
-
| [EDRi](https://edri.org/) | 80 | **
|
|
412
|
-
| [EFF](https://www.eff.org/) | 54 | **
|
|
405
|
+
| [A List Apart](https://alistapart.com/) | 63 | **53** | 55 | 56 | 55 | 58 | 56 |
|
|
406
|
+
| [Apple](https://www.apple.com/) | 236 | **198** | 209 | 212 | 213 | 215 | 215 |
|
|
407
|
+
| [BBC](https://www.bbc.co.uk/) | 651 | **605** | 611 | 612 | 613 | 646 | n/a |
|
|
408
|
+
| [CERN](https://home.cern/) | 150 | **80** | 90 | 90 | 90 | 92 | 95 |
|
|
409
|
+
| [CSS-Tricks](https://css-tricks.com/) | 155 | 127 | **121** | 136 | 137 | 141 | 138 |
|
|
410
|
+
| [ECMAScript](https://tc39.es/ecma262/) | 7261 | **6447** | 6583 | 6465 | 6589 | 6637 | n/a |
|
|
411
|
+
| [EDRi](https://edri.org/) | 80 | **68** | 69 | 69 | 71 | 74 | 72 |
|
|
412
|
+
| [EFF](https://www.eff.org/) | 54 | **45** | 49 | 47 | 48 | 49 | 49 |
|
|
413
413
|
| [European Alternatives](https://european-alternatives.eu/) | 48 | **30** | 32 | 32 | 32 | 32 | 32 |
|
|
414
|
-
| [FAZ](https://www.faz.net/aktuell/) |
|
|
414
|
+
| [FAZ](https://www.faz.net/aktuell/) | 1519 | 1389 | **1364** | 1446 | 1457 | 1467 | n/a |
|
|
415
415
|
| [French Tech](https://lafrenchtech.gouv.fr/) | 153 | **122** | 126 | 126 | 126 | 132 | 127 |
|
|
416
|
-
| [Frontend Dogma](https://frontenddogma.com/) | 227 | **219** |
|
|
417
|
-
| [Google](https://www.google.com/) | 18 | **16** |
|
|
418
|
-
| [Ground News](https://ground.news/) |
|
|
416
|
+
| [Frontend Dogma](https://frontenddogma.com/) | 227 | **219** | 240 | 225 | 227 | 246 | 226 |
|
|
417
|
+
| [Google](https://www.google.com/) | 18 | **16** | **16** | **16** | 17 | 18 | 18 |
|
|
418
|
+
| [Ground News](https://ground.news/) | 1465 | **1328** | 1350 | 1372 | 1377 | 1453 | n/a |
|
|
419
419
|
| [HTML Living Standard](https://html.spec.whatwg.org/multipage/) | 149 | 148 | 153 | **147** | 149 | 155 | 149 |
|
|
420
|
-
| [Igalia](https://www.igalia.com/) | 49 | **
|
|
421
|
-
| [Leanpub](https://leanpub.com/) | 241 | **
|
|
422
|
-
| [Mastodon](https://mastodon.social/explore) | 38 |
|
|
423
|
-
| [MDN](https://developer.mozilla.org/en-US/) | 109 | **
|
|
424
|
-
| [Middle East Eye](https://www.middleeasteye.net/) |
|
|
425
|
-
| [Mistral AI](https://mistral.ai/) | 343 | **
|
|
420
|
+
| [Igalia](https://www.igalia.com/) | 49 | **34** | 36 | 36 | 36 | 37 | 37 |
|
|
421
|
+
| [Leanpub](https://leanpub.com/) | 241 | **224** | 226 | 226 | 227 | 236 | 238 |
|
|
422
|
+
| [Mastodon](https://mastodon.social/explore) | 38 | 35 | **32** | 35 | 36 | 37 | 37 |
|
|
423
|
+
| [MDN](https://developer.mozilla.org/en-US/) | 109 | **63** | 64 | 65 | 65 | 68 | 68 |
|
|
424
|
+
| [Middle East Eye](https://www.middleeasteye.net/) | 220 | **194** | 200 | 198 | 198 | 199 | 200 |
|
|
425
|
+
| [Mistral AI](https://mistral.ai/) | 343 | **307** | **307** | 310 | 311 | 340 | n/a |
|
|
426
426
|
| [Mozilla](https://www.mozilla.org/) | 47 | **32** | 35 | 35 | 35 | 36 | 36 |
|
|
427
|
-
| [Nielsen Norman Group](https://www.nngroup.com/) | 97 |
|
|
428
|
-
| [SitePoint](https://www.sitepoint.com/) | 494 |
|
|
427
|
+
| [Nielsen Norman Group](https://www.nngroup.com/) | 97 | 73 | **59** | 78 | 80 | 81 | 81 |
|
|
428
|
+
| [SitePoint](https://www.sitepoint.com/) | 494 | 456 | **431** | 468 | 473 | 491 | n/a |
|
|
429
429
|
| [Startup-Verband](https://startupverband.de/) | 43 | **30** | 31 | **30** | 31 | 31 | 31 |
|
|
430
|
-
| [TetraLogical](https://tetralogical.com/) | 59 |
|
|
430
|
+
| [TetraLogical](https://tetralogical.com/) | 59 | 52 | **49** | 51 | 53 | 53 | 53 |
|
|
431
431
|
| [TPGi](https://www.tpgi.com/) | 173 | **157** | 159 | 163 | 164 | 170 | 170 |
|
|
432
|
-
| [United Nations](https://www.un.org/en/) | 151 | **113** | 121 | 125 |
|
|
433
|
-
| [Vivaldi](https://vivaldi.com/) | 93 | **
|
|
432
|
+
| [United Nations](https://www.un.org/en/) | 151 | **113** | 121 | 125 | 124 | 130 | 123 |
|
|
433
|
+
| [Vivaldi](https://vivaldi.com/) | 93 | **76** | n/a | 79 | 81 | 84 | 82 |
|
|
434
434
|
| [W3C](https://www.w3.org/) | 50 | **36** | 39 | 38 | 38 | 41 | 39 |
|
|
435
|
-
| **Average processing time** | |
|
|
435
|
+
| **Average processing time** | | 83 ms (30/30) | 165 ms (29/30) | 47 ms (30/30) | **13 ms (30/30)** | 283 ms (30/30) | 1254 ms (24/30) |
|
|
436
436
|
|
|
437
|
-
(Last updated: Feb
|
|
437
|
+
(Last updated: Feb 4, 2026)
|
|
438
438
|
<!-- End auto-generated -->
|
|
439
439
|
|
|
440
440
|
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
|
@@ -41,6 +41,8 @@ const camelCase = (str) => paramCase(str).replace(/-([a-z])/g, (_, c) => c.toUpp
|
|
|
41
41
|
|
|
42
42
|
// Lazy-load HMN to reduce CLI cold-start overhead
|
|
43
43
|
import { getPreset, getPresetNames } from './src/presets.js';
|
|
44
|
+
import { parseRegExp } from './src/lib/utils.js';
|
|
45
|
+
import { optionDefinitions } from './src/lib/option-definitions.js';
|
|
44
46
|
|
|
45
47
|
const require = createRequire(import.meta.url);
|
|
46
48
|
const pkg = require('./package.json');
|
|
@@ -49,7 +51,6 @@ const DEFAULT_FILE_EXTENSIONS = ['html', 'htm', 'xhtml', 'shtml'];
|
|
|
49
51
|
|
|
50
52
|
const program = new Command();
|
|
51
53
|
program.name(pkg.name);
|
|
52
|
-
program.version(pkg.version);
|
|
53
54
|
|
|
54
55
|
function fatal(message) {
|
|
55
56
|
console.error(message);
|
|
@@ -82,11 +83,6 @@ process.stdout.on('error', (err) => {
|
|
|
82
83
|
*
|
|
83
84
|
* --customAttrSurround "[\"//matchString//\"]"
|
|
84
85
|
*/
|
|
85
|
-
function parseRegExp(value) {
|
|
86
|
-
if (value) {
|
|
87
|
-
return new RegExp(value.replace(/^\/(.*)\/$/, '$1'));
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
86
|
|
|
91
87
|
function parseJSON(value) {
|
|
92
88
|
if (value) {
|
|
@@ -125,68 +121,28 @@ const parseValidInt = (optionName) => (value) => {
|
|
|
125
121
|
return num;
|
|
126
122
|
};
|
|
127
123
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
continueOnParseError: 'Handle parse errors instead of aborting',
|
|
137
|
-
customAttrAssign: ['Array of regexes that allow to support custom attribute assign expressions (e.g., `<div flex?="{{mode != cover}}"></div>`)', parseJSONRegExpArray],
|
|
138
|
-
customAttrCollapse: ['Regex that specifies custom attribute to strip newlines from (e.g., /ng-class/)', parseRegExp],
|
|
139
|
-
customAttrSurround: ['Array of regexes that allow to support custom attribute surround expressions (e.g., `<input {{#if value}}checked="checked"{{/if}}>`)', parseJSONRegExpArray],
|
|
140
|
-
customEventAttributes: ['Array of regexes that allow to support custom event attributes for minifyJS (e.g., `ng-click`)', parseJSONRegExpArray],
|
|
141
|
-
customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
|
|
142
|
-
decodeEntities: 'Use direct Unicode characters whenever possible',
|
|
143
|
-
ignoreCustomComments: ['Array of regexes that allow to ignore matching comments', parseJSONRegExpArray],
|
|
144
|
-
ignoreCustomFragments: ['Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`)', parseJSONRegExpArray],
|
|
145
|
-
includeAutoGeneratedTags: 'Insert elements generated by HTML parser',
|
|
146
|
-
inlineCustomElements: ['Array of names of custom elements which are inline, for whitespace handling', parseJSONArray],
|
|
147
|
-
keepClosingSlash: 'Keep the trailing slash on void elements',
|
|
148
|
-
maxInputLength: ['Maximum input length to prevent ReDoS attacks', parseValidInt('maxInputLength')],
|
|
149
|
-
maxLineLength: ['Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points', parseValidInt('maxLineLength')],
|
|
150
|
-
mergeScripts: 'Merge consecutive inline `script` elements into one',
|
|
151
|
-
minifyCSS: ['Minify CSS in `style` elements and attributes (uses Lightning CSS)', parseJSON],
|
|
152
|
-
minifyJS: ['Minify JavaScript in `script` elements and event attributes (uses Terser or SWC; pass `{"engine": "swc"}` for SWC)', parseJSON],
|
|
153
|
-
minifySVG: ['Minify SVG elements and attributes (numeric precision, default attributes, colors)', parseJSON],
|
|
154
|
-
minifyURLs: ['Minify URLs in various attributes', parseJSON],
|
|
155
|
-
noNewlinesBeforeTagClose: 'Never add a newline before a tag that closes an element',
|
|
156
|
-
partialMarkup: 'Treat input as a partial HTML fragment, preserving stray end tags and unclosed tags',
|
|
157
|
-
preserveLineBreaks: 'Always collapse to one line break (never remove it entirely) when whitespace between tags includes a line break—use with `--collapse-whitespace`',
|
|
158
|
-
preventAttributesEscaping: 'Prevents the escaping of the values of attributes',
|
|
159
|
-
processConditionalComments: 'Process contents of conditional comments through minifier',
|
|
160
|
-
processScripts: ['Array of strings corresponding to types of `script` elements to process through minifier (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.)', parseJSONArray],
|
|
161
|
-
quoteCharacter: ['Type of quote to use for attribute values (“\'” or “"”)', parseString],
|
|
162
|
-
removeAttributeQuotes: 'Remove quotes around attributes when possible',
|
|
163
|
-
removeComments: 'Strip HTML comments',
|
|
164
|
-
removeEmptyAttributes: 'Remove all attributes with whitespace-only values',
|
|
165
|
-
removeEmptyElements: 'Remove all elements with empty contents',
|
|
166
|
-
removeEmptyElementsExcept: ['Array of elements to preserve when `--remove-empty-elements` is enabled (e.g., `td`, `["td", "<span aria-hidden=\'true\'>"]`)', parseJSONArray],
|
|
167
|
-
removeOptionalTags: 'Remove unrequired tags',
|
|
168
|
-
removeRedundantAttributes: 'Remove attributes when value matches default',
|
|
169
|
-
removeScriptTypeAttributes: 'Remove `type="text/javascript"` from `script` elements; other `type` attribute values are left intact',
|
|
170
|
-
removeStyleLinkTypeAttributes: 'Remove `type="text/css"` from `style` and `link` elements; other `type` attribute values are left intact',
|
|
171
|
-
removeTagWhitespace: 'Remove space between attributes whenever possible; note that this will result in invalid HTML',
|
|
172
|
-
sortAttributes: 'Sort attributes by frequency',
|
|
173
|
-
sortClassNames: 'Sort style classes by frequency',
|
|
174
|
-
trimCustomFragments: 'Trim whitespace around custom fragments (`--ignore-custom-fragments`)',
|
|
175
|
-
useShortDoctype: 'Replaces the doctype with the short HTML doctype'
|
|
124
|
+
// Map option types to CLI parsers
|
|
125
|
+
const typeParsers = {
|
|
126
|
+
regexp: parseRegExp,
|
|
127
|
+
regexpArray: parseJSONRegExpArray,
|
|
128
|
+
json: parseJSON,
|
|
129
|
+
jsonArray: parseJSONArray,
|
|
130
|
+
string: parseString,
|
|
131
|
+
int: (key) => parseValidInt(key)
|
|
176
132
|
};
|
|
177
133
|
|
|
178
|
-
// Configure command-line flags
|
|
179
|
-
const mainOptionKeys = Object.keys(
|
|
134
|
+
// Configure command-line flags from shared option definitions
|
|
135
|
+
const mainOptionKeys = Object.keys(optionDefinitions);
|
|
180
136
|
mainOptionKeys.forEach(function (key) {
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
program.option(key,
|
|
186
|
-
} else if (key === 'continueOnMinifyError') {
|
|
187
|
-
program.option('--no-' + paramCase(key), option);
|
|
137
|
+
const { description, type } = optionDefinitions[key];
|
|
138
|
+
if (type === 'invertedBoolean') {
|
|
139
|
+
program.option('--no-' + paramCase(key), description);
|
|
140
|
+
} else if (type === 'boolean') {
|
|
141
|
+
program.option('--' + paramCase(key), description);
|
|
188
142
|
} else {
|
|
189
|
-
|
|
143
|
+
const flag = '--' + paramCase(key) + (type === 'json' ? ' [value]' : ' <value>');
|
|
144
|
+
const parser = type === 'int' ? typeParsers.int(key) : typeParsers[type];
|
|
145
|
+
program.option(flag, description, parser);
|
|
190
146
|
}
|
|
191
147
|
});
|
|
192
148
|
program.option('-o --output <file>', 'Specify output file (reads from file arguments or STDIN; outputs to STDOUT if not specified)');
|
|
@@ -252,10 +208,11 @@ function normalizeConfig(config) {
|
|
|
252
208
|
// Apply parsers to main options
|
|
253
209
|
mainOptionKeys.forEach(function (key) {
|
|
254
210
|
if (key in normalized) {
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
211
|
+
const { type } = optionDefinitions[key];
|
|
212
|
+
if (type !== 'boolean' && type !== 'invertedBoolean') {
|
|
213
|
+
const parser = type === 'int' ? typeParsers.int(key) : typeParsers[type];
|
|
257
214
|
const value = normalized[key];
|
|
258
|
-
normalized[key] =
|
|
215
|
+
normalized[key] = parser(typeof value === 'string' ? value : JSON.stringify(value));
|
|
259
216
|
}
|
|
260
217
|
}
|
|
261
218
|
});
|
|
@@ -288,6 +245,8 @@ program.option('-p --preset <name>', `Use a preset configuration (${getPresetNam
|
|
|
288
245
|
program.option('-c --config-file <file>', 'Use config file');
|
|
289
246
|
program.option('--cache-css <size>', 'Set CSS minification cache size (number of entries, default: 500)', parseValidInt('cacheCSS'));
|
|
290
247
|
program.option('--cache-js <size>', 'Set JavaScript minification cache size (number of entries, default: 500)', parseValidInt('cacheJS'));
|
|
248
|
+
program.version(pkg.version, '-V, --version', 'Output the version number');
|
|
249
|
+
program.helpOption('-h, --help', 'Display help for command');
|
|
291
250
|
|
|
292
251
|
(async () => {
|
|
293
252
|
let content;
|
package/dist/htmlminifier.cjs
CHANGED
|
@@ -966,7 +966,7 @@ function uniqueId(value) {
|
|
|
966
966
|
return id;
|
|
967
967
|
}
|
|
968
968
|
|
|
969
|
-
// Identity functions
|
|
969
|
+
// Identity and transform functions
|
|
970
970
|
|
|
971
971
|
function identity(value) {
|
|
972
972
|
return value;
|
|
@@ -976,6 +976,10 @@ function identityAsync(value) {
|
|
|
976
976
|
return Promise.resolve(value);
|
|
977
977
|
}
|
|
978
978
|
|
|
979
|
+
function lowercase(value) {
|
|
980
|
+
return value.toLowerCase();
|
|
981
|
+
}
|
|
982
|
+
|
|
979
983
|
// Replace async helper
|
|
980
984
|
|
|
981
985
|
/**
|
|
@@ -997,6 +1001,20 @@ async function replaceAsync(str, regex, asyncFn) {
|
|
|
997
1001
|
return str.replace(regex, () => data.shift());
|
|
998
1002
|
}
|
|
999
1003
|
|
|
1004
|
+
// String patterns to RegExp conversion (for JSON config support)
|
|
1005
|
+
|
|
1006
|
+
function parseRegExp(value) {
|
|
1007
|
+
if (typeof value === 'string') {
|
|
1008
|
+
if (!value) return undefined; // Empty string = not configured
|
|
1009
|
+
const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
|
|
1010
|
+
if (match) {
|
|
1011
|
+
return new RegExp(match[1], match[2]);
|
|
1012
|
+
}
|
|
1013
|
+
return new RegExp(value);
|
|
1014
|
+
}
|
|
1015
|
+
return value;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1000
1018
|
// Regex patterns (to avoid repeated allocations in hot paths)
|
|
1001
1019
|
|
|
1002
1020
|
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
@@ -2041,6 +2059,22 @@ function getSVGMinifierOptions(userOptions) {
|
|
|
2041
2059
|
return null;
|
|
2042
2060
|
}
|
|
2043
2061
|
|
|
2062
|
+
// Single source of truth for minifier option names, descriptions, types, and shared defaults
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
const optionDefaults = {
|
|
2066
|
+
continueOnMinifyError: true,
|
|
2067
|
+
ignoreCustomComments: [
|
|
2068
|
+
/^!/,
|
|
2069
|
+
/^\s*#/
|
|
2070
|
+
],
|
|
2071
|
+
ignoreCustomFragments: [
|
|
2072
|
+
/<%[\s\S]*?%>/,
|
|
2073
|
+
/<\?[\s\S]*?\?>/
|
|
2074
|
+
],
|
|
2075
|
+
includeAutoGeneratedTags: false
|
|
2076
|
+
};
|
|
2077
|
+
|
|
2044
2078
|
// Imports
|
|
2045
2079
|
|
|
2046
2080
|
|
|
@@ -2072,21 +2106,10 @@ function shouldMinifyInnerHTML(options) {
|
|
|
2072
2106
|
*/
|
|
2073
2107
|
const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssMinifyCache, jsMinifyCache } = {}) => {
|
|
2074
2108
|
const options = {
|
|
2075
|
-
name:
|
|
2076
|
-
return name.toLowerCase();
|
|
2077
|
-
},
|
|
2109
|
+
name: lowercase,
|
|
2078
2110
|
canCollapseWhitespace,
|
|
2079
2111
|
canTrimWhitespace,
|
|
2080
|
-
|
|
2081
|
-
ignoreCustomComments: [
|
|
2082
|
-
/^!/,
|
|
2083
|
-
/^\s*#/
|
|
2084
|
-
],
|
|
2085
|
-
ignoreCustomFragments: [
|
|
2086
|
-
/<%[\s\S]*?%>/,
|
|
2087
|
-
/<\?[\s\S]*?\?>/
|
|
2088
|
-
],
|
|
2089
|
-
includeAutoGeneratedTags: false,
|
|
2112
|
+
...optionDefaults,
|
|
2090
2113
|
log: identity,
|
|
2091
2114
|
minifyCSS: identityAsync,
|
|
2092
2115
|
minifyJS: identity,
|
|
@@ -2094,14 +2117,6 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
|
|
|
2094
2117
|
minifySVG: null
|
|
2095
2118
|
};
|
|
2096
2119
|
|
|
2097
|
-
// Helper to convert string patterns to RegExp (for JSON config support)
|
|
2098
|
-
const parseRegExp = (value) => {
|
|
2099
|
-
if (typeof value === 'string') {
|
|
2100
|
-
return new RegExp(value.replace(/^\/(.*)\/$/, '$1'));
|
|
2101
|
-
}
|
|
2102
|
-
return value; // Already a RegExp or another type
|
|
2103
|
-
};
|
|
2104
|
-
|
|
2105
2120
|
const parseRegExpArray = (arr) => {
|
|
2106
2121
|
return Array.isArray(arr) ? arr.map(parseRegExp) : arr;
|
|
2107
2122
|
};
|
|
@@ -2456,6 +2471,36 @@ function attributesInclude(attributes, attribute) {
|
|
|
2456
2471
|
return false;
|
|
2457
2472
|
}
|
|
2458
2473
|
|
|
2474
|
+
/**
|
|
2475
|
+
* Remove duplicate attributes from an attribute list.
|
|
2476
|
+
* Per HTML spec, when an attribute appears multiple times, the first occurrence wins.
|
|
2477
|
+
* Duplicate attributes result in invalid HTML, so we keep only the first.
|
|
2478
|
+
* @param {Array} attrs - Array of attribute objects with `name` property
|
|
2479
|
+
* @param {boolean} caseSensitive - Whether to compare names case-sensitively (for XML/SVG)
|
|
2480
|
+
* @returns {Array} Deduplicated attribute array (modifies in place and returns)
|
|
2481
|
+
*/
|
|
2482
|
+
function deduplicateAttributes(attrs, caseSensitive) {
|
|
2483
|
+
if (attrs.length < 2) {
|
|
2484
|
+
return attrs;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
const seen = new Set();
|
|
2488
|
+
let writeIndex = 0;
|
|
2489
|
+
|
|
2490
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
2491
|
+
const attr = attrs[i];
|
|
2492
|
+
const key = caseSensitive ? attr.name : attr.name.toLowerCase();
|
|
2493
|
+
|
|
2494
|
+
if (!seen.has(key)) {
|
|
2495
|
+
seen.add(key);
|
|
2496
|
+
attrs[writeIndex++] = attr;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
attrs.length = writeIndex;
|
|
2501
|
+
return attrs;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2459
2504
|
function isAttributeRedundant(tag, attrName, attrValue, attrs) {
|
|
2460
2505
|
// Fast-path: Check if this element–attribute combination can possibly be redundant
|
|
2461
2506
|
// before doing expensive string operations
|
|
@@ -3036,6 +3081,16 @@ function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
|
3036
3081
|
// Element removal logic
|
|
3037
3082
|
|
|
3038
3083
|
function canRemoveElement(tag, attrs) {
|
|
3084
|
+
// Elements with `id` attribute must never be removed—they serve as:
|
|
3085
|
+
// - Navigation targets (skip links, URL fragments)
|
|
3086
|
+
// - JavaScript selector targets (`getElementById`, `querySelector`)
|
|
3087
|
+
// - CSS targets (`:target` pseudo-class, ID selectors)
|
|
3088
|
+
// - Accessibility landmarks (ARIA references)
|
|
3089
|
+
// - Portal mount points (React portals, etc.)
|
|
3090
|
+
if (hasAttrName('id', attrs)) {
|
|
3091
|
+
return false;
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3039
3094
|
switch (tag) {
|
|
3040
3095
|
case 'textarea':
|
|
3041
3096
|
return false;
|
|
@@ -3231,6 +3286,9 @@ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript
|
|
|
3231
3286
|
const RE_START_TAG = /^<[^/!]/;
|
|
3232
3287
|
const RE_END_TAG = /^<\//;
|
|
3233
3288
|
|
|
3289
|
+
// HTML encoding types for annotation-xml (MathML)
|
|
3290
|
+
const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
|
|
3291
|
+
|
|
3234
3292
|
// Script merging
|
|
3235
3293
|
|
|
3236
3294
|
/**
|
|
@@ -4174,8 +4232,24 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
4174
4232
|
options.keepClosingSlash = true;
|
|
4175
4233
|
options.name = identity;
|
|
4176
4234
|
options.insideSVG = lowerTag === 'svg';
|
|
4235
|
+
options.insideForeignContent = true;
|
|
4177
4236
|
}
|
|
4178
|
-
|
|
4237
|
+
// `foreignObject` in SVG and `annotation-xml` in MathML contain HTML content
|
|
4238
|
+
// Note: The element itself is in SVG/MathML namespace, only its children are HTML
|
|
4239
|
+
let useParentNameForTag = false;
|
|
4240
|
+
if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
|
|
4241
|
+
(lowerTag === 'annotation-xml' && attrs.some(a => a.name.toLowerCase() === 'encoding' &&
|
|
4242
|
+
RE_HTML_ENCODING.test(a.value))))) {
|
|
4243
|
+
const parentName = options.name;
|
|
4244
|
+
options = Object.create(options);
|
|
4245
|
+
options.caseSensitive = false;
|
|
4246
|
+
options.keepClosingSlash = false;
|
|
4247
|
+
options.parentName = parentName; // Preserve for the element tag itself
|
|
4248
|
+
options.name = options.htmlName || lowercase;
|
|
4249
|
+
options.insideForeignContent = false;
|
|
4250
|
+
useParentNameForTag = true;
|
|
4251
|
+
}
|
|
4252
|
+
tag = (useParentNameForTag ? options.parentName : options.name)(tag);
|
|
4179
4253
|
currentTag = tag;
|
|
4180
4254
|
charsPrevTag = tag;
|
|
4181
4255
|
if (!inlineTextSet.has(tag)) {
|
|
@@ -4229,6 +4303,11 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
4229
4303
|
|
|
4230
4304
|
buffer.push(openTag);
|
|
4231
4305
|
|
|
4306
|
+
// Remove duplicate attributes (per HTML spec, first occurrence wins)
|
|
4307
|
+
// Duplicate attributes result in invalid HTML
|
|
4308
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
|
|
4309
|
+
deduplicateAttributes(attrs, options.caseSensitive);
|
|
4310
|
+
|
|
4232
4311
|
if (options.sortAttributes) {
|
|
4233
4312
|
options.sortAttributes(tag, attrs);
|
|
4234
4313
|
}
|
|
@@ -4260,8 +4339,12 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
4260
4339
|
},
|
|
4261
4340
|
end: function (tag, attrs, autoGenerated) {
|
|
4262
4341
|
const lowerTag = tag.toLowerCase();
|
|
4342
|
+
// Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
|
|
4263
4343
|
if (lowerTag === 'svg' || lowerTag === 'math') {
|
|
4264
4344
|
options = Object.getPrototypeOf(options);
|
|
4345
|
+
} else if ((lowerTag === 'foreignobject' || lowerTag === 'annotation-xml') &&
|
|
4346
|
+
!options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
|
|
4347
|
+
options = Object.getPrototypeOf(options);
|
|
4265
4348
|
}
|
|
4266
4349
|
tag = options.name(tag);
|
|
4267
4350
|
|
|
@@ -4303,7 +4386,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
4303
4386
|
optionalEndTagEmitted = true;
|
|
4304
4387
|
}
|
|
4305
4388
|
|
|
4306
|
-
if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
|
|
4389
|
+
if (options.removeEmptyElements && isElementEmpty && !options.insideForeignContent && canRemoveElement(tag, attrs)) {
|
|
4307
4390
|
let preserve = false;
|
|
4308
4391
|
if (removeEmptyElementsExcept.length) {
|
|
4309
4392
|
// Normalize attribute names for comparison with specs
|
|
@@ -3579,7 +3579,7 @@ function uniqueId(value) {
|
|
|
3579
3579
|
return id;
|
|
3580
3580
|
}
|
|
3581
3581
|
|
|
3582
|
-
// Identity functions
|
|
3582
|
+
// Identity and transform functions
|
|
3583
3583
|
|
|
3584
3584
|
function identity(value) {
|
|
3585
3585
|
return value;
|
|
@@ -3589,6 +3589,10 @@ function identityAsync(value) {
|
|
|
3589
3589
|
return Promise.resolve(value);
|
|
3590
3590
|
}
|
|
3591
3591
|
|
|
3592
|
+
function lowercase(value) {
|
|
3593
|
+
return value.toLowerCase();
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3592
3596
|
// Replace async helper
|
|
3593
3597
|
|
|
3594
3598
|
/**
|
|
@@ -3610,6 +3614,20 @@ async function replaceAsync(str, regex, asyncFn) {
|
|
|
3610
3614
|
return str.replace(regex, () => data.shift());
|
|
3611
3615
|
}
|
|
3612
3616
|
|
|
3617
|
+
// String patterns to RegExp conversion (for JSON config support)
|
|
3618
|
+
|
|
3619
|
+
function parseRegExp(value) {
|
|
3620
|
+
if (typeof value === 'string') {
|
|
3621
|
+
if (!value) return undefined; // Empty string = not configured
|
|
3622
|
+
const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
|
|
3623
|
+
if (match) {
|
|
3624
|
+
return new RegExp(match[1], match[2]);
|
|
3625
|
+
}
|
|
3626
|
+
return new RegExp(value);
|
|
3627
|
+
}
|
|
3628
|
+
return value;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3613
3631
|
// Regex patterns (to avoid repeated allocations in hot paths)
|
|
3614
3632
|
|
|
3615
3633
|
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
@@ -4654,6 +4672,22 @@ function getSVGMinifierOptions(userOptions) {
|
|
|
4654
4672
|
return null;
|
|
4655
4673
|
}
|
|
4656
4674
|
|
|
4675
|
+
// Single source of truth for minifier option names, descriptions, types, and shared defaults
|
|
4676
|
+
|
|
4677
|
+
|
|
4678
|
+
const optionDefaults = {
|
|
4679
|
+
continueOnMinifyError: true,
|
|
4680
|
+
ignoreCustomComments: [
|
|
4681
|
+
/^!/,
|
|
4682
|
+
/^\s*#/
|
|
4683
|
+
],
|
|
4684
|
+
ignoreCustomFragments: [
|
|
4685
|
+
/<%[\s\S]*?%>/,
|
|
4686
|
+
/<\?[\s\S]*?\?>/
|
|
4687
|
+
],
|
|
4688
|
+
includeAutoGeneratedTags: false
|
|
4689
|
+
};
|
|
4690
|
+
|
|
4657
4691
|
// Imports
|
|
4658
4692
|
|
|
4659
4693
|
|
|
@@ -4685,21 +4719,10 @@ function shouldMinifyInnerHTML(options) {
|
|
|
4685
4719
|
*/
|
|
4686
4720
|
const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssMinifyCache, jsMinifyCache } = {}) => {
|
|
4687
4721
|
const options = {
|
|
4688
|
-
name:
|
|
4689
|
-
return name.toLowerCase();
|
|
4690
|
-
},
|
|
4722
|
+
name: lowercase,
|
|
4691
4723
|
canCollapseWhitespace,
|
|
4692
4724
|
canTrimWhitespace,
|
|
4693
|
-
|
|
4694
|
-
ignoreCustomComments: [
|
|
4695
|
-
/^!/,
|
|
4696
|
-
/^\s*#/
|
|
4697
|
-
],
|
|
4698
|
-
ignoreCustomFragments: [
|
|
4699
|
-
/<%[\s\S]*?%>/,
|
|
4700
|
-
/<\?[\s\S]*?\?>/
|
|
4701
|
-
],
|
|
4702
|
-
includeAutoGeneratedTags: false,
|
|
4725
|
+
...optionDefaults,
|
|
4703
4726
|
log: identity,
|
|
4704
4727
|
minifyCSS: identityAsync,
|
|
4705
4728
|
minifyJS: identity,
|
|
@@ -4707,14 +4730,6 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, cssM
|
|
|
4707
4730
|
minifySVG: null
|
|
4708
4731
|
};
|
|
4709
4732
|
|
|
4710
|
-
// Helper to convert string patterns to RegExp (for JSON config support)
|
|
4711
|
-
const parseRegExp = (value) => {
|
|
4712
|
-
if (typeof value === 'string') {
|
|
4713
|
-
return new RegExp(value.replace(/^\/(.*)\/$/, '$1'));
|
|
4714
|
-
}
|
|
4715
|
-
return value; // Already a RegExp or another type
|
|
4716
|
-
};
|
|
4717
|
-
|
|
4718
4733
|
const parseRegExpArray = (arr) => {
|
|
4719
4734
|
return Array.isArray(arr) ? arr.map(parseRegExp) : arr;
|
|
4720
4735
|
};
|
|
@@ -5069,6 +5084,36 @@ function attributesInclude(attributes, attribute) {
|
|
|
5069
5084
|
return false;
|
|
5070
5085
|
}
|
|
5071
5086
|
|
|
5087
|
+
/**
|
|
5088
|
+
* Remove duplicate attributes from an attribute list.
|
|
5089
|
+
* Per HTML spec, when an attribute appears multiple times, the first occurrence wins.
|
|
5090
|
+
* Duplicate attributes result in invalid HTML, so we keep only the first.
|
|
5091
|
+
* @param {Array} attrs - Array of attribute objects with `name` property
|
|
5092
|
+
* @param {boolean} caseSensitive - Whether to compare names case-sensitively (for XML/SVG)
|
|
5093
|
+
* @returns {Array} Deduplicated attribute array (modifies in place and returns)
|
|
5094
|
+
*/
|
|
5095
|
+
function deduplicateAttributes(attrs, caseSensitive) {
|
|
5096
|
+
if (attrs.length < 2) {
|
|
5097
|
+
return attrs;
|
|
5098
|
+
}
|
|
5099
|
+
|
|
5100
|
+
const seen = new Set();
|
|
5101
|
+
let writeIndex = 0;
|
|
5102
|
+
|
|
5103
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
5104
|
+
const attr = attrs[i];
|
|
5105
|
+
const key = caseSensitive ? attr.name : attr.name.toLowerCase();
|
|
5106
|
+
|
|
5107
|
+
if (!seen.has(key)) {
|
|
5108
|
+
seen.add(key);
|
|
5109
|
+
attrs[writeIndex++] = attr;
|
|
5110
|
+
}
|
|
5111
|
+
}
|
|
5112
|
+
|
|
5113
|
+
attrs.length = writeIndex;
|
|
5114
|
+
return attrs;
|
|
5115
|
+
}
|
|
5116
|
+
|
|
5072
5117
|
function isAttributeRedundant(tag, attrName, attrValue, attrs) {
|
|
5073
5118
|
// Fast-path: Check if this element–attribute combination can possibly be redundant
|
|
5074
5119
|
// before doing expensive string operations
|
|
@@ -5649,6 +5694,16 @@ function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
|
5649
5694
|
// Element removal logic
|
|
5650
5695
|
|
|
5651
5696
|
function canRemoveElement(tag, attrs) {
|
|
5697
|
+
// Elements with `id` attribute must never be removed—they serve as:
|
|
5698
|
+
// - Navigation targets (skip links, URL fragments)
|
|
5699
|
+
// - JavaScript selector targets (`getElementById`, `querySelector`)
|
|
5700
|
+
// - CSS targets (`:target` pseudo-class, ID selectors)
|
|
5701
|
+
// - Accessibility landmarks (ARIA references)
|
|
5702
|
+
// - Portal mount points (React portals, etc.)
|
|
5703
|
+
if (hasAttrName('id', attrs)) {
|
|
5704
|
+
return false;
|
|
5705
|
+
}
|
|
5706
|
+
|
|
5652
5707
|
switch (tag) {
|
|
5653
5708
|
case 'textarea':
|
|
5654
5709
|
return false;
|
|
@@ -5844,6 +5899,9 @@ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript
|
|
|
5844
5899
|
const RE_START_TAG = /^<[^/!]/;
|
|
5845
5900
|
const RE_END_TAG = /^<\//;
|
|
5846
5901
|
|
|
5902
|
+
// HTML encoding types for annotation-xml (MathML)
|
|
5903
|
+
const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
|
|
5904
|
+
|
|
5847
5905
|
// Script merging
|
|
5848
5906
|
|
|
5849
5907
|
/**
|
|
@@ -6787,8 +6845,24 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
6787
6845
|
options.keepClosingSlash = true;
|
|
6788
6846
|
options.name = identity;
|
|
6789
6847
|
options.insideSVG = lowerTag === 'svg';
|
|
6848
|
+
options.insideForeignContent = true;
|
|
6790
6849
|
}
|
|
6791
|
-
|
|
6850
|
+
// `foreignObject` in SVG and `annotation-xml` in MathML contain HTML content
|
|
6851
|
+
// Note: The element itself is in SVG/MathML namespace, only its children are HTML
|
|
6852
|
+
let useParentNameForTag = false;
|
|
6853
|
+
if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
|
|
6854
|
+
(lowerTag === 'annotation-xml' && attrs.some(a => a.name.toLowerCase() === 'encoding' &&
|
|
6855
|
+
RE_HTML_ENCODING.test(a.value))))) {
|
|
6856
|
+
const parentName = options.name;
|
|
6857
|
+
options = Object.create(options);
|
|
6858
|
+
options.caseSensitive = false;
|
|
6859
|
+
options.keepClosingSlash = false;
|
|
6860
|
+
options.parentName = parentName; // Preserve for the element tag itself
|
|
6861
|
+
options.name = options.htmlName || lowercase;
|
|
6862
|
+
options.insideForeignContent = false;
|
|
6863
|
+
useParentNameForTag = true;
|
|
6864
|
+
}
|
|
6865
|
+
tag = (useParentNameForTag ? options.parentName : options.name)(tag);
|
|
6792
6866
|
currentTag = tag;
|
|
6793
6867
|
charsPrevTag = tag;
|
|
6794
6868
|
if (!inlineTextSet.has(tag)) {
|
|
@@ -6842,6 +6916,11 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
6842
6916
|
|
|
6843
6917
|
buffer.push(openTag);
|
|
6844
6918
|
|
|
6919
|
+
// Remove duplicate attributes (per HTML spec, first occurrence wins)
|
|
6920
|
+
// Duplicate attributes result in invalid HTML
|
|
6921
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
|
|
6922
|
+
deduplicateAttributes(attrs, options.caseSensitive);
|
|
6923
|
+
|
|
6845
6924
|
if (options.sortAttributes) {
|
|
6846
6925
|
options.sortAttributes(tag, attrs);
|
|
6847
6926
|
}
|
|
@@ -6873,8 +6952,12 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
6873
6952
|
},
|
|
6874
6953
|
end: function (tag, attrs, autoGenerated) {
|
|
6875
6954
|
const lowerTag = tag.toLowerCase();
|
|
6955
|
+
// Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
|
|
6876
6956
|
if (lowerTag === 'svg' || lowerTag === 'math') {
|
|
6877
6957
|
options = Object.getPrototypeOf(options);
|
|
6958
|
+
} else if ((lowerTag === 'foreignobject' || lowerTag === 'annotation-xml') &&
|
|
6959
|
+
!options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
|
|
6960
|
+
options = Object.getPrototypeOf(options);
|
|
6878
6961
|
}
|
|
6879
6962
|
tag = options.name(tag);
|
|
6880
6963
|
|
|
@@ -6916,7 +6999,7 @@ async function minifyHTML(value, options, partialMarkup) {
|
|
|
6916
6999
|
optionalEndTagEmitted = true;
|
|
6917
7000
|
}
|
|
6918
7001
|
|
|
6919
|
-
if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
|
|
7002
|
+
if (options.removeEmptyElements && isElementEmpty && !options.insideForeignContent && canRemoveElement(tag, attrs)) {
|
|
6920
7003
|
let preserve = false;
|
|
6921
7004
|
if (removeEmptyElementsExcept.length) {
|
|
6922
7005
|
// Normalize attribute names for comparison with specs
|