html-minifier 3.5.21 → 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 -109
  3. package/cli.js +991 -206
  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 +2848 -1095
  37. package/src/htmlparser.js +1165 -450
  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 -39
  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,456 +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
  }
185
763
  }
186
764
 
187
- var text;
188
- if (textEnd >= 0) {
189
- text = html.substring(0, textEnd);
190
- html = html.substring(textEnd);
191
- }
192
- else {
193
- text = html;
194
- html = '';
765
+ if (pos === lastPos) {
766
+ if (handler.continueOnParseError) {
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;
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 ? '…' : ''}`);
195
786
  }
787
+ }
196
788
 
197
- // next tag
198
- var nextTagMatch = parseStartTag(html);
199
- if (nextTagMatch) {
200
- nextTag = nextTagMatch.tagName;
201
- }
202
- else {
203
- nextTagMatch = html.match(endTag);
204
- if (nextTagMatch) {
205
- nextTag = '/' + nextTagMatch[1];
206
- }
207
- else {
208
- nextTag = '';
789
+ if (!handler.partialMarkup) {
790
+ // Clean up any remaining tags
791
+ parseEndTag('', '');
792
+ }
793
+
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;
209
845
  }
846
+ nameEnd = startPos + open[0].length;
847
+ name = open[1] ?? '';
210
848
  }
849
+ const close = findEndTagClose(fullHtml, nameEnd);
850
+ return close === -1 ? null : { text: fullHtml.slice(startPos, close + 1), name };
851
+ }
211
852
 
212
- if (handler.chars) {
213
- 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);
214
861
  }
215
- prevTag = '';
216
-
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;
217
869
  }
218
- else {
219
- var stackedTag = lastTag.toLowerCase();
220
- var reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)</' + stackedTag + '[^>]*>', 'i'));
221
870
 
222
- html = html.replace(reStackedTag, function(all, text) {
223
- if (stackedTag !== 'script' && stackedTag !== 'style' && stackedTag !== 'noscript') {
224
- text = text
225
- .replace(/<!--([\s\S]*?)-->/g, '$1')
226
- .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;
227
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;
228
897
 
229
- if (handler.chars) {
230
- handler.chars(text);
231
- }
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
232
900
 
233
- return '';
234
- });
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
+ }
235
907
 
236
- parseEndTag('</' + stackedTag + '>', stackedTag);
237
- }
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
+ }
238
946
 
239
- if (html === last) {
240
- throw new Error('Parse Error: ' + html);
241
- }
242
- }
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
+ }
243
953
 
244
- if (!handler.partialMarkup) {
245
- // Clean up any remaining tags
246
- parseEndTag();
247
- }
954
+ if (!attr) {
955
+ break;
956
+ }
248
957
 
249
- function parseStartTag(input) {
250
- var start = input.match(startTagOpen);
251
- if (start) {
252
- var match = {
253
- tagName: start[1],
254
- attrs: []
255
- };
256
- input = input.slice(start[0].length);
257
- var end, attr;
258
- while (!(end = input.match(startTagClose)) && (attr = input.match(attribute))) {
259
- input = input.slice(attr[0].length);
958
+ const attrLen = attr[0].length;
959
+ currentPos += attrLen;
960
+ consumed += attrLen;
260
961
  match.attrs.push(attr);
261
962
  }
262
- if (end) {
263
- match.unarySlash = end[1];
264
- 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;
265
972
  return match;
266
973
  }
974
+ return undefined;
267
975
  }
268
- }
269
-
270
- function closeIfFound(tagName) {
271
- if (findTag(tagName) >= 0) {
272
- parseEndTag('', tagName);
273
- return true;
274
- }
275
- }
276
-
277
- function handleStartTag(match) {
278
- var tagName = match.tagName;
279
- var unarySlash = match.unarySlash;
280
976
 
281
- if (handler.html5) {
282
- if (lastTag === 'p' && nonPhrasing(tagName)) {
283
- parseEndTag('', lastTag);
284
- }
285
- else if (tagName === 'tbody') {
286
- closeIfFound('thead');
287
- }
288
- else if (tagName === 'tfoot') {
289
- if (!closeIfFound('tbody')) {
290
- 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;
291
988
  }
292
989
  }
293
- if (tagName === 'col' && findTag('colgroup') < 0) {
294
- lastTag = 'colgroup';
295
- stack.push({ tag: lastTag, attrs: [] });
296
- if (handler.start) {
297
- 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);
298
1000
  }
299
1001
  }
1002
+ stack.length = stackIndex;
1003
+ stackVersion++;
1004
+ lastTag = stackIndex ? (stack[stackIndex - 1]?.tag ?? '') : '';
1005
+ lastTagLower = stackIndex ? (stack[stackIndex - 1]?.lowerTag ?? '') : '';
300
1006
  }
301
1007
 
302
- if (!handler.html5 && !inline(tagName)) {
303
- while (lastTag && inline(lastTag)) {
304
- 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;
305
1014
  }
1015
+ return false;
306
1016
  }
307
1017
 
308
- if (closeSelf(tagName) && lastTag === tagName) {
309
- 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
+ }
310
1036
  }
311
1037
 
312
- 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
+ }
313
1086
 
314
- var attrs = match.attrs.map(function(args) {
315
- var name, value, customOpen, customClose, customAssign, quote;
316
- 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
+ }
317
1096
 
318
- // hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
319
- if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
320
- if (args[3] === '') { delete args[3]; }
321
- if (args[4] === '') { delete args[4]; }
322
- 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();
323
1101
  }
324
1102
 
325
- function populate(index) {
326
- customAssign = args[index];
327
- value = args[index + 1];
328
- if (typeof value !== 'undefined') {
329
- 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');
330
1118
  }
331
- value = args[index + 2];
332
- if (typeof value !== 'undefined') {
333
- return '\'';
1119
+ }
1120
+ if (lowerTagName === 'tbody') {
1121
+ if (!closeIfFoundInCurrentTable('tfoot')) {
1122
+ closeIfFoundInCurrentTable('thead');
334
1123
  }
335
- value = args[index + 3];
336
- if (typeof value === 'undefined' && fillAttrs(name)) {
337
- 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');
338
1131
  }
339
- return '';
340
1132
  }
341
-
342
- var j = 1;
343
- if (handler.customAttrSurround) {
344
- for (var i = 0, l = handler.customAttrSurround.length; i < l; i++, j += ncp) {
345
- name = args[j + 1];
346
- if (name) {
347
- quote = populate(j + 2);
348
- customOpen = args[j];
349
- customClose = args[j + 6];
350
- break;
351
- }
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);
352
1139
  }
1140
+ } else if (lowerTagName !== 'col' && lastTagLower === 'colgroup') {
1141
+ // Auto-close synthetic `<colgroup>` when a non-`col` element starts
1142
+ parseEndTag('', 'colgroup');
353
1143
  }
354
1144
 
355
- if (!name && (name = args[j])) {
356
- quote = populate(j + 1);
1145
+ if (closeSelf.has(lowerTagName) && lastTagLower === lowerTagName) {
1146
+ parseEndTag('', tagName);
357
1147
  }
358
1148
 
359
- return {
360
- name: name,
361
- value: value,
362
- customAssign: customAssign || '=',
363
- customOpen: customOpen || '',
364
- customClose: customClose || '',
365
- quote: quote || ''
366
- };
367
- });
368
-
369
- if (!unary) {
370
- stack.push({ tag: tagName, attrs: attrs });
371
- lastTag = tagName;
372
- unarySlash = '';
373
- }
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
+ }
374
1153
 
375
- if (handler.start) {
376
- handler.start(tagName, attrs, unary, unarySlash);
377
- }
378
- }
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;
379
1158
 
380
- function findTag(tagName) {
381
- var pos;
382
- var needle = tagName.toLowerCase();
383
- for (pos = stack.length - 1; pos >= 0; pos--) {
384
- if (stack[pos].tag.toLowerCase() === needle) {
385
- 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
+ }
386
1167
  }
387
- }
388
- return pos;
389
- }
390
1168
 
391
- function parseEndTag(tag, tagName) {
392
- var pos;
1169
+ // `prevAttrs` feeds whitespace collapsing
1170
+ prevAttrs = attrs;
393
1171
 
394
- // Find the closest opened tag of the same type
395
- if (tagName) {
396
- pos = findTag(tagName);
397
- }
398
- // If no tag name is provided, clean shop
399
- else {
400
- 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;
401
1175
  }
402
1176
 
403
- if (pos >= 0) {
404
- // Close all the open elements, up the stack
405
- for (var i = stack.length - 1; i >= pos; i--) {
406
- if (handler.end) {
407
- 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;
408
1190
  }
409
1191
  }
410
-
411
- // Remove the open elements from the stack
412
- stack.length = pos;
413
- lastTag = pos && stack[pos - 1].tag;
414
- }
415
- else if (tagName.toLowerCase() === 'br') {
416
- if (handler.start) {
417
- handler.start(tagName, [], true, '');
418
- }
1192
+ stack.push({ tag, lowerTag, attrs, namespace, pScope });
1193
+ stackVersion++;
1194
+ return namespace;
419
1195
  }
420
- else if (tagName.toLowerCase() === 'p') {
421
- if (handler.start) {
422
- handler.start(tagName, [], false, '', true);
423
- }
424
- if (handler.end) {
425
- 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
+ }
426
1204
  }
1205
+ return stackIndex;
427
1206
  }
428
- }
429
- }
430
1207
 
431
- exports.HTMLParser = HTMLParser;
432
- exports.HTMLtoXML = function(html) {
433
- var results = '';
434
-
435
- new HTMLParser(html, {
436
- start: function(tag, attrs, unary) {
437
- results += '<' + tag;
438
-
439
- for (var i = 0, len = attrs.length; i < len; i++) {
440
- results += ' ' + attrs[i].name + '="' + (attrs[i].value || '').replace(/"/g, '&#34;') + '"';
441
- }
442
-
443
- results += (unary ? '/' : '') + '>';
444
- },
445
- end: function(tag) {
446
- results += '</' + tag + '>';
447
- },
448
- chars: function(text) {
449
- results += text;
450
- },
451
- comment: function(text) {
452
- results += '<!--' + text + '-->';
453
- },
454
- ignore: function(text) {
455
- 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);
456
1219
  }
457
- });
458
-
459
- return results;
460
- };
461
-
462
- exports.HTMLtoDOM = function(html, doc) {
463
- // There can be only one of these elements
464
- var one = {
465
- html: true,
466
- head: true,
467
- body: true,
468
- title: true
469
- };
470
-
471
- // Enforce a structure for the document
472
- var structure = {
473
- link: 'head',
474
- base: 'head'
475
- };
476
-
477
- if (doc) {
478
- doc = doc.ownerDocument || doc.getOwnerDocument && doc.getOwnerDocument() || doc;
479
- }
480
- else if (typeof DOMDocument !== 'undefined') {
481
- doc = new DOMDocument();
482
- }
483
- else if (typeof document !== 'undefined' && document.implementation && document.implementation.createDocument) {
484
- doc = document.implementation.createDocument('', '', null);
485
- }
486
- else if (typeof ActiveX !== 'undefined') {
487
- doc = new ActiveXObject('Msxml.DOMDocument');
488
- }
489
1220
 
490
- var elems = [],
491
- documentElement = doc.documentElement ||
492
- doc.getDocumentElement && doc.getDocumentElement();
493
-
494
- // If we're dealing with an empty document then we
495
- // need to pre-populate it with the HTML document structure
496
- if (!documentElement && doc.createElement) {
497
- (function() {
498
- var html = doc.createElement('html');
499
- var head = doc.createElement('head');
500
- head.appendChild(doc.createElement('title'));
501
- html.appendChild(head);
502
- html.appendChild(doc.createElement('body'));
503
- doc.appendChild(html);
504
- })();
505
- }
506
-
507
- // Find all the unique elements
508
- if (doc.getElementsByTagName) {
509
- for (var i in one) {
510
- one[i] = doc.getElementsByTagName(i)[0];
511
- }
512
- }
1221
+ function parseEndTag(/** @type {string} */ tag, /** @type {string} */ tagName) {
1222
+ docModeSettable = false;
1223
+ let stackIndex;
1224
+ const lowerTagName = tagName ? tagName.toLowerCase() : '';
513
1225
 
514
- // If we're working with a document, inject contents into
515
- // the body element
516
- 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
+ }
517
1230
 
518
- new HTMLParser(html, {
519
- start: function(tagName, attrs, unary) {
520
- // If it's a pre-built element, then we can ignore
521
- // its construction
522
- if (one[tagName]) {
523
- curParentNode = one[tagName];
524
- 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;
525
1236
  }
526
1237
 
527
- 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) : '';
528
1240
 
529
- for (var attr in attrs) {
530
- elem.setAttribute(attrs[attr].name, attrs[attr].value);
531
- }
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
+ }
532
1249
 
533
- if (structure[tagName] && typeof one[structure[tagName]] !== 'boolean') {
534
- one[structure[tagName]].appendChild(elem);
535
- }
536
- else if (curParentNode && curParentNode.appendChild) {
537
- 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();
538
1273
  }
539
-
540
- if (!unary) {
541
- elems.push(elem);
542
- curParentNode = elem;
543
- }
544
- },
545
- end: function(/* tag */) {
546
- elems.length -= 1;
547
-
548
- // Init the new parentNode
549
- curParentNode = elems[elems.length - 1];
550
- },
551
- chars: function(text) {
552
- curParentNode.appendChild(doc.createTextNode(text));
553
- },
554
- comment: function(/* text */) {
555
- // create comment node
556
- },
557
- ignore: function(/* text */) {
558
- // What to do here?
1274
+ // Returned so the parse loop can skip lowercasing the name again
1275
+ return lowerTagName;
559
1276
  }
560
- });
561
-
562
- return doc;
563
- };
1277
+ }
1278
+ }