html-minifier-next 4.7.1 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -159,7 +159,7 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
159
159
  | `minifyURLs`<br>`--minify-urls` | Minify URLs in various attributes (uses [relateurl](https://github.com/stevenvachon/relateurl)) | `false` (could be `String`, `Object`, `Function(text)`, `async Function(text)`) |
160
160
  | `noNewlinesBeforeTagClose`<br>`--no-newlines-before-tag-close` | Never add a newline before a tag that closes an element | `false` |
161
161
  | `partialMarkup`<br>`--partial-markup` | Treat input as a partial HTML fragment, preserving stray end tags (closing tags without opening tags) and preventing auto-closing of unclosed tags at end of input | `false` |
162
- | `preserveLineBreaks`<br>`--preserve-line-breaks` | Always collapse to 1 line break (never remove it entirely) when whitespace between tags includes a line break—use with `collapseWhitespace: true` | `false` |
162
+ | `preserveLineBreaks`<br>`--preserve-line-breaks` | Always collapse to one line break (never remove it entirely) when whitespace between tags includes a line break—use with `collapseWhitespace: true` | `false` |
163
163
  | `preventAttributesEscaping`<br>`--prevent-attributes-escaping` | Prevents the escaping of the values of attributes | `false` |
164
164
  | `processConditionalComments`<br>`--process-conditional-comments` | Process contents of conditional comments through minifier | `false` |
165
165
  | `processScripts`<br>`--process-scripts` | Array of strings corresponding to types of `script` elements to process through minifier (e.g., `text/ng-template`, `text/x-handlebars-template`, etc.) | `[]` |
@@ -168,6 +168,7 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
168
168
  | `removeComments`<br>`--remove-comments` | [Strip HTML comments](https://perfectionkills.com/experimenting-with-html-minifier#remove_comments) | `false` |
169
169
  | `removeEmptyAttributes`<br>`--remove-empty-attributes` | [Remove all attributes with whitespace-only values](https://perfectionkills.com/experimenting-with-html-minifier#remove_empty_or_blank_attributes) | `false` (could be `true`, `Function(attrName, tag)`) |
170
170
  | `removeEmptyElements`<br>`--remove-empty-elements` | [Remove all elements with empty contents](https://perfectionkills.com/experimenting-with-html-minifier#remove_empty_elements) | `false` |
171
+ | `removeEmptyElementsExcept`<br>`--remove-empty-elements-except` | Array of elements to preserve when `removeEmptyElements` is enabled; accepts simple tag names (e.g., `["td"]`) or HTML-like markup with attributes (e.g., `["<span aria-hidden='true'>"]`); supports double quotes, single quotes, and unquoted attribute values | `[]` |
171
172
  | `removeOptionalTags`<br>`--remove-optional-tags` | [Remove optional tags](https://perfectionkills.com/experimenting-with-html-minifier#remove_optional_tags) | `false` |
172
173
  | `removeRedundantAttributes`<br>`--remove-redundant-attributes` | [Remove attributes when value matches default](https://meiert.com/blog/optional-html/#toc-attribute-values) | `false` |
173
174
  | `removeScriptTypeAttributes`<br>`--remove-script-type-attributes` | Remove `type="text/javascript"` from `script` elements; other `type` attribute values are left intact | `false` |
@@ -262,7 +263,7 @@ How does HTML Minifier Next compare to other minifiers? (All with the most aggre
262
263
  **Sample command line:**
263
264
 
264
265
  ```bash
265
- html-minifier-next --collapse-whitespace --remove-comments --minify-js true --input-dir=. --output-dir=example
266
+ html-minifier-next --collapse-whitespace --remove-comments --minify-js --input-dir=. --output-dir=example
266
267
  ```
267
268
 
268
269
  **Process specific files and directories:**
package/cli.js CHANGED
@@ -119,9 +119,9 @@ const mainOptions = {
119
119
  caseSensitive: 'Treat attributes in case-sensitive manner (useful for custom HTML elements)',
120
120
  collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
121
121
  customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseValidInt('customFragmentQuantifierLimit')],
122
- collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with “collapseWhitespace=true”',
122
+ collapseInlineTagWhitespace: 'Don’t leave any spaces between “display: inline;” elements when collapsing—use with “--collapse-whitespace”',
123
123
  collapseWhitespace: 'Collapse whitespace that contributes to text nodes in a document tree',
124
- conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)—use with “collapseWhitespace=true”',
124
+ conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)—use with “--collapse-whitespace”',
125
125
  continueOnMinifyError: 'Abort on minification errors',
126
126
  continueOnParseError: 'Handle parse errors instead of aborting',
127
127
  customAttrAssign: ['Arrays of regexes that allow to support custom attribute assign expressions (e.g., “<div flex?="{{mode != cover}}"></div>”)', parseJSONRegExpArray],
@@ -142,7 +142,7 @@ const mainOptions = {
142
142
  minifyURLs: ['Minify URLs in various attributes (uses relateurl)', parseJSON],
143
143
  noNewlinesBeforeTagClose: 'Never add a newline before a tag that closes an element',
144
144
  partialMarkup: 'Treat input as a partial HTML fragment, preserving stray end tags and unclosed tags',
145
- preserveLineBreaks: 'Always collapse to 1 line break (never remove it entirely) when whitespace between tags includes a line break—use with "collapseWhitespace=true"',
145
+ preserveLineBreaks: 'Always collapse to one line break (never remove it entirely) when whitespace between tags includes a line break—use with “--collapse-whitespace”',
146
146
  preventAttributesEscaping: 'Prevents the escaping of the values of attributes',
147
147
  processConditionalComments: 'Process contents of conditional comments through minifier',
148
148
  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],
@@ -151,6 +151,7 @@ const mainOptions = {
151
151
  removeComments: 'Strip HTML comments',
152
152
  removeEmptyAttributes: 'Remove all attributes with whitespace-only values',
153
153
  removeEmptyElements: 'Remove all elements with empty contents',
154
+ removeEmptyElementsExcept: ['Array of elements to preserve when “--remove-empty-elements” is enabled (e.g., “td”, “["td", "<span aria-hidden=\'true\'>"]”)', parseJSONArray],
154
155
  removeOptionalTags: 'Remove unrequired tags',
155
156
  removeRedundantAttributes: 'Remove attributes when value matches default',
156
157
  removeScriptTypeAttributes: 'Remove “type="text/javascript"” from “script” elements; other “type” attribute values are left intact',
@@ -1117,9 +1117,9 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
1117
1117
  }))).join(', ');
1118
1118
  } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
1119
1119
  attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
1120
- // "0.90000" -> "0.9"
1121
- // "1.0" -> "1"
1122
- // "1.0001" -> "1.0001" (unchanged)
1120
+ // 0.90000 0.9
1121
+ // 1.0 1
1122
+ // 1.0001 1.0001 (unchanged)
1123
1123
  return (+numString).toString();
1124
1124
  });
1125
1125
  } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
@@ -1390,6 +1390,107 @@ function canRemoveElement(tag, attrs) {
1390
1390
  return true;
1391
1391
  }
1392
1392
 
1393
+ function parseElementSpec(str, options) {
1394
+ if (typeof str !== 'string') {
1395
+ return null;
1396
+ }
1397
+
1398
+ const trimmed = str.trim();
1399
+ if (!trimmed) {
1400
+ return null;
1401
+ }
1402
+
1403
+ // Simple tag name: “td”
1404
+ if (!/[<>]/.test(trimmed)) {
1405
+ return { tag: options.name(trimmed), attrs: null };
1406
+ }
1407
+
1408
+ // HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
1409
+ // Extract opening tag using regex
1410
+ const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
1411
+ if (!match) {
1412
+ return null;
1413
+ }
1414
+
1415
+ const tag = options.name(match[1]);
1416
+ const attrString = match[2];
1417
+
1418
+ if (!attrString.trim()) {
1419
+ return { tag, attrs: null };
1420
+ }
1421
+
1422
+ // Parse attributes from string
1423
+ const attrs = {};
1424
+ const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
1425
+ let attrMatch;
1426
+
1427
+ while ((attrMatch = attrRegex.exec(attrString))) {
1428
+ const attrName = options.name(attrMatch[1]);
1429
+ const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
1430
+ // Boolean attributes have no value (undefined)
1431
+ attrs[attrName] = attrValue;
1432
+ }
1433
+
1434
+ return {
1435
+ tag,
1436
+ attrs: Object.keys(attrs).length > 0 ? attrs : null
1437
+ };
1438
+ }
1439
+
1440
+ function parseRemoveEmptyElementsExcept(input, options) {
1441
+ if (!Array.isArray(input)) {
1442
+ return [];
1443
+ }
1444
+
1445
+ return input.map(item => {
1446
+ if (typeof item === 'string') {
1447
+ const spec = parseElementSpec(item, options);
1448
+ if (!spec && options.log) {
1449
+ options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
1450
+ }
1451
+ return spec;
1452
+ }
1453
+ if (options.log) {
1454
+ options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
1455
+ }
1456
+ return null;
1457
+ }).filter(Boolean);
1458
+ }
1459
+
1460
+ function shouldPreserveEmptyElement(tag, attrs, preserveList) {
1461
+ for (const spec of preserveList) {
1462
+ // Tag name must match
1463
+ if (spec.tag !== tag) {
1464
+ continue;
1465
+ }
1466
+
1467
+ // If no attributes specified in spec, tag match is enough
1468
+ if (!spec.attrs) {
1469
+ return true;
1470
+ }
1471
+
1472
+ // Check if all specified attributes match
1473
+ const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
1474
+ const attr = attrs.find(a => a.name === name);
1475
+ if (!attr) {
1476
+ return false; // Attribute not present
1477
+ }
1478
+ // Boolean attribute in spec (undefined value) matches if attribute is present
1479
+ if (value === undefined) {
1480
+ return true;
1481
+ }
1482
+ // Valued attribute must match exactly
1483
+ return attr.value === value;
1484
+ });
1485
+
1486
+ if (allAttrsMatch) {
1487
+ return true;
1488
+ }
1489
+ }
1490
+
1491
+ return false;
1492
+ }
1493
+
1393
1494
  function canCollapseWhitespace(tag) {
1394
1495
  return !/^(?:script|style|pre|textarea)$/.test(tag);
1395
1496
  }
@@ -1839,6 +1940,17 @@ async function minifyHTML(value, options, partialMarkup) {
1839
1940
  const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
1840
1941
  const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
1841
1942
 
1943
+ // Parse `removeEmptyElementsExcept` option
1944
+ let removeEmptyElementsExcept;
1945
+ if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
1946
+ if (options.log) {
1947
+ options.log('Warning: "removeEmptyElementsExcept" option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
1948
+ }
1949
+ removeEmptyElementsExcept = [];
1950
+ } else {
1951
+ removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
1952
+ }
1953
+
1842
1954
  // Temporarily replace ignored chunks with comments,
1843
1955
  // so that we don’t have to worry what’s there.
1844
1956
  // For all we care there might be
@@ -1866,7 +1978,7 @@ async function minifyHTML(value, options, partialMarkup) {
1866
1978
  // Warn about potential ReDoS if custom fragments use unlimited quantifiers
1867
1979
  for (let i = 0; i < customFragments.length; i++) {
1868
1980
  if (/[*+]/.test(customFragments[i])) {
1869
- options.log('Warning: Custom fragment contains unlimited quantifiers (* or +) which may cause ReDoS vulnerability');
1981
+ options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
1870
1982
  break;
1871
1983
  }
1872
1984
  }
@@ -2109,10 +2221,32 @@ async function minifyHTML(value, options, partialMarkup) {
2109
2221
  }
2110
2222
 
2111
2223
  if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
2112
- // Remove last “element” from buffer
2113
- removeStartTag();
2114
- optionalStartTag = '';
2115
- optionalEndTag = '';
2224
+ let preserve = false;
2225
+ if (removeEmptyElementsExcept.length) {
2226
+ // Normalize attribute names for comparison with specs
2227
+ const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
2228
+ preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
2229
+ }
2230
+
2231
+ if (!preserve) {
2232
+ // Remove last “element” from buffer
2233
+ removeStartTag();
2234
+ optionalStartTag = '';
2235
+ optionalEndTag = '';
2236
+ } else {
2237
+ // Preserve the element - add closing tag
2238
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
2239
+ optionalEndTag = '';
2240
+ } else {
2241
+ buffer.push('</' + tag + '>');
2242
+ }
2243
+ charsPrevTag = '/' + tag;
2244
+ if (!inlineElements.has(tag)) {
2245
+ currentChars = '';
2246
+ } else if (isElementEmpty) {
2247
+ currentChars += '|';
2248
+ }
2249
+ }
2116
2250
  } else {
2117
2251
  if (autoGenerated && !options.includeAutoGeneratedTags) {
2118
2252
  optionalEndTag = '';
@@ -2393,7 +2527,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
2393
2527
  *
2394
2528
  * @prop {boolean} [collapseBooleanAttributes]
2395
2529
  * Collapse boolean attributes to their name only (for example
2396
- * `disabled="disabled"` -> `disabled`).
2530
+ * `disabled="disabled"` `disabled`).
2397
2531
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
2398
2532
  *
2399
2533
  * Default: `false`
@@ -2637,6 +2771,31 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
2637
2771
  *
2638
2772
  * Default: `false`
2639
2773
  *
2774
+ * @prop {string[]} [removeEmptyElementsExcept]
2775
+ * Specifies empty elements to preserve when `removeEmptyElements` is enabled.
2776
+ * Has no effect unless `removeEmptyElements: true`.
2777
+ *
2778
+ * Accepts tag names or HTML-like element specifications:
2779
+ *
2780
+ * * Tag name only: `["td", "span"]`—preserves all empty elements of these types
2781
+ * * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
2782
+ * * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
2783
+ * * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
2784
+ *
2785
+ * Attribute matching:
2786
+ *
2787
+ * * All specified attributes must be present and match (valued attributes must have exact values)
2788
+ * * Additional attributes on the element are allowed
2789
+ * * Attribute name matching respects the `caseSensitive` option
2790
+ * * Supports double quotes, single quotes, and unquoted attribute values in specifications
2791
+ *
2792
+ * Limitations:
2793
+ *
2794
+ * * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
2795
+ * * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
2796
+ *
2797
+ * Default: `[]`
2798
+ *
2640
2799
  * @prop {boolean} [removeOptionalTags]
2641
2800
  * Drop optional start/end tags where the HTML specification permits it
2642
2801
  * (for example `</li>`, optional `<html>` etc.).
@@ -2664,7 +2823,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
2664
2823
  * Default: `false`
2665
2824
  *
2666
2825
  * @prop {boolean} [removeTagWhitespace]
2667
- * **Note that this will currently result in invalid HTML!**
2826
+ * **Note that this will result in invalid HTML!**
2668
2827
  *
2669
2828
  * When true, extra whitespace between tag name and attributes (or before
2670
2829
  * the closing bracket) will be removed where possible. Affects output spacing
@@ -40170,9 +40170,9 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
40170
40170
  }))).join(', ');
40171
40171
  } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
40172
40172
  attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
40173
- // "0.90000" -> "0.9"
40174
- // "1.0" -> "1"
40175
- // "1.0001" -> "1.0001" (unchanged)
40173
+ // 0.90000 0.9
40174
+ // 1.0 1
40175
+ // 1.0001 1.0001 (unchanged)
40176
40176
  return (+numString).toString();
40177
40177
  });
40178
40178
  } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
@@ -40443,6 +40443,107 @@ function canRemoveElement(tag, attrs) {
40443
40443
  return true;
40444
40444
  }
40445
40445
 
40446
+ function parseElementSpec(str, options) {
40447
+ if (typeof str !== 'string') {
40448
+ return null;
40449
+ }
40450
+
40451
+ const trimmed = str.trim();
40452
+ if (!trimmed) {
40453
+ return null;
40454
+ }
40455
+
40456
+ // Simple tag name: “td”
40457
+ if (!/[<>]/.test(trimmed)) {
40458
+ return { tag: options.name(trimmed), attrs: null };
40459
+ }
40460
+
40461
+ // HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
40462
+ // Extract opening tag using regex
40463
+ const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
40464
+ if (!match) {
40465
+ return null;
40466
+ }
40467
+
40468
+ const tag = options.name(match[1]);
40469
+ const attrString = match[2];
40470
+
40471
+ if (!attrString.trim()) {
40472
+ return { tag, attrs: null };
40473
+ }
40474
+
40475
+ // Parse attributes from string
40476
+ const attrs = {};
40477
+ const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
40478
+ let attrMatch;
40479
+
40480
+ while ((attrMatch = attrRegex.exec(attrString))) {
40481
+ const attrName = options.name(attrMatch[1]);
40482
+ const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
40483
+ // Boolean attributes have no value (undefined)
40484
+ attrs[attrName] = attrValue;
40485
+ }
40486
+
40487
+ return {
40488
+ tag,
40489
+ attrs: Object.keys(attrs).length > 0 ? attrs : null
40490
+ };
40491
+ }
40492
+
40493
+ function parseRemoveEmptyElementsExcept(input, options) {
40494
+ if (!Array.isArray(input)) {
40495
+ return [];
40496
+ }
40497
+
40498
+ return input.map(item => {
40499
+ if (typeof item === 'string') {
40500
+ const spec = parseElementSpec(item, options);
40501
+ if (!spec && options.log) {
40502
+ options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
40503
+ }
40504
+ return spec;
40505
+ }
40506
+ if (options.log) {
40507
+ options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
40508
+ }
40509
+ return null;
40510
+ }).filter(Boolean);
40511
+ }
40512
+
40513
+ function shouldPreserveEmptyElement(tag, attrs, preserveList) {
40514
+ for (const spec of preserveList) {
40515
+ // Tag name must match
40516
+ if (spec.tag !== tag) {
40517
+ continue;
40518
+ }
40519
+
40520
+ // If no attributes specified in spec, tag match is enough
40521
+ if (!spec.attrs) {
40522
+ return true;
40523
+ }
40524
+
40525
+ // Check if all specified attributes match
40526
+ const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
40527
+ const attr = attrs.find(a => a.name === name);
40528
+ if (!attr) {
40529
+ return false; // Attribute not present
40530
+ }
40531
+ // Boolean attribute in spec (undefined value) matches if attribute is present
40532
+ if (value === undefined) {
40533
+ return true;
40534
+ }
40535
+ // Valued attribute must match exactly
40536
+ return attr.value === value;
40537
+ });
40538
+
40539
+ if (allAttrsMatch) {
40540
+ return true;
40541
+ }
40542
+ }
40543
+
40544
+ return false;
40545
+ }
40546
+
40446
40547
  function canCollapseWhitespace(tag) {
40447
40548
  return !/^(?:script|style|pre|textarea)$/.test(tag);
40448
40549
  }
@@ -40892,6 +40993,17 @@ async function minifyHTML(value, options, partialMarkup) {
40892
40993
  const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
40893
40994
  const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
40894
40995
 
40996
+ // Parse `removeEmptyElementsExcept` option
40997
+ let removeEmptyElementsExcept;
40998
+ if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
40999
+ if (options.log) {
41000
+ options.log('Warning: "removeEmptyElementsExcept" option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
41001
+ }
41002
+ removeEmptyElementsExcept = [];
41003
+ } else {
41004
+ removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
41005
+ }
41006
+
40895
41007
  // Temporarily replace ignored chunks with comments,
40896
41008
  // so that we don’t have to worry what’s there.
40897
41009
  // For all we care there might be
@@ -40919,7 +41031,7 @@ async function minifyHTML(value, options, partialMarkup) {
40919
41031
  // Warn about potential ReDoS if custom fragments use unlimited quantifiers
40920
41032
  for (let i = 0; i < customFragments.length; i++) {
40921
41033
  if (/[*+]/.test(customFragments[i])) {
40922
- options.log('Warning: Custom fragment contains unlimited quantifiers (* or +) which may cause ReDoS vulnerability');
41034
+ options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
40923
41035
  break;
40924
41036
  }
40925
41037
  }
@@ -41162,10 +41274,32 @@ async function minifyHTML(value, options, partialMarkup) {
41162
41274
  }
41163
41275
 
41164
41276
  if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
41165
- // Remove last “element” from buffer
41166
- removeStartTag();
41167
- optionalStartTag = '';
41168
- optionalEndTag = '';
41277
+ let preserve = false;
41278
+ if (removeEmptyElementsExcept.length) {
41279
+ // Normalize attribute names for comparison with specs
41280
+ const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
41281
+ preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
41282
+ }
41283
+
41284
+ if (!preserve) {
41285
+ // Remove last “element” from buffer
41286
+ removeStartTag();
41287
+ optionalStartTag = '';
41288
+ optionalEndTag = '';
41289
+ } else {
41290
+ // Preserve the element - add closing tag
41291
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
41292
+ optionalEndTag = '';
41293
+ } else {
41294
+ buffer.push('</' + tag + '>');
41295
+ }
41296
+ charsPrevTag = '/' + tag;
41297
+ if (!inlineElements.has(tag)) {
41298
+ currentChars = '';
41299
+ } else if (isElementEmpty) {
41300
+ currentChars += '|';
41301
+ }
41302
+ }
41169
41303
  } else {
41170
41304
  if (autoGenerated && !options.includeAutoGeneratedTags) {
41171
41305
  optionalEndTag = '';
@@ -41446,7 +41580,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
41446
41580
  *
41447
41581
  * @prop {boolean} [collapseBooleanAttributes]
41448
41582
  * Collapse boolean attributes to their name only (for example
41449
- * `disabled="disabled"` -> `disabled`).
41583
+ * `disabled="disabled"` `disabled`).
41450
41584
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
41451
41585
  *
41452
41586
  * Default: `false`
@@ -41690,6 +41824,31 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
41690
41824
  *
41691
41825
  * Default: `false`
41692
41826
  *
41827
+ * @prop {string[]} [removeEmptyElementsExcept]
41828
+ * Specifies empty elements to preserve when `removeEmptyElements` is enabled.
41829
+ * Has no effect unless `removeEmptyElements: true`.
41830
+ *
41831
+ * Accepts tag names or HTML-like element specifications:
41832
+ *
41833
+ * * Tag name only: `["td", "span"]`—preserves all empty elements of these types
41834
+ * * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
41835
+ * * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
41836
+ * * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
41837
+ *
41838
+ * Attribute matching:
41839
+ *
41840
+ * * All specified attributes must be present and match (valued attributes must have exact values)
41841
+ * * Additional attributes on the element are allowed
41842
+ * * Attribute name matching respects the `caseSensitive` option
41843
+ * * Supports double quotes, single quotes, and unquoted attribute values in specifications
41844
+ *
41845
+ * Limitations:
41846
+ *
41847
+ * * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
41848
+ * * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
41849
+ *
41850
+ * Default: `[]`
41851
+ *
41693
41852
  * @prop {boolean} [removeOptionalTags]
41694
41853
  * Drop optional start/end tags where the HTML specification permits it
41695
41854
  * (for example `</li>`, optional `<html>` etc.).
@@ -41717,7 +41876,7 @@ var htmlminifier = { minify, presets, getPreset, getPresetNames };
41717
41876
  * Default: `false`
41718
41877
  *
41719
41878
  * @prop {boolean} [removeTagWhitespace]
41720
- * **Note that this will currently result in invalid HTML!**
41879
+ * **Note that this will result in invalid HTML!**
41721
41880
  *
41722
41881
  * When true, extra whitespace between tag name and attributes (or before
41723
41882
  * the closing bracket) will be removed where possible. Affects output spacing
@@ -46,7 +46,7 @@ export type MinifierOptions = {
46
46
  caseSensitive?: boolean;
47
47
  /**
48
48
  * Collapse boolean attributes to their name only (for example
49
- * `disabled="disabled"` -> `disabled`).
49
+ * `disabled="disabled"` `disabled`).
50
50
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
51
51
  *
52
52
  * Default: `false`
@@ -326,6 +326,32 @@ export type MinifierOptions = {
326
326
  * Default: `false`
327
327
  */
328
328
  removeEmptyElements?: boolean;
329
+ /**
330
+ * Specifies empty elements to preserve when `removeEmptyElements` is enabled.
331
+ * Has no effect unless `removeEmptyElements: true`.
332
+ *
333
+ * Accepts tag names or HTML-like element specifications:
334
+ *
335
+ * * Tag name only: `["td", "span"]`—preserves all empty elements of these types
336
+ * * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
337
+ * * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
338
+ * * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
339
+ *
340
+ * Attribute matching:
341
+ *
342
+ * * All specified attributes must be present and match (valued attributes must have exact values)
343
+ * * Additional attributes on the element are allowed
344
+ * * Attribute name matching respects the `caseSensitive` option
345
+ * * Supports double quotes, single quotes, and unquoted attribute values in specifications
346
+ *
347
+ * Limitations:
348
+ *
349
+ * * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
350
+ * * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
351
+ *
352
+ * Default: `[]`
353
+ */
354
+ removeEmptyElementsExcept?: string[];
329
355
  /**
330
356
  * Drop optional start/end tags where the HTML specification permits it
331
357
  * (for example `</li>`, optional `<html>` etc.).
@@ -357,7 +383,7 @@ export type MinifierOptions = {
357
383
  */
358
384
  removeStyleLinkTypeAttributes?: boolean;
359
385
  /**
360
- * **Note that this will currently result in invalid HTML!**
386
+ * **Note that this will result in invalid HTML!**
361
387
  *
362
388
  * When true, extra whitespace between tag name and attributes (or before
363
389
  * the closing bracket) will be removed where possible. Affects output spacing
@@ -1 +1 @@
1
- {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AAknDO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UAUS,MAAM;YACN,MAAM;YACN,MAAM;mBACN,MAAM;iBACN,MAAM;kBACN,MAAM;;;;;;;;;;;;;4BAQN,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;wBAMjG,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,SAAS,EAAE,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;;oBAMhH,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;;kCAOP,OAAO;;;;;;;;yBAOP,OAAO;;;;;;;;2BAOP,OAAO;;;;;;;;4BAOP,OAAO;;;;;;;2BAOP,OAAO;;;;;;;;uBAMP,MAAM,EAAE;;;;;;yBAOR,MAAM;;;;;;yBAKN,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;;;;;;;4BAKlB,MAAM,EAAE;;;;;;;oCAMR,MAAM;;;;;;;qBAMN,OAAO;;;;;;;YAMP,OAAO;;;;;;;;2BAMP,MAAM,EAAE;;;;;;;;;4BAOR,MAAM,EAAE;;;;;;;+BAQR,OAAO;;;;;;;2BAMP,SAAS,CAAC,MAAM,CAAC;;;;;;uBAMjB,OAAO;;;;;;;;UAKP,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI;;;;;;;;qBAO1B,MAAM;;;;;;;oBAON,MAAM;;;;;;;;;;gBAMN,OAAO,GAAG,OAAO,CAAC,OAAO,cAAc,EAAE,gBAAgB,CAAC,OAAO,cAAc,EAAE,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;eAS9J,OAAO,GAAG,OAAO,QAAQ,EAAE,aAAa,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;iBASzG,OAAO,GAAG,MAAM,GAAG,OAAO,WAAW,EAAE,OAAO,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;WAS7F,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM;;;;;;;+BAOxB,OAAO;;;;;;;;;;oBAMP,OAAO;;;;;;;;yBASP,OAAO;;;;;;;gCAOP,OAAO;;;;;;;;iCAMP,OAAO;;;;;;;;;;qBAOP,MAAM,EAAE;;;;;;;qBASR,IAAI,GAAG,GAAG;;;;;;;4BAMV,OAAO;;;;;;;;qBAMP,OAAO;;;;;;;;;4BAOP,OAAO,GAAG,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;;;;;;;;0BAQtD,OAAO;;;;;;;;yBAOP,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;iCAOP,OAAO;;;;;;;oCAMP,OAAO;;;;;;;;;;0BAMP,OAAO;;;;;;;;;qBASP,OAAO,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;;;;;;;;;oBAQzD,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;;;;;;;;0BAQrC,OAAO;;;;;;;sBAOP,OAAO;;wBA38DkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
1
+ {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"AAwvDO,8BAJI,MAAM,YACN,eAAe,GACb,OAAO,CAAC,MAAM,CAAC,CAQ3B;;;;;;;;;;;;UAUS,MAAM;YACN,MAAM;YACN,MAAM;mBACN,MAAM;iBACN,MAAM;kBACN,MAAM;;;;;;;;;;;;;4BAQN,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;wBAMjG,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,SAAS,EAAE,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO;;;;;;;;oBAMhH,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;;kCAOP,OAAO;;;;;;;;yBAOP,OAAO;;;;;;;;2BAOP,OAAO;;;;;;;;4BAOP,OAAO;;;;;;;2BAOP,OAAO;;;;;;;;uBAMP,MAAM,EAAE;;;;;;yBAOR,MAAM;;;;;;yBAKN,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE;;;;;;;4BAKlB,MAAM,EAAE;;;;;;;oCAMR,MAAM;;;;;;;qBAMN,OAAO;;;;;;;YAMP,OAAO;;;;;;;;2BAMP,MAAM,EAAE;;;;;;;;;4BAOR,MAAM,EAAE;;;;;;;+BAQR,OAAO;;;;;;;2BAMP,SAAS,CAAC,MAAM,CAAC;;;;;;uBAMjB,OAAO;;;;;;;;UAKP,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI;;;;;;;;qBAO1B,MAAM;;;;;;;oBAON,MAAM;;;;;;;;;;gBAMN,OAAO,GAAG,OAAO,CAAC,OAAO,cAAc,EAAE,gBAAgB,CAAC,OAAO,cAAc,EAAE,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;eAS9J,OAAO,GAAG,OAAO,QAAQ,EAAE,aAAa,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;;;iBASzG,OAAO,GAAG,MAAM,GAAG,OAAO,WAAW,EAAE,OAAO,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;;;;;;;;WAS7F,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM;;;;;;;+BAOxB,OAAO;;;;;;;;;;oBAMP,OAAO;;;;;;;;yBASP,OAAO;;;;;;;gCAOP,OAAO;;;;;;;;iCAMP,OAAO;;;;;;;;;;qBAOP,MAAM,EAAE;;;;;;;qBASR,IAAI,GAAG,GAAG;;;;;;;4BAMV,OAAO;;;;;;;;qBAMP,OAAO;;;;;;;;;4BAOP,OAAO,GAAG,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;;;;;;;;0BAQtD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;gCAOP,MAAM,EAAE;;;;;;;;yBAyBR,OAAO;;;;;;;;gCAOP,OAAO;;;;;;;iCAOP,OAAO;;;;;;;oCAMP,OAAO;;;;;;;;;;0BAMP,OAAO;;;;;;;;;qBASP,OAAO,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;;;;;;;;;oBAQzD,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;;;;;;;;0BAQrC,OAAO;;;;;;;sBAOP,OAAO;;wBA1mEkC,cAAc;0BAAd,cAAc;+BAAd,cAAc"}
package/package.json CHANGED
@@ -84,5 +84,5 @@
84
84
  "test:watch": "node --test --watch tests/*.spec.js"
85
85
  },
86
86
  "type": "module",
87
- "version": "4.7.1"
87
+ "version": "4.8.0"
88
88
  }
@@ -420,9 +420,9 @@ async function cleanAttributeValue(tag, attrName, attrValue, options, attrs, min
420
420
  }))).join(', ');
421
421
  } else if (isMetaViewport(tag, attrs) && attrName === 'content') {
422
422
  attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function (numString) {
423
- // "0.90000" -> "0.9"
424
- // "1.0" -> "1"
425
- // "1.0001" -> "1.0001" (unchanged)
423
+ // 0.90000 0.9
424
+ // 1.0 1
425
+ // 1.0001 1.0001 (unchanged)
426
426
  return (+numString).toString();
427
427
  });
428
428
  } else if (isContentSecurityPolicy(tag, attrs) && attrName.toLowerCase() === 'content') {
@@ -693,6 +693,107 @@ function canRemoveElement(tag, attrs) {
693
693
  return true;
694
694
  }
695
695
 
696
+ function parseElementSpec(str, options) {
697
+ if (typeof str !== 'string') {
698
+ return null;
699
+ }
700
+
701
+ const trimmed = str.trim();
702
+ if (!trimmed) {
703
+ return null;
704
+ }
705
+
706
+ // Simple tag name: “td”
707
+ if (!/[<>]/.test(trimmed)) {
708
+ return { tag: options.name(trimmed), attrs: null };
709
+ }
710
+
711
+ // HTML-like markup: “<span aria-hidden='true'>” or “<td></td>”
712
+ // Extract opening tag using regex
713
+ const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
714
+ if (!match) {
715
+ return null;
716
+ }
717
+
718
+ const tag = options.name(match[1]);
719
+ const attrString = match[2];
720
+
721
+ if (!attrString.trim()) {
722
+ return { tag, attrs: null };
723
+ }
724
+
725
+ // Parse attributes from string
726
+ const attrs = {};
727
+ const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
728
+ let attrMatch;
729
+
730
+ while ((attrMatch = attrRegex.exec(attrString))) {
731
+ const attrName = options.name(attrMatch[1]);
732
+ const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
733
+ // Boolean attributes have no value (undefined)
734
+ attrs[attrName] = attrValue;
735
+ }
736
+
737
+ return {
738
+ tag,
739
+ attrs: Object.keys(attrs).length > 0 ? attrs : null
740
+ };
741
+ }
742
+
743
+ function parseRemoveEmptyElementsExcept(input, options) {
744
+ if (!Array.isArray(input)) {
745
+ return [];
746
+ }
747
+
748
+ return input.map(item => {
749
+ if (typeof item === 'string') {
750
+ const spec = parseElementSpec(item, options);
751
+ if (!spec && options.log) {
752
+ options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
753
+ }
754
+ return spec;
755
+ }
756
+ if (options.log) {
757
+ options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
758
+ }
759
+ return null;
760
+ }).filter(Boolean);
761
+ }
762
+
763
+ function shouldPreserveEmptyElement(tag, attrs, preserveList) {
764
+ for (const spec of preserveList) {
765
+ // Tag name must match
766
+ if (spec.tag !== tag) {
767
+ continue;
768
+ }
769
+
770
+ // If no attributes specified in spec, tag match is enough
771
+ if (!spec.attrs) {
772
+ return true;
773
+ }
774
+
775
+ // Check if all specified attributes match
776
+ const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
777
+ const attr = attrs.find(a => a.name === name);
778
+ if (!attr) {
779
+ return false; // Attribute not present
780
+ }
781
+ // Boolean attribute in spec (undefined value) matches if attribute is present
782
+ if (value === undefined) {
783
+ return true;
784
+ }
785
+ // Valued attribute must match exactly
786
+ return attr.value === value;
787
+ });
788
+
789
+ if (allAttrsMatch) {
790
+ return true;
791
+ }
792
+ }
793
+
794
+ return false;
795
+ }
796
+
696
797
  function canCollapseWhitespace(tag) {
697
798
  return !/^(?:script|style|pre|textarea)$/.test(tag);
698
799
  }
@@ -1142,6 +1243,17 @@ async function minifyHTML(value, options, partialMarkup) {
1142
1243
  const inlineTextSet = new Set([...inlineElementsToKeepWhitespaceWithin, ...normalizedCustomElements]);
1143
1244
  const inlineElements = new Set([...inlineElementsToKeepWhitespaceAround, ...normalizedCustomElements]);
1144
1245
 
1246
+ // Parse `removeEmptyElementsExcept` option
1247
+ let removeEmptyElementsExcept;
1248
+ if (options.removeEmptyElementsExcept && !Array.isArray(options.removeEmptyElementsExcept)) {
1249
+ if (options.log) {
1250
+ options.log('Warning: "removeEmptyElementsExcept" option must be an array, received: ' + typeof options.removeEmptyElementsExcept);
1251
+ }
1252
+ removeEmptyElementsExcept = [];
1253
+ } else {
1254
+ removeEmptyElementsExcept = parseRemoveEmptyElementsExcept(options.removeEmptyElementsExcept, options) || [];
1255
+ }
1256
+
1145
1257
  // Temporarily replace ignored chunks with comments,
1146
1258
  // so that we don’t have to worry what’s there.
1147
1259
  // For all we care there might be
@@ -1169,7 +1281,7 @@ async function minifyHTML(value, options, partialMarkup) {
1169
1281
  // Warn about potential ReDoS if custom fragments use unlimited quantifiers
1170
1282
  for (let i = 0; i < customFragments.length; i++) {
1171
1283
  if (/[*+]/.test(customFragments[i])) {
1172
- options.log('Warning: Custom fragment contains unlimited quantifiers (* or +) which may cause ReDoS vulnerability');
1284
+ options.log('Warning: Custom fragment contains unlimited quantifiers (“*” or “+”) which may cause ReDoS vulnerability');
1173
1285
  break;
1174
1286
  }
1175
1287
  }
@@ -1412,10 +1524,32 @@ async function minifyHTML(value, options, partialMarkup) {
1412
1524
  }
1413
1525
 
1414
1526
  if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
1415
- // Remove last “element” from buffer
1416
- removeStartTag();
1417
- optionalStartTag = '';
1418
- optionalEndTag = '';
1527
+ let preserve = false;
1528
+ if (removeEmptyElementsExcept.length) {
1529
+ // Normalize attribute names for comparison with specs
1530
+ const normalizedAttrs = attrs.map(attr => ({ ...attr, name: options.name(attr.name) }));
1531
+ preserve = shouldPreserveEmptyElement(tag, normalizedAttrs, removeEmptyElementsExcept);
1532
+ }
1533
+
1534
+ if (!preserve) {
1535
+ // Remove last “element” from buffer
1536
+ removeStartTag();
1537
+ optionalStartTag = '';
1538
+ optionalEndTag = '';
1539
+ } else {
1540
+ // Preserve the element - add closing tag
1541
+ if (autoGenerated && !options.includeAutoGeneratedTags) {
1542
+ optionalEndTag = '';
1543
+ } else {
1544
+ buffer.push('</' + tag + '>');
1545
+ }
1546
+ charsPrevTag = '/' + tag;
1547
+ if (!inlineElements.has(tag)) {
1548
+ currentChars = '';
1549
+ } else if (isElementEmpty) {
1550
+ currentChars += '|';
1551
+ }
1552
+ }
1419
1553
  } else {
1420
1554
  if (autoGenerated && !options.includeAutoGeneratedTags) {
1421
1555
  optionalEndTag = '';
@@ -1698,7 +1832,7 @@ export default { minify, presets, getPreset, getPresetNames };
1698
1832
  *
1699
1833
  * @prop {boolean} [collapseBooleanAttributes]
1700
1834
  * Collapse boolean attributes to their name only (for example
1701
- * `disabled="disabled"` -> `disabled`).
1835
+ * `disabled="disabled"` `disabled`).
1702
1836
  * See also: https://perfectionkills.com/experimenting-with-html-minifier/#collapse_boolean_attributes
1703
1837
  *
1704
1838
  * Default: `false`
@@ -1942,6 +2076,31 @@ export default { minify, presets, getPreset, getPresetNames };
1942
2076
  *
1943
2077
  * Default: `false`
1944
2078
  *
2079
+ * @prop {string[]} [removeEmptyElementsExcept]
2080
+ * Specifies empty elements to preserve when `removeEmptyElements` is enabled.
2081
+ * Has no effect unless `removeEmptyElements: true`.
2082
+ *
2083
+ * Accepts tag names or HTML-like element specifications:
2084
+ *
2085
+ * * Tag name only: `["td", "span"]`—preserves all empty elements of these types
2086
+ * * With valued attributes: `["<span aria-hidden='true'>"]`—preserves only when attribute values match
2087
+ * * With boolean attributes: `["<input disabled>"]`—preserves only when boolean attribute is present
2088
+ * * Mixed: `["<button type='button' disabled>"]`—all specified attributes must match
2089
+ *
2090
+ * Attribute matching:
2091
+ *
2092
+ * * All specified attributes must be present and match (valued attributes must have exact values)
2093
+ * * Additional attributes on the element are allowed
2094
+ * * Attribute name matching respects the `caseSensitive` option
2095
+ * * Supports double quotes, single quotes, and unquoted attribute values in specifications
2096
+ *
2097
+ * Limitations:
2098
+ *
2099
+ * * Self-closing syntax (e.g., `["<span/>"]`) is not supported; use `["span"]` instead
2100
+ * * Definitions containing `>` within quoted attribute values (e.g., `["<span title='a>b'>"]`) are not supported
2101
+ *
2102
+ * Default: `[]`
2103
+ *
1945
2104
  * @prop {boolean} [removeOptionalTags]
1946
2105
  * Drop optional start/end tags where the HTML specification permits it
1947
2106
  * (for example `</li>`, optional `<html>` etc.).
@@ -1969,7 +2128,7 @@ export default { minify, presets, getPreset, getPresetNames };
1969
2128
  * Default: `false`
1970
2129
  *
1971
2130
  * @prop {boolean} [removeTagWhitespace]
1972
- * **Note that this will currently result in invalid HTML!**
2131
+ * **Note that this will result in invalid HTML!**
1973
2132
  *
1974
2133
  * When true, extra whitespace between tag name and attributes (or before
1975
2134
  * the closing bracket) will be removed where possible. Affects output spacing