html-minifier 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.
@@ -17,7 +17,10 @@ import {
17
17
  RE_WS_ONLY,
18
18
  RE_WS_NBSP_END,
19
19
  RE_STYLE_ELEMENT,
20
+ RE_ENDS_COLLAPSIBLE,
21
+ RE_DOCTYPE_SEGMENT,
20
22
  formattingElements,
23
+ inlineElementsToKeepWhitespace,
21
24
  inlineElementsToKeepWhitespaceAround,
22
25
  inlineElementsToKeepWhitespaceWithin,
23
26
  inlineElementsTransparent,
@@ -78,7 +81,7 @@ import {
78
81
  } from './lib/content.js';
79
82
 
80
83
  import { processOptions } from './lib/options.js';
81
- import { toFragment, replaceCustomFragments } from './lib/fragments.js';
84
+ import { toFragment, replaceCustomFragments, isMinifiedCodeSafe } from './lib/fragments.js';
82
85
 
83
86
  /** @import { ProcessedOptions } from './lib/options.js' */
84
87
 
@@ -826,13 +829,13 @@ function mergeConsecutiveScripts(html, hasMathML = false) {
826
829
  }
827
830
  if (boolConflict || a1.nonce !== a2.nonce || (!mergedAny && insideMath(m1.index))) break;
828
831
 
829
- // Join with a semicolon, or with a newline where the first ends in a `//` comment
832
+ // Join with a semicolon, after a newline where the first may end in a `//` comment
830
833
  const content2 = html.slice(tagEnd2 + 1, close2.index);
831
834
  const c1 = mergedContent.trim();
832
835
  const c2 = content2.trim();
833
836
  if (c1 && c2) {
834
837
  const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
835
- const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
838
+ const separator = lastLine.includes('//') ? '\n;' : (c1.endsWith(';') ? '' : ';');
836
839
  mergedContent = c1 + separator + c2;
837
840
  } else {
838
841
  mergedContent = c1 || c2;
@@ -1124,8 +1127,12 @@ async function minifyHTML(value, options, partialMarkup) {
1124
1127
  // Open elements under `removeEmptyElements`, so an element whose content
1125
1128
  // went is removed, too: where its start tag sits in `buffer`, and whether
1126
1129
  // anything kept went in since
1127
- /** @type {Array<{tag: string, index: number, empty: boolean}>} */
1130
+ /** @type {Array<{tag: string, index: number, empty: boolean, omittedStartTag?: {tag: string, text: string} | undefined, omittedEndTag?: {tag: string, text: string} | undefined, impliedEnds?: Array<{tag: string, text: string}> | undefined}>} */
1128
1131
  const openElements = [];
1132
+ // End tags not written, for the start tag that comes next (`text` is empty where one can’t be)
1133
+ /** @type {Array<{tag: string, text: string}>} */
1134
+ let impliedEnds = [];
1135
+ let impliedEndsIndex = -1;
1129
1136
  // Under `selfCloseForeignElements`, the open `svg` and `math` elements and the integration points
1130
1137
  // in them, whose children are HTML, which a slash does not close
1131
1138
  /** @type {string[]} */
@@ -1149,7 +1156,7 @@ async function minifyHTML(value, options, partialMarkup) {
1149
1156
  const stackNoTrimWhitespace = [];
1150
1157
  /** @type {string[]} */
1151
1158
  const stackNoCollapseWhitespace = [];
1152
- let preTextareaDepth = 0; // Count of `pre`/`textarea` entries in `stackNoTrimWhitespace`
1159
+ let preDepth = 0; // Count of `pre` entries in `stackNoTrimWhitespace`
1153
1160
  // Where the last element whose whitespace may not be trimmed ended in `buffer`;
1154
1161
  // what lies up to there stays as written, also where `canTrimWhitespace` decides
1155
1162
  // by attributes the trim walk lacks
@@ -1263,31 +1270,32 @@ async function minifyHTML(value, options, partialMarkup) {
1263
1270
  uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
1264
1271
  uidAttrExactPattern = new RegExp('^' + uidAttr + '(\\d+)' + uidAttr + '$');
1265
1272
 
1266
- if (options.minifyCSS !== identity) {
1267
- options.minifyCSS = (function (/** @type {ProcessedOptions['minifyCSS']} */ fn) {
1268
- return function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {ProcessedOptions['cssContext']} */ context) {
1269
- text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1270
- const chunks = ignoredCustomMarkupChunks[+index];
1271
- customMarkupInCode.add(+index);
1272
- return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1273
- });
1274
-
1275
- return fn(text, type, context);
1276
- };
1277
- })(options.minifyCSS);
1278
- }
1279
-
1280
- if (options.minifyJS !== identity) {
1281
- options.minifyJS = (function (/** @type {ProcessedOptions['minifyJS']} */ fn) {
1282
- return function (/** @type {string} */ text, /** @type {boolean | undefined} */ inline, /** @type {boolean | undefined} */ isModule) {
1283
- return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1284
- const chunks = ignoredCustomMarkupChunks[+index];
1285
- customMarkupInCode.add(+index);
1286
- return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1287
- }), inline, isModule);
1288
- };
1289
- })(options.minifyJS);
1290
- }
1273
+ const uidPlaceholderPattern = new RegExp(uidAttr + '\\d+' + uidAttr, 'g');
1274
+
1275
+ /**
1276
+ * @param {(text: string, ...args: any[]) => any} fn
1277
+ * @param {boolean} isJS
1278
+ * @returns {any}
1279
+ */
1280
+ const wrapCodeMinifier = (fn, isJS) => (/** @type {string} */ text, /** @type {any[]} */ ...args) => {
1281
+ if (!text.includes(/** @type {string} */ (uidAttr))) return fn(text, ...args);
1282
+
1283
+ const source = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1284
+ const chunks = ignoredCustomMarkupChunks[+index];
1285
+ customMarkupInCode.add(+index);
1286
+ return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1287
+ });
1288
+ const result = fn(source, ...args);
1289
+ const finish = (/** @type {string} */ output) => {
1290
+ if (isMinifiedCodeSafe(source, output, uidPlaceholderPattern, isJS)) return output;
1291
+ options.log('Keeping CSS or JavaScript as written, as minifying could break or lose a custom fragment');
1292
+ return source;
1293
+ };
1294
+ return isThenable(result) ? Promise.resolve(result).then(finish) : finish(result);
1295
+ };
1296
+
1297
+ if (options.minifyCSS !== identity) options.minifyCSS = wrapCodeMinifier(options.minifyCSS, false);
1298
+ if (options.minifyJS !== identity) options.minifyJS = wrapCodeMinifier(options.minifyJS, true);
1291
1299
  }
1292
1300
 
1293
1301
  const token = uidAttr + ignoredCustomMarkupChunks.length + uidAttr;
@@ -1502,8 +1510,11 @@ async function minifyHTML(value, options, partialMarkup) {
1502
1510
  return index;
1503
1511
  }
1504
1512
 
1505
- function removeStartTag() {
1506
- bufferTruncate(lastStartTagIndex());
1513
+ function removeStartTag(/** @type {boolean} */ capture = false) {
1514
+ const index = lastStartTagIndex();
1515
+ const text = capture ? buffer.slice(index).join('') : undefined;
1516
+ bufferTruncate(index);
1517
+ return text;
1507
1518
  }
1508
1519
 
1509
1520
  // Write the last start tag as self-closing, where it is the last thing written and holds no
@@ -1548,6 +1559,26 @@ async function minifyHTML(value, options, partialMarkup) {
1548
1559
  return lowerTag === 'svg' || lowerTag === 'math' || scope === undefined || scope === 'svg' || scope === 'math';
1549
1560
  }
1550
1561
 
1562
+ // Browsers can close a formatting element with others left open inside it;
1563
+ // any other end tag that doesn’t match the top entry of a stack is stray
1564
+ function canCloseBelowTop(/** @type {string} */ tag, /** @type {string} */ lowerTag) {
1565
+ return formattingElements.has((lowerTag || tag).toLowerCase());
1566
+ }
1567
+
1568
+ // Returns whether `tag` was open
1569
+ function removeClosedTag(/** @type {string[]} */ tags, /** @type {string} */ tag, /** @type {string} */ lowerTag) {
1570
+ if (tags[tags.length - 1] === tag) {
1571
+ tags.pop();
1572
+ return true;
1573
+ }
1574
+ const index = canCloseBelowTop(tag, lowerTag) ? tags.lastIndexOf(tag) : -1;
1575
+ if (index === -1) {
1576
+ return false;
1577
+ }
1578
+ tags.splice(index, 1);
1579
+ return true;
1580
+ }
1581
+
1551
1582
  function markOpenElementFilled() {
1552
1583
  // Guarded, as reading index -1 is a slow property lookup, and this runs for every text node
1553
1584
  if (openElements.length) {
@@ -1595,11 +1626,12 @@ async function minifyHTML(value, options, partialMarkup) {
1595
1626
  while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
1596
1627
  index--;
1597
1628
  }
1629
+ const text = buffer[index] ?? '';
1598
1630
  // Text that follows the end tag is kept as is, so drop the tag alone. Whitespace kept
1599
1631
  // verbatim—in `pre`, in `textarea`, or wherever `canTrimWhitespace` says so—counts as
1600
1632
  // kept text too, no matter how whitespace is collapsed elsewhere.
1601
1633
  const noTrim = insideNoTrim || stackNoTrimWhitespace.length > 0;
1602
- if (index < buffer.length - 1 && (keepsWhitespace() || noTrim) && RE_END_TAG.test(buffer[index] ?? '')) {
1634
+ if (index < buffer.length - 1 && (keepsWhitespace() || noTrim) && RE_END_TAG.test(text)) {
1603
1635
  buffer.splice(index, 1);
1604
1636
  bufferTags?.splice(index, 1);
1605
1637
  // Collapsed whitespace can merge, kept whitespace can leave a line of only whitespace
@@ -1610,9 +1642,10 @@ async function minifyHTML(value, options, partialMarkup) {
1610
1642
  removeWhitespaceLine(index);
1611
1643
  }
1612
1644
  }
1613
- return;
1645
+ return text;
1614
1646
  }
1615
1647
  bufferTruncate(index);
1648
+ return text;
1616
1649
  }
1617
1650
 
1618
1651
  // Look for trailing whitespace, bypassing inline tags
@@ -1620,11 +1653,14 @@ async function minifyHTML(value, options, partialMarkup) {
1620
1653
  // Nothing to the left of the text is in play here, so only its trailing run can
1621
1654
  // change—unless preserved line breaks bring the leading run into play, too
1622
1655
  const trailingOnly = !options.preserveLineBreaks;
1623
- for (let prevTag = ''; index > noTrimEndIndex && canTrimWhitespace(namesAsWritten ? prevTag.toLowerCase() : prevTag, prevTag, emptyAttrs); index--) {
1656
+ let prevTag = '';
1657
+ let prevTagLower = '';
1658
+ for (; index > noTrimEndIndex && canTrimWhitespace(prevTagLower, prevTag, emptyAttrs); index--) {
1624
1659
  const str = buffer[index] ?? '';
1625
1660
  const endTagName = matchPlainEndTag(str);
1626
1661
  if (endTagName !== null) {
1627
1662
  prevTag = endTagName;
1663
+ prevTagLower = prevTag.toLowerCase();
1628
1664
  continue;
1629
1665
  }
1630
1666
  // A tag ends the search, and so does text that survives collapsing. Text with no
@@ -1771,11 +1807,11 @@ async function minifyHTML(value, options, partialMarkup) {
1771
1807
 
1772
1808
  // Whitespace collapsing phase (sync)
1773
1809
  function charsCollapse(/** @type {string} */ text) {
1774
- // Trim a single trailing newline inside `pre`/`textarea`, often a template engine artifact
1775
- // (several are likely intentional formatting)
1810
+ // Trim a single trailing newline inside `pre`, often a template engine artifact (several are
1811
+ // likely intentional formatting); in `textarea`, it is part of the value
1776
1812
  if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
1777
1813
  const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
1778
- if (preTextareaDepth > 0) {
1814
+ if (preDepth > 0 && topTag?.toLowerCase() !== 'textarea') {
1779
1815
  // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
1780
1816
  if (textNextTag && textNextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
1781
1817
  text = text.replace(RE_LINE_BREAK_BLANKS_END, '');
@@ -1799,6 +1835,10 @@ async function minifyHTML(value, options, partialMarkup) {
1799
1835
  const charsIndex = buffer.length - 2;
1800
1836
  buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
1801
1837
  text = trailingSpaces + text;
1838
+ // It moved, and is judged again as part of this text
1839
+ if (currentChars.endsWith(trailingSpaces)) {
1840
+ currentChars = currentChars.slice(0, -trailingSpaces.length);
1841
+ }
1802
1842
  return '';
1803
1843
  });
1804
1844
  }
@@ -1831,7 +1871,7 @@ async function minifyHTML(value, options, partialMarkup) {
1831
1871
  trimTrailingWhitespace(tagIndex - 1, 'br');
1832
1872
  }
1833
1873
  } else if (inlineSets.withinEither.has(textPrevTag)) {
1834
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars), false, false, stackNoCollapseWhitespace.length > 0);
1874
+ text = collapseWhitespace(text, options, RE_ENDS_COLLAPSIBLE.test(currentChars), false, false, stackNoCollapseWhitespace.length > 0);
1835
1875
  }
1836
1876
  }
1837
1877
  if (textPrevTag || textNextTag) {
@@ -2146,8 +2186,9 @@ async function minifyHTML(value, options, partialMarkup) {
2146
2186
  * @param {string | boolean | undefined} hasUnarySlash
2147
2187
  * @param {boolean | undefined} optional
2148
2188
  * @param {ProcessedOptions | null} restoreOptions - Context a unary tag pushed, or null
2189
+ * @param {ProcessedOptions} optionsTag - Context the element’s own attributes are in
2149
2190
  */
2150
- function emitStartTag(normalizedAttrs, tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions) {
2191
+ function emitStartTag(normalizedAttrs, tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions, optionsTag) {
2151
2192
  // Find the last kept attribute, then emit in order, with no intermediate array
2152
2193
  let lastKeptIndex = -1;
2153
2194
  for (let i = normalizedAttrs.length - 1; i >= 0; i--) {
@@ -2169,7 +2210,7 @@ async function minifyHTML(value, options, partialMarkup) {
2169
2210
  if (uidAttr && normalizedAttr.name.includes(uidAttr)) {
2170
2211
  markTagFragments(normalizedAttr.name);
2171
2212
  }
2172
- tagText += buildAttr(normalizedAttr, hasUnarySlash, options, i === lastKeptIndex, uidAttr);
2213
+ tagText += buildAttr(normalizedAttr, tag, hasUnarySlash, optionsTag, i === lastKeptIndex, uidAttr, markers);
2173
2214
  }
2174
2215
  }
2175
2216
  } else {
@@ -2182,7 +2223,7 @@ async function minifyHTML(value, options, partialMarkup) {
2182
2223
  if (uidAttr && normalizedAttr.name.includes(uidAttr)) {
2183
2224
  markTagFragments(normalizedAttr.name);
2184
2225
  }
2185
- const builtAttr = buildAttr(normalizedAttr, hasUnarySlash, options, i === lastKeptIndex, uidAttr);
2226
+ const builtAttr = buildAttr(normalizedAttr, tag, hasUnarySlash, optionsTag, i === lastKeptIndex, uidAttr, markers);
2186
2227
  // One entry with the tag name, so that no line break can come between them
2187
2228
  if (i === 0 && fragmentJoinsTag) {
2188
2229
  buffer[buffer.length - 1] += builtAttr;
@@ -2338,12 +2379,21 @@ async function minifyHTML(value, options, partialMarkup) {
2338
2379
  }
2339
2380
  currentTag = tag;
2340
2381
  charsPrevTag = tag;
2341
- if (!inlineTextSet.has(tag)) {
2342
- currentChars = '';
2382
+ if (!inlineTextSet.has(lowerTag || tag)) {
2383
+ // Replaced content counts as content for the whitespace after what holds it
2384
+ currentChars = inlineElementsToKeepWhitespace.has(lowerTag || tag) ? '|' : '';
2343
2385
  }
2344
2386
  hasChars = false;
2345
2387
  currentAttrs = attrs;
2346
2388
 
2389
+ // What this start tag closed, which is left to the end tags if its element is removed
2390
+ const impliedEndsClosed = impliedEndsIndex === buffer.length ? impliedEnds : undefined;
2391
+
2392
+ // What this start tag made optional, for if the element then turns out to be removed
2393
+ /** @type {{tag: string, text: string} | undefined} */
2394
+ let omittedStartTag;
2395
+ /** @type {{tag: string, text: string} | undefined} */
2396
+ let omittedEndTag;
2347
2397
  let optional = options.removeOptionalTags;
2348
2398
  if (optional) {
2349
2399
  const htmlTag = htmlElements.has(tag);
@@ -2354,7 +2404,10 @@ async function minifyHTML(value, options, partialMarkup) {
2354
2404
  // `<colgroup>` may be omitted if first thing inside is `<col>`
2355
2405
  // `<tbody>` may be omitted if first thing inside is `<tr>`
2356
2406
  if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
2357
- removeStartTag();
2407
+ const text = removeStartTag(options.removeEmptyElements);
2408
+ if (text !== undefined) {
2409
+ omittedStartTag = { tag: optionalStartTag, text };
2410
+ }
2358
2411
  }
2359
2412
  optionalStartTag = '';
2360
2413
  // End-tag-followed-by-start-tag omission rules
@@ -2362,7 +2415,10 @@ async function minifyHTML(value, options, partialMarkup) {
2362
2415
  if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag) && (tableClosesP || optionalEndTag !== 'p' || tag !== 'table')) {
2363
2416
  // Only a start tag that gets written can stand in for the end tag
2364
2417
  if (optionalEndTagEmitted && (!autoGenerated || options.includeAutoGeneratedTags)) {
2365
- removeEndTag();
2418
+ const text = removeEndTag();
2419
+ if (options.removeEmptyElements) {
2420
+ omittedEndTag = { tag: optionalEndTag, text };
2421
+ }
2366
2422
  }
2367
2423
  // `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
2368
2424
  // `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is
@@ -2376,17 +2432,18 @@ async function minifyHTML(value, options, partialMarkup) {
2376
2432
  // Set whitespace flags for nested tags (e.g., `<code>` within a `<pre>`); verbatim
2377
2433
  // whitespace is tracked without `collapseWhitespace` too, as `removeWhitespaceLine` spares it
2378
2434
  if (options.collapseWhitespace && !stackNoTrimWhitespace.length) {
2379
- squashTrailingWhitespace(tag);
2435
+ squashTrailingWhitespace(lowerTag || tag);
2380
2436
  }
2381
- if (options.collapseWhitespace && !unary && inlineElementsTransparent.has(tag)) {
2437
+ if (options.collapseWhitespace && !unary && inlineElementsTransparent.has(lowerTag || tag)) {
2382
2438
  transparentDepth++;
2383
2439
  }
2384
2440
  if (!unary) {
2385
- if (!canTrimWhitespace(tag, tagOut, attrs) || stackNoTrimWhitespace.length) {
2441
+ const tagLower = lowerTag || tag;
2442
+ if (!canTrimWhitespace(tagLower, tagOut, attrs) || stackNoTrimWhitespace.length) {
2386
2443
  stackNoTrimWhitespace.push(tag);
2387
- if (tag === 'pre' || tag === 'textarea') preTextareaDepth++;
2444
+ if (tagLower === 'pre') preDepth++;
2388
2445
  }
2389
- if (options.collapseWhitespace && (!canCollapseWhitespace(tag, tagOut, attrs) || stackNoCollapseWhitespace.length)) {
2446
+ if (options.collapseWhitespace && (!canCollapseWhitespace(tagLower, tagOut, attrs) || stackNoCollapseWhitespace.length)) {
2390
2447
  stackNoCollapseWhitespace.push(tag);
2391
2448
  }
2392
2449
  }
@@ -2403,7 +2460,9 @@ async function minifyHTML(value, options, partialMarkup) {
2403
2460
  // SVGO reads the whole SVG block as XML, where an element closes itself or not, so
2404
2461
  // every unary element in there is written with a slash
2405
2462
  const needsXMLSlash = unary && Boolean(options.insideSVG) && Boolean(options.minifySVG);
2406
- const hasUnarySlash = needsXMLSlash || (unarySlash && (useNameParentForTag ? optionsParent : options).keepClosingSlash);
2463
+ // `foreignObject` and `annotation-xml` hold HTML, but are foreign elements themselves
2464
+ const optionsTag = useNameParentForTag ? optionsParent : options;
2465
+ const hasUnarySlash = needsXMLSlash || (unarySlash && optionsTag.keepClosingSlash);
2407
2466
 
2408
2467
  bufferPush(openTag, true);
2409
2468
 
@@ -2412,7 +2471,7 @@ async function minifyHTML(value, options, partialMarkup) {
2412
2471
  markOpenElementFilled();
2413
2472
  } else {
2414
2473
  // A start tag not written can’t be removed with its element
2415
- openElements.push({ tag, index: buffer.length - 1, empty: !autoGenerated || Boolean(options.includeAutoGeneratedTags) });
2474
+ openElements.push({ tag, index: buffer.length - 1, empty: !autoGenerated || Boolean(options.includeAutoGeneratedTags), omittedStartTag, omittedEndTag, impliedEnds: impliedEndsClosed });
2416
2475
  }
2417
2476
  }
2418
2477
 
@@ -2450,10 +2509,10 @@ async function minifyHTML(value, options, partialMarkup) {
2450
2509
  const restoreOptions = unary && pushedContext ? optionsParent : null;
2451
2510
  if (anyThenable) {
2452
2511
  return Promise.all(attrResults).then(resolved => {
2453
- emitStartTag(resolved, tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions);
2512
+ emitStartTag(resolved, tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions, optionsTag);
2454
2513
  });
2455
2514
  }
2456
- emitStartTag(/** @type {Array<{name: string, value: string | undefined, attr: HTMLAttribute} | undefined>} */ (attrResults), tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions);
2515
+ emitStartTag(/** @type {Array<{name: string, value: string | undefined, attr: HTMLAttribute} | undefined>} */ (attrResults), tag, unary, autoGenerated, fragmentJoinsTag, hasUnarySlash, optional, restoreOptions, optionsTag);
2457
2516
  return undefined;
2458
2517
  },
2459
2518
  end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated, /** @type {string | undefined} */ rest, /** @type {boolean | undefined} */ holdsTable) {
@@ -2505,21 +2564,19 @@ async function minifyHTML(value, options, partialMarkup) {
2505
2564
  const insideNoTrim = stackNoTrimWhitespace.length > 0;
2506
2565
  let closesNoTrim = false;
2507
2566
  if (stackNoTrimWhitespace.length) {
2508
- if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
2509
- if (tag === 'pre' || tag === 'textarea') preTextareaDepth--;
2510
- stackNoTrimWhitespace.pop();
2567
+ if (removeClosedTag(stackNoTrimWhitespace, tag, lowerTag)) {
2568
+ const tagLower = lowerTag || tag;
2569
+ if (tagLower === 'pre') preDepth--;
2511
2570
  closesNoTrim = !stackNoTrimWhitespace.length;
2512
2571
  }
2513
2572
  } else if (options.collapseWhitespace) {
2514
- squashTrailingWhitespace('/' + tag);
2573
+ squashTrailingWhitespace('/' + (lowerTag || tag));
2515
2574
  }
2516
- if (transparentDepth && inlineElementsTransparent.has(tag)) {
2575
+ if (transparentDepth && inlineElementsTransparent.has(lowerTag || tag)) {
2517
2576
  transparentDepth--;
2518
2577
  }
2519
2578
  let closesNoCollapse = false;
2520
- if (stackNoCollapseWhitespace.length &&
2521
- tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
2522
- stackNoCollapseWhitespace.pop();
2579
+ if (stackNoCollapseWhitespace.length && removeClosedTag(stackNoCollapseWhitespace, tag, lowerTag)) {
2523
2580
  closesNoCollapse = !stackNoCollapseWhitespace.length;
2524
2581
  }
2525
2582
 
@@ -2530,6 +2587,7 @@ async function minifyHTML(value, options, partialMarkup) {
2530
2587
  const skipsEndTag = autoGenerated && (!options.includeAutoGeneratedTags || Boolean(holdsTable));
2531
2588
  impliedStartTag = '';
2532
2589
 
2590
+ let startTagRemoved = false;
2533
2591
  let isElementEmpty = false;
2534
2592
  if (tag === currentTag) {
2535
2593
  currentTag = '';
@@ -2537,7 +2595,19 @@ async function minifyHTML(value, options, partialMarkup) {
2537
2595
  }
2538
2596
  selfCloses &&= isElementEmpty;
2539
2597
  // A stray end tag has no open element of its own
2540
- const openElement = options.removeEmptyElements && openElements[openElements.length - 1]?.tag === tag ? openElements.pop() : undefined;
2598
+ let openElement;
2599
+ if (options.removeEmptyElements && openElements.length) {
2600
+ if (openElements[openElements.length - 1]?.tag === tag) {
2601
+ openElement = openElements.pop();
2602
+ } else if (canCloseBelowTop(tag, lowerTag)) {
2603
+ const index = openElements.findLastIndex(entry => entry.tag === tag);
2604
+ if (index !== -1) {
2605
+ openElement = openElements.splice(index, 1)[0];
2606
+ // Closed around what is open inside it, so that holds this end tag now
2607
+ currentTag = '';
2608
+ }
2609
+ }
2610
+ }
2541
2611
  // Emptied by removing what it held, as long as its start tag is still the last one written
2542
2612
  const isElementEmptied = !isElementEmpty && Boolean(openElement?.empty) && lastStartTagIndex() === openElement?.index;
2543
2613
 
@@ -2545,6 +2615,7 @@ async function minifyHTML(value, options, partialMarkup) {
2545
2615
  // `<html>`, `<head>`, or `<body>` may be omitted if the element is empty
2546
2616
  if (isElementEmpty && topLevelElements.has(optionalStartTag)) {
2547
2617
  removeStartTag();
2618
+ startTagRemoved = true;
2548
2619
  }
2549
2620
  optionalStartTag = '';
2550
2621
  // `</html>` or `</body>` may be omitted if not followed by comment
@@ -2563,7 +2634,7 @@ async function minifyHTML(value, options, partialMarkup) {
2563
2634
  let isElementRemoved = false;
2564
2635
  // A formatting element closed by a block is reopened after it by browsers, so it has to stay
2565
2636
  if (options.removeEmptyElements && (isElementEmpty || isElementEmptied) && !options.insideForeignContent && canRemoveElement(tag, attrs) &&
2566
- !(autoGenerated && formattingElements.has(tag))) {
2637
+ !(autoGenerated && formattingElements.has(tag)) && !openElement?.impliedEnds?.some(implied => !implied.text)) {
2567
2638
  let preserve = false;
2568
2639
  if (removeEmptyElementsExcept.length) {
2569
2640
  const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
@@ -2571,11 +2642,37 @@ async function minifyHTML(value, options, partialMarkup) {
2571
2642
  }
2572
2643
 
2573
2644
  if (!preserve) {
2574
- removeStartTag();
2645
+ // Once it is gone, the last start tag is another element’s
2646
+ if (!startTagRemoved) {
2647
+ removeStartTag();
2648
+ }
2575
2649
  isElementRemoved = true;
2576
2650
  optionalStartTag = '';
2577
2651
  optionalEndTag = '';
2578
2652
  optionalEndTagEmitted = false;
2653
+ // What it made optional is up to what comes next now
2654
+ if (openElement?.omittedEndTag) {
2655
+ bufferPush(openElement.omittedEndTag.text, true);
2656
+ optionalEndTag = openElement.omittedEndTag.tag;
2657
+ optionalEndTagEmitted = true;
2658
+ }
2659
+ if (openElement?.omittedStartTag) {
2660
+ bufferPush(openElement.omittedStartTag.text, true);
2661
+ optionalStartTag = openElement.omittedStartTag.tag;
2662
+ }
2663
+ if (openElement?.impliedEnds) {
2664
+ // Browsers reopen formatting elements after what closed them, which their end tags
2665
+ // would prevent and whitespace following an omitted end tag would change
2666
+ const written = openElement.impliedEnds.filter(implied => !formattingElements.has(implied.tag));
2667
+ for (const implied of written) {
2668
+ bufferPush(implied.text, true);
2669
+ }
2670
+ const last = written[written.length - 1];
2671
+ if (last && written.length === openElement.impliedEnds.length && optionalEndTags.has(last.tag)) {
2672
+ optionalEndTag = last.tag;
2673
+ optionalEndTagEmitted = true;
2674
+ }
2675
+ }
2579
2676
  } else {
2580
2677
  // Preserve the element—add closing tag
2581
2678
  if (skipsEndTag) {
@@ -2584,9 +2681,9 @@ async function minifyHTML(value, options, partialMarkup) {
2584
2681
  bufferPush(endTagText, true);
2585
2682
  }
2586
2683
  charsPrevTag = '/' + tag;
2587
- if (!inlineElements.has(tag)) {
2684
+ if (!inlineElements.has(lowerTag || tag)) {
2588
2685
  currentChars = '';
2589
- } else if (isElementEmpty) {
2686
+ } else if (isElementEmpty || !inlineTextSet.has(lowerTag || tag)) {
2590
2687
  currentChars += '|';
2591
2688
  }
2592
2689
  }
@@ -2597,13 +2694,22 @@ async function minifyHTML(value, options, partialMarkup) {
2597
2694
  bufferPush(endTagText, true);
2598
2695
  }
2599
2696
  charsPrevTag = '/' + tag;
2600
- if (!inlineElements.has(tag)) {
2697
+ if (!inlineElements.has(lowerTag || tag)) {
2601
2698
  currentChars = '';
2602
- } else if (isElementEmpty) {
2699
+ } else if (isElementEmpty || !inlineTextSet.has(lowerTag || tag)) {
2700
+ // What is not text counts as content for the whitespace after it
2603
2701
  currentChars += '|';
2604
2702
  }
2605
2703
  }
2606
2704
 
2705
+ if (skipsEndTag && !isElementRemoved && options.removeEmptyElements) {
2706
+ if (impliedEndsIndex !== buffer.length) {
2707
+ impliedEnds = [];
2708
+ impliedEndsIndex = buffer.length;
2709
+ }
2710
+ impliedEnds.push({ tag, text: holdsTable ? '' : endTagText });
2711
+ }
2712
+
2607
2713
  if (closesNoTrim && !isElementRemoved) {
2608
2714
  noTrimEndIndex = buffer.length - 1;
2609
2715
  }
@@ -2701,12 +2807,16 @@ async function minifyHTML(value, options, partialMarkup) {
2701
2807
  strayEnd: function () {
2702
2808
  removed = true;
2703
2809
  },
2704
- doctype: function (/** @type {string} */ doctype, /** @type {boolean} */ noQuirksMode) {
2810
+ doctype: function (/** @type {string} */ doctype, /** @type {boolean} */ noQuirksMode, /** @type {boolean} */ isIgnored) {
2705
2811
  tableClosesP = noQuirksMode;
2812
+ if (isIgnored) {
2813
+ removed = true;
2814
+ return;
2815
+ }
2706
2816
  bufferPush(options.useShortDoctype
2707
2817
  ? '<!doctype' +
2708
2818
  (options.removeTagWhitespace ? '' : ' ') + 'html>'
2709
- : collapseWhitespaceAll(doctype), false);
2819
+ : doctype.replace(RE_DOCTYPE_SEGMENT, (segment, quoted) => quoted ?? collapseWhitespaceAll(segment)), false);
2710
2820
  }
2711
2821
  });
2712
2822