html-minifier 5.0.2 → 5.0.4

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.
@@ -2,7 +2,7 @@ import { HTMLParser } from './htmlparser.js';
2
2
  import TokenChain from './tokenchain.js';
3
3
  import { presets, getPreset, getPresetNames } from './presets.js';
4
4
 
5
- import { LRU, decodeForeignText, describeDependencyFailure, escapeXML, findTagEnd, identity, isThenable, lowercase, uniqueId } from './lib/utils.js';
5
+ import { LRU, decodeForeignText, describeDependencyFailure, escapeXML, findTagEnd, identity, isThenable, lowercase, replaceNUL, uniqueId } from './lib/utils.js';
6
6
  import { collectUsage } from './lib/unused-css.js';
7
7
 
8
8
  import {
@@ -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,
@@ -31,6 +34,7 @@ import {
31
34
  compactElements,
32
35
  looseElements,
33
36
  trailingElements,
37
+ specialOptionalEndTags,
34
38
  pClosingParents,
35
39
  namesForeignMixedCase,
36
40
  integrationPointsForeign,
@@ -665,6 +669,7 @@ const cacheStatsOrEmpty = (cache) => cache ? cache.stats() : { gets: 0, hits: 0,
665
669
  const RE_SCRIPT_ATTRS = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
666
670
  const RE_SCRIPT_OPEN = /<script(?=[\s>])/gi; // Finds tag start; use `findTagEnd()` for the actual closing `>`
667
671
  const RE_SCRIPT_CLOSE = /<\/script\s*>/gi;
672
+ const RE_SCRIPT_START_TEXT = /<script[\t\n\f\r />]/i; // As script text, after `<!--`
668
673
  const RE_MATH_TAG = /<(\/?)math(?=[\s/>])/gi;
669
674
  const SCRIPT_BOOL_ATTRS = new Set(['async', 'defer', 'nomodule']);
670
675
  const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript']);
@@ -826,14 +831,20 @@ function mergeConsecutiveScripts(html, hasMathML = false) {
826
831
  }
827
832
  if (boolConflict || a1.nonce !== a2.nonce || (!mergedAny && insideMath(m1.index))) break;
828
833
 
829
- // Join with a semicolon, or with a newline where the first ends in a `//` comment
830
834
  const content2 = html.slice(tagEnd2 + 1, close2.index);
835
+ // After `<!--`, `<script` opens a state `</script>` doesn’t end,
836
+ // which would swallow what follows the merged script
837
+ if (mergedContent.indexOf('<!--') !== -1 && RE_SCRIPT_START_TEXT.test(content2)) break;
838
+
839
+ // Join with a semicolon, after a newline where the first may end in a `//`, `<!--`,
840
+ // or `-->` comment, all of which browsers read as running to the end of the line,
841
+ // and before one where the second starts with `-->`, a comment only there
831
842
  const c1 = mergedContent.trim();
832
843
  const c2 = content2.trim();
833
844
  if (c1 && c2) {
834
845
  const lastLine = c1.slice(c1.lastIndexOf('\n') + 1);
835
- const separator = lastLine.includes('//') ? '\n' : (c1.endsWith(';') ? '' : ';');
836
- mergedContent = c1 + separator + c2;
846
+ const separator = /\/\/|<!--|-->/.test(lastLine) ? '\n;' : (c1.endsWith(';') ? '' : ';');
847
+ mergedContent = c1 + separator + (c2.startsWith('-->') ? '\n' : '') + c2;
837
848
  } else {
838
849
  mergedContent = c1 || c2;
839
850
  }
@@ -953,6 +964,11 @@ async function createSortFns(value, options, uidIgnore, ignoredMarkupChunks) {
953
964
  removeTagWhitespace: false,
954
965
  decodeEntities: false,
955
966
  processScripts: false,
967
+ minifyCSS: identity,
968
+ minifyJS: identity,
969
+ minifyJSON: false,
970
+ minifySVG: null,
971
+ minifyURLs: identity,
956
972
  // `ignoreCustomFragments` stays, and template syntax may not parse cleanly
957
973
  continueOnParseError: true,
958
974
  log: identity
@@ -1112,8 +1128,13 @@ async function minifyHTML(value, options, partialMarkup) {
1112
1128
  /** @type {HTMLAttribute[]} */
1113
1129
  const emptyAttrs = [];
1114
1130
 
1131
+ // While parsing, entries already in it change through `bufferSet`, `bufferRemove`,
1132
+ // or `bufferTruncate`, so that `trimSettledIndex` stays true
1115
1133
  /** @type {string[]} */
1116
1134
  const buffer = [];
1135
+ // Where the last trim walk began that came to text it couldn’t trim, past end tags alone: A
1136
+ // later walk that gets there finds the same, until an entry up to there changes
1137
+ let trimSettledIndex = -1;
1117
1138
  // Parallel to `buffer`: whether the entry is a tag—the only place lines may break
1118
1139
  const bufferTags = options.maxLineLength ? /** @type {boolean[]} */ ([]) : null;
1119
1140
  /** @type {string} */
@@ -1124,8 +1145,12 @@ async function minifyHTML(value, options, partialMarkup) {
1124
1145
  // Open elements under `removeEmptyElements`, so an element whose content
1125
1146
  // went is removed, too: where its start tag sits in `buffer`, and whether
1126
1147
  // anything kept went in since
1127
- /** @type {Array<{tag: string, index: number, empty: boolean}>} */
1148
+ /** @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
1149
  const openElements = [];
1150
+ // End tags not written, for the start tag that comes next (`text` is empty where one can’t be)
1151
+ /** @type {Array<{tag: string, text: string}>} */
1152
+ let impliedEnds = [];
1153
+ let impliedEndsIndex = -1;
1129
1154
  // Under `selfCloseForeignElements`, the open `svg` and `math` elements and the integration points
1130
1155
  // in them, whose children are HTML, which a slash does not close
1131
1156
  /** @type {string[]} */
@@ -1149,7 +1174,7 @@ async function minifyHTML(value, options, partialMarkup) {
1149
1174
  const stackNoTrimWhitespace = [];
1150
1175
  /** @type {string[]} */
1151
1176
  const stackNoCollapseWhitespace = [];
1152
- let preTextareaDepth = 0; // Count of `pre`/`textarea` entries in `stackNoTrimWhitespace`
1177
+ let preDepth = 0; // Count of `pre` entries in `stackNoTrimWhitespace`
1153
1178
  // Where the last element whose whitespace may not be trimmed ended in `buffer`;
1154
1179
  // what lies up to there stays as written, also where `canTrimWhitespace` decides
1155
1180
  // by attributes the trim walk lacks
@@ -1411,8 +1436,21 @@ async function minifyHTML(value, options, partialMarkup) {
1411
1436
  if (entry) removed = false;
1412
1437
  }
1413
1438
 
1439
+ function bufferSet(/** @type {number} */ index, /** @type {string} */ entry) {
1440
+ buffer[index] = entry;
1441
+ if (index <= trimSettledIndex) trimSettledIndex = -1;
1442
+ }
1443
+
1444
+ // Entries after `index` move down by one, so a settled walk from there on is no longer one
1445
+ function bufferRemove(/** @type {number} */ index) {
1446
+ buffer.splice(index, 1);
1447
+ bufferTags?.splice(index, 1);
1448
+ if (index <= trimSettledIndex) trimSettledIndex = -1;
1449
+ }
1450
+
1414
1451
  function bufferTruncate(/** @type {number} */ length) {
1415
1452
  buffer.length = Math.max(0, length);
1453
+ if (length <= trimSettledIndex) trimSettledIndex = -1;
1416
1454
  if (bufferTags) bufferTags.length = buffer.length;
1417
1455
  if (transparentGapIndex >= buffer.length) transparentGapIndex = -1;
1418
1456
  if (noTrimEndIndex >= buffer.length) noTrimEndIndex = -1;
@@ -1457,15 +1495,15 @@ async function minifyHTML(value, options, partialMarkup) {
1457
1495
  return;
1458
1496
  }
1459
1497
  for (let i = before + 1; i <= first; i++) {
1460
- buffer[i] = '';
1498
+ bufferSet(i, '');
1461
1499
  }
1462
1500
  // The line break goes on the side of the text before the removal, so that the text after
1463
1501
  // it keeps its own for a removal that may follow
1464
1502
  if (before < 0) {
1465
- buffer[after] = next.replace(RE_BLANKS_LINE_BREAK_START, '');
1503
+ bufferSet(after, next.replace(RE_BLANKS_LINE_BREAK_START, ''));
1466
1504
  } else {
1467
- buffer[before] = prev.replace(RE_LINE_BREAK_BLANKS_END, '');
1468
- buffer[after] = next.replace(RE_BLANKS_START, '');
1505
+ bufferSet(before, prev.replace(RE_LINE_BREAK_BLANKS_END, ''));
1506
+ bufferSet(after, next.replace(RE_BLANKS_START, ''));
1469
1507
  }
1470
1508
  }
1471
1509
 
@@ -1480,18 +1518,18 @@ async function minifyHTML(value, options, partialMarkup) {
1480
1518
  if (!textVerbatim) {
1481
1519
  let index = buffer.length - 1;
1482
1520
  while (index >= 0 && (buffer[index] === '' || RE_WS_ONLY.test(buffer[index] ?? ''))) {
1483
- buffer[index--] = '';
1521
+ bufferSet(index--, '');
1484
1522
  }
1485
1523
  if (index >= 0) {
1486
- buffer[index] = (buffer[index] ?? '').replace(RE_WS_END, '');
1524
+ bufferSet(index, (buffer[index] ?? '').replace(RE_WS_END, ''));
1487
1525
  }
1488
1526
  }
1489
1527
  let index = 0;
1490
1528
  while (index < buffer.length && (buffer[index] === '' || RE_WS_ONLY.test(buffer[index] ?? ''))) {
1491
- buffer[index++] = '';
1529
+ bufferSet(index++, '');
1492
1530
  }
1493
1531
  if (index < buffer.length) {
1494
- buffer[index] = (buffer[index] ?? '').replace(RE_WS_START, '');
1532
+ bufferSet(index, (buffer[index] ?? '').replace(RE_WS_START, ''));
1495
1533
  }
1496
1534
  }
1497
1535
 
@@ -1503,8 +1541,11 @@ async function minifyHTML(value, options, partialMarkup) {
1503
1541
  return index;
1504
1542
  }
1505
1543
 
1506
- function removeStartTag() {
1507
- bufferTruncate(lastStartTagIndex());
1544
+ function removeStartTag(/** @type {boolean} */ capture = false) {
1545
+ const index = lastStartTagIndex();
1546
+ const text = capture ? buffer.slice(index).join('') : undefined;
1547
+ bufferTruncate(index);
1548
+ return text;
1508
1549
  }
1509
1550
 
1510
1551
  // Write the last start tag as self-closing, where it is the last thing written and holds no
@@ -1527,7 +1568,7 @@ async function minifyHTML(value, options, partialMarkup) {
1527
1568
  }
1528
1569
  const tagOpen = tagText.slice(0, -1);
1529
1570
  // A slash right after an unquoted value would belong to the value
1530
- buffer[last] = tagOpen + (RE_UNQUOTED_VALUE_END.test(tagOpen) ? ' />' : '/>');
1571
+ bufferSet(last, tagOpen + (RE_UNQUOTED_VALUE_END.test(tagOpen) ? ' />' : '/>'));
1531
1572
  return true;
1532
1573
  }
1533
1574
 
@@ -1549,6 +1590,26 @@ async function minifyHTML(value, options, partialMarkup) {
1549
1590
  return lowerTag === 'svg' || lowerTag === 'math' || scope === undefined || scope === 'svg' || scope === 'math';
1550
1591
  }
1551
1592
 
1593
+ // Browsers can close a formatting element with others left open inside it;
1594
+ // any other end tag that doesn’t match the top entry of a stack is stray
1595
+ function canCloseBelowTop(/** @type {string} */ tag, /** @type {string} */ lowerTag) {
1596
+ return formattingElements.has((lowerTag || tag).toLowerCase());
1597
+ }
1598
+
1599
+ // Returns whether `tag` was open
1600
+ function removeClosedTag(/** @type {string[]} */ tags, /** @type {string} */ tag, /** @type {string} */ lowerTag) {
1601
+ if (tags[tags.length - 1] === tag) {
1602
+ tags.pop();
1603
+ return true;
1604
+ }
1605
+ const index = canCloseBelowTop(tag, lowerTag) ? tags.lastIndexOf(tag) : -1;
1606
+ if (index === -1) {
1607
+ return false;
1608
+ }
1609
+ tags.splice(index, 1);
1610
+ return true;
1611
+ }
1612
+
1552
1613
  function markOpenElementFilled() {
1553
1614
  // Guarded, as reading index -1 is a slow property lookup, and this runs for every text node
1554
1615
  if (openElements.length) {
@@ -1584,9 +1645,9 @@ async function minifyHTML(value, options, partialMarkup) {
1584
1645
  // A line break the run already carries outranks the whitespace ahead of it; trimming
1585
1646
  // the other side takes ASCII whitespace only, so a no-break space stays as is
1586
1647
  if (options.preserveLineBreaks && /[\n\r]/.test((next.match(RE_WS_START) ?? [''])[0])) {
1587
- buffer[before] = prev.replace(RE_WS_END, '');
1648
+ bufferSet(before, prev.replace(RE_WS_END, ''));
1588
1649
  } else {
1589
- buffer[after] = next.replace(RE_WS_START, '');
1650
+ bufferSet(after, next.replace(RE_WS_START, ''));
1590
1651
  }
1591
1652
  }
1592
1653
 
@@ -1596,13 +1657,13 @@ async function minifyHTML(value, options, partialMarkup) {
1596
1657
  while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
1597
1658
  index--;
1598
1659
  }
1660
+ const text = buffer[index] ?? '';
1599
1661
  // Text that follows the end tag is kept as is, so drop the tag alone. Whitespace kept
1600
1662
  // verbatim—in `pre`, in `textarea`, or wherever `canTrimWhitespace` says so—counts as
1601
1663
  // kept text too, no matter how whitespace is collapsed elsewhere.
1602
1664
  const noTrim = insideNoTrim || stackNoTrimWhitespace.length > 0;
1603
- if (index < buffer.length - 1 && (keepsWhitespace() || noTrim) && RE_END_TAG.test(buffer[index] ?? '')) {
1604
- buffer.splice(index, 1);
1605
- bufferTags?.splice(index, 1);
1665
+ if (index < buffer.length - 1 && (keepsWhitespace() || noTrim) && RE_END_TAG.test(text)) {
1666
+ bufferRemove(index);
1606
1667
  // Collapsed whitespace can merge, kept whitespace can leave a line of only whitespace
1607
1668
  if (!noTrim) {
1608
1669
  if (options.collapseWhitespace) {
@@ -1611,9 +1672,10 @@ async function minifyHTML(value, options, partialMarkup) {
1611
1672
  removeWhitespaceLine(index);
1612
1673
  }
1613
1674
  }
1614
- return;
1675
+ return text;
1615
1676
  }
1616
1677
  bufferTruncate(index);
1678
+ return text;
1617
1679
  }
1618
1680
 
1619
1681
  // Look for trailing whitespace, bypassing inline tags
@@ -1621,10 +1683,18 @@ async function minifyHTML(value, options, partialMarkup) {
1621
1683
  // Nothing to the left of the text is in play here, so only its trailing run can
1622
1684
  // change—unless preserved line breaks bring the leading run into play, too
1623
1685
  const trailingOnly = !options.preserveLineBreaks;
1686
+ const indexStart = index;
1624
1687
  let prevTag = '';
1625
1688
  let prevTagLower = '';
1626
1689
  for (; index > noTrimEndIndex && canTrimWhitespace(prevTagLower, prevTag, emptyAttrs); index--) {
1690
+ if (index === trimSettledIndex) {
1691
+ trimSettledIndex = indexStart;
1692
+ return;
1693
+ }
1627
1694
  const str = buffer[index] ?? '';
1695
+ if (!str) {
1696
+ continue;
1697
+ }
1628
1698
  const endTagName = matchPlainEndTag(str);
1629
1699
  if (endTagName !== null) {
1630
1700
  prevTag = endTagName;
@@ -1633,14 +1703,12 @@ async function minifyHTML(value, options, partialMarkup) {
1633
1703
  }
1634
1704
  // A tag ends the search, and so does text that survives collapsing. Text with no
1635
1705
  // trailing run comes back from the call unchanged, so it can end the search on
1636
- // the spot; empty text cannot, as the search has to carry on past it
1637
- if (str.charCodeAt(str.length - 1) === 62 /* > */) {
1638
- break;
1639
- }
1640
- if (trailingOnly && str && !endsWithWhitespace(str)) {
1706
+ // the spot, and so can every later search that gets as far as this one began
1707
+ if (str.charCodeAt(str.length - 1) === 62 /* > */ || (trailingOnly && !endsWithWhitespace(str))) {
1708
+ trimSettledIndex = indexStart;
1641
1709
  break;
1642
1710
  }
1643
- buffer[index] = collapseWhitespaceSmart(str, '', nextTag, emptyAttrs, emptyAttrs, options, inlineSets, !stackNoCollapseWhitespace.length && index > noCollapseEndIndex);
1711
+ bufferSet(index, collapseWhitespaceSmart(str, '', nextTag, emptyAttrs, emptyAttrs, options, inlineSets, !stackNoCollapseWhitespace.length && index > noCollapseEndIndex));
1644
1712
  if (buffer[index]) {
1645
1713
  break;
1646
1714
  }
@@ -1696,7 +1764,7 @@ async function minifyHTML(value, options, partialMarkup) {
1696
1764
  // Keep or trim the whitespace pending before or between elements that don’t render
1697
1765
  function settleTransparentGap(/** @type {boolean} */ keep) {
1698
1766
  if (!keep) {
1699
- buffer[transparentGapIndex] = collapseWhitespace(buffer[transparentGapIndex] ?? '', options, false, true);
1767
+ bufferSet(transparentGapIndex, collapseWhitespace(buffer[transparentGapIndex] ?? '', options, false, true));
1700
1768
  }
1701
1769
  transparentGapIndex = -1;
1702
1770
  }
@@ -1775,11 +1843,11 @@ async function minifyHTML(value, options, partialMarkup) {
1775
1843
 
1776
1844
  // Whitespace collapsing phase (sync)
1777
1845
  function charsCollapse(/** @type {string} */ text) {
1778
- // Trim a single trailing newline inside `pre`/`textarea`, often a template engine artifact
1779
- // (several are likely intentional formatting)
1846
+ // Trim a single trailing newline inside `pre`, often a template engine artifact (several are
1847
+ // likely intentional formatting); in `textarea`, it is part of the value
1780
1848
  if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
1781
1849
  const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
1782
- if (preTextareaDepth > 0) {
1850
+ if (preDepth > 0 && topTag?.toLowerCase() !== 'textarea') {
1783
1851
  // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
1784
1852
  if (textNextTag && textNextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
1785
1853
  text = text.replace(RE_LINE_BREAK_BLANKS_END, '');
@@ -1801,10 +1869,14 @@ async function minifyHTML(value, options, partialMarkup) {
1801
1869
  }
1802
1870
  if (buffer.length > 1 && (!prevComment || (!options.conservativeCollapse && / $/.test(currentChars)))) {
1803
1871
  const charsIndex = buffer.length - 2;
1804
- buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
1872
+ bufferSet(charsIndex, (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
1805
1873
  text = trailingSpaces + text;
1874
+ // It moved, and is judged again as part of this text
1875
+ if (currentChars.endsWith(trailingSpaces)) {
1876
+ currentChars = currentChars.slice(0, -trailingSpaces.length);
1877
+ }
1806
1878
  return '';
1807
- });
1879
+ }));
1808
1880
  }
1809
1881
  } else if (uidIgnorePlaceholderPattern && textNextTag !== 'comment') {
1810
1882
  // UID placeholder followed by a real element—derive the effective `prevTag` from the
@@ -1835,7 +1907,7 @@ async function minifyHTML(value, options, partialMarkup) {
1835
1907
  trimTrailingWhitespace(tagIndex - 1, 'br');
1836
1908
  }
1837
1909
  } else if (inlineSets.withinEither.has(textPrevTag)) {
1838
- text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars), false, false, stackNoCollapseWhitespace.length > 0);
1910
+ text = collapseWhitespace(text, options, RE_ENDS_COLLAPSIBLE.test(currentChars), false, false, stackNoCollapseWhitespace.length > 0);
1839
1911
  }
1840
1912
  }
1841
1913
  if (textPrevTag || textNextTag) {
@@ -1938,9 +2010,16 @@ async function minifyHTML(value, options, partialMarkup) {
1938
2010
  }
1939
2011
  if (text && options.insideSVG && options.minifySVG) {
1940
2012
  // SVGO reads XML, so text is escaped for it, while raw text, which XML cannot read as
1941
- // HTML does (as in `foreignObject`), passes SVGO as a placeholder
2013
+ // HTML does (as in `foreignObject`), passes SVGO as a placeholder—as does HTML text
2014
+ // with whitespace at an end, which SVGO trims (`trim()`, so no-break spaces, too)
2015
+ // outside SVG text elements
1942
2016
  if (!rawText) {
1943
2017
  text = escapeXML(text);
2018
+ if (!options.insideForeignContent && /^\s|\s$/.test(text)) {
2019
+ uidSVGRawText ||= uniqueId(value);
2020
+ svgRawTexts.push(text);
2021
+ text = '/*!' + uidSVGRawText + (svgRawTexts.length - 1) + '*/';
2022
+ }
1944
2023
  } else if (text.indexOf('&') !== -1 || text.indexOf('<') !== -1 || text.indexOf(']]>') !== -1) {
1945
2024
  uidSVGRawText ||= uniqueId(value);
1946
2025
  svgRawTexts.push(text);
@@ -2024,9 +2103,9 @@ async function minifyHTML(value, options, partialMarkup) {
2024
2103
  const firstTag = match ? RE_FIRST_HTML_TAG.exec(ignoredMarkupChunks[+(match[1] ?? 0)] ?? '')?.[1] : undefined;
2025
2104
  if (firstTag && inlineElementsTransparent.has(resolveName(firstTag)) && inlineElementsTransparentAfter.has(charsPrevTag)) {
2026
2105
  // Emptied first, as `precedesTransparentRun` stops at text
2027
- buffer[buffer.length - 1] = '';
2106
+ bufferSet(buffer.length - 1, '');
2028
2107
  if (transparentGapIndex === buffer.length - 1) transparentGapIndex = -1;
2029
- buffer[buffer.length - 1] = options.conservativeCollapse || precedesTransparentRun(true) ? ' ' : '';
2108
+ bufferSet(buffer.length - 1, options.conservativeCollapse || precedesTransparentRun(true) ? ' ' : '');
2030
2109
  }
2031
2110
  }
2032
2111
  // What an `htmlmin:ignore` block holds is unknown, so whitespace pending before it stays
@@ -2097,7 +2176,7 @@ async function minifyHTML(value, options, partialMarkup) {
2097
2176
  }
2098
2177
  }
2099
2178
 
2100
- buffer[buffer.length - 1] = collapsedText;
2179
+ bufferSet(buffer.length - 1, collapsedText);
2101
2180
  }
2102
2181
  }
2103
2182
  }
@@ -2125,7 +2204,7 @@ async function minifyHTML(value, options, partialMarkup) {
2125
2204
  const trims = Boolean(options.collapseWhitespace) &&
2126
2205
  !options.conservativeCollapse && !options.preserveLineBreaks;
2127
2206
  for (const { code, isModule } of bodies) {
2128
- const body = trims ? code.trim() : code;
2207
+ const body = replaceNUL(trims ? code.trim() : code);
2129
2208
  const key = (isModule ? 'm|' : '|') + body;
2130
2209
  if (jsPrewarmed.has(key)) {
2131
2210
  continue;
@@ -2190,7 +2269,7 @@ async function minifyHTML(value, options, partialMarkup) {
2190
2269
  const builtAttr = buildAttr(normalizedAttr, tag, hasUnarySlash, optionsTag, i === lastKeptIndex, uidAttr, markers);
2191
2270
  // One entry with the tag name, so that no line break can come between them
2192
2271
  if (i === 0 && fragmentJoinsTag) {
2193
- buffer[buffer.length - 1] += builtAttr;
2272
+ bufferSet(buffer.length - 1, (buffer[buffer.length - 1] ?? '') + builtAttr);
2194
2273
  } else {
2195
2274
  bufferPush(builtAttr, true);
2196
2275
  }
@@ -2202,7 +2281,7 @@ async function minifyHTML(value, options, partialMarkup) {
2202
2281
  optionalStartTag = tag;
2203
2282
  }
2204
2283
 
2205
- buffer[buffer.length - 1] += tagText + (hasUnarySlash ? '/' : '') + '>';
2284
+ bufferSet(buffer.length - 1, (buffer[buffer.length - 1] ?? '') + tagText + (hasUnarySlash ? '/' : '') + '>');
2206
2285
 
2207
2286
  impliedStartTag = autoGenerated && !options.includeAutoGeneratedTags ? tag : '';
2208
2287
  if (impliedStartTag) {
@@ -2344,11 +2423,20 @@ async function minifyHTML(value, options, partialMarkup) {
2344
2423
  currentTag = tag;
2345
2424
  charsPrevTag = tag;
2346
2425
  if (!inlineTextSet.has(lowerTag || tag)) {
2347
- currentChars = '';
2426
+ // Replaced content counts as content for the whitespace after what holds it
2427
+ currentChars = inlineElementsToKeepWhitespace.has(lowerTag || tag) ? '|' : '';
2348
2428
  }
2349
2429
  hasChars = false;
2350
2430
  currentAttrs = attrs;
2351
2431
 
2432
+ // What this start tag closed, which is left to the end tags if its element is removed
2433
+ const impliedEndsClosed = impliedEndsIndex === buffer.length ? impliedEnds : undefined;
2434
+
2435
+ // What this start tag made optional, for if the element then turns out to be removed
2436
+ /** @type {{tag: string, text: string} | undefined} */
2437
+ let omittedStartTag;
2438
+ /** @type {{tag: string, text: string} | undefined} */
2439
+ let omittedEndTag;
2352
2440
  let optional = options.removeOptionalTags;
2353
2441
  if (optional) {
2354
2442
  const htmlTag = htmlElements.has(tag);
@@ -2359,7 +2447,10 @@ async function minifyHTML(value, options, partialMarkup) {
2359
2447
  // `<colgroup>` may be omitted if first thing inside is `<col>`
2360
2448
  // `<tbody>` may be omitted if first thing inside is `<tr>`
2361
2449
  if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
2362
- removeStartTag();
2450
+ const text = removeStartTag(options.removeEmptyElements);
2451
+ if (text !== undefined) {
2452
+ omittedStartTag = { tag: optionalStartTag, text };
2453
+ }
2363
2454
  }
2364
2455
  optionalStartTag = '';
2365
2456
  // End-tag-followed-by-start-tag omission rules
@@ -2367,7 +2458,10 @@ async function minifyHTML(value, options, partialMarkup) {
2367
2458
  if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag) && (tableClosesP || optionalEndTag !== 'p' || tag !== 'table')) {
2368
2459
  // Only a start tag that gets written can stand in for the end tag
2369
2460
  if (optionalEndTagEmitted && (!autoGenerated || options.includeAutoGeneratedTags)) {
2370
- removeEndTag();
2461
+ const text = removeEndTag();
2462
+ if (options.removeEmptyElements) {
2463
+ omittedEndTag = { tag: optionalEndTag, text };
2464
+ }
2371
2465
  }
2372
2466
  // `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
2373
2467
  // `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is
@@ -2390,7 +2484,7 @@ async function minifyHTML(value, options, partialMarkup) {
2390
2484
  const tagLower = lowerTag || tag;
2391
2485
  if (!canTrimWhitespace(tagLower, tagOut, attrs) || stackNoTrimWhitespace.length) {
2392
2486
  stackNoTrimWhitespace.push(tag);
2393
- if (tagLower === 'pre' || tagLower === 'textarea') preTextareaDepth++;
2487
+ if (tagLower === 'pre') preDepth++;
2394
2488
  }
2395
2489
  if (options.collapseWhitespace && (!canCollapseWhitespace(tagLower, tagOut, attrs) || stackNoCollapseWhitespace.length)) {
2396
2490
  stackNoCollapseWhitespace.push(tag);
@@ -2420,7 +2514,7 @@ async function minifyHTML(value, options, partialMarkup) {
2420
2514
  markOpenElementFilled();
2421
2515
  } else {
2422
2516
  // A start tag not written can’t be removed with its element
2423
- openElements.push({ tag, index: buffer.length - 1, empty: !autoGenerated || Boolean(options.includeAutoGeneratedTags) });
2517
+ openElements.push({ tag, index: buffer.length - 1, empty: !autoGenerated || Boolean(options.includeAutoGeneratedTags), omittedStartTag, omittedEndTag, impliedEnds: impliedEndsClosed });
2424
2518
  }
2425
2519
  }
2426
2520
 
@@ -2513,10 +2607,9 @@ async function minifyHTML(value, options, partialMarkup) {
2513
2607
  const insideNoTrim = stackNoTrimWhitespace.length > 0;
2514
2608
  let closesNoTrim = false;
2515
2609
  if (stackNoTrimWhitespace.length) {
2516
- if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
2610
+ if (removeClosedTag(stackNoTrimWhitespace, tag, lowerTag)) {
2517
2611
  const tagLower = lowerTag || tag;
2518
- if (tagLower === 'pre' || tagLower === 'textarea') preTextareaDepth--;
2519
- stackNoTrimWhitespace.pop();
2612
+ if (tagLower === 'pre') preDepth--;
2520
2613
  closesNoTrim = !stackNoTrimWhitespace.length;
2521
2614
  }
2522
2615
  } else if (options.collapseWhitespace) {
@@ -2526,9 +2619,7 @@ async function minifyHTML(value, options, partialMarkup) {
2526
2619
  transparentDepth--;
2527
2620
  }
2528
2621
  let closesNoCollapse = false;
2529
- if (stackNoCollapseWhitespace.length &&
2530
- tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
2531
- stackNoCollapseWhitespace.pop();
2622
+ if (stackNoCollapseWhitespace.length && removeClosedTag(stackNoCollapseWhitespace, tag, lowerTag)) {
2532
2623
  closesNoCollapse = !stackNoCollapseWhitespace.length;
2533
2624
  }
2534
2625
 
@@ -2539,6 +2630,7 @@ async function minifyHTML(value, options, partialMarkup) {
2539
2630
  const skipsEndTag = autoGenerated && (!options.includeAutoGeneratedTags || Boolean(holdsTable));
2540
2631
  impliedStartTag = '';
2541
2632
 
2633
+ let startTagRemoved = false;
2542
2634
  let isElementEmpty = false;
2543
2635
  if (tag === currentTag) {
2544
2636
  currentTag = '';
@@ -2546,7 +2638,19 @@ async function minifyHTML(value, options, partialMarkup) {
2546
2638
  }
2547
2639
  selfCloses &&= isElementEmpty;
2548
2640
  // A stray end tag has no open element of its own
2549
- const openElement = options.removeEmptyElements && openElements[openElements.length - 1]?.tag === tag ? openElements.pop() : undefined;
2641
+ let openElement;
2642
+ if (options.removeEmptyElements && openElements.length) {
2643
+ if (openElements[openElements.length - 1]?.tag === tag) {
2644
+ openElement = openElements.pop();
2645
+ } else if (canCloseBelowTop(tag, lowerTag)) {
2646
+ const index = openElements.findLastIndex(entry => entry.tag === tag);
2647
+ if (index !== -1) {
2648
+ openElement = openElements.splice(index, 1)[0];
2649
+ // Closed around what is open inside it, so that holds this end tag now
2650
+ currentTag = '';
2651
+ }
2652
+ }
2653
+ }
2550
2654
  // Emptied by removing what it held, as long as its start tag is still the last one written
2551
2655
  const isElementEmptied = !isElementEmpty && Boolean(openElement?.empty) && lastStartTagIndex() === openElement?.index;
2552
2656
 
@@ -2554,15 +2658,17 @@ async function minifyHTML(value, options, partialMarkup) {
2554
2658
  // `<html>`, `<head>`, or `<body>` may be omitted if the element is empty
2555
2659
  if (isElementEmpty && topLevelElements.has(optionalStartTag)) {
2556
2660
  removeStartTag();
2661
+ startTagRemoved = true;
2557
2662
  }
2558
2663
  optionalStartTag = '';
2559
- // `</html>` or `</body>` may be omitted if not followed by comment
2560
- // `</head>` may be omitted if not followed by space or comment
2561
- // `</p>` may be omitted if no more content in parent, as long as the parent’s end tag
2562
- // closes the `p` in browsers (`pClosingParents`)
2563
- // Except for `</dt>` or `</thead>`, end tags may be omitted if no more content in parent
2664
+ // `</html>` or `</body>` may be omitted if not followed by comment;
2665
+ // `</head>` may be omitted if not followed by space or comment;
2666
+ // `</p>`, `</li>`, `</dd>`, and `</dt>` may be omitted if no more content
2667
+ // in parent, as long as the parent’s end tag closes these special elements
2668
+ // in browsers (`pClosingParents`); except for `</dt>` or `</thead>`,
2669
+ // end tags may be omitted if no more content in parent
2564
2670
  // https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
2565
- if (tag && optionalEndTag && optionalEndTagEmitted && !trailingElements.has(optionalEndTag) && (optionalEndTag !== 'p' || pClosingParents.has(tag))) {
2671
+ if (tag && optionalEndTag && optionalEndTagEmitted && !trailingElements.has(optionalEndTag) && (!specialOptionalEndTags.has(optionalEndTag) || pClosingParents.has(tag))) {
2566
2672
  removeEndTag(insideNoTrim);
2567
2673
  }
2568
2674
  optionalEndTag = optionalEndTags.has(tag) && !endsImpliedStart && !holdsTable ? tag : '';
@@ -2571,8 +2677,8 @@ async function minifyHTML(value, options, partialMarkup) {
2571
2677
 
2572
2678
  let isElementRemoved = false;
2573
2679
  // A formatting element closed by a block is reopened after it by browsers, so it has to stay
2574
- if (options.removeEmptyElements && (isElementEmpty || isElementEmptied) && !options.insideForeignContent && canRemoveElement(tag, attrs) &&
2575
- !(autoGenerated && formattingElements.has(tag))) {
2680
+ if (options.removeEmptyElements && (isElementEmpty || isElementEmptied) && !options.insideForeignContent && canRemoveElement(tag, attrs, options, tagOut) &&
2681
+ !(autoGenerated && formattingElements.has(tag)) && !openElement?.impliedEnds?.some(implied => !implied.text)) {
2576
2682
  let preserve = false;
2577
2683
  if (removeEmptyElementsExcept.length) {
2578
2684
  const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
@@ -2580,11 +2686,37 @@ async function minifyHTML(value, options, partialMarkup) {
2580
2686
  }
2581
2687
 
2582
2688
  if (!preserve) {
2583
- removeStartTag();
2689
+ // Once it is gone, the last start tag is another element’s
2690
+ if (!startTagRemoved) {
2691
+ removeStartTag();
2692
+ }
2584
2693
  isElementRemoved = true;
2585
2694
  optionalStartTag = '';
2586
2695
  optionalEndTag = '';
2587
2696
  optionalEndTagEmitted = false;
2697
+ // What it made optional is up to what comes next now
2698
+ if (openElement?.omittedEndTag) {
2699
+ bufferPush(openElement.omittedEndTag.text, true);
2700
+ optionalEndTag = openElement.omittedEndTag.tag;
2701
+ optionalEndTagEmitted = true;
2702
+ }
2703
+ if (openElement?.omittedStartTag) {
2704
+ bufferPush(openElement.omittedStartTag.text, true);
2705
+ optionalStartTag = openElement.omittedStartTag.tag;
2706
+ }
2707
+ if (openElement?.impliedEnds) {
2708
+ // Browsers reopen formatting elements after what closed them, which their end tags
2709
+ // would prevent and whitespace following an omitted end tag would change
2710
+ const written = openElement.impliedEnds.filter(implied => !formattingElements.has(implied.tag));
2711
+ for (const implied of written) {
2712
+ bufferPush(implied.text, true);
2713
+ }
2714
+ const last = written[written.length - 1];
2715
+ if (last && written.length === openElement.impliedEnds.length && optionalEndTags.has(last.tag)) {
2716
+ optionalEndTag = last.tag;
2717
+ optionalEndTagEmitted = true;
2718
+ }
2719
+ }
2588
2720
  } else {
2589
2721
  // Preserve the element—add closing tag
2590
2722
  if (skipsEndTag) {
@@ -2595,7 +2727,7 @@ async function minifyHTML(value, options, partialMarkup) {
2595
2727
  charsPrevTag = '/' + tag;
2596
2728
  if (!inlineElements.has(lowerTag || tag)) {
2597
2729
  currentChars = '';
2598
- } else if (isElementEmpty) {
2730
+ } else if (isElementEmpty || !inlineTextSet.has(lowerTag || tag)) {
2599
2731
  currentChars += '|';
2600
2732
  }
2601
2733
  }
@@ -2608,11 +2740,20 @@ async function minifyHTML(value, options, partialMarkup) {
2608
2740
  charsPrevTag = '/' + tag;
2609
2741
  if (!inlineElements.has(lowerTag || tag)) {
2610
2742
  currentChars = '';
2611
- } else if (isElementEmpty) {
2743
+ } else if (isElementEmpty || !inlineTextSet.has(lowerTag || tag)) {
2744
+ // What is not text counts as content for the whitespace after it
2612
2745
  currentChars += '|';
2613
2746
  }
2614
2747
  }
2615
2748
 
2749
+ if (skipsEndTag && !isElementRemoved && options.removeEmptyElements) {
2750
+ if (impliedEndsIndex !== buffer.length) {
2751
+ impliedEnds = [];
2752
+ impliedEndsIndex = buffer.length;
2753
+ }
2754
+ impliedEnds.push({ tag, text: holdsTable ? '' : endTagText });
2755
+ }
2756
+
2616
2757
  if (closesNoTrim && !isElementRemoved) {
2617
2758
  noTrimEndIndex = buffer.length - 1;
2618
2759
  }
@@ -2678,6 +2819,9 @@ async function minifyHTML(value, options, partialMarkup) {
2678
2819
  if (needsProcessScript) {
2679
2820
  text = await processScript(text, options, currentTag, currentAttrs, minifyHTML);
2680
2821
  }
2822
+ if (needsMinifyJS || needsMinifyCSS) {
2823
+ text = replaceNUL(text);
2824
+ }
2681
2825
  if (needsMinifyJS) {
2682
2826
  const prewarmed = jsPrewarmed?.get((isModuleScript ? 'm|' : '|') + text);
2683
2827
  text = prewarmed ? await prewarmed : await options.minifyJS(text, false, isModuleScript);
@@ -2710,12 +2854,16 @@ async function minifyHTML(value, options, partialMarkup) {
2710
2854
  strayEnd: function () {
2711
2855
  removed = true;
2712
2856
  },
2713
- doctype: function (/** @type {string} */ doctype, /** @type {boolean} */ noQuirksMode) {
2857
+ doctype: function (/** @type {string} */ doctype, /** @type {boolean} */ noQuirksMode, /** @type {boolean} */ isIgnored) {
2714
2858
  tableClosesP = noQuirksMode;
2859
+ if (isIgnored) {
2860
+ removed = true;
2861
+ return;
2862
+ }
2715
2863
  bufferPush(options.useShortDoctype
2716
2864
  ? '<!doctype' +
2717
2865
  (options.removeTagWhitespace ? '' : ' ') + 'html>'
2718
- : collapseWhitespaceAll(doctype), false);
2866
+ : doctype.replace(RE_DOCTYPE_SEGMENT, (segment, quoted) => quoted ?? collapseWhitespaceAll(segment)), false);
2719
2867
  }
2720
2868
  });
2721
2869
 
@@ -2749,7 +2897,7 @@ async function minifyHTML(value, options, partialMarkup) {
2749
2897
  for (let i = 0; i < buffer.length; i++) {
2750
2898
  const segment = /** @type {string} */ (buffer[i]);
2751
2899
  if (segment.indexOf(uidSVGRawText) !== -1) {
2752
- buffer[i] = segment.replace(pattern, (/** @type {string} */ match, /** @type {string} */ index) => svgRawTexts[+index] ?? match);
2900
+ bufferSet(i, segment.replace(pattern, (/** @type {string} */ match, /** @type {string} */ index) => svgRawTexts[+index] ?? match));
2753
2901
  }
2754
2902
  }
2755
2903
  }
@@ -3035,6 +3183,9 @@ export const minify = async function (value, options) {
3035
3183
  let processedBase;
3036
3184
  // `valueUnchanged` needs no budget of its own: Recursion is bounded by the
3037
3185
  // stored snapshot, which is a finite tree of at most `SNAPSHOT_MAX_NODES`
3186
+ //
3187
+ // Note: The comparison takes about 8% of a call on tiny documents; only options
3188
+ // known never to change (e.g., deeply frozen ones) could skip it
3038
3189
  if (cached && valueUnchanged(cached.snapshot, inputOptions)) {
3039
3190
  processedBase = cached.processed;
3040
3191
  } else {