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 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>[![npm last update](https://img.shields.io/npm/last-update/html-minifier-next)](https://socket.dev/npm/package/html-minifier-next) | [htmlnano](https://github.com/posthtml/htmlnano)<br>[![npm last update](https://img.shields.io/npm/last-update/htmlnano)](https://socket.dev/npm/package/htmlnano) | [@swc/html](https://github.com/swc-project/swc)<br>[![npm last update](https://img.shields.io/npm/last-update/@swc/html)](https://socket.dev/npm/package/@swc/html) | [minify-html](https://github.com/wilsonzlin/minify-html)<br>[![npm last update](https://img.shields.io/npm/last-update/@minify-html/node)](https://socket.dev/npm/package/@minify-html/node) | [minimize](https://github.com/Swaagie/minimize)<br>[![npm last update](https://img.shields.io/npm/last-update/minimize)](https://socket.dev/npm/package/minimize) | [html­com­pressor.­com](https://htmlcompressor.com/) |
404
404
  | --- | --- | --- | --- | --- | --- | --- | --- |
405
- | [A List Apart](https://alistapart.com/) | 64 | **53** | 55 | 56 | 55 | 59 | 57 |
406
- | [Apple](https://www.apple.com/) | 233 | **174** | 206 | 209 | 210 | 212 | 212 |
407
- | [BBC](https://www.bbc.co.uk/) | 625 | **569** | 587 | 587 | 588 | 620 | n/a |
408
- | [CERN](https://home.cern/) | 151 | **82** | 90 | 90 | 91 | 92 | 95 |
409
- | [CSS-Tricks](https://css-tricks.com/) | 156 | **115** | 122 | 137 | 138 | 142 | 139 |
410
- | [ECMAScript](https://tc39.es/ecma262/) | 7261 | **6411** | 6583 | 6465 | 6589 | 6637 | n/a |
411
- | [EDRi](https://edri.org/) | 80 | **59** | 69 | 69 | 71 | 74 | 72 |
412
- | [EFF](https://www.eff.org/) | 54 | **44** | 48 | 47 | 48 | 49 | 49 |
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/) | 1488 | 1361 | **1337** | 1416 | 1427 | 1436 | n/a |
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** | 241 | 226 | 227 | 246 | 227 |
417
- | [Google](https://www.google.com/) | 18 | **16** | 17 | 17 | 17 | 18 | 18 |
418
- | [Ground News](https://ground.news/) | 2093 | **1829** | 1928 | 1954 | 1957 | 2080 | n/a |
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 | **33** | 36 | 35 | 36 | 37 | 36 |
421
- | [Leanpub](https://leanpub.com/) | 241 | **210** | 225 | 225 | 225 | 236 | 238 |
422
- | [Mastodon](https://mastodon.social/explore) | 38 | **28** | 32 | 35 | 35 | 36 | 36 |
423
- | [MDN](https://developer.mozilla.org/en-US/) | 109 | **62** | 64 | 65 | 65 | 68 | 68 |
424
- | [Middle East Eye](https://www.middleeasteye.net/) | 219 | **194** | 199 | 198 | 198 | 199 | 200 |
425
- | [Mistral AI](https://mistral.ai/) | 343 | **301** | 307 | 310 | 311 | 340 | n/a |
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 | 72 | **59** | 78 | 80 | 81 | 80 |
428
- | [SitePoint](https://www.sitepoint.com/) | 494 | **352** | 431 | 468 | 473 | 491 | n/a |
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 | **23** | 49 | 51 | 53 | 53 | 53 |
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 | 125 | 130 | 123 |
433
- | [Vivaldi](https://vivaldi.com/) | 93 | **74** | n/a | 79 | 81 | 84 | 82 |
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** | | 73 ms (30/30) | 153 ms (29/30) | 51 ms (30/30) | **14 ms (30/30)** | 289 ms (30/30) | 1214 ms (24/30) |
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 2, 2026)
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
- const mainOptions = {
129
- caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)',
130
- collapseAttributeWhitespace: 'Trim and collapse whitespace characters within attribute values',
131
- collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
132
- collapseInlineTagWhitespace: 'Collapse whitespace more aggressively between inline elements—use with `--collapse-whitespace`',
133
- collapseWhitespace: 'Collapse whitespace that contributes to text nodes in a document tree',
134
- conservativeCollapse: 'Always collapse to one space (never remove it entirely)—use with `--collapse-whitespace`',
135
- continueOnMinifyError: 'Abort on minification errors',
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(mainOptions);
134
+ // Configure command-line flags from shared option definitions
135
+ const mainOptionKeys = Object.keys(optionDefinitions);
180
136
  mainOptionKeys.forEach(function (key) {
181
- const option = mainOptions[key];
182
- if (Array.isArray(option)) {
183
- key = '--' + paramCase(key);
184
- key += option[1] === parseJSON ? ' [value]' : ' <value>';
185
- program.option(key, option[0], option[1]);
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
- program.option('--' + paramCase(key), option);
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 option = mainOptions[key];
256
- if (Array.isArray(option)) {
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] = option[1](typeof value === 'string' ? value : JSON.stringify(value));
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;
@@ -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: function (name) {
2076
- return name.toLowerCase();
2077
- },
2109
+ name: lowercase,
2078
2110
  canCollapseWhitespace,
2079
2111
  canTrimWhitespace,
2080
- continueOnMinifyError: true,
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
- tag = options.name(tag);
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: function (name) {
4689
- return name.toLowerCase();
4690
- },
4722
+ name: lowercase,
4691
4723
  canCollapseWhitespace,
4692
4724
  canTrimWhitespace,
4693
- continueOnMinifyError: true,
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
- tag = options.name(tag);
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