html-minifier-next 5.0.0 → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 |
405
+ | [A List Apart](https://alistapart.com/) | 63 | **53** | 55 | 56 | 55 | 58 | 56 |
406
+ | [Apple](https://www.apple.com/) | 236 | **197** | 209 | 212 | 213 | 215 | 215 |
407
+ | [BBC](https://www.bbc.co.uk/) | 647 | **601** | 607 | 608 | 609 | 642 | n/a |
408
+ | [CERN](https://home.cern/) | 150 | **82** | 90 | 90 | 90 | 92 | 95 |
409
+ | [CSS-Tricks](https://css-tricks.com/) | 155 | 127 | **121** | 136 | 137 | 141 | 138 |
410
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 |
411
+ | [EDRi](https://edri.org/) | 80 | **68** | 69 | 69 | 71 | 74 | 72 |
412
+ | [EFF](https://www.eff.org/) | 54 | **45** | 48 | 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/) | 1545 | 1412 | **1385** | 1471 | 1482 | 1492 | 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 | 227 |
417
+ | [Google](https://www.google.com/) | 18 | **16** | **16** | **16** | 17 | 18 | 18 |
418
+ | [Ground News](https://ground.news/) | 1513 | **1371** | 1394 | 1418 | 1424 | 1499 | 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 |
420
+ | [Igalia](https://www.igalia.com/) | 49 | **34** | 36 | 36 | 36 | 37 | 37 |
421
+ | [Leanpub](https://leanpub.com/) | 243 | **226** | 229 | 228 | 229 | 239 | 240 |
422
+ | [Mastodon](https://mastodon.social/explore) | 38 | 35 | **32** | 35 | 36 | 37 | 37 |
423
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 |
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/) | 495 | 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 | **75** | 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** | | 77 ms (30/30) | 149 ms (29/30) | 49 ms (30/30) | **17 ms (30/30)** | 330 ms (30/30) | 1513 ms (24/30) |
436
436
 
437
- (Last updated: Feb 2, 2026)
437
+ (Last updated: Feb 3, 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
  };
@@ -3231,6 +3246,9 @@ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript
3231
3246
  const RE_START_TAG = /^<[^/!]/;
3232
3247
  const RE_END_TAG = /^<\//;
3233
3248
 
3249
+ // HTML encoding types for annotation-xml (MathML)
3250
+ const RE_HTML_ENCODING = /^(text\/html|application\/xhtml\+xml)$/i;
3251
+
3234
3252
  // Script merging
3235
3253
 
3236
3254
  /**
@@ -3993,6 +4011,7 @@ async function minifyHTML(value, options, partialMarkup) {
3993
4011
  const stackNoCollapseWhitespace = [];
3994
4012
  let optionalStartTag = '';
3995
4013
  let optionalEndTag = '';
4014
+ let optionalEndTagEmitted = false;
3996
4015
  const ignoredMarkupChunks = [];
3997
4016
  const ignoredCustomMarkupChunks = [];
3998
4017
  let uidIgnore;
@@ -4173,8 +4192,24 @@ async function minifyHTML(value, options, partialMarkup) {
4173
4192
  options.keepClosingSlash = true;
4174
4193
  options.name = identity;
4175
4194
  options.insideSVG = lowerTag === 'svg';
4195
+ options.insideForeignContent = true;
4176
4196
  }
4177
- tag = options.name(tag);
4197
+ // `foreignObject` in SVG and `annotation-xml` in MathML contain HTML content
4198
+ // Note: The element itself is in SVG/MathML namespace, only its children are HTML
4199
+ let useParentNameForTag = false;
4200
+ if (options.insideForeignContent && (lowerTag === 'foreignobject' ||
4201
+ (lowerTag === 'annotation-xml' && attrs.some(a => a.name.toLowerCase() === 'encoding' &&
4202
+ RE_HTML_ENCODING.test(a.value))))) {
4203
+ const parentName = options.name;
4204
+ options = Object.create(options);
4205
+ options.caseSensitive = false;
4206
+ options.keepClosingSlash = false;
4207
+ options.parentName = parentName; // Preserve for the element tag itself
4208
+ options.name = options.htmlName || lowercase;
4209
+ options.insideForeignContent = false;
4210
+ useParentNameForTag = true;
4211
+ }
4212
+ tag = (useParentNameForTag ? options.parentName : options.name)(tag);
4178
4213
  currentTag = tag;
4179
4214
  charsPrevTag = tag;
4180
4215
  if (!inlineTextSet.has(tag)) {
@@ -4197,12 +4232,15 @@ async function minifyHTML(value, options, partialMarkup) {
4197
4232
  optionalStartTag = '';
4198
4233
  // End-tag-followed-by-start-tag omission rules
4199
4234
  if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
4200
- removeEndTag();
4235
+ if (optionalEndTagEmitted) {
4236
+ removeEndTag();
4237
+ }
4201
4238
  // `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
4202
4239
  // `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is omitted
4203
4240
  optional = !isStartTagMandatory(optionalEndTag, tag);
4204
4241
  }
4205
4242
  optionalEndTag = '';
4243
+ optionalEndTagEmitted = false;
4206
4244
  }
4207
4245
 
4208
4246
  // Set whitespace flags for nested tags (e.g., `<code>` within a `<pre>`)
@@ -4256,8 +4294,12 @@ async function minifyHTML(value, options, partialMarkup) {
4256
4294
  },
4257
4295
  end: function (tag, attrs, autoGenerated) {
4258
4296
  const lowerTag = tag.toLowerCase();
4297
+ // Restore parent context when exiting SVG/MathML or HTML-in-foreign-content elements
4259
4298
  if (lowerTag === 'svg' || lowerTag === 'math') {
4260
4299
  options = Object.getPrototypeOf(options);
4300
+ } else if ((lowerTag === 'foreignobject' || lowerTag === 'annotation-xml') &&
4301
+ !options.insideForeignContent && Object.getPrototypeOf(options).insideForeignContent) {
4302
+ options = Object.getPrototypeOf(options);
4261
4303
  }
4262
4304
  tag = options.name(tag);
4263
4305
 
@@ -4292,13 +4334,14 @@ async function minifyHTML(value, options, partialMarkup) {
4292
4334
  // `</head>` may be omitted if not followed by space or comment
4293
4335
  // `</p>` may be omitted if no more content in non-`</a>` parent
4294
4336
  // except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
4295
- if (tag && optionalEndTag && !trailingElements.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineElements.has(tag))) {
4337
+ if (tag && optionalEndTag && optionalEndTagEmitted && !trailingElements.has(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineElements.has(tag))) {
4296
4338
  removeEndTag();
4297
4339
  }
4298
4340
  optionalEndTag = optionalEndTags.has(tag) ? tag : '';
4341
+ optionalEndTagEmitted = true;
4299
4342
  }
4300
4343
 
4301
- if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
4344
+ if (options.removeEmptyElements && isElementEmpty && !options.insideForeignContent && canRemoveElement(tag, attrs)) {
4302
4345
  let preserve = false;
4303
4346
  if (removeEmptyElementsExcept.length) {
4304
4347
  // Normalize attribute names for comparison with specs
@@ -4311,10 +4354,11 @@ async function minifyHTML(value, options, partialMarkup) {
4311
4354
  removeStartTag();
4312
4355
  optionalStartTag = '';
4313
4356
  optionalEndTag = '';
4357
+ optionalEndTagEmitted = false;
4314
4358
  } else {
4315
4359
  // Preserve the element—add closing tag
4316
4360
  if (autoGenerated && !options.includeAutoGeneratedTags) {
4317
- optionalEndTag = '';
4361
+ optionalEndTagEmitted = false;
4318
4362
  } else {
4319
4363
  buffer.push('</' + tag + '>');
4320
4364
  }
@@ -4327,7 +4371,7 @@ async function minifyHTML(value, options, partialMarkup) {
4327
4371
  }
4328
4372
  } else {
4329
4373
  if (autoGenerated && !options.includeAutoGeneratedTags) {
4330
- optionalEndTag = '';
4374
+ optionalEndTagEmitted = false;
4331
4375
  } else {
4332
4376
  buffer.push('</' + tag + '>');
4333
4377
  }
@@ -4424,12 +4468,13 @@ async function minifyHTML(value, options, partialMarkup) {
4424
4468
  optionalStartTag = '';
4425
4469
  // `</html>` or `</body>` may be omitted if not followed by comment
4426
4470
  // `</head>`, `</colgroup>`, or `</caption>` may be omitted if not followed by space or comment
4427
- if (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text))) {
4471
+ if (optionalEndTagEmitted && (compactElements.has(optionalEndTag) || (looseElements.has(optionalEndTag) && !/^\s/.test(text)))) {
4428
4472
  removeEndTag();
4429
4473
  }
4430
- // Dont reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
4474
+ // Don't reset optionalEndTag if text is only whitespace and will be collapsed (not conservatively)
4431
4475
  if (!/^\s+$/.test(text) || !options.collapseWhitespace || options.conservativeCollapse) {
4432
4476
  optionalEndTag = '';
4477
+ optionalEndTagEmitted = false;
4433
4478
  }
4434
4479
  }
4435
4480
  charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
@@ -4475,6 +4520,7 @@ async function minifyHTML(value, options, partialMarkup) {
4475
4520
  // Preceding comments suppress tag omissions
4476
4521
  optionalStartTag = '';
4477
4522
  optionalEndTag = '';
4523
+ optionalEndTagEmitted = false;
4478
4524
  }
4479
4525
 
4480
4526
  // Optimize whitespace collapsing between consecutive `htmlmin:ignore` placeholder comments
@@ -4567,7 +4613,7 @@ async function minifyHTML(value, options, partialMarkup) {
4567
4613
  removeStartTag();
4568
4614
  }
4569
4615
  // except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent element
4570
- if (optionalEndTag && !trailingElements.has(optionalEndTag)) {
4616
+ if (optionalEndTag && optionalEndTagEmitted && !trailingElements.has(optionalEndTag)) {
4571
4617
  removeEndTag();
4572
4618
  }
4573
4619
  }