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.
package/src/htmlparser.js CHANGED
@@ -49,10 +49,14 @@ import { endlessRawTextElements, escapableRawTextElements, formattingElements, g
49
49
 
50
50
  // Regular expressions for parsing tags and attributes
51
51
  const singleAttrIdentifier = /([^\s"'<>/=]+)/;
52
+ // Lenient attribute name pattern for `continueOnParseError`:
53
+ // Quotes in names are parse errors that browsers keep in the name
54
+ const singleAttrIdentifierLenient = /([^\s<>/=]+)/;
52
55
  const singleAttrAssigns = [/=/];
53
56
  const singleAttrValues = [
54
- /"([^"]*)"+/.source,
55
- /'([^']*)'+/.source,
57
+ // Repeated closing quotes go with the value, unless they run into a name, which they then start
58
+ /"([^"]*)"(?:"+(?![^\s/>]))?/.source,
59
+ /'([^']*)'(?:'+(?![^\s/>]))?/.source,
56
60
  // Attr value, no quotes (strict: excludes `=` per HTML spec)
57
61
  /([^ \t\n\f\r"'`=<>]+)/.source
58
62
  ];
@@ -62,6 +66,7 @@ const singleAttrValues = [
62
66
  const singleAttrValueLenientUnquoted = /([^ \t\n\f\r"'>]+)/.source;
63
67
  // The characters the lenient pattern takes in beyond the strict one, each a parse error
64
68
  const RE_UNQUOTED_VALUE_ERROR = /[<=`]/;
69
+ const RE_ATTR_NAME_QUOTE = /["']/;
65
70
  // An attribute name and `=`, for reading a quoted value too long for the attribute pattern
66
71
  const RE_ATTR_NAME_ASSIGN = /^\s*([^\s"'<>/=]+)\s*=\s*/;
67
72
  // The rest of an unquoted value, strict and lenient, as one character class read in a single
@@ -81,8 +86,10 @@ const qnameCapture = (function () {
81
86
  const startTagOpen = new RegExp('^<' + qnameCapture);
82
87
  const endTagOpen = new RegExp('^</' + qnameCapture);
83
88
 
89
+ const RE_COMMENT_END = /--!?>/g;
90
+
84
91
  // Empty elements
85
- const empty = new Set(['area', 'base', 'basefont', 'br', 'col', 'embed', 'frame', 'hr', 'img', 'input', 'isindex', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
92
+ const empty = new Set(['area', 'base', 'basefont', 'br', 'col', 'embed', 'frame', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
86
93
 
87
94
  // Elements that you can, intentionally, leave open (and which close themselves)
88
95
  const closeSelf = new Set(['colgroup', 'dd', 'dt', 'li', 'option', 'p', 'td', 'tfoot', 'th', 'thead', 'tr', 'source']);
@@ -115,6 +122,26 @@ const buttonScopeBoundaries = new Set(['applet', 'button', 'caption', 'html', 'm
115
122
  // https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody
116
123
  const closesP = new Set(['address', 'article', 'aside', 'blockquote', 'center', 'dd', 'details', 'dialog', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', 'listing', 'main', 'menu', 'nav', 'ol', 'p', 'plaintext', 'pre', 'search', 'section', 'summary', 'table', 'ul', 'xmp']);
117
124
 
125
+ const tableSectionElements = new Set(['tbody', 'tfoot', 'thead']);
126
+ const tablePartElements = new Set([...tableSectionElements, 'td', 'th', 'tr']);
127
+
128
+ // Elements that end the scope a formatting element’s end tag looks for it in,
129
+ // and those that make it leave the elements inside it open
130
+ // https://html.spec.whatwg.org/multipage/parsing.html#has-an-element-in-scope
131
+ const formattingScopeBoundaries = new Set(['applet', 'caption', 'html', 'marquee', 'object', 'table', 'td', 'template', 'th']);
132
+ // https://html.spec.whatwg.org/multipage/parsing.html#special
133
+ const specialElements = new Set(['address', 'article', 'aside', 'blockquote', 'body', 'button', 'center', 'colgroup', 'dd', 'details', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'iframe', 'li', 'listing', 'main', 'menu', 'nav', 'noembed', 'noframes', 'noscript', 'ol', 'p', 'plaintext', 'pre', 'script', 'search', 'section', 'select', 'style', 'summary', 'tbody', 'textarea', 'tfoot', 'thead', 'title', 'tr', 'ul', 'xmp']);
134
+
135
+ // End tags browsers ignore unless the element is in scope, which ends at the boundaries below, at
136
+ // `button` for `p`, and at `ol` and `ul` for `li`. Cells and captions are left out, as they’re
137
+ // only open within a `table` that ends the scope anyway, and `form`, whose end tag has an effect
138
+ // even where it’s ignored.
139
+ // https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody
140
+ const scopedEndTagElements = new Set(['address', 'applet', 'article', 'aside', 'blockquote', 'button', 'center', 'dd', 'details', 'dialog', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'li', 'listing', 'main', 'marquee', 'menu', 'nav', 'object', 'ol', 'p', 'pre', 'search', 'section', 'summary', 'ul']);
141
+ const endTagScopeBoundaries = new Set(['applet', 'html', 'marquee', 'object', 'table', 'template']);
142
+
143
+ const isSpecialOrBoundary = (/** @type {string} */ lowerTag) => specialElements.has(lowerTag) || formattingScopeBoundaries.has(lowerTag);
144
+
118
145
  // A tag name ends at whitespace, a slash, or the closing bracket, so `</scriptx>` names a
119
146
  // different element and does not end this one
120
147
  const endsTagName = (/** @type {string} */ character) =>
@@ -184,6 +211,9 @@ function findEndTagClose(text, nameEnd) {
184
211
  * reaches, or `null` where no end tag of its own follows
185
212
  */
186
213
  function findRawTextEnd(text, tag) {
214
+ if (tag === 'script') {
215
+ return findScriptEnd(text);
216
+ }
187
217
  const nameEnd = tag.length + 2;
188
218
  for (let candidate = text.indexOf('</'); candidate !== -1; candidate = text.indexOf('</', candidate + 2)) {
189
219
  if (text.slice(candidate + 2, candidate + nameEnd).toLowerCase() !== tag || !endsTagName(text.charAt(candidate + nameEnd))) {
@@ -197,6 +227,48 @@ function findRawTextEnd(text, tag) {
197
227
  return null;
198
228
  }
199
229
 
230
+ // What changes the state script content is read in; a tag name ends as in `endsTagName`,
231
+ // and `--!>` is left out as it only ends comments, not these states
232
+ const RE_SCRIPT_STATE = /<!--|-->|<\/?script(?=[\s/>])/gi;
233
+
234
+ /**
235
+ * Browsers read `<!--` in a script as opening an escaped state, in which
236
+ * `<script` opens a double escaped one that an end tag only leaves again
237
+ * https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state
238
+ * @param {string} text - The input from the start of the content on
239
+ * @returns {{content: string, length: number} | null} What the element holds and how far it
240
+ * reaches, or `null` where no end tag of its own follows
241
+ */
242
+ function findScriptEnd(text) {
243
+ const DATA = 0;
244
+ const ESCAPED = 1;
245
+ const DOUBLE_ESCAPED = 2;
246
+ let state = DATA;
247
+ RE_SCRIPT_STATE.lastIndex = 0;
248
+ for (let match = RE_SCRIPT_STATE.exec(text); match; match = RE_SCRIPT_STATE.exec(text)) {
249
+ const token = match[0].toLowerCase();
250
+ if (token === '<!--') {
251
+ if (state === DATA) {
252
+ state = ESCAPED;
253
+ // The dashes can close what they open (`<!-->`)
254
+ RE_SCRIPT_STATE.lastIndex = match.index + 2;
255
+ }
256
+ } else if (token === '-->') {
257
+ state = DATA;
258
+ } else if (token === '<script') {
259
+ if (state === ESCAPED) {
260
+ state = DOUBLE_ESCAPED;
261
+ }
262
+ } else if (state === DOUBLE_ESCAPED) {
263
+ state = ESCAPED;
264
+ } else {
265
+ const end = findEndTagClose(text, match.index + token.length);
266
+ return end === -1 ? null : { content: text.slice(0, match.index), length: end + 1 };
267
+ }
268
+ }
269
+ return null;
270
+ }
271
+
200
272
  // Cache for compiled attribute regexes per handler configuration
201
273
  const attrRegexCache = new WeakMap();
202
274
 
@@ -204,7 +276,8 @@ const attrRegexCache = new WeakMap();
204
276
  function buildAttrRegex(handler) {
205
277
  const unquotedValue = handler.continueOnParseError ? singleAttrValueLenientUnquoted : singleAttrValues[2];
206
278
  const attrValues = [singleAttrValues[0], singleAttrValues[1], unquotedValue];
207
- let pattern = singleAttrIdentifier.source +
279
+ const attrName = handler.continueOnParseError ? singleAttrIdentifierLenient : singleAttrIdentifier;
280
+ let pattern = attrName.source +
208
281
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
209
282
  '[ \\t\\n\\f\\r]*(?:' + attrValues.join('|') + '))?';
210
283
  if (handler.customAttrSurround) {
@@ -280,7 +353,8 @@ export class HTMLParser {
280
353
  const fullLength = fullHtml.length;
281
354
 
282
355
  // `pScope` is the index of the `p` a start tag would close from within the entry, or -1
283
- /** @type {Array<{tag: string, lowerTag: string, attrs: HTMLAttribute[], namespace: string, pScope: number}>} */
356
+ // A `table` entry’s `impliedRow` and `impliedSection` say what is open without a start tag
357
+ /** @type {Array<{tag: string, lowerTag: string, attrs: HTMLAttribute[], namespace: string, pScope: number, impliedRow?: boolean, impliedSection?: boolean}>} */
284
358
  const stack = [];
285
359
  /** @type {string} */
286
360
  let lastTag = '';
@@ -293,6 +367,10 @@ export class HTMLParser {
293
367
  /** @type {'' | 'quirks' | 'no-quirks'} */
294
368
  let docMode = '';
295
369
  let docModeSettable = true;
370
+ // Whether markup ended that, and whether a processing instruction came up: Unlike after text
371
+ // or such an instruction, which template code may be, a later doctype is sure to be ignored
372
+ let docStartsWithMarkup = false;
373
+ let docHasInstruction = false;
296
374
  // Open `p` elements a `table` started in while the mode was unknown
297
375
  /** @type {WeakSet<object>} */
298
376
  const pHoldingTable = new WeakSet();
@@ -376,8 +454,24 @@ export class HTMLParser {
376
454
  return { end: hasCloseAtOrAfter(p + 2) ? nextGtPos + 1 : fullLength, dropped };
377
455
  };
378
456
 
457
+ // Where the comment opened at `p` ends: `<!-->` and `<!--->` are empty comments,
458
+ // and besides `-->`, `--!>` ends one, too
459
+ // https://html.spec.whatwg.org/multipage/parsing.html#comment-end-bang-state
460
+ const matchCommentEnd = (/** @type {number} */ p) => {
461
+ const dataStart = p + 4;
462
+ if (fullHtml[dataStart] === '>') return { dataEnd: dataStart, end: dataStart + 1 };
463
+ if (fullHtml.startsWith('->', dataStart)) return { dataEnd: dataStart, end: dataStart + 2 };
464
+ RE_COMMENT_END.lastIndex = dataStart;
465
+ const match = RE_COMMENT_END.exec(fullHtml);
466
+ return match ? { dataEnd: match.index, end: match.index + match[0].length } : null;
467
+ };
468
+
379
469
  const advance = (/** @type {number} */ n) => { pos += n; };
380
470
 
471
+ // An end tag that is dropped leaves no tag for the whitespace around it to go by
472
+ let endTagDropped = false;
473
+ const endTagTokenOf = (/** @type {string} */ lowerTagName) => endTagDropped ? '' : '/' + lowerTagName;
474
+
381
475
  // Lazy line/column calculation—only compute on actual errors
382
476
  const getLineColumn = (/** @type {number} */ position) => {
383
477
  let line = 1;
@@ -505,7 +599,7 @@ export class HTMLParser {
505
599
  // Make sure not to be in an element whose content is text, not markup
506
600
  if (!lastTagLower || !topHoldsRawText()) {
507
601
  let textEnd = fullHtml.indexOf('<', pos);
508
- if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && inForeignContent()) {
602
+ if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && (inForeignContent() || lastTagLower === 'svg' || lastTagLower === 'math')) {
509
603
  textEnd = skipCDATA(textEnd);
510
604
  }
511
605
 
@@ -527,7 +621,7 @@ export class HTMLParser {
527
621
  cachedNextStartTag = null;
528
622
  cachedNextEndTag = null;
529
623
  advance(endTagMatch.text.length);
530
- prevTag = '/' + parseEndTag(endTagMatch.text, endTagMatch.name);
624
+ prevTag = endTagTokenOf(parseEndTag(endTagMatch.text, endTagMatch.name));
531
625
  prevAttrs = emptyAttrs;
532
626
  continue;
533
627
  }
@@ -542,15 +636,15 @@ export class HTMLParser {
542
636
  if (nextCode === 33) { // `!`
543
637
  commentTestY.lastIndex = pos;
544
638
  if (commentTestY.test(fullHtml)) {
545
- const commentEnd = commentEndAhead ? fullHtml.indexOf('-->', pos + 4) : -1;
546
- if (commentEnd === -1) commentEndAhead = false;
639
+ const comment = commentEndAhead ? matchCommentEnd(pos) : null;
640
+ if (!comment) commentEndAhead = false;
547
641
 
548
- if (commentEnd >= 0) {
642
+ if (comment) {
549
643
  if (handler.comment) {
550
- const result = handler.comment(fullHtml.substring(pos + 4, commentEnd));
644
+ const result = handler.comment(fullHtml.substring(pos + 4, comment.dataEnd));
551
645
  if (isThenable(result)) await result;
552
646
  }
553
- advance(commentEnd + 3 - pos);
647
+ advance(comment.end - pos);
554
648
  prevTag = '';
555
649
  prevAttrs = emptyAttrs;
556
650
  continue;
@@ -578,12 +672,14 @@ export class HTMLParser {
578
672
  doctypeY.lastIndex = pos;
579
673
  const doctypeMatch = doctypeY.exec(fullHtml);
580
674
  if (doctypeMatch) {
581
- if (docModeSettable) {
675
+ const setsMode = docModeSettable;
676
+ if (setsMode) {
582
677
  docMode = handler.noQuirksMode || !isQuirksDoctype(doctypeMatch[0]) ? 'no-quirks' : 'quirks';
583
678
  docModeSettable = false;
679
+ docStartsWithMarkup = true;
584
680
  }
585
681
  if (handler.doctype) {
586
- handler.doctype(doctypeMatch[0], docMode === 'no-quirks');
682
+ handler.doctype(doctypeMatch[0], docMode === 'no-quirks', !setsMode && docStartsWithMarkup && !docHasInstruction);
587
683
  }
588
684
  advance(doctypeMatch[0].length);
589
685
  prevTag = '';
@@ -593,6 +689,7 @@ export class HTMLParser {
593
689
  } else if (nextCode === 63) { // `?`
594
690
  const processingInstructionEnd = matchProcessingInstruction(pos);
595
691
  if (processingInstructionEnd !== -1) {
692
+ docHasInstruction = true;
596
693
  if (handler.processingInstruction) {
597
694
  handler.processingInstruction(fullHtml.substring(pos, processingInstructionEnd));
598
695
  }
@@ -622,7 +719,7 @@ export class HTMLParser {
622
719
  const endTagMatch = matchEndTag(pos);
623
720
  if (endTagMatch) {
624
721
  advance(endTagMatch.text.length);
625
- prevTag = '/' + parseEndTag(endTagMatch.text, endTagMatch.name);
722
+ prevTag = endTagTokenOf(parseEndTag(endTagMatch.text, endTagMatch.name));
626
723
  prevAttrs = emptyAttrs;
627
724
  continue;
628
725
  }
@@ -698,7 +795,7 @@ export class HTMLParser {
698
795
  } else if (nextCode === 47 && hasCloseAtOrAfter(pos)) {
699
796
  const nextEndTagMatch = matchEndTag(pos);
700
797
  if (nextEndTagMatch) {
701
- nextTag = '/' + nextEndTagMatch.name.toLowerCase();
798
+ nextTag = isDroppedEndTag(nextEndTagMatch.name.toLowerCase()) ? '' : '/' + nextEndTagMatch.name.toLowerCase();
702
799
  nextAttrs = emptyAttrs;
703
800
  cachedNextEndTag = { match: nextEndTagMatch, pos };
704
801
  } else {
@@ -803,6 +900,10 @@ export class HTMLParser {
803
900
  // Repeated closing quotes belong to the attribute, as in the attribute pattern
804
901
  let attrEnd = closeQuote + 1;
805
902
  while (fullHtml[attrEnd] === quoteChar) attrEnd++;
903
+ const codeAfter = fullHtml.charCodeAt(attrEnd);
904
+ if (attrEnd < fullLength && !isWhitespaceCode(codeAfter) && codeAfter !== 47 && codeAfter !== 62) {
905
+ attrEnd = closeQuote + 1;
906
+ }
806
907
  /** @type {Array<string | undefined>} */
807
908
  const attr = [];
808
909
  attr[0] = fullHtml.substring(attrPos, attrEnd);
@@ -1015,6 +1116,26 @@ export class HTMLParser {
1015
1116
  return false;
1016
1117
  }
1017
1118
 
1119
+ function closeCellInCurrentTable() {
1120
+ return closeIfFoundInCurrentTable('td') || closeIfFoundInCurrentTable('th');
1121
+ }
1122
+
1123
+ // Browsers open a row for a cell and a section for a row where the source has no start tag
1124
+ // for them, and those stay open until an end tag or the next row or section closes them
1125
+ function noteImpliedTableParts(/** @type {string} */ lowerTagName) {
1126
+ const table = stack[findTagInCurrentTable('table')];
1127
+ if (!table) {
1128
+ return;
1129
+ }
1130
+ if (lowerTagName === 'tbody' || lowerTagName === 'tfoot' || lowerTagName === 'thead') {
1131
+ table.impliedRow = false;
1132
+ table.impliedSection = false;
1133
+ return;
1134
+ }
1135
+ table.impliedRow = lowerTagName !== 'tr' && (table.impliedRow || findTagInCurrentTable('tr') < 0);
1136
+ table.impliedSection = findTagInCurrentTable('tbody') < 0 && findTagInCurrentTable('tfoot') < 0 && findTagInCurrentTable('thead') < 0;
1137
+ }
1138
+
1018
1139
  // The lenient attribute pattern takes characters into unquoted values that the strict
1019
1140
  // one rejects; the start tag has just been consumed, so it ends right before `pos`
1020
1141
  function reportUnquotedValueErrors(/** @type {{attrs: Array<Array<string | undefined>>, advance: number}} */ match) {
@@ -1023,6 +1144,11 @@ export class HTMLParser {
1023
1144
  const text = attr[0] ?? '';
1024
1145
  attrPos = fullHtml.indexOf(text, attrPos);
1025
1146
  const surroundCount = handler.customAttrSurround ? handler.customAttrSurround.length : 0;
1147
+ const attrName = attr[baseIndex] ?? '';
1148
+ const nameQuote = attrName.includes('"') || attrName.includes("'") ? attrName.search(RE_ATTR_NAME_QUOTE) : -1;
1149
+ if (nameQuote !== -1) {
1150
+ reportParseError(attrPos + text.indexOf(attrName) + nameQuote, `Kept \`${attrName[nameQuote]}\` in attribute name`);
1151
+ }
1026
1152
  for (let i = 0; i <= surroundCount; i++) {
1027
1153
  // Unquoted values sit at index 5 of each surround block, and 4 behind the plain name
1028
1154
  const value = i < surroundCount ? attr[1 + i * NCP + 5] : attr[1 + surroundCount * NCP + 4];
@@ -1042,6 +1168,7 @@ export class HTMLParser {
1042
1168
  const lowerTagName = match.lowerTagName;
1043
1169
  let unarySlash = match.unarySlash;
1044
1170
 
1171
+ docStartsWithMarkup ||= docModeSettable;
1045
1172
  docModeSettable = false;
1046
1173
  if (handler.parseError) reportUnquotedValueErrors(match);
1047
1174
  // A plain loop, so a tag costs no closure allocation per attribute
@@ -1111,12 +1238,19 @@ export class HTMLParser {
1111
1238
  // A row or cell start tag closes the row or cell still open in the same table,
1112
1239
  // which may sit deeper down the stack than the element that was opened last
1113
1240
  if (lowerTagName === 'tr') {
1114
- closeIfFoundInCurrentTable('tr');
1241
+ if (!closeIfFoundInCurrentTable('tr')) {
1242
+ closeCellInCurrentTable();
1243
+ }
1115
1244
  } else if (lowerTagName === 'td' || lowerTagName === 'th') {
1116
- if (!closeIfFoundInCurrentTable('td')) {
1117
- closeIfFoundInCurrentTable('th');
1245
+ closeCellInCurrentTable();
1246
+ } else if (lowerTagName === 'tbody' || lowerTagName === 'tfoot' || lowerTagName === 'thead') {
1247
+ if (!closeIfFoundInCurrentTable('tr')) {
1248
+ closeCellInCurrentTable();
1118
1249
  }
1119
1250
  }
1251
+ if (tablePartElements.has(lowerTagName)) {
1252
+ noteImpliedTableParts(lowerTagName);
1253
+ }
1120
1254
  if (lowerTagName === 'tbody') {
1121
1255
  if (!closeIfFoundInCurrentTable('tfoot')) {
1122
1256
  closeIfFoundInCurrentTable('thead');
@@ -1171,7 +1305,9 @@ export class HTMLParser {
1171
1305
 
1172
1306
  // The name is returned so the parse loop can skip lowercasing it again
1173
1307
  const started = handler.start?.(tagName, attrs, unary, unarySlash);
1174
- return isThenable(started) ? started.then(() => lowerTagName) : lowerTagName;
1308
+ // What a slash closes reads as ended to the whitespace logic
1309
+ const token = selfClosed ? '/' + lowerTagName : lowerTagName;
1310
+ return isThenable(started) ? started.then(() => token) : token;
1175
1311
  }
1176
1312
 
1177
1313
  // Pushes an open element, noting the `p` in scope from within it: its own index
@@ -1179,7 +1315,8 @@ export class HTMLParser {
1179
1315
  // holds text with scripting), and otherwise its parent’s; returns the namespace it sits in
1180
1316
  function pushOpenElement(/** @type {string} */ tag, /** @type {string} */ lowerTag, /** @type {HTMLAttribute[]} */ attrs) {
1181
1317
  const parent = stack.length ? stack[stack.length - 1] : undefined;
1182
- const namespace = namespaceInside(parent);
1318
+ // An `svg` start tag leads out of MathML from `annotation-xml`, as it does from HTML
1319
+ const namespace = lowerTag === 'svg' && parent?.lowerTag === 'annotation-xml' ? '' : namespaceInside(parent);
1183
1320
  let pScope = -1;
1184
1321
  if (!namespace) {
1185
1322
  if (lowerTag === 'p') {
@@ -1218,8 +1355,141 @@ export class HTMLParser {
1218
1355
  parseEndTagAt(stackIndex, false);
1219
1356
  }
1220
1357
 
1358
+ // What a formatting element’s end tag meets among the elements open inside it
1359
+ // @@ Reopen formatting on whitespace (`<p><a><div></div> <div>` should stay as is)
1360
+ function readFormattingScope(/** @type {number} */ stackIndex) {
1361
+ let hasSpecial = false;
1362
+ for (let i = stack.length - 1; i > stackIndex; i--) {
1363
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
1364
+ if (entry.namespace) {
1365
+ return 'foreign';
1366
+ }
1367
+ if (formattingScopeBoundaries.has(entry.lowerTag)) {
1368
+ return 'boundary';
1369
+ }
1370
+ hasSpecial ||= specialElements.has(entry.lowerTag);
1371
+ }
1372
+ return hasSpecial ? 'special' : '';
1373
+ }
1374
+
1375
+ // A formatting element’s end tag leaves open the first special element in it, and is ignored
1376
+ // from beyond a scope boundary; returns whether either applied, or `false` to close as usual
1377
+ // https://html.spec.whatwg.org/multipage/parsing.html#adoption-agency-algorithm
1378
+ function closeFormattingElement(/** @type {number} */ stackIndex, /** @type {string} */ rest) {
1379
+ const scope = readFormattingScope(stackIndex);
1380
+ if (scope === 'boundary') {
1381
+ endTagDropped = true;
1382
+ handler.strayEnd?.();
1383
+ return true;
1384
+ }
1385
+ if (scope !== 'special') {
1386
+ return false;
1387
+ }
1388
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[stackIndex]);
1389
+ handler.end?.(entry.tag, entry.attrs, false, rest);
1390
+ stack.splice(stackIndex, 1);
1391
+ for (let i = stackIndex; i < stack.length; i++) {
1392
+ const above = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
1393
+ if (above.pScope > stackIndex) {
1394
+ above.pScope--;
1395
+ }
1396
+ }
1397
+ stackVersion++;
1398
+ return true;
1399
+ }
1400
+
1401
+ // Browsers ignore the end tag of an ordinary element with a special one open inside it
1402
+ function isBehindSpecialElement(/** @type {number} */ stackIndex) {
1403
+ const target = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[stackIndex]);
1404
+ // `dialog` isn’t special, but its end tag closes what is open in it nevertheless
1405
+ if (target.namespace || isSpecialOrBoundary(target.lowerTag) || formattingElements.has(target.lowerTag) || tablePartElements.has(target.lowerTag) || ['dialog', 'math', 'svg'].includes(target.lowerTag)) {
1406
+ return false;
1407
+ }
1408
+ for (let i = stack.length - 1; i > stackIndex; i--) {
1409
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
1410
+ if (entry.namespace) {
1411
+ return false;
1412
+ }
1413
+ if (isSpecialOrBoundary(entry.lowerTag)) {
1414
+ return true;
1415
+ }
1416
+ }
1417
+ return false;
1418
+ }
1419
+
1420
+ // An end tag for a row or section whose start tag is implied by the cell or row open in it;
1421
+ // closes what is open within it (returning whether there is such a part)
1422
+ function closeImpliedTablePart(/** @type {string} */ lowerTagName) {
1423
+ if (!isImpliedTablePart(lowerTagName)) {
1424
+ return false;
1425
+ }
1426
+ const table = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[findTagInCurrentTable('table')]);
1427
+ if (lowerTagName === 'tr') {
1428
+ closeCellInCurrentTable();
1429
+ } else {
1430
+ if (!closeIfFoundInCurrentTable('tr')) {
1431
+ closeCellInCurrentTable();
1432
+ }
1433
+ table.impliedSection = false;
1434
+ }
1435
+ table.impliedRow = false;
1436
+ return true;
1437
+ }
1438
+
1439
+ function isImpliedTablePart(/** @type {string} */ lowerTagName) {
1440
+ const table = stack[findTagInCurrentTable('table')];
1441
+ return lowerTagName === 'tr' ? Boolean(table?.impliedRow) : tableSectionElements.has(lowerTagName) && Boolean(table?.impliedSection);
1442
+ }
1443
+
1444
+ // Whether `parseEndTag` would drop this end tag, for the look-ahead
1445
+ function isDroppedEndTag(/** @type {string} */ lowerTagName) {
1446
+ const stackIndex = findEndTagTarget(lowerTagName);
1447
+ if (stackIndex >= 0) {
1448
+ if (stackIndex === stack.length - 1) {
1449
+ return false;
1450
+ }
1451
+ if (formattingElements.has(lowerTagName) && !stack[stackIndex]?.namespace && readFormattingScope(stackIndex) === 'boundary') {
1452
+ return true;
1453
+ }
1454
+ return isBehindSpecialElement(stackIndex);
1455
+ }
1456
+ return !handler.partialMarkup && !formattingElements.has(lowerTagName) && lowerTagName !== 'br' && lowerTagName !== 'p' && !isImpliedTablePart(lowerTagName);
1457
+ }
1458
+
1459
+ // Table parts end within their own table only
1460
+ function findEndTagTarget(/** @type {string} */ lowerTagName) {
1461
+ const stackIndex = stack.length - 1;
1462
+ if (stack[stackIndex]?.lowerTag === lowerTagName) {
1463
+ return stackIndex;
1464
+ }
1465
+ if (tablePartElements.has(lowerTagName) || lowerTagName === 'caption') {
1466
+ return findTagInCurrentTable(lowerTagName);
1467
+ }
1468
+ const target = findTag(lowerTagName);
1469
+ return target >= 0 && isOutOfScope(lowerTagName, target) ? -1 : target;
1470
+ }
1471
+
1472
+ // @@ Close only `form` on `</form>` (`<form><div></form>x</div>y` should stay as is)
1473
+ function isOutOfScope(/** @type {string} */ lowerTagName, /** @type {number} */ stackIndex) {
1474
+ if (!scopedEndTagElements.has(lowerTagName) || stack[stackIndex]?.namespace) {
1475
+ return false;
1476
+ }
1477
+ for (let i = stack.length - 1; i > stackIndex; i--) {
1478
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
1479
+ if (entry.namespace) {
1480
+ return false;
1481
+ }
1482
+ if (endTagScopeBoundaries.has(entry.lowerTag) || (lowerTagName === 'p' && entry.lowerTag === 'button') || (lowerTagName === 'li' && (entry.lowerTag === 'ol' || entry.lowerTag === 'ul'))) {
1483
+ return true;
1484
+ }
1485
+ }
1486
+ return false;
1487
+ }
1488
+
1221
1489
  function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
1490
+ docStartsWithMarkup ||= docModeSettable;
1222
1491
  docModeSettable = false;
1492
+ endTagDropped = false;
1223
1493
  let stackIndex;
1224
1494
  const lowerTagName = tagName ? tagName.toLowerCase() : '';
1225
1495
 
@@ -1230,7 +1500,7 @@ export class HTMLParser {
1230
1500
 
1231
1501
  // Find the closest opened tag of the same type
1232
1502
  if (tagName) {
1233
- stackIndex = findTag(lowerTagName);
1503
+ stackIndex = tag ? findEndTagTarget(lowerTagName) : findTag(lowerTagName);
1234
1504
  } else { // If no tag name is provided, clean shop
1235
1505
  stackIndex = 0;
1236
1506
  }
@@ -1238,6 +1508,16 @@ export class HTMLParser {
1238
1508
  // Whatever follows the name, which browsers drop but a custom fragment may stand in for
1239
1509
  const rest = tag.length > tagName.length + 3 ? tag.slice(tagName.length + 2, -1) : '';
1240
1510
 
1511
+ const closesTop = stackIndex === stack.length - 1;
1512
+ if (tag && !closesTop && stackIndex >= 0 && formattingElements.has(lowerTagName) && !stack[stackIndex]?.namespace && closeFormattingElement(stackIndex, rest)) {
1513
+ return lowerTagName;
1514
+ }
1515
+ if (tag && !closesTop && stackIndex >= 0 && isBehindSpecialElement(stackIndex)) {
1516
+ endTagDropped = true;
1517
+ handler.strayEnd?.();
1518
+ return lowerTagName;
1519
+ }
1520
+
1241
1521
  if (stackIndex >= 0) {
1242
1522
  // Close all the open elements, up the stack
1243
1523
  for (let i = stack.length - 1; i >= stackIndex; i--) {
@@ -1256,6 +1536,10 @@ export class HTMLParser {
1256
1536
  if (handler.end) {
1257
1537
  handler.end(tagName, [], false, rest);
1258
1538
  }
1539
+ } else if (tag && closeImpliedTablePart(lowerTagName)) {
1540
+ if (handler.end) {
1541
+ handler.end(tagName, [], false, rest);
1542
+ }
1259
1543
  } else if (lowerTagName === 'br') {
1260
1544
  if (handler.start) {
1261
1545
  handler.start(tagName, [], true, '');
@@ -1267,9 +1551,10 @@ export class HTMLParser {
1267
1551
  if (handler.end) {
1268
1552
  handler.end(tagName, []);
1269
1553
  }
1270
- } else if (handler.strayEnd) {
1554
+ } else {
1271
1555
  // An end tag with nothing to close is dropped
1272
- handler.strayEnd();
1556
+ endTagDropped = Boolean(tag);
1557
+ handler.strayEnd?.();
1273
1558
  }
1274
1559
  // Returned so the parse loop can skip lowercasing the name again
1275
1560
  return lowerTagName;