html-minifier 5.0.2 → 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,
@@ -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
@@ -1503,8 +1510,11 @@ async function minifyHTML(value, options, partialMarkup) {
1503
1510
  return index;
1504
1511
  }
1505
1512
 
1506
- function removeStartTag() {
1507
- 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;
1508
1518
  }
1509
1519
 
1510
1520
  // Write the last start tag as self-closing, where it is the last thing written and holds no
@@ -1549,6 +1559,26 @@ async function minifyHTML(value, options, partialMarkup) {
1549
1559
  return lowerTag === 'svg' || lowerTag === 'math' || scope === undefined || scope === 'svg' || scope === 'math';
1550
1560
  }
1551
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
+
1552
1582
  function markOpenElementFilled() {
1553
1583
  // Guarded, as reading index -1 is a slow property lookup, and this runs for every text node
1554
1584
  if (openElements.length) {
@@ -1596,11 +1626,12 @@ async function minifyHTML(value, options, partialMarkup) {
1596
1626
  while (index > 0 && !RE_END_TAG.test(buffer[index] ?? '')) {
1597
1627
  index--;
1598
1628
  }
1629
+ const text = buffer[index] ?? '';
1599
1630
  // Text that follows the end tag is kept as is, so drop the tag alone. Whitespace kept
1600
1631
  // verbatim—in `pre`, in `textarea`, or wherever `canTrimWhitespace` says so—counts as
1601
1632
  // kept text too, no matter how whitespace is collapsed elsewhere.
1602
1633
  const noTrim = insideNoTrim || stackNoTrimWhitespace.length > 0;
1603
- 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)) {
1604
1635
  buffer.splice(index, 1);
1605
1636
  bufferTags?.splice(index, 1);
1606
1637
  // Collapsed whitespace can merge, kept whitespace can leave a line of only whitespace
@@ -1611,9 +1642,10 @@ async function minifyHTML(value, options, partialMarkup) {
1611
1642
  removeWhitespaceLine(index);
1612
1643
  }
1613
1644
  }
1614
- return;
1645
+ return text;
1615
1646
  }
1616
1647
  bufferTruncate(index);
1648
+ return text;
1617
1649
  }
1618
1650
 
1619
1651
  // Look for trailing whitespace, bypassing inline tags
@@ -1775,11 +1807,11 @@ async function minifyHTML(value, options, partialMarkup) {
1775
1807
 
1776
1808
  // Whitespace collapsing phase (sync)
1777
1809
  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)
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
1780
1812
  if (options.collapseWhitespace && stackNoTrimWhitespace.length) {
1781
1813
  const topTag = stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1];
1782
- if (preTextareaDepth > 0) {
1814
+ if (preDepth > 0 && topTag?.toLowerCase() !== 'textarea') {
1783
1815
  // Treat CRLF (`\r\n`), CR (`\r`), and LF (`\n`) as single line-ending units
1784
1816
  if (textNextTag && textNextTag === '/' + topTag && /[^\r\n](?:\r\n|\r|\n)[ \t]*$/.test(text)) {
1785
1817
  text = text.replace(RE_LINE_BREAK_BLANKS_END, '');
@@ -1803,6 +1835,10 @@ async function minifyHTML(value, options, partialMarkup) {
1803
1835
  const charsIndex = buffer.length - 2;
1804
1836
  buffer[charsIndex] = (buffer[charsIndex] ?? '').replace(/\s+$/, function (trailingSpaces) {
1805
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
+ }
1806
1842
  return '';
1807
1843
  });
1808
1844
  }
@@ -1835,7 +1871,7 @@ async function minifyHTML(value, options, partialMarkup) {
1835
1871
  trimTrailingWhitespace(tagIndex - 1, 'br');
1836
1872
  }
1837
1873
  } else if (inlineSets.withinEither.has(textPrevTag)) {
1838
- 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);
1839
1875
  }
1840
1876
  }
1841
1877
  if (textPrevTag || textNextTag) {
@@ -2344,11 +2380,20 @@ async function minifyHTML(value, options, partialMarkup) {
2344
2380
  currentTag = tag;
2345
2381
  charsPrevTag = tag;
2346
2382
  if (!inlineTextSet.has(lowerTag || tag)) {
2347
- currentChars = '';
2383
+ // Replaced content counts as content for the whitespace after what holds it
2384
+ currentChars = inlineElementsToKeepWhitespace.has(lowerTag || tag) ? '|' : '';
2348
2385
  }
2349
2386
  hasChars = false;
2350
2387
  currentAttrs = attrs;
2351
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;
2352
2397
  let optional = options.removeOptionalTags;
2353
2398
  if (optional) {
2354
2399
  const htmlTag = htmlElements.has(tag);
@@ -2359,7 +2404,10 @@ async function minifyHTML(value, options, partialMarkup) {
2359
2404
  // `<colgroup>` may be omitted if first thing inside is `<col>`
2360
2405
  // `<tbody>` may be omitted if first thing inside is `<tr>`
2361
2406
  if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
2362
- removeStartTag();
2407
+ const text = removeStartTag(options.removeEmptyElements);
2408
+ if (text !== undefined) {
2409
+ omittedStartTag = { tag: optionalStartTag, text };
2410
+ }
2363
2411
  }
2364
2412
  optionalStartTag = '';
2365
2413
  // End-tag-followed-by-start-tag omission rules
@@ -2367,7 +2415,10 @@ async function minifyHTML(value, options, partialMarkup) {
2367
2415
  if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag) && (tableClosesP || optionalEndTag !== 'p' || tag !== 'table')) {
2368
2416
  // Only a start tag that gets written can stand in for the end tag
2369
2417
  if (optionalEndTagEmitted && (!autoGenerated || options.includeAutoGeneratedTags)) {
2370
- removeEndTag();
2418
+ const text = removeEndTag();
2419
+ if (options.removeEmptyElements) {
2420
+ omittedEndTag = { tag: optionalEndTag, text };
2421
+ }
2371
2422
  }
2372
2423
  // `<colgroup>` cannot be omitted if preceding `</colgroup>` is omitted
2373
2424
  // `<tbody>` cannot be omitted if preceding `</tbody>`, `</thead>`, or `</tfoot>` is
@@ -2390,7 +2441,7 @@ async function minifyHTML(value, options, partialMarkup) {
2390
2441
  const tagLower = lowerTag || tag;
2391
2442
  if (!canTrimWhitespace(tagLower, tagOut, attrs) || stackNoTrimWhitespace.length) {
2392
2443
  stackNoTrimWhitespace.push(tag);
2393
- if (tagLower === 'pre' || tagLower === 'textarea') preTextareaDepth++;
2444
+ if (tagLower === 'pre') preDepth++;
2394
2445
  }
2395
2446
  if (options.collapseWhitespace && (!canCollapseWhitespace(tagLower, tagOut, attrs) || stackNoCollapseWhitespace.length)) {
2396
2447
  stackNoCollapseWhitespace.push(tag);
@@ -2420,7 +2471,7 @@ async function minifyHTML(value, options, partialMarkup) {
2420
2471
  markOpenElementFilled();
2421
2472
  } else {
2422
2473
  // 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) });
2474
+ openElements.push({ tag, index: buffer.length - 1, empty: !autoGenerated || Boolean(options.includeAutoGeneratedTags), omittedStartTag, omittedEndTag, impliedEnds: impliedEndsClosed });
2424
2475
  }
2425
2476
  }
2426
2477
 
@@ -2513,10 +2564,9 @@ async function minifyHTML(value, options, partialMarkup) {
2513
2564
  const insideNoTrim = stackNoTrimWhitespace.length > 0;
2514
2565
  let closesNoTrim = false;
2515
2566
  if (stackNoTrimWhitespace.length) {
2516
- if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
2567
+ if (removeClosedTag(stackNoTrimWhitespace, tag, lowerTag)) {
2517
2568
  const tagLower = lowerTag || tag;
2518
- if (tagLower === 'pre' || tagLower === 'textarea') preTextareaDepth--;
2519
- stackNoTrimWhitespace.pop();
2569
+ if (tagLower === 'pre') preDepth--;
2520
2570
  closesNoTrim = !stackNoTrimWhitespace.length;
2521
2571
  }
2522
2572
  } else if (options.collapseWhitespace) {
@@ -2526,9 +2576,7 @@ async function minifyHTML(value, options, partialMarkup) {
2526
2576
  transparentDepth--;
2527
2577
  }
2528
2578
  let closesNoCollapse = false;
2529
- if (stackNoCollapseWhitespace.length &&
2530
- tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
2531
- stackNoCollapseWhitespace.pop();
2579
+ if (stackNoCollapseWhitespace.length && removeClosedTag(stackNoCollapseWhitespace, tag, lowerTag)) {
2532
2580
  closesNoCollapse = !stackNoCollapseWhitespace.length;
2533
2581
  }
2534
2582
 
@@ -2539,6 +2587,7 @@ async function minifyHTML(value, options, partialMarkup) {
2539
2587
  const skipsEndTag = autoGenerated && (!options.includeAutoGeneratedTags || Boolean(holdsTable));
2540
2588
  impliedStartTag = '';
2541
2589
 
2590
+ let startTagRemoved = false;
2542
2591
  let isElementEmpty = false;
2543
2592
  if (tag === currentTag) {
2544
2593
  currentTag = '';
@@ -2546,7 +2595,19 @@ async function minifyHTML(value, options, partialMarkup) {
2546
2595
  }
2547
2596
  selfCloses &&= isElementEmpty;
2548
2597
  // 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;
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
+ }
2550
2611
  // Emptied by removing what it held, as long as its start tag is still the last one written
2551
2612
  const isElementEmptied = !isElementEmpty && Boolean(openElement?.empty) && lastStartTagIndex() === openElement?.index;
2552
2613
 
@@ -2554,6 +2615,7 @@ async function minifyHTML(value, options, partialMarkup) {
2554
2615
  // `<html>`, `<head>`, or `<body>` may be omitted if the element is empty
2555
2616
  if (isElementEmpty && topLevelElements.has(optionalStartTag)) {
2556
2617
  removeStartTag();
2618
+ startTagRemoved = true;
2557
2619
  }
2558
2620
  optionalStartTag = '';
2559
2621
  // `</html>` or `</body>` may be omitted if not followed by comment
@@ -2572,7 +2634,7 @@ async function minifyHTML(value, options, partialMarkup) {
2572
2634
  let isElementRemoved = false;
2573
2635
  // A formatting element closed by a block is reopened after it by browsers, so it has to stay
2574
2636
  if (options.removeEmptyElements && (isElementEmpty || isElementEmptied) && !options.insideForeignContent && canRemoveElement(tag, attrs) &&
2575
- !(autoGenerated && formattingElements.has(tag))) {
2637
+ !(autoGenerated && formattingElements.has(tag)) && !openElement?.impliedEnds?.some(implied => !implied.text)) {
2576
2638
  let preserve = false;
2577
2639
  if (removeEmptyElementsExcept.length) {
2578
2640
  const normalizedAttrs = attrs.map((/** @type {HTMLAttribute} */ attr) => ({ ...attr, name: resolveName(attr.name) }));
@@ -2580,11 +2642,37 @@ async function minifyHTML(value, options, partialMarkup) {
2580
2642
  }
2581
2643
 
2582
2644
  if (!preserve) {
2583
- removeStartTag();
2645
+ // Once it is gone, the last start tag is another element’s
2646
+ if (!startTagRemoved) {
2647
+ removeStartTag();
2648
+ }
2584
2649
  isElementRemoved = true;
2585
2650
  optionalStartTag = '';
2586
2651
  optionalEndTag = '';
2587
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
+ }
2588
2676
  } else {
2589
2677
  // Preserve the element—add closing tag
2590
2678
  if (skipsEndTag) {
@@ -2595,7 +2683,7 @@ async function minifyHTML(value, options, partialMarkup) {
2595
2683
  charsPrevTag = '/' + tag;
2596
2684
  if (!inlineElements.has(lowerTag || tag)) {
2597
2685
  currentChars = '';
2598
- } else if (isElementEmpty) {
2686
+ } else if (isElementEmpty || !inlineTextSet.has(lowerTag || tag)) {
2599
2687
  currentChars += '|';
2600
2688
  }
2601
2689
  }
@@ -2608,11 +2696,20 @@ async function minifyHTML(value, options, partialMarkup) {
2608
2696
  charsPrevTag = '/' + tag;
2609
2697
  if (!inlineElements.has(lowerTag || tag)) {
2610
2698
  currentChars = '';
2611
- } else if (isElementEmpty) {
2699
+ } else if (isElementEmpty || !inlineTextSet.has(lowerTag || tag)) {
2700
+ // What is not text counts as content for the whitespace after it
2612
2701
  currentChars += '|';
2613
2702
  }
2614
2703
  }
2615
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
+
2616
2713
  if (closesNoTrim && !isElementRemoved) {
2617
2714
  noTrimEndIndex = buffer.length - 1;
2618
2715
  }
@@ -2710,12 +2807,16 @@ async function minifyHTML(value, options, partialMarkup) {
2710
2807
  strayEnd: function () {
2711
2808
  removed = true;
2712
2809
  },
2713
- doctype: function (/** @type {string} */ doctype, /** @type {boolean} */ noQuirksMode) {
2810
+ doctype: function (/** @type {string} */ doctype, /** @type {boolean} */ noQuirksMode, /** @type {boolean} */ isIgnored) {
2714
2811
  tableClosesP = noQuirksMode;
2812
+ if (isIgnored) {
2813
+ removed = true;
2814
+ return;
2815
+ }
2715
2816
  bufferPush(options.useShortDoctype
2716
2817
  ? '<!doctype' +
2717
2818
  (options.removeTagWhitespace ? '' : ' ') + 'html>'
2718
- : collapseWhitespaceAll(doctype), false);
2819
+ : doctype.replace(RE_DOCTYPE_SEGMENT, (segment, quoted) => quoted ?? collapseWhitespaceAll(segment)), false);
2719
2820
  }
2720
2821
  });
2721
2822