html-minifier 4.0.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +23 -0
- package/README.md +851 -110
- package/cli.js +991 -207
- package/dist/types/htmlminifier.d.ts +629 -0
- package/dist/types/htmlminifier.d.ts.map +1 -0
- package/dist/types/htmlparser.d.ts +28 -0
- package/dist/types/htmlparser.d.ts.map +1 -0
- package/dist/types/lib/attributes.d.ts +87 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +421 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +44 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +48 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/fragments.d.ts +47 -0
- package/dist/types/lib/fragments.d.ts.map +1 -0
- package/dist/types/lib/option-definitions.d.ts +33 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +93 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/unused-css.d.ts +76 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/urls.d.ts +15 -0
- package/dist/types/lib/urls.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +132 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +97 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts +50 -0
- package/dist/types/presets.d.ts.map +1 -0
- package/dist/types/tokenchain.d.ts +36 -0
- package/dist/types/tokenchain.d.ts.map +1 -0
- package/html-minifier.schema.json +322 -0
- package/package.json +71 -60
- package/src/htmlminifier.js +2862 -1106
- package/src/htmlparser.js +1163 -452
- package/src/lib/attributes.js +1196 -0
- package/src/lib/constants.js +521 -0
- package/src/lib/content.js +237 -0
- package/src/lib/elements.js +268 -0
- package/src/lib/file-pool.js +193 -0
- package/src/lib/file-write.js +87 -0
- package/src/lib/fragments.js +340 -0
- package/src/lib/option-definitions.js +246 -0
- package/src/lib/options.js +841 -0
- package/src/lib/unused-css.js +499 -0
- package/src/lib/urls.js +146 -0
- package/src/lib/utils.js +1026 -0
- package/src/lib/whitespace.js +431 -0
- package/src/presets.js +58 -0
- package/src/tokenchain.js +142 -56
- package/LICENSE +0 -22
- package/sample-cli-config-file.conf +0 -40
- 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
|
|
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
|
-
*
|
|
40
|
+
* Use like so:
|
|
41
|
+
*
|
|
10
42
|
* HTMLParser(htmlString, {
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
259
|
+
).map(function (assign) {
|
|
260
|
+
return '(?:' + embedSource(assign) + ')';
|
|
116
261
|
}).join('|');
|
|
117
262
|
}
|
|
118
263
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
765
|
+
if (pos === lastPos) {
|
|
187
766
|
if (handler.continueOnParseError) {
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
954
|
+
if (!attr) {
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
252
957
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
//
|
|
323
|
-
if (
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
1119
|
+
}
|
|
1120
|
+
if (lowerTagName === 'tbody') {
|
|
1121
|
+
if (!closeIfFoundInCurrentTable('tfoot')) {
|
|
1122
|
+
closeIfFoundInCurrentTable('thead');
|
|
338
1123
|
}
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 (
|
|
360
|
-
|
|
1145
|
+
if (closeSelf.has(lowerTagName) && lastTagLower === lowerTagName) {
|
|
1146
|
+
parseEndTag('', tagName);
|
|
361
1147
|
}
|
|
362
1148
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
396
|
-
|
|
1169
|
+
// `prevAttrs` feeds whitespace collapsing
|
|
1170
|
+
prevAttrs = attrs;
|
|
397
1171
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
//
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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
|
+
}
|