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.
package/src/htmlparser.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
6
6
  */
7
7
 
8
- import { isThenable, isQuirksDoctype, embedSource, findTagEnd } from './lib/utils.js';
8
+ import { isThenable, isQuirksDoctype, isWhitespaceCode, embedSource, findTagEnd } from './lib/utils.js';
9
9
  import { endlessRawTextElements, escapableRawTextElements, formattingElements, genericRawTextElements, processingInstructionTag, RE_HTML_ENCODING, RE_WS_ONLY } from './lib/constants.js';
10
10
 
11
11
  /** @import { HTMLAttribute } from './lib/attributes.js' */
@@ -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) =>
@@ -145,13 +172,6 @@ function scanAsciiTagName(html, start) {
145
172
  }
146
173
  }
147
174
 
148
- // The characters `\s` matches, by code: Tab through carriage return, space, and the
149
- // Unicode whitespace and line terminators
150
- const isWhitespaceCode = (/** @type {number} */ code) =>
151
- (code >= 9 && code <= 13) || code === 32 || code === 0xA0 || code === 0x1680 ||
152
- (code >= 0x2000 && code <= 0x200A) || code === 0x2028 || code === 0x2029 ||
153
- code === 0x202F || code === 0x205F || code === 0x3000 || code === 0xFEFF;
154
-
155
175
  /**
156
176
  * @param {string} text - The input the tag stands in
157
177
  * @param {number} nameEnd - Position right after the tag name
@@ -184,6 +204,9 @@ function findEndTagClose(text, nameEnd) {
184
204
  * reaches, or `null` where no end tag of its own follows
185
205
  */
186
206
  function findRawTextEnd(text, tag) {
207
+ if (tag === 'script') {
208
+ return findScriptEnd(text);
209
+ }
187
210
  const nameEnd = tag.length + 2;
188
211
  for (let candidate = text.indexOf('</'); candidate !== -1; candidate = text.indexOf('</', candidate + 2)) {
189
212
  if (text.slice(candidate + 2, candidate + nameEnd).toLowerCase() !== tag || !endsTagName(text.charAt(candidate + nameEnd))) {
@@ -197,14 +220,54 @@ function findRawTextEnd(text, tag) {
197
220
  return null;
198
221
  }
199
222
 
200
- // Cache for compiled attribute regexes per handler configuration
201
- const attrRegexCache = new WeakMap();
223
+ // What changes the state script content is read in; a tag name ends as in `endsTagName`,
224
+ // and `--!>` is left out as it only ends comments, not these states
225
+ const RE_SCRIPT_STATE = /<!--|-->|<\/?script(?=[\s/>])/gi;
226
+
227
+ /**
228
+ * Browsers read `<!--` in a script as opening an escaped state, in which
229
+ * `<script` opens a double escaped one that an end tag only leaves again
230
+ * https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state
231
+ * @param {string} text - The input from the start of the content on
232
+ * @returns {{content: string, length: number} | null} What the element holds and how far it
233
+ * reaches, or `null` where no end tag of its own follows
234
+ */
235
+ function findScriptEnd(text) {
236
+ const DATA = 0;
237
+ const ESCAPED = 1;
238
+ const DOUBLE_ESCAPED = 2;
239
+ let state = DATA;
240
+ RE_SCRIPT_STATE.lastIndex = 0;
241
+ for (let match = RE_SCRIPT_STATE.exec(text); match; match = RE_SCRIPT_STATE.exec(text)) {
242
+ const token = match[0].toLowerCase();
243
+ if (token === '<!--') {
244
+ if (state === DATA) {
245
+ state = ESCAPED;
246
+ // The dashes can close what they open (`<!-->`)
247
+ RE_SCRIPT_STATE.lastIndex = match.index + 2;
248
+ }
249
+ } else if (token === '-->') {
250
+ state = DATA;
251
+ } else if (token === '<script') {
252
+ if (state === ESCAPED) {
253
+ state = DOUBLE_ESCAPED;
254
+ }
255
+ } else if (state === DOUBLE_ESCAPED) {
256
+ state = ESCAPED;
257
+ } else {
258
+ const end = findEndTagClose(text, match.index + token.length);
259
+ return end === -1 ? null : { content: text.slice(0, match.index), length: end + 1 };
260
+ }
261
+ }
262
+ return null;
263
+ }
202
264
 
203
265
  /** @param {HTMLParserHandler} handler */
204
266
  function buildAttrRegex(handler) {
205
267
  const unquotedValue = handler.continueOnParseError ? singleAttrValueLenientUnquoted : singleAttrValues[2];
206
268
  const attrValues = [singleAttrValues[0], singleAttrValues[1], unquotedValue];
207
- let pattern = singleAttrIdentifier.source +
269
+ const attrName = handler.continueOnParseError ? singleAttrIdentifierLenient : singleAttrIdentifier;
270
+ let pattern = attrName.source +
208
271
  '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
209
272
  '[ \\t\\n\\f\\r]*(?:' + attrValues.join('|') + '))?';
210
273
  if (handler.customAttrSurround) {
@@ -229,26 +292,32 @@ function buildAttrRegex(handler) {
229
292
  return new RegExp('^\\s*' + pattern);
230
293
  }
231
294
 
232
- /** @param {HTMLParserHandler} handler */
233
- function getAttrRegexForHandler(handler) {
234
- const cached = attrRegexCache.get(handler);
235
- if (cached) return cached;
236
- const compiled = buildAttrRegex(handler);
237
- attrRegexCache.set(handler, compiled);
238
- return compiled;
239
- }
295
+ // Compiled attribute regexes, the sticky one (`y`) for matching at a position in the whole
296
+ // input: By handler where it holds custom patterns, else by the flags they depend on, as
297
+ // a handler lasts one parse only
298
+ /** @type {WeakMap<HTMLParserHandler, {attribute: RegExp, attributeY: RegExp}>} */
299
+ const attrRegexCache = new WeakMap();
300
+ /** @type {Map<number, {attribute: RegExp, attributeY: RegExp}>} */
301
+ const attrRegexPlainCache = new Map();
240
302
 
241
- // Cache for sticky attribute regexes (`y` flag for position-based matching on full string)
242
- const attrRegexStickyCache = new WeakMap();
303
+ /** @param {unknown} value */
304
+ const isEmptyOption = value => !value || (Array.isArray(value) && !value.length);
243
305
 
244
306
  /** @param {HTMLParserHandler} handler */
245
- function getAttrRegexStickyForHandler(handler) {
246
- const cached = attrRegexStickyCache.get(handler);
307
+ function getAttrRegexes(handler) {
308
+ const plain = isEmptyOption(handler.customAttrAssign) && isEmptyOption(handler.customAttrSurround);
309
+ // An empty `customAttrSurround` still shapes the pattern, if not what it matches
310
+ const key = (handler.continueOnParseError ? 1 : 0) + (handler.customAttrSurround ? 2 : 0);
311
+ const cached = plain ? attrRegexPlainCache.get(key) : attrRegexCache.get(handler);
247
312
  if (cached) return cached;
248
- const nonSticky = getAttrRegexForHandler(handler);
249
- // Derive sticky version: Remove `^` anchor, add `y` flag
250
- const compiled = new RegExp(nonSticky.source.slice(1), 'y');
251
- attrRegexStickyCache.set(handler, compiled);
313
+ const attribute = buildAttrRegex(handler);
314
+ // Without the `^` anchor, as stickiness anchors it
315
+ const compiled = { attribute, attributeY: new RegExp(attribute.source.slice(1), 'y') };
316
+ if (plain) {
317
+ attrRegexPlainCache.set(key, compiled);
318
+ } else {
319
+ attrRegexCache.set(handler, compiled);
320
+ }
252
321
  return compiled;
253
322
  }
254
323
 
@@ -280,7 +349,8 @@ export class HTMLParser {
280
349
  const fullLength = fullHtml.length;
281
350
 
282
351
  // `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}>} */
352
+ // A `table` entry’s `impliedRow` and `impliedSection` say what is open without a start tag
353
+ /** @type {Array<{tag: string, lowerTag: string, attrs: HTMLAttribute[], namespace: string, pScope: number, impliedRow?: boolean, impliedSection?: boolean}>} */
284
354
  const stack = [];
285
355
  /** @type {string} */
286
356
  let lastTag = '';
@@ -293,14 +363,17 @@ export class HTMLParser {
293
363
  /** @type {'' | 'quirks' | 'no-quirks'} */
294
364
  let docMode = '';
295
365
  let docModeSettable = true;
366
+ // Whether markup ended that, and whether a processing instruction came up: Unlike after text
367
+ // or such an instruction, which template code may be, a later doctype is sure to be ignored
368
+ let docStartsWithMarkup = false;
369
+ let docHasInstruction = false;
296
370
  // Open `p` elements a `table` started in while the mode was unknown
297
371
  /** @type {WeakSet<object>} */
298
372
  const pHoldingTable = new WeakSet();
299
373
  // Lowercase counterpart of `lastTag`, kept in sync to avoid per-iteration lowercasing
300
374
  /** @type {string} */
301
375
  let lastTagLower = '';
302
- const attribute = getAttrRegexForHandler(handler);
303
- const attributeY = getAttrRegexStickyForHandler(handler);
376
+ const { attribute, attributeY } = getAttrRegexes(handler);
304
377
  // Index of the attribute name in a match, after each `customAttrSurround` pattern’s captures
305
378
  const baseIndex = 1 + (handler.customAttrSurround ? handler.customAttrSurround.length * NCP : 0);
306
379
  /** @type {string | undefined} */
@@ -376,8 +449,24 @@ export class HTMLParser {
376
449
  return { end: hasCloseAtOrAfter(p + 2) ? nextGtPos + 1 : fullLength, dropped };
377
450
  };
378
451
 
452
+ // Where the comment opened at `p` ends: `<!-->` and `<!--->` are empty comments,
453
+ // and besides `-->`, `--!>` ends one, too
454
+ // https://html.spec.whatwg.org/multipage/parsing.html#comment-end-bang-state
455
+ const matchCommentEnd = (/** @type {number} */ p) => {
456
+ const dataStart = p + 4;
457
+ if (fullHtml[dataStart] === '>') return { dataEnd: dataStart, end: dataStart + 1 };
458
+ if (fullHtml.startsWith('->', dataStart)) return { dataEnd: dataStart, end: dataStart + 2 };
459
+ RE_COMMENT_END.lastIndex = dataStart;
460
+ const match = RE_COMMENT_END.exec(fullHtml);
461
+ return match ? { dataEnd: match.index, end: match.index + match[0].length } : null;
462
+ };
463
+
379
464
  const advance = (/** @type {number} */ n) => { pos += n; };
380
465
 
466
+ // An end tag that is dropped leaves no tag for the whitespace around it to go by
467
+ let endTagDropped = false;
468
+ const endTagTokenOf = (/** @type {string} */ lowerTagName) => endTagDropped ? '' : '/' + lowerTagName;
469
+
381
470
  // Lazy line/column calculation—only compute on actual errors
382
471
  const getLineColumn = (/** @type {number} */ position) => {
383
472
  let line = 1;
@@ -505,7 +594,7 @@ export class HTMLParser {
505
594
  // Make sure not to be in an element whose content is text, not markup
506
595
  if (!lastTagLower || !topHoldsRawText()) {
507
596
  let textEnd = fullHtml.indexOf('<', pos);
508
- if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && inForeignContent()) {
597
+ if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && (inForeignContent() || lastTagLower === 'svg' || lastTagLower === 'math')) {
509
598
  textEnd = skipCDATA(textEnd);
510
599
  }
511
600
 
@@ -527,7 +616,7 @@ export class HTMLParser {
527
616
  cachedNextStartTag = null;
528
617
  cachedNextEndTag = null;
529
618
  advance(endTagMatch.text.length);
530
- prevTag = '/' + parseEndTag(endTagMatch.text, endTagMatch.name);
619
+ prevTag = endTagTokenOf(parseEndTag(endTagMatch.text, endTagMatch.name));
531
620
  prevAttrs = emptyAttrs;
532
621
  continue;
533
622
  }
@@ -542,15 +631,15 @@ export class HTMLParser {
542
631
  if (nextCode === 33) { // `!`
543
632
  commentTestY.lastIndex = pos;
544
633
  if (commentTestY.test(fullHtml)) {
545
- const commentEnd = commentEndAhead ? fullHtml.indexOf('-->', pos + 4) : -1;
546
- if (commentEnd === -1) commentEndAhead = false;
634
+ const comment = commentEndAhead ? matchCommentEnd(pos) : null;
635
+ if (!comment) commentEndAhead = false;
547
636
 
548
- if (commentEnd >= 0) {
637
+ if (comment) {
549
638
  if (handler.comment) {
550
- const result = handler.comment(fullHtml.substring(pos + 4, commentEnd));
639
+ const result = handler.comment(fullHtml.substring(pos + 4, comment.dataEnd));
551
640
  if (isThenable(result)) await result;
552
641
  }
553
- advance(commentEnd + 3 - pos);
642
+ advance(comment.end - pos);
554
643
  prevTag = '';
555
644
  prevAttrs = emptyAttrs;
556
645
  continue;
@@ -578,12 +667,14 @@ export class HTMLParser {
578
667
  doctypeY.lastIndex = pos;
579
668
  const doctypeMatch = doctypeY.exec(fullHtml);
580
669
  if (doctypeMatch) {
581
- if (docModeSettable) {
670
+ const setsMode = docModeSettable;
671
+ if (setsMode) {
582
672
  docMode = handler.noQuirksMode || !isQuirksDoctype(doctypeMatch[0]) ? 'no-quirks' : 'quirks';
583
673
  docModeSettable = false;
674
+ docStartsWithMarkup = true;
584
675
  }
585
676
  if (handler.doctype) {
586
- handler.doctype(doctypeMatch[0], docMode === 'no-quirks');
677
+ handler.doctype(doctypeMatch[0], docMode === 'no-quirks', !setsMode && docStartsWithMarkup && !docHasInstruction);
587
678
  }
588
679
  advance(doctypeMatch[0].length);
589
680
  prevTag = '';
@@ -593,6 +684,7 @@ export class HTMLParser {
593
684
  } else if (nextCode === 63) { // `?`
594
685
  const processingInstructionEnd = matchProcessingInstruction(pos);
595
686
  if (processingInstructionEnd !== -1) {
687
+ docHasInstruction = true;
596
688
  if (handler.processingInstruction) {
597
689
  handler.processingInstruction(fullHtml.substring(pos, processingInstructionEnd));
598
690
  }
@@ -622,7 +714,7 @@ export class HTMLParser {
622
714
  const endTagMatch = matchEndTag(pos);
623
715
  if (endTagMatch) {
624
716
  advance(endTagMatch.text.length);
625
- prevTag = '/' + parseEndTag(endTagMatch.text, endTagMatch.name);
717
+ prevTag = endTagTokenOf(parseEndTag(endTagMatch.text, endTagMatch.name));
626
718
  prevAttrs = emptyAttrs;
627
719
  continue;
628
720
  }
@@ -698,7 +790,7 @@ export class HTMLParser {
698
790
  } else if (nextCode === 47 && hasCloseAtOrAfter(pos)) {
699
791
  const nextEndTagMatch = matchEndTag(pos);
700
792
  if (nextEndTagMatch) {
701
- nextTag = '/' + nextEndTagMatch.name.toLowerCase();
793
+ nextTag = isDroppedEndTag(nextEndTagMatch.name.toLowerCase()) ? '' : '/' + nextEndTagMatch.name.toLowerCase();
702
794
  nextAttrs = emptyAttrs;
703
795
  cachedNextEndTag = { match: nextEndTagMatch, pos };
704
796
  } else {
@@ -803,6 +895,10 @@ export class HTMLParser {
803
895
  // Repeated closing quotes belong to the attribute, as in the attribute pattern
804
896
  let attrEnd = closeQuote + 1;
805
897
  while (fullHtml[attrEnd] === quoteChar) attrEnd++;
898
+ const codeAfter = fullHtml.charCodeAt(attrEnd);
899
+ if (attrEnd < fullLength && !isWhitespaceCode(codeAfter) && codeAfter !== 47 && codeAfter !== 62) {
900
+ attrEnd = closeQuote + 1;
901
+ }
806
902
  /** @type {Array<string | undefined>} */
807
903
  const attr = [];
808
904
  attr[0] = fullHtml.substring(attrPos, attrEnd);
@@ -955,7 +1051,7 @@ export class HTMLParser {
955
1051
  break;
956
1052
  }
957
1053
 
958
- const attrLen = attr[0].length;
1054
+ const attrLen = /** @type {string} */ (attr[0]).length;
959
1055
  currentPos += attrLen;
960
1056
  consumed += attrLen;
961
1057
  match.attrs.push(attr);
@@ -1015,6 +1111,26 @@ export class HTMLParser {
1015
1111
  return false;
1016
1112
  }
1017
1113
 
1114
+ function closeCellInCurrentTable() {
1115
+ return closeIfFoundInCurrentTable('td') || closeIfFoundInCurrentTable('th');
1116
+ }
1117
+
1118
+ // Browsers open a row for a cell and a section for a row where the source has no start tag
1119
+ // for them, and those stay open until an end tag or the next row or section closes them
1120
+ function noteImpliedTableParts(/** @type {string} */ lowerTagName) {
1121
+ const table = stack[findTagInCurrentTable('table')];
1122
+ if (!table) {
1123
+ return;
1124
+ }
1125
+ if (lowerTagName === 'tbody' || lowerTagName === 'tfoot' || lowerTagName === 'thead') {
1126
+ table.impliedRow = false;
1127
+ table.impliedSection = false;
1128
+ return;
1129
+ }
1130
+ table.impliedRow = lowerTagName !== 'tr' && (table.impliedRow || findTagInCurrentTable('tr') < 0);
1131
+ table.impliedSection = findTagInCurrentTable('tbody') < 0 && findTagInCurrentTable('tfoot') < 0 && findTagInCurrentTable('thead') < 0;
1132
+ }
1133
+
1018
1134
  // The lenient attribute pattern takes characters into unquoted values that the strict
1019
1135
  // one rejects; the start tag has just been consumed, so it ends right before `pos`
1020
1136
  function reportUnquotedValueErrors(/** @type {{attrs: Array<Array<string | undefined>>, advance: number}} */ match) {
@@ -1023,6 +1139,11 @@ export class HTMLParser {
1023
1139
  const text = attr[0] ?? '';
1024
1140
  attrPos = fullHtml.indexOf(text, attrPos);
1025
1141
  const surroundCount = handler.customAttrSurround ? handler.customAttrSurround.length : 0;
1142
+ const attrName = attr[baseIndex] ?? '';
1143
+ const nameQuote = attrName.includes('"') || attrName.includes("'") ? attrName.search(RE_ATTR_NAME_QUOTE) : -1;
1144
+ if (nameQuote !== -1) {
1145
+ reportParseError(attrPos + text.indexOf(attrName) + nameQuote, `Kept \`${attrName[nameQuote]}\` in attribute name`);
1146
+ }
1026
1147
  for (let i = 0; i <= surroundCount; i++) {
1027
1148
  // Unquoted values sit at index 5 of each surround block, and 4 behind the plain name
1028
1149
  const value = i < surroundCount ? attr[1 + i * NCP + 5] : attr[1 + surroundCount * NCP + 4];
@@ -1042,6 +1163,7 @@ export class HTMLParser {
1042
1163
  const lowerTagName = match.lowerTagName;
1043
1164
  let unarySlash = match.unarySlash;
1044
1165
 
1166
+ docStartsWithMarkup ||= docModeSettable;
1045
1167
  docModeSettable = false;
1046
1168
  if (handler.parseError) reportUnquotedValueErrors(match);
1047
1169
  // A plain loop, so a tag costs no closure allocation per attribute
@@ -1111,12 +1233,19 @@ export class HTMLParser {
1111
1233
  // A row or cell start tag closes the row or cell still open in the same table,
1112
1234
  // which may sit deeper down the stack than the element that was opened last
1113
1235
  if (lowerTagName === 'tr') {
1114
- closeIfFoundInCurrentTable('tr');
1236
+ if (!closeIfFoundInCurrentTable('tr')) {
1237
+ closeCellInCurrentTable();
1238
+ }
1115
1239
  } else if (lowerTagName === 'td' || lowerTagName === 'th') {
1116
- if (!closeIfFoundInCurrentTable('td')) {
1117
- closeIfFoundInCurrentTable('th');
1240
+ closeCellInCurrentTable();
1241
+ } else if (lowerTagName === 'tbody' || lowerTagName === 'tfoot' || lowerTagName === 'thead') {
1242
+ if (!closeIfFoundInCurrentTable('tr')) {
1243
+ closeCellInCurrentTable();
1118
1244
  }
1119
1245
  }
1246
+ if (tablePartElements.has(lowerTagName)) {
1247
+ noteImpliedTableParts(lowerTagName);
1248
+ }
1120
1249
  if (lowerTagName === 'tbody') {
1121
1250
  if (!closeIfFoundInCurrentTable('tfoot')) {
1122
1251
  closeIfFoundInCurrentTable('thead');
@@ -1171,7 +1300,9 @@ export class HTMLParser {
1171
1300
 
1172
1301
  // The name is returned so the parse loop can skip lowercasing it again
1173
1302
  const started = handler.start?.(tagName, attrs, unary, unarySlash);
1174
- return isThenable(started) ? started.then(() => lowerTagName) : lowerTagName;
1303
+ // What a slash closes reads as ended to the whitespace logic
1304
+ const token = selfClosed ? '/' + lowerTagName : lowerTagName;
1305
+ return isThenable(started) ? started.then(() => token) : token;
1175
1306
  }
1176
1307
 
1177
1308
  // Pushes an open element, noting the `p` in scope from within it: its own index
@@ -1179,7 +1310,8 @@ export class HTMLParser {
1179
1310
  // holds text with scripting), and otherwise its parent’s; returns the namespace it sits in
1180
1311
  function pushOpenElement(/** @type {string} */ tag, /** @type {string} */ lowerTag, /** @type {HTMLAttribute[]} */ attrs) {
1181
1312
  const parent = stack.length ? stack[stack.length - 1] : undefined;
1182
- const namespace = namespaceInside(parent);
1313
+ // An `svg` start tag leads out of MathML from `annotation-xml`, as it does from HTML
1314
+ const namespace = lowerTag === 'svg' && parent?.lowerTag === 'annotation-xml' ? '' : namespaceInside(parent);
1183
1315
  let pScope = -1;
1184
1316
  if (!namespace) {
1185
1317
  if (lowerTag === 'p') {
@@ -1218,8 +1350,141 @@ export class HTMLParser {
1218
1350
  parseEndTagAt(stackIndex, false);
1219
1351
  }
1220
1352
 
1353
+ // What a formatting element’s end tag meets among the elements open inside it
1354
+ // @@ Reopen formatting on whitespace (`<p><a><div></div> <div>` should stay as is)
1355
+ function readFormattingScope(/** @type {number} */ stackIndex) {
1356
+ let hasSpecial = false;
1357
+ for (let i = stack.length - 1; i > stackIndex; i--) {
1358
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
1359
+ if (entry.namespace) {
1360
+ return 'foreign';
1361
+ }
1362
+ if (formattingScopeBoundaries.has(entry.lowerTag)) {
1363
+ return 'boundary';
1364
+ }
1365
+ hasSpecial ||= specialElements.has(entry.lowerTag);
1366
+ }
1367
+ return hasSpecial ? 'special' : '';
1368
+ }
1369
+
1370
+ // A formatting element’s end tag leaves open the first special element in it, and is ignored
1371
+ // from beyond a scope boundary; returns whether either applied, or `false` to close as usual
1372
+ // https://html.spec.whatwg.org/multipage/parsing.html#adoption-agency-algorithm
1373
+ function closeFormattingElement(/** @type {number} */ stackIndex, /** @type {string} */ rest) {
1374
+ const scope = readFormattingScope(stackIndex);
1375
+ if (scope === 'boundary') {
1376
+ endTagDropped = true;
1377
+ handler.strayEnd?.();
1378
+ return true;
1379
+ }
1380
+ if (scope !== 'special') {
1381
+ return false;
1382
+ }
1383
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[stackIndex]);
1384
+ handler.end?.(entry.tag, entry.attrs, false, rest);
1385
+ stack.splice(stackIndex, 1);
1386
+ for (let i = stackIndex; i < stack.length; i++) {
1387
+ const above = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
1388
+ if (above.pScope > stackIndex) {
1389
+ above.pScope--;
1390
+ }
1391
+ }
1392
+ stackVersion++;
1393
+ return true;
1394
+ }
1395
+
1396
+ // Browsers ignore the end tag of an ordinary element with a special one open inside it
1397
+ function isBehindSpecialElement(/** @type {number} */ stackIndex) {
1398
+ const target = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[stackIndex]);
1399
+ // `dialog` isn’t special, but its end tag closes what is open in it nevertheless
1400
+ if (target.namespace || isSpecialOrBoundary(target.lowerTag) || formattingElements.has(target.lowerTag) || tablePartElements.has(target.lowerTag) || ['dialog', 'math', 'svg'].includes(target.lowerTag)) {
1401
+ return false;
1402
+ }
1403
+ for (let i = stack.length - 1; i > stackIndex; i--) {
1404
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
1405
+ if (entry.namespace) {
1406
+ return false;
1407
+ }
1408
+ if (isSpecialOrBoundary(entry.lowerTag)) {
1409
+ return true;
1410
+ }
1411
+ }
1412
+ return false;
1413
+ }
1414
+
1415
+ // An end tag for a row or section whose start tag is implied by the cell or row open in it;
1416
+ // closes what is open within it (returning whether there is such a part)
1417
+ function closeImpliedTablePart(/** @type {string} */ lowerTagName) {
1418
+ if (!isImpliedTablePart(lowerTagName)) {
1419
+ return false;
1420
+ }
1421
+ const table = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[findTagInCurrentTable('table')]);
1422
+ if (lowerTagName === 'tr') {
1423
+ closeCellInCurrentTable();
1424
+ } else {
1425
+ if (!closeIfFoundInCurrentTable('tr')) {
1426
+ closeCellInCurrentTable();
1427
+ }
1428
+ table.impliedSection = false;
1429
+ }
1430
+ table.impliedRow = false;
1431
+ return true;
1432
+ }
1433
+
1434
+ function isImpliedTablePart(/** @type {string} */ lowerTagName) {
1435
+ const table = stack[findTagInCurrentTable('table')];
1436
+ return lowerTagName === 'tr' ? Boolean(table?.impliedRow) : tableSectionElements.has(lowerTagName) && Boolean(table?.impliedSection);
1437
+ }
1438
+
1439
+ // Whether `parseEndTag` would drop this end tag, for the look-ahead
1440
+ function isDroppedEndTag(/** @type {string} */ lowerTagName) {
1441
+ const stackIndex = findEndTagTarget(lowerTagName);
1442
+ if (stackIndex >= 0) {
1443
+ if (stackIndex === stack.length - 1) {
1444
+ return false;
1445
+ }
1446
+ if (formattingElements.has(lowerTagName) && !stack[stackIndex]?.namespace && readFormattingScope(stackIndex) === 'boundary') {
1447
+ return true;
1448
+ }
1449
+ return isBehindSpecialElement(stackIndex);
1450
+ }
1451
+ return !handler.partialMarkup && !formattingElements.has(lowerTagName) && lowerTagName !== 'br' && lowerTagName !== 'p' && !isImpliedTablePart(lowerTagName);
1452
+ }
1453
+
1454
+ // Table parts end within their own table only
1455
+ function findEndTagTarget(/** @type {string} */ lowerTagName) {
1456
+ const stackIndex = stack.length - 1;
1457
+ if (stack[stackIndex]?.lowerTag === lowerTagName) {
1458
+ return stackIndex;
1459
+ }
1460
+ if (tablePartElements.has(lowerTagName) || lowerTagName === 'caption') {
1461
+ return findTagInCurrentTable(lowerTagName);
1462
+ }
1463
+ const target = findTag(lowerTagName);
1464
+ return target >= 0 && isOutOfScope(lowerTagName, target) ? -1 : target;
1465
+ }
1466
+
1467
+ // @@ Close only `form` on `</form>` (`<form><div></form>x</div>y` should stay as is)
1468
+ function isOutOfScope(/** @type {string} */ lowerTagName, /** @type {number} */ stackIndex) {
1469
+ if (!scopedEndTagElements.has(lowerTagName) || stack[stackIndex]?.namespace) {
1470
+ return false;
1471
+ }
1472
+ for (let i = stack.length - 1; i > stackIndex; i--) {
1473
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
1474
+ if (entry.namespace) {
1475
+ return false;
1476
+ }
1477
+ if (endTagScopeBoundaries.has(entry.lowerTag) || (lowerTagName === 'p' && entry.lowerTag === 'button') || (lowerTagName === 'li' && (entry.lowerTag === 'ol' || entry.lowerTag === 'ul'))) {
1478
+ return true;
1479
+ }
1480
+ }
1481
+ return false;
1482
+ }
1483
+
1221
1484
  function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
1485
+ docStartsWithMarkup ||= docModeSettable;
1222
1486
  docModeSettable = false;
1487
+ endTagDropped = false;
1223
1488
  let stackIndex;
1224
1489
  const lowerTagName = tagName ? tagName.toLowerCase() : '';
1225
1490
 
@@ -1230,7 +1495,7 @@ export class HTMLParser {
1230
1495
 
1231
1496
  // Find the closest opened tag of the same type
1232
1497
  if (tagName) {
1233
- stackIndex = findTag(lowerTagName);
1498
+ stackIndex = tag ? findEndTagTarget(lowerTagName) : findTag(lowerTagName);
1234
1499
  } else { // If no tag name is provided, clean shop
1235
1500
  stackIndex = 0;
1236
1501
  }
@@ -1238,6 +1503,16 @@ export class HTMLParser {
1238
1503
  // Whatever follows the name, which browsers drop but a custom fragment may stand in for
1239
1504
  const rest = tag.length > tagName.length + 3 ? tag.slice(tagName.length + 2, -1) : '';
1240
1505
 
1506
+ const closesTop = stackIndex === stack.length - 1;
1507
+ if (tag && !closesTop && stackIndex >= 0 && formattingElements.has(lowerTagName) && !stack[stackIndex]?.namespace && closeFormattingElement(stackIndex, rest)) {
1508
+ return lowerTagName;
1509
+ }
1510
+ if (tag && !closesTop && stackIndex >= 0 && isBehindSpecialElement(stackIndex)) {
1511
+ endTagDropped = true;
1512
+ handler.strayEnd?.();
1513
+ return lowerTagName;
1514
+ }
1515
+
1241
1516
  if (stackIndex >= 0) {
1242
1517
  // Close all the open elements, up the stack
1243
1518
  for (let i = stack.length - 1; i >= stackIndex; i--) {
@@ -1256,6 +1531,10 @@ export class HTMLParser {
1256
1531
  if (handler.end) {
1257
1532
  handler.end(tagName, [], false, rest);
1258
1533
  }
1534
+ } else if (tag && closeImpliedTablePart(lowerTagName)) {
1535
+ if (handler.end) {
1536
+ handler.end(tagName, [], false, rest);
1537
+ }
1259
1538
  } else if (lowerTagName === 'br') {
1260
1539
  if (handler.start) {
1261
1540
  handler.start(tagName, [], true, '');
@@ -1267,9 +1546,10 @@ export class HTMLParser {
1267
1546
  if (handler.end) {
1268
1547
  handler.end(tagName, []);
1269
1548
  }
1270
- } else if (handler.strayEnd) {
1549
+ } else {
1271
1550
  // An end tag with nothing to close is dropped
1272
- handler.strayEnd();
1551
+ endTagDropped = Boolean(tag);
1552
+ handler.strayEnd?.();
1273
1553
  }
1274
1554
  // Returned so the parse loop can skip lowercasing the name again
1275
1555
  return lowerTagName;