html-minifier 4.0.0 → 5.0.0

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.
Files changed (55) hide show
  1. package/LICENSE.txt +23 -0
  2. package/README.md +851 -110
  3. package/cli.js +991 -207
  4. package/dist/types/htmlminifier.d.ts +629 -0
  5. package/dist/types/htmlminifier.d.ts.map +1 -0
  6. package/dist/types/htmlparser.d.ts +28 -0
  7. package/dist/types/htmlparser.d.ts.map +1 -0
  8. package/dist/types/lib/attributes.d.ts +87 -0
  9. package/dist/types/lib/attributes.d.ts.map +1 -0
  10. package/dist/types/lib/constants.d.ts +421 -0
  11. package/dist/types/lib/constants.d.ts.map +1 -0
  12. package/dist/types/lib/content.d.ts +44 -0
  13. package/dist/types/lib/content.d.ts.map +1 -0
  14. package/dist/types/lib/elements.d.ts +48 -0
  15. package/dist/types/lib/elements.d.ts.map +1 -0
  16. package/dist/types/lib/fragments.d.ts +47 -0
  17. package/dist/types/lib/fragments.d.ts.map +1 -0
  18. package/dist/types/lib/option-definitions.d.ts +33 -0
  19. package/dist/types/lib/option-definitions.d.ts.map +1 -0
  20. package/dist/types/lib/options.d.ts +93 -0
  21. package/dist/types/lib/options.d.ts.map +1 -0
  22. package/dist/types/lib/unused-css.d.ts +76 -0
  23. package/dist/types/lib/unused-css.d.ts.map +1 -0
  24. package/dist/types/lib/urls.d.ts +15 -0
  25. package/dist/types/lib/urls.d.ts.map +1 -0
  26. package/dist/types/lib/utils.d.ts +132 -0
  27. package/dist/types/lib/utils.d.ts.map +1 -0
  28. package/dist/types/lib/whitespace.d.ts +97 -0
  29. package/dist/types/lib/whitespace.d.ts.map +1 -0
  30. package/dist/types/presets.d.ts +50 -0
  31. package/dist/types/presets.d.ts.map +1 -0
  32. package/dist/types/tokenchain.d.ts +36 -0
  33. package/dist/types/tokenchain.d.ts.map +1 -0
  34. package/html-minifier.schema.json +322 -0
  35. package/package.json +70 -58
  36. package/src/htmlminifier.js +2845 -1107
  37. package/src/htmlparser.js +1163 -452
  38. package/src/lib/attributes.js +1196 -0
  39. package/src/lib/constants.js +521 -0
  40. package/src/lib/content.js +237 -0
  41. package/src/lib/elements.js +268 -0
  42. package/src/lib/file-pool.js +193 -0
  43. package/src/lib/file-write.js +87 -0
  44. package/src/lib/fragments.js +340 -0
  45. package/src/lib/option-definitions.js +246 -0
  46. package/src/lib/options.js +841 -0
  47. package/src/lib/unused-css.js +499 -0
  48. package/src/lib/urls.js +146 -0
  49. package/src/lib/utils.js +1026 -0
  50. package/src/lib/whitespace.js +431 -0
  51. package/src/presets.js +58 -0
  52. package/src/tokenchain.js +142 -56
  53. package/LICENSE +0 -22
  54. package/sample-cli-config-file.conf +0 -40
  55. package/src/utils.js +0 -18
package/src/htmlparser.js CHANGED
@@ -1,106 +1,227 @@
1
- /*!
1
+ /*
2
2
  * HTML Parser By John Resig (ejohn.org)
3
- * Modified by Juriy "kangax" Zaytsev
3
+ * Modified by Juriy “kangax” Zaytsev and Jens Oliver Meiert
4
4
  * Original code by Erik Arvidsson, Mozilla Public License
5
5
  * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
6
6
  */
7
7
 
8
+ import { isThenable, isQuirksDoctype, embedSource, findTagEnd } from './lib/utils.js';
9
+ import { endlessRawTextElements, escapableRawTextElements, formattingElements, genericRawTextElements, processingInstructionTag, RE_HTML_ENCODING, RE_WS_ONLY } from './lib/constants.js';
10
+
11
+ /** @import { HTMLAttribute } from './lib/attributes.js' */
12
+
13
+ /**
14
+ * `start`, `chars`, and `comment` return `undefined` when their work needs no `await`
15
+ * and a Promise when it does; the parse loop awaits only what it gets back. `end`,
16
+ * `processingInstruction`, `doctype`, and `strayEnd` never suspend. `start` is
17
+ * synchronous wherever it is passed no attributes, which is what keeps the calls
18
+ * inside `parseEndTag` synchronous. `chars` is told by its sixth argument when
19
+ * the text is all an SVG `script` or `style` element holds, and so its source.
20
+ * @typedef {{
21
+ * start?: Function,
22
+ * end?: Function,
23
+ * chars?: Function,
24
+ * comment?: Function,
25
+ * processingInstruction?: Function,
26
+ * doctype?: Function,
27
+ * strayEnd?: Function,
28
+ * parseError?: Function | undefined,
29
+ * continueOnParseError?: boolean | undefined,
30
+ * partialMarkup?: boolean | undefined,
31
+ * noQuirksMode?: boolean | undefined,
32
+ * wantsNextTag?: boolean | undefined,
33
+ * selfClosingSlash?: boolean | undefined,
34
+ * customAttrSurround?: RegExp[][] | undefined,
35
+ * customAttrAssign?: RegExp[] | undefined
36
+ * }} HTMLParserHandler
37
+ */
38
+
8
39
  /*
9
- * // Use like so:
40
+ * Use like so:
41
+ *
10
42
  * HTMLParser(htmlString, {
11
- * start: function(tag, attrs, unary) {},
12
- * end: function(tag) {},
13
- * chars: function(text) {},
14
- * comment: function(text) {}
43
+ * start: function(tag, attrs, unary) {},
44
+ * end: function(tag) {},
45
+ * chars: function(text) {},
46
+ * comment: function(text) {}
15
47
  * });
16
- *
17
- * // or to get an XML string:
18
- * HTMLtoXML(htmlString);
19
- *
20
- * // or to get an XML DOM Document
21
- * HTMLtoDOM(htmlString);
22
- *
23
- * // or to inject into an existing document/DOM node
24
- * HTMLtoDOM(htmlString, document);
25
- * HTMLtoDOM(htmlString, document.body);
26
- *
27
48
  */
28
49
 
29
- /* global ActiveXObject, DOMDocument */
30
-
31
- 'use strict';
50
+ // Regular expressions for parsing tags and attributes
51
+ const singleAttrIdentifier = /([^\s"'<>/=]+)/;
52
+ const singleAttrAssigns = [/=/];
53
+ const singleAttrValues = [
54
+ /"([^"]*)"+/.source,
55
+ /'([^']*)'+/.source,
56
+ // Attr value, no quotes (strict: excludes `=` per HTML spec)
57
+ /([^ \t\n\f\r"'`=<>]+)/.source
58
+ ];
59
+ // Lenient unquoted value pattern for `continueOnParseError`: Allows `<`, `=`, and backticks per
60
+ // HTML error-recovery rules (all are parse errors in unquoted-attribute-value state but appended to
61
+ // the value); `"` and `'` remain excluded—permitting them requires broader test coverage
62
+ const singleAttrValueLenientUnquoted = /([^ \t\n\f\r"'>]+)/.source;
63
+ // The characters the lenient pattern takes in beyond the strict one, each a parse error
64
+ const RE_UNQUOTED_VALUE_ERROR = /[<=`]/;
65
+ // An attribute name and `=`, for reading a quoted value too long for the attribute pattern
66
+ const RE_ATTR_NAME_ASSIGN = /^\s*([^\s"'<>/=]+)\s*=\s*/;
67
+ // The rest of an unquoted value, strict and lenient, as one character class read in a single
68
+ // pass from where the attribute window cut the value short
69
+ const RE_UNQUOTED_VALUE_REST = new RegExp(/** @type {string} */ (singleAttrValues[2]).slice(1, -2) + '*', 'y');
70
+ const RE_UNQUOTED_VALUE_REST_LENIENT = new RegExp(singleAttrValueLenientUnquoted.slice(1, -2) + '*', 'y');
71
+ // https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
72
+ const qnameCapture = (function () {
73
+ // https://www.npmjs.com/package/ncname
74
+ const combiningChar = '\u0300-\u0345\u0360\u0361\u0483-\u0486\u0591-\u05A1\u05A3-\u05B9\u05BB-\u05BD\u05BF\u05C1\u05C2\u05C4\u064B-\u0652\u0670\u06D6-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0901-\u0903\u093C\u093E-\u094D\u0951-\u0954\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A02\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A70\u0A71\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0B01-\u0B03\u0B3C\u0B3E-\u0B43\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B82\u0B83\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C01-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C82\u0C83\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0D02\u0D03\u0D3E-\u0D43\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86-\u0F8B\u0F90-\u0F95\u0F97\u0F99-\u0FAD\u0FB1-\u0FB7\u0FB9\u20D0-\u20DC\u20E1\u302A-\u302F\u3099\u309A';
75
+ const digit = '0-9\u0660-\u0669\u06F0-\u06F9\u0966-\u096F\u09E6-\u09EF\u0A66-\u0A6F\u0AE6-\u0AEF\u0B66-\u0B6F\u0BE7-\u0BEF\u0C66-\u0C6F\u0CE6-\u0CEF\u0D66-\u0D6F\u0E50-\u0E59\u0ED0-\u0ED9\u0F20-\u0F29';
76
+ const extender = '\xB7\u02D0\u02D1\u0387\u0640\u0E46\u0EC6\u3005\u3031-\u3035\u309D\u309E\u30FC-\u30FE';
77
+ const letter = 'A-Za-z\xC0-\xD6\xD8-\xF6\xF8-\u0131\u0134-\u013E\u0141-\u0148\u014A-\u017E\u0180-\u01C3\u01CD-\u01F0\u01F4\u01F5\u01FA-\u0217\u0250-\u02A8\u02BB-\u02C1\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03CE\u03D0-\u03D6\u03DA\u03DC\u03DE\u03E0\u03E2-\u03F3\u0401-\u040C\u040E-\u044F\u0451-\u045C\u045E-\u0481\u0490-\u04C4\u04C7\u04C8\u04CB\u04CC\u04D0-\u04EB\u04EE-\u04F5\u04F8\u04F9\u0531-\u0556\u0559\u0561-\u0586\u05D0-\u05EA\u05F0-\u05F2\u0621-\u063A\u0641-\u064A\u0671-\u06B7\u06BA-\u06BE\u06C0-\u06CE\u06D0-\u06D3\u06D5\u06E5\u06E6\u0905-\u0939\u093D\u0958-\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8B\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B36-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD\u0EAE\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0F40-\u0F47\u0F49-\u0F69\u10A0-\u10C5\u10D0-\u10F6\u1100\u1102\u1103\u1105-\u1107\u1109\u110B\u110C\u110E-\u1112\u113C\u113E\u1140\u114C\u114E\u1150\u1154\u1155\u1159\u115F-\u1161\u1163\u1165\u1167\u1169\u116D\u116E\u1172\u1173\u1175\u119E\u11A8\u11AB\u11AE\u11AF\u11B7\u11B8\u11BA\u11BC-\u11C2\u11EB\u11F0\u11F9\u1E00-\u1E9B\u1EA0-\u1EF9\u1F00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2126\u212A\u212B\u212E\u2180-\u2182\u3007\u3021-\u3029\u3041-\u3094\u30A1-\u30FA\u3105-\u312C\u4E00-\u9FA5\uAC00-\uD7A3';
78
+ const ncname = '[' + letter + '_][' + letter + digit + '\\.\\-_' + combiningChar + extender + ']*';
79
+ return '((?:' + ncname + '\\:)?' + ncname + ')';
80
+ })();
81
+ const startTagOpen = new RegExp('^<' + qnameCapture);
82
+ const endTagOpen = new RegExp('^</' + qnameCapture);
83
+
84
+ // 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']);
86
+
87
+ // Elements that you can, intentionally, leave open (and which close themselves)
88
+ const closeSelf = new Set(['colgroup', 'dd', 'dt', 'li', 'option', 'p', 'td', 'tfoot', 'th', 'thead', 'tr', 'source']);
89
+
90
+ // Special elements (can contain anything)
91
+ const special = new Set(['script', 'style']);
92
+
93
+ // Where a CDATA section starts and ends, which holds text in foreign content
94
+ const cdataOpen = '<![CDATA[';
95
+ const cdataClose = ']]>';
96
+
97
+ // Elements whose children are HTML rather than foreign content, each only in
98
+ // the namespace it belongs to (`annotation-xml` is one, too, but only for some
99
+ // `encoding` values, so it is checked separately)
100
+ // https://html.spec.whatwg.org/multipage/parsing.html#html-integration-point
101
+ const svgIntegrationPoints = new Set(['foreignobject', 'desc', 'title']);
102
+ const mathIntegrationPoints = new Set(['mi', 'mo', 'mn', 'ms', 'mtext']);
103
+
104
+ // Start tags that close foreign content and are read as HTML (`font` only with
105
+ // `color`, `face`, or `size`)
106
+ // https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inforeign
107
+ const breaksOutOfForeignContent = new Set(['b', 'big', 'blockquote', 'body', 'br', 'center', 'code', 'dd', 'div', 'dl', 'dt', 'em', 'embed', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'hr', 'i', 'img', 'li', 'listing', 'menu', 'meta', 'nobr', 'ol', 'p', 'pre', 'ruby', 's', 'small', 'span', 'strong', 'strike', 'sub', 'sup', 'table', 'tt', 'u', 'ul', 'var']);
108
+ const RE_FONT_BREAKOUT_ATTR = /^(?:color|face|size)$/i;
109
+
110
+ // Elements that end the scope an open `p` is looked for in
111
+ // https://html.spec.whatwg.org/multipage/parsing.html#has-an-element-in-button-scope
112
+ const buttonScopeBoundaries = new Set(['applet', 'button', 'caption', 'html', 'marquee', 'object', 'table', 'td', 'template', 'th']);
113
+ // Start tags that close an open `p` (other start tags, like `meta` or `style`, go in it, or are
114
+ // ignored, like `td` outside a table)
115
+ // https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody
116
+ 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
+
118
+ // A tag name ends at whitespace, a slash, or the closing bracket, so `</scriptx>` names a
119
+ // different element and does not end this one
120
+ const endsTagName = (/** @type {string} */ character) =>
121
+ character === '' || character === '/' || character === '>' || /\s/.test(character);
122
+
123
+ // Fast path for tag names
124
+ /**
125
+ * @param {string} html
126
+ * @param {number} start - Position of the first name character (after `<` or `</`)
127
+ * @returns {number} Name length from `start`, or -1 to fall back to the qname regex
128
+ */
129
+ function scanAsciiTagName(html, start) {
130
+ const first = html.charCodeAt(start);
131
+ // First char: Letter or `_` (as for ncname)
132
+ if (!((first >= 65 && first <= 90) || (first >= 97 && first <= 122) || first === 95)) {
133
+ return -1;
134
+ }
135
+ let i = start + 1;
136
+ for (;;) {
137
+ const code = html.charCodeAt(i);
138
+ if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || code === 95 || code === 45 || code === 46) {
139
+ i++;
140
+ } else if (code === 58 || code >= 128) { // `:` or non-ASCII
141
+ return -1;
142
+ } else {
143
+ return i - start;
144
+ }
145
+ }
146
+ }
32
147
 
33
- var createMapFromString = require('./utils').createMapFromString;
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
+ /**
156
+ * @param {string} text - The input the tag stands in
157
+ * @param {number} nameEnd - Position right after the tag name
158
+ * @returns {number} Position of the `>` that ends the tag, or -1 where none follows
159
+ */
160
+ function findEndTagClose(text, nameEnd) {
161
+ const close = text.indexOf('>', nameEnd);
162
+ if (close === -1) {
163
+ return -1;
164
+ }
165
+ // A quoted attribute value can hold a `>` that does not end the tag it stands in, so a
166
+ // quote ahead of that bracket calls for a scan that knows about quoting. It stops at the
167
+ // next `<`, which no tag reaches past: The scans of consecutive tags then never overlap,
168
+ // and an unquoted value that spans one leaves the first bracket as the end, as `indexOf` does.
169
+ for (let index = nameEnd; index < close; index++) {
170
+ const character = text[index];
171
+ if (character === '"' || character === "'") {
172
+ const nextOpen = text.indexOf('<', nameEnd);
173
+ const quoted = findTagEnd(text, nameEnd, nextOpen === -1 ? text.length : nextOpen);
174
+ return quoted === -1 ? close : quoted;
175
+ }
176
+ }
177
+ return close;
178
+ }
34
179
 
35
- function makeMap(values) {
36
- return createMapFromString(values, true);
180
+ /**
181
+ * @param {string} text - The input from the start of the content on
182
+ * @param {string} tag - Lowercase element name
183
+ * @returns {{content: string, length: number} | null} What the element holds and how far it
184
+ * reaches, or `null` where no end tag of its own follows
185
+ */
186
+ function findRawTextEnd(text, tag) {
187
+ const nameEnd = tag.length + 2;
188
+ for (let candidate = text.indexOf('</'); candidate !== -1; candidate = text.indexOf('</', candidate + 2)) {
189
+ if (text.slice(candidate + 2, candidate + nameEnd).toLowerCase() !== tag || !endsTagName(text.charAt(candidate + nameEnd))) {
190
+ continue;
191
+ }
192
+ // What follows the name is not part of it, and the tag ends at the next bracket outside
193
+ // its attribute values—where none follows, no later end tag can be complete, either
194
+ const end = findEndTagClose(text, candidate + nameEnd);
195
+ return end === -1 ? null : { content: text.slice(0, candidate), length: end + 1 };
196
+ }
197
+ return null;
37
198
  }
38
199
 
39
- // Regular Expressions for parsing tags and attributes
40
- var singleAttrIdentifier = /([^\s"'<>/=]+)/,
41
- singleAttrAssigns = [/=/],
42
- singleAttrValues = [
43
- // attr value double quotes
44
- /"([^"]*)"+/.source,
45
- // attr value, single quotes
46
- /'([^']*)'+/.source,
47
- // attr value, no quotes
48
- /([^ \t\n\f\r"'`=<>]+)/.source
49
- ],
50
- // https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
51
- qnameCapture = (function() {
52
- // based on https://www.npmjs.com/package/ncname
53
- var combiningChar = '\\u0300-\\u0345\\u0360\\u0361\\u0483-\\u0486\\u0591-\\u05A1\\u05A3-\\u05B9\\u05BB-\\u05BD\\u05BF\\u05C1\\u05C2\\u05C4\\u064B-\\u0652\\u0670\\u06D6-\\u06E4\\u06E7\\u06E8\\u06EA-\\u06ED\\u0901-\\u0903\\u093C\\u093E-\\u094D\\u0951-\\u0954\\u0962\\u0963\\u0981-\\u0983\\u09BC\\u09BE-\\u09C4\\u09C7\\u09C8\\u09CB-\\u09CD\\u09D7\\u09E2\\u09E3\\u0A02\\u0A3C\\u0A3E-\\u0A42\\u0A47\\u0A48\\u0A4B-\\u0A4D\\u0A70\\u0A71\\u0A81-\\u0A83\\u0ABC\\u0ABE-\\u0AC5\\u0AC7-\\u0AC9\\u0ACB-\\u0ACD\\u0B01-\\u0B03\\u0B3C\\u0B3E-\\u0B43\\u0B47\\u0B48\\u0B4B-\\u0B4D\\u0B56\\u0B57\\u0B82\\u0B83\\u0BBE-\\u0BC2\\u0BC6-\\u0BC8\\u0BCA-\\u0BCD\\u0BD7\\u0C01-\\u0C03\\u0C3E-\\u0C44\\u0C46-\\u0C48\\u0C4A-\\u0C4D\\u0C55\\u0C56\\u0C82\\u0C83\\u0CBE-\\u0CC4\\u0CC6-\\u0CC8\\u0CCA-\\u0CCD\\u0CD5\\u0CD6\\u0D02\\u0D03\\u0D3E-\\u0D43\\u0D46-\\u0D48\\u0D4A-\\u0D4D\\u0D57\\u0E31\\u0E34-\\u0E3A\\u0E47-\\u0E4E\\u0EB1\\u0EB4-\\u0EB9\\u0EBB\\u0EBC\\u0EC8-\\u0ECD\\u0F18\\u0F19\\u0F35\\u0F37\\u0F39\\u0F3E\\u0F3F\\u0F71-\\u0F84\\u0F86-\\u0F8B\\u0F90-\\u0F95\\u0F97\\u0F99-\\u0FAD\\u0FB1-\\u0FB7\\u0FB9\\u20D0-\\u20DC\\u20E1\\u302A-\\u302F\\u3099\\u309A';
54
- var digit = '0-9\\u0660-\\u0669\\u06F0-\\u06F9\\u0966-\\u096F\\u09E6-\\u09EF\\u0A66-\\u0A6F\\u0AE6-\\u0AEF\\u0B66-\\u0B6F\\u0BE7-\\u0BEF\\u0C66-\\u0C6F\\u0CE6-\\u0CEF\\u0D66-\\u0D6F\\u0E50-\\u0E59\\u0ED0-\\u0ED9\\u0F20-\\u0F29';
55
- var extender = '\\xB7\\u02D0\\u02D1\\u0387\\u0640\\u0E46\\u0EC6\\u3005\\u3031-\\u3035\\u309D\\u309E\\u30FC-\\u30FE';
56
- var letter = 'A-Za-z\\xC0-\\xD6\\xD8-\\xF6\\xF8-\\u0131\\u0134-\\u013E\\u0141-\\u0148\\u014A-\\u017E\\u0180-\\u01C3\\u01CD-\\u01F0\\u01F4\\u01F5\\u01FA-\\u0217\\u0250-\\u02A8\\u02BB-\\u02C1\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03CE\\u03D0-\\u03D6\\u03DA\\u03DC\\u03DE\\u03E0\\u03E2-\\u03F3\\u0401-\\u040C\\u040E-\\u044F\\u0451-\\u045C\\u045E-\\u0481\\u0490-\\u04C4\\u04C7\\u04C8\\u04CB\\u04CC\\u04D0-\\u04EB\\u04EE-\\u04F5\\u04F8\\u04F9\\u0531-\\u0556\\u0559\\u0561-\\u0586\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0621-\\u063A\\u0641-\\u064A\\u0671-\\u06B7\\u06BA-\\u06BE\\u06C0-\\u06CE\\u06D0-\\u06D3\\u06D5\\u06E5\\u06E6\\u0905-\\u0939\\u093D\\u0958-\\u0961\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8B\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AE0\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B36-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB5\\u0BB7-\\u0BB9\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CDE\\u0CE0\\u0CE1\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D28\\u0D2A-\\u0D39\\u0D60\\u0D61\\u0E01-\\u0E2E\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E45\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD\\u0EAE\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0F40-\\u0F47\\u0F49-\\u0F69\\u10A0-\\u10C5\\u10D0-\\u10F6\\u1100\\u1102\\u1103\\u1105-\\u1107\\u1109\\u110B\\u110C\\u110E-\\u1112\\u113C\\u113E\\u1140\\u114C\\u114E\\u1150\\u1154\\u1155\\u1159\\u115F-\\u1161\\u1163\\u1165\\u1167\\u1169\\u116D\\u116E\\u1172\\u1173\\u1175\\u119E\\u11A8\\u11AB\\u11AE\\u11AF\\u11B7\\u11B8\\u11BA\\u11BC-\\u11C2\\u11EB\\u11F0\\u11F9\\u1E00-\\u1E9B\\u1EA0-\\u1EF9\\u1F00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2126\\u212A\\u212B\\u212E\\u2180-\\u2182\\u3007\\u3021-\\u3029\\u3041-\\u3094\\u30A1-\\u30FA\\u3105-\\u312C\\u4E00-\\u9FA5\\uAC00-\\uD7A3';
57
- var ncname = '[' + letter + '_][' + letter + digit + '\\.\\-_' + combiningChar + extender + ']*';
58
- return '((?:' + ncname + '\\:)?' + ncname + ')';
59
- })(),
60
- startTagOpen = new RegExp('^<' + qnameCapture),
61
- startTagClose = /^\s*(\/?)>/,
62
- endTag = new RegExp('^<\\/' + qnameCapture + '[^>]*>'),
63
- doctype = /^<!DOCTYPE\s?[^>]+>/i;
64
-
65
- var IS_REGEX_CAPTURING_BROKEN = false;
66
- 'x'.replace(/x(.)?/g, function(m, g) {
67
- IS_REGEX_CAPTURING_BROKEN = g === '';
68
- });
69
-
70
- // Empty Elements
71
- var empty = makeMap('area,base,basefont,br,col,embed,frame,hr,img,input,isindex,keygen,link,meta,param,source,track,wbr');
72
-
73
- // Inline Elements
74
- var inline = makeMap('a,abbr,acronym,applet,b,basefont,bdo,big,br,button,cite,code,del,dfn,em,font,i,iframe,img,input,ins,kbd,label,map,noscript,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,svg,textarea,tt,u,var');
75
-
76
- // Elements that you can, intentionally, leave open
77
- // (and which close themselves)
78
- var closeSelf = makeMap('colgroup,dd,dt,li,option,p,td,tfoot,th,thead,tr,source');
79
-
80
- // Attributes that have their values filled in disabled='disabled'
81
- var fillAttrs = makeMap('checked,compact,declare,defer,disabled,ismap,multiple,nohref,noresize,noshade,nowrap,readonly,selected');
82
-
83
- // Special Elements (can contain anything)
84
- var special = makeMap('script,style');
85
-
86
- // HTML5 tags https://html.spec.whatwg.org/multipage/indices.html#elements-3
87
- // Phrasing Content https://html.spec.whatwg.org/multipage/dom.html#phrasing-content
88
- var nonPhrasing = makeMap('address,article,aside,base,blockquote,body,caption,col,colgroup,dd,details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta,ol,optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead,title,tr,track,ul');
89
-
90
- var reCache = {};
91
-
92
- function attrForHandler(handler) {
93
- var pattern = singleAttrIdentifier.source +
94
- '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
95
- '[ \\t\\n\\f\\r]*(?:' + singleAttrValues.join('|') + '))?';
200
+ // Cache for compiled attribute regexes per handler configuration
201
+ const attrRegexCache = new WeakMap();
202
+
203
+ /** @param {HTMLParserHandler} handler */
204
+ function buildAttrRegex(handler) {
205
+ const unquotedValue = handler.continueOnParseError ? singleAttrValueLenientUnquoted : singleAttrValues[2];
206
+ const attrValues = [singleAttrValues[0], singleAttrValues[1], unquotedValue];
207
+ let pattern = singleAttrIdentifier.source +
208
+ '(?:\\s*(' + joinSingleAttrAssigns(handler) + ')' +
209
+ '[ \\t\\n\\f\\r]*(?:' + attrValues.join('|') + '))?';
96
210
  if (handler.customAttrSurround) {
97
- var attrClauses = [];
98
- for (var i = handler.customAttrSurround.length - 1; i >= 0; i--) {
211
+ if (!Array.isArray(handler.customAttrSurround)) {
212
+ throw new Error('`customAttrSurround` must be an array of `[RegExp, RegExp]` pairs');
213
+ }
214
+ const attrClauses = [];
215
+ for (let i = handler.customAttrSurround.length - 1; i >= 0; i--) {
216
+ const pair = handler.customAttrSurround[i];
217
+ if (!Array.isArray(pair) || !(pair[0] instanceof RegExp) || !(pair[1] instanceof RegExp)) {
218
+ throw new Error('`customAttrSurround` entries must be `[RegExp, RegExp]` pairs');
219
+ }
99
220
  attrClauses[i] = '(?:' +
100
- '(' + handler.customAttrSurround[i][0].source + ')\\s*' +
101
- pattern +
102
- '\\s*(' + handler.customAttrSurround[i][1].source + ')' +
103
- ')';
221
+ '(' + embedSource(pair[0]) + ')\\s*' +
222
+ pattern +
223
+ '\\s*(' + embedSource(pair[1]) + ')' +
224
+ ')';
104
225
  }
105
226
  attrClauses.push('(?:' + pattern + ')');
106
227
  pattern = '(?:' + attrClauses.join('|') + ')';
@@ -108,460 +229,1050 @@ function attrForHandler(handler) {
108
229
  return new RegExp('^\\s*' + pattern);
109
230
  }
110
231
 
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
+ }
240
+
241
+ // Cache for sticky attribute regexes (`y` flag for position-based matching on full string)
242
+ const attrRegexStickyCache = new WeakMap();
243
+
244
+ /** @param {HTMLParserHandler} handler */
245
+ function getAttrRegexStickyForHandler(handler) {
246
+ const cached = attrRegexStickyCache.get(handler);
247
+ 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);
252
+ return compiled;
253
+ }
254
+
255
+ /** @param {HTMLParserHandler} handler */
111
256
  function joinSingleAttrAssigns(handler) {
112
257
  return singleAttrAssigns.concat(
113
258
  handler.customAttrAssign || []
114
- ).map(function(assign) {
115
- return '(?:' + assign.source + ')';
259
+ ).map(function (assign) {
260
+ return '(?:' + embedSource(assign) + ')';
116
261
  }).join('|');
117
262
  }
118
263
 
119
- function HTMLParser(html, handler) {
120
- var stack = [], lastTag;
121
- var attribute = attrForHandler(handler);
122
- var last, prevTag, nextTag;
123
- while (html) {
124
- last = html;
125
- // Make sure we're not in a script or style element
126
- if (!lastTag || !special(lastTag)) {
127
- var textEnd = html.indexOf('<');
128
- if (textEnd === 0) {
129
- // Comment:
130
- if (/^<!--/.test(html)) {
131
- var commentEnd = html.indexOf('-->');
132
-
133
- if (commentEnd >= 0) {
134
- if (handler.comment) {
135
- handler.comment(html.substring(4, commentEnd));
136
- }
137
- html = html.substring(commentEnd + 3);
138
- prevTag = '';
264
+ // Number of captured parts per `customAttrSurround` pattern
265
+ const NCP = 7;
266
+
267
+ export class HTMLParser {
268
+ /**
269
+ * @param {string} html
270
+ * @param {HTMLParserHandler} handler
271
+ */
272
+ constructor(html, handler) {
273
+ this.html = html;
274
+ this.handler = handler;
275
+ }
276
+
277
+ async parse() {
278
+ const handler = this.handler;
279
+ const fullHtml = this.html;
280
+ const fullLength = fullHtml.length;
281
+
282
+ // `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}>} */
284
+ const stack = [];
285
+ /** @type {string} */
286
+ let lastTag = '';
287
+ // The document mode, which only the first doctype sets, and only after
288
+ // nothing but comments and whitespace (with `noQuirksMode`, to no-quirks,
289
+ // as the doctype gets replaced by the HTML one): `table` closes an open `p`
290
+ // in no-quirks mode but not in quirks mode; without such a doctype (as in a
291
+ // fragment) the mode is unknown, so the `p` stays open as in quirks mode,
292
+ // and its end tag, which no-quirks mode reads as stray, has to stay, too
293
+ /** @type {'' | 'quirks' | 'no-quirks'} */
294
+ let docMode = '';
295
+ let docModeSettable = true;
296
+ // Open `p` elements a `table` started in while the mode was unknown
297
+ /** @type {WeakSet<object>} */
298
+ const pHoldingTable = new WeakSet();
299
+ // Lowercase counterpart of `lastTag`, kept in sync to avoid per-iteration lowercasing
300
+ /** @type {string} */
301
+ let lastTagLower = '';
302
+ const attribute = getAttrRegexForHandler(handler);
303
+ const attributeY = getAttrRegexStickyForHandler(handler);
304
+ // Index of the attribute name in a match, after each `customAttrSurround` pattern’s captures
305
+ const baseIndex = 1 + (handler.customAttrSurround ? handler.customAttrSurround.length * NCP : 0);
306
+ /** @type {string | undefined} */
307
+ let prevTag;
308
+ /** @type {string | undefined} */
309
+ let nextTag;
310
+ // One shared empty array for the resets—nothing ever writes to `prevAttrs`/`nextAttrs`
311
+ /** @type {HTMLAttribute[]} */
312
+ const emptyAttrs = [];
313
+ /** @type {HTMLAttribute[]} */
314
+ let prevAttrs = emptyAttrs;
315
+ /** @type {HTMLAttribute[]} */
316
+ let nextAttrs = emptyAttrs;
317
+
318
+ // Sticky regex versions for position-based matching (avoids string slicing)
319
+ const startTagOpenY = new RegExp(startTagOpen.source.slice(1), 'y');
320
+ const endTagOpenY = new RegExp(endTagOpen.source.slice(1), 'y');
321
+ const doctypeY = /<!DOCTYPE[^<>]*>/iy;
322
+ const commentTestY = /<!--/y;
323
+ const conditionalTestY = /<!\[/y;
324
+ const processingInstructionY = /<\?([A-Za-z_][\w-]*)[\t\n\f\r ?>]/y;
325
+
326
+ // Cached next-tag from lookahead (avoids re-parsing the same tag)
327
+ let cachedNextStartTag = null;
328
+ let cachedNextEndTag = null;
329
+
330
+ // Index-based parsing
331
+ let pos = 0;
332
+ let lastPos;
333
+
334
+ // An end tag always ends at a `>`; without one ahead, matching the name would rescan the name
335
+ // run at every position, O(n²) on a long unclosed name (`</aaaa…`). Tracking the next `>`
336
+ // (`pos` only advances, so this stays O(n)) skips the match where the tag cannot be complete.
337
+ let nextGtPos = fullHtml.indexOf('>');
338
+ const hasCloseAtOrAfter = (/** @type {number} */ p) => {
339
+ if (nextGtPos !== -1 && nextGtPos < p) {
340
+ nextGtPos = fullHtml.indexOf('>', p);
341
+ }
342
+ return nextGtPos !== -1;
343
+ };
344
+
345
+ // A `-->` that the opener at `pos` does not find is not there for any later one, either,
346
+ // and the same holds for the `]>` of a downlevel-revealed conditional comment. `pos`
347
+ // only advances, so remembering the search that came up empty keeps the aggregate work
348
+ // O(n), where searching again at every opener rescans the rest of the input each time.
349
+ let commentEndAhead = true;
350
+ let conditionalEndAhead = true;
351
+
352
+ // The end of the processing instruction at `p`, or -1 where there is none: `xml` and
353
+ // `xml-stylesheet` open a bogus comment instead, and one without a `>` is dropped
354
+ // https://html.spec.whatwg.org/multipage/parsing.html#processing-instruction-open-state
355
+ const matchProcessingInstruction = (/** @type {number} */ p) => {
356
+ if (!hasCloseAtOrAfter(p)) return -1;
357
+ processingInstructionY.lastIndex = p;
358
+ const match = processingInstructionY.exec(fullHtml);
359
+ if (!match) return -1;
360
+ const target = (match[1] ?? '').toLowerCase();
361
+ return target === 'xml' || target === 'xml-stylesheet' ? -1 : nextGtPos + 1;
362
+ };
363
+
364
+ // Where what `<?` at `p` opens ends, when it is not a processing instruction: A valid target
365
+ // without a `>` is dropped (`dropped`), anything else is a bogus comment up to the next `>`
366
+ const matchBogusProcessingInstruction = (/** @type {number} */ p) => {
367
+ let i = p + 2;
368
+ const nameCode = fullHtml.charCodeAt(i) | 32;
369
+ let dropped = i >= fullLength;
370
+ if (nameCode >= 97 && nameCode <= 122 || fullHtml[i] === '_') {
371
+ while (i < fullLength && /[\w-]/.test(fullHtml[i] ?? '')) i++;
372
+ const target = fullHtml.substring(p + 2, i).toLowerCase();
373
+ dropped = i >= fullLength || (/[\t\n\f\r ?>]/.test(fullHtml[i] ?? '') && target !== 'xml' && target !== 'xml-stylesheet');
374
+ }
375
+ if (dropped) return { end: fullLength, dropped };
376
+ return { end: hasCloseAtOrAfter(p + 2) ? nextGtPos + 1 : fullLength, dropped };
377
+ };
378
+
379
+ const advance = (/** @type {number} */ n) => { pos += n; };
380
+
381
+ // Lazy line/column calculation—only compute on actual errors
382
+ const getLineColumn = (/** @type {number} */ position) => {
383
+ let line = 1;
384
+ let column = 1;
385
+ for (let i = 0; i < position; i++) {
386
+ if (fullHtml[i] === '\n') {
387
+ line++;
388
+ column = 1;
389
+ } else {
390
+ column++;
391
+ }
392
+ }
393
+ return { line, column };
394
+ };
395
+
396
+ // Recoveries are reported in document order, so each picks up where the last left off
397
+ let reportedPos = 0;
398
+ let reportedLoc = { line: 1, column: 1 };
399
+ const reportParseError = (/** @type {number} */ position, /** @type {string} */ recovery) => {
400
+ if (!handler.parseError) return;
401
+ if (position < reportedPos) {
402
+ reportedPos = 0;
403
+ reportedLoc = { line: 1, column: 1 };
404
+ }
405
+ let { line, column } = reportedLoc;
406
+ for (let i = reportedPos; i < position; i++) {
407
+ if (fullHtml.charCodeAt(i) === 10) {
408
+ line++;
409
+ column = 1;
410
+ } else {
411
+ column++;
412
+ }
413
+ }
414
+ reportedPos = position;
415
+ reportedLoc = { line, column };
416
+ handler.parseError(`Parse error at line ${line}, column ${column}: ${recovery}`);
417
+ };
418
+
419
+ const sliceFromPos = (/** @type {number} */ startPos) => {
420
+ return fullHtml.slice(startPos);
421
+ };
422
+
423
+ // `annotation-xml` holds HTML only where its `encoding` says so; with any other value,
424
+ // and with none, its content stays MathML
425
+ const annotationHoldsHTML = (/** @type {{attrs?: HTMLAttribute[]}} */ entry) => {
426
+ for (const attr of entry.attrs ?? []) {
427
+ if (attr.name.toLowerCase() === 'encoding') {
428
+ return RE_HTML_ENCODING.test(attr.value ?? '');
429
+ }
430
+ }
431
+ return false;
432
+ };
433
+
434
+ // Escapable raw text is an HTML rule, so what decides it is the namespace the element sits
435
+ // in. Walking down from the root resolves that: `<svg>`/`<math>` lead out of HTML, an
436
+ // integration point leads back in, and a name counts only in the namespace it belongs to
437
+ // (`title` is one in SVG but not in MathML, `annotation-xml` one in MathML but not in SVG)
438
+ /** @param {{lowerTag: string, namespace: string, attrs?: HTMLAttribute[]} | undefined} entry */
439
+ const namespaceInside = (entry) => {
440
+ if (!entry) {
441
+ return '';
442
+ }
443
+ const { namespace, lowerTag } = entry;
444
+ if (!namespace) {
445
+ return lowerTag === 'svg' || lowerTag === 'math' ? lowerTag : '';
446
+ }
447
+ if (namespace === 'svg') {
448
+ return svgIntegrationPoints.has(lowerTag) ? '' : namespace;
449
+ }
450
+ return mathIntegrationPoints.has(lowerTag) || (lowerTag === 'annotation-xml' && annotationHoldsHTML(entry)) ? '' : namespace;
451
+ };
452
+
453
+ // The element itself never decides this: `<svg><title>` is SVG, what it holds is HTML
454
+ const inForeignContent = () => Boolean(stack[stack.length - 1]?.namespace);
455
+
456
+ // The same question for a start tag, which is not on the stack yet: The element enters the
457
+ // namespace its parent holds, except that `svg` and `math` lead out of HTML themselves
458
+ const startsForeignContent = (/** @type {string} */ lowerTag) =>
459
+ Boolean(namespaceInside(stack[stack.length - 1])) || lowerTag === 'svg' || lowerTag === 'math';
460
+
461
+ // Whether the content of the element being parsed is read as text rather than
462
+ // markup, which only HTML elements hold—in foreign content, `script` and `style`
463
+ // hold markup, too
464
+ const holdsRawText = (/** @type {string} */ tag) =>
465
+ (special.has(tag) || genericRawTextElements.has(tag) || escapableRawTextElements.has(tag)) && !inForeignContent();
466
+
467
+ // Where the content of the last SVG `script` or `style` start tag begins, which text
468
+ // there is the source of when it runs up to the element’s end tag
469
+ let sourcePos = -1;
470
+
471
+ // Whether the text from `textStart` is all such an element holds; kept out of the parse
472
+ // loop, which slows down with every line it grows by
473
+ const isSource = (/** @type {number} */ textStart) =>
474
+ sourcePos === textStart && fullHtml.charCodeAt(pos + 1) === 47 && matchEndTag(pos)?.name.toLowerCase() === lastTagLower;
475
+
476
+ // In foreign content, text runs through CDATA sections, and one without an end to the
477
+ // end of the input (-1)
478
+ const skipCDATA = (/** @type {number} */ textEnd) => {
479
+ while (textEnd !== -1 && fullHtml.startsWith(cdataOpen, textEnd)) {
480
+ const cdataEnd = fullHtml.indexOf(cdataClose, textEnd + cdataOpen.length);
481
+ textEnd = cdataEnd === -1 ? -1 : fullHtml.indexOf('<', cdataEnd + cdataClose.length);
482
+ }
483
+ return textEnd;
484
+ };
485
+
486
+ // The answer changes only when the top of the stack does, so it is recomputed per stack
487
+ // change rather than per loop iteration (`stackVersion` covers same-named tags whose
488
+ // namespace differs, as a name alone does not say which element sits on top)
489
+ let stackVersion = 0;
490
+ let rawTextVersion = -1;
491
+ let rawTextTag = '';
492
+ let rawTextResult = false;
493
+ const topHoldsRawText = () => {
494
+ if (stackVersion !== rawTextVersion || lastTagLower !== rawTextTag) {
495
+ rawTextResult = holdsRawText(lastTagLower);
496
+ rawTextVersion = stackVersion;
497
+ rawTextTag = lastTagLower;
498
+ }
499
+ return rawTextResult;
500
+ };
501
+
502
+ while (pos < fullLength) {
503
+ lastPos = pos;
504
+
505
+ // Make sure not to be in an element whose content is text, not markup
506
+ if (!lastTagLower || !topHoldsRawText()) {
507
+ let textEnd = fullHtml.indexOf('<', pos);
508
+ if (textEnd !== -1 && lastTagLower && fullHtml.charCodeAt(textEnd + 1) === 33 && inForeignContent()) {
509
+ textEnd = skipCDATA(textEnd);
510
+ }
511
+
512
+ if (textEnd === pos) {
513
+ // Tag found at current position; the lookahead may have parsed it already
514
+ if (cachedNextStartTag && cachedNextStartTag.pos === pos) {
515
+ const startTagMatch = cachedNextStartTag.match;
516
+ cachedNextStartTag = null;
517
+ cachedNextEndTag = null;
518
+ advance(startTagMatch.advance);
519
+ // Awaited only when the handler has reason to, which is what keeps a plain
520
+ // document off the microtask queue once per tag
521
+ const started = handleStartTag(startTagMatch);
522
+ prevTag = isThenable(started) ? await started : started;
139
523
  continue;
140
524
  }
141
- }
525
+ if (cachedNextEndTag && cachedNextEndTag.pos === pos) {
526
+ const endTagMatch = cachedNextEndTag.match;
527
+ cachedNextStartTag = null;
528
+ cachedNextEndTag = null;
529
+ advance(endTagMatch.text.length);
530
+ prevTag = '/' + parseEndTag(endTagMatch.text, endTagMatch.name);
531
+ prevAttrs = emptyAttrs;
532
+ continue;
533
+ }
534
+ cachedNextStartTag = null;
535
+ cachedNextEndTag = null;
536
+
537
+ // Dispatch on the character after `<`: Each branch below can only match
538
+ // with one leading character (`!` for markup declarations, `/` for end
539
+ // tags, a name character for start tags), so the matchers that require
540
+ // another one never run—a name lookup costs several regex executions
541
+ const nextCode = fullHtml.charCodeAt(pos + 1);
542
+ if (nextCode === 33) { // `!`
543
+ commentTestY.lastIndex = pos;
544
+ if (commentTestY.test(fullHtml)) {
545
+ const commentEnd = commentEndAhead ? fullHtml.indexOf('-->', pos + 4) : -1;
546
+ if (commentEnd === -1) commentEndAhead = false;
547
+
548
+ if (commentEnd >= 0) {
549
+ if (handler.comment) {
550
+ const result = handler.comment(fullHtml.substring(pos + 4, commentEnd));
551
+ if (isThenable(result)) await result;
552
+ }
553
+ advance(commentEnd + 3 - pos);
554
+ prevTag = '';
555
+ prevAttrs = emptyAttrs;
556
+ continue;
557
+ }
558
+ }
142
559
 
143
- // https://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
144
- if (/^<!\[/.test(html)) {
145
- var conditionalEnd = html.indexOf(']>');
560
+ // https://web.archive.org/web/20241201212701/https://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
561
+ conditionalTestY.lastIndex = pos;
562
+ if (conditionalTestY.test(fullHtml)) {
563
+ const conditionalEnd = conditionalEndAhead ? fullHtml.indexOf(']>', pos + 3) : -1;
564
+ if (conditionalEnd === -1) conditionalEndAhead = false;
565
+
566
+ if (conditionalEnd >= 0) {
567
+ if (handler.comment) {
568
+ const result = handler.comment(fullHtml.substring(pos + 2, conditionalEnd + 1), true /* Non-standard */);
569
+ if (isThenable(result)) await result;
570
+ }
571
+ advance(conditionalEnd + 2 - pos);
572
+ prevTag = '';
573
+ prevAttrs = emptyAttrs;
574
+ continue;
575
+ }
576
+ }
146
577
 
147
- if (conditionalEnd >= 0) {
148
- if (handler.comment) {
149
- handler.comment(html.substring(2, conditionalEnd + 1), true /* non-standard */);
578
+ doctypeY.lastIndex = pos;
579
+ const doctypeMatch = doctypeY.exec(fullHtml);
580
+ if (doctypeMatch) {
581
+ if (docModeSettable) {
582
+ docMode = handler.noQuirksMode || !isQuirksDoctype(doctypeMatch[0]) ? 'no-quirks' : 'quirks';
583
+ docModeSettable = false;
584
+ }
585
+ if (handler.doctype) {
586
+ handler.doctype(doctypeMatch[0], docMode === 'no-quirks');
587
+ }
588
+ advance(doctypeMatch[0].length);
589
+ prevTag = '';
590
+ prevAttrs = emptyAttrs;
591
+ continue;
592
+ }
593
+ } else if (nextCode === 63) { // `?`
594
+ const processingInstructionEnd = matchProcessingInstruction(pos);
595
+ if (processingInstructionEnd !== -1) {
596
+ if (handler.processingInstruction) {
597
+ handler.processingInstruction(fullHtml.substring(pos, processingInstructionEnd));
598
+ }
599
+ advance(processingInstructionEnd - pos);
600
+ prevTag = processingInstructionTag;
601
+ prevAttrs = emptyAttrs;
602
+ continue;
603
+ }
604
+ if (handler.continueOnParseError) {
605
+ const { end, dropped } = matchBogusProcessingInstruction(pos);
606
+ reportParseError(pos, dropped ? 'Dropped processing instruction without `>`' : 'Read `<?` without a processing instruction target as comment');
607
+ if (!dropped && handler.comment) {
608
+ // The data starts with the `?`, and the markup stays as written
609
+ const data = fullHtml.substring(pos + 1, fullHtml[end - 1] === '>' ? end - 1 : end);
610
+ const result = handler.comment(data, false, fullHtml.substring(pos, end));
611
+ if (isThenable(result)) await result;
612
+ }
613
+ advance(end - pos);
614
+ prevTag = '';
615
+ prevAttrs = emptyAttrs;
616
+ continue;
617
+ }
618
+ } else if (nextCode === 47) { // `/`
619
+ // End tag—skip when no `>` lies ahead: The tag cannot be complete, and matching
620
+ // its name at every position would cost O(n²) on a long unclosed name
621
+ if (hasCloseAtOrAfter(pos)) {
622
+ const endTagMatch = matchEndTag(pos);
623
+ if (endTagMatch) {
624
+ advance(endTagMatch.text.length);
625
+ prevTag = '/' + parseEndTag(endTagMatch.text, endTagMatch.name);
626
+ prevAttrs = emptyAttrs;
627
+ continue;
628
+ }
629
+ }
630
+ // Without a letter for a name, browsers drop `</>` and read anything else up to
631
+ // the next `>` (or the end of the input) as a bogus comment, never as text
632
+ const nameCode = fullHtml.charCodeAt(pos + 2) | 32;
633
+ if (handler.continueOnParseError && pos + 2 < fullLength && !(nameCode >= 97 && nameCode <= 122)) {
634
+ const bogusEnd = hasCloseAtOrAfter(pos + 2) ? nextGtPos : fullLength;
635
+ reportParseError(pos, bogusEnd === pos + 2 ? 'Dropped end tag without a name' : 'Read end tag without a name as comment');
636
+ if (bogusEnd > pos + 2 && handler.comment) {
637
+ const result = handler.comment(fullHtml.substring(pos + 2, bogusEnd));
638
+ if (isThenable(result)) await result;
639
+ }
640
+ advance(Math.min(bogusEnd + 1, fullLength) - pos);
641
+ prevTag = '';
642
+ prevAttrs = emptyAttrs;
643
+ continue;
644
+ }
645
+ } else {
646
+ const startTagMatch = parseStartTag(pos);
647
+ if (startTagMatch) {
648
+ advance(startTagMatch.advance);
649
+ const started = handleStartTag(startTagMatch);
650
+ prevTag = isThenable(started) ? await started : started;
651
+ continue;
150
652
  }
151
- html = html.substring(conditionalEnd + 2);
152
- prevTag = '';
153
- continue;
154
653
  }
654
+ // Otherwise, the `<` is read as text
155
655
  }
156
656
 
157
- // Doctype:
158
- var doctypeMatch = html.match(doctype);
159
- if (doctypeMatch) {
160
- if (handler.doctype) {
161
- handler.doctype(doctypeMatch[0]);
657
+ let text;
658
+ if (textEnd >= 0) {
659
+ text = fullHtml.substring(pos, textEnd);
660
+ advance(textEnd - pos);
661
+ } else {
662
+ text = fullHtml.substring(pos);
663
+ advance(fullLength - pos);
664
+ }
665
+ // Text keeps a later doctype from setting the mode (template code may render to text
666
+ // or to nothing), except for whitespace and a byte order mark at the start, which
667
+ // browsers strip (as it runs before the first tag only, this costs next to nothing)
668
+ if (docModeSettable) {
669
+ const bom = pos === text.length && text.charCodeAt(0) === 0xFEFF;
670
+ const rest = bom ? text.slice(1) : text;
671
+ if (rest && !RE_WS_ONLY.test(rest)) {
672
+ docModeSettable = false;
162
673
  }
163
- html = html.substring(doctypeMatch[0].length);
164
- prevTag = '';
165
- continue;
166
674
  }
167
675
 
168
- // End tag:
169
- var endTagMatch = html.match(endTag);
170
- if (endTagMatch) {
171
- html = html.substring(endTagMatch[0].length);
172
- endTagMatch[0].replace(endTag, parseEndTag);
173
- prevTag = '/' + endTagMatch[1].toLowerCase();
174
- continue;
676
+ // Next tag for whitespace processing context
677
+ if (handler.wantsNextTag) {
678
+ // Same dispatch as above: A start tag cannot follow `<` + `/` or `!`,
679
+ // an end tag requires `/`—the other matcher never runs
680
+ const nextCode = fullHtml.charCodeAt(pos + 1);
681
+ if (nextCode === 63) {
682
+ nextTag = matchProcessingInstruction(pos) !== -1 ? processingInstructionTag : '';
683
+ nextAttrs = emptyAttrs;
684
+ } else if (nextCode !== 47 && nextCode !== 33) {
685
+ const nextStartTagMatch = parseStartTag(pos);
686
+ if (nextStartTagMatch) {
687
+ // Lowercase, as `prevTag` is, so `SPAN` reads as `span` to the whitespace logic
688
+ nextTag = nextStartTagMatch.lowerTagName;
689
+ // Extract minimal attribute info for whitespace logic (just name/value pairs)—
690
+ // only consulted next to an `input` (hidden inputs), so skip the extraction
691
+ // for every other tag rather than allocating attribute objects for each
692
+ nextAttrs = nextTag === 'input' ? extractAttrInfo(nextStartTagMatch.attrs) : emptyAttrs;
693
+ cachedNextStartTag = { match: nextStartTagMatch, pos };
694
+ } else {
695
+ nextTag = '';
696
+ nextAttrs = emptyAttrs;
697
+ }
698
+ } else if (nextCode === 47 && hasCloseAtOrAfter(pos)) {
699
+ const nextEndTagMatch = matchEndTag(pos);
700
+ if (nextEndTagMatch) {
701
+ nextTag = '/' + nextEndTagMatch.name.toLowerCase();
702
+ nextAttrs = emptyAttrs;
703
+ cachedNextEndTag = { match: nextEndTagMatch, pos };
704
+ } else {
705
+ nextTag = '';
706
+ nextAttrs = emptyAttrs;
707
+ }
708
+ } else {
709
+ nextTag = '';
710
+ nextAttrs = emptyAttrs;
711
+ }
175
712
  }
176
713
 
177
- // Start tag:
178
- var startTagMatch = parseStartTag(html);
179
- if (startTagMatch) {
180
- html = startTagMatch.rest;
181
- handleStartTag(startTagMatch);
182
- prevTag = startTagMatch.tagName.toLowerCase();
183
- continue;
714
+ if (handler.chars) {
715
+ // An element whose end tag follows its start tag’s text alone has that text for content
716
+ const result = handler.chars(text, prevTag, nextTag, prevAttrs, nextAttrs, sourcePos !== -1 && isSource(pos - text.length));
717
+ if (isThenable(result)) await result;
718
+ }
719
+ prevTag = '';
720
+ prevAttrs = emptyAttrs;
721
+ } else {
722
+ const stackedTag = lastTagLower;
723
+ const isEscapableRawText = escapableRawTextElements.has(stackedTag);
724
+
725
+ const remaining = sliceFromPos(pos);
726
+ // Where the raw text has no end, no end tag of its own ends the element either
727
+ const rawText = endlessRawTextElements.has(stackedTag) ? null : findRawTextEnd(remaining, stackedTag);
728
+ if (rawText) {
729
+ const text = rawText.content;
730
+ if (handler.chars) {
731
+ // Escapable raw text is a text node like any other, so the handler gets the tags
732
+ // around it—it collapses and trims whitespace by what stands next to the text
733
+ const result = isEscapableRawText
734
+ ? handler.chars(text, stackedTag, '/' + stackedTag, prevAttrs, [])
735
+ : handler.chars(text);
736
+ if (isThenable(result)) await result;
737
+ }
738
+ // Advance HTML past the matched special tag content and its closing tag
739
+ advance(rawText.length);
740
+ parseEndTag('</' + stackedTag + '>', stackedTag);
741
+ // What follows stands after that end tag, not after the start tag still named here—
742
+ // whitespace next to `textarea` stays verbatim only while the element is open
743
+ prevTag = '/' + stackedTag;
744
+ prevAttrs = emptyAttrs;
745
+ } else {
746
+ // Without an end tag of its own, the element holds the rest of the input as text
747
+ if (handler.chars && remaining) {
748
+ const result = isEscapableRawText
749
+ ? handler.chars(remaining, stackedTag, '', prevAttrs, [])
750
+ : handler.chars(remaining);
751
+ if (isThenable(result)) await result;
752
+ }
753
+ advance(remaining.length);
754
+ if (endlessRawTextElements.has(stackedTag)) {
755
+ // Nothing that stands behind the start tag is markup, so no end tag may be
756
+ // generated for the element, or for anything it stands in
757
+ stack.length = 0;
758
+ stackVersion++;
759
+ lastTag = '';
760
+ lastTagLower = '';
761
+ }
184
762
  }
763
+ }
185
764
 
186
- // Treat `<` as text
765
+ if (pos === lastPos) {
187
766
  if (handler.continueOnParseError) {
188
- textEnd = html.indexOf('<', 1);
767
+ // Keep the problematic character as text and move on
768
+ docModeSettable = false;
769
+ reportParseError(pos, `Kept \`${fullHtml[pos]}\` as text`);
770
+ if (handler.chars) {
771
+ const result = handler.chars(fullHtml[pos], prevTag, '', prevAttrs, []);
772
+ if (isThenable(result)) await result;
773
+ }
774
+ advance(1);
775
+ prevTag = '';
776
+ prevAttrs = emptyAttrs;
777
+ continue;
189
778
  }
779
+ const loc = getLineColumn(pos);
780
+ // Include some context before the error position so the snippet contains the offending
781
+ // markup plus preceding characters (e.g., `invalid<tag`)
782
+ const CONTEXT_BEFORE = 50;
783
+ const startPos = Math.max(0, pos - CONTEXT_BEFORE);
784
+ const snippet = fullHtml.slice(startPos, startPos + 200).replace(/\n/g, ' ');
785
+ throw new Error(`Parse error at line ${loc.line}, column ${loc.column}:\n${snippet}${fullHtml.length > startPos + 200 ? '…' : ''}`);
190
786
  }
787
+ }
191
788
 
192
- var text;
193
- if (textEnd >= 0) {
194
- text = html.substring(0, textEnd);
195
- html = html.substring(textEnd);
196
- }
197
- else {
198
- text = html;
199
- html = '';
200
- }
789
+ if (!handler.partialMarkup) {
790
+ // Clean up any remaining tags
791
+ parseEndTag('', '');
792
+ }
201
793
 
202
- // next tag
203
- var nextTagMatch = parseStartTag(html);
204
- if (nextTagMatch) {
205
- nextTag = nextTagMatch.tagName;
206
- }
207
- else {
208
- nextTagMatch = html.match(endTag);
209
- if (nextTagMatch) {
210
- nextTag = '/' + nextTagMatch[1];
211
- }
212
- else {
213
- nextTag = '';
794
+ // A quoted attribute read from the full input, where the attribute pattern’s window cuts it off
795
+ function readLongQuotedAttr(/** @type {number} */ attrPos, /** @type {string} */ searchStr) {
796
+ const nameMatch = searchStr.match(RE_ATTR_NAME_ASSIGN);
797
+ if (!nameMatch) return null;
798
+ const valueStart = attrPos + nameMatch[0].length;
799
+ const quoteChar = fullHtml[valueStart];
800
+ if (quoteChar !== '"' && quoteChar !== "'") return null;
801
+ const closeQuote = fullHtml.indexOf(quoteChar, valueStart + 1);
802
+ if (closeQuote === -1) return null;
803
+ // Repeated closing quotes belong to the attribute, as in the attribute pattern
804
+ let attrEnd = closeQuote + 1;
805
+ while (fullHtml[attrEnd] === quoteChar) attrEnd++;
806
+ /** @type {Array<string | undefined>} */
807
+ const attr = [];
808
+ attr[0] = fullHtml.substring(attrPos, attrEnd);
809
+ attr[baseIndex] = nameMatch[1];
810
+ attr[baseIndex + 1] = '='; // `customAssign` falls back to `=` for huge attributes
811
+ // Double-quoted values go to `baseIndex + 2`, single-quoted ones to `+ 3`
812
+ attr[quoteChar === '"' ? baseIndex + 2 : baseIndex + 3] = fullHtml.substring(valueStart + 1, closeQuote);
813
+ return attr;
814
+ }
815
+
816
+ // Minimal attribute info (name/value pairs) from raw matches, for whitespace collapsing
817
+ function extractAttrInfo(/** @type {Array<Array<string | undefined>>} */ rawAttrs) {
818
+ if (!rawAttrs || !rawAttrs.length) return [];
819
+
820
+ return /** @type {HTMLAttribute[]} */ (rawAttrs.map((/** @type {Array<string | undefined>} */ args) => {
821
+ const name = args[baseIndex];
822
+ // Double-quoted (`baseIndex + 2`), single-quoted (`+ 3`), or unquoted (`+ 4`)
823
+ const value = args[baseIndex + 2] ?? args[baseIndex + 3] ?? args[baseIndex + 4];
824
+ return { name: name?.toLowerCase() ?? '', value };
825
+ }).filter(attr => attr.name));
826
+ }
827
+
828
+ /**
829
+ * @param {number} startPos
830
+ * @returns {{text: string, name: string} | null} The end tag standing there, or `null`
831
+ * where none is complete
832
+ */
833
+ function matchEndTag(startPos) {
834
+ let name;
835
+ let nameEnd;
836
+ const scanned = scanAsciiTagName(fullHtml, startPos + 2);
837
+ if (scanned !== -1) {
838
+ nameEnd = startPos + 2 + scanned;
839
+ name = fullHtml.slice(startPos + 2, nameEnd);
840
+ } else {
841
+ endTagOpenY.lastIndex = startPos;
842
+ const open = endTagOpenY.exec(fullHtml);
843
+ if (!open) {
844
+ return null;
214
845
  }
846
+ nameEnd = startPos + open[0].length;
847
+ name = open[1] ?? '';
215
848
  }
849
+ const close = findEndTagClose(fullHtml, nameEnd);
850
+ return close === -1 ? null : { text: fullHtml.slice(startPos, close + 1), name };
851
+ }
216
852
 
217
- if (handler.chars) {
218
- handler.chars(text, prevTag, nextTag);
853
+ // Where a tag ends: After optional whitespace, at `>` or `/>`. Scanned by hand in one pass, as
854
+ // a regex costs an execution per attribute; the result packs length and slash into one number
855
+ // (`length << 1 | hasSlash`, 0 for no match), so the per-attribute loop allocates nothing.
856
+ function matchTagClose(/** @type {number} */ currentPos) {
857
+ let scan = currentPos;
858
+ let code = fullHtml.charCodeAt(scan);
859
+ while (isWhitespaceCode(code)) {
860
+ code = fullHtml.charCodeAt(++scan);
219
861
  }
220
- prevTag = '';
862
+ if (code === 62) { // `>`
863
+ return (scan - currentPos + 1) << 1;
864
+ }
865
+ if (code === 47 && fullHtml.charCodeAt(scan + 1) === 62) { // `/>`
866
+ return ((scan - currentPos + 2) << 1) | 1;
867
+ }
868
+ return 0;
221
869
  }
222
- else {
223
- var stackedTag = lastTag.toLowerCase();
224
- var reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)</' + stackedTag + '[^>]*>', 'i'));
225
870
 
226
- html = html.replace(reStackedTag, function(all, text) {
227
- if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') {
228
- text = text
229
- .replace(/<!--([\s\S]*?)-->/g, '$1')
230
- .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
871
+ function parseStartTag(/** @type {number} */ startPos) {
872
+ let tagName;
873
+ let consumed;
874
+ const scanned = scanAsciiTagName(fullHtml, startPos + 1);
875
+ if (scanned !== -1) {
876
+ tagName = fullHtml.slice(startPos + 1, startPos + 1 + scanned);
877
+ consumed = 1 + scanned;
878
+ } else {
879
+ startTagOpenY.lastIndex = startPos;
880
+ const start = startTagOpenY.exec(fullHtml);
881
+ if (!start) {
882
+ return undefined;
231
883
  }
884
+ tagName = start[1] ?? '';
885
+ consumed = start[0].length;
886
+ }
887
+ /** @type {{tagName: string, lowerTagName: string, attrs: Array<Array<string|undefined>>, advance: number, unarySlash?: string}} */
888
+ const match = {
889
+ tagName,
890
+ // Once per tag, for the look-ahead as well as for the start tag itself
891
+ lowerTagName: tagName.toLowerCase(),
892
+ attrs: [],
893
+ advance: 0
894
+ };
895
+ let currentPos = startPos + consumed;
896
+ let close, attr;
232
897
 
233
- if (handler.chars) {
234
- handler.chars(text);
235
- }
898
+ // Caps the input the attribute regex sees, against catastrophic backtracking on huge values
899
+ const MAX_ATTR_PARSE_LENGTH = 20000; // 20 KB should be enough for any reasonable tag
236
900
 
237
- return '';
238
- });
901
+ while (true) {
902
+ // Check for closing tag first (manual scan, regex only for the unusual)
903
+ close = matchTagClose(currentPos);
904
+ if (close) {
905
+ break;
906
+ }
239
907
 
240
- parseEndTag('</' + stackedTag + '>', stackedTag);
241
- }
908
+ const remainingLen = fullLength - currentPos;
909
+ const isLimited = remainingLen > MAX_ATTR_PARSE_LENGTH;
910
+
911
+ if (!isLimited) {
912
+ // Common case: Use sticky regex directly on full string (no slicing)
913
+ attributeY.lastIndex = currentPos;
914
+ attr = attributeY.exec(fullHtml);
915
+ } else {
916
+ const extractEndPos = currentPos + MAX_ATTR_PARSE_LENGTH;
917
+
918
+ // Create a temporary substring only for attribute parsing (limited for safety)
919
+ const searchStr = fullHtml.substring(currentPos, extractEndPos);
920
+ attr = searchStr.match(attribute);
921
+
922
+ // If input was limited and there’s a match, check if the value might be truncated
923
+ if (attr) {
924
+ const attrEnd = attr[0].length;
925
+ const unquotedValue = attr[baseIndex + 4];
926
+ if (unquotedValue !== undefined && attrEnd === searchStr.length) {
927
+ // An unquoted value running into the window’s end is read on past it
928
+ const unquotedRest = handler.continueOnParseError ? RE_UNQUOTED_VALUE_REST_LENIENT : RE_UNQUOTED_VALUE_REST;
929
+ unquotedRest.lastIndex = currentPos + attrEnd;
930
+ const rest = unquotedRest.exec(fullHtml)?.[0] ?? '';
931
+ attr[0] += rest;
932
+ attr[baseIndex + 4] = unquotedValue + rest;
933
+ } else if (attrEnd > MAX_ATTR_PARSE_LENGTH - 100) {
934
+ // If the match ends near the limit, a quoted value might be truncated,
935
+ // so extract the attribute manually
936
+ attr = readLongQuotedAttr(currentPos, searchStr) ?? attr;
937
+ } else if (attr[baseIndex + 1] === undefined) {
938
+ // If attr has no value assign but `=` follows in `fullHtml`,
939
+ // the value would be cut off—reset to trigger manual extraction below
940
+ const posAfterName = currentPos + attrEnd;
941
+ if (/^\s*=/.test(fullHtml.slice(posAfterName, posAfterName + 50))) {
942
+ attr = null;
943
+ }
944
+ }
945
+ }
242
946
 
243
- if (html === last) {
244
- throw new Error('Parse Error: ' + html);
245
- }
246
- }
947
+ if (!attr) {
948
+ // If input was limited and there’s no match, a quoted value may exceed
949
+ // `MAX_ATTR_PARSE_LENGTH`, so try manual extraction
950
+ attr = readLongQuotedAttr(currentPos, searchStr);
951
+ }
952
+ }
247
953
 
248
- if (!handler.partialMarkup) {
249
- // Clean up any remaining tags
250
- parseEndTag();
251
- }
954
+ if (!attr) {
955
+ break;
956
+ }
252
957
 
253
- function parseStartTag(input) {
254
- var start = input.match(startTagOpen);
255
- if (start) {
256
- var match = {
257
- tagName: start[1],
258
- attrs: []
259
- };
260
- input = input.slice(start[0].length);
261
- var end, attr;
262
- while (!(end = input.match(startTagClose)) && (attr = input.match(attribute))) {
263
- input = input.slice(attr[0].length);
958
+ const attrLen = attr[0].length;
959
+ currentPos += attrLen;
960
+ consumed += attrLen;
264
961
  match.attrs.push(attr);
265
962
  }
266
- if (end) {
267
- match.unarySlash = end[1];
268
- match.rest = input.slice(end[0].length);
963
+
964
+ // Check for closing tag (manual scan, regex only for the unusual)
965
+ if (!close) {
966
+ close = matchTagClose(currentPos);
967
+ }
968
+ if (close) {
969
+ match.unarySlash = (close & 1) ? '/' : '';
970
+ consumed += close >> 1;
971
+ match.advance = consumed;
269
972
  return match;
270
973
  }
974
+ return undefined;
271
975
  }
272
- }
273
-
274
- function closeIfFound(tagName) {
275
- if (findTag(tagName) >= 0) {
276
- parseEndTag('', tagName);
277
- return true;
278
- }
279
- }
280
-
281
- function handleStartTag(match) {
282
- var tagName = match.tagName;
283
- var unarySlash = match.unarySlash;
284
976
 
285
- if (handler.html5) {
286
- if (lastTag === 'p' && nonPhrasing(tagName)) {
287
- parseEndTag('', lastTag);
288
- }
289
- else if (tagName === 'tbody') {
290
- closeIfFound('thead');
291
- }
292
- else if (tagName === 'tfoot') {
293
- if (!closeIfFound('tbody')) {
294
- closeIfFound('thead');
977
+ // `needle` must already be lowercase
978
+ function findTagInCurrentTable(/** @type {string} */ needle) {
979
+ let stackIndex;
980
+ for (stackIndex = stack.length - 1; stackIndex >= 0; stackIndex--) {
981
+ const entry = stack[stackIndex];
982
+ const currentTag = entry?.lowerTag;
983
+ if (currentTag === needle) {
984
+ return stackIndex;
985
+ }
986
+ if (currentTag === 'table') {
987
+ break;
295
988
  }
296
989
  }
297
- if (tagName === 'col' && findTag('colgroup') < 0) {
298
- lastTag = 'colgroup';
299
- stack.push({ tag: lastTag, attrs: [] });
300
- if (handler.start) {
301
- handler.start(lastTag, [], false, '');
990
+ return -1;
991
+ }
992
+
993
+ function parseEndTagAt(/** @type {number} */ stackIndex, autoGenerated = true) {
994
+ // Close all open elements up to `stackIndex` (mirrors `parseEndTag`’s core branch);
995
+ // `end` handlers are synchronous—invoked without awaiting, as in `parseEndTag`
996
+ for (let i = stack.length - 1; i >= stackIndex; i--) {
997
+ const entry = /** @type {NonNullable<(typeof stack)[number]>} */ (stack[i]);
998
+ if (handler.end) {
999
+ handler.end(entry.tag, entry.attrs, autoGenerated);
302
1000
  }
303
1001
  }
1002
+ stack.length = stackIndex;
1003
+ stackVersion++;
1004
+ lastTag = stackIndex ? (stack[stackIndex - 1]?.tag ?? '') : '';
1005
+ lastTagLower = stackIndex ? (stack[stackIndex - 1]?.lowerTag ?? '') : '';
304
1006
  }
305
1007
 
306
- if (!handler.html5 && !inline(tagName)) {
307
- while (lastTag && inline(lastTag)) {
308
- parseEndTag('', lastTag);
1008
+ function closeIfFoundInCurrentTable(/** @type {string} */ tagName) {
1009
+ const stackIndex = findTagInCurrentTable(tagName);
1010
+ if (stackIndex >= 0) {
1011
+ // Close at the specific index to avoid re-searching
1012
+ parseEndTagAt(stackIndex);
1013
+ return true;
309
1014
  }
1015
+ return false;
310
1016
  }
311
1017
 
312
- if (closeSelf(tagName) && lastTag === tagName) {
313
- parseEndTag('', tagName);
1018
+ // The lenient attribute pattern takes characters into unquoted values that the strict
1019
+ // one rejects; the start tag has just been consumed, so it ends right before `pos`
1020
+ function reportUnquotedValueErrors(/** @type {{attrs: Array<Array<string | undefined>>, advance: number}} */ match) {
1021
+ let attrPos = pos - match.advance;
1022
+ for (const attr of match.attrs) {
1023
+ const text = attr[0] ?? '';
1024
+ attrPos = fullHtml.indexOf(text, attrPos);
1025
+ const surroundCount = handler.customAttrSurround ? handler.customAttrSurround.length : 0;
1026
+ for (let i = 0; i <= surroundCount; i++) {
1027
+ // Unquoted values sit at index 5 of each surround block, and 4 behind the plain name
1028
+ const value = i < surroundCount ? attr[1 + i * NCP + 5] : attr[1 + surroundCount * NCP + 4];
1029
+ const errorIndex = value ? value.search(RE_UNQUOTED_VALUE_ERROR) : -1;
1030
+ if (value && errorIndex !== -1) {
1031
+ reportParseError(attrPos + text.lastIndexOf(value) + errorIndex, `Kept \`${value[errorIndex]}\` in unquoted attribute value`);
1032
+ }
1033
+ }
1034
+ attrPos += text.length;
1035
+ }
314
1036
  }
315
1037
 
316
- var unary = empty(tagName) || tagName === 'html' && lastTag === 'head' || !!unarySlash;
1038
+ // Returns the lowercase name for the parse loop, or a Promise of it
1039
+ // when `handler.start` awaits (which only happens with attributes)
1040
+ function handleStartTag(/** @type {{tagName: string, lowerTagName: string, attrs: Array<Array<string | undefined>>, advance: number, unarySlash?: string}} */ match) {
1041
+ const tagName = match.tagName;
1042
+ const lowerTagName = match.lowerTagName;
1043
+ let unarySlash = match.unarySlash;
1044
+
1045
+ docModeSettable = false;
1046
+ if (handler.parseError) reportUnquotedValueErrors(match);
1047
+ // A plain loop, so a tag costs no closure allocation per attribute
1048
+ const rawAttrs = match.attrs;
1049
+ /** @type {HTMLAttribute[]} */
1050
+ const attrs = [];
1051
+ for (let i = 0; i < rawAttrs.length; i++) {
1052
+ const args = /** @type {Array<string | undefined>} */ (rawAttrs[i]);
1053
+ /** @type {string | undefined} */
1054
+ let name, value, customOpen, customClose, customAssign, quote;
1055
+
1056
+ // Where `customAssign` sits; the value is in one of the three slots after it
1057
+ let assignIndex = -1;
1058
+ let j = 1;
1059
+ if (handler.customAttrSurround) {
1060
+ for (let s = 0, l = handler.customAttrSurround.length; s < l; s++, j += NCP) {
1061
+ name = args[j + 1];
1062
+ if (name) {
1063
+ assignIndex = j + 2;
1064
+ customOpen = args[j];
1065
+ customClose = args[j + 6];
1066
+ break;
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ if (!name && (name = args[j])) {
1072
+ assignIndex = j + 1;
1073
+ }
1074
+
1075
+ if (name) {
1076
+ customAssign = args[assignIndex];
1077
+ if (typeof (value = args[assignIndex + 1]) !== 'undefined') {
1078
+ quote = '"';
1079
+ } else if (typeof (value = args[assignIndex + 2]) !== 'undefined') {
1080
+ quote = '\'';
1081
+ } else {
1082
+ value = args[assignIndex + 3];
1083
+ quote = '';
1084
+ }
1085
+ }
317
1086
 
318
- var attrs = match.attrs.map(function(args) {
319
- var name, value, customOpen, customClose, customAssign, quote;
320
- var ncp = 7; // number of captured parts, scalar
1087
+ attrs.push({
1088
+ name: name ?? '',
1089
+ value,
1090
+ customAssign: customAssign || '=',
1091
+ customOpen: customOpen || '',
1092
+ customClose: customClose || '',
1093
+ quote: quote || ''
1094
+ });
1095
+ }
321
1096
 
322
- // hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
323
- if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
324
- if (args[3] === '') { delete args[3]; }
325
- if (args[4] === '') { delete args[4]; }
326
- if (args[5] === '') { delete args[5]; }
1097
+ // HTML reads these as its own, so the foreign elements open end here
1098
+ if (namespaceInside(stack[stack.length - 1]) && (breaksOutOfForeignContent.has(lowerTagName) ||
1099
+ (lowerTagName === 'font' && attrs.some(attr => RE_FONT_BREAKOUT_ATTR.test(attr.name))))) {
1100
+ closeForeignContent();
327
1101
  }
328
1102
 
329
- function populate(index) {
330
- customAssign = args[index];
331
- value = args[index + 1];
332
- if (typeof value !== 'undefined') {
333
- return '"';
1103
+ const pIndex = stack.length ? (stack[stack.length - 1]?.pScope ?? -1) : -1;
1104
+ if (pIndex >= 0 && closesP.has(lowerTagName)) {
1105
+ if (lowerTagName !== 'table' || docMode === 'no-quirks') {
1106
+ parseEndTag('', stack[pIndex]?.tag ?? 'p');
1107
+ } else if (!docMode) {
1108
+ pHoldingTable.add(/** @type {object} */ (stack[pIndex]));
1109
+ }
1110
+ }
1111
+ // A row or cell start tag closes the row or cell still open in the same table,
1112
+ // which may sit deeper down the stack than the element that was opened last
1113
+ if (lowerTagName === 'tr') {
1114
+ closeIfFoundInCurrentTable('tr');
1115
+ } else if (lowerTagName === 'td' || lowerTagName === 'th') {
1116
+ if (!closeIfFoundInCurrentTable('td')) {
1117
+ closeIfFoundInCurrentTable('th');
334
1118
  }
335
- value = args[index + 2];
336
- if (typeof value !== 'undefined') {
337
- return '\'';
1119
+ }
1120
+ if (lowerTagName === 'tbody') {
1121
+ if (!closeIfFoundInCurrentTable('tfoot')) {
1122
+ closeIfFoundInCurrentTable('thead');
338
1123
  }
339
- value = args[index + 3];
340
- if (typeof value === 'undefined' && fillAttrs(name)) {
341
- value = name;
1124
+ } else if (lowerTagName === 'tfoot') {
1125
+ if (!closeIfFoundInCurrentTable('tbody')) {
1126
+ closeIfFoundInCurrentTable('thead');
1127
+ }
1128
+ } else if (lowerTagName === 'thead') {
1129
+ if (!closeIfFoundInCurrentTable('tbody')) {
1130
+ closeIfFoundInCurrentTable('tfoot');
342
1131
  }
343
- return '';
344
1132
  }
345
-
346
- var j = 1;
347
- if (handler.customAttrSurround) {
348
- for (var i = 0, l = handler.customAttrSurround.length; i < l; i++, j += ncp) {
349
- name = args[j + 1];
350
- if (name) {
351
- quote = populate(j + 2);
352
- customOpen = args[j];
353
- customClose = args[j + 6];
354
- break;
355
- }
1133
+ if (lowerTagName === 'col' && findTagInCurrentTable('colgroup') < 0) {
1134
+ lastTag = 'colgroup';
1135
+ lastTagLower = 'colgroup';
1136
+ pushOpenElement(lastTag, 'colgroup', []);
1137
+ if (handler.start) {
1138
+ handler.start(lastTag, [], false, '', true);
356
1139
  }
1140
+ } else if (lowerTagName !== 'col' && lastTagLower === 'colgroup') {
1141
+ // Auto-close synthetic `<colgroup>` when a non-`col` element starts
1142
+ parseEndTag('', 'colgroup');
357
1143
  }
358
1144
 
359
- if (!name && (name = args[j])) {
360
- quote = populate(j + 1);
1145
+ if (closeSelf.has(lowerTagName) && lastTagLower === lowerTagName) {
1146
+ parseEndTag('', tagName);
361
1147
  }
362
1148
 
363
- return {
364
- name: name,
365
- value: value,
366
- customAssign: customAssign || '=',
367
- customOpen: customOpen || '',
368
- customClose: customClose || '',
369
- quote: quote || ''
370
- };
371
- });
372
-
373
- if (!unary) {
374
- stack.push({ tag: tagName, attrs: attrs });
375
- lastTag = tagName;
376
- unarySlash = '';
377
- }
1149
+ // Handle `dt`/`dd` cross-closing: `dt` followed by `dd`, or `dd` followed by `dt`
1150
+ if ((lowerTagName === 'dt' || lowerTagName === 'dd') && (lastTagLower === 'dt' || lastTagLower === 'dd')) {
1151
+ parseEndTag('', lastTag);
1152
+ }
378
1153
 
379
- if (handler.start) {
380
- handler.start(tagName, attrs, unary, unarySlash);
381
- }
382
- }
1154
+ // HTML ignores the slash on a start tag—only in SVG and MathML does it close the
1155
+ // element, which `selfClosingSlash` extends back to every element
1156
+ const selfClosed = !!unarySlash && (handler.selfClosingSlash || startsForeignContent(lowerTagName));
1157
+ const unary = empty.has(lowerTagName) || (lowerTagName === 'html' && lastTagLower === 'head') || selfClosed;
383
1158
 
384
- function findTag(tagName) {
385
- var pos;
386
- var needle = tagName.toLowerCase();
387
- for (pos = stack.length - 1; pos >= 0; pos--) {
388
- if (stack[pos].tag.toLowerCase() === needle) {
389
- break;
1159
+ if (!unary) {
1160
+ const namespace = pushOpenElement(tagName, lowerTagName, attrs);
1161
+ lastTag = tagName;
1162
+ lastTagLower = lowerTagName;
1163
+ unarySlash = '';
1164
+ if (namespace === 'svg' && special.has(lowerTagName)) {
1165
+ sourcePos = pos;
1166
+ }
390
1167
  }
391
- }
392
- return pos;
393
- }
394
1168
 
395
- function parseEndTag(tag, tagName) {
396
- var pos;
1169
+ // `prevAttrs` feeds whitespace collapsing
1170
+ prevAttrs = attrs;
397
1171
 
398
- // Find the closest opened tag of the same type
399
- if (tagName) {
400
- pos = findTag(tagName);
401
- }
402
- // If no tag name is provided, clean shop
403
- else {
404
- pos = 0;
1172
+ // The name is returned so the parse loop can skip lowercasing it again
1173
+ const started = handler.start?.(tagName, attrs, unary, unarySlash);
1174
+ return isThenable(started) ? started.then(() => lowerTagName) : lowerTagName;
405
1175
  }
406
1176
 
407
- if (pos >= 0) {
408
- // Close all the open elements, up the stack
409
- for (var i = stack.length - 1; i >= pos; i--) {
410
- if (handler.end) {
411
- handler.end(stack[i].tag, stack[i].attrs, i > pos || !tag);
1177
+ // Pushes an open element, noting the `p` in scope from within it: its own index
1178
+ // for a `p`, none past a scope boundary, foreign element, or `noscript` (which
1179
+ // holds text with scripting), and otherwise its parent’s; returns the namespace it sits in
1180
+ function pushOpenElement(/** @type {string} */ tag, /** @type {string} */ lowerTag, /** @type {HTMLAttribute[]} */ attrs) {
1181
+ const parent = stack.length ? stack[stack.length - 1] : undefined;
1182
+ const namespace = namespaceInside(parent);
1183
+ let pScope = -1;
1184
+ if (!namespace) {
1185
+ if (lowerTag === 'p') {
1186
+ pScope = stack.length;
1187
+ } else if (parent && parent.pScope !== -1 && !buttonScopeBoundaries.has(lowerTag) && lowerTag !== 'svg' && lowerTag !== 'math' && lowerTag !== 'noscript') {
1188
+ // Most elements sit outside any `p`, so the boundary checks only run inside one
1189
+ pScope = parent.pScope;
412
1190
  }
413
1191
  }
414
-
415
- // Remove the open elements from the stack
416
- stack.length = pos;
417
- lastTag = pos && stack[pos - 1].tag;
418
- }
419
- else if (tagName.toLowerCase() === 'br') {
420
- if (handler.start) {
421
- handler.start(tagName, [], true, '');
422
- }
1192
+ stack.push({ tag, lowerTag, attrs, namespace, pScope });
1193
+ stackVersion++;
1194
+ return namespace;
423
1195
  }
424
- else if (tagName.toLowerCase() === 'p') {
425
- if (handler.start) {
426
- handler.start(tagName, [], false, '', true);
427
- }
428
- if (handler.end) {
429
- handler.end(tagName, []);
1196
+
1197
+ // `needle` must already be lowercase
1198
+ function findTag(/** @type {string} */ needle) {
1199
+ let stackIndex;
1200
+ for (stackIndex = stack.length - 1; stackIndex >= 0; stackIndex--) {
1201
+ if (stack[stackIndex]?.lowerTag === needle) {
1202
+ break;
1203
+ }
430
1204
  }
1205
+ return stackIndex;
431
1206
  }
432
- }
433
- }
434
1207
 
435
- exports.HTMLParser = HTMLParser;
436
- exports.HTMLtoXML = function(html) {
437
- var results = '';
438
-
439
- new HTMLParser(html, {
440
- start: function(tag, attrs, unary) {
441
- results += '<' + tag;
442
-
443
- for (var i = 0, len = attrs.length; i < len; i++) {
444
- results += ' ' + attrs[i].name + '="' + (attrs[i].value || '').replace(/"/g, '&#34;') + '"';
445
- }
446
-
447
- results += (unary ? '/' : '') + '>';
448
- },
449
- end: function(tag) {
450
- results += '</' + tag + '>';
451
- },
452
- chars: function(text) {
453
- results += text;
454
- },
455
- comment: function(text) {
456
- results += '<!--' + text + '-->';
457
- },
458
- ignore: function(text) {
459
- results += text;
1208
+ // Closes the foreign elements open down to the nearest HTML element or
1209
+ // integration point, writing their end tags so that an SVG stays well-formed
1210
+ // for SVGO (none of them is an HTML `p`, so none can hold a table). This
1211
+ // doesn’t share the closing loop of `parseEndTag`, as that costs the hot
1212
+ // function about 1%.
1213
+ function closeForeignContent() {
1214
+ let stackIndex = stack.length;
1215
+ while (stackIndex && namespaceInside(stack[stackIndex - 1])) {
1216
+ stackIndex--;
1217
+ }
1218
+ parseEndTagAt(stackIndex, false);
460
1219
  }
461
- });
462
-
463
- return results;
464
- };
465
-
466
- exports.HTMLtoDOM = function(html, doc) {
467
- // There can be only one of these elements
468
- var one = {
469
- html: true,
470
- head: true,
471
- body: true,
472
- title: true
473
- };
474
-
475
- // Enforce a structure for the document
476
- var structure = {
477
- link: 'head',
478
- base: 'head'
479
- };
480
-
481
- if (doc) {
482
- doc = doc.ownerDocument || doc.getOwnerDocument && doc.getOwnerDocument() || doc;
483
- }
484
- else if (typeof DOMDocument !== 'undefined') {
485
- doc = new DOMDocument();
486
- }
487
- else if (typeof document !== 'undefined' && document.implementation && document.implementation.createDocument) {
488
- doc = document.implementation.createDocument('', '', null);
489
- }
490
- else if (typeof ActiveX !== 'undefined') {
491
- doc = new ActiveXObject('Msxml.DOMDocument');
492
- }
493
1220
 
494
- var elems = [],
495
- documentElement = doc.documentElement ||
496
- doc.getDocumentElement && doc.getDocumentElement();
497
-
498
- // If we're dealing with an empty document then we
499
- // need to pre-populate it with the HTML document structure
500
- if (!documentElement && doc.createElement) {
501
- (function() {
502
- var html = doc.createElement('html');
503
- var head = doc.createElement('head');
504
- head.appendChild(doc.createElement('title'));
505
- html.appendChild(head);
506
- html.appendChild(doc.createElement('body'));
507
- doc.appendChild(html);
508
- })();
509
- }
510
-
511
- // Find all the unique elements
512
- if (doc.getElementsByTagName) {
513
- for (var i in one) {
514
- one[i] = doc.getElementsByTagName(i)[0];
515
- }
516
- }
1221
+ function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
1222
+ docModeSettable = false;
1223
+ let stackIndex;
1224
+ const lowerTagName = tagName ? tagName.toLowerCase() : '';
517
1225
 
518
- // If we're working with a document, inject contents into
519
- // the body element
520
- var curParentNode = one.body;
1226
+ // As with the start tags that break out of foreign content
1227
+ if (tag && (lowerTagName === 'p' || lowerTagName === 'br') && namespaceInside(stack[stack.length - 1])) {
1228
+ closeForeignContent();
1229
+ }
521
1230
 
522
- new HTMLParser(html, {
523
- start: function(tagName, attrs, unary) {
524
- // If it's a pre-built element, then we can ignore
525
- // its construction
526
- if (one[tagName]) {
527
- curParentNode = one[tagName];
528
- return;
1231
+ // Find the closest opened tag of the same type
1232
+ if (tagName) {
1233
+ stackIndex = findTag(lowerTagName);
1234
+ } else { // If no tag name is provided, clean shop
1235
+ stackIndex = 0;
529
1236
  }
530
1237
 
531
- var elem = doc.createElement(tagName);
1238
+ // Whatever follows the name, which browsers drop but a custom fragment may stand in for
1239
+ const rest = tag.length > tagName.length + 3 ? tag.slice(tagName.length + 2, -1) : '';
532
1240
 
533
- for (var attr in attrs) {
534
- elem.setAttribute(attrs[attr].name, attrs[attr].value);
535
- }
1241
+ if (stackIndex >= 0) {
1242
+ // Close all the open elements, up the stack
1243
+ for (let i = stack.length - 1; i >= stackIndex; i--) {
1244
+ if (handler.end) {
1245
+ const entry = stack[i];
1246
+ handler.end(entry?.tag, entry?.attrs, i > stackIndex || !tag, i === stackIndex ? rest : '', entry?.lowerTag === 'p' && pHoldingTable.has(entry));
1247
+ }
1248
+ }
536
1249
 
537
- if (structure[tagName] && typeof one[structure[tagName]] !== 'boolean') {
538
- one[structure[tagName]].appendChild(elem);
539
- }
540
- else if (curParentNode && curParentNode.appendChild) {
541
- curParentNode.appendChild(elem);
1250
+ stack.length = stackIndex;
1251
+ stackVersion++;
1252
+ lastTag = stackIndex ? (stack[stackIndex - 1]?.tag ?? '') : '';
1253
+ lastTagLower = stackIndex ? (stack[stackIndex - 1]?.lowerTag ?? '') : '';
1254
+ } else if ((handler.partialMarkup || formattingElements.has(lowerTagName)) && tagName) {
1255
+ // In partial markup mode, preserve stray end tags, and those of formatting elements always
1256
+ if (handler.end) {
1257
+ handler.end(tagName, [], false, rest);
1258
+ }
1259
+ } else if (lowerTagName === 'br') {
1260
+ if (handler.start) {
1261
+ handler.start(tagName, [], true, '');
1262
+ }
1263
+ } else if (lowerTagName === 'p') {
1264
+ if (handler.start) {
1265
+ handler.start(tagName, [], false, '', true);
1266
+ }
1267
+ if (handler.end) {
1268
+ handler.end(tagName, []);
1269
+ }
1270
+ } else if (handler.strayEnd) {
1271
+ // An end tag with nothing to close is dropped
1272
+ handler.strayEnd();
542
1273
  }
543
-
544
- if (!unary) {
545
- elems.push(elem);
546
- curParentNode = elem;
547
- }
548
- },
549
- end: function(/* tag */) {
550
- elems.length -= 1;
551
-
552
- // Init the new parentNode
553
- curParentNode = elems[elems.length - 1];
554
- },
555
- chars: function(text) {
556
- curParentNode.appendChild(doc.createTextNode(text));
557
- },
558
- comment: function(/* text */) {
559
- // create comment node
560
- },
561
- ignore: function(/* text */) {
562
- // What to do here?
1274
+ // Returned so the parse loop can skip lowercasing the name again
1275
+ return lowerTagName;
563
1276
  }
564
- });
565
-
566
- return doc;
567
- };
1277
+ }
1278
+ }