/
githubmirror
/
webpack
Обзор
Документация
Войти
/
githubmirror
/
webpack
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
lib/html/syntax.js
10 754 строки
384 KB
Alexander Akait
feat: configure or exclude each asset type's minifier via optimization.minimize (#21663)
10 авг 2026, 18:37
Не верифицирован
10 авг 2026, 18:37
5a8fa6c
Код
Авторство
О чём код?
/* MIT License http://www.opensource.org/licenses/mit-license.php Author Raj Aryan (based on SWC parser by Alexander Akait) */ "use strict"; const GenericSourceProcessor = require("../util/SourceProcessor"); /** @typedef {import("../css/syntax").CssEnvironment} CssEnvironment */ /** @typedef {import("../../declarations/WebpackOptions").OutputHtmlOptions} OutputHtmlOptions */ const { ADDRESS_DIV_P, APPLET_MARQUEE_OBJECT, BLOCK_END, BLOCK_START, BODY_HTML_BR, BOOLEAN_ATTRIBUTES, CAPTION_IGNORED_ENDS, CAPTION_TABLE_STARTS, CELL_IGNORED_ENDS, CLEAR_TABLE, CLEAR_TABLE_BODY, CLEAR_TABLE_ROW, COMMA_LIST_ATTRIBUTES, FONT_BREAKOUT_ATTRS, FOREIGN_ATTR_NS, FOREIGN_BREAKOUT, FORMATTING, HEADING, HEAD_BODY_HTML_BR, HEAD_ELEMENTS, HEAD_VOID_ELEMENTS, HTML_SCOPE, IGNORED_BODY_TABLE_STARTS, IMPLIED, IMPLIED_THOROUGH, INTEGER_ATTRIBUTES, IN_HEAD_NOSCRIPT_PASSTHROUGH, IN_TABLE_IGNORED_ENDS, JSON_SCRIPT_TYPES, LEADING_NEWLINE_ELEMENTS, LITERAL_TEXT_PARENTS, MATHML_SPECIAL, MATHML_TEXT_INTEGRATION, NOFRAMES_STYLE_NOSCRIPT, NO_DECODE_TEXT, OPTIONAL_END_TAG_AT_END, OPTIONAL_END_TAG_FOLLOWERS, OPTIONAL_END_TAG_UNLESS_TRAILING_NODE, PARAM_SOURCE_TRACK, P_KEEPS_END_TAG_IN, QUIRKY_EXACT, QUIRKY_PREFIXES, RAW_TEXT_ELEMENTS, ROW_IGNORED_ENDS, ROW_TRIGGER_STARTS, SIGNED_INTEGER_ATTRIBUTES, SPECIAL, SRCSET_ATTRIBUTES, STYLE_SCRIPT_TEMPLATE, SVG_ATTR_ADJUST, SVG_SPECIAL, SVG_TAG_ADJUST, TABLE_CONTEXT, TABLE_SCOPE_STOP, TBODY_GROUP, TBODY_IGNORED_ENDS, TBODY_TRIGGER_STARTS, TD_TH, TD_TH_TR, TOKEN_LIST_ATTRIBUTES, TRANSPARENT_IMPLIED_ELEMENTS, URL_ATTRIBUTES, VOID, VOID_FORMATTING } = require("./data"); // cspell:ignore apos notpre noncharacter noncharacters DFFF FFFE CCLS ALNUM // #region html entities // The contents of this region are auto-generated by // `tooling/generate-html-entities.js` from `tooling/html-entities.json`. // Do not edit by hand — re-run the generator (via `yarn fix:special`) to refresh. // // WHATWG named character references. Keys are entity names WITHOUT the // leading `&` (some end with `;`, others omit it for legacy entities that // match without a closing semicolon). Values are the decoded character // strings (1–2 UTF-16 code units). // Built on a null prototype so bracket lookups (`HTML_ENTITIES[name]`) // can't be poisoned by inherited `Object.prototype` keys like `toString`, // `constructor`, or `__proto__` — without this, `&toString;` would falsely // look like a matched named character reference. // prettier-ignore // cspell:disable-next-line const HTML_ENTITIES = /** @type {Readonly<Record<string, string>>} */ (Object.freeze(Object.assign(Object.create(null), {"AElig":"Æ","AElig;":"Æ","AMP":"&","AMP;":"&","Aacute":"Á","Aacute;":"Á","Abrev … [Строка слишком длинная. Вы можете скачать файл] // #endregion const STATE_DATA = 0; const STATE_TAG_OPEN = 1; const STATE_END_TAG_OPEN = 2; const STATE_TAG_NAME = 3; const STATE_BEFORE_ATTRIBUTE_NAME = 4; const STATE_ATTRIBUTE_NAME = 5; const STATE_AFTER_ATTRIBUTE_NAME = 6; const STATE_BEFORE_ATTRIBUTE_VALUE = 7; const STATE_ATTRIBUTE_VALUE_DOUBLE_QUOTED = 8; const STATE_ATTRIBUTE_VALUE_SINGLE_QUOTED = 9; const STATE_ATTRIBUTE_VALUE_UNQUOTED = 10; const STATE_AFTER_ATTRIBUTE_VALUE_QUOTED = 11; const STATE_SELF_CLOSING_START_TAG = 12; const STATE_MARKUP_DECLARATION_OPEN = 13; const STATE_COMMENT_START = 14; const STATE_COMMENT_START_DASH = 15; const STATE_COMMENT = 16; const STATE_COMMENT_END_DASH = 17; const STATE_COMMENT_END = 18; const STATE_COMMENT_END_BANG = 19; const STATE_BOGUS_COMMENT = 20; const STATE_COMMENT_LESS_THAN_SIGN = 21; const STATE_COMMENT_LESS_THAN_SIGN_BANG = 22; const STATE_COMMENT_LESS_THAN_SIGN_BANG_DASH = 23; const STATE_COMMENT_LESS_THAN_SIGN_BANG_DASH_DASH = 24; const STATE_DOCTYPE = 25; const STATE_BEFORE_DOCTYPE_NAME = 26; const STATE_DOCTYPE_NAME = 27; const STATE_AFTER_DOCTYPE_NAME = 28; const STATE_AFTER_DOCTYPE_PUBLIC_KEYWORD = 29; const STATE_BEFORE_DOCTYPE_PUBLIC_IDENTIFIER = 30; const STATE_DOCTYPE_PUBLIC_IDENTIFIER_DOUBLE_QUOTED = 31; const STATE_DOCTYPE_PUBLIC_IDENTIFIER_SINGLE_QUOTED = 32; const STATE_AFTER_DOCTYPE_PUBLIC_IDENTIFIER = 33; const STATE_BETWEEN_DOCTYPE_PUBLIC_AND_SYSTEM_IDENTIFIERS = 34; const STATE_AFTER_DOCTYPE_SYSTEM_KEYWORD = 35; const STATE_BEFORE_DOCTYPE_SYSTEM_IDENTIFIER = 36; const STATE_DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED = 37; const STATE_DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED = 38; const STATE_AFTER_DOCTYPE_SYSTEM_IDENTIFIER = 39; const STATE_BOGUS_DOCTYPE = 40; const STATE_CDATA_SECTION = 41; const STATE_CDATA_SECTION_BRACKET = 42; const STATE_CDATA_SECTION_END = 43; const STATE_RCDATA = 44; const STATE_RCDATA_LESS_THAN_SIGN = 45; const STATE_RCDATA_END_TAG_OPEN = 46; const STATE_RCDATA_END_TAG_NAME = 47; const STATE_RAWTEXT = 48; const STATE_RAWTEXT_LESS_THAN_SIGN = 49; const STATE_RAWTEXT_END_TAG_OPEN = 50; const STATE_RAWTEXT_END_TAG_NAME = 51; const STATE_SCRIPT_DATA = 52; const STATE_SCRIPT_DATA_LESS_THAN_SIGN = 53; const STATE_SCRIPT_DATA_END_TAG_OPEN = 54; const STATE_SCRIPT_DATA_END_TAG_NAME = 55; const STATE_SCRIPT_DATA_ESCAPE_START = 56; const STATE_SCRIPT_DATA_ESCAPE_START_DASH = 57; const STATE_SCRIPT_DATA_ESCAPED = 58; const STATE_SCRIPT_DATA_ESCAPED_DASH = 59; const STATE_SCRIPT_DATA_ESCAPED_DASH_DASH = 60; const STATE_SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN = 61; const STATE_SCRIPT_DATA_ESCAPED_END_TAG_OPEN = 62; const STATE_SCRIPT_DATA_ESCAPED_END_TAG_NAME = 63; const STATE_SCRIPT_DATA_DOUBLE_ESCAPE_START = 64; const STATE_SCRIPT_DATA_DOUBLE_ESCAPED = 65; const STATE_SCRIPT_DATA_DOUBLE_ESCAPED_DASH = 66; const STATE_SCRIPT_DATA_DOUBLE_ESCAPED_DASH_DASH = 67; const STATE_SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN = 68; const STATE_SCRIPT_DATA_DOUBLE_ESCAPE_END = 69; const STATE_PLAINTEXT = 70; // https://html.spec.whatwg.org/multipage/parsing.html#character-reference-state const STATE_CHARACTER_REFERENCE = 71; // https://html.spec.whatwg.org/multipage/parsing.html#named-character-reference-state const STATE_NAMED_CHARACTER_REFERENCE = 72; // https://html.spec.whatwg.org/multipage/parsing.html#ambiguous-ampersand-state const STATE_AMBIGUOUS_AMPERSAND = 73; // https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-state const STATE_NUMERIC_CHARACTER_REFERENCE = 74; // https://html.spec.whatwg.org/multipage/parsing.html#hexadecimal-character-reference-start-state const STATE_HEXADECIMAL_CHARACTER_REFERENCE_START = 75; // https://html.spec.whatwg.org/multipage/parsing.html#decimal-character-reference-start-state const STATE_DECIMAL_CHARACTER_REFERENCE_START = 76; // https://html.spec.whatwg.org/multipage/parsing.html#hexadecimal-character-reference-state const STATE_HEXADECIMAL_CHARACTER_REFERENCE = 77; // https://html.spec.whatwg.org/multipage/parsing.html#decimal-character-reference-state const STATE_DECIMAL_CHARACTER_REFERENCE = 78; // https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state const STATE_NUMERIC_CHARACTER_REFERENCE_END = 79; const CC_TAB = 0x09; const CC_LF = 0x0a; const CC_FF = 0x0c; const CC_CR = 0x0d; const CC_SPACE = 0x20; const CC_NULL = 0x00; const CC_EXCLAMATION_MARK = 0x21; const CC_QUOTATION_MARK = 0x22; const CC_NUMBER_SIGN = 0x23; const CC_AMPERSAND = 0x26; const CC_APOSTROPHE = 0x27; const CC_HYPHEN_MINUS = 0x2d; const CC_SOLIDUS = 0x2f; const CC_SEMICOLON = 0x3b; const CC_LESS_THAN = 0x3c; const CC_EQUALS = 0x3d; const CC_GREATER_THAN = 0x3e; const CC_QUESTION_MARK = 0x3f; const CC_LEFT_SQUARE_BRACKET = 0x5b; const CC_RIGHT_SQUARE_BRACKET = 0x5d; const CC_GRAVE_ACCENT = 0x60; const CC_NO_BREAK_SPACE = 0xa0; const QUOTE_DOUBLE = 1; const QUOTE_SINGLE = 2; const QUOTE_NONE = 0; // Longest WHATWG named entity name *including* the trailing `;` is 32 chars // (`CounterClockwiseContourIntegral;`); without the trailing `;` it's 31. // Used to cap both the tokenizer's named-character-reference run length and // the decoder's longest-prefix backtrack so pathological inputs (e.g. `&` // followed by thousands of alphanumerics) stay linear-time. const MAX_ENTITY_NAME_LEN = 32; // ASCII character-class bit flags packed into one lookup table. The tokenizer // runs these predicates per code point (tag names, attribute names, character // references, whitespace skipping), so a single table load + mask replaces the // per-call comparison chains. Code points >= 0x80 are never in any of these // classes, so callers short-circuit on `cc < 0x80` before indexing. const CCLS_SPACE = 1; const CCLS_DIGIT = 2; const CCLS_UPPER = 4; const CCLS_LOWER = 8; const CCLS_HEX = 16; // Terminator sets of the tag-name / attribute-name states' fast-forward scans, // mirroring those states' arcs; one mask test replaces their compare chains. const CCLS_TAG_NAME_TERM = 32; const CCLS_ATTR_NAME_TERM = 64; const CCLS_ALPHA = CCLS_UPPER | CCLS_LOWER; const CCLS_ALNUM = CCLS_ALPHA | CCLS_DIGIT; const CHAR_CLASS = new Uint8Array(128); for (let i = 0; i < 128; i++) { let f = 0; if ( i === CC_TAB || i === CC_LF || i === CC_FF || i === CC_CR || i === CC_SPACE ) { f |= CCLS_SPACE; } if (i >= 0x30 && i <= 0x39) f |= CCLS_DIGIT | CCLS_HEX; if (i >= 0x41 && i <= 0x5a) f |= CCLS_UPPER; if (i >= 0x61 && i <= 0x7a) f |= CCLS_LOWER; if ((i >= 0x41 && i <= 0x46) || (i >= 0x61 && i <= 0x66)) f |= CCLS_HEX; if ( (f & CCLS_SPACE) !== 0 || i === CC_SOLIDUS || i === CC_GREATER_THAN || i === CC_NULL ) { f |= CCLS_TAG_NAME_TERM | CCLS_ATTR_NAME_TERM; } if ( i === CC_EQUALS || i === CC_QUOTATION_MARK || i === CC_APOSTROPHE || i === CC_LESS_THAN ) { f |= CCLS_ATTR_NAME_TERM; } CHAR_CLASS[i] = f; } /** * @param {number} cc character code * @returns {boolean} is ascii alpha */ const isAsciiAlpha = (cc) => cc < 0x80 && (CHAR_CLASS[cc] & CCLS_ALPHA) !== 0; /** * @param {number} cc character code * @returns {boolean} is ascii alphanumeric */ const isAsciiAlphanumeric = (cc) => cc < 0x80 && (CHAR_CLASS[cc] & CCLS_ALNUM) !== 0; /** * @param {number} cc character code * @returns {boolean} is ascii digit */ const isAsciiDigit = (cc) => cc < 0x80 && (CHAR_CLASS[cc] & CCLS_DIGIT) !== 0; /** * @param {number} cc character code * @returns {boolean} is ascii hex digit */ const isAsciiHexDigit = (cc) => cc < 0x80 && (CHAR_CLASS[cc] & CCLS_HEX) !== 0; /** * @param {number} cc character code * @returns {boolean} is ascii upper alpha */ const isAsciiUpperAlpha = (cc) => cc < 0x80 && (CHAR_CLASS[cc] & CCLS_UPPER) !== 0; /** * @param {number} cc character code * @returns {boolean} is ascii lower alpha */ const isAsciiLowerAlpha = (cc) => cc < 0x80 && (CHAR_CLASS[cc] & CCLS_LOWER) !== 0; /** * Tokenizer whitespace. U+000D CARRIAGE RETURN is included because the spec's * input-stream preprocessing converts CR (and CRLF) to LF before tokenizing; * this scanner keeps the original offsets, so it treats a raw CR as whitespace * to match the post-preprocessing behaviour. * @param {number} cc character code * @returns {boolean} is space */ const isSpace = (cc) => cc < 0x80 && (CHAR_CLASS[cc] & CCLS_SPACE) !== 0; /** * @param {number} code numeric character reference code point * @returns {boolean} is a Unicode noncharacter */ const isNoncharacter = (code) => (code >= 0xfdd0 && code <= 0xfdef) || (code & 0xfffe) === 0xfffe; /** * @param {number} code numeric character reference code point * @returns {boolean} is a C0/C1 control that is not ASCII whitespace */ const isControlReference = (code) => code === 0x0d || ((code <= 0x1f || (code >= 0x7f && code <= 0x9f)) && code !== CC_TAB && code !== CC_LF && code !== CC_FF && code !== CC_SPACE); /** * Severity of a tokenizer-detected parse error. `"warning"` is recoverable * (the tokenizer continued and the emitted token is still well-formed, e.g. * missing-attribute-value); `"error"` means the emitted token's offset * range is incomplete or does not match what the spec would produce, e.g. * eof-in-tag. * * Token offsets are JS string indices (UTF-16 code-unit offsets into * `input`), not byte offsets — relevant for inputs containing non-BMP * code points where one code point spans two indices. * @typedef {"warning" | "error"} ParseErrorSeverity */ /** * @typedef {object} HtmlTokenCallbacks * @property {(input: string, start: number, end: number, nameStart: number, nameEnd: number, selfClosing: boolean) => number=} openTag * @property {(input: string, start: number, end: number, nameStart: number, nameEnd: number) => number=} closeTag * @property {(input: string, start: number, end: number) => number=} text * @property {(input: string, nameStart: number, nameEnd: number, valueStart: number, valueEnd: number, quoteType: number) => number=} attribute * @property {(input: string, start: number, end: number, dataStart: number, dataEnd: number) => number=} comment * @property {(input: string, start: number, end: number, nameStart: number, nameEnd: number, publicStart: number, publicEnd: number, systemStart: number, systemEnd: number, forceQuirks: boolean) => number=} doctype * @property {(input: string, code: string, start: number, end: number, severity: ParseErrorSeverity) => void=} parseError * @property {(() => boolean)=} isForeign returns true when the adjusted current node is in a foreign (SVG/MathML) namespace, vetoing RAWTEXT/RCDATA/script content-mode switches * @property {string=} fragmentContext context element tag name for fragment parsing; seeds the initial content mode */ /** * @param {string} name tag name (lowercase) * @returns {number} content mode state for this tag, or STATE_DATA */ const getContentModeForTag = (name) => { switch (name) { case "textarea": case "title": return STATE_RCDATA; case "style": case "xmp": case "iframe": case "noembed": case "noframes": return STATE_RAWTEXT; case "script": return STATE_SCRIPT_DATA; case "plaintext": return STATE_PLAINTEXT; default: return STATE_DATA; } }; /** * Case-insensitive comparison of `input[start..end)` to a lowercase ASCII * literal, without allocating the slice. * @param {string} input input * @param {number} start range start * @param {number} end range end * @param {string} lit lowercase ASCII literal * @returns {boolean} true if the range equals `lit` ignoring ASCII case */ const rangeEqualsLowerCase = (input, start, end, lit) => { if (end - start !== lit.length) return false; for (let i = 0; i < lit.length; i++) { let c = input.charCodeAt(start + i); if (c >= 0x41 && c <= 0x5a) c += 0x20; if (c !== lit.charCodeAt(i)) return false; } return true; }; /** * Content mode for the just-opened tag whose name spans `input[start..end)`, * matched on the raw range so ordinary tags need neither a slice nor a * `toLowerCase`. Mirrors `getContentModeForTag`. * @param {string} input input * @param {number} start tag-name start * @param {number} end tag-name end * @returns {number} content mode state, or STATE_DATA */ const getContentModeForRange = (input, start, end) => { switch (end - start) { case 3: if (rangeEqualsLowerCase(input, start, end, "xmp")) return STATE_RAWTEXT; return STATE_DATA; case 5: if (rangeEqualsLowerCase(input, start, end, "title")) return STATE_RCDATA; if (rangeEqualsLowerCase(input, start, end, "style")) { return STATE_RAWTEXT; } return STATE_DATA; case 6: if (rangeEqualsLowerCase(input, start, end, "script")) { return STATE_SCRIPT_DATA; } if (rangeEqualsLowerCase(input, start, end, "iframe")) { return STATE_RAWTEXT; } return STATE_DATA; case 7: if (rangeEqualsLowerCase(input, start, end, "noembed")) { return STATE_RAWTEXT; } return STATE_DATA; case 8: if (rangeEqualsLowerCase(input, start, end, "textarea")) { return STATE_RCDATA; } if (rangeEqualsLowerCase(input, start, end, "noframes")) { return STATE_RAWTEXT; } return STATE_DATA; case 9: if (rangeEqualsLowerCase(input, start, end, "plaintext")) { return STATE_PLAINTEXT; } return STATE_DATA; default: return STATE_DATA; } }; /** * @param {string} input input string * @param {number} pos current position * @param {HtmlTokenCallbacks} callbacks callbacks * @returns {number} final position */ const tokenize = (input, pos = 0, callbacks = {}) => { const len = input.length; let state = STATE_DATA; let returnState = STATE_DATA; let textStart = pos; let tagStart = pos; let tagNameStart = -1; let tagNameEnd = -1; let attributeNameStart = -1; let attributeNameEnd = -1; let attributeValueStart = -1; let attrQuoteType = QUOTE_NONE; let commentStart = pos; // Data range of the comment being scanned. The comment states append // characters that are not the ones just consumed (the `--` a `-->` inside a // comment turns into, the `--!` of a comment-end-bang), so the range is // tracked here rather than re-derived by trimming delimiters off the token. let commentDataStart = pos; let commentDataEnd = pos; // Sub-ranges of the DOCTYPE token being scanned (-1 = the spec's // "missing" value, which a consumer must tell apart from an empty one). let doctypeNameStart = -1; let doctypeNameEnd = -1; let doctypePublicStart = -1; let doctypePublicEnd = -1; let doctypeSystemStart = -1; let doctypeSystemEnd = -1; let doctypeForceQuirks = false; let lastOpenTagName = ""; // Tag-name offsets of the last open tag; the lowercased `lastOpenTagName` is // derived from these lazily (only for special-content tags). let lastOpenTagStart = -1; let lastOpenTagEnd = -1; // Counter used by SCRIPT_DATA_DOUBLE_ESCAPE_{START,END} to detect whether // the ASCII-alpha run after `<` / `</` spells exactly `"script"`. Values // 0..6 = number of chars matched so far; 7 = no longer matches (sentinel). // Avoids growing a buffer for pathological inputs with long alpha runs. let scriptMatch = 0; let namedEntityConsumed = 0; // Offset of the opening `&` and the running numeric value (clamped past the // Unicode range so it can't overflow); used for numeric-reference errors. let charRefStart = -1; let charRefCode = 0; // Tracks whether the current tag has parsed any attributes — used to // fire the `end-tag-with-attributes` parse error when an end tag emits. let tagHasAttributes = false; // Memoized next occurrence of `<` / `&` / NUL for the text-run // fast-forwards: native `indexOf` beats a per-char JS loop, and `pos` only // moves forward, so each memo is refreshed at most once per occurrence // (`len` = no further occurrence). let nextLt = -1; let nextAmp = -1; let nextNul = -1; let nextHyphen = -1; let nextGt = -1; // Same memo scheme for the closing quote of quoted attribute values (long // values: data: URIs, srcset, inline style). Memoized so a value with many // `&` references doesn't re-run the quote scan per reference. let nextDQuote = -1; let nextSQuote = -1; /** * Reports a tokenizer parse error to the consumer. The offset range and * severity follow the WHATWG spec naming. Severity is `"error"` for * cases where the emitted token is incomplete (EOF inside a tag or * comment); everything else is a `"warning"`. Offsets are JS string * indices (UTF-16 code-unit offsets into `input`). * @param {string} code WHATWG parse-error code (kebab-case) * @param {number} start string offset where the error starts * @param {number} end string offset where the error ends * @param {ParseErrorSeverity} severity error severity */ const reportError = (code, start, end, severity) => { if (callbacks.parseError !== undefined) { callbacks.parseError(input, code, start, end, severity); } }; /** * Emits the WHATWG numeric-character-reference validation parse error for * the accumulated `charRefCode`, if any. Used both inline (when the * reference is terminated by a real next character) and at EOF (when the * reference runs to the end of input). The scanner only flags the error — * the spec's U+FFFD / Windows-1252 substitution is done by `decodeEntities`. * @param {number} endPos offset just past the reference */ const validateNumericReference = (endPos) => { if (charRefCode === 0) { reportError("null-character-reference", charRefStart, endPos, "warning"); } else if (charRefCode > 0x10ffff) { reportError( "character-reference-outside-unicode-range", charRefStart, endPos, "warning" ); } else if (charRefCode >= 0xd800 && charRefCode <= 0xdfff) { reportError( "surrogate-character-reference", charRefStart, endPos, "warning" ); } else if (isNoncharacter(charRefCode)) { reportError( "noncharacter-character-reference", charRefStart, endPos, "warning" ); } else if (isControlReference(charRefCode)) { reportError( "control-character-reference", charRefStart, endPos, "warning" ); } }; // Content mode for the tag just opened (name at `lastOpenTagStart..End`). In // foreign content (SVG/MathML) the tree builder vetoes RAWTEXT/RCDATA/script // switching via `isForeign`, so e.g. an SVG `<title>`/`<style>` is parsed as // normal markup. `lastOpenTagName` (the lowercased name compared by the // special end-tag states) is materialized only when a special mode is // actually entered — ordinary tags never allocate it. const contentModeAfterOpenTag = () => { const m = getContentModeForRange(input, lastOpenTagStart, lastOpenTagEnd); // Ordinary tags stay in data state regardless of `isForeign` (which only // vetoes a switch *into* a special mode), so skip the per-open-tag // `isForeign` callback for them. if (m === STATE_DATA) return STATE_DATA; if (callbacks.isForeign !== undefined && callbacks.isForeign()) { return STATE_DATA; } lastOpenTagName = input .slice(lastOpenTagStart, lastOpenTagEnd) .toLowerCase(); return m; }; // HTML fragment parsing: seed the tokenizer with the context element's // content mode (e.g. a `textarea`/`style`/`script` context starts in // RCDATA/RAWTEXT/script-data rather than data state). if (callbacks.fragmentContext !== undefined) { lastOpenTagName = callbacks.fragmentContext; state = callbacks.isForeign !== undefined && callbacks.isForeign() ? STATE_DATA : getContentModeForTag(lastOpenTagName); } /** * @param {number} endPos end position */ const flushText = (endPos) => { if (textStart < endPos) { if (callbacks.text !== undefined) { callbacks.text(input, textStart, endPos); } // Advance `textStart` so a second `flushText` for the same span // (e.g. from the EOF handler after a tag-open transition already // flushed the pending text) is a no-op rather than a duplicate // emit. emitOpenTag / emitCloseTag overwrite `textStart` with // their own `nextPos` anyway, so this doesn't shift their start. textStart = endPos; } }; /** * @param {number} endPos end position * @returns {number} next position */ const emitAttribute = (endPos) => { // Default `nextPos` advances past the closing quote (if any) so the // state machine can continue when no `attribute` callback is provided. // When a callback IS provided, its return value overrides the default — // the callback is expected to do the same advance based on the // reported `quoteType`. let nextPos = attrQuoteType === QUOTE_NONE ? endPos : endPos + 1; if (callbacks.attribute !== undefined && attributeNameStart !== -1) { nextPos = callbacks.attribute( input, attributeNameStart, attributeNameEnd, attributeValueStart, attributeValueStart === -1 ? -1 : endPos, attrQuoteType ); } if (attributeNameStart !== -1) tagHasAttributes = true; attributeNameStart = -1; attributeValueStart = -1; attrQuoteType = QUOTE_NONE; return nextPos; }; /** * @param {number} endPos end position * @param {boolean} selfClosing is self closing * @returns {number} next position */ const emitOpenTag = (endPos, selfClosing) => { let nextPos = endPos; if (callbacks.openTag !== undefined) { nextPos = callbacks.openTag( input, tagStart, endPos, tagNameStart, tagNameEnd, selfClosing ); } if (!selfClosing) { // Record offsets only; `contentModeAfterOpenTag` lowercases lazily. lastOpenTagStart = tagNameStart; lastOpenTagEnd = tagNameEnd; } tagHasAttributes = false; textStart = nextPos; return nextPos; }; /** * @param {number} endPos end position * @returns {number} next position */ const emitCloseTag = (endPos) => { // Per WHATWG: an end tag emitted with attributes is a parse error. if (tagHasAttributes) { reportError("end-tag-with-attributes", tagStart, endPos, "warning"); } let nextPos = endPos; if (callbacks.closeTag !== undefined) { nextPos = callbacks.closeTag( input, tagStart, endPos, tagNameStart, tagNameEnd ); } tagHasAttributes = false; textStart = nextPos; return nextPos; }; while (pos < len) { const cc = input.charCodeAt(pos); // All WHATWG tokenizer states handled. Deliberately omitted parse errors // (need state this offset scanner lacks): duplicate-attribute, // cdata-in-html-content, `*-in-input-stream`. Reference substitution is // left to `decodeEntities`. switch (state) { // https://html.spec.whatwg.org/multipage/parsing.html#data-state case STATE_DATA: // Consume the next input character: // U+003C LESS-THAN SIGN (<) // Set the return state to the data state. Switch to the tag open state. if (cc === CC_LESS_THAN) { tagStart = pos; state = STATE_TAG_OPEN; pos++; } else if (cc === CC_AMPERSAND) { // U+0026 AMPERSAND (&) // Set the return state to the data state. Switch to the // character reference state. returnState = STATE_DATA; state = STATE_CHARACTER_REFERENCE; pos++; } else if (cc === CC_NULL) { // U+0000 NULL: unexpected-null-character (the data state // emits the NULL as-is; the scanner only flags the error). reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; } else { // Fast-forward over the run of ordinary text without re-entering // the per-state switch — batches only the data state's "Anything // else — emit the current input character" arc; the stops are // exactly the code points with their own arcs (`<`, `&`, NUL). pos++; if (nextLt < pos) { nextLt = input.indexOf("<", pos); if (nextLt === -1) nextLt = len; } if (nextAmp < pos) { nextAmp = input.indexOf("&", pos); if (nextAmp === -1) nextAmp = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextLt, nextAmp, nextNul); // Fused: the memo invariant guarantees `input[nextLt] === "<"`, so // take this state's `<` arc without another dispatch. if (pos === nextLt && pos < len) { tagStart = pos; state = STATE_TAG_OPEN; pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state case STATE_TAG_OPEN: // Consume the next input character: // U+002F SOLIDUS (/) // Switch to the end tag open state. if (cc === CC_SOLIDUS) { state = STATE_END_TAG_OPEN; pos++; // Fused: when the next char is alpha, take the end-tag-open state's // alpha arc (and its tag-name run scan) without another dispatch. if (pos < len && isAsciiAlpha(input.charCodeAt(pos))) { flushText(tagStart); tagNameStart = pos; state = STATE_TAG_NAME; pos++; while (pos < len) { const c2 = input.charCodeAt(pos); if (c2 < 0x80 && (CHAR_CLASS[c2] & CCLS_TAG_NAME_TERM) !== 0) { break; } pos++; } } } else if (cc === CC_EXCLAMATION_MARK) { // U+0021 EXCLAMATION MARK (!) // Switch to the markup declaration open state. flushText(tagStart); commentStart = tagStart; state = STATE_MARKUP_DECLARATION_OPEN; pos++; } else if (isAsciiAlpha(cc)) { // ASCII alpha // Create a new start tag token, set its tag name to the empty string. // Reconsume in the tag name state. flushText(tagStart); tagNameStart = pos; state = STATE_TAG_NAME; // Fused reconsume: the first char is alpha, so the tag-name state // always lands in its run scan — run it here without a dispatch. pos++; while (pos < len) { const c2 = input.charCodeAt(pos); if (c2 < 0x80 && (CHAR_CLASS[c2] & CCLS_TAG_NAME_TERM) !== 0) { break; } pos++; } } else if (cc === CC_QUESTION_MARK) { // U+003F QUESTION MARK (?) // This is an unexpected-question-mark-instead-of-tag-name parse error. // Create a comment token whose data is the empty string. Reconsume in the // bogus comment state. reportError( "unexpected-question-mark-instead-of-tag-name", pos, pos + 1, "warning" ); flushText(tagStart); commentStart = tagStart; commentDataStart = pos; commentDataEnd = pos; state = STATE_BOGUS_COMMENT; // Reconsume — let the bogus-comment state consume the `?` // itself, matching the spec. } else { // Anything else // This is an invalid-first-character-of-tag-name parse error. Emit a U+003C // LESS-THAN SIGN character token. Reconsume in the data state. reportError( "invalid-first-character-of-tag-name", pos, pos + 1, "warning" ); state = STATE_DATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#end-tag-open-state case STATE_END_TAG_OPEN: // Consume the next input character. The spec's ASCII-alpha arc (create // an end tag token, reconsume in the tag name state) is taken inline by // the `<` `/` arc above, the only way into this state, so `cc` is never // ASCII alpha here. if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is a missing-end-tag-name parse error. Switch to the data state. // No token is emitted, so `</>` is dropped rather than left in the // text run: a browser renders nothing for it, and keeping it would // resurface as visible text once the text node is re-escaped. reportError("missing-end-tag-name", pos, pos + 1, "warning"); flushText(tagStart); state = STATE_DATA; pos++; textStart = pos; } else { // Anything else // This is an invalid-first-character-of-tag-name parse error. Create a // comment token whose data is the empty string. Reconsume in the bogus // comment state. reportError( "invalid-first-character-of-tag-name", pos, pos + 1, "warning" ); flushText(tagStart); commentStart = tagStart; commentDataStart = pos; commentDataEnd = pos; state = STATE_BOGUS_COMMENT; // Reconsume — let bogus-comment consume this char itself. } break; // https://html.spec.whatwg.org/multipage/parsing.html#tag-name-state case STATE_TAG_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Switch to the before attribute name state. if (isSpace(cc)) { tagNameEnd = pos; state = STATE_BEFORE_ATTRIBUTE_NAME; pos++; } else if (cc === CC_SOLIDUS) { // U+002F SOLIDUS (/) // Switch to the self-closing start tag state. tagNameEnd = pos; state = STATE_SELF_CLOSING_START_TAG; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current tag token. tagNameEnd = pos; if (input.charCodeAt(tagStart + 1) === CC_SOLIDUS) { state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { const nextPos = emitOpenTag(pos + 1, false); state = nextPos > pos + 1 ? STATE_DATA : contentModeAfterOpenTag(); pos = nextPos; } } else { // U+0000 NULL: unexpected-null-character (append U+FFFD). if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } // Fast-forward over the ordinary run of the tag name. pos++; while (pos < len) { const c2 = input.charCodeAt(pos); if (c2 < 0x80 && (CHAR_CLASS[c2] & CCLS_TAG_NAME_TERM) !== 0) { break; } pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state case STATE_BEFORE_ATTRIBUTE_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. // Reconsume so space is handled in BEFORE_ATTRIBUTE_NAME if (isSpace(cc)) { pos++; } else if (cc === CC_SOLIDUS || cc === CC_GREATER_THAN) { // U+002F SOLIDUS (/) // U+003E GREATER-THAN SIGN (>) // EOF // Reconsume in the after attribute name state. state = STATE_AFTER_ATTRIBUTE_NAME; // Reconsume } else if (cc === CC_EQUALS) { // U+003D EQUALS SIGN (=) // This is an unexpected-equals-sign-before-attribute-name parse // error. Start a new attribute. Switch to the attribute name state. reportError( "unexpected-equals-sign-before-attribute-name", pos, pos + 1, "warning" ); attributeNameStart = pos; state = STATE_ATTRIBUTE_NAME; pos++; } else { // Anything else // Start a new attribute in the current tag token. Set that attribute name // and value to the empty string. Reconsume in the attribute name state. attributeNameStart = pos; state = STATE_ATTRIBUTE_NAME; // Fused reconsume: `cc` can't be a terminator here (space / `/` / // `>` / `=` took earlier arcs), so run the attribute-name state's // else arc — its first-char errors and run scan — without a dispatch. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } else if ( cc === CC_QUOTATION_MARK || cc === CC_APOSTROPHE || cc === CC_LESS_THAN ) { reportError( "unexpected-character-in-attribute-name", pos, pos + 1, "warning" ); } pos++; while (pos < len) { const c2 = input.charCodeAt(pos); if (c2 < 0x80 && (CHAR_CLASS[c2] & CCLS_ATTR_NAME_TERM) !== 0) { break; } pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state case STATE_ATTRIBUTE_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // U+002F SOLIDUS (/) // U+003E GREATER-THAN SIGN (>) // EOF // Reconsume in the after attribute name state. if (isSpace(cc) || cc === CC_SOLIDUS || cc === CC_GREATER_THAN) { attributeNameEnd = pos; state = STATE_AFTER_ATTRIBUTE_NAME; // Reconsume } else if (cc === CC_EQUALS) { attributeNameEnd = pos; state = STATE_BEFORE_ATTRIBUTE_VALUE; pos++; } else { // NULL -> unexpected-null-character; `"` `'` `<` -> unexpected-character-in-attribute-name. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } else if ( cc === CC_QUOTATION_MARK || cc === CC_APOSTROPHE || cc === CC_LESS_THAN ) { reportError( "unexpected-character-in-attribute-name", pos, pos + 1, "warning" ); } // Fast-forward over the ordinary run of the attribute name; stop on // any terminator (space / `/` / `>` / `=`) or a char that needs a // per-occurrence parse error (NULL / `"` / `'` / `<`), which the // outer switch then re-handles. pos++; while (pos < len) { const c2 = input.charCodeAt(pos); if (c2 < 0x80 && (CHAR_CLASS[c2] & CCLS_ATTR_NAME_TERM) !== 0) { break; } pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#after-attribute-name-state case STATE_AFTER_ATTRIBUTE_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. if (isSpace(cc)) { pos++; } else if (cc === CC_SOLIDUS) { // U+002F SOLIDUS (/) // Switch to the self-closing start tag state. emitAttribute(pos); state = STATE_SELF_CLOSING_START_TAG; pos++; } else if (cc === CC_EQUALS) { // U+003D EQUALS SIGN (=) // Switch to the before attribute value state. state = STATE_BEFORE_ATTRIBUTE_VALUE; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current tag token. emitAttribute(pos); if (input.charCodeAt(tagStart + 1) === CC_SOLIDUS) { state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { const nextPos = emitOpenTag(pos + 1, false); state = nextPos > pos + 1 ? STATE_DATA : contentModeAfterOpenTag(); pos = nextPos; } } else { // Anything else // Start a new attribute in the current tag token. emitAttribute(pos); attributeNameStart = pos; state = STATE_ATTRIBUTE_NAME; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-value-state case STATE_BEFORE_ATTRIBUTE_VALUE: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. if (isSpace(cc)) { pos++; } else if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // Switch to the attribute value (double-quoted) state. attributeValueStart = pos + 1; attrQuoteType = QUOTE_DOUBLE; state = STATE_ATTRIBUTE_VALUE_DOUBLE_QUOTED; pos++; // Fused: run that state's memoized value scan now; a leading `"` / // `&` / NUL keeps the min at `pos` for the next dispatch. if (nextDQuote < pos) { nextDQuote = input.indexOf('"', pos); if (nextDQuote === -1) nextDQuote = len; } if (nextAmp < pos) { nextAmp = input.indexOf("&", pos); if (nextAmp === -1) nextAmp = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextDQuote, nextAmp, nextNul); } else if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // Switch to the attribute value (single-quoted) state. attributeValueStart = pos + 1; attrQuoteType = QUOTE_SINGLE; state = STATE_ATTRIBUTE_VALUE_SINGLE_QUOTED; pos++; // Fused: run that state's memoized value scan now; a leading `'` / // `&` / NUL keeps the min at `pos` for the next dispatch. if (nextSQuote < pos) { nextSQuote = input.indexOf("'", pos); if (nextSQuote === -1) nextSQuote = len; } if (nextAmp < pos) { nextAmp = input.indexOf("&", pos); if (nextAmp === -1) nextAmp = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextSQuote, nextAmp, nextNul); } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is a missing-attribute-value parse error. Switch to the data // state. Emit the current tag token. The attribute is reported with // an empty value range pointing at the `>` so the open-tag offset range // still includes the `>`. reportError("missing-attribute-value", pos, pos + 1, "warning"); attributeValueStart = pos; attrQuoteType = QUOTE_NONE; pos = emitAttribute(pos); if (input.charCodeAt(tagStart + 1) === CC_SOLIDUS) { state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { const nextPos = emitOpenTag(pos + 1, false); state = nextPos > pos + 1 ? STATE_DATA : contentModeAfterOpenTag(); pos = nextPos; } } else { // Anything else // Reconsume in the attribute value (unquoted) state. attributeValueStart = pos; attrQuoteType = QUOTE_NONE; state = STATE_ATTRIBUTE_VALUE_UNQUOTED; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(double-quoted)-state case STATE_ATTRIBUTE_VALUE_DOUBLE_QUOTED: // Consume the next input character: // U+0022 QUOTATION MARK (") // Switch to the after attribute value (quoted) state. if (cc === CC_QUOTATION_MARK) { pos = emitAttribute(pos); state = STATE_AFTER_ATTRIBUTE_VALUE_QUOTED; } else if (cc === CC_AMPERSAND) { // U+0026 AMPERSAND (&) // Set the return state to the attribute value (double-quoted) // state. Switch to the character reference state. returnState = STATE_ATTRIBUTE_VALUE_DOUBLE_QUOTED; state = STATE_CHARACTER_REFERENCE; pos++; } else if (cc === CC_NULL) { // U+0000 NULL: unexpected-null-character (append U+FFFD). reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; } else { // Fast-forward over the ordinary run of the quoted value with the // same memoized native-scan scheme as the data state. pos++; if (nextDQuote < pos) { nextDQuote = input.indexOf('"', pos); if (nextDQuote === -1) nextDQuote = len; } if (nextAmp < pos) { nextAmp = input.indexOf("&", pos); if (nextAmp === -1) nextAmp = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextDQuote, nextAmp, nextNul); } break; // https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(single-quoted)-state case STATE_ATTRIBUTE_VALUE_SINGLE_QUOTED: // Consume the next input character: // U+0027 APOSTROPHE (') // Switch to the after attribute value (quoted) state. if (cc === CC_APOSTROPHE) { pos = emitAttribute(pos); state = STATE_AFTER_ATTRIBUTE_VALUE_QUOTED; } else if (cc === CC_AMPERSAND) { // U+0026 AMPERSAND (&) // Set the return state to the attribute value (single-quoted) // state. Switch to the character reference state. returnState = STATE_ATTRIBUTE_VALUE_SINGLE_QUOTED; state = STATE_CHARACTER_REFERENCE; pos++; } else if (cc === CC_NULL) { // U+0000 NULL: unexpected-null-character (append U+FFFD). reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; } else { // Fast-forward over the ordinary run of the quoted value with the // same memoized native-scan scheme as the data state. pos++; if (nextSQuote < pos) { nextSQuote = input.indexOf("'", pos); if (nextSQuote === -1) nextSQuote = len; } if (nextAmp < pos) { nextAmp = input.indexOf("&", pos); if (nextAmp === -1) nextAmp = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextSQuote, nextAmp, nextNul); } break; // https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(unquoted)-state case STATE_ATTRIBUTE_VALUE_UNQUOTED: if (isSpace(cc)) { pos = emitAttribute(pos); state = STATE_BEFORE_ATTRIBUTE_NAME; // Reconsume so space is handled in BEFORE_ATTRIBUTE_NAME } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is a missing-attribute-value parse error. Switch to the data state. // Emit the current tag token. pos = emitAttribute(pos); if (input.charCodeAt(tagStart + 1) === CC_SOLIDUS) { state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { const nextPos = emitOpenTag(pos + 1, false); state = nextPos > pos + 1 ? STATE_DATA : contentModeAfterOpenTag(); pos = nextPos; } } else if (cc === CC_AMPERSAND) { // U+0026 AMPERSAND (&) // Set the return state to the attribute value (unquoted) // state. Switch to the character reference state. returnState = STATE_ATTRIBUTE_VALUE_UNQUOTED; state = STATE_CHARACTER_REFERENCE; pos++; } else { // NULL -> unexpected-null-character; `"` `'` `<` `=` `` ` `` -> unexpected-character-in-unquoted-attribute-value. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } else if ( cc === CC_QUOTATION_MARK || cc === CC_APOSTROPHE || cc === CC_LESS_THAN || cc === CC_EQUALS || cc === CC_GRAVE_ACCENT ) { reportError( "unexpected-character-in-unquoted-attribute-value", pos, pos + 1, "warning" ); } pos++; } break; // https://html.spec.whatwg.org/multipage/parsing.html#after-attribute-value-(quoted)-state case STATE_AFTER_ATTRIBUTE_VALUE_QUOTED: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Switch to the before attribute name state. if (isSpace(cc)) { state = STATE_BEFORE_ATTRIBUTE_NAME; pos++; } else if (cc === CC_SOLIDUS) { // U+002F SOLIDUS (/) // Switch to the self-closing start tag state. state = STATE_SELF_CLOSING_START_TAG; pos++; } else if (cc === CC_GREATER_THAN) { if (input.charCodeAt(tagStart + 1) === CC_SOLIDUS) { state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { const nextPos = emitOpenTag(pos + 1, false); state = nextPos > pos + 1 ? STATE_DATA : contentModeAfterOpenTag(); pos = nextPos; } } else { // Anything else // This is a missing-whitespace-between-attributes parse error. Reconsume in // the before attribute name state. reportError( "missing-whitespace-between-attributes", pos, pos + 1, "warning" ); state = STATE_BEFORE_ATTRIBUTE_NAME; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#self-closing-start-tag-state case STATE_SELF_CLOSING_START_TAG: // Consume the next input character: // U+003E GREATER-THAN SIGN (>) // Set the self-closing flag of the current tag token. Switch to the data // state. Emit the current tag token. if (cc === CC_GREATER_THAN) { if (input.charCodeAt(tagStart + 1) === CC_SOLIDUS) { // An end tag emitted with the self-closing flag set is an // end-tag-with-trailing-solidus parse error. reportError( "end-tag-with-trailing-solidus", tagStart, pos + 1, "warning" ); state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { pos = emitOpenTag(pos + 1, true); state = STATE_DATA; } } else { // Anything else // This is an unexpected-solidus-in-tag parse error. Reconsume in the before // attribute name state. reportError("unexpected-solidus-in-tag", pos, pos + 1, "warning"); state = STATE_BEFORE_ATTRIBUTE_NAME; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#markup-declaration-open-state case STATE_MARKUP_DECLARATION_OPEN: // If the next few characters are: // Two U+002D HYPHEN-MINUS characters (-) // Consume those two characters, create a comment token whose data // is the empty string, and switch to the comment start state. if ( cc === CC_HYPHEN_MINUS && input.charCodeAt(pos + 1) === CC_HYPHEN_MINUS ) { pos += 2; commentStart = tagStart; commentDataStart = pos; commentDataEnd = pos; state = STATE_COMMENT_START; } else if ( // ASCII case-insensitive match for the word "DOCTYPE" // Consume those characters and switch to the DOCTYPE state. (cc === 0x44 || cc === 0x64) /* D or d */ && (input.charCodeAt(pos + 1) | 0x20) === 0x6f /* o */ && (input.charCodeAt(pos + 2) | 0x20) === 0x63 /* c */ && (input.charCodeAt(pos + 3) | 0x20) === 0x74 /* t */ && (input.charCodeAt(pos + 4) | 0x20) === 0x79 /* y */ && (input.charCodeAt(pos + 5) | 0x20) === 0x70 /* p */ && (input.charCodeAt(pos + 6) | 0x20) === 0x65 /* e */ ) { pos += 7; commentStart = tagStart; doctypeNameStart = -1; doctypeNameEnd = -1; doctypePublicStart = -1; doctypePublicEnd = -1; doctypeSystemStart = -1; doctypeSystemEnd = -1; doctypeForceQuirks = false; state = STATE_DOCTYPE; } else if ( // The string "[CDATA[" (the five uppercase letters "CDATA" with a // U+005B LEFT SQUARE BRACKET character before and after) // Consume those characters and switch to the CDATA section state. // Only when there is an adjusted current node and it is not an // element in the HTML namespace: everywhere else this is the // "anything else" branch below, a bogus comment ending at the first // `>`, which leaves the rest (typically `]]>`) as text. cc === CC_LEFT_SQUARE_BRACKET && input.charCodeAt(pos + 1) === 0x43 /* C */ && input.charCodeAt(pos + 2) === 0x44 /* D */ && input.charCodeAt(pos + 3) === 0x41 /* A */ && input.charCodeAt(pos + 4) === 0x54 /* T */ && input.charCodeAt(pos + 5) === 0x41 /* A */ && input.charCodeAt(pos + 6) === CC_LEFT_SQUARE_BRACKET && callbacks.isForeign !== undefined && callbacks.isForeign() ) { pos += 7; commentStart = tagStart; commentDataStart = pos; commentDataEnd = pos; state = STATE_CDATA_SECTION; } else { // Anything else // This is an incorrectly-opened-comment parse error. Create a comment token // whose data is the empty string. Switch to the bogus comment state (don't // consume anything in the current state). reportError("incorrectly-opened-comment", tagStart, pos, "warning"); commentStart = tagStart; commentDataStart = pos; commentDataEnd = pos; state = STATE_BOGUS_COMMENT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-start-state case STATE_COMMENT_START: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the comment start dash state. if (cc === CC_HYPHEN_MINUS) { state = STATE_COMMENT_START_DASH; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is an abrupt-closing-of-empty-comment parse error. Switch to the // data state. Emit the current comment token. reportError( "abrupt-closing-of-empty-comment", pos, pos + 1, "warning" ); let nextPos = pos + 1; if (callbacks.comment !== undefined) { nextPos = callbacks.comment( input, commentStart, pos + 1, commentDataStart, commentDataEnd ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // Reconsume in the comment state. state = STATE_COMMENT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-start-dash-state case STATE_COMMENT_START_DASH: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the comment end state. if (cc === CC_HYPHEN_MINUS) { state = STATE_COMMENT_END; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is an abrupt-closing-of-empty-comment parse error. Switch to the // data state. Emit the current comment token. reportError( "abrupt-closing-of-empty-comment", pos, pos + 1, "warning" ); let nextPos = pos + 1; if (callbacks.comment !== undefined) { nextPos = callbacks.comment( input, commentStart, pos + 1, commentDataStart, commentDataEnd ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // Append a U+002D HYPHEN-MINUS character (-) to the comment token's data. // Reconsume in the comment state. commentDataEnd = pos; state = STATE_COMMENT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-state case STATE_COMMENT: // Consume the next input character: // U+003C LESS-THAN SIGN (<) // Append a U+003C LESS-THAN SIGN character to the comment token's data. Switch to the comment less-than sign state. if (cc === CC_LESS_THAN) { state = STATE_COMMENT_LESS_THAN_SIGN; pos++; commentDataEnd = pos; } else if (cc === CC_HYPHEN_MINUS) { // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the comment end dash state. state = STATE_COMMENT_END_DASH; pos++; } else { // U+0000 NULL: unexpected-null-character (append U+FFFD). if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } // Fast-forward over ordinary comment text (same memoized // `indexOf` scheme as the data state); stop on `<` / `-` / NUL. pos++; if (nextLt < pos) { nextLt = input.indexOf("<", pos); if (nextLt === -1) nextLt = len; } if (nextHyphen < pos) { nextHyphen = input.indexOf("-", pos); if (nextHyphen === -1) nextHyphen = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextLt, nextHyphen, nextNul); commentDataEnd = pos; } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-end-dash-state case STATE_COMMENT_END_DASH: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the comment end state. if (cc === CC_HYPHEN_MINUS) { state = STATE_COMMENT_END; pos++; } else { // Anything else // Append a U+002D HYPHEN-MINUS character (-) to the comment token's data. // Reconsume in the comment state (so e.g. NULL and `<` are // handled there). commentDataEnd = pos; state = STATE_COMMENT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-end-state case STATE_COMMENT_END: // Consume the next input character: // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current comment token. if (cc === CC_GREATER_THAN) { let nextPos = pos + 1; if (callbacks.comment !== undefined) { nextPos = callbacks.comment( input, commentStart, pos + 1, commentDataStart, commentDataEnd ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else if (cc === CC_EXCLAMATION_MARK) { // U+0021 EXCLAMATION MARK (!) // Switch to the comment end bang state. state = STATE_COMMENT_END_BANG; pos++; } else if (cc === CC_HYPHEN_MINUS) { // One of the two pending dashes joins the data; the other stays // pending, so the range grows by one rather than to `pos`. commentDataEnd++; pos++; } else { // Anything else // Append two U+002D HYPHEN-MINUS characters (-) to the comment token's // data. Reconsume in the comment state (so NULL and `<` are // handled there). commentDataEnd = pos; state = STATE_COMMENT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-end-bang-state case STATE_COMMENT_END_BANG: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Append two U+002D HYPHEN-MINUS characters (-) and a U+0021 EXCLAMATION // MARK character (!) to the comment token's data. Switch to the comment end // dash state. if (cc === CC_HYPHEN_MINUS) { commentDataEnd = pos; state = STATE_COMMENT_END_DASH; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is an incorrectly-closed-comment parse error. Switch to the data // state. Emit the current comment token. reportError("incorrectly-closed-comment", pos, pos + 1, "warning"); let nextPos = pos + 1; if (callbacks.comment !== undefined) { nextPos = callbacks.comment( input, commentStart, pos + 1, commentDataStart, commentDataEnd ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // Append two U+002D HYPHEN-MINUS characters (-) and a U+0021 EXCLAMATION // MARK character (!) to the comment token's data. Reconsume in the comment // state (so NULL and `<` are handled there). commentDataEnd = pos; state = STATE_COMMENT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#bogus-comment-state case STATE_BOGUS_COMMENT: // Consume the next input character: // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current comment token. if (cc === CC_GREATER_THAN) { let nextPos = pos + 1; if (callbacks.comment !== undefined) { nextPos = callbacks.comment( input, commentStart, pos + 1, commentDataStart, commentDataEnd ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // U+0000 NULL: unexpected-null-character (append U+FFFD). if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } // Fast-forward over ordinary bogus-comment text (memoized `indexOf` // scheme); stop on `>` / NUL. pos++; if (nextGt < pos) { nextGt = input.indexOf(">", pos); if (nextGt === -1) nextGt = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextGt, nextNul); commentDataEnd = pos; } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-less-than-sign-state case STATE_COMMENT_LESS_THAN_SIGN: // Consume the next input character: // U+0021 EXCLAMATION MARK (!) // Append the current input character to the comment token's data. Switch to // the comment less-than sign bang state. if (cc === CC_EXCLAMATION_MARK) { state = STATE_COMMENT_LESS_THAN_SIGN_BANG; pos++; commentDataEnd = pos; } else if (cc === CC_LESS_THAN) { // U+003C LESS-THAN SIGN (<) // Append the current input character to the comment token's data. pos++; commentDataEnd = pos; } else { // Anything else // Reconsume in the comment state. state = STATE_COMMENT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-less-than-sign-bang-state case STATE_COMMENT_LESS_THAN_SIGN_BANG: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the comment less-than sign bang dash state. if (cc === CC_HYPHEN_MINUS) { state = STATE_COMMENT_LESS_THAN_SIGN_BANG_DASH; pos++; } else { // Anything else // Reconsume in the comment state. state = STATE_COMMENT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-less-than-sign-bang-dash-state case STATE_COMMENT_LESS_THAN_SIGN_BANG_DASH: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the comment less-than sign bang dash dash state. if (cc === CC_HYPHEN_MINUS) { state = STATE_COMMENT_LESS_THAN_SIGN_BANG_DASH_DASH; pos++; } else { // Anything else // Reconsume in the comment end dash state. state = STATE_COMMENT_END_DASH; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#comment-less-than-sign-bang-dash-dash-state case STATE_COMMENT_LESS_THAN_SIGN_BANG_DASH_DASH: // Consume the next input character: // U+003E GREATER-THAN SIGN (>) // EOF // Reconsume in the comment end state. // Anything else // This is a nested-comment parse error. Reconsume in the comment end state. if (cc !== CC_GREATER_THAN) { reportError("nested-comment", pos, pos + 1, "warning"); } state = STATE_COMMENT_END; // Reconsume break; // https://html.spec.whatwg.org/multipage/parsing.html#doctype-state case STATE_DOCTYPE: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Switch to the before DOCTYPE name state. if (isSpace(cc)) { state = STATE_BEFORE_DOCTYPE_NAME; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Reconsume in the before DOCTYPE name state. state = STATE_BEFORE_DOCTYPE_NAME; } else { // Anything else // This is a missing-whitespace-before-doctype-name parse error. Reconsume // in the before DOCTYPE name state. reportError( "missing-whitespace-before-doctype-name", pos, pos + 1, "warning" ); state = STATE_BEFORE_DOCTYPE_NAME; } break; // https://html.spec.whatwg.org/multipage/parsing.html#before-doctype-name-state case STATE_BEFORE_DOCTYPE_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. if (isSpace(cc)) { pos++; } else if (cc === CC_NULL) { // U+0000 NULL // This is an unexpected-null-character parse error. Create a new DOCTYPE // token. Set the token's name to a U+FFFD REPLACEMENT CHARACTER character. // Switch to the DOCTYPE name state. reportError("unexpected-null-character", pos, pos + 1, "warning"); state = STATE_DOCTYPE_NAME; doctypeNameStart = pos; pos++; doctypeNameEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is a missing-doctype-name parse error. Create a new DOCTYPE token. // Set its force-quirks flag to on. Switch to the data state. Emit the // current token. reportError("missing-doctype-name", pos, pos + 1, "warning"); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // ASCII upper alpha // Create a new DOCTYPE token. Set the token's name to the lowercase version // of the current input character (add 0x0020 to the character's code // point). Switch to the DOCTYPE name state. // Anything else // Create a new DOCTYPE token. Set the token's name to the current input // character. Switch to the DOCTYPE name state. state = STATE_DOCTYPE_NAME; doctypeNameStart = pos; pos++; doctypeNameEnd = pos; } break; // https://html.spec.whatwg.org/multipage/parsing.html#doctype-name-state case STATE_DOCTYPE_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Switch to the after DOCTYPE name state. if (isSpace(cc)) { state = STATE_AFTER_DOCTYPE_NAME; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current DOCTYPE token. let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else if (cc === CC_NULL) { // U+0000 NULL // This is an unexpected-null-character parse error. Append a U+FFFD // REPLACEMENT CHARACTER character to the current DOCTYPE token's name. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; doctypeNameEnd = pos; } else { // ASCII upper alpha // Append the lowercase version of the current input character (add 0x0020 // to the character's code point) to the current DOCTYPE token's name. // Anything else // Append the current input character to the current DOCTYPE token's name. pos++; doctypeNameEnd = pos; } break; // https://html.spec.whatwg.org/multipage/parsing.html#after-doctype-name-state case STATE_AFTER_DOCTYPE_NAME: // Consume the next input character: if (isSpace(cc)) { // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current DOCTYPE token. let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else if ( pos + 5 < len && (cc === 0x50 || cc === 0x70) /* P or p */ && (input.charCodeAt(pos + 1) | 0x20) === 0x75 /* u */ && (input.charCodeAt(pos + 2) | 0x20) === 0x62 /* b */ && (input.charCodeAt(pos + 3) | 0x20) === 0x6c /* l */ && (input.charCodeAt(pos + 4) | 0x20) === 0x69 /* i */ && (input.charCodeAt(pos + 5) | 0x20) === 0x63 /* c */ ) { // ASCII case-insensitive match for the word "PUBLIC" pos += 6; state = STATE_AFTER_DOCTYPE_PUBLIC_KEYWORD; } else if ( pos + 5 < len && (cc === 0x53 || cc === 0x73) /* S or s */ && (input.charCodeAt(pos + 1) | 0x20) === 0x79 /* y */ && (input.charCodeAt(pos + 2) | 0x20) === 0x73 /* s */ && (input.charCodeAt(pos + 3) | 0x20) === 0x74 /* t */ && (input.charCodeAt(pos + 4) | 0x20) === 0x65 /* e */ && (input.charCodeAt(pos + 5) | 0x20) === 0x6d /* m */ ) { // ASCII case-insensitive match for the word "SYSTEM" pos += 6; state = STATE_AFTER_DOCTYPE_SYSTEM_KEYWORD; } else { // Anything else // This is an invalid-character-sequence-after-doctype-name parse error. Set // the current DOCTYPE token's force-quirks flag to on. Reconsume in the // bogus DOCTYPE state. reportError( "invalid-character-sequence-after-doctype-name", pos, pos + 1, "warning" ); doctypeForceQuirks = true; state = STATE_BOGUS_DOCTYPE; } break; // https://html.spec.whatwg.org/multipage/parsing.html#after-doctype-public-keyword-state case STATE_AFTER_DOCTYPE_PUBLIC_KEYWORD: // Consume the next input character: if (isSpace(cc)) { // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Switch to the before DOCTYPE public identifier state. state = STATE_BEFORE_DOCTYPE_PUBLIC_IDENTIFIER; pos++; } else if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // This is a missing-whitespace-after-doctype-public-keyword parse error. // Set the current DOCTYPE token's public identifier to the empty string // (not missing), then switch to the DOCTYPE public identifier // (double-quoted) state. reportError( "missing-whitespace-after-doctype-public-keyword", pos, pos + 1, "warning" ); state = STATE_DOCTYPE_PUBLIC_IDENTIFIER_DOUBLE_QUOTED; pos++; doctypePublicStart = pos; doctypePublicEnd = pos; } else if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // This is a missing-whitespace-after-doctype-public-keyword parse error. // Set the current DOCTYPE token's public identifier to the empty string // (not missing), then switch to the DOCTYPE public identifier // (single-quoted) state. reportError( "missing-whitespace-after-doctype-public-keyword", pos, pos + 1, "warning" ); state = STATE_DOCTYPE_PUBLIC_IDENTIFIER_SINGLE_QUOTED; pos++; doctypePublicStart = pos; doctypePublicEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is a missing-doctype-public-identifier parse error. Set the current // DOCTYPE token's force-quirks flag to on. Switch to the data state. Emit // the current DOCTYPE token. reportError( "missing-doctype-public-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // This is a missing-quote-before-doctype-public-identifier parse error. Set // the current DOCTYPE token's force-quirks flag to on. Reconsume in the // bogus DOCTYPE state. reportError( "missing-quote-before-doctype-public-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; state = STATE_BOGUS_DOCTYPE; } break; // https://html.spec.whatwg.org/multipage/parsing.html#before-doctype-public-identifier-state case STATE_BEFORE_DOCTYPE_PUBLIC_IDENTIFIER: // Consume the next input character: if (isSpace(cc)) { // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. pos++; } else if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // Set the current DOCTYPE token's public identifier to the empty string // (not missing), then switch to the DOCTYPE public identifier // (double-quoted) state. state = STATE_DOCTYPE_PUBLIC_IDENTIFIER_DOUBLE_QUOTED; pos++; doctypePublicStart = pos; doctypePublicEnd = pos; } else if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // Set the current DOCTYPE token's public identifier to the empty string // (not missing), then switch to the DOCTYPE public identifier // (single-quoted) state. state = STATE_DOCTYPE_PUBLIC_IDENTIFIER_SINGLE_QUOTED; pos++; doctypePublicStart = pos; doctypePublicEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is a missing-doctype-public-identifier parse error. Set the current // DOCTYPE token's force-quirks flag to on. Switch to the data state. Emit // the current DOCTYPE token. reportError( "missing-doctype-public-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // This is a missing-quote-before-doctype-public-identifier parse error. Set // the current DOCTYPE token's force-quirks flag to on. Reconsume in the // bogus DOCTYPE state. reportError( "missing-quote-before-doctype-public-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; state = STATE_BOGUS_DOCTYPE; } break; // https://html.spec.whatwg.org/multipage/parsing.html#doctype-public-identifier-(double-quoted)-state case STATE_DOCTYPE_PUBLIC_IDENTIFIER_DOUBLE_QUOTED: // Consume the next input character: if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // Switch to the after DOCTYPE public identifier state. state = STATE_AFTER_DOCTYPE_PUBLIC_IDENTIFIER; pos++; } else if (cc === CC_NULL) { // U+0000 NULL // This is an unexpected-null-character parse error. Append a U+FFFD // REPLACEMENT CHARACTER character to the current DOCTYPE token's public // identifier. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; doctypePublicEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is an abrupt-doctype-public-identifier parse error. Set the current // DOCTYPE token's force-quirks flag to on. Switch to the data state. Emit // the current DOCTYPE token. reportError( "abrupt-doctype-public-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // Append the current input character to the current DOCTYPE token's public // identifier. pos++; doctypePublicEnd = pos; } break; // https://html.spec.whatwg.org/multipage/parsing.html#doctype-public-identifier-(single-quoted)-state case STATE_DOCTYPE_PUBLIC_IDENTIFIER_SINGLE_QUOTED: // Consume the next input character: if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // Switch to the after DOCTYPE public identifier state. state = STATE_AFTER_DOCTYPE_PUBLIC_IDENTIFIER; pos++; } else if (cc === CC_NULL) { // U+0000 NULL // This is an unexpected-null-character parse error. Append a U+FFFD // REPLACEMENT CHARACTER character to the current DOCTYPE token's public // identifier. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; doctypePublicEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is an abrupt-doctype-public-identifier parse error. Set the current // DOCTYPE token's force-quirks flag to on. Switch to the data state. Emit // the current DOCTYPE token. reportError( "abrupt-doctype-public-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // Append the current input character to the current DOCTYPE token's public // identifier. pos++; doctypePublicEnd = pos; } break; // https://html.spec.whatwg.org/multipage/parsing.html#after-doctype-public-identifier-state case STATE_AFTER_DOCTYPE_PUBLIC_IDENTIFIER: // Consume the next input character: if (isSpace(cc)) { // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Switch to the between DOCTYPE public and system identifiers state. state = STATE_BETWEEN_DOCTYPE_PUBLIC_AND_SYSTEM_IDENTIFIERS; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current DOCTYPE token. let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // This is a missing-whitespace-between-doctype-public-and-system-identifiers // parse error. Set the current DOCTYPE token's system // identifier to the empty string (not missing), then switch // to the DOCTYPE system identifier (double-quoted) state. reportError( "missing-whitespace-between-doctype-public-and-system-identifiers", pos, pos + 1, "warning" ); state = STATE_DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED; pos++; doctypeSystemStart = pos; doctypeSystemEnd = pos; } else if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // This is a missing-whitespace-between-doctype-public-and-system-identifiers // parse error. Set the current DOCTYPE token's system // identifier to the empty string (not missing), then switch // to the DOCTYPE system identifier (single-quoted) state. reportError( "missing-whitespace-between-doctype-public-and-system-identifiers", pos, pos + 1, "warning" ); state = STATE_DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED; pos++; doctypeSystemStart = pos; doctypeSystemEnd = pos; } else { // Anything else // This is a missing-quote-before-doctype-system-identifier parse error. Set // the current DOCTYPE token's force-quirks flag to on. Reconsume in the // bogus DOCTYPE state. reportError( "missing-quote-before-doctype-system-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; state = STATE_BOGUS_DOCTYPE; } break; // https://html.spec.whatwg.org/multipage/parsing.html#between-doctype-public-and-system-identifiers-state case STATE_BETWEEN_DOCTYPE_PUBLIC_AND_SYSTEM_IDENTIFIERS: // Consume the next input character: if (isSpace(cc)) { // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current DOCTYPE token. let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // Set the current DOCTYPE token's system identifier to the empty string // (not missing), then switch to the DOCTYPE system identifier // (double-quoted) state. state = STATE_DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED; pos++; doctypeSystemStart = pos; doctypeSystemEnd = pos; } else if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // Set the current DOCTYPE token's system identifier to the empty string // (not missing), then switch to the DOCTYPE system identifier // (single-quoted) state. state = STATE_DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED; pos++; doctypeSystemStart = pos; doctypeSystemEnd = pos; } else { // Anything else // This is a missing-quote-before-doctype-system-identifier parse error. Set // the current DOCTYPE token's force-quirks flag to on. Reconsume in the // bogus DOCTYPE state. reportError( "missing-quote-before-doctype-system-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; state = STATE_BOGUS_DOCTYPE; } break; // https://html.spec.whatwg.org/multipage/parsing.html#after-doctype-system-keyword-state case STATE_AFTER_DOCTYPE_SYSTEM_KEYWORD: // Consume the next input character: if (isSpace(cc)) { // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Switch to the before DOCTYPE system identifier state. state = STATE_BEFORE_DOCTYPE_SYSTEM_IDENTIFIER; pos++; } else if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // This is a missing-whitespace-after-doctype-system-keyword parse error. // Set the current DOCTYPE token's system identifier to the empty string // (not missing), then switch to the DOCTYPE system identifier // (double-quoted) state. reportError( "missing-whitespace-after-doctype-system-keyword", pos, pos + 1, "warning" ); state = STATE_DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED; pos++; doctypeSystemStart = pos; doctypeSystemEnd = pos; } else if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // This is a missing-whitespace-after-doctype-system-keyword parse error. // Set the current DOCTYPE token's system identifier to the empty string // (not missing), then switch to the DOCTYPE system identifier // (single-quoted) state. reportError( "missing-whitespace-after-doctype-system-keyword", pos, pos + 1, "warning" ); state = STATE_DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED; pos++; doctypeSystemStart = pos; doctypeSystemEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is a missing-doctype-system-identifier parse error. Set the current // DOCTYPE token's force-quirks flag to on. Switch to the data state. Emit // the current DOCTYPE token. reportError( "missing-doctype-system-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // This is a missing-quote-before-doctype-system-identifier parse error. Set // the current DOCTYPE token's force-quirks flag to on. Reconsume in the // bogus DOCTYPE state. reportError( "missing-quote-before-doctype-system-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; state = STATE_BOGUS_DOCTYPE; } break; // https://html.spec.whatwg.org/multipage/parsing.html#before-doctype-system-identifier-state case STATE_BEFORE_DOCTYPE_SYSTEM_IDENTIFIER: // Consume the next input character: if (isSpace(cc)) { // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. pos++; } else if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // Set the current DOCTYPE token's system identifier to the empty string // (not missing), then switch to the DOCTYPE system identifier // (double-quoted) state. state = STATE_DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED; pos++; doctypeSystemStart = pos; doctypeSystemEnd = pos; } else if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // Set the current DOCTYPE token's system identifier to the empty string // (not missing), then switch to the DOCTYPE system identifier // (single-quoted) state. state = STATE_DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED; pos++; doctypeSystemStart = pos; doctypeSystemEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is a missing-doctype-system-identifier parse error. Set the current // DOCTYPE token's force-quirks flag to on. Switch to the data state. Emit // the current DOCTYPE token. reportError( "missing-doctype-system-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // This is a missing-quote-before-doctype-system-identifier parse error. Set // the current DOCTYPE token's force-quirks flag to on. Reconsume in the // bogus DOCTYPE state. reportError( "missing-quote-before-doctype-system-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; state = STATE_BOGUS_DOCTYPE; } break; // https://html.spec.whatwg.org/multipage/parsing.html#doctype-system-identifier-(double-quoted)-state case STATE_DOCTYPE_SYSTEM_IDENTIFIER_DOUBLE_QUOTED: // Consume the next input character: if (cc === CC_QUOTATION_MARK) { // U+0022 QUOTATION MARK (") // Switch to the after DOCTYPE system identifier state. state = STATE_AFTER_DOCTYPE_SYSTEM_IDENTIFIER; pos++; } else if (cc === CC_NULL) { // U+0000 NULL // This is an unexpected-null-character parse error. Append a U+FFFD // REPLACEMENT CHARACTER character to the current DOCTYPE token's system // identifier. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; doctypeSystemEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is an abrupt-doctype-system-identifier parse error. Set the current // DOCTYPE token's force-quirks flag to on. Switch to the data state. Emit // the current DOCTYPE token. reportError( "abrupt-doctype-system-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // Append the current input character to the current DOCTYPE token's system // identifier. pos++; doctypeSystemEnd = pos; } break; // https://html.spec.whatwg.org/multipage/parsing.html#doctype-system-identifier-(single-quoted)-state case STATE_DOCTYPE_SYSTEM_IDENTIFIER_SINGLE_QUOTED: // Consume the next input character: if (cc === CC_APOSTROPHE) { // U+0027 APOSTROPHE (') // Switch to the after DOCTYPE system identifier state. state = STATE_AFTER_DOCTYPE_SYSTEM_IDENTIFIER; pos++; } else if (cc === CC_NULL) { // U+0000 NULL // This is an unexpected-null-character parse error. Append a U+FFFD // REPLACEMENT CHARACTER character to the current DOCTYPE token's system // identifier. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; doctypeSystemEnd = pos; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // This is an abrupt-doctype-system-identifier parse error. Set the current // DOCTYPE token's force-quirks flag to on. Switch to the data state. Emit // the current DOCTYPE token. reportError( "abrupt-doctype-system-identifier", pos, pos + 1, "warning" ); doctypeForceQuirks = true; let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // Append the current input character to the current DOCTYPE token's system // identifier. pos++; doctypeSystemEnd = pos; } break; // https://html.spec.whatwg.org/multipage/parsing.html#after-doctype-system-identifier-state case STATE_AFTER_DOCTYPE_SYSTEM_IDENTIFIER: // Consume the next input character: if (isSpace(cc)) { // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // Ignore the character. pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the current DOCTYPE token. let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // This is an unexpected-character-after-doctype-system-identifier parse // error. Reconsume in the bogus DOCTYPE state. (This does not set the // current DOCTYPE token's force-quirks flag to on.) reportError( "unexpected-character-after-doctype-system-identifier", pos, pos + 1, "warning" ); state = STATE_BOGUS_DOCTYPE; } break; // https://html.spec.whatwg.org/multipage/parsing.html#bogus-doctype-state case STATE_BOGUS_DOCTYPE: // Consume the next input character: if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. Emit the DOCTYPE token. let nextPos = pos + 1; if (callbacks.doctype !== undefined) { nextPos = callbacks.doctype( input, commentStart, pos + 1, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else if (cc === CC_NULL) { // U+0000 NULL // This is an unexpected-null-character parse error. Ignore the character. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; } else { // Anything else // Ignore the character. pos++; } break; // https://html.spec.whatwg.org/multipage/parsing.html#cdata-section-state case STATE_CDATA_SECTION: // Consume the next input character: // U+005D RIGHT SQUARE BRACKET (]) // Switch to the CDATA section bracket state. if (cc === CC_RIGHT_SQUARE_BRACKET) { state = STATE_CDATA_SECTION_BRACKET; pos++; } else { // Anything else // Emit the current input character as a character token. // Fast-forward to the next `]` (the only code point with its own // arc) in one native scan. pos = input.indexOf("]", pos + 1); if (pos === -1) pos = len; } break; // https://html.spec.whatwg.org/multipage/parsing.html#cdata-section-bracket-state case STATE_CDATA_SECTION_BRACKET: // Consume the next input character: // U+005D RIGHT SQUARE BRACKET (]) // Switch to the CDATA section end state. if (cc === CC_RIGHT_SQUARE_BRACKET) { state = STATE_CDATA_SECTION_END; pos++; } else { // Anything else // Emit a U+005D RIGHT SQUARE BRACKET character token. Reconsume in the // CDATA section state. state = STATE_CDATA_SECTION; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#cdata-section-end-state case STATE_CDATA_SECTION_END: // Consume the next input character: // U+005D RIGHT SQUARE BRACKET (]) // Emit a U+005D RIGHT SQUARE BRACKET character token. if (cc === CC_RIGHT_SQUARE_BRACKET) { pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the data state. The `]]` just consumed closes the // section rather than joining its content. commentDataEnd = pos - 2; let nextPos = pos + 1; if (callbacks.comment !== undefined) { nextPos = callbacks.comment( input, commentStart, pos + 1, commentDataStart, commentDataEnd ); } state = STATE_DATA; textStart = nextPos; pos = nextPos; } else { // Anything else // Emit two U+005D RIGHT SQUARE BRACKET character tokens. Reconsume in the // CDATA section state. state = STATE_CDATA_SECTION; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state case STATE_RCDATA: // Consume the next input character: if (cc === CC_AMPERSAND) { // U+0026 AMPERSAND (&) // Set the return state to the RCDATA state. Switch to the // character reference state. (RCDATA processes references; // RAWTEXT/script/PLAINTEXT do not.) returnState = STATE_RCDATA; state = STATE_CHARACTER_REFERENCE; pos++; } else if (cc === CC_LESS_THAN) { // U+003C LESS-THAN SIGN (<) // Switch to the RCDATA less-than sign state. tagStart = pos; state = STATE_RCDATA_LESS_THAN_SIGN; pos++; } else if (cc === CC_NULL) { // U+0000 NULL is an unexpected-null-character parse error. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; } else { // Fast-forward over ordinary RCDATA text (same memoized // `indexOf` scheme as the data state) — batches only this // state's "anything else" arc; `&` / `<` / NUL keep their own. pos++; if (nextLt < pos) { nextLt = input.indexOf("<", pos); if (nextLt === -1) nextLt = len; } if (nextAmp < pos) { nextAmp = input.indexOf("&", pos); if (nextAmp === -1) nextAmp = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextLt, nextAmp, nextNul); // Fused: take this state's `<` arc without another dispatch. if (pos === nextLt && pos < len) { tagStart = pos; state = STATE_RCDATA_LESS_THAN_SIGN; pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#rcdata-less-than-sign-state case STATE_RCDATA_LESS_THAN_SIGN: // Consume the next input character: // U+002F SOLIDUS (/) // Switch to the RCDATA end tag open state. (Spec sets a // temporary buffer here; we track the would-be content via // offset ranges instead.) if (cc === CC_SOLIDUS) { state = STATE_RCDATA_END_TAG_OPEN; pos++; } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token. Reconsume in the RCDATA // state. state = STATE_RCDATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#rcdata-end-tag-open-state case STATE_RCDATA_END_TAG_OPEN: // Consume the next input character: // ASCII alpha // Create a new end tag token, set its tag name to the empty string. // Reconsume in the RCDATA end tag name state. if (isAsciiAlpha(cc)) { tagNameStart = pos; state = STATE_RCDATA_END_TAG_NAME; // Reconsume } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token and a U+002F SOLIDUS // character token. Reconsume in the RCDATA state. state = STATE_RCDATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#rcdata-end-tag-name-state case STATE_RCDATA_END_TAG_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // If the current end tag token is an appropriate end tag token, then switch // to the before attribute name state. Otherwise, treat it as per the // "anything else" entry below. if (isSpace(cc)) { tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_BEFORE_ATTRIBUTE_NAME; pos++; } else { state = STATE_RCDATA; // Reconsume } } else if (cc === CC_SOLIDUS) { // U+002F SOLIDUS (/) // If the current end tag token is an appropriate end tag token, then switch // to the self-closing start tag state. Otherwise, treat it as per the // "anything else" entry below. tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_SELF_CLOSING_START_TAG; pos++; } else { state = STATE_RCDATA; // Reconsume } } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // If the current end tag token is an appropriate end tag token, then switch // to the data state and emit the current tag token. Otherwise, treat it as // per the "anything else" entry below. tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { state = STATE_RCDATA; // Reconsume } } else if (isAsciiAlpha(cc)) { // ASCII upper alpha / ASCII lower alpha // Append the lowercase version of the current input character to the // current tag token's tag name. Append the current input character to // the temporary buffer. // Fused: consume the whole alpha run in one dispatch. pos++; while (pos < len && isAsciiAlpha(input.charCodeAt(pos))) pos++; } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token, a U+002F SOLIDUS character // token, and a character token for each of the characters in the temporary // buffer (in the order they were added to the buffer). Reconsume in the // RCDATA state. state = STATE_RCDATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#rawtext-state case STATE_RAWTEXT: // Consume the next input character: // U+003C LESS-THAN SIGN (<) // Switch to the RAWTEXT less-than sign state. if (cc === CC_LESS_THAN) { tagStart = pos; state = STATE_RAWTEXT_LESS_THAN_SIGN; pos++; } else if (cc === CC_NULL) { // U+0000 NULL is an unexpected-null-character parse error. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; } else { // Fast-forward over ordinary RAWTEXT text (same memoized // `indexOf` scheme as the data state) — batches only this // state's "anything else" arc; `<` / NUL keep their own. pos++; if (nextLt < pos) { nextLt = input.indexOf("<", pos); if (nextLt === -1) nextLt = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextLt, nextNul); // Fused: take this state's `<` arc without another dispatch. if (pos === nextLt && pos < len) { tagStart = pos; state = STATE_RAWTEXT_LESS_THAN_SIGN; pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#rawtext-less-than-sign-state case STATE_RAWTEXT_LESS_THAN_SIGN: // Consume the next input character: // U+002F SOLIDUS (/) // Switch to the RAWTEXT end tag open state. (Spec sets a // temporary buffer here; we track via offset ranges instead.) if (cc === CC_SOLIDUS) { state = STATE_RAWTEXT_END_TAG_OPEN; pos++; } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token. Reconsume in the RAWTEXT // state. state = STATE_RAWTEXT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-open-state case STATE_RAWTEXT_END_TAG_OPEN: // Consume the next input character: // ASCII alpha // Create a new end tag token, set its tag name to the empty string. // Reconsume in the RAWTEXT end tag name state. if (isAsciiAlpha(cc)) { tagNameStart = pos; state = STATE_RAWTEXT_END_TAG_NAME; // Reconsume } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token and a U+002F SOLIDUS // character token. Reconsume in the RAWTEXT state. state = STATE_RAWTEXT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state case STATE_RAWTEXT_END_TAG_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // If the current end tag token is an appropriate end tag token, then switch // to the before attribute name state. Otherwise, treat it as per the // "anything else" entry below. if (isSpace(cc)) { tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_BEFORE_ATTRIBUTE_NAME; pos++; } else { state = STATE_RAWTEXT; } } else if (cc === CC_SOLIDUS) { // U+002F SOLIDUS (/) // If the current end tag token is an appropriate end tag token, then switch // to the self-closing start tag state. Otherwise, treat it as per the // "anything else" entry below. tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_SELF_CLOSING_START_TAG; pos++; } else { state = STATE_RAWTEXT; } } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // If the current end tag token is an appropriate end tag token, then switch // to the data state and emit the current tag token. Otherwise, treat it as // per the "anything else" entry below. tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { state = STATE_RAWTEXT; } } else if (isAsciiAlpha(cc)) { // ASCII upper alpha / ASCII lower alpha // Append the lowercase version of the current input character to the // current tag token's tag name. Append the current input character to // the temporary buffer. // Fused: consume the whole alpha run in one dispatch. pos++; while (pos < len && isAsciiAlpha(input.charCodeAt(pos))) pos++; } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token, a U+002F SOLIDUS character // token, and a character token for each of the characters in the temporary // buffer (in the order they were added to the buffer). Reconsume in the // RAWTEXT state. state = STATE_RAWTEXT; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-state case STATE_SCRIPT_DATA: // Consume the next input character: // U+003C LESS-THAN SIGN (<) // Switch to the script data less-than sign state. if (cc === CC_LESS_THAN) { tagStart = pos; state = STATE_SCRIPT_DATA_LESS_THAN_SIGN; pos++; } else if (cc === CC_NULL) { // U+0000 NULL is an unexpected-null-character parse error. reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; } else { // Fast-forward over ordinary script-data text (same memoized // `indexOf` scheme as the data state) — batches only this // state's "anything else" arc; `<` / NUL keep their own. pos++; if (nextLt < pos) { nextLt = input.indexOf("<", pos); if (nextLt === -1) nextLt = len; } if (nextNul < pos) { nextNul = input.indexOf("\0", pos); if (nextNul === -1) nextNul = len; } pos = Math.min(nextLt, nextNul); // Fused: take this state's `<` arc without another dispatch. if (pos === nextLt && pos < len) { tagStart = pos; state = STATE_SCRIPT_DATA_LESS_THAN_SIGN; pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-less-than-sign-state case STATE_SCRIPT_DATA_LESS_THAN_SIGN: // Consume the next input character: // U+002F SOLIDUS (/) // Switch to the script data end tag open state. (Spec sets a // temporary buffer here; we track via offset ranges instead.) if (cc === CC_SOLIDUS) { state = STATE_SCRIPT_DATA_END_TAG_OPEN; pos++; } else if (cc === CC_EXCLAMATION_MARK) { // U+0021 EXCLAMATION MARK (!) // Switch to the script data escape start state. Emit a U+003C LESS-THAN // SIGN character token and a U+0021 EXCLAMATION MARK character token. state = STATE_SCRIPT_DATA_ESCAPE_START; pos++; } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token. Reconsume in the script // data state. state = STATE_SCRIPT_DATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-end-tag-open-state case STATE_SCRIPT_DATA_END_TAG_OPEN: // Consume the next input character: // ASCII alpha // Create a new end tag token, set its tag name to the empty string. // Reconsume in the script data end tag name state. if (isAsciiAlpha(cc)) { tagNameStart = pos; state = STATE_SCRIPT_DATA_END_TAG_NAME; // Reconsume } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token and a U+002F SOLIDUS // character token. Reconsume in the script data state. state = STATE_SCRIPT_DATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-end-tag-name-state case STATE_SCRIPT_DATA_END_TAG_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // If the current end tag token is an appropriate end tag token, then switch // to the before attribute name state. Otherwise, treat it as per the // "anything else" entry below. if (isSpace(cc)) { tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_BEFORE_ATTRIBUTE_NAME; pos++; } else { state = STATE_SCRIPT_DATA; } } else if (cc === CC_SOLIDUS) { // U+002F SOLIDUS (/) // If the current end tag token is an appropriate end tag token, then switch // to the self-closing start tag state. Otherwise, treat it as per the // "anything else" entry below. tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_SELF_CLOSING_START_TAG; pos++; } else { state = STATE_SCRIPT_DATA; } } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // If the current end tag token is an appropriate end tag token, then switch // to the data state and emit the current tag token. Otherwise, treat it as // per the "anything else" entry below. tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { state = STATE_SCRIPT_DATA; } } else if (isAsciiAlpha(cc)) { // ASCII upper alpha / ASCII lower alpha // Fused: consume the whole alpha run in one dispatch. pos++; while (pos < len && isAsciiAlpha(input.charCodeAt(pos))) pos++; } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token, a U+002F SOLIDUS character // token, and a character token for each of the characters in the temporary // buffer (in the order they were added to the buffer). Reconsume in the // script data state. state = STATE_SCRIPT_DATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-escape-start-state case STATE_SCRIPT_DATA_ESCAPE_START: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the script data escape start dash state. Emit a U+002D // HYPHEN-MINUS character token. if (cc === CC_HYPHEN_MINUS) { state = STATE_SCRIPT_DATA_ESCAPE_START_DASH; pos++; } else { // Anything else // Reconsume in the script data state. state = STATE_SCRIPT_DATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-escape-start-dash-state case STATE_SCRIPT_DATA_ESCAPE_START_DASH: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the script data escaped dash dash state. Emit a U+002D // HYPHEN-MINUS character token. if (cc === CC_HYPHEN_MINUS) { state = STATE_SCRIPT_DATA_ESCAPED_DASH_DASH; pos++; } else { // Anything else // Reconsume in the script data state. state = STATE_SCRIPT_DATA; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-state case STATE_SCRIPT_DATA_ESCAPED: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the script data escaped dash state. Emit a U+002D HYPHEN-MINUS // character token. if (cc === CC_HYPHEN_MINUS) { state = STATE_SCRIPT_DATA_ESCAPED_DASH; pos++; } else if (cc === CC_LESS_THAN) { // U+003C LESS-THAN SIGN (<) // Switch to the script data escaped less-than sign state. tagStart = pos; state = STATE_SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN; pos++; } else { // Anything else // Emit the current input character as a character token. // U+0000 NULL is an unexpected-null-character parse error. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } // Fast-forward ordinary escaped script text without re-entering // the state switch; stop on the significant code points above. pos++; while (pos < len) { const c2 = input.charCodeAt(pos); if ( c2 === CC_HYPHEN_MINUS || c2 === CC_LESS_THAN || c2 === CC_NULL ) { break; } pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-dash-state case STATE_SCRIPT_DATA_ESCAPED_DASH: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the script data escaped dash dash state. Emit a U+002D // HYPHEN-MINUS character token. if (cc === CC_HYPHEN_MINUS) { state = STATE_SCRIPT_DATA_ESCAPED_DASH_DASH; pos++; } else if (cc === CC_LESS_THAN) { // U+003C LESS-THAN SIGN (<) // Switch to the script data escaped less-than sign state. tagStart = pos; state = STATE_SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN; pos++; } else { // Anything else // Switch to the script data escaped state. Emit the current input character // as a character token. U+0000 NULL is an unexpected-null-character error. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } state = STATE_SCRIPT_DATA_ESCAPED; pos++; } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-dash-dash-state case STATE_SCRIPT_DATA_ESCAPED_DASH_DASH: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Emit a U+002D HYPHEN-MINUS character token. if (cc === CC_HYPHEN_MINUS) { pos++; } else if (cc === CC_LESS_THAN) { // U+003C LESS-THAN SIGN (<) // Switch to the script data escaped less-than sign state. tagStart = pos; state = STATE_SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the script data state. Emit a U+003E GREATER-THAN SIGN // character token. state = STATE_SCRIPT_DATA; pos++; } else { // Anything else // Switch to the script data escaped state. Emit the current input character // as a character token. U+0000 NULL is an unexpected-null-character error. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } state = STATE_SCRIPT_DATA_ESCAPED; pos++; } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-less-than-sign-state case STATE_SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN: // Consume the next input character: // U+002F SOLIDUS (/) // Switch to the script data escaped end tag open state. // (Spec sets a temporary buffer; we track via offset ranges.) if (cc === CC_SOLIDUS) { state = STATE_SCRIPT_DATA_ESCAPED_END_TAG_OPEN; pos++; } else if (isAsciiAlpha(cc)) { // ASCII alpha // Set the temporary buffer to the empty string. Emit a U+003C LESS-THAN // SIGN character token. Reconsume in the script data double escape start // state. scriptMatch = 0; state = STATE_SCRIPT_DATA_DOUBLE_ESCAPE_START; // Reconsume } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token. Reconsume in the script // data escaped state. state = STATE_SCRIPT_DATA_ESCAPED; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-end-tag-open-state case STATE_SCRIPT_DATA_ESCAPED_END_TAG_OPEN: // Consume the next input character: // ASCII alpha // Create a new end tag token, set its tag name to the empty string. // Reconsume in the script data escaped end tag name state. if (isAsciiAlpha(cc)) { tagNameStart = pos; state = STATE_SCRIPT_DATA_ESCAPED_END_TAG_NAME; // Reconsume } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token and a U+002F SOLIDUS // character token. Reconsume in the script data escaped state. state = STATE_SCRIPT_DATA_ESCAPED; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-escaped-end-tag-name-state case STATE_SCRIPT_DATA_ESCAPED_END_TAG_NAME: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // If the current end tag token is an appropriate end tag token, then switch // to the before attribute name state. Otherwise, treat it as per the // "anything else" entry below. if (isSpace(cc)) { tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_BEFORE_ATTRIBUTE_NAME; pos++; } else { state = STATE_SCRIPT_DATA_ESCAPED; } } else if (cc === CC_SOLIDUS) { // U+002F SOLIDUS (/) // If the current end tag token is an appropriate end tag token, then switch // to the self-closing start tag state. Otherwise, treat it as per the // "anything else" entry below. tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_SELF_CLOSING_START_TAG; pos++; } else { state = STATE_SCRIPT_DATA_ESCAPED; } } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // If the current end tag token is an appropriate end tag token, then switch // to the data state and emit the current tag token. Otherwise, treat it as // per the "anything else" entry below. tagNameEnd = pos; if ( rangeEqualsLowerCase( input, tagNameStart, tagNameEnd, lastOpenTagName ) ) { flushText(tagStart); state = STATE_DATA; pos = emitCloseTag(pos + 1); } else { state = STATE_SCRIPT_DATA_ESCAPED; } } else if (isAsciiAlpha(cc)) { // ASCII upper alpha / ASCII lower alpha // Fused: consume the whole alpha run in one dispatch. pos++; while (pos < len && isAsciiAlpha(input.charCodeAt(pos))) pos++; } else { // Anything else // Emit a U+003C LESS-THAN SIGN character token, a U+002F SOLIDUS character // token, and a character token for each of the characters in the temporary // buffer (in the order they were added to the buffer). Reconsume in the // script data escaped state. state = STATE_SCRIPT_DATA_ESCAPED; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escape-start-state case STATE_SCRIPT_DATA_DOUBLE_ESCAPE_START: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // U+002F SOLIDUS (/) // U+003E GREATER-THAN SIGN (>) // If the temporary buffer is the string "script", then switch to the script // data double escaped state. Otherwise, switch to the script data escaped // state. Emit the current input character as a character token. if (isSpace(cc) || cc === CC_SOLIDUS || cc === CC_GREATER_THAN) { state = scriptMatch === 6 ? STATE_SCRIPT_DATA_DOUBLE_ESCAPED : STATE_SCRIPT_DATA_ESCAPED; pos++; } else if (isAsciiUpperAlpha(cc) || isAsciiLowerAlpha(cc)) { // ASCII alpha — advance the `"script"` match counter if the // lowercase form matches the next expected char, otherwise // snap to the sentinel so further chars can't revive a // match. No buffer allocation. const lower = isAsciiUpperAlpha(cc) ? cc + 0x20 : cc; if (scriptMatch < 6 && lower === "script".charCodeAt(scriptMatch)) { scriptMatch++; } else { scriptMatch = 7; } pos++; } else { // Anything else // Reconsume in the script data escaped state. state = STATE_SCRIPT_DATA_ESCAPED; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-state case STATE_SCRIPT_DATA_DOUBLE_ESCAPED: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the script data double escaped dash state. Emit a U+002D // HYPHEN-MINUS character token. if (cc === CC_HYPHEN_MINUS) { state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED_DASH; pos++; } else if (cc === CC_LESS_THAN) { // U+003C LESS-THAN SIGN (<) // Switch to the script data double escaped less-than sign state. Emit a // U+003C LESS-THAN SIGN character token. state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN; pos++; } else { // Anything else // Emit the current input character as a character token. // U+0000 NULL is an unexpected-null-character parse error. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } // Fast-forward ordinary double-escaped script text without re-entering // the state switch; stop on the significant code points above. pos++; while (pos < len) { const c2 = input.charCodeAt(pos); if ( c2 === CC_HYPHEN_MINUS || c2 === CC_LESS_THAN || c2 === CC_NULL ) { break; } pos++; } } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-dash-state case STATE_SCRIPT_DATA_DOUBLE_ESCAPED_DASH: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Switch to the script data double escaped dash dash state. Emit a U+002D // HYPHEN-MINUS character token. if (cc === CC_HYPHEN_MINUS) { state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED_DASH_DASH; pos++; } else if (cc === CC_LESS_THAN) { // U+003C LESS-THAN SIGN (<) // Switch to the script data double escaped less-than sign state. Emit a // U+003C LESS-THAN SIGN character token. state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN; pos++; } else { // Anything else // Switch to the script data double escaped state. Emit the current input // character as a character token. NULL is unexpected-null-character. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED; pos++; } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-dash-dash-state case STATE_SCRIPT_DATA_DOUBLE_ESCAPED_DASH_DASH: // Consume the next input character: // U+002D HYPHEN-MINUS (-) // Emit a U+002D HYPHEN-MINUS character token. if (cc === CC_HYPHEN_MINUS) { pos++; } else if (cc === CC_LESS_THAN) { // U+003C LESS-THAN SIGN (<) // Switch to the script data double escaped less-than sign state. Emit a // U+003C LESS-THAN SIGN character token. state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN; pos++; } else if (cc === CC_GREATER_THAN) { // U+003E GREATER-THAN SIGN (>) // Switch to the script data state. Emit a U+003E GREATER-THAN SIGN // character token. state = STATE_SCRIPT_DATA; pos++; } else { // Anything else // Switch to the script data double escaped state. Emit the current input // character as a character token. NULL is unexpected-null-character. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); } state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED; pos++; } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-less-than-sign-state case STATE_SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN: // Consume the next input character: // U+002F SOLIDUS (/) // Set the temporary buffer to the empty string. Switch to the script data // double escape end state. Emit a U+002F SOLIDUS character token. if (cc === CC_SOLIDUS) { scriptMatch = 0; state = STATE_SCRIPT_DATA_DOUBLE_ESCAPE_END; pos++; } else { // Anything else // Reconsume in the script data double escaped state. state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escape-end-state case STATE_SCRIPT_DATA_DOUBLE_ESCAPE_END: // Consume the next input character: // U+0009 CHARACTER TABULATION (tab) // U+000A LINE FEED (LF) // U+000C FORM FEED (FF) // U+0020 SPACE // U+002F SOLIDUS (/) // U+003E GREATER-THAN SIGN (>) // If the temporary buffer is the string "script", then switch to the script // data escaped state. Otherwise, switch to the script data double escaped // state. Emit the current input character as a character token. if (isSpace(cc) || cc === CC_SOLIDUS || cc === CC_GREATER_THAN) { state = scriptMatch === 6 ? STATE_SCRIPT_DATA_ESCAPED : STATE_SCRIPT_DATA_DOUBLE_ESCAPED; pos++; } else if (isAsciiUpperAlpha(cc) || isAsciiLowerAlpha(cc)) { // ASCII alpha — advance the `"script"` match counter if the // lowercase form matches the next expected char, otherwise // snap to the sentinel so further chars can't revive a // match. No buffer allocation. const lower = isAsciiUpperAlpha(cc) ? cc + 0x20 : cc; if (scriptMatch < 6 && lower === "script".charCodeAt(scriptMatch)) { scriptMatch++; } else { scriptMatch = 7; } pos++; } else { // Anything else // Reconsume in the script data double escaped state. state = STATE_SCRIPT_DATA_DOUBLE_ESCAPED; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#plaintext-state case STATE_PLAINTEXT: // Consume the next input character: // U+0000 NULL is an unexpected-null-character parse error. // Anything else: emit the current input character. if (cc === CC_NULL) { reportError("unexpected-null-character", pos, pos + 1, "warning"); pos++; } else { // Fast-forward to the next NULL (or EOF) in one native scan. pos++; const nul = input.indexOf("\0", pos); pos = nul === -1 ? len : nul; } break; // https://html.spec.whatwg.org/multipage/parsing.html#character-reference-state case STATE_CHARACTER_REFERENCE: // Set the temporary buffer to the empty string. Append a U+0026 // AMPERSAND (&) character to the temporary buffer. // `charRefStart` points at that `&` (one before the current pos). charRefStart = pos - 1; // Consume the next input character: if (isAsciiAlphanumeric(cc)) { // ASCII alphanumeric // Reconsume in the named character reference state. state = STATE_NAMED_CHARACTER_REFERENCE; // Reconsume } else if (cc === CC_NUMBER_SIGN) { // U+0023 NUMBER SIGN (#) // Append the current input character to the temporary buffer. // Set the character reference code to zero. Switch to the // numeric character reference state. charRefCode = 0; state = STATE_NUMERIC_CHARACTER_REFERENCE; pos++; } else { // Anything else // Flush code points consumed as a character reference. // Reconsume in the return state. state = returnState; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#named-character-reference-state case STATE_NAMED_CHARACTER_REFERENCE: { // Consume the maximum number of characters possible where the // consumed characters are one of the identifiers in the first // column of the named character references table. // // We measure the longest run of ASCII alphanumeric characters // (capped at MAX_ENTITY_NAME_LEN - 1 since the optional `;` is // handled separately), then walk that run from longest to // shortest looking for the first prefix that exists in the // entity table (with a trailing `;` if present, otherwise the // legacy bare form). let runLen = 0; while ( pos + runLen < len && isAsciiAlphanumeric(input.charCodeAt(pos + runLen)) && runLen < MAX_ENTITY_NAME_LEN - 1 ) { runLen++; } const hasSemicolon = pos + runLen < len && input.charCodeAt(pos + runLen) === CC_SEMICOLON; namedEntityConsumed = 0; let matchedWithSemicolon = false; // Try the full run with its trailing `;` first — the overwhelmingly // common case (`&`, ` `, …) then needs exactly one slice. if (hasSemicolon && runLen > 0) { const withSemicolon = input.slice(pos, pos + runLen + 1); if (HTML_ENTITIES[withSemicolon] !== undefined) { namedEntityConsumed = runLen + 1; matchedWithSemicolon = true; } } if (namedEntityConsumed === 0) { // Slice the candidate run once; prefixes come from this short // string instead of re-slicing the input per length. const run = input.slice(pos, pos + runLen); for (let n = runLen; n > 0; n--) { const bare = n === runLen ? run : run.slice(0, n); if (HTML_ENTITIES[bare] !== undefined) { namedEntityConsumed = n; break; } } } if (namedEntityConsumed > 0) { // A legacy match without a trailing `;` is a // missing-semicolon-after-character-reference parse error, // except for the spec's historical attribute rule: when // consumed in an attribute value and the next char is `=` or // ASCII alphanumeric, the reference is left undecoded silently. if (!matchedWithSemicolon) { const next = input.charCodeAt(pos + namedEntityConsumed); const inAttribute = returnState === STATE_ATTRIBUTE_VALUE_DOUBLE_QUOTED || returnState === STATE_ATTRIBUTE_VALUE_SINGLE_QUOTED || returnState === STATE_ATTRIBUTE_VALUE_UNQUOTED; if (!( inAttribute && (next === CC_EQUALS || isAsciiAlphanumeric(next)) )) { reportError( "missing-semicolon-after-character-reference", pos + namedEntityConsumed, pos + namedEntityConsumed + 1, "warning" ); } } pos += namedEntityConsumed; state = returnState; } else { // No match — flush code points consumed as a character // reference. Switch to the ambiguous ampersand state. state = STATE_AMBIGUOUS_AMPERSAND; } break; } // https://html.spec.whatwg.org/multipage/parsing.html#ambiguous-ampersand-state case STATE_AMBIGUOUS_AMPERSAND: // Consume the next input character: if (isAsciiAlphanumeric(cc)) { // ASCII alphanumeric // If the character reference was consumed as part of an // attribute, then append the current input character to the // current attribute's value. Otherwise, emit the current // input character as a character token. pos++; } else if (cc === CC_SEMICOLON) { // U+003B SEMICOLON (;) // This is an unknown-named-character-reference parse error. // Reconsume in the return state. reportError( "unknown-named-character-reference", pos, pos + 1, "warning" ); state = returnState; // Reconsume } else { // Anything else // Reconsume in the return state. state = returnState; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-state case STATE_NUMERIC_CHARACTER_REFERENCE: // Set the character reference code to zero (0). // Consume the next input character: if (cc === 0x78 || cc === 0x58) { // U+0078 LATIN SMALL LETTER X // U+0058 LATIN CAPITAL LETTER X // Append the current input character to the temporary // buffer. Switch to the hexadecimal character reference // start state. state = STATE_HEXADECIMAL_CHARACTER_REFERENCE_START; pos++; } else { // Anything else // Reconsume in the decimal character reference start state. state = STATE_DECIMAL_CHARACTER_REFERENCE_START; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#hexadecimal-character-reference-start-state case STATE_HEXADECIMAL_CHARACTER_REFERENCE_START: // Consume the next input character: // ASCII hex digit: reconsume in the hexadecimal character reference state. // Anything else: absence-of-digits-in-numeric-character-reference parse // error. Flush code points consumed as a character reference. Reconsume // in the return state. if (isAsciiHexDigit(cc)) { state = STATE_HEXADECIMAL_CHARACTER_REFERENCE; } else { reportError( "absence-of-digits-in-numeric-character-reference", pos, pos + 1, "warning" ); state = returnState; } // Reconsume break; // https://html.spec.whatwg.org/multipage/parsing.html#decimal-character-reference-start-state case STATE_DECIMAL_CHARACTER_REFERENCE_START: // Consume the next input character: // ASCII digit: reconsume in the decimal character reference state. // Anything else: absence-of-digits-in-numeric-character-reference parse // error. Flush code points consumed as a character reference. Reconsume // in the return state. if (isAsciiDigit(cc)) { state = STATE_DECIMAL_CHARACTER_REFERENCE; } else { reportError( "absence-of-digits-in-numeric-character-reference", pos, pos + 1, "warning" ); state = returnState; } // Reconsume break; // https://html.spec.whatwg.org/multipage/parsing.html#hexadecimal-character-reference-state case STATE_HEXADECIMAL_CHARACTER_REFERENCE: // Consume the next input character: if (isAsciiHexDigit(cc)) { // ASCII digit / upper hex / lower hex // Multiply the character reference code by 16. Add a numeric // version of the current input character to the character // reference code. Stop accumulating once past the Unicode // range so the value can't overflow (still flags as // outside-range at the end). if (charRefCode < 0x110000) { const v = cc <= 0x39 ? cc - 0x30 : (cc | 0x20) - 0x61 + 10; charRefCode = charRefCode * 16 + v; } pos++; } else if (cc === CC_SEMICOLON) { // U+003B SEMICOLON // Switch to the numeric character reference end state. state = STATE_NUMERIC_CHARACTER_REFERENCE_END; pos++; } else { // Anything else // This is a missing-semicolon-after-character-reference // parse error. Reconsume in the numeric character reference // end state. reportError( "missing-semicolon-after-character-reference", pos, pos + 1, "warning" ); state = STATE_NUMERIC_CHARACTER_REFERENCE_END; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#decimal-character-reference-state case STATE_DECIMAL_CHARACTER_REFERENCE: // Consume the next input character: if (isAsciiDigit(cc)) { // ASCII digit // Multiply the character reference code by 10. Add a numeric // version of the current input character (subtract 0x0030 // from the character's code point) to the character reference // code. Stop accumulating once past the Unicode range so the // value can't overflow (still flags as outside-range). if (charRefCode < 0x110000) { charRefCode = charRefCode * 10 + (cc - 0x30); } pos++; } else if (cc === CC_SEMICOLON) { // U+003B SEMICOLON // Switch to the numeric character reference end state. state = STATE_NUMERIC_CHARACTER_REFERENCE_END; pos++; } else { // Anything else // This is a missing-semicolon-after-character-reference // parse error. Reconsume in the numeric character reference // end state. reportError( "missing-semicolon-after-character-reference", pos, pos + 1, "warning" ); state = STATE_NUMERIC_CHARACTER_REFERENCE_END; // Reconsume } break; // https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state case STATE_NUMERIC_CHARACTER_REFERENCE_END: // Check the character reference code and report the matching // WHATWG validation parse error. validateNumericReference(pos); // Flush code points consumed as a character reference. // Switch to the return state. state = returnState; // Reconsume break; /* istanbul ignore next -- @preserve: defensive fallback, all states are explicit above */ default: pos++; } } // Handle EOF in non-data states per the WHATWG spec. // // Each in-progress comment / doctype / cdata / tag emits its partial // token range plus a corresponding `eof-in-X` parse error. Severity is // `"error"` because the emitted token offset range is incomplete (missing // trailing `-->`, `>`, `]]>`, etc.). For data / `<` / `</` / `<!`-only // inputs we emit `eof-before-tag-name` and fall through to flush the // pending text span (which still contains the lone `<`). // EOF inside a character-reference state: run the end-of-reference // processing (numeric end states never run when the reference ends at // EOF), then resume in the return state for the branches below. if ( state >= STATE_CHARACTER_REFERENCE && state <= STATE_NUMERIC_CHARACTER_REFERENCE_END ) { if ( state === STATE_NUMERIC_CHARACTER_REFERENCE || state === STATE_HEXADECIMAL_CHARACTER_REFERENCE_START || state === STATE_DECIMAL_CHARACTER_REFERENCE_START ) { // No digits before EOF. reportError( "absence-of-digits-in-numeric-character-reference", len, len, "warning" ); } else if ( state === STATE_HEXADECIMAL_CHARACTER_REFERENCE || state === STATE_DECIMAL_CHARACTER_REFERENCE ) { // Digits but no closing `;` before EOF. reportError( "missing-semicolon-after-character-reference", len, len, "warning" ); validateNumericReference(len); } else if (state === STATE_NUMERIC_CHARACTER_REFERENCE_END) { validateNumericReference(len); } state = returnState; } if ( (state >= STATE_TAG_NAME && state <= STATE_SELF_CLOSING_START_TAG) || state === STATE_RCDATA_END_TAG_NAME || state === STATE_RAWTEXT_END_TAG_NAME || state === STATE_SCRIPT_DATA_END_TAG_NAME || state === STATE_SCRIPT_DATA_ESCAPED_END_TAG_NAME ) { // EOF mid-tag — emit the partial open/close tag at EOF so the // consumer still sees the tag. This is a deliberate deviation // from the spec's per-character emission model: rather than // dropping the in-progress tag, we emit its offset range up to EOF. reportError("eof-in-tag", len, len, "error"); // If we hit EOF mid-attribute-name, the name runs to EOF. Set // attributeNameEnd here so the emitted attribute range is valid. if (state === STATE_ATTRIBUTE_NAME && attributeNameStart !== -1) { attributeNameEnd = len; } if (attributeNameStart !== -1) emitAttribute(len); // If we hit EOF before the tag-name end was recorded, the name runs // to EOF. `tagNameEnd` may carry over from a previously emitted tag, // so reset it whenever it's missing or stale (less than `tagNameStart`) // — covers `<div` open-tag EOFs as well as `<title>x</tit` and other // content-mode end-tag-name EOFs. if (tagNameStart !== -1 && tagNameEnd < tagNameStart) { tagNameEnd = len; } flushText(tagStart); pos = input.charCodeAt(tagStart + 1) === CC_SOLIDUS ? emitCloseTag(len) : emitOpenTag(len, false); } else if ( (state >= STATE_COMMENT_START && state <= STATE_BOGUS_COMMENT) || (state >= STATE_COMMENT_LESS_THAN_SIGN && state <= STATE_COMMENT_LESS_THAN_SIGN_BANG_DASH_DASH) || state === STATE_MARKUP_DECLARATION_OPEN ) { // EOF in markup-declaration-open takes the spec's "anything else" // branch: incorrectly-opened-comment, then a bogus comment (which has // no EOF error of its own). Bogus comments at EOF are likewise normal. if (state === STATE_MARKUP_DECLARATION_OPEN) { reportError("incorrectly-opened-comment", commentStart, len, "warning"); // The bogus comment this becomes has nothing left to consume. commentDataStart = len; commentDataEnd = len; } else if (state !== STATE_BOGUS_COMMENT) { reportError("eof-in-comment", len, len, "error"); } if (callbacks.comment !== undefined) { pos = callbacks.comment( input, commentStart, len, commentDataStart, commentDataEnd ); } } else if (state >= STATE_CDATA_SECTION && state <= STATE_CDATA_SECTION_END) { reportError("eof-in-cdata", len, len, "error"); if (callbacks.comment !== undefined) { // No `]]>` closed it, so everything after `<![CDATA[` is content. pos = callbacks.comment(input, commentStart, len, commentDataStart, len); } } else if (state >= STATE_DOCTYPE && state <= STATE_BOGUS_DOCTYPE) { // EOF in bogus DOCTYPE emits the token with no parse error (spec). if (state !== STATE_BOGUS_DOCTYPE) { reportError("eof-in-doctype", len, len, "error"); doctypeForceQuirks = true; } if (callbacks.doctype !== undefined) { pos = callbacks.doctype( input, commentStart, len, doctypeNameStart, doctypeNameEnd, doctypePublicStart, doctypePublicEnd, doctypeSystemStart, doctypeSystemEnd, doctypeForceQuirks ); } } else { if ( state === STATE_SCRIPT_DATA_ESCAPED || state === STATE_SCRIPT_DATA_ESCAPED_DASH || state === STATE_SCRIPT_DATA_ESCAPED_DASH_DASH || state === STATE_SCRIPT_DATA_ESCAPED_LESS_THAN_SIGN || state === STATE_SCRIPT_DATA_DOUBLE_ESCAPED || state === STATE_SCRIPT_DATA_DOUBLE_ESCAPED_DASH || state === STATE_SCRIPT_DATA_DOUBLE_ESCAPED_DASH_DASH || state === STATE_SCRIPT_DATA_DOUBLE_ESCAPED_LESS_THAN_SIGN || state === STATE_SCRIPT_DATA_DOUBLE_ESCAPE_END ) { // Inside `<script><!-- … ` at EOF — spec calls this an // eof-in-script-html-comment-like-text parse error. The // less-than-sign and double-escape-end states reconsume back // into the (double-)escaped state on EOF per spec, which then // hits this same error. reportError("eof-in-script-html-comment-like-text", len, len, "error"); } else if (state === STATE_TAG_OPEN || state === STATE_END_TAG_OPEN) { // `<` or `</` with nothing after; spec calls this // eof-before-tag-name. The lone `<` / `</` is preserved in the // pending text span which is flushed below. reportError("eof-before-tag-name", len, len, "warning"); } if (textStart < len && callbacks.text !== undefined) { callbacks.text(input, textStart, len); } } return pos; }; // WHATWG numeric-character-reference-end Windows-1252 remap table for the // 0x80-0x9F range. Per spec these C1 control code points decode to the // corresponding Windows-1252 glyph (with a parse error) rather than to the // raw C1 control character. const NUMERIC_C1_REMAP = { 0x80: "€", 0x82: "‚", 0x83: "ƒ", 0x84: "„", 0x85: "…", 0x86: "†", 0x87: "‡", 0x88: "ˆ", 0x89: "‰", 0x8a: "Š", 0x8b: "‹", 0x8c: "Œ", 0x8e: "Ž", 0x91: "‘", 0x92: "’", 0x93: "“", 0x94: "”", 0x95: "•", 0x96: "–", 0x97: "—", 0x98: "˜", 0x99: "™", 0x9a: "š", 0x9b: "›", 0x9c: "œ", 0x9e: "ž", 0x9f: "Ÿ" }; /** * @param {number} code numeric character reference code point * @returns {string} decoded character per WHATWG remap rules */ const decodeNumericReference = (code) => { // Per WHATWG numeric-character-reference-end-state: // - 0x00, > 0x10FFFF, or surrogate (0xD800-0xDFFF) -> U+FFFD. // - 0x80-0x9F -> Windows-1252 remap (above). // - Anything else (including noncharacters and C0 controls) -> the // code point itself; we don't surface the spec's parse-error // classes here since decoding is happening after the scanner ran. if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) { return "�"; } if (code >= 0x80 && code <= 0x9f) { const remapped = /** @type {Record<number, string>} */ (NUMERIC_C1_REMAP)[ code ]; if (remapped !== undefined) return remapped; } return String.fromCodePoint(code); }; /** * Decode a single matched character reference. * @param {string} match the matched reference text * @param {number} nextCharCode char code following the match in the source (NaN at the end) * @param {boolean=} isAttribute true when the match came from an attribute value * @returns {string} decoded text, or `match` itself when it stays literal */ /** * Decode a named character reference spanning `[start, end)` of `str` (the * `&` at `start`, the optional `;` included). Slices the name once; WHATWG * longest-prefix and consumed-as-part-of-an-attribute semantics as before. * @param {string} str the raw string * @param {number} start offset of the reference's `&` * @param {number} end offset just past the reference (past the `;` if present) * @param {number} nextCharCode char code following the reference (NaN at the end) * @param {boolean=} isAttribute true when the reference came from an attribute value * @returns {string | undefined} decoded text, or `undefined` when it stays literal */ const decodeNamedReference = (str, start, end, nextCharCode, isAttribute) => { // Mirrors the old `match.slice(1)`: the name keeps its trailing `;`. const name = str.slice(start + 1, end); const matchEndsWithSemi = name.charCodeAt(name.length - 1) === 0x3b; // Attribute-context guard: if the entity match didn't end with `;` // and the next character in the source is `=` or ASCII // alphanumeric, the WHATWG spec says to flush the literal text // rather than decode. The greedy scan already absorbed any // trailing alphanumerics, so the only candidate "next char" here // is `=` (or any non-alphanumeric). if (isAttribute && !matchEndsWithSemi && nextCharCode === 0x3d /* = */) { return undefined; } // Fast path: the scan usually captures exactly one entity (`&`, // `<`, ` `, ...), so the whole `name` is the match. if (name.length <= MAX_ENTITY_NAME_LEN) { const full = HTML_ENTITIES[name]; if (full !== undefined) return full; } // Cap the longest-prefix search at MAX_ENTITY_NAME_LEN so pathological // inputs like `&` + thousands of alphanumerics stay linear-time. // Anything past that cap can't possibly match and is appended // verbatim as part of `name.slice(i)`. The full-length case was just // handled above, so start one shorter when it's the cap. const searchLen = name.length > MAX_ENTITY_NAME_LEN ? MAX_ENTITY_NAME_LEN : name.length - 1; for (let i = searchLen; i > 0; i--) { const prefix = name.slice(0, i); if (HTML_ENTITIES[prefix] !== undefined) { // Attribute-context longest-prefix guard: if the matched // prefix doesn't end with `;` and the leftover starts with // an alphanumeric character, leave literal per WHATWG. if ( isAttribute && i < name.length && prefix.charCodeAt(prefix.length - 1) !== 0x3b ) { return undefined; } return HTML_ENTITIES[prefix] + name.slice(i); } } return undefined; }; /** @typedef {{ text: string, map: number[] | undefined }} DecodedEntitiesWithMap */ /** * Decode HTML character references in a string. Handles all numeric * references (with WHATWG remap of 0x00, surrogates, out-of-range, and the * C1 Windows-1252 table) and the full WHATWG named character references * table. Unknown or malformed references are left as literal text. * * When `isAttribute` is `true`, applies the WHATWG * "consumed-as-part-of-an-attribute" rule: a named reference without a * trailing `;` whose next character is `=` or ASCII alphanumeric is left * undecoded, so e.g. `&=foo` stays literal in an attribute value but * decodes to `&=foo` in text. * * With `withMap` the result also carries a boundary map from the decoded * string back to raw offsets, so spans computed on the decoded text (e.g. * srcset candidate URLs) can be translated to source ranges. `map[i]` is the * raw offset of decoded boundary `i` (`0..text.length`); boundaries inside a * reference's decoded text map to the reference start. `map` is `undefined` * when nothing was decoded (then `text === str`). * @overload * @param {string} str the raw string from the token slice * @param {boolean=} isAttribute true if `str` came from an attribute value * @returns {string} decoded string */ /** * @overload * @param {string} str the raw string from the token slice * @param {boolean | undefined} isAttribute true if `str` came from an attribute value * @param {true} withMap also build the decoded-to-raw boundary map * @returns {DecodedEntitiesWithMap} decoded text and offset map */ /** * @param {string} str the raw string from the token slice * @param {boolean=} isAttribute true if `str` came from an attribute value * @param {boolean=} withMap also build the decoded-to-raw boundary map * @returns {string | DecodedEntitiesWithMap} decoded string (with map when `withMap`) */ const decodeEntities = (str, isAttribute, withMap) => { // Hand-rolled scan of one of three reference forms, each with an optional // trailing `;` — `&#x<hex>+` / `&#<dec>+` / `&<alpha><alnum>*` (kept separate // so `Ab` doesn't eat the `b` as hex): entity-dense text pays no regex // machinery or per-match object, and a string whose references all stay // literal is returned unchanged without rebuilding. Map bookkeeping is // gated on `withMap` at the rare per-decoded-reference points, so the // dominant no-map mode pays nothing for the unified implementation. let amp = str.indexOf("&"); if (amp === -1) return withMap ? { text: str, map: undefined } : str; let out = ""; let last = 0; /** @type {number[] | undefined} */ let map; do { // `end` stays 0 when the `&` doesn't start a well-formed reference; a // numeric reference's code point accumulates during the digit scan // (saturating above the code-point range — every overflow decodes the // same replacement) so it needs no slice at all. let end = 0; let code = -1; let cc = str.charCodeAt(amp + 1); if (cc === 0x23 /* # */) { cc = str.charCodeAt(amp + 2); let p; if (cc === 0x78 /* x */ || cc === 0x58 /* X */) { code = 0; p = amp + 3; while (isAsciiHexDigit((cc = str.charCodeAt(p)))) { code = code * 16 + (cc <= 0x39 ? cc - 0x30 : (cc | 0x20) - 0x57); if (code > 0x10ffff) code = 0x110000; p++; } if (p > amp + 3) end = p; } else { code = 0; p = amp + 2; while (isAsciiDigit((cc = str.charCodeAt(p)))) { code = code * 10 + (cc - 0x30); if (code > 0x10ffff) code = 0x110000; p++; } if (p > amp + 2) end = p; } } else if (isAsciiAlpha(cc)) { code = -1; let p = amp + 2; while (isAsciiAlphanumeric(str.charCodeAt(p))) p++; end = p; } if (end !== 0) { if (str.charCodeAt(end) === 0x3b /* ; */) end++; // Numeric references always decode; named ones may stay literal. const decoded = code >= 0 ? decodeNumericReference(code) : decodeNamedReference( str, amp, end, str.charCodeAt(end), isAttribute ); if (decoded !== undefined) { if (withMap) map = _mapReference(map, last, amp, decoded.length); out += str.slice(last, amp); out += decoded; last = end; } amp = str.indexOf("&", end); } else { amp = str.indexOf("&", amp + 1); } } while (amp !== -1); if (last === 0) return withMap ? { text: str, map: undefined } : str; const text = out + str.slice(last); return withMap ? _finishMap(text, /** @type {number[]} */ (map), last, str.length) : text; }; // Map-mode bookkeeping for `decodeEntities`, kept out of the scanner body so // the dominant no-map mode stays small enough to inline. /** * @param {number[] | undefined} map boundary map so far * @param {number} last raw offset the literal run starts at * @param {number} amp raw offset of the decoded reference * @param {number} decodedLength length of the decoded text * @returns {number[]} the map, with the literal run's and reference's boundaries appended */ const _mapReference = (map, last, amp, decodedLength) => { if (map === undefined) map = []; for (let r = last; r < amp; r++) map.push(r); for (let i = 0; i < decodedLength; i++) map.push(amp); return map; }; /** * @param {string} text decoded text * @param {number[]} map boundary map so far * @param {number} last raw offset of the trailing literal run * @param {number} rawLength raw string length * @returns {DecodedEntitiesWithMap} the finished result */ const _finishMap = (text, map, last, rawLength) => { for (let r = last; r < rawLength; r++) map.push(r); map.push(rawLength); return { text, map }; }; /** * Escape a string per the WHATWG "escape a string" algorithm * (https://html.spec.whatwg.org/multipage/parsing.html#escapingString) in * attribute mode: `&`, U+00A0 and `"`. CR/LF are additionally encoded as * numeric references so the result stays single-line (usable in a `data:` * URI). Single pass, returns the input unchanged when nothing needs escaping. * @param {string} s string to escape * @returns {string} HTML attribute-safe string */ const escapeAttribute = (s) => { // fast path: native indexOf scans beat any per-character loop if ( !s.includes("&") && !s.includes('"') && !s.includes("\u00A0") && !s.includes("\n") && !s.includes("\r") ) { return s; } let out = ""; let last = 0; for (let i = 0; i < s.length; i++) { let rep; switch (s.charCodeAt(i)) { case CC_AMPERSAND: rep = "&"; break; case CC_QUOTATION_MARK: rep = """; break; case CC_NO_BREAK_SPACE: rep = " "; break; case CC_LF: rep = " "; break; case CC_CR: rep = " "; break; default: continue; } out += s.slice(last, i) + rep; last = i + 1; } return out + s.slice(last); }; /** * Escape a string per the WHATWG "escape a string" algorithm in text mode: * `&`, U+00A0, `<` and `>`. CR/LF are additionally encoded as numeric * references so the result stays single-line (usable in a `data:` URI). * Single pass, returns the input unchanged when nothing needs escaping. * @param {string} s string to escape * @returns {string} HTML text-content-safe string */ const escapeText = (s) => { // fast path: native indexOf scans beat any per-character loop if ( !s.includes("&") && !s.includes("<") && !s.includes(">") && !s.includes("\u00A0") && !s.includes("\n") && !s.includes("\r") ) { return s; } let out = ""; let last = 0; for (let i = 0; i < s.length; i++) { let rep; switch (s.charCodeAt(i)) { case CC_AMPERSAND: rep = "&"; break; case CC_LESS_THAN: rep = "<"; break; case CC_GREATER_THAN: rep = ">"; break; case CC_NO_BREAK_SPACE: rep = " "; break; case CC_LF: rep = " "; break; case CC_CR: rep = " "; break; default: continue; } out += s.slice(last, i) + rep; last = i + 1; } return out + s.slice(last); }; /** * @param {string} name meta name * @param {string} content meta content * @returns {string} meta tag */ const metaTag = (name, content) => { // og: uses property=; all others (including twitter:) use name= const attr = name.startsWith("og:") ? `property="${escapeAttribute(name)}"` : `name="${escapeAttribute(name)}"`; return `<meta ${attr} content="${escapeAttribute(content)}">`; }; /** * @param {string | { href: string, target?: string }} base base option * @returns {string} base tag */ const baseTag = (base) => { const href = typeof base === "string" ? base : base.href; const targetAttr = typeof base === "object" && base.target ? ` target="${escapeAttribute(base.target)}"` : ""; return `<base href="${escapeAttribute(href)}"${targetAttr}>`; }; /** * Serializes head tags in spec order — charset, base, meta, title — for a page * built from scratch; an authored page merges the same options in `parse`. * @param {OutputHtmlOptions} opts html options * @returns {string} head tags string */ const buildHeadTags = (opts) => { let out = ""; const meta = opts.meta; if (meta && meta.charset) { out += `<meta charset="${escapeAttribute(meta.charset)}">`; } if (opts.base) out += baseTag(opts.base); if (meta) { for (const [name, content] of Object.entries(meta)) { if (name === "charset") continue; out += metaTag(name, content); } } if (opts.title) { out += `<title>${escapeText(opts.title)}</title>`; } return out; }; // cspell:ignore definitionurl malignmark mglyph selectedcontent megamorphic attributeless rowspan imagesizes novalidate maxlength // WHATWG HTML tree construction (https://html.spec.whatwg.org/multipage/parsing.html#tree-construction) // on top of tokenize. Scripting is always disabled (webpack is a build tool). // Namespaces (mirrors swc_html_ast::Namespace) const NS_HTML = 0; const NS_MATHML = 1; const NS_SVG = 2; /** * AST node `type` discriminators. Numeric for the same reason as the CSS * `NodeType`: compact integer `===` dispatch on the tree-construction and * visitor-walk hot paths. * @type {{ Document: 1, DocumentFragment: 2, Element: 3, Text: 4, Comment: 5, Doctype: 6 }} */ const NodeType = { Document: 1, DocumentFragment: 2, Element: 3, Text: 4, Comment: 5, Doctype: 6 }; /** * A contiguous run of attribute ids in the attribute columns — how a start-tag * token and an element refer to their attributes. `start` is the first id * (`count` 0 = none). * @typedef {{ start: number, count: number }} AttributeRun */ // Shared frozen empty run for attributeless elements and synthesized tags. const EMPTY_ATTRS = /** @type {AttributeRun} */ ( Object.freeze({ start: 0, count: 0 }) ); // === Struct-of-arrays AST backend === // One AST node = one integer id (`HtmlNodeRef`) indexing the parallel columns // below — no per-node object and no per-parent children array. Tree shape // lives in the four link columns (parent / firstChild / lastChild / // nextSibling); the only heap references are the string payload / attribute // name-and-value side arrays. Columns are module-level and reused // across parses (grown, shrunk on release only past `_COLUMN_SHRINK_CAPACITY`, // mirroring the CSS parser's column backend), so a steady-state parse // allocates almost nothing per node; consumers must fully read a tree before // the next `parseHtml` call. Id 0 is reserved as "no node" so the link columns // can use 0 as null. let _nodeCapacity = 0; let _nodeCount = 0; // Highest id `_nodeCount` ever reached this parse. Streaming rewinds the // allocator, so `_nodeCount` alone no longer bounds the ids that were written — // the release pass has to clear up to here or the recycled slots keep their // strings alive until a later parse happens to overwrite them. let _nodeHighWater = 0; /** `NodeType` per node */ let _nodeTypes = new Uint8Array(0); /** bits 0-1 namespace (`NS_*`), bit 2 self-closing (void), bit 3 template content */ let _nodeFlags = new Uint8Array(0); let _nodeStarts = new Int32Array(0); let _nodeEnds = new Int32Array(0); /** end offset of an element's opening tag (after `>`) */ let _nodeTagEnds = new Int32Array(0); /** end offset of an element's tag name */ let _nodeNameEnds = new Int32Array(0); /** * under `skip.text`, a raw-text element's body-end offset (`HtmlAstSkip`); for * a `<template>` (`FLAG_HAS_TEMPLATE`) it instead holds the content-fragment ref */ let _nodeContentEnds = new Int32Array(0); let _nodeParents = new Int32Array(0); let _nodeFirstChildren = new Int32Array(0); let _nodeLastChildren = new Int32Array(0); let _nodeNextSiblings = new Int32Array(0); /** @type {string[]} tag name / text data / comment data / doctype name */ const _nodeStrings = []; /** first attribute id of an element's contiguous run */ let _nodeAttrStarts = new Int32Array(0); /** attribute count of an element's run */ let _nodeAttrCounts = new Int32Array(0); // The single doctype node's public/system ids (a document inserts at most one // doctype node — later doctype tokens are ignored — so no column is needed). /** @type {string | null} */ let _doctypePublicId = null; /** @type {string | null} */ let _doctypeSystemId = null; const NS_MASK = 3; const FLAG_SELF_CLOSING = 4; // A `<template>` element: `_nodeContentEnds` holds its content-fragment ref // instead of a raw-text body-end offset (the two never coexist on one node). const FLAG_HAS_TEMPLATE = 8; /** @type {(el: HtmlNodeRef) => string} */ const _tagNameOf = (el) => _nodeStrings[el]; // A `<template>`'s content-fragment ref (in the shared `_nodeContentEnds` slot), // or 0 when `el` is not a template — the tree builder's spec "template contents". /** @type {(el: HtmlNodeRef) => HtmlDocumentFragment} */ const _templateContentOf = (el) => (_nodeFlags[el] & FLAG_HAS_TEMPLATE) !== 0 ? _nodeContentEnds[el] : 0; /** @type {(el: HtmlNodeRef) => number} */ const _namespaceOf = (el) => _nodeFlags[el] & NS_MASK; // === Attribute columns === // One attribute = one integer id into these columns; an element (and a // start-tag token) holds a contiguous run. The value string is derived from // the source by offset on read — `_attrValues` carries an override only for // valueless attributes (`""`) and offset-less adoption-agency clones — and the // html5lib serializer name is derived from the adjusted name plus one flag // bit, so per attribute only the interned name pointer is retained. let _attrCapacity = 0; let _attrCount = 0; // Elements a repeated `<html>` / `<body>` start tag merged attributes onto // (§13.2.6.4.4, §13.2.6.4.7). The merged names are not in the element's source // open tag, so the printer rebuilds that tag instead of echoing it. const _mergedAttrNodes = new Set(); let _attrNameStarts = new Int32Array(0); let _attrNameEnds = new Int32Array(0); let _attrValueStarts = new Int32Array(0); let _attrValueEnds = new Int32Array(0); /** bit 0: name has a `FOREIGN_ATTR_NS` serializer name (set on foreign adjust) */ let _attrFlags = new Uint8Array(0); /** @type {string[]} lowercased (foreign-content: adjusted) attribute name */ const _attrNames = []; /** @type {(string | null)[]} value override (null = slice the source by offset) */ const _attrValues = []; /** source of the current parse, for by-offset attribute values */ let _htmlSource = ""; /** * @param {number} need minimum capacity * @param {boolean=} exact allocate `need` directly (initial pre-size) instead of doubling */ const _growAttrColumns = (need, exact) => { let cap = _attrCapacity || 4096; if (exact) { if (need > cap) cap = need; } else { while (cap < need) cap *= 2; } const nameStart = new Int32Array(cap); nameStart.set(_attrNameStarts); _attrNameStarts = nameStart; const nameEnd = new Int32Array(cap); nameEnd.set(_attrNameEnds); _attrNameEnds = nameEnd; const valStart = new Int32Array(cap); valStart.set(_attrValueStarts); _attrValueStarts = valStart; const valEnd = new Int32Array(cap); valEnd.set(_attrValueEnds); _attrValueEnds = valEnd; const fl = new Uint8Array(cap); fl.set(_attrFlags); _attrFlags = fl; // Keep the string columns filled to capacity (see `_growNodeColumns`). for (let i = _attrNames.length; i < cap; i++) { _attrNames.push(""); _attrValues.push(null); } _attrCapacity = cap; }; /** @type {(name: string, value: string | null, nameStart: number, nameEnd: number, valueStart: number, valueEnd: number) => number} */ const _allocAttr = (name, value, nameStart, nameEnd, valueStart, valueEnd) => { const i = ++_attrCount; if (i >= _attrCapacity) _growAttrColumns(i + 1); _attrNameStarts[i] = nameStart; _attrNameEnds[i] = nameEnd; _attrValueStarts[i] = valueStart; _attrValueEnds[i] = valueEnd; _attrFlags[i] = 0; // Ids are sequential, so these indexed writes append (arrays stay packed). _attrNames[i] = name; _attrValues[i] = value; return i; }; /** @type {(i: number) => string} */ const _attrValueOf = (i) => { const v = _attrValues[i]; return v !== null ? v : _htmlSource.slice(_attrValueStarts[i], _attrValueEnds[i]); }; // Linear name lookup in a run — attribute lists are short, a loop beats a Map. /** @type {(start: number, count: number, name: string) => number} */ const _findAttr = (start, count, name) => { for (let i = start; i < start + count; i++) { if (_attrNames[i] === name) return i; } return 0; }; /** @type {(i: number) => number} exact copy of an attribute into a new id */ const _copyAttr = (i) => { const c = _allocAttr( _attrNames[i], _attrValues[i], _attrNameStarts[i], _attrNameEnds[i], _attrValueStarts[i], _attrValueEnds[i] ); _attrFlags[c] = _attrFlags[i]; return c; }; // html5lib serializer name, derived: a `FOREIGN_ATTR_NS`-adjusted attribute is // flagged (its name is the table key), and a camelCase-adjusted name contains // an uppercase letter (unadjusted names are always lowercased), serializing as // itself. Everything else serializes as the plain name (undefined here). /** @type {(i: number) => string | undefined} */ const _attrSerializedName = (i) => { if ((_attrFlags[i] & 1) !== 0) return FOREIGN_ATTR_NS[_attrNames[i]]; const name = _attrNames[i]; return /[A-Z]/.test(name) ? name : undefined; }; /** * @param {number} need minimum capacity * @param {boolean=} exact allocate `need` directly (initial pre-size) instead of doubling */ const _growNodeColumns = (need, exact) => { let cap = _nodeCapacity || 4096; // `exact` (the initial pre-size): allocate the estimate directly. Doubling // from 4096 would overshoot it to the next power of two (~2x) and waste that // capacity for the process lifetime. Incremental growth still doubles. if (exact) { if (need > cap) cap = need; } else { while (cap < need) cap *= 2; } const ty = new Uint8Array(cap); ty.set(_nodeTypes); _nodeTypes = ty; const fl = new Uint8Array(cap); fl.set(_nodeFlags); _nodeFlags = fl; const st = new Int32Array(cap); st.set(_nodeStarts); _nodeStarts = st; const en = new Int32Array(cap); en.set(_nodeEnds); _nodeEnds = en; const tagEnd = new Int32Array(cap); tagEnd.set(_nodeTagEnds); _nodeTagEnds = tagEnd; const nameEnd = new Int32Array(cap); nameEnd.set(_nodeNameEnds); _nodeNameEnds = nameEnd; const cEnd = new Int32Array(cap); cEnd.set(_nodeContentEnds); _nodeContentEnds = cEnd; const parent = new Int32Array(cap); parent.set(_nodeParents); _nodeParents = parent; const first = new Int32Array(cap); first.set(_nodeFirstChildren); _nodeFirstChildren = first; const last = new Int32Array(cap); last.set(_nodeLastChildren); _nodeLastChildren = last; const next = new Int32Array(cap); next.set(_nodeNextSiblings); _nodeNextSiblings = next; const aStart = new Int32Array(cap); aStart.set(_nodeAttrStarts); _nodeAttrStarts = aStart; const aCount = new Int32Array(cap); aCount.set(_nodeAttrCounts); _nodeAttrCounts = aCount; // Keep the string column filled to capacity so per-node writes are in-place // packed stores, not length-growing appends re-paying the regrowth cascade. for (let i = _nodeStrings.length; i < cap; i++) _nodeStrings.push(""); _nodeCapacity = cap; }; /** Start a new parse: invalidate all prior refs, release prior heap refs. */ const _resetAstColumns = () => { // Overwrite the used prefix (releases the prior parse's strings) instead of // truncating: a `length = 0` would right-size the backing store and make // every write of the next parse a length-growing append again. const written = _nodeCount > _nodeHighWater ? _nodeCount : _nodeHighWater; const usedNodes = Math.min(written, _nodeStrings.length - 1); for (let i = 0; i <= usedNodes; i++) _nodeStrings[i] = ""; const usedAttrs = Math.min(_attrCount, _attrNames.length - 1); for (let i = 0; i <= usedAttrs; i++) { _attrNames[i] = ""; _attrValues[i] = null; } _nodeCount = 0; _nodeHighWater = 0; _attrCount = 0; _mergedAttrNodes.clear(); _doctypePublicId = null; _doctypeSystemId = null; }; // The typed-array columns grow to the largest document ever parsed; above this // capacity they are re-shrunk on release so one pathological file can't pin // tens of MB at module level for the process lifetime (~46 bytes/node, // ~17 bytes/attribute of capacity across the columns). const _COLUMN_SHRINK_CAPACITY = 65536; // Release the side arrays' heap references (strings, attribute names/values) // once a walk has consumed the tree, so the retained columns don't pin the // parsed source until the next parse. const _releaseAstColumns = () => { // Kept regime: overwrite the used prefix in place (see `_resetAstColumns`); // oversized arrays are dropped entirely, mirroring the typed columns below. if (_nodeStrings.length > _COLUMN_SHRINK_CAPACITY) { _nodeStrings.length = 0; } else { const written = _nodeCount > _nodeHighWater ? _nodeCount : _nodeHighWater; const usedNodes = Math.min(written, _nodeStrings.length - 1); for (let i = 0; i <= usedNodes; i++) _nodeStrings[i] = ""; } if (_attrNames.length > _COLUMN_SHRINK_CAPACITY) { _attrNames.length = 0; _attrValues.length = 0; } else { const usedAttrs = Math.min(_attrCount, _attrNames.length - 1); for (let i = 0; i <= usedAttrs; i++) { _attrNames[i] = ""; _attrValues[i] = null; } } _nodeCount = 0; _nodeHighWater = 0; _attrCount = 0; _mergedAttrNodes.clear(); _htmlSource = ""; _doctypePublicId = null; _doctypeSystemId = null; if (_nodeCapacity > _COLUMN_SHRINK_CAPACITY) { _nodeCapacity = 0; _nodeTypes = new Uint8Array(0); _nodeFlags = new Uint8Array(0); _nodeStarts = new Int32Array(0); _nodeEnds = new Int32Array(0); _nodeTagEnds = new Int32Array(0); _nodeNameEnds = new Int32Array(0); _nodeContentEnds = new Int32Array(0); _nodeParents = new Int32Array(0); _nodeFirstChildren = new Int32Array(0); _nodeLastChildren = new Int32Array(0); _nodeNextSiblings = new Int32Array(0); _nodeAttrStarts = new Int32Array(0); _nodeAttrCounts = new Int32Array(0); } if (_attrCapacity > _COLUMN_SHRINK_CAPACITY) { _attrCapacity = 0; _attrNameStarts = new Int32Array(0); _attrNameEnds = new Int32Array(0); _attrValueStarts = new Int32Array(0); _attrValueEnds = new Int32Array(0); _attrFlags = new Uint8Array(0); } }; /** @type {(type: number, start: number, end: number) => HtmlNodeRef} */ // The element-only columns (tagEnds/nameEnds/contentEnds/attrStarts/ // attrCounts) are left stale here — they are only ever read for Element // nodes, and every Element writes them in mkEl/cloneSubtree. const _allocNode = (type, start, end) => { const i = ++_nodeCount; if (i >= _nodeCapacity) _growNodeColumns(i + 1); _nodeTypes[i] = type; _nodeFlags[i] = 0; _nodeStarts[i] = start; _nodeEnds[i] = end; _nodeParents[i] = 0; _nodeFirstChildren[i] = 0; _nodeLastChildren[i] = 0; _nodeNextSiblings[i] = 0; // `_nodeStrings[i]` is written by every caller right after this returns // (tag name / text / comment data; "" at the few string-less sites), as a // packed in-place store — no placeholder double-write here. return i; }; // Raw child append — no text merging, no `<template>` content redirect (the // tree builder layers those on top). `node` must be detached (`next` = 0). /** @type {(parent: HtmlNodeRef, node: HtmlNodeRef) => void} */ const _appendChild = (parent, node) => { _nodeParents[node] = parent; const last = _nodeLastChildren[parent]; if (last === 0) _nodeFirstChildren[parent] = node; else _nodeNextSiblings[last] = node; _nodeLastChildren[parent] = node; }; /** @type {(data: string, start: number, end: number) => HtmlNodeRef} */ const _makeTextNode = (data, start, end) => { const i = _allocNode(NodeType.Text, start, end); _nodeStrings[i] = data; return i; }; /** @type {(data: string, start: number, end: number) => HtmlNodeRef} */ const _makeCommentNode = (data, start, end) => { const i = _allocNode(NodeType.Comment, start, end); _nodeStrings[i] = data; return i; }; // Marker entry in the active-formatting-elements list (never a valid ref). const AFE_MARKER = -1; // Clone of an element's attribute run: keep name/value (and the serializer // name flag) but drop source offsets so the consumer doesn't emit a duplicate // dependency for the reopened element's spans. Values are materialized since // the offsets are gone. const cloneAttrs = (/** @type {HtmlElement} */ el) => { const start = _nodeAttrStarts[el]; const count = _nodeAttrCounts[el]; const newStart = _attrCount + 1; for (let i = start; i < start + count; i++) { const c = _allocAttr(_attrNames[i], _attrValueOf(i), -1, -1, -1, -1); _attrFlags[c] = _attrFlags[i]; } return { start: newStart, count }; }; // Merge a repeated `<html>`/`<body>` tag's attributes into the element: only // names not already present are added (in source order after the existing // ones). Runs are contiguous, so any addition re-allocates the whole run; the // old slots are orphaned (at most once per repeated tag, rare). const mergeAttrs = ( /** @type {HtmlElement} */ el, /** @type {AttributeRun} */ run ) => { const start = _nodeAttrStarts[el]; const count = _nodeAttrCounts[el]; let extra = 0; for (let i = run.start; i < run.start + run.count; i++) { if (_findAttr(start, count, _attrNames[i]) === 0) extra++; } if (extra === 0) return; const newStart = _attrCount + 1; for (let i = start; i < start + count; i++) _copyAttr(i); for (let i = run.start; i < run.start + run.count; i++) { if (_findAttr(start, count, _attrNames[i]) === 0) _copyAttr(i); } _nodeAttrStarts[el] = newStart; _nodeAttrCounts[el] = count + extra; _mergedAttrNodes.add(el); }; /** * A materialized attribute as returned by `A.attributes` (tests/tooling) — * the parser-facing representation is an id into the attribute columns, read * through the scalar `A.attr*` accessors. * @typedef {object} HtmlAttribute * @property {string} name lowercased (and, in foreign content, adjusted) attribute name * @property {string} value * @property {string=} serializedName name used by the html5lib tree serializer (foreign-namespaced) * @property {number} nameStart source offset, or -1 on adoption-agency clones * @property {number} nameEnd * @property {number} valueStart source offset, or -1 when valueless / on clones * @property {number} valueEnd */ /** * A node reference into the struct-of-arrays AST: an integer id indexing the * parallel `_h*` columns. Read fields through the exported accessor `A`. Refs * are only valid until the next `parseHtml` call — the columns are reused * across parses — so consume a tree fully before parsing again. * @typedef {number} HtmlNodeRef */ /** @typedef {HtmlNodeRef} HtmlElement ref to an Element node */ /** @typedef {HtmlNodeRef} HtmlText ref to a Text node */ /** @typedef {HtmlNodeRef} HtmlComment ref to a Comment node */ /** @typedef {HtmlNodeRef} HtmlDoctype ref to a Doctype node */ /** @typedef {HtmlNodeRef} HtmlDocument ref to the Document node */ /** @typedef {HtmlNodeRef} HtmlDocumentFragment ref to a DocumentFragment node */ /** @typedef {HtmlNodeRef} HtmlNode */ /** * An attribute reference: an integer id into the attribute columns, read * through the `A.attr*` accessors. Same validity contract as `HtmlNodeRef`. * @typedef {number} HtmlAttributeRef */ /** @typedef {{ start: number, end: number, tagEnd: number, nameEnd: number }} TagPos */ // Tree-construction token `type` discriminators. Numeric for the same reason // as `NodeType` / the CSS `TT_*` constants: the insertion modes dispatch on // `t.type` per token, and integer `===` beats string comparison there. const TOKEN_CHAR = 1; const TOKEN_COMMENT = 2; const TOKEN_DOCTYPE = 3; const TOKEN_START_TAG = 4; const TOKEN_END_TAG = 5; const TOKEN_EOF = 6; /** @typedef {{ type: typeof TOKEN_CHAR, data: string, start: number, end: number }} CharToken */ /** @typedef {{ type: typeof TOKEN_COMMENT, data: string, start: number, end: number }} CommentToken */ /** @typedef {{ type: typeof TOKEN_DOCTYPE, name: string, publicId: (string | null), systemId: (string | null), forceQuirks: boolean, start: number, end: number }} DoctypeToken */ /** @typedef {{ type: typeof TOKEN_START_TAG, name: string, attrs: AttributeRun, selfClosing: boolean, pos: TagPos, swallowNewline?: boolean }} StartTagToken */ /** @typedef {{ type: typeof TOKEN_END_TAG, name: string, pos: TagPos }} EndTagToken */ /** @typedef {{ type: typeof TOKEN_EOF }} EofToken */ /** * Internal token passed through the tree-construction insertion modes. * @typedef {CharToken | CommentToken | DoctypeToken | StartTagToken | EndTagToken | EofToken} Token */ /** * The tree builder reuses a single mutable token (with a reused `pos`) instead * of allocating one object per tokenizer callback. All fields are always * present so the shape never changes — keeping the `process`/insertion-mode * `t.*` reads monomorphic — and fields irrelevant to the current `type` carry * stale values that those handlers never read. Tokens that must outlive the * current callback (buffered table characters, synthesized re-dispatches) are * copied into fresh plain objects instead. * @typedef {{ type: number, name: string, data: string, attrs: AttributeRun, selfClosing: boolean, start: number, end: number, publicId: (string | null), systemId: (string | null), forceQuirks: boolean, swallowNewline: boolean, pos: TagPos }} MutableToken */ /** @typedef {{ parent: HtmlNodeRef, beforeNode: HtmlNodeRef }} InsertionPlace `beforeNode` 0 = plain append */ // Insertion modes (§13.2.4.1). Numeric for the same reason as the token and // `NodeType` enums: `runMode` dispatches on `mode` once per token. const MODE_INITIAL = 1; const MODE_BEFORE_HTML = 2; const MODE_BEFORE_HEAD = 3; const MODE_IN_HEAD = 4; const MODE_IN_HEAD_NOSCRIPT = 5; const MODE_AFTER_HEAD = 6; const MODE_IN_BODY = 7; const MODE_TEXT = 8; const MODE_IN_TABLE = 9; const MODE_IN_TABLE_TEXT = 10; const MODE_IN_CAPTION = 11; const MODE_IN_COLUMN_GROUP = 12; const MODE_IN_TABLE_BODY = 13; const MODE_IN_ROW = 14; const MODE_IN_CELL = 15; const MODE_IN_TEMPLATE = 16; const MODE_AFTER_BODY = 17; const MODE_IN_FRAMESET = 18; const MODE_AFTER_FRAMESET = 19; const MODE_AFTER_AFTER_BODY = 20; const MODE_AFTER_AFTER_FRAMESET = 21; // "in template" start-tag re-dispatch targets (§13.2.6.4.18). const TEMPLATE_START_TAG_MODES = new Map([ ["caption", MODE_IN_TABLE], ["colgroup", MODE_IN_TABLE], ["tbody", MODE_IN_TABLE], ["tfoot", MODE_IN_TABLE], ["thead", MODE_IN_TABLE], ["col", MODE_IN_COLUMN_GROUP], ["tr", MODE_IN_TABLE_BODY], ["td", MODE_IN_ROW], ["th", MODE_IN_ROW] ]); // MathML/SVG specials handled via namespace checks below. const isSpecial = (/** @type {HtmlElement} */ el) => { const ns = _namespaceOf(el); const tag = _tagNameOf(el); if (ns === NS_HTML) return SPECIAL.has(tag); if (ns === NS_MATHML) return MATHML_SPECIAL.has(tag); if (ns === NS_SVG) return SVG_SPECIAL.has(tag.toLowerCase()); return false; }; // `<font color|face|size>` breaks out of foreign content (§13.2.6.5). const hasFontBreakoutAttr = (/** @type {AttributeRun} */ run) => { for (let i = run.start; i < run.start + run.count; i++) { if (FONT_BREAKOUT_ATTRS.has(_attrNames[i])) return true; } return false; }; /** * Hash of the ASCII-lowercased `name` for the intern tables below; must stay * in sync with the range hash in `internLowerName`. * @param {string} name lowercase name * @returns {number} hash */ const hashLowerName = (name) => { let h = name.length; for (let i = 0; i < name.length; i++) { let c = name.charCodeAt(i); if (c >= 0x41 && c <= 0x5a) c += 0x20; h = (Math.imul(h, 31) + c) | 0; } return h; }; // Text-run scan classes for the `skip.text` fast path: 2 = stop the fast // path (& / NUL / CR), 1 = ASCII whitespace, 0 = ordinary text. const _TEXT_SCAN_CLASS = new Uint8Array(128); _TEXT_SCAN_CLASS[0x09] = 1; _TEXT_SCAN_CLASS[0x0a] = 1; _TEXT_SCAN_CLASS[0x0c] = 1; _TEXT_SCAN_CLASS[0x20] = 1; _TEXT_SCAN_CLASS[0x26] = 2; _TEXT_SCAN_CLASS[0x00] = 2; _TEXT_SCAN_CLASS[0x0d] = 2; /** * @param {Iterable<string>} names lowercase names to intern * @returns {{ mask: number, hashes: Int32Array, values: (string | string[] | undefined)[] }} open-addressed intern table */ const buildNameInternTable = (names) => { // Open-addressed table (~25% load): the per-name probe is one or two array // reads instead of a `Map#get`, and the tables are built once at startup. const unique = [...new Set(names)]; let size = 8; while (size < unique.length * 4) size <<= 1; const mask = size - 1; const hashes = new Int32Array(size); /** @type {(string | string[] | undefined)[]} */ const values = Array.from({ length: size }); for (const name of unique) { const h = hashLowerName(name); let slot = h & mask; while (values[slot] !== undefined && hashes[slot] !== h) { slot = (slot + 1) & mask; } const cur = values[slot]; if (cur === undefined) { hashes[slot] = h; values[slot] = name; } else if (typeof cur === "string") { values[slot] = [cur, name]; } else { cur.push(name); } } return { mask, hashes, values }; }; /** @typedef {ReturnType<typeof buildNameInternTable>} NameInternTable */ /** * The lowercased name for `input[start..end)`, returning the shared interned * string for known names — skipping the per-tag `slice().toLowerCase()` * allocation, and making the tree builder's many Set/Map lookups and `===` * comparisons on the name hit one string instance with a cached hash. * @param {NameInternTable} table intern table * @param {string} input source text * @param {number} start name start * @param {number} end name end * @returns {string} lowercased name */ const internLowerName = (table, input, start, end) => { let h = end - start; for (let i = start; i < end; i++) { let c = input.charCodeAt(i); if (c >= 0x41 && c <= 0x5a) c += 0x20; h = (Math.imul(h, 31) + c) | 0; } const { mask, hashes, values } = table; let slot = h & mask; for (;;) { const hit = values[slot]; if (hit === undefined) break; if (hashes[slot] === h) { if (typeof hit === "string") { if (rangeEqualsLowerCase(input, start, end, hit)) return hit; } else { for (let i = 0; i < hit.length; i++) { if (rangeEqualsLowerCase(input, start, end, hit[i])) return hit[i]; } } break; } slot = (slot + 1) & mask; } // Unknown name (custom element, data-* attribute, non-ASCII, …). Only ASCII // folds — a Unicode `toLowerCase` maps U+212A onto `k` and U+0130 onto two // characters, neither of which the tokenizer does. return _asciiLowerCase(input.slice(start, end)); }; // Every tag name the tree builder compares against (the sets above already // cover most of the spec), plus the remaining standard/foreign names so // ordinary documents intern every tag. const TAG_NAME_INTERN = buildNameInternTable([ ...VOID, ...SPECIAL, ...FORMATTING, ...HEADING, ...MATHML_TEXT_INTEGRATION, ...MATHML_SPECIAL, ...SVG_SPECIAL, ...HTML_SCOPE, ...TABLE_CONTEXT, ...VOID_FORMATTING, ...NO_DECODE_TEXT, ...FOREIGN_BREAKOUT, ...HEAD_ELEMENTS, ...Object.keys(SVG_TAG_ADJUST), ..."a abbr audio bdi bdo canvas cite data datalist del dfn dialog ins kbd label legend map mark math menuitem meter optgroup option output picture progress q rb rp rt rtc ruby samp selectedcontent slot span sub sup svg time u var video".split( " " ) ]); // Common attribute names (unknown ones — data-*, ARIA, events — fall back). const ATTR_NAME_INTERN = buildNameInternTable( "href src srcset sizes alt title class id style name type value content charset rel media target action method placeholder disabled checked selected multiple readonly required hidden tabindex role lang dir width height loading decoding async defer integrity crossorigin referrerpolicy nonce as for colspan rowspan span label max min step pattern autocomplete autofocus autoplay controls loop muted poster preload download ping imagesrcset imagesizes slot part is property http-equiv accept enctype novalidate maxlength minlength size cols rows wrap open scope headers datetime cite usemap ismap shape coords start reversed face color encoding xmlns".split( " " ) ); // Hoisted so the many `open.some(...)` "is there an open HTML <template>?" // checks reuse one predicate instead of allocating an arrow per call. const isHtmlTemplateEl = (/** @type {HtmlElement} */ e) => _tagNameOf(e) === "template" && _namespaceOf(e) === NS_HTML; /** * Shared empty skip set so the common (no-skip) call allocates nothing. * @type {HtmlAstSkip} */ const EMPTY_SKIP = Object.freeze({}); /** * Optional node kinds a consumer can drop from the AST for speed/memory. Each * is a pure output reduction — tree construction (and quirks detection) runs * unchanged, so element structure and offsets are identical either way. * @typedef {object} HtmlAstSkip * @property {boolean=} text drop every `Text` node. Raw-text element bodies (`<script>`/`<style>`/…) aren't emitted either — their content span is recorded as the element's `contentEnd` (see `RAW_TEXT_ELEMENTS`) so a consumer can read `[tagEnd, contentEnd]` by offset. For consumers that read text by offset (e.g. `HtmlParser`), never the html5lib serializer. * @property {boolean=} comments drop comment nodes entirely. Not for consumers that read comments (e.g. webpack magic comments). * @property {boolean=} doctype drop the doctype node; quirks-mode detection is unaffected. */ /** * @typedef {object} HtmlParseOptions * @property {string=} fragmentContext context element name for fragment parsing (e.g. `td`, `svg path`); omit for a full document * @property {HtmlAstSkip=} skip node kinds to omit from the AST (see `HtmlAstSkip`); omit to build the full tree */ /** * @param {string} name raw SVG tag name * @returns {string} case-adjusted SVG tag name */ const adjustSvgTag = (name) => /** @type {Record<string, string>} */ (SVG_TAG_ADJUST)[name] || name; /** * @param {string} name doctype name * @param {string | null} pub public id * @param {string | null} sys system id * @returns {boolean} whether the doctype forces quirks mode */ const isQuirky = (name, pub, sys) => { if (name !== "html") return true; const p = pub ? pub.toLowerCase() : null; const sl = sys ? sys.toLowerCase() : null; if (p !== null) { if (QUIRKY_EXACT.has(p)) return true; for (const pre of QUIRKY_PREFIXES) if (p.startsWith(pre)) return true; if ( sl === null && (p.startsWith("-//w3c//dtd html 4.01 frameset//") || p.startsWith("-//w3c//dtd html 4.01 transitional//")) ) { return true; } } if (sl === "http://www.ibm.com/data/dtd/v11/ibmxhtml1-transitional.dtd") { return true; } return false; }; const mkEl = ( /** @type {string} */ tagName, /** @type {number} */ ns, /** @type {AttributeRun} */ attrs, /** @type {TagPos | null | undefined} */ pos ) => { // Inlined element allocation: every column is written exactly once (the // generic _allocNode would zero five columns mkEl immediately overwrites). const el = ++_nodeCount; if (el >= _nodeCapacity) _growNodeColumns(el + 1); _nodeTypes[el] = NodeType.Element; // Void HTML elements are marked self-closing and never receive children. _nodeFlags[el] = ns === NS_HTML && VOID.has(tagName) ? ns | FLAG_SELF_CLOSING : ns; if (pos) { _nodeStarts[el] = pos.start; _nodeEnds[el] = pos.end; _nodeTagEnds[el] = pos.tagEnd; _nodeNameEnds[el] = pos.nameEnd; // End of a raw-text element's body under `skip.text` (defaults to the // body start, i.e. empty); lets consumers read `<script>`/`<style>` // content as [`tagEnd`, `contentEnd`] without a `Text` node. _nodeContentEnds[el] = pos.tagEnd; } else { _nodeStarts[el] = 0; _nodeEnds[el] = 0; _nodeTagEnds[el] = 0; _nodeNameEnds[el] = 0; _nodeContentEnds[el] = 0; } _nodeParents[el] = 0; _nodeFirstChildren[el] = 0; _nodeLastChildren[el] = 0; _nodeNextSiblings[el] = 0; _nodeAttrStarts[el] = attrs.start; _nodeAttrCounts[el] = attrs.count; _nodeStrings[el] = tagName; return el; }; /** * A `<template>`'s children live in its content fragment; inserting into a * template really inserts there (the spec's "template contents" redirect). * @param {HtmlNodeRef} parent container * @returns {HtmlNodeRef} effective container to link children into */ const effParent = (parent) => { const tc = _templateContentOf(parent); return tc !== 0 ? tc : parent; }; const isScopeBoundary = (/** @type {HtmlElement} */ el) => { if (_namespaceOf(el) === NS_HTML) return HTML_SCOPE.has(_tagNameOf(el)); if (_namespaceOf(el) === NS_MATHML) { return MATHML_SPECIAL.has(_tagNameOf(el)); } if (_namespaceOf(el) === NS_SVG) { return SVG_SPECIAL.has(_tagNameOf(el).toLowerCase()); } return false; }; // Attribute values are stored raw (offsets into the source), so two unresolved // values compare by range — `_attrValueOf` would slice a string per side just // to throw it away. /** @type {(i: number, j: number) => boolean} */ const _attrValueEquals = (i, j) => { if (_attrValues[i] === null && _attrValues[j] === null) { const start = _attrValueStarts[i]; const len = _attrValueEnds[i] - start; const otherStart = _attrValueStarts[j]; if (len !== _attrValueEnds[j] - otherStart) return false; for (let k = 0; k < len; k++) { if ( _htmlSource.charCodeAt(start + k) !== _htmlSource.charCodeAt(otherStart + k) ) { return false; } } return true; } return _attrValueOf(i) === _attrValueOf(j); }; const sameAttrs = ( /** @type {HtmlElement} */ a, /** @type {HtmlElement} */ b ) => { const aStart = _nodeAttrStarts[a]; const aCount = _nodeAttrCounts[a]; const bStart = _nodeAttrStarts[b]; const bCount = _nodeAttrCounts[b]; if (aCount !== bCount) return false; // Names are unique (deduped), counts tiny — a nested scan beats a Map // here on this formatting-element hot path. for (let i = bStart; i < bStart + bCount; i++) { const j = _findAttr(aStart, aCount, _attrNames[i]); if (j === 0 || !_attrValueEquals(j, i)) return false; } return true; }; const isAllWs = (/** @type {string} */ s) => { for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); // HTML whitespace: tab / LF / FF / CR / space. A charCodeAt loop avoids // the `for…of` code-point iterator + per-char string + Set lookup. if (c !== 0x09 && c !== 0x0a && c !== 0x0c && c !== 0x0d && c !== 0x20) { return false; } } return true; }; /** * Collapse every run of HTML whitespace to one space — never remove one: * `a <b>c</b>` and `a<b>c</b>` render differently. * @param {string} s text data * @returns {string} the collapsed text */ const collapseWhitespaceRuns = (s) => { let out = ""; let last = 0; let run = -1; for (let i = 0; i <= s.length; i++) { const c = i === s.length ? 0 : s.charCodeAt(i); const isWhitespace = c === 0x09 || c === 0x0a || c === 0x0c || c === 0x0d || c === 0x20; if (isWhitespace) { if (run === -1) run = i; continue; } // A one-character run is already a single space unless it is a tab or a // newline, which still print shorter as one. if (run !== -1 && (i - run > 1 || s.charCodeAt(run) !== 0x20)) { out += `${s.slice(last, run)} `; last = i; } run = -1; } return last === 0 ? s : out + s.slice(last); }; const mathmlTextIntegrationPoint = (/** @type {HtmlElement} */ el) => _namespaceOf(el) === NS_MATHML && MATHML_TEXT_INTEGRATION.has(_tagNameOf(el)); const htmlIntegrationPoint = (/** @type {HtmlElement} */ el) => { if (_namespaceOf(el) === NS_MATHML && _tagNameOf(el) === "annotation-xml") { const enc = _findAttr(_nodeAttrStarts[el], _nodeAttrCounts[el], "encoding"); if (enc !== 0) { const value = _attrValueOf(enc).toLowerCase(); if (value === "text/html" || value === "application/xhtml+xml") { return true; } } return false; } if ( _namespaceOf(el) === NS_SVG && SVG_SPECIAL.has(_tagNameOf(el).toLowerCase()) ) { return true; } return false; }; // Adjust a foreign start tag's attribute run in place (the run is consumed // only by this tag's element): SVG camelCase names are rewritten, and // namespaced names get the serializer-name flag (see `_attrSerializedName`). const adjustForeignAttrs = ( /** @type {AttributeRun} */ run, /** @type {number} */ ns ) => { for (let i = run.start; i < run.start + run.count; i++) { const name = _attrNames[i]; if ( ns === NS_SVG && /** @type {Record<string, string>} */ (SVG_ATTR_ADJUST)[name] ) { _attrNames[i] = /** @type {Record<string, string>} */ (SVG_ATTR_ADJUST)[ name ]; } if (/** @type {Record<string, string>} */ (FOREIGN_ATTR_NS)[name]) { _attrFlags[i] |= 1; } } return run; }; // Only `definitionurl` is rewritten (in place — the run is consumed only by // this tag's element); the camelCase name serializes as itself. const adjustMathmlAttrs = (/** @type {AttributeRun} */ run) => { for (let i = run.start; i < run.start + run.count; i++) { if (_attrNames[i] === "definitionurl") _attrNames[i] = "definitionURL"; } return run; }; // ---- HTML tree-construction parser state ---- // One active parse at a time (results are valid only until the next // `parseHtml`); kept at module scope so the helpers below are defined once // instead of re-created as closures on every parse. let source = ""; // Whole-document properties, so a substring cannot contain what the input does // not. Decoding cannot introduce either: a numeric reference to 0 yields U+FFFD, // and CR normalization runs on the raw slice before decoding. Default to `true` // so an unset flag only ever costs the old scan. let inputHasCr = true; let inputHasNul = true; let skipText = false; let skipComments = false; let skipDoctype = false; /** @type {HtmlNodeRef} */ let doc = 0; let mode = 0; let originalMode = 0; /** @type {HtmlElement[]} stack of open elements (bottom .. top) */ const open = []; /** @type {HtmlNodeRef[]} active formatting elements (AFE_MARKER = marker) */ const afe = []; /** @type {HtmlElement} 0 = none */ let head = 0; /** @type {HtmlElement} 0 = none */ let form = 0; let framesetOk = true; let pendAttrStart = 0; let fosterParenting = false; /** @type {number[]} */ const templateModes = []; let quirks = false; /** @type {HtmlElement} fragment context element (0 = document parse) */ let fragment = 0; let tokenEnd = 0; let swallowNextNewline = false; let eofInTag = false; let sawSelectedContent = false; const decode = decodeEntities; /** @type {MutableToken} */ const tok = { type: TOKEN_EOF, name: "", data: "", attrs: { start: 0, count: 0 }, selfClosing: false, start: 0, end: 0, publicId: null, systemId: null, forceQuirks: false, swallowNewline: false, pos: { start: 0, end: 0, tagEnd: 0, nameEnd: 0 } }; const cur = () => /** @type {HtmlElement} */ (open.length > 0 ? open[open.length - 1] : 0); /** * Remove an element from the middle of the open element stack. A removal that * leaves still-open descendants above it breaks the streamed walk whether or * not the walk entered the element: it drops off the stack without its subtree * ending, so those descendants would be entered under a parent the walk has no * open-stack evidence for. Removing the top is just a pop and keeps streaming — * which is the common `</form>` after implied end tags. * @param {number} index position in `open` to remove * @returns {void} */ const removeOpenAt = (index) => { open.splice(index, 1); _openSpliced = true; _openStackChanged = true; if (_streaming && index < open.length) _streamHalted = true; }; /** * Pop the open element stack. Every pop routes through here so the streamed * walk has a single completion point — the HTML analog of the CSS parser's * `onRule` sink — instead of being polled once per token. * @returns {HtmlElement} the popped element */ const popOpen = () => { const el = /** @type {HtmlElement} */ (open.pop()); // Only mark: an element's end offset is assigned after it is popped, so the // walk has to wait for the token to finish. _openStackChanged = true; return el; }; const adjustedCurrent = () => { if (open.length === 1 && fragment) return fragment; return cur(); }; const appendTo = ( /** @type {HtmlNodeRef} */ parent, /** @type {HtmlNodeRef} */ node ) => { const p = effParent(parent); const last = _nodeLastChildren[p]; if ( _nodeTypes[node] === NodeType.Text && last !== 0 && _nodeTypes[last] === NodeType.Text ) { _nodeStrings[last] += _nodeStrings[node]; _nodeEnds[last] = _nodeEnds[node]; return; } _appendChild(p, node); }; // Reused result of `appropriatePlace` — consumed synchronously by // `insertAtPlace` and never retained, so one shared object avoids an // allocation per inserted node. /** @type {InsertionPlace} */ const sharedPlace = { parent: doc, beforeNode: 0 }; const placeAt = ( /** @type {HtmlNodeRef} */ parent, /** @type {HtmlNodeRef} */ beforeNode ) => { sharedPlace.parent = parent; sharedPlace.beforeNode = beforeNode; return sharedPlace; }; // "appropriate place for inserting a node" const appropriatePlace = () => { const target = cur(); if ( fosterParenting && TABLE_CONTEXT.has(_tagNameOf(target)) && _namespaceOf(target) === NS_HTML ) { // find last template / last table let lastTemplate = -1; let lastTable = -1; for (let i = open.length - 1; i >= 0; i--) { if ( _tagNameOf(open[i]) === "template" && _namespaceOf(open[i]) === NS_HTML && lastTemplate === -1 ) { lastTemplate = i; } if ( _tagNameOf(open[i]) === "table" && _namespaceOf(open[i]) === NS_HTML && lastTable === -1 ) { lastTable = i; } } if (lastTemplate !== -1 && (lastTable === -1 || lastTemplate > lastTable)) { return placeAt(open[lastTemplate], 0); } if (lastTable === -1) { return placeAt(open[0], 0); } const table = open[lastTable]; const tp = _nodeParents[table]; if (tp !== 0) return placeAt(tp, table); return placeAt(open[lastTable - 1], 0); } return placeAt(target, 0); }; const insertAtPlace = ( /** @type {InsertionPlace} */ place, /** @type {HtmlNodeRef} */ node ) => { const before = place.beforeNode; if (before !== 0) { const p = effParent(place.parent); // Find `before`'s previous sibling (insert-before is a rare foster/ // adoption path, so the sibling scan stays off the hot path). let prev = 0; let c = _nodeFirstChildren[p]; while (c !== 0 && c !== before) { prev = c; c = _nodeNextSiblings[c]; } if (c === 0) { // `before` not under `parent` (not reachable from the spec paths). appendTo(place.parent, node); return; } if ( _nodeTypes[node] === NodeType.Text && prev !== 0 && _nodeTypes[prev] === NodeType.Text ) { // Before-node merge deliberately does not bump the sibling's `end`. _nodeStrings[prev] += _nodeStrings[node]; return; } _nodeParents[node] = p; _nodeNextSiblings[node] = before; if (prev === 0) _nodeFirstChildren[p] = node; else _nodeNextSiblings[prev] = node; } else { appendTo(place.parent, node); } }; const insertCharacters = ( /** @type {string} */ data, /** @type {number} */ start, /** @type {number} */ end ) => { const place = appropriatePlace(); if (_nodeTypes[place.parent] === NodeType.Document) return; // never insert text into document // `skip.text`: drop every `Text` node — construction already used the // decoded token, so removing the node never affects element structure. // For a raw-text element record the body end so a consumer reads the span // [`tagEnd`, `contentEnd`] without a `Text` node (see `HtmlParser`). // Namespace-agnostic: `HtmlParser` extracts `<script>`/`<style>` bodies in // foreign content (e.g. SVG `<style>`) too. if (skipText) { const p = place.parent; if ( _nodeTypes[p] === NodeType.Element && RAW_TEXT_ELEMENTS.has(_tagNameOf(p)) ) { _nodeContentEnds[p] = end; } return; } // Inlined text insert: when the run merges into the adjacent text sibling // (common with inline formatting) only the string is appended — no // throwaway text node is allocated. Mirrors `insertAtPlace`/`appendTo`, // including that the before-node merge does not bump `end`. const p = effParent(place.parent); const before = place.beforeNode; if (before !== 0) { let prev = 0; let c = _nodeFirstChildren[p]; while (c !== 0 && c !== before) { prev = c; c = _nodeNextSiblings[c]; } if (c === 0) { // `before` not under `parent` (not reachable from the spec paths). _appendChild(p, _makeTextNode(data, start, end)); return; } if (prev !== 0 && _nodeTypes[prev] === NodeType.Text) { _nodeStrings[prev] += data; return; } const node = _makeTextNode(data, start, end); _nodeParents[node] = p; _nodeNextSiblings[node] = before; if (prev === 0) _nodeFirstChildren[p] = node; else _nodeNextSiblings[prev] = node; } else { const last = _nodeLastChildren[p]; if (last !== 0 && _nodeTypes[last] === NodeType.Text) { _nodeStrings[last] += data; _nodeEnds[last] = end; return; } _appendChild(p, _makeTextNode(data, start, end)); } }; /** * @param {string} data comment data * @param {number} start start offset * @param {number} end end offset * @param {InsertionPlace=} place explicit insertion place */ const insertComment = (data, start, end, place) => { if (skipComments) return; const p = place || appropriatePlace(); insertAtPlace(p, _makeCommentNode(data, start, end)); }; const insertHtmlElement = ( /** @type {string} */ tagName, /** @type {AttributeRun} */ attrs, /** @type {TagPos | null} */ pos ) => { const el = mkEl(tagName, NS_HTML, attrs, pos); const place = appropriatePlace(); insertAtPlace(place, el); open.push(el); return el; }; const insertForeignElement = ( /** @type {string} */ tagName, /** @type {number} */ ns, /** @type {AttributeRun} */ attrs, /** @type {TagPos | null} */ pos ) => { const el = mkEl(tagName, ns, attrs, pos); const place = appropriatePlace(); insertAtPlace(place, el); open.push(el); return el; }; // ---- scopes ---- // Scope "kind" selects which extra elements act as boundaries. Passed as a // small int so the scope checks below allocate no per-call predicate closure // (these run several times per body tag). const SCOPE_DEFAULT = 0; const SCOPE_BUTTON = 1; const SCOPE_LIST_ITEM = 2; const isBoundaryForKind = ( /** @type {HtmlElement} */ el, /** @type {number} */ kind ) => { if (isScopeBoundary(el)) return true; if (_namespaceOf(el) !== NS_HTML) return false; if (kind === SCOPE_BUTTON) return _tagNameOf(el) === "button"; if (kind === SCOPE_LIST_ITEM) { return _tagNameOf(el) === "ol" || _tagNameOf(el) === "ul"; } return false; }; // "have an element in scope": walk the open stack from the top until the // named HTML element is found (true) or a scope boundary is hit (false). const hasNameInScope = ( /** @type {string} */ tagName, /** @type {number} */ kind ) => { for (let i = open.length - 1; i >= 0; i--) { const el = open[i]; if (_namespaceOf(el) === NS_HTML && _tagNameOf(el) === tagName) { return true; } if (isBoundaryForKind(el, kind)) return false; } return false; }; const inScope = (/** @type {string} */ tagName) => hasNameInScope(tagName, SCOPE_DEFAULT); const inButtonScope = (/** @type {string} */ tagName) => hasNameInScope(tagName, SCOPE_BUTTON); const inListItemScope = (/** @type {string} */ tagName) => hasNameInScope(tagName, SCOPE_LIST_ITEM); const inScopeEl = (/** @type {HtmlElement} */ target) => { for (let i = open.length - 1; i >= 0; i--) { const el = open[i]; if (el === target) return true; if (isScopeBoundary(el)) return false; } return false; }; // `target` is a single tag name (the common case) or a Set of names. const inTableScope = (/** @type {string | Set<string>} */ target) => { const set = typeof target === "string" ? null : target; for (let i = open.length - 1; i >= 0; i--) { const el = open[i]; if (_namespaceOf(el) === NS_HTML) { if (set ? set.has(_tagNameOf(el)) : _tagNameOf(el) === target) { return true; } if (TABLE_SCOPE_STOP.has(_tagNameOf(el))) return false; } } return false; }; const generateImpliedEndTags = (except = "") => { while (open.length) { const el = cur(); if ( _namespaceOf(el) === NS_HTML && IMPLIED.has(_tagNameOf(el)) && _tagNameOf(el) !== except ) { popOpen(); } else { break; } } }; const generateImpliedEndTagsThorough = () => { while (open.length) { const el = cur(); if (_namespaceOf(el) === NS_HTML && IMPLIED_THOROUGH.has(_tagNameOf(el))) { popOpen(); } else { break; } } }; // ---- active formatting elements ---- // `splice` allocates an array for the removed element on every call; the list // is short, so shifting in place is both cheaper and allocation-free. /** @type {(i: number) => void} */ const afeRemoveAt = (i) => { const last = afe.length - 1; for (let k = i; k < last; k++) afe[k] = afe[k + 1]; afe.pop(); }; const pushAfe = (/** @type {HtmlElement} */ el) => { let count = 0; for (let i = afe.length - 1; i >= 0; i--) { const e = afe[i]; if (e === AFE_MARKER) break; if ( _tagNameOf(e) === _tagNameOf(el) && _namespaceOf(e) === _namespaceOf(el) && sameAttrs(e, el) ) { count++; if (count === 3) { afeRemoveAt(i); break; } } } afe.push(el); }; const insertMarker = () => afe.push(AFE_MARKER); const clearAfeToMarker = () => { while (afe.length) { if (afe.pop() === AFE_MARKER) break; } }; const reconstructAfe = () => { if (afe.length === 0) return; let i = afe.length - 1; if (afe[i] === AFE_MARKER || open.includes(afe[i])) return; while (i > 0) { i--; if (afe[i] === AFE_MARKER || open.includes(afe[i])) { i++; break; } } for (; i < afe.length; i++) { const e = afe[i]; const el = mkEl(_tagNameOf(e), _namespaceOf(e), cloneAttrs(e), null); const place = appropriatePlace(); insertAtPlace(place, el); open.push(el); afe[i] = el; } }; // ---- close p ---- const closePElement = () => { generateImpliedEndTags("p"); // pop until a p has been popped while (open.length) { const el = /** @type {HtmlElement} */ (popOpen()); _nodeEnds[el] = tokenEnd; if (_namespaceOf(el) === NS_HTML && _tagNameOf(el) === "p") break; } }; const popUntil = (/** @type {string} */ tagName) => { while (open.length) { const el = /** @type {HtmlElement} */ (popOpen()); _nodeEnds[el] = tokenEnd; if (_namespaceOf(el) === NS_HTML && _tagNameOf(el) === tagName) break; } }; const popUntilOneOf = (/** @type {Set<string>} */ set) => { while (open.length) { const el = /** @type {HtmlElement} */ (popOpen()); _nodeEnds[el] = tokenEnd; if (_namespaceOf(el) === NS_HTML && set.has(_tagNameOf(el))) break; } }; // ---- reset insertion mode appropriately ---- const resetInsertionMode = () => { let last = false; for (let i = open.length - 1; i >= 0; i--) { let node = open[i]; if (i === 0) { last = true; if (fragment) node = fragment; } const tn = _tagNameOf(node); if (_namespaceOf(node) === NS_HTML) { if ((tn === "td" || tn === "th") && !last) { mode = MODE_IN_CELL; return; } if (tn === "tr") { mode = MODE_IN_ROW; return; } if (TBODY_GROUP.has(tn)) { mode = MODE_IN_TABLE_BODY; return; } if (tn === "caption") { mode = MODE_IN_CAPTION; return; } if (tn === "colgroup") { mode = MODE_IN_COLUMN_GROUP; return; } if (tn === "table") { mode = MODE_IN_TABLE; return; } if (tn === "template") { mode = templateModes[templateModes.length - 1]; return; } if (tn === "head" && !last) { mode = MODE_IN_HEAD; return; } if (tn === "body") { mode = MODE_IN_BODY; return; } if (tn === "frameset") { mode = MODE_IN_FRAMESET; return; } if (tn === "html") { mode = head ? MODE_AFTER_HEAD : MODE_BEFORE_HEAD; return; } } if (last) { mode = MODE_IN_BODY; return; } } }; // ---------- token processing ---------- // Split a character token's leading whitespace; per the spec each character // is its own token, so a mixed run can straddle a mode change. Inserts the // leading whitespace when `insert`, returns the non-whitespace remainder // token (or null when the token was entirely whitespace). const leadingWs = ( /** @type {CharToken} */ t, /** @type {boolean} */ insert ) => { const m = /^[\t\n\f\r ]+/.exec(t.data); const ws = m ? m[0] : ""; // `t.data` is decoded, the offsets are raw, so counting one in the other // only lines up when decoding changed no length (no CRLF, no reference). // Otherwise keep the whole span: too wide is harmless, too narrow names // source that is not this text (see the `Text` printer). const aligned = t.end - t.start === t.data.length; if (ws && insert) { insertCharacters(ws, t.start, aligned ? t.start + ws.length : t.end); } if (ws.length === t.data.length) return null; return { ...t, data: t.data.slice(ws.length), start: aligned ? t.start + ws.length : t.start }; }; const process = (/** @type {Token} */ t) => { // Track the current token's end so explicit closes can set element `.end`. // Dispatch on `type` instead of the `in` operator, which goes megamorphic // across the token union and shows up on the per-token hot path. const ty = t.type; if (ty === TOKEN_START_TAG || ty === TOKEN_END_TAG) tokenEnd = t.pos.end; else if (ty !== TOKEN_EOF) tokenEnd = t.end; // foreign content dispatch const ac = adjustedCurrent(); const useForeign = open.length > 0 && ac && _namespaceOf(ac) !== NS_HTML && ty !== TOKEN_EOF && shouldUseForeignRules(ac, t); if (useForeign) { foreignContent(t); return; } runMode(t); }; const shouldUseForeignRules = ( /** @type {HtmlElement} */ ac, /** @type {Token} */ t ) => { if (_namespaceOf(ac) === NS_HTML) return false; if (t.type === TOKEN_START_TAG) { if ( mathmlTextIntegrationPoint(ac) && t.name !== "mglyph" && t.name !== "malignmark" ) { return false; } if ( _namespaceOf(ac) === NS_MATHML && _tagNameOf(ac) === "annotation-xml" && t.name === "svg" ) { return false; } if (htmlIntegrationPoint(ac)) return false; return true; } if (t.type === TOKEN_CHAR) { if (mathmlTextIntegrationPoint(ac)) return false; if (htmlIntegrationPoint(ac)) return false; return true; } if (t.type === TOKEN_END_TAG) return true; if (t.type === TOKEN_COMMENT) return true; return false; }; const foreignContent = (/** @type {Token} */ t) => { if (t.type === TOKEN_CHAR) { const data = inputHasNul ? t.data.replace(/\0/g, "�") : t.data; insertCharacters(data, t.start, t.end); // eslint-disable-next-line no-control-regex if (/[^\t\n\f\r \u0000]/.test(t.data)) framesetOk = false; return; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG) { const acn = _namespaceOf(adjustedCurrent()); if ( FOREIGN_BREAKOUT.has(t.name) || (t.name === "font" && hasFontBreakoutAttr(t.attrs)) ) { // parse error; pop until integration point / html / mathml-text-integration while (open.length > 1) { const c = cur(); if ( _namespaceOf(c) === NS_HTML || mathmlTextIntegrationPoint(c) || htmlIntegrationPoint(c) ) { break; } popOpen(); } runMode(t); return; } const ns = acn; let name = t.name; let attrs = t.attrs; if (ns === NS_SVG) { name = adjustSvgTag(name); } if (ns === NS_MATHML) { attrs = adjustMathmlAttrs(attrs); } attrs = adjustForeignAttrs(attrs, ns); insertForeignElement(name, ns, attrs, t.pos); if (t.selfClosing) { popOpen(); } return; } if (t.type === TOKEN_END_TAG) { if ( t.name === "script" && _tagNameOf(cur()) === "script" && _namespaceOf(cur()) === NS_SVG ) { popOpen(); return; } // `</p>` and `</br>` break out: pop foreign elements up to the // nearest HTML element or integration point, then process in HTML. if (t.name === "p" || t.name === "br") { while ( open.length > 1 && _namespaceOf(cur()) !== NS_HTML && !mathmlTextIntegrationPoint(cur()) && !htmlIntegrationPoint(cur()) ) { popOpen(); } runMode(t); return; } // any other end tag let i = open.length - 1; let node = open[i]; if (_tagNameOf(node).toLowerCase() !== t.name) { /* parse error */ } while (i >= 0) { node = open[i]; if (i === 0) return; if ( _namespaceOf(node) !== NS_HTML && _tagNameOf(node).toLowerCase() === t.name ) { while (open.length > i) popOpen(); return; } i--; if (open[i] && _namespaceOf(open[i]) === NS_HTML) { runMode(t); return; } } } }; // ---------- adoption agency algorithm ---------- const adoptionAgency = ( /** @type {string} */ subject, /** @type {TagPos | null} */ pos ) => { // step 1 const c = cur(); if ( _namespaceOf(c) === NS_HTML && _tagNameOf(c) === subject && !afe.includes(c) ) { popOpen(); return true; } let outer = 0; while (outer < 8) { outer++; // find formatting element let fmtIdx = -1; for (let i = afe.length - 1; i >= 0; i--) { if (afe[i] === AFE_MARKER) break; if (_tagNameOf(afe[i]) === subject && _namespaceOf(afe[i]) === NS_HTML) { fmtIdx = i; break; } } if (fmtIdx === -1) return false; // act as any other end tag const fmt = afe[fmtIdx]; const openIdx = open.indexOf(fmt); if (openIdx === -1) { afeRemoveAt(fmtIdx); return true; } if (!inScopeEl(fmt)) return true; // parse error, ignore // step: furthest block let furthestIdx = -1; for (let i = openIdx + 1; i < open.length; i++) { if (isSpecial(open[i])) { furthestIdx = i; break; } } if (furthestIdx === -1) { while (open.length > openIdx) popOpen(); afeRemoveAt(fmtIdx); return true; } const furthest = open[furthestIdx]; const commonAncestor = open[openIdx - 1]; let bookmark = fmtIdx; let node = furthest; let lastNode = furthest; let nodeIdx = furthestIdx; let inner = 0; while (true) { inner++; nodeIdx--; node = open[nodeIdx]; if (node === fmt) break; let nodeAfeIdx = afe.indexOf(node); if (inner > 3 && nodeAfeIdx !== -1) { afeRemoveAt(nodeAfeIdx); if (nodeAfeIdx < bookmark) bookmark--; nodeAfeIdx = -1; } if (nodeAfeIdx === -1) { removeOpenAt(nodeIdx); continue; } // create clone const clone = mkEl( _tagNameOf(node), _namespaceOf(node), cloneAttrs(node), null ); afe[nodeAfeIdx] = clone; open[nodeIdx] = clone; node = clone; if (lastNode === furthest) bookmark = nodeAfeIdx + 1; // append lastNode to node detach(lastNode); appendTo(node, lastNode); lastNode = node; } // insert lastNode into common ancestor (with foster parenting) detach(lastNode); const place = placeForCommonAncestor(commonAncestor); insertAtPlace(place, lastNode); // create element for fmt token, take children of furthest const cloneFmt = mkEl( _tagNameOf(fmt), _namespaceOf(fmt), cloneAttrs(fmt), null ); // Take all direct children of `furthest` (a template's content fragment // deliberately stays put — mirrors childrenOf-less spec behavior here). let k = _nodeFirstChildren[furthest]; _nodeFirstChildren[furthest] = 0; _nodeLastChildren[furthest] = 0; while (k !== 0) { const next = _nodeNextSiblings[k]; _nodeNextSiblings[k] = 0; _nodeParents[k] = 0; appendTo(cloneFmt, k); k = next; } appendTo(furthest, cloneFmt); // remove fmt from afe, insert clone at bookmark const curFmtIdx = afe.indexOf(fmt); if (curFmtIdx !== -1) { afeRemoveAt(curFmtIdx); if (curFmtIdx < bookmark) bookmark--; } afe.splice(bookmark, 0, cloneFmt); // remove fmt from open, insert clone below furthest const ofi = open.indexOf(fmt); if (ofi !== -1) removeOpenAt(ofi); const newFurthestIdx = open.indexOf(furthest); open.splice(newFurthestIdx + 1, 0, cloneFmt); _openSpliced = true; _openStackChanged = true; } return true; }; const placeForCommonAncestor = (/** @type {HtmlElement} */ commonAncestor) => { if ( TABLE_CONTEXT.has(_tagNameOf(commonAncestor)) && _namespaceOf(commonAncestor) === NS_HTML ) { // foster // reuse appropriatePlace logic but rooted differently: emulate let lastTemplate = -1; let lastTable = -1; for (let i = open.length - 1; i >= 0; i--) { if ( _tagNameOf(open[i]) === "template" && _namespaceOf(open[i]) === NS_HTML && lastTemplate === -1 ) { lastTemplate = i; } if ( _tagNameOf(open[i]) === "table" && _namespaceOf(open[i]) === NS_HTML && lastTable === -1 ) { lastTable = i; } } if (lastTemplate !== -1 && (lastTable === -1 || lastTemplate > lastTable)) { return { parent: open[lastTemplate], beforeNode: 0 }; } if (lastTable === -1) return { parent: open[0], beforeNode: 0 }; const table = open[lastTable]; const tp = _nodeParents[table]; if (tp !== 0) return { parent: tp, beforeNode: table }; return { parent: open[lastTable - 1], beforeNode: 0 }; } return { parent: commonAncestor, beforeNode: 0 }; }; const detach = (/** @type {HtmlNodeRef} */ node) => { const p = _nodeParents[node]; if (p === 0) return; let prev = 0; let c = _nodeFirstChildren[p]; while (c !== 0 && c !== node) { prev = c; c = _nodeNextSiblings[c]; } if (c === 0) return; if (prev === 0) _nodeFirstChildren[p] = _nodeNextSiblings[node]; else _nodeNextSiblings[prev] = _nodeNextSiblings[node]; if (_nodeLastChildren[p] === node) _nodeLastChildren[p] = prev; _nodeNextSiblings[node] = 0; _nodeParents[node] = 0; }; // ---------- insertion modes ---------- /** @type {Record<string, (t: Token) => void>} */ const modes = {}; // Dispatch the current insertion mode. An integer switch (cases ordered by // frequency) keeps each `modes.x(t)` call site monomorphic, where a keyed // `modes[mode]` load + indirect call would defeat inlining on the per-token // hot path. const runMode = (/** @type {Token} */ t) => { switch (mode) { case MODE_IN_BODY: return modes.inBody(t); case MODE_TEXT: return modes.text(t); case MODE_IN_CELL: return modes.inCell(t); case MODE_IN_ROW: return modes.inRow(t); case MODE_IN_TABLE_BODY: return modes.inTableBody(t); case MODE_IN_TABLE: return modes.inTable(t); case MODE_IN_TABLE_TEXT: return modes.inTableText(t); case MODE_IN_CAPTION: return modes.inCaption(t); case MODE_IN_COLUMN_GROUP: return modes.inColumnGroup(t); case MODE_IN_TEMPLATE: return modes.inTemplate(t); case MODE_IN_HEAD: return modes.inHead(t); case MODE_IN_HEAD_NOSCRIPT: return modes.inHeadNoscript(t); case MODE_AFTER_HEAD: return modes.afterHead(t); case MODE_BEFORE_HEAD: return modes.beforeHead(t); case MODE_BEFORE_HTML: return modes.beforeHtml(t); case MODE_INITIAL: return modes.initial(t); case MODE_AFTER_BODY: return modes.afterBody(t); case MODE_AFTER_AFTER_BODY: return modes.afterAfterBody(t); case MODE_IN_FRAMESET: return modes.inFrameset(t); case MODE_AFTER_FRAMESET: return modes.afterFrameset(t); // MODE_AFTER_AFTER_FRAMESET — every mode is enumerated, so the last // one is the `default` (also satisfies exhaustiveness linting). default: return modes.afterAfterFrameset(t); } }; modes.initial = (t) => { if (t.type === TOKEN_CHAR) { const r = leadingWs(t, false); if (!r) return; quirks = true; mode = MODE_BEFORE_HTML; process(r); return; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end, { parent: doc, beforeNode: 0 }); return; } if (t.type === TOKEN_DOCTYPE) { // `skip.doctype` drops the node only; quirks detection below is unaffected. if (!skipDoctype) { const dt = _allocNode(NodeType.Doctype, t.start, t.end); _nodeStrings[dt] = t.name; // At most one doctype node is ever inserted (later doctype tokens // are ignored), so its ids live in two per-parse scalars. _doctypePublicId = t.publicId; _doctypeSystemId = t.systemId; _appendChild(doc, dt); } quirks = t.forceQuirks || isQuirky(t.name, t.publicId, t.systemId); mode = MODE_BEFORE_HTML; return; } quirks = true; mode = MODE_BEFORE_HTML; process(t); }; modes.beforeHtml = (t) => { if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end, { parent: doc, beforeNode: 0 }); return; } if (t.type === TOKEN_CHAR) { const r = leadingWs(t, false); if (!r) return; t = r; } if (t.type === TOKEN_START_TAG && t.name === "html") { const el = mkEl("html", NS_HTML, t.attrs, t.pos); _appendChild(doc, el); open.push(el); mode = MODE_BEFORE_HEAD; return; } if (t.type === TOKEN_END_TAG && !HEAD_BODY_HTML_BR.has(t.name)) { return; } const el = mkEl("html", NS_HTML, EMPTY_ATTRS, null); _appendChild(doc, el); open.push(el); mode = MODE_BEFORE_HEAD; process(t); }; modes.beforeHead = (t) => { if (t.type === TOKEN_CHAR) { const r = leadingWs(t, false); if (!r) return; t = r; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG && t.name === "html") return modes.inBody(t); if (t.type === TOKEN_START_TAG && t.name === "head") { head = insertHtmlElement("head", t.attrs, t.pos); mode = MODE_IN_HEAD; return; } if (t.type === TOKEN_END_TAG && !HEAD_BODY_HTML_BR.has(t.name)) { return; } head = insertHtmlElement("head", EMPTY_ATTRS, null); mode = MODE_IN_HEAD; process(t); }; modes.inHead = (t) => { if (t.type === TOKEN_CHAR) { const r = leadingWs(t, true); if (!r) return; popOpen(); mode = MODE_AFTER_HEAD; process(r); return; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG) { if (t.name === "html") return modes.inBody(t); if (HEAD_VOID_ELEMENTS.has(t.name)) { insertHtmlElement(t.name, t.attrs, t.pos); popOpen(); return; } if (t.name === "title") { genericRcdata(t); return; } if (NOFRAMES_STYLE_NOSCRIPT.has(t.name)) { if (t.name === "noscript") { insertHtmlElement("noscript", t.attrs, t.pos); mode = MODE_IN_HEAD_NOSCRIPT; return; } genericRawtext(t); return; } if (t.name === "script") { genericRawtext(t); return; } if (t.name === "template") { insertHtmlElement("template", t.attrs, t.pos); insertMarker(); framesetOk = false; mode = MODE_IN_TEMPLATE; templateModes.push(MODE_IN_TEMPLATE); const el = cur(); const fragment = _allocNode(NodeType.DocumentFragment, 0, 0); _nodeStrings[fragment] = ""; // Parent link so the iterative walk can ascend out of the content. _nodeParents[fragment] = el; _nodeContentEnds[el] = fragment; _nodeFlags[el] |= FLAG_HAS_TEMPLATE; return; } if (t.name === "head") return; } if (t.type === TOKEN_END_TAG) { if (t.name === "head") { popOpen(); mode = MODE_AFTER_HEAD; return; } if (BODY_HTML_BR.has(t.name)) { /* fallthrough */ } else if (t.name === "template") { if (!open.some(isHtmlTemplateEl)) { return; } generateImpliedEndTagsThorough(); popUntil("template"); clearAfeToMarker(); templateModes.pop(); resetInsertionMode(); return; } else { return; } } // anything else popOpen(); mode = MODE_AFTER_HEAD; process(t); }; modes.inHeadNoscript = (t) => { if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG && t.name === "html") return modes.inBody(t); if (t.type === TOKEN_END_TAG && t.name === "noscript") { popOpen(); mode = MODE_IN_HEAD; return; } if (t.type === TOKEN_CHAR && isAllWs(t.data)) return modes.inHead(t); if (t.type === TOKEN_COMMENT) return modes.inHead(t); if (t.type === TOKEN_START_TAG && IN_HEAD_NOSCRIPT_PASSTHROUGH.has(t.name)) { return modes.inHead(t); } // A stray end tag other than </br>/</noscript> is ignored (the comment // or content stays inside <noscript>); only </br> and other content fall // back to popping <noscript>. if (t.type === TOKEN_END_TAG && t.name !== "br") return; if ( t.type === TOKEN_START_TAG && (t.name === "head" || t.name === "noscript") ) { return; } popOpen(); mode = MODE_IN_HEAD; process(t); }; modes.afterHead = (t) => { if (t.type === TOKEN_CHAR) { const r = leadingWs(t, true); if (!r) return; insertHtmlElement("body", EMPTY_ATTRS, null); mode = MODE_IN_BODY; process(r); return; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG) { if (t.name === "html") return modes.inBody(t); if (t.name === "body") { insertHtmlElement("body", t.attrs, t.pos); framesetOk = false; mode = MODE_IN_BODY; return; } if (t.name === "frameset") { insertHtmlElement("frameset", t.attrs, t.pos); mode = MODE_IN_FRAMESET; return; } if (HEAD_ELEMENTS.has(t.name)) { const headEl = /** @type {HtmlElement} */ (head); open.push(headEl); modes.inHead(t); const idx = open.indexOf(headEl); if (idx !== -1) removeOpenAt(idx); return; } if (t.name === "head") return; } if (t.type === TOKEN_END_TAG) { if (t.name === "template") return modes.inHead(t); if (!BODY_HTML_BR.has(t.name)) return; } insertHtmlElement("body", EMPTY_ATTRS, null); mode = MODE_IN_BODY; process(t); }; modes.inBody = (t) => { if (t.type === TOKEN_CHAR) { // Deliberately not guarded by `inputHasNul`: this `includes` flattens the // decoded rope, and skipping it makes the inserts below allocate more. if (t.data.includes("\0")) t = { ...t, data: t.data.replace(/\0/g, "") }; if (t.data === "") return; reconstructAfe(); insertCharacters(t.data, t.start, t.end); // `framesetOk` only ever goes true→false, so once it's false skip the // per-text-token whitespace scan entirely (it flips false very early in // real documents). if (framesetOk && !isAllWs(t.data)) framesetOk = false; return; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG) return startTagInBody(t); if (t.type === TOKEN_END_TAG) return endTagInBody(t); if (t.type === TOKEN_EOF && templateModes.length) { return modes.inTemplate(t); } }; const closeIfPInButtonScope = () => { if (inButtonScope("p")) closePElement(); }; // "any other end tag" in body: pop to the matching open element, stopping at // the first special element; also the adoption agency's no-element fallback. const anyOtherEndTag = (/** @type {string} */ name) => { for (let i = open.length - 1; i >= 0; i--) { const node = open[i]; if (_namespaceOf(node) === NS_HTML && _tagNameOf(node) === name) { generateImpliedEndTags(name); while (open.length > i) { _nodeEnds[open[open.length - 1]] = tokenEnd; popOpen(); } return; } if (isSpecial(node)) return; } }; const startTagInBody = (/** @type {StartTagToken} */ t) => { const name = t.name; if (name === "html") { if (open.some(isHtmlTemplateEl)) { return; } mergeAttrs(open[0], t.attrs); return; } if (HEAD_ELEMENTS.has(name)) { return modes.inHead(t); } if (name === "body") { const second = open[1]; if ( !second || _tagNameOf(second) !== "body" || open.some(isHtmlTemplateEl) ) { return; } framesetOk = false; mergeAttrs(second, t.attrs); return; } if (name === "frameset") { const second = open[1]; if (!second || _tagNameOf(second) !== "body") return; if (!framesetOk) return; detach(second); while (open.length > 1) popOpen(); insertHtmlElement("frameset", t.attrs, t.pos); mode = MODE_IN_FRAMESET; return; } if (BLOCK_START.has(name)) { closeIfPInButtonScope(); insertHtmlElement(name, t.attrs, t.pos); return; } if (HEADING.has(name)) { closeIfPInButtonScope(); if (_namespaceOf(cur()) === NS_HTML && HEADING.has(_tagNameOf(cur()))) { popOpen(); } insertHtmlElement(name, t.attrs, t.pos); return; } if (name === "pre" || name === "listing") { closeIfPInButtonScope(); insertHtmlElement(name, t.attrs, t.pos); t.swallowNewline = true; framesetOk = false; return; } if (name === "form") { if (form && !open.some(isHtmlTemplateEl)) { return; } closeIfPInButtonScope(); const el = insertHtmlElement("form", t.attrs, t.pos); if (!open.some(isHtmlTemplateEl)) { form = el; } return; } if (name === "li") { framesetOk = false; for (let i = open.length - 1; i >= 0; i--) { const node = open[i]; if (_namespaceOf(node) === NS_HTML && _tagNameOf(node) === "li") { generateImpliedEndTags("li"); popUntil("li"); break; } if ( isSpecial(node) && !(_namespaceOf(node) === NS_HTML && ADDRESS_DIV_P.has(_tagNameOf(node))) ) { break; } } closeIfPInButtonScope(); insertHtmlElement("li", t.attrs, t.pos); return; } if (name === "dd" || name === "dt") { framesetOk = false; for (let i = open.length - 1; i >= 0; i--) { const node = open[i]; if ( _namespaceOf(node) === NS_HTML && (_tagNameOf(node) === "dd" || _tagNameOf(node) === "dt") ) { generateImpliedEndTags(_tagNameOf(node)); popUntil(_tagNameOf(node)); break; } if ( isSpecial(node) && !(_namespaceOf(node) === NS_HTML && ADDRESS_DIV_P.has(_tagNameOf(node))) ) { break; } } closeIfPInButtonScope(); insertHtmlElement(name, t.attrs, t.pos); return; } if (name === "plaintext") { closeIfPInButtonScope(); insertHtmlElement("plaintext", t.attrs, t.pos); return; } if (name === "button") { if (inScope("button")) { generateImpliedEndTags(); popUntil("button"); } reconstructAfe(); insertHtmlElement("button", t.attrs, t.pos); framesetOk = false; return; } if (name === "a") { // if there's an <a> in afe after last marker for (let i = afe.length - 1; i >= 0; i--) { if (afe[i] === AFE_MARKER) break; if (_tagNameOf(afe[i]) === "a") { adoptionAgency("a", t.pos); const idx = afe.findIndex( (e) => e !== AFE_MARKER && _tagNameOf(e) === "a" ); if (idx !== -1) { const el = afe[idx]; afeRemoveAt(idx); const oi = open.indexOf(el); if (oi !== -1) removeOpenAt(oi); } break; } } reconstructAfe(); const el = insertHtmlElement("a", t.attrs, t.pos); pushAfe(el); return; } if (FORMATTING.has(name) && name !== "a" && name !== "nobr") { reconstructAfe(); const el = insertHtmlElement(name, t.attrs, t.pos); pushAfe(el); return; } if (name === "nobr") { reconstructAfe(); if (inScope("nobr")) { // The adoption agency returns false when a marker shields the nobr // from the active formatting list; then act as "any other end tag". if (!adoptionAgency("nobr", t.pos)) anyOtherEndTag("nobr"); reconstructAfe(); } const el = insertHtmlElement("nobr", t.attrs, t.pos); pushAfe(el); return; } if (APPLET_MARQUEE_OBJECT.has(name)) { reconstructAfe(); insertHtmlElement(name, t.attrs, t.pos); insertMarker(); framesetOk = false; return; } if (name === "table") { if (!quirks) closeIfPInButtonScope(); insertHtmlElement("table", t.attrs, t.pos); framesetOk = false; mode = MODE_IN_TABLE; return; } if (VOID_FORMATTING.has(name)) { reconstructAfe(); insertHtmlElement(name, t.attrs, t.pos); popOpen(); framesetOk = false; return; } if (name === "input") { // `<input>` inside a select is dropped; if a select is open it is // closed first (keygen/textarea no longer behave this way). if (inScope("select")) { popUntil("select"); resetInsertionMode(); } else if ( fragment && _namespaceOf(fragment) === NS_HTML && _tagNameOf(fragment) === "select" ) { return; } reconstructAfe(); insertHtmlElement("input", t.attrs, t.pos); popOpen(); const ty = _findAttr(t.attrs.start, t.attrs.count, "type"); if (ty === 0 || _attrValueOf(ty).toLowerCase() !== "hidden") { framesetOk = false; } return; } if (PARAM_SOURCE_TRACK.has(name)) { insertHtmlElement(name, t.attrs, t.pos); popOpen(); return; } if (name === "hr") { if (_namespaceOf(cur()) === NS_HTML && _tagNameOf(cur()) === "option") { popOpen(); } if (_namespaceOf(cur()) === NS_HTML && _tagNameOf(cur()) === "optgroup") { popOpen(); } closeIfPInButtonScope(); insertHtmlElement("hr", t.attrs, t.pos); popOpen(); framesetOk = false; return; } if (name === "image") { return startTagInBody({ ...t, name: "img" }); } if (name === "textarea") { genericRcdata(t, true); framesetOk = false; return; } if (name === "xmp") { closeIfPInButtonScope(); reconstructAfe(); framesetOk = false; genericRawtext(t); return; } if (name === "iframe") { framesetOk = false; genericRawtext(t); return; } if (name === "noembed") { genericRawtext(t); return; } if (name === "select") { reconstructAfe(); if (inScope("select")) { generateImpliedEndTags(); popUntil("select"); resetInsertionMode(); return; } insertHtmlElement("select", t.attrs, t.pos); // Marker so a stray formatting end tag (e.g. `</font>`) can't adopt // across the select boundary now that select has no own insertion mode. insertMarker(); framesetOk = false; return; } if (name === "optgroup" || name === "option") { if (_namespaceOf(cur()) === NS_HTML && _tagNameOf(cur()) === "option") { popOpen(); } if ( name === "optgroup" && _namespaceOf(cur()) === NS_HTML && _tagNameOf(cur()) === "optgroup" ) { popOpen(); } reconstructAfe(); insertHtmlElement(name, t.attrs, t.pos); return; } if (name === "rb" || name === "rtc") { if (inScope("ruby")) generateImpliedEndTags(); insertHtmlElement(name, t.attrs, t.pos); return; } if (name === "rp" || name === "rt") { if (inScope("ruby")) generateImpliedEndTags("rtc"); insertHtmlElement(name, t.attrs, t.pos); return; } if (name === "math") { reconstructAfe(); const attrs = adjustForeignAttrs(adjustMathmlAttrs(t.attrs), NS_MATHML); insertForeignElement("math", NS_MATHML, attrs, t.pos); if (t.selfClosing) popOpen(); return; } if (name === "svg") { reconstructAfe(); const attrs = adjustForeignAttrs(t.attrs, NS_SVG); insertForeignElement("svg", NS_SVG, attrs, t.pos); if (t.selfClosing) popOpen(); return; } if (IGNORED_BODY_TABLE_STARTS.has(name)) { return; } // any other start tag reconstructAfe(); insertHtmlElement(name, t.attrs, t.pos); }; const endTagInBody = (/** @type {EndTagToken} */ t) => { const name = t.name; if (name === "template") return modes.inHead(t); if (name === "select") { if (!inScope("select")) return; generateImpliedEndTags(); popUntil("select"); return; } if (name === "body" || name === "html") { if (!inScope("body")) return; mode = MODE_AFTER_BODY; if (name === "html") process(t); return; } if (BLOCK_END.has(name)) { if (!inScope(name)) return; generateImpliedEndTags(); popUntil(name); return; } if (name === "form") { if (!open.some(isHtmlTemplateEl)) { const node = form; form = 0; if (!node || !inScopeEl(node)) return; generateImpliedEndTags(); const idx = open.indexOf(node); if (idx !== -1) removeOpenAt(idx); } else { if (!inScope("form")) return; generateImpliedEndTags(); popUntil("form"); } return; } if (name === "p") { if (!inButtonScope("p")) insertHtmlElement("p", EMPTY_ATTRS, t.pos); closePElement(); return; } if (name === "li") { if (!inListItemScope("li")) return; generateImpliedEndTags("li"); popUntil("li"); return; } if (name === "dd" || name === "dt") { if (!inScope(name)) return; generateImpliedEndTags(name); popUntil(name); return; } if (HEADING.has(name)) { let anyHeadingInScope = false; for (const h of HEADING) { if (inScope(h)) { anyHeadingInScope = true; break; } } if (!anyHeadingInScope) return; generateImpliedEndTags(); popUntilOneOf(HEADING); return; } if (name === "sarcasm") { /* take a deep breath */ } if (FORMATTING.has(name)) { adoptionAgency(name, t.pos); return; } if (APPLET_MARQUEE_OBJECT.has(name)) { if (!inScope(name)) return; generateImpliedEndTags(); popUntil(name); clearAfeToMarker(); return; } if (name === "br") { reconstructAfe(); insertHtmlElement("br", EMPTY_ATTRS, t.pos); popOpen(); framesetOk = false; return; } anyOtherEndTag(name); }; // generic RCDATA/RAWTEXT: tokenizer already emits the text + end tag, so we // just insert the element and switch to "text" mode; text mode appends chars // and the matching end tag pops. const genericRawtext = (/** @type {StartTagToken} */ t) => { insertHtmlElement(t.name, t.attrs, t.pos); originalMode = mode; mode = MODE_TEXT; }; const genericRcdata = (/** @type {StartTagToken} */ t, swallow = false) => { insertHtmlElement(t.name, t.attrs, t.pos); if (swallow) t.swallowNewline = true; originalMode = mode; mode = MODE_TEXT; }; modes.text = (t) => { if (t.type === TOKEN_CHAR) { insertCharacters(t.data, t.start, t.end); return; } if (t.type === TOKEN_EOF) { if (open.length) popOpen(); mode = originalMode; process(t); return; } if (t.type === TOKEN_END_TAG) { // Treat as rawtext rather than an end tag when it can't close the // current element: a non-matching name (e.g. a fragment context), or // a name that ran straight to EOF with no delimiter (`</script` at // EOF — the tokenizer still emits a partial tag there). if ( cur() && (t.name !== _tagNameOf(cur()) || t.pos.end === t.pos.nameEnd) ) { insertCharacters( source.slice(t.pos.start, t.pos.end), t.pos.start, t.pos.end ); return; } // span the close tag, like every other explicit-close pop _nodeEnds[/** @type {HtmlElement} */ (popOpen())] = tokenEnd; mode = originalMode; } }; // ---------- table modes ---------- /** @type {{ list: CharToken[], hasNonWs: boolean } | null} */ let pendingTableChars = null; modes.inTable = (t) => { if (t.type === TOKEN_CHAR) { const c = cur(); if (TABLE_CONTEXT.has(_tagNameOf(c)) && _namespaceOf(c) === NS_HTML) { pendingTableChars = { list: [], hasNonWs: false }; originalMode = mode; mode = MODE_IN_TABLE_TEXT; return process(t); } } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG) { const name = t.name; if (name === "caption") { clearStackToTableContext(); insertMarker(); insertHtmlElement("caption", t.attrs, t.pos); mode = MODE_IN_CAPTION; return; } if (name === "colgroup") { clearStackToTableContext(); insertHtmlElement("colgroup", t.attrs, t.pos); mode = MODE_IN_COLUMN_GROUP; return; } if (name === "col") { clearStackToTableContext(); insertHtmlElement("colgroup", EMPTY_ATTRS, t.pos); mode = MODE_IN_COLUMN_GROUP; return process(t); } if (TBODY_GROUP.has(name)) { clearStackToTableContext(); insertHtmlElement(name, t.attrs, t.pos); mode = MODE_IN_TABLE_BODY; return; } if (TD_TH_TR.has(name)) { clearStackToTableContext(); insertHtmlElement("tbody", EMPTY_ATTRS, t.pos); mode = MODE_IN_TABLE_BODY; return process(t); } if (name === "table") { if (!inTableScope("table")) return; popUntil("table"); resetInsertionMode(); return process(t); } if (STYLE_SCRIPT_TEMPLATE.has(name)) { return modes.inHead(t); } if (name === "input") { const ty = _findAttr(t.attrs.start, t.attrs.count, "type"); if (ty !== 0 && _attrValueOf(ty).toLowerCase() === "hidden") { insertHtmlElement("input", t.attrs, t.pos); popOpen(); return; } } if (name === "form") { if (form || open.some(isHtmlTemplateEl)) { return; } form = insertHtmlElement("form", t.attrs, t.pos); popOpen(); return; } } if (t.type === TOKEN_END_TAG) { if (t.name === "table") { if (!inTableScope("table")) return; popUntil("table"); resetInsertionMode(); return; } if (IN_TABLE_IGNORED_ENDS.has(t.name)) { return; } if (t.name === "template") return modes.inHead(t); } if (t.type === TOKEN_EOF) return modes.inBody(t); // anything else: foster parenting fosterParenting = true; modes.inBody(t); fosterParenting = false; }; modes.inTableText = (t) => { if (t.type === TOKEN_CHAR) { const data = inputHasNul && t.data.includes("\0") ? t.data.replace(/\0/g, "") : t.data; if (data === "") return; // Snapshot into a fresh token: these are buffered and replayed after // later tokens arrive, so they must not alias the reused token. /** @type {CharToken} */ const tc = { type: TOKEN_CHAR, data, start: t.start, end: t.end }; const pending = /** @type {{ list: CharToken[], hasNonWs: boolean }} */ ( pendingTableChars ); pending.list.push(tc); if (!isAllWs(tc.data)) pending.hasNonWs = true; return; } // flush const chars = /** @type {{ list: CharToken[], hasNonWs: boolean }} */ ( pendingTableChars ); pendingTableChars = null; mode = originalMode; for (const ct of chars.list) { if (chars.hasNonWs) { fosterParenting = true; modes.inBody(ct); fosterParenting = false; } else { insertCharacters(ct.data, ct.start, ct.end); } } process(t); }; const clearStackToTableContext = () => { while (open.length) { const c = cur(); if (_namespaceOf(c) === NS_HTML && CLEAR_TABLE.has(_tagNameOf(c))) { break; } popOpen(); } }; const clearStackToTableBodyContext = () => { while (open.length) { const c = cur(); if (_namespaceOf(c) === NS_HTML && CLEAR_TABLE_BODY.has(_tagNameOf(c))) { break; } popOpen(); } }; const clearStackToTableRowContext = () => { while (open.length) { const c = cur(); if (_namespaceOf(c) === NS_HTML && CLEAR_TABLE_ROW.has(_tagNameOf(c))) { break; } popOpen(); } }; modes.inCaption = (t) => { if ( (t.type === TOKEN_END_TAG && t.name === "caption") || (t.type === TOKEN_START_TAG && CAPTION_TABLE_STARTS.has(t.name)) || (t.type === TOKEN_END_TAG && t.name === "table") ) { if (!inTableScope("caption")) return; generateImpliedEndTags(); popUntil("caption"); clearAfeToMarker(); mode = MODE_IN_TABLE; if (!(t.type === TOKEN_END_TAG && t.name === "caption")) { return process(t); } return; } if (t.type === TOKEN_END_TAG && CAPTION_IGNORED_ENDS.has(t.name)) { return; } return modes.inBody(t); }; modes.inColumnGroup = (t) => { if (t.type === TOKEN_CHAR) { const r = leadingWs(t, true); if (!r) return; if (_tagNameOf(cur()) !== "colgroup") return; popOpen(); mode = MODE_IN_TABLE; process(r); return; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG && t.name === "html") return modes.inBody(t); if (t.type === TOKEN_START_TAG && t.name === "col") { insertHtmlElement("col", t.attrs, t.pos); popOpen(); return; } if (t.type === TOKEN_END_TAG && t.name === "colgroup") { if (_tagNameOf(cur()) !== "colgroup") return; popOpen(); mode = MODE_IN_TABLE; return; } if (t.type === TOKEN_END_TAG && t.name === "col") return; if ( (t.type === TOKEN_START_TAG || t.type === TOKEN_END_TAG) && t.name === "template" ) { return modes.inHead(t); } if (t.type === TOKEN_EOF) return modes.inBody(t); if (_tagNameOf(cur()) !== "colgroup") return; popOpen(); mode = MODE_IN_TABLE; process(t); }; modes.inTableBody = (t) => { if (t.type === TOKEN_START_TAG && t.name === "tr") { clearStackToTableBodyContext(); insertHtmlElement("tr", t.attrs, t.pos); mode = MODE_IN_ROW; return; } if (t.type === TOKEN_START_TAG && (t.name === "th" || t.name === "td")) { clearStackToTableBodyContext(); insertHtmlElement("tr", EMPTY_ATTRS, t.pos); mode = MODE_IN_ROW; return process(t); } if (t.type === TOKEN_END_TAG && TBODY_GROUP.has(t.name)) { if (!inTableScope(t.name)) return; clearStackToTableBodyContext(); popOpen(); mode = MODE_IN_TABLE; return; } if ( (t.type === TOKEN_START_TAG && TBODY_TRIGGER_STARTS.has(t.name)) || (t.type === TOKEN_END_TAG && t.name === "table") ) { if (!inTableScope(TBODY_GROUP)) return; clearStackToTableBodyContext(); popOpen(); mode = MODE_IN_TABLE; return process(t); } if (t.type === TOKEN_END_TAG && TBODY_IGNORED_ENDS.has(t.name)) { return; } return modes.inTable(t); }; modes.inRow = (t) => { if (t.type === TOKEN_START_TAG && (t.name === "th" || t.name === "td")) { clearStackToTableRowContext(); insertHtmlElement(t.name, t.attrs, t.pos); mode = MODE_IN_CELL; insertMarker(); return; } if (t.type === TOKEN_END_TAG && t.name === "tr") { if (!inTableScope("tr")) return; clearStackToTableRowContext(); popOpen(); mode = MODE_IN_TABLE_BODY; return; } if ( (t.type === TOKEN_START_TAG && ROW_TRIGGER_STARTS.has(t.name)) || (t.type === TOKEN_END_TAG && t.name === "table") ) { if (!inTableScope("tr")) return; clearStackToTableRowContext(); popOpen(); mode = MODE_IN_TABLE_BODY; return process(t); } if (t.type === TOKEN_END_TAG && TBODY_GROUP.has(t.name)) { if (!inTableScope(t.name)) return; if (!inTableScope("tr")) return; clearStackToTableRowContext(); popOpen(); mode = MODE_IN_TABLE_BODY; return process(t); } if (t.type === TOKEN_END_TAG && ROW_IGNORED_ENDS.has(t.name)) { return; } return modes.inTable(t); }; modes.inCell = (t) => { if (t.type === TOKEN_END_TAG && (t.name === "td" || t.name === "th")) { if (!inTableScope(t.name)) return; generateImpliedEndTags(); popUntil(t.name); clearAfeToMarker(); mode = MODE_IN_ROW; return; } if (t.type === TOKEN_START_TAG && CAPTION_TABLE_STARTS.has(t.name)) { if (!inTableScope("td") && !inTableScope("th")) return; closeCell(); return process(t); } if (t.type === TOKEN_END_TAG && TABLE_CONTEXT.has(t.name)) { if (!inTableScope(t.name)) return; closeCell(); return process(t); } if (t.type === TOKEN_END_TAG && CELL_IGNORED_ENDS.has(t.name)) { return; } return modes.inBody(t); }; const closeCell = () => { generateImpliedEndTags(); popUntilOneOf(TD_TH); clearAfeToMarker(); mode = MODE_IN_ROW; }; modes.inTemplate = (t) => { if ( t.type === TOKEN_CHAR || t.type === TOKEN_COMMENT || t.type === TOKEN_DOCTYPE ) { return modes.inBody(t); } if (t.type === TOKEN_START_TAG) { if (HEAD_ELEMENTS.has(t.name)) { return modes.inHead(t); } const target = TEMPLATE_START_TAG_MODES.get(t.name) || MODE_IN_BODY; templateModes[templateModes.length - 1] = target; mode = target; return process(t); } if (t.type === TOKEN_END_TAG) { if (t.name === "template") return modes.inHead(t); return; } if (t.type === TOKEN_EOF) { if (!open.some(isHtmlTemplateEl)) { return; } popUntil("template"); clearAfeToMarker(); templateModes.pop(); resetInsertionMode(); return process(t); } }; modes.afterBody = (t) => { if (t.type === TOKEN_CHAR && isAllWs(t.data)) return modes.inBody(t); if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end, { parent: open[0], beforeNode: 0 }); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG && t.name === "html") return modes.inBody(t); if (t.type === TOKEN_END_TAG && t.name === "html") { if (fragment) return; mode = MODE_AFTER_AFTER_BODY; return; } if (t.type === TOKEN_EOF) return; mode = MODE_IN_BODY; process(t); }; modes.inFrameset = (t) => { if (t.type === TOKEN_CHAR) { const ws = t.data.replace(/[^\t\n\f\r ]/g, ""); if (ws) insertCharacters(ws, t.start, t.end); return; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG && t.name === "html") return modes.inBody(t); if (t.type === TOKEN_START_TAG && t.name === "frameset") { insertHtmlElement("frameset", t.attrs, t.pos); return; } if (t.type === TOKEN_END_TAG && t.name === "frameset") { if (_tagNameOf(cur()) === "html") return; popOpen(); if (!fragment && _tagNameOf(cur()) !== "frameset") { mode = MODE_AFTER_FRAMESET; } return; } if (t.type === TOKEN_START_TAG && t.name === "frame") { insertHtmlElement("frame", t.attrs, t.pos); popOpen(); return; } if (t.type === TOKEN_START_TAG && t.name === "noframes") { return modes.inHead(t); } }; modes.afterFrameset = (t) => { if (t.type === TOKEN_CHAR) { const ws = t.data.replace(/[^\t\n\f\r ]/g, ""); if (ws) insertCharacters(ws, t.start, t.end); return; } if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end); return; } if (t.type === TOKEN_DOCTYPE) return; if (t.type === TOKEN_START_TAG && t.name === "html") return modes.inBody(t); if (t.type === TOKEN_END_TAG && t.name === "html") { mode = MODE_AFTER_AFTER_FRAMESET; return; } if (t.type === TOKEN_START_TAG && t.name === "noframes") { return modes.inHead(t); } }; modes.afterAfterBody = (t) => { if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end, { parent: doc, beforeNode: 0 }); return; } if (t.type === TOKEN_DOCTYPE) return modes.inBody(t); if (t.type === TOKEN_CHAR && isAllWs(t.data)) return modes.inBody(t); if (t.type === TOKEN_START_TAG && t.name === "html") return modes.inBody(t); if (t.type === TOKEN_EOF) return; mode = MODE_IN_BODY; process(t); }; modes.afterAfterFrameset = (t) => { if (t.type === TOKEN_COMMENT) { insertComment(t.data, t.start, t.end, { parent: doc, beforeNode: 0 }); return; } if (t.type === TOKEN_DOCTYPE) return modes.inBody(t); if (t.type === TOKEN_CHAR && isAllWs(t.data)) return modes.inBody(t); if (t.type === TOKEN_START_TAG && t.name === "html") return modes.inBody(t); if (t.type === TOKEN_START_TAG && t.name === "noframes") { return modes.inHead(t); } }; const dispatch = () => { process(/** @type {Token} */ (/** @type {unknown} */ (tok))); // One boolean per token: only a stack change can complete a subtree. if (_streamEligible && (_openStackChanged || _openSpliced)) { _openStackChanged = false; if (_streaming) { _streamSync(); } else if (_nodeCount >= _STREAM_MIN_NODES) { // Big enough to be worth recycling: start streaming here. The tree so // far is unvisited, so the first sync enters the open stack and flushes // everything already finished under it. _streaming = true; _streamSync(); } } }; // Tokenizer callbacks, hoisted to module scope and closing over the module- // scope parser state. `tokenize`'s indirect `callbacks.text(...)` / `openTag` // / `attribute(...)` sites then see one stable function identity across every // parse, staying monomorphic so V8 can inline them — a fresh per-parse closure // made them megamorphic and blocked inlining. `parseHtml` only refreshes // `fragmentContext` on the shared object before each tokenize. /** @type {() => boolean} */ const currentNodeIsForeign = () => { const adjustedCurrentNode = adjustedCurrent(); return ( open.length > 0 && adjustedCurrentNode !== 0 && _namespaceOf(adjustedCurrentNode) !== NS_HTML ); }; /** @type {(input: string, code: string) => void} */ const handleParseError = (input, code) => { if (code === "eof-in-tag") eofInTag = true; }; /** * @param {string} input input * @param {number} rangeStart start of the range * @param {number} rangeEnd end of the range * @returns {string} the range with NULLs replaced, per the DOCTYPE states */ const doctypePart = (input, rangeStart, rangeEnd) => { const raw = input.slice(rangeStart, rangeEnd); return inputHasNul ? raw.replace(/\0/g, "\uFFFD") : raw; }; /** @type {(input: string, start: number, end: number, nameStart: number, nameEnd: number, publicStart: number, publicEnd: number, systemStart: number, systemEnd: number, forceQuirks: boolean) => number} */ const handleDoctypeToken = ( input, start, end, nameStart, nameEnd, publicStart, publicEnd, systemStart, systemEnd, forceQuirks ) => { tok.type = TOKEN_DOCTYPE; // A missing name and an empty one are the same to every consumer here: the // spec's name states always append at least one character. // ASCII-only folding: the name states lowercase ASCII upper alpha and append // every other character unchanged, so a name of `Ð` stays `Ð`. tok.name = nameStart === -1 ? "" : _asciiLowerCase(doctypePart(input, nameStart, nameEnd)); tok.publicId = publicStart === -1 ? null : doctypePart(input, publicStart, publicEnd); tok.systemId = systemStart === -1 ? null : doctypePart(input, systemStart, systemEnd); tok.forceQuirks = forceQuirks; tok.start = start; tok.end = end; dispatch(); return end; }; /** @type {(input: string, start: number, end: number, dataStart: number, dataEnd: number) => number} */ const handleCommentToken = (input, start, end, dataStart, dataEnd) => { // A CDATA section in foreign content arrives through this callback but is // character data, not a comment. if ( input.startsWith("<![CDATA[", start) && open.length > 0 && adjustedCurrent() && _namespaceOf(adjustedCurrent()) !== NS_HTML ) { const data = input.slice(dataStart, dataEnd).replace(/\r\n?/g, "\n"); if (data !== "") { tok.type = TOKEN_CHAR; tok.data = data; tok.start = start; tok.end = end; dispatch(); } return end; } tok.type = TOKEN_COMMENT; const commentRaw = input.slice(dataStart, dataEnd); tok.data = inputHasNul ? commentRaw.replace(/\0/g, "�") : commentRaw; tok.start = start; tok.end = end; dispatch(); return end; }; /** @type {(input: string, start: number, end: number) => number} */ const handleTextToken = (input, start, end) => { // `skip.text` fast path: node dropped, so only whitespace-ness matters. // With no `&`/`\0`/`\r` (and no pending newline-swallow) the decoded value // equals the raw range — dispatch a canonical marker (`" "`/`"x"`) without // the slice + entity decode; anything trickier falls through. if (skipText && !swallowNextNewline) { let hasNonWhitespace = false; let i = start; for (; i < end; i++) { const charCode = input.charCodeAt(i); // 2 = break (& / NUL / CR), 1 = whitespace, 0 = other. const scanClass = charCode < 128 ? _TEXT_SCAN_CLASS[charCode] : 0; if (scanClass === 2) break; if (scanClass === 0) hasNonWhitespace = true; } if (i === end) { tok.type = TOKEN_CHAR; tok.data = hasNonWhitespace ? "x" : " "; tok.start = start; tok.end = end; dispatch(); return end; } } const raw = input.slice(start, end); // CR normalization only when a CR is actually present (common case has none). const normalized = inputHasCr && raw.includes("\r") ? raw.replace(/\r\n?/g, "\n") : raw; const top = adjustedCurrent(); const rawMode = mode === MODE_TEXT || (top && _namespaceOf(top) === NS_HTML && _tagNameOf(top) === "plaintext"); const noDecode = top && _namespaceOf(top) === NS_HTML && NO_DECODE_TEXT.has(_tagNameOf(top)) && (mode === MODE_TEXT || mode === MODE_IN_BODY); let data = noDecode ? normalized : decode(normalized, false); // In RAWTEXT/RCDATA/script/PLAINTEXT, NULL becomes U+FFFD (tokenizer // rule); in data state NULLs pass through to be dropped in "in body". if (rawMode && inputHasNul && data.includes("\0")) { data = data.replace(/\0/g, "�"); } // pre/listing/textarea swallow a leading newline (post entity decode). if (swallowNextNewline) { swallowNextNewline = false; if (data[0] === "\n") data = data.slice(1); } if (data === "") return end; tok.type = TOKEN_CHAR; tok.data = data; tok.start = start; tok.end = end; dispatch(); return end; }; /** @type {(input: string, nameStart: number, nameEnd: number, valueStart: number, valueEnd: number, quoteType: number) => number} */ const handleAttributeToken = ( input, nameStart, nameEnd, valueStart, valueEnd, quoteType ) => { const name = internLowerName(ATTR_NAME_INTERN, input, nameStart, nameEnd); if (pendAttrStart === 0) pendAttrStart = _attrCount + 1; // Drop duplicate attribute names (per spec). Plain loop avoids a // per-attribute closure allocation on this hot path. let dup = false; for (let i = pendAttrStart; i <= _attrCount; i++) { if (_attrNames[i] === name) { dup = true; break; } } if (!dup) { // The raw (undecoded) value is read from the source by offset on // demand: consumers re-resolve requests from it and the offsets must // stay aligned with the source. Only a valueless attribute stores an // override (""). _allocAttr( name, valueStart !== -1 ? null : "", nameStart, nameEnd, valueStart, valueEnd ); } if (valueStart === -1) return nameEnd; return quoteType !== QUOTE_NONE ? valueEnd + 1 : valueEnd; }; /** @type {(input: string, start: number, end: number, nameStart: number, nameEnd: number, selfClosing: boolean) => number} */ const handleStartTagToken = ( input, start, end, nameStart, nameEnd, selfClosing ) => { // A start tag the tokenizer only emitted because it hit EOF mid-tag // is dropped, matching the spec's eof-in-tag handling. if (eofInTag) { // Any attribute slots already allocated for it are orphaned. pendAttrStart = 0; return end; } const name = internLowerName(TAG_NAME_INTERN, input, nameStart, nameEnd); if (name === "selectedcontent") sawSelectedContent = true; tok.type = TOKEN_START_TAG; tok.name = name; // The reused token carries the tag's attribute run; every consumer of // `t.attrs` runs synchronously within this dispatch. tok.attrs.start = pendAttrStart; tok.attrs.count = pendAttrStart === 0 ? 0 : _attrCount + 1 - pendAttrStart; pendAttrStart = 0; tok.selfClosing = selfClosing; tok.swallowNewline = false; tok.pos.start = start; tok.pos.end = end; tok.pos.tagEnd = end; tok.pos.nameEnd = nameEnd; dispatch(); if (tok.swallowNewline) swallowNextNewline = true; return end; }; /** @type {(input: string, start: number, end: number, nameStart: number, nameEnd: number) => number} */ const handleEndTagToken = (input, start, end, nameStart, nameEnd) => { // Most end tags close the current element: compare the raw range against // its already-interned tag name — one case-folding walk, no hash/probe. let name; if (open.length > 0) { const topName = _nodeStrings[open[open.length - 1]]; if (rangeEqualsLowerCase(input, nameStart, nameEnd, topName)) { name = topName; } } if (name === undefined) { name = internLowerName(TAG_NAME_INTERN, input, nameStart, nameEnd); } // End tags drop any parsed attributes (the slots are orphaned). pendAttrStart = 0; tok.type = TOKEN_END_TAG; tok.name = name; tok.pos.start = start; tok.pos.end = end; tok.pos.tagEnd = end; tok.pos.nameEnd = nameEnd; dispatch(); return end; }; /** @type {HtmlTokenCallbacks} */ const PARSE_CALLBACKS = { isForeign: currentNodeIsForeign, fragmentContext: undefined, parseError: handleParseError, doctype: handleDoctypeToken, comment: handleCommentToken, text: handleTextToken, attribute: handleAttributeToken, openTag: handleStartTagToken, closeTag: handleEndTagToken }; /** * @param {string} input HTML source * @param {number=} pos start byte offset (string input only; default `0`) * @param {HtmlParseOptions=} options parse options (fragment context, AST skips) * @returns {HtmlDocument} ref to the document node — read through `A`; valid until the next parse */ const parseHtml = (input, pos = 0, options = {}) => { source = input; inputHasCr = input.includes("\r"); inputHasNul = input.includes("\0"); const { fragmentContext, skip = EMPTY_SKIP } = options; skipText = skip.text === true; skipComments = skip.comments === true; skipDoctype = skip.doctype === true; _resetAstColumns(); _entered.length = 0; _skipFrom = -1; _streaming = false; _docSkipped = false; _openStackChanged = false; _openSpliced = false; _streamHalted = false; // Pre-size the columns from the input length: a document big enough to // exceed `_COLUMN_SHRINK_CAPACITY` re-enters with released (empty) columns, // and growing through the doubling cascade re-allocates and copies every // column several times per parse. ~12 bytes/node and ~50 bytes/attribute // are the densest realistic HTML, so `len/12` (`len/48`) reaches the final // capacity in one allocation; a sparser document over-allocates bounded // scratch that the post-walk release frees, and the doubling growth remains // as the safety net when the estimate is short. `skip.text` (what // `HtmlParser` uses) drops every text node — roughly half of all nodes — so // its node estimate uses `len/24`; attributes are unaffected by the skip and // keep `len/48`. let nodeEstimate = (input.length / (skipText ? 24 : 12)) | 0; const attrEstimate = (input.length / 48) | 0; if (_streamEligible) { // Node ids are recycled once streaming starts, so a document past the // threshold never needs the whole estimate — one allocation covering the // activation point plus a batch of headroom is its high-water mark. A // document below it is unaffected and still pre-sizes exactly. Attributes // are not recycled (`_attrCount` only resets between parses), so their // estimate stands either way. const cap = _STREAM_MIN_NODES + _FLUSH_BATCH * 2; if (nodeEstimate > cap) nodeEstimate = cap; } if (nodeEstimate > _nodeCapacity) _growNodeColumns(nodeEstimate, true); if (attrEstimate > _attrCapacity) _growAttrColumns(attrEstimate, true); _htmlSource = source; doc = _allocNode(NodeType.Document, 0, 0); _nodeStrings[doc] = ""; mode = MODE_INITIAL; originalMode = 0; open.length = 0; afe.length = 0; head = 0; form = 0; framesetOk = true; pendAttrStart = 0; fosterParenting = false; templateModes.length = 0; quirks = false; fragment = 0; tokenEnd = 0; sharedPlace.parent = doc; sharedPlace.beforeNode = 0; pendingTableChars = null; swallowNextNewline = false; eofInTag = false; sawSelectedContent = false; // ---------- fragment setup ---------- if (fragmentContext) { let ctxName = fragmentContext.toLowerCase(); let ctxNs = NS_HTML; if (ctxName.startsWith("svg ")) { ctxNs = NS_SVG; ctxName = ctxName.slice(4); } else if (ctxName.startsWith("math ")) { ctxNs = NS_MATHML; ctxName = ctxName.slice(5); } fragment = mkEl(ctxName, ctxNs, EMPTY_ATTRS, null); const htmlEl = mkEl("html", NS_HTML, EMPTY_ATTRS, null); _appendChild(doc, htmlEl); open.push(htmlEl); if (ctxNs !== NS_HTML) { mode = MODE_IN_BODY; } else if (["title", "textarea"].includes(ctxName)) { originalMode = MODE_IN_BODY; mode = MODE_TEXT; } else if ( ["style", "xmp", "iframe", "noembed", "noframes", "script"].includes( ctxName ) ) { originalMode = MODE_IN_BODY; mode = MODE_TEXT; } else if (ctxName === "noscript" || ctxName === "plaintext") { mode = MODE_IN_BODY; } else { resetInsertionMode(); } if (ctxName === "template") { templateModes.push(MODE_IN_TEMPLATE); mode = MODE_IN_TEMPLATE; } } // Refresh the per-parse fragment context on the shared module-scope // callbacks object; its function properties stay identity-stable so the // tokenizer's callback call sites remain monomorphic. PARSE_CALLBACKS.fragmentContext = fragment ? _tagNameOf(fragment) : undefined; // A `skipChildren()` on the root means nothing under it is visited, so the // walk never starts and only the document's own `exit` is left to fire. if (_streamEligible && !_enterNode(doc)) { _streamEligible = false; _docSkipped = true; } tokenize(source, pos, PARSE_CALLBACKS); tok.type = TOKEN_EOF; dispatch(); if (sawSelectedContent) mirrorSelectedContent(doc, 0); if (_streamEligible) _streamFinish(doc); else if (_docSkipped) _exitNode(doc); return doc; }; /** * Deep-clone a node into fresh ids, dropping attribute source offsets and the * raw-text body span so cloned content does not re-emit dependencies. * @param {HtmlNodeRef} node node * @returns {HtmlNodeRef} clone */ const cloneSubtree = (node) => { const ty = _nodeTypes[node]; const clone = _allocNode(ty, _nodeStarts[node], _nodeEnds[node]); _nodeStrings[clone] = _nodeStrings[node]; if (ty !== NodeType.Element) return clone; _nodeFlags[clone] = _nodeFlags[node]; const attrs = cloneAttrs(node); _nodeAttrStarts[clone] = attrs.start; _nodeAttrCounts[clone] = attrs.count; _nodeTagEnds[clone] = _nodeTagEnds[node]; _nodeNameEnds[clone] = _nodeNameEnds[node]; // Empty body span: the clone re-emits no raw-text dependency. _nodeContentEnds[clone] = _nodeTagEnds[node]; for (let k = _nodeFirstChildren[node]; k !== 0; k = _nodeNextSiblings[k]) { _appendChild(clone, cloneSubtree(k)); } // Clone `<template>` content into the clone's own fragment. const tc = _templateContentOf(node); if (tc !== 0) { const fragment = _allocNode(NodeType.DocumentFragment, 0, 0); _nodeStrings[fragment] = ""; // Parent link so the iterative walk can ascend out of the content. _nodeParents[fragment] = clone; _nodeContentEnds[clone] = fragment; _nodeFlags[clone] |= FLAG_HAS_TEMPLATE; for (let k = _nodeFirstChildren[tc]; k !== 0; k = _nodeNextSiblings[k]) { _appendChild(fragment, cloneSubtree(k)); } } return clone; }; /** * The selected option of a select: the last `<option selected>`, else the * first option (scanning direct children and `<optgroup>` children). * @param {HtmlElement} select select element * @returns {HtmlElement} selected option (0 = none) */ const selectedOption = (select) => { /** @type {HtmlElement[]} */ const options = []; /** @param {HtmlElement} el element */ const collect = (el) => { for (let c = _nodeFirstChildren[el]; c !== 0; c = _nodeNextSiblings[c]) { if (_nodeTypes[c] !== NodeType.Element || _namespaceOf(c) !== NS_HTML) { continue; } if (_nodeStrings[c] === "option") options.push(c); else if (_nodeStrings[c] === "optgroup") collect(c); } }; collect(select); if (options.length === 0) return 0; for (let i = options.length - 1; i >= 0; i--) { const el = options[i]; if (_findAttr(_nodeAttrStarts[el], _nodeAttrCounts[el], "selected") !== 0) { return el; } } return options[0]; }; /** * Fill each `<selectedcontent>` with a clone of its `<select>`'s selected * option subtree (the customizable-select mirroring behavior). * @param {HtmlNodeRef} node node * @param {HtmlElement} select nearest ancestor select (0 = none) */ const mirrorSelectedContent = (node, select) => { // A `<template>`'s children live in its content fragment. const tc = _templateContentOf(node); const container = tc !== 0 ? tc : node; for ( let child = _nodeFirstChildren[container]; child !== 0; child = _nodeNextSiblings[child] ) { if (_nodeTypes[child] !== NodeType.Element) continue; if (_namespaceOf(child) === NS_HTML && _nodeStrings[child] === "select") { mirrorSelectedContent(child, child); } else if ( select !== 0 && _namespaceOf(child) === NS_HTML && _nodeStrings[child] === "selectedcontent" ) { const option = selectedOption(select); if (option !== 0) { // Replace the children with clones of the option's subtree. _nodeFirstChildren[child] = 0; _nodeLastChildren[child] = 0; for ( let k = _nodeFirstChildren[option]; k !== 0; k = _nodeNextSiblings[k] ) { _appendChild(child, cloneSubtree(k)); } } } else { mirrorSelectedContent(child, select); } } }; /** @typedef {HtmlNode | HtmlDocument | HtmlDocumentFragment} HtmlVisitableNode */ // HTML-typed views over the generic visitor machinery (`util/SourceProcessor`). /** * @typedef {import("../util/SourceProcessor").VisitorFn<HtmlPath>} VisitorFn * @typedef {import("../util/SourceProcessor").VisitorBucket<HtmlPath>} VisitorBucket * @typedef {import("../util/SourceProcessor").VisitorMap<HtmlPath>} VisitorMap * @typedef {import("../util/SourceProcessor").CompiledVisitorMap<HtmlPath>} CompiledVisitorMap */ /** * @typedef {object} HtmlProcessOptions * @property {string=} fragmentContext context element tag name for fragment parsing (see `parseHtml`); the HTML analog of the CSS parser's `as` parse-mode option * @property {HtmlAstSkip=} skip node kinds to omit from the AST for speed/memory (see `HtmlAstSkip`) * @property {boolean=} minimize print the safely-minified serialization (nodes rebuilt from source, inert comments dropped, opening-tag whitespace collapsed) as `process` walks, and return it (default false = walk only, return `""`) * @property {CssEnvironment=} environment what the target can read, forwarded to the CSS minifier this runs over an inline `<style>` and every `style=""` * @property {boolean=} convertLengthUnits forwarded to that CSS minifier with `environment`: a length may be rewritten into a shorter unit it exactly equals (default false) * @property {boolean=} collapseWhitespace collapse each run of whitespace in text to a single space, except where an ancestor renders it verbatim (default false) */ /** @typedef {import("../util/SourceProcessor").PrintContext<HtmlPath, HtmlNodeRef>} PrintContext */ // Whether the text ends inside an unterminated character reference, so the next // sibling's first characters could complete it (`a &am` + `p;`). /** * @param {string} s source text * @returns {boolean} true when a character reference is still open at the end */ const _hasOpenReference = (s) => { const last = s.lastIndexOf("&"); return last !== -1 && !/[^\dA-Za-z#]/.test(s.slice(last + 1)); }; /** * Whether the `&` at `i` would be consumed as a character reference when the * text is read back (§13.2.5.72): only before `#` or a name the table holds. * Every other `&` the tokenizer flushes as literal text, so `R&D` needs no * escape — and neither does the `&` in a query string. * @param {string} s decoded text * @param {number} i the `&`'s index * @returns {boolean} true when it has to be escaped */ const _startsCharacterReference = (s, i) => { // Nothing terminates the run before the node ends, so the next sibling's // first characters could complete it once a comment between them is dropped // (`a &am` + `p;`) — the same reason the source fast path keeps out. let end = i + 1; while (end < s.length) { const c = s.charCodeAt(end); if (!isAsciiAlphanumeric(c) && c !== 0x23) break; end++; } if (end === s.length) return true; const next = s.charCodeAt(i + 1); // `#` opens a numeric reference. if (next === 0x23) return true; if (!isAsciiAlphanumeric(next)) return false; // The named-reference table holds the names with and without their `;`, so // any prefix hit is a reference the tokenizer would take. const longest = Math.min(s.length, i + 1 + MAX_ENTITY_NAME_LEN); for (let j = i + 2; j <= longest; j++) { if (HTML_ENTITIES[s.slice(i + 1, j)] !== undefined) return true; } return false; }; /** * Escape a text node's decoded data for re-serialization: `&` and `<`, which * start a character reference and a tag, plus `>` outside minification, where * the §13.3 serialization is followed to the letter. Unlike the exported * `escapeText` (which also numerically encodes newlines and U+00A0 for * single-line `data:` URIs), this keeps newlines and U+00A0 literal — byte-lean * and the same DOM. * @param {string} s decoded text * @param {boolean} minify whether the shorter (still equivalent) form is wanted * @returns {string} text-content-safe string */ const _escapeTextContent = (s, minify) => { // A bare `>` is only ever a character in text, so §13.3's escape of it buys // nothing but bytes. `<` always starts a tag, and an `&` only sometimes // starts a character reference — §13.2.5.72 flushes the rest as literal text. if (minify) { if (!s.includes("&") && !s.includes("<")) return s; let out = ""; let last = 0; for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); if (c === 0x3c) { out += `${s.slice(last, i)}<`; last = i + 1; } else if (c === 0x26 && _startsCharacterReference(s, i)) { out += `${s.slice(last, i)}&`; last = i + 1; } } return last === 0 ? s : out + s.slice(last); } if (!s.includes("&") && !s.includes("<") && !s.includes(">")) return s; return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }; /** * Rebuild an opening tag from the node's name and attributes, for elements with * no matching source tag (adoption-agency clones, reconstructed formatting * elements, renamed tokens like `<image>`). Attribute values are raw * (undecoded), so they re-parse to the original decoded values. * @param {HtmlPath} path the accessor positioned on the element * @returns {string} serialized opening tag */ const _synthesizeOpenTag = (path) => { let out = `<${path.tagName()}`; for (const attribute of path.attributes()) { const name = attribute.serializedName !== undefined ? attribute.serializedName : attribute.name; out += attribute.value === "" ? ` ${name}` : ` ${name}="${attribute.value.replace(/"/g, """)}"`; } return `${out}>`; }; /** * Whether an element's end tag may be omitted (§13.1.2.4). Reads the AST, not * the emitted text, so an intervening comment or whitespace node keeps the tag * even when minification later drops it — conservative, never wrong. * @param {HtmlPath} path the accessor positioned on the element * @param {string} name the element's lowercased tag name * @param {HtmlNodeRef} node the element * @returns {boolean} true when the end tag can be left out */ const _canOmitEndTag = (path, name, node) => { if (path.namespace(node) !== NS_HTML) return false; if (OPTIONAL_END_TAG_UNLESS_TRAILING_NODE.has(name)) { // Whitespace or a comment behind it would move inside once the tag goes; // an element closes it through the insertion mode instead. const trailing = path.nextSibling(node); return trailing === 0 || path.type(trailing) === NodeType.Element; } const followers = OPTIONAL_END_TAG_FOLLOWERS.get(name); if (followers === undefined) return false; const next = path.nextSibling(node); if (next === 0) { if (!OPTIONAL_END_TAG_AT_END.has(name)) return false; const parent = path.parentOf(node); const parentIsHtml = parent !== 0 && path.namespace(parent) === NS_HTML; // Left open inside a formatting element, this is the furthest block the // adoption agency needs — the parent's end tag would then restructure both. if ( parentIsHtml && SPECIAL.has(name) && FORMATTING.has(path.tagName(parent)) ) { return false; } if (name !== "p") return true; if (!parentIsHtml) return false; const parentName = path.tagName(parent); // An autonomous custom element is spelled with a hyphen. return !P_KEEPS_END_TAG_IN.has(parentName) && !parentName.includes("-"); } if ( path.type(next) !== NodeType.Element || path.namespace(next) !== NS_HTML || !followers.has(path.tagName(next)) ) { return false; } // A parser-implied `<tbody>` / `<tr>` is printed transparently, so there is // no start tag to close this element — its rows would continue this one. return ( path.openTag(next) !== "" || path.attributeCount(next) !== 0 || !TRANSPARENT_IMPLIED_ELEMENTS.has(path.tagName(next)) ); }; /** * The node before `node` in its parent's child chain, or 0 — the accessor only * walks forward. * @param {HtmlPath} path the accessor * @param {HtmlNodeRef} node the element * @returns {HtmlNodeRef} the preceding sibling, or 0 */ const _previousSibling = (path, node) => { let previous = 0; for ( let c = path.firstChild(path.parentOf(node)); c !== 0 && c !== node; c = path.nextSibling(c) ) { previous = c; } return previous; }; /** * Whether an element is shaped like one the parser would re-imply: no attributes * to carry, and `child` opening it. Anything before that child (whitespace, a * comment) would land outside the implied element, so it blocks the omission. * @param {HtmlPath} path the accessor * @param {HtmlNodeRef} node the element * @param {string} child the tag name the implied element must start with * @returns {boolean} true when the shape allows dropping the start tag */ const _impliedStartTagShape = (path, node, child) => { if (path.attributeCount(node) !== 0 || path.namespace(node) !== NS_HTML) { return false; } const first = path.firstChild(node); return ( first !== 0 && path.type(first) === NodeType.Element && path.namespace(first) === NS_HTML && path.tagName(first) === child ); }; /** * Whether a `<tbody>` start tag may be omitted (§13.1.2.4): the parser re-implies * it when a `<tr>` opens a table section, but only if no earlier section ran into * it — a preceding `tbody` / `thead` / `tfoot` that also dropped its end tag would * swallow these rows instead. * @param {HtmlPath} path the accessor positioned on the element * @param {HtmlNodeRef} node the element * @returns {boolean} true when the start tag can be left out */ const _canOmitTbodyStart = (path, node) => { if (!_impliedStartTagShape(path, node, "tr")) return false; const previous = _previousSibling(path, node); if (previous === 0 || path.type(previous) !== NodeType.Element) return true; const previousName = path.tagName(previous); if ( previousName !== "tbody" && previousName !== "thead" && previousName !== "tfoot" ) { return true; } return !_canOmitEndTag(path, previousName, previous); }; /** * Whether a `<colgroup>` start tag may be omitted (§13.1.2.4): a `<col>` in a * table implies one, but only when the `<col>` is the first thing inside and no * preceding `<colgroup>` dropped its end tag — that one would take these columns. * @param {HtmlPath} path the accessor positioned on the element * @param {HtmlNodeRef} node the element * @returns {boolean} true when the start tag can be left out */ const _canOmitColgroupStart = (path, node) => { if (!_impliedStartTagShape(path, node, "col")) return false; const previous = _previousSibling(path, node); if ( previous === 0 || path.type(previous) !== NodeType.Element || path.tagName(previous) !== "colgroup" ) { return true; } return !_canOmitEndTag(path, "colgroup", previous); }; // The CSS abilities of the target, forwarded into the nested CSS minifier so an // inline `<style>` or `style=""` is gated exactly like a `.css` asset. Set per // print, since the printer is the only place the options are in scope. /** @type {CssEnvironment | undefined} */ let _cssEnvironment; // Whether the nested CSS minifier may rewrite a length's unit — forwarded like // `_cssEnvironment` so inline CSS agrees with the `.css` assets. let _cssConvertLengthUnits = false; // Whether text whitespace may be collapsed (see `HtmlProcessOptions`) — set // per print, like `_cssEnvironment`. let _collapseWhitespace = false; /** * Minify a `<style>` body with webpack's own CSS minifier — the one that runs on * CSS assets, so an inline sheet is held to the same rules. Anything it rejects * is handed back untouched rather than failing the build. * @param {string} css the element's text * @returns {string} the minified text */ const _minifyInlineCss = (css) => { if (css.trim() === "") return css; try { const cssSyntax = _cssSyntax || (_cssSyntax = require("../css/syntax")); return new cssSyntax.SourceProcessor().process(css, { minimize: true, environment: _cssEnvironment, convertLengthUnits: _cssConvertLengthUnits }).code; } catch (_err) { return css; } }; // A `style` attribute holds a declaration list, so it is minified inside a // throwaway rule; anything that did not stay one rule escaped the wrapper. const _STYLE_ATTRIBUTE_WRAPPER = /^a\{([^{}]*)\}$/; /** * Minify a `style` attribute's declaration list with webpack's CSS minifier. * @param {string} raw raw (undecoded) attribute value * @returns {string} the minified declarations */ const _minifyStyleAttribute = (raw) => { if (raw.trim() === "") return raw; let out; try { const cssSyntax = _cssSyntax || (_cssSyntax = require("../css/syntax")); out = new cssSyntax.SourceProcessor().process(`a{${raw}}`, { minimize: true, environment: _cssEnvironment, convertLengthUnits: _cssConvertLengthUnits }).code; } catch (_err) { return raw; } const wrapped = _STYLE_ATTRIBUTE_WRAPPER.exec(out); return wrapped === null ? raw : wrapped[1]; }; /** * Trim ASCII whitespace, which is all these grammars skip — `String#trim` also * eats NBSP and the other Unicode spaces, which they read as ordinary * characters (a leading NBSP makes an integer unparsable, and is a class token * of its own). * @param {string} value the value * @returns {string} the trimmed value */ const _asciiTrim = (value) => { let start = 0; let end = value.length; while (start < end && isSpace(value.charCodeAt(start))) start++; while (end > start && isSpace(value.charCodeAt(end - 1))) end--; return start === 0 && end === value.length ? value : value.slice(start, end); }; /** * Normalize a `<meta name=viewport>` content list: its grammar ignores the * whitespace around the `,` / `;` / `=` separators, so dropping that keeps every * pair intact. * @param {string} raw raw (undecoded) attribute value * @returns {string} the normalized list */ const _normalizeViewport = (raw) => _asciiTrim(raw) .replace(/[\t\n\f\r ]*([,;=])[\t\n\f\r ]*/g, "$1") .replace(/[\t\n\f\r ]+/g, " "); /** * Wrap a rewritten attribute value in the shortest quote it admits, or null when * it carries both kinds and the source spelling has to stand. * @param {string} value the value * @returns {string | null} the quoted value, or null */ const _quoteAttributeValue = (value) => { if (!value.includes('"')) return `"${value}"`; if (!value.includes("'")) return `'${value}'`; return null; }; /** * Whether an attribute is boolean on this element, so its presence alone is the * value. `null` in the table means the attribute is global. * @param {string} tagName lowercased element name * @param {string} name lowercased attribute name * @returns {boolean} true when the value carries nothing */ const _isBooleanAttribute = (tagName, name) => { const elements = BOOLEAN_ATTRIBUTES.get(name); if (elements === undefined) return BOOLEAN_ATTRIBUTES.has(name); return elements === null || elements.has(tagName); }; /** * Collapse an ASCII-whitespace-separated token list (`class`) to single spaces. * The token set is what the DOM reads, and it is unchanged. * @param {string} raw raw (undecoded) value * @returns {string} the collapsed list */ const _normalizeTokenList = (raw) => _asciiTrim(raw).replace(/[\t\n\f\r ]+/g, " "); /** * Drop the whitespace a comma-separated list's grammar ignores: "split a string * on commas" strips each token's edges and nothing else. Whitespace inside a * token stays — it is part of the token `accept` keeps, and a separator of its * own in the number list `coords` parses. * @param {string} raw raw (undecoded) value * @returns {string} the normalized list */ const _normalizeCommaList = (raw) => raw .split(",") .map((item) => _asciiTrim(item)) .join(","); /** * Normalize an integer attribute: the parse rules skip leading ASCII whitespace * and stop at the first non-digit, so the ends and any leading zeros carry * nothing. A value those rules would not read as one integer is left alone — * only the signed rules accept a `+` or `-`, so a signed value on a * non-negative attribute stays as it is (it does not parse at all). * @param {string} raw raw (undecoded) value * @param {boolean} signed whether the attribute parses with the signed rules * @returns {string} the normalized integer */ const _normalizeInteger = (raw, signed) => { const value = _asciiTrim(raw); if (!(signed ? /^[+-]?\d+$/ : /^\d+$/).test(value)) return raw; const negative = value.charCodeAt(0) === 0x2d; const digits = value.replace(/^[+-]/, "").replace(/^0+(?=\d)/, ""); return negative && digits !== "0" ? `-${digits}` : digits; }; /** * The value rewrites that keep an attribute's parsed meaning: a srcset's * whitespace, a `style` declaration list, and the viewport `content` list. * @param {string} element lowercased element name * @param {string} name lowercased attribute name * @param {string} raw raw (undecoded) value * @param {boolean} viewport whether the element is `<meta name=viewport>` * @returns {string} the rewritten value, or `raw` when nothing applies */ const _rewriteAttributeValue = (element, name, raw, viewport) => { if (SRCSET_ATTRIBUTES.has(name)) return _normalizeSrcset(raw); if (name === "style") return _minifyStyleAttribute(raw); const tokenListOn = TOKEN_LIST_ATTRIBUTES.get(name); if ( tokenListOn !== undefined && (tokenListOn === null || tokenListOn.has(element)) ) { return _normalizeTokenList(raw); } if (viewport && name === "content") return _normalizeViewport(raw); if (COMMA_LIST_ATTRIBUTES.has(name)) return _normalizeCommaList(raw); const urlOn = URL_ATTRIBUTES.get(name); if (urlOn !== undefined && (urlOn === null || urlOn.has(element))) { return _asciiTrim(raw); } const integerOn = INTEGER_ATTRIBUTES.get(name); if ( integerOn !== undefined && (integerOn === null || integerOn.has(element)) ) { return _normalizeInteger(raw, SIGNED_INTEGER_ATTRIBUTES.has(name)); } return raw; }; /** * A `<script>`'s lowercased `type`, or `""` when it has none. * @param {HtmlPath} path the accessor * @param {HtmlElement} element the `<script>` element * @returns {string} the type */ const _scriptType = (path, element) => { for (const attribute of path.attributes(element)) { if (attribute.name === "type") { return _asciiLowerCase(_asciiTrim(attribute.value)); } } return ""; }; /** * Whether an element is the viewport `<meta>`, whose content list is the only * `content` this rewrites. * @param {HtmlPath} path the accessor positioned on the `<meta>` * @returns {boolean} true for `<meta name=viewport>` */ const _isViewportMeta = (path) => { for (const attribute of path.attributes()) { if (attribute.name === "name") { return _asciiLowerCase(_asciiTrim(attribute.value)) === "viewport"; } } return false; }; const _JSON_SUBTYPE_REGEXP = /^[!#$%&'*+.^_`|~\w-]+\/[!#$%&'*+.^_`|~\w-]*\+json$/; /** * Strip the whitespace between a JSON body's tokens. Every literal is copied * byte for byte — re-serializing would reorder nothing but would round numbers * through a double, drop a duplicate key and rewrite escapes, none of which is * this transform's to do. * @param {string} json a `<script>` body * @returns {string} the stripped body */ const _minifyInlineJson = (json) => { if (json.trim() === "") return json; try { JSON.parse(json); } catch (_err) { // Not JSON after all (a template, a placeholder) — not ours to touch. return json; } let out = ""; let inString = false; let escaped = false; for (let i = 0; i < json.length; i++) { const c = json.charCodeAt(i); if (inString) { out += json[i]; if (escaped) escaped = false; else if (c === 0x5c) escaped = true; else if (c === 0x22) inString = false; continue; } if (c === 0x22) { inString = true; out += json[i]; continue; } // JSON whitespace (RFC 8259): tab, LF, CR, space. if (c === 0x09 || c === 0x0a || c === 0x0d || c === 0x20) continue; out += json[i]; } return out; }; /** * Whether a `<style>` element's body is CSS: the attribute is optional, and the * only value that keeps it CSS is `text/css`. * @param {HtmlPath} path the accessor * @param {HtmlElement} element the `<style>` element * @returns {boolean} true when the body may be minified as CSS */ const _isCssStyleElement = (path, element) => { for (const attribute of path.attributes(element)) { if (attribute.name !== "type") continue; const value = _asciiLowerCase(_asciiTrim(attribute.value)); return value === "" || value === "text/css"; } return true; }; /** * Re-serialize a srcset with its whitespace reduced to what the grammar needs. * The candidate list is unchanged — only the bytes between candidates go — and * anything the parser rejects is handed back untouched. * @param {string} raw raw (undecoded) attribute value * @returns {string} the normalized value */ const _normalizeSrcset = (raw) => { let candidates; try { candidates = parseSrcset(raw); } catch (_err) { return raw; } if (candidates.length === 0) return raw; let out = ""; let previousBare = false; for (let i = 0; i < candidates.length; i++) { const url = candidates[i][0]; const end = candidates[i][2]; const nextStart = i + 1 < candidates.length ? candidates[i + 1][1] : raw.length; let descriptor = raw.slice(end, nextStart); const comma = descriptor.indexOf(","); if (comma !== -1) descriptor = descriptor.slice(0, comma); descriptor = _asciiTrim(descriptor).replace(/[\t\n\f\r ]+/g, " "); // A descriptor-less URL runs to the next whitespace, so without one it // would swallow the separating comma and the URL behind it. if (i !== 0) out += previousBare ? ", " : ","; out += descriptor === "" ? url : `${url} ${descriptor}`; previousBare = descriptor === ""; } return out; }; /** * ASCII-lowercase a name or a keyword value, which is all HTML folds — a Unicode * `toLowerCase` maps characters the parser leaves alone (U+212A onto `k`). * Returns the input untouched when there is nothing to fold (the common case). * @param {string} value source name or value * @returns {string} the value as the parser would match it */ const _asciiLowerCase = (value) => { for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); if (c >= 0x41 && c <= 0x5a) { return ( value.slice(0, i) + value .slice(i) .replace(/[A-Z]/g, (u) => String.fromCharCode(u.charCodeAt(0) + 0x20)) ); } } return value; }; /** * Whether a raw attribute value keeps its meaning unquoted: §13.1.2.3 admits a * non-empty run with no whitespace, quote, backtick, `=`, `<` or `>`. Character * references decode identically either way, so `&` needs no guard. * @param {string} raw raw (undecoded) value, without its quotes * @returns {boolean} true when the unquoted form re-parses to the same value */ const _canUnquoteAttributeValue = (raw) => { for (let i = 0; i < raw.length; i++) { const c = raw.charCodeAt(i); if ( c === 0x20 || c === 0x22 || c === 0x27 || c === 0x3c || c === 0x3d || c === 0x3e || c === 0x60 || (c >= 0x09 && c <= 0x0d) ) { return false; } } return raw.length !== 0; }; /** * Rewrite an opening tag from its own source spans, collapsing the whitespace * between its attributes to one space and dropping the quotes around any value * that is legal unquoted (§13.1.2.3). webpack's own late asset passes match the * sentinels they left behind with the quote optional, so they still see them * (see `INTEGRITY_SENTINEL_REGEXP`); a third-party post-processor that regexes * emitted HTML assuming quotes would not, which is why `removeAttributeQuotes` * is off by default in html-minifier. * * Falls back to the source tag when an attribute lies outside it — a repeated * `<html>` / `<body>` tag merges its attributes onto the element already open, * and those cannot be sliced from here. * @param {HtmlPath} path the accessor positioned on the element * @param {string} open the opening tag's source text * @returns {string} the minified opening tag */ const _minifyOpenTag = (path, open) => { const tagStart = path.start(); const tagEnd = path.tagEnd(); const count = path.attributeCount(); // HTML parsing ASCII-lowercases names, so folding one changes nothing. Foreign // content keeps its source bytes: SVG admits element names no adjustment table // would case-correct on the way back in. const html = path.namespace() === NS_HTML; const sourceName = open.slice(1, path.nameEnd() - tagStart); const elementName = html ? _asciiLowerCase(sourceName) : sourceName; let out = `<${elementName}`; const isViewportMeta = html && path.tagName() === "meta" && _isViewportMeta(path); let unquotedTail = false; let consumedEnd = path.nameEnd(); for (let i = 0; i < count; i++) { const attribute = path.attributeAt(i); const nameStart = path.attributeNameStart(attribute); const nameEnd = path.attributeNameEnd(attribute); if (nameStart < tagStart || nameEnd > tagEnd) return open; const sourceAttributeName = open.slice( nameStart - tagStart, nameEnd - tagStart ); const attributeName = html ? _asciiLowerCase(sourceAttributeName) : sourceAttributeName; const valueStart = path.attributeValueStart(attribute); if (valueStart === -1) { out += ` ${attributeName}`; unquotedTail = false; consumedEnd = nameEnd; continue; } // `attributeValueStart` points past the opening quote, `attributeValueEnd` // at the closing one, so a quoted value's span is one wider on each side. const quote = open.charCodeAt(valueStart - 1 - tagStart); const quoted = quote === 34 || quote === 39; const rawEnd = path.attributeValueEnd(attribute); const valueEnd = rawEnd + (quoted ? 1 : 0); if (quoted) { const rawValue = open.slice(valueStart - tagStart, rawEnd - tagStart); // A character reference would decode to something these grammars read // differently, so only a reference-free value is rewritten. const value = html && !rawValue.includes("&") ? _rewriteAttributeValue( elementName, attributeName, rawValue, isViewportMeta ) : rawValue; // A boolean attribute's presence is its value, so the canonical spellings // (`disabled`, `disabled=""`) carry nothing. Any other value is left as // written -- it is not the form the spec canonicalizes. if ( html && (rawValue === "" || _asciiLowerCase(rawValue) === attributeName) && _isBooleanAttribute(elementName, attributeName) ) { out += ` ${attributeName}`; unquotedTail = false; consumedEnd = valueEnd; continue; } if (_canUnquoteAttributeValue(value)) { out += ` ${attributeName}=${value}`; unquotedTail = true; consumedEnd = valueEnd; continue; } if (value !== rawValue) { const quotedValue = _quoteAttributeValue(value); if (quotedValue !== null) { out += ` ${attributeName}=${quotedValue}`; unquotedTail = false; consumedEnd = valueEnd; continue; } } } // A quoted value carries its quotes over; an unquoted one starts at the // value itself. out += ` ${attributeName}=${open.slice( (quoted ? valueStart - 1 : valueStart) - tagStart, valueEnd - tagStart )}`; unquotedTail = !quoted; consumedEnd = valueEnd; } // A source `/>` self-closes a foreign element, so it survives there — but only // when the `/` really is the flag: in `<a href=x/>` it is the last character of // an unquoted value instead, and after one of those it needs a space or it // would fuse into the value. An HTML element's parser ignores it, so it goes. if ( path.namespace() !== NS_HTML && open.charCodeAt(open.length - 2) === 47 && tagEnd - 2 >= consumedEnd ) { return `${out}${unquotedTail ? " /" : "/"}>`; } return `${out}>`; }; /** * Whether a comment must survive minification: downlevel conditional comments * (`<!--[if …]>` / `<![endif]-->`) drive IE branching, server-side includes * (`<!--#…-->`) are directives, and a `<?…?>` bogus comment is a server-side * template tag — all behavior-bearing. Every other comment is inert, so * dropping it preserves meaning. * @param {string} data comment data (between `<!--` and `-->`) * @param {string} source the comment's source text, including its delimiters * @returns {boolean} true to keep the comment */ const _keepComment = (data, source) => { // `<?php … ?>` / `<%… %>` reach the tree as bogus comments (§13.2.5.42) but // are server-side directives, so they outrank the inert-comment rule. if (source.charCodeAt(1) === 63) return true; const s = data.trimStart(); // `<![` covers the downlevel-revealed forms: `<!--<![endif]-->` closers and // standalone `<![if …]>` openers. return ( s.startsWith("[if") || s.startsWith("[endif") || s.startsWith("<![") || s.startsWith("#") ); }; /** * An element's open tag as the printer would emit it. * @param {HtmlPath} path positioned on the element * @param {boolean} minify whether minifying * @returns {{ source: string, tag: string }} its source open tag and the printed one */ const _elementOpenTag = (path, minify) => { // A repeated `<html>` / `<body>` start tag adds its unseen attributes to the // open element and is itself dropped, so that element's source tag no longer // spells its attributes — rebuild it rather than echo a stale one. const source = _mergedAttrNodes.has(path.node) ? _synthesizeOpenTag(path) : path.openTag(); return { source, tag: minify && source !== "" ? _minifyOpenTag(path, source) : source }; }; /** * An element's end tag as the printer would emit it. * @param {HtmlPath} path positioned on the element * @param {string} name its tag name * @param {string} open its source open tag * @param {boolean} innerEmpty whether it printed no children * @param {boolean} minify whether minifying * @returns {string} the end tag, `""` when it carries nothing */ const _elementCloseTag = (path, name, open, innerEmpty, minify) => { // Everything after `<plaintext>` is text, so an emitted end tag would re-parse // as literal text; a source self-closing tag (`<rect/>` in foreign content) // with no children needs none either. const sourceClose = path.closeTag(); // `</name>` spelled by the source, falling back to the element's own name when // there is no source end tag to echo. const closeName = _asciiLowerCase(sourceClose.slice(2, -1)).trim() || name; return name === "plaintext" || (innerEmpty && open.endsWith("/>")) || (minify && _canOmitEndTag(path, name, path.node)) ? "" : minify && path.namespace() === NS_HTML ? `</${closeName}>` : sourceClose === "" ? `</${name}>` : sourceClose; }; /** * An element printed in pieces prints as `open + children + close`, and these are * those two — the printer's `Element` case, minus everything in it that can only * be decided once the children's text is in. `null` for an element not of that * shape: a leading newline to round-trip, a self-closing tag, a `<template>`'s * content fragment, or a source `/>` whose end tag hangs on whether anything * printed inside it. `implied` names an omitted `<html>` / `<body>` whose `open` * still materializes if the first thing inside it turns out to be whitespace. * @param {HtmlPath} path positioned on the element * @param {boolean} minify whether minifying * @returns {{ open: string, close: string, implied: string } | null} its pieces, or `null` */ const _streamTags = (path, minify) => { if (path.selfClosing() || path.templateContent() !== 0) return null; const name = path.tagName(); if (LEADING_NEWLINE_ELEMENTS.has(name)) return null; const { source, tag } = _elementOpenTag(path, minify); if (source.endsWith("/>")) return null; // A `<tbody>` / `<colgroup>` the parser would re-imply prints without its // start tag, whatever the source spelled. if ( minify && (name === "tbody" ? _canOmitTbodyStart(path, path.node) : name === "colgroup" && _canOmitColgroupStart(path, path.node)) ) { return { open: "", close: _canOmitEndTag(path, name, path.node) ? "" : `</${name}>`, implied: "" }; } if (source === "") { return path.attributeCount() === 0 && TRANSPARENT_IMPLIED_ELEMENTS.has(name) ? { open: "", close: "", implied: name === "body" || name === "html" ? name : "" } : { open: _synthesizeOpenTag(path), close: `</${name}>`, implied: "" }; } return { open: tag, close: _elementCloseTag(path, name, source, false, minify), implied: "" }; }; /** * The default HTML node printer — passed to the `SourceProcessor` and fired per * node once its children are printed (a developer could supply their own). It * takes the same `path` a visitor gets plus the print context as its `writer`, * and knows nothing of the walk: it switches on `path.type()`, serializes the node * — opening tags kept verbatim from source (attribute quoting / spacing * preserved), end tags generated, text re-escaped from its decoded value — pulling * its children's already-printed text from `writer.get`, and **returns** it. This * is the WHATWG serialization, so the output re-parses to the same DOM (text-node * offsets can overrun end tags, so source slices can't be used for text). The * minify transforms are dropping inert comments and collapsing the whitespace * between an opening tag's attributes; DOM-absent whitespace (between the * doctype and `<html>`, etc.) naturally falls away. * @param {HtmlPath} path the accessor positioned on the finished node * @param {PrintContext} writer the print context (children's printed text) * @returns {string} the node's serialized text * @experimental exposed as `webpack.html.syntax.printer`; unstable API */ const printer = (path, writer) => { const minify = writer.options.mode === "minify"; _cssEnvironment = writer.options.environment; _cssConvertLengthUnits = writer.options.convertLengthUnits === true; _collapseWhitespace = writer.options.collapseWhitespace === true; switch (path.type()) { case NodeType.Element: { // Minifying rewrites the tag (attribute spacing / quoting) but never its // `/>`-ness, which the end-tag decision below reads off the source. const { source: open, tag } = _elementOpenTag(path, minify); // Void / self-closing elements have no children and no end tag; a // tag-less one (`<image>` → img, `</br>` → br) is rebuilt, not dropped. if (path.selfClosing()) { return open !== "" ? tag : _synthesizeOpenTag(path); } // `<template>` holds its children in a content fragment, not the child // chain; every other element reconstructs from its children in order. const tc = path.templateContent(); let inner = ""; if (tc !== 0) { inner = writer.get(tc); } else { for (let c = path.firstChild(); c !== 0; c = path.nextSibling(c)) { inner += writer.get(c); } // Round-trip the parser's leading-newline strip (`<pre>` / `<textarea>` // / `<listing>`) so a value that starts with one survives re-parsing. if ( inner.charCodeAt(0) === 10 && LEADING_NEWLINE_ELEMENTS.has(path.tagName()) ) { inner = `\n${inner}`; } } const name = path.tagName(); // A `<tbody>` / `<colgroup>` the parser would re-imply needs no start tag; // its end tag is handled by the optional-end-tag rules like any other. if ( minify && (name === "tbody" ? _canOmitTbodyStart(path, path.node) : name === "colgroup" && _canOmitColgroupStart(path, path.node)) ) { const implied = _canOmitEndTag(path, name, path.node) ? "" : `</${name}>`; return inner + implied; } if (open === "") { // A parser-implied structural element stays transparent — the parser // re-implies it — but only when attribute-less (repeated `<html>` / // `<body>` tags merge attributes onto the implied element). if ( path.attributeCount() === 0 && TRANSPARENT_IMPLIED_ELEMENTS.has(name) ) { // Leading whitespace only survives re-parsing once `<body>` has // started: before that the insertion modes drop it. Materializing // the tag is the cheapest way to keep the text node intact. const leading = inner.charCodeAt(0); if ( (name === "body" || name === "html") && (leading === 0x09 || leading === 0x0a || leading === 0x0c || leading === 0x0d || leading === 0x20) ) { return `<${name}>${inner}`; } return inner; } // Anything else tag-less (adoption-agency clone, reconstructed // formatting element) must materialize or its formatting is lost. return `${_synthesizeOpenTag(path) + inner}</${name}>`; } return ( tag + inner + _elementCloseTag(path, name, open, inner === "", minify) ); } case NodeType.Text: { const data = path.data(); // Literal-text elements (`script` / `style` / …) keep their body raw; // every other text node is escaped (the WHATWG text split). const parent = path.parentOf(); // TODO minify an inline `<script>` too: terser reaches webpack only // through `minimizer-webpack-plugin` and its API is async, so it needs // a JS-minify hook on the options rather than a call from here. if (parent !== 0 && LITERAL_TEXT_PARENTS.has(path.tagName(parent))) { if (!minify) return data; const literalName = path.tagName(parent); if (literalName === "style") { return _isCssStyleElement(path, parent) ? _minifyInlineCss(data) : data; } if (literalName === "script") { const type = _scriptType(path, parent); if (JSON_SCRIPT_TYPES.has(type) || _JSON_SUBTYPE_REGEXP.test(type)) { return _minifyInlineJson(data); } } return data; } // Whitespace directly under `<head>` or `<html>` is outside any block // formatting context, so nothing ever renders it — the same inert-node // reasoning that drops comments. `<body>`'s whitespace does render. if ( minify && parent !== 0 && path.namespace(parent) === NS_HTML && (path.tagName(parent) === "head" || path.tagName(parent) === "html") && isAllWs(data) ) { return ""; } // A `<` puts the node on the source-passthrough path below, which is what // keeps `<%= x %>` off the escaper — worth more than the whitespace. if (minify && _collapseWhitespace && !data.includes("<")) { // Only elements rendering whitespace verbatim without any CSS are // excluded — by ancestor, since `white-space` inherits. let preformatted = false; for ( let ancestor = parent; ancestor !== 0; ancestor = path.parentOf(ancestor) ) { if ( path.namespace(ancestor) === NS_HTML && LEADING_NEWLINE_ELEMENTS.has(path.tagName(ancestor)) ) { preformatted = true; break; } } if (!preformatted) { const collapsed = collapseWhitespaceRuns(data); if (collapsed !== data) return _escapeTextContent(collapsed, minify); } } const raw = path.source(); // Off-spec workaround: §13.3 escapes `<`/`>` in text, which would rewrite // `<%= x %>` to `<%= x %>` in HTML webpack only passes through. Text // that decoded to itself re-tokenizes to itself, so emit its source bytes. // The guards are what keep that equivalence: every character reference is // at least two characters longer than what it decodes to, so equal lengths // mean nothing decoded — an unequal one means the node was merged or // foster-parented and its range no longer describes `data`. The tail is // checked separately because it is the only place concatenation can change // tokenization: a `<`, or a `&` still open on a reference, would fuse with // the next sibling once a comment between them is dropped. A CR is the one // rewrite the length test cannot see — preprocessing maps a lone one to LF // without changing length — so it is excluded by name. if ( raw.length === data.length && raw.charCodeAt(raw.length - 1) !== 60 && !(inputHasCr && raw.includes("\r")) && !_hasOpenReference(raw) ) { return raw; } return _escapeTextContent(data, minify); } case NodeType.Doctype: { // A leaf with no children, so its source is already tight. Parsing // ASCII-lowercases the keyword and the name; the identifiers behind them // are case-sensitive strings and stay as written. const source = path.source(); if (!minify) return source; const head = /^<!doctype[\t\n\f\r ]+([^\t\n\f\r >]+)/i.exec(source); return head === null ? source : `<!doctype ${_asciiLowerCase(head[1])}${source.slice( head[0].length )}`; } case NodeType.Comment: return minify && !_keepComment(path.data(), path.source()) ? "" : path.source(); default: { // Document / DocumentFragment: concatenate children in order. let out = ""; for (let c = path.firstChild(); c !== 0; c = path.nextSibling(c)) { out += writer.get(c); } return out; } } }; // === Walk === // Walk state, hoisted to module scope so the walk helpers keep one function // identity across parses and the streaming path can drive them mid-parse. /** @type {CompiledVisitorMap} the between-parses value, so no map is retained */ const EMPTY_VISITORS = []; /** @type {CompiledVisitorMap} */ let _visitors = EMPTY_VISITORS; /** @type {PrintContext | undefined} */ let _writer; // Iterative depth-first walk over the link columns: `firstChild` / // `nextSibling` descend, the `parent` column ascends, so arbitrarily deep // markup can't overflow the call stack (the old recursive walk died at // ~10⁵ nesting). A `<template>`'s content fragment is visited before the // element's children; ascending out of it continues with those children // (the fragment is never in a sibling chain, `_nodeNextSiblings` = 0). /** * Fire a node's `enter` visitors; true when the walk may descend. * @param {HtmlNodeRef} node node * @returns {boolean} false when a visitor called `skipChildren()` */ const _enterNode = (node) => { const b = _visitors[_nodeTypes[node]]; if (b === undefined || b.enter.length === 0) return true; _walkSkip = false; _currentNode = node; const p = _nodeParents[node]; _currentParent = p === 0 ? null : p; const e = b.enter; for (let i = 0; i < e.length; i++) e[i](A); const skip = _walkSkip; _walkSkip = false; return !skip; }; // === Printing in pieces === // The printer composes a node from its children's text, so the store holds every // node's until the root is taken — several times the output on a document of any // size. A node whose text is exactly `open + children + close` instead emits its // opener as it opens and its closer as it exits, and each child goes straight out // as it finishes; the store then only ever holds the subtrees that cannot (see // `_streamTags`). Indexed by print depth, which is the open path. /** @type {number} depth of the node the printing walk is on (maintained by `_walkRun`) */ let _printDepth = 0; /** @type {number[]} 1 while the node at that depth prints in pieces */ const _printOpen = []; /** @type {string[]} the closer the node at that depth still owes */ const _printClose = []; // An omitted `<html>` / `<body>` only materializes when the first thing inside it // is whitespace, which is known once the first piece is emitted — so its opener // waits in the writer's pending stack until then. /** @type {string[]} names of the elements whose openers are held back, innermost last */ const _printPendingNames = []; /** @type {number[]} pending-stack slot of the node at that depth, -1 for none */ const _printPendingSlot = []; /** * Emit one piece of a node printed in pieces. The omitted openers held back above * it decide first: an omitted `<html>` / `<body>` materializes exactly when the * first thing inside it is whitespace, which the parser would otherwise drop on * the way back in. Only the innermost can — once it does, everything outside it * starts with a `<`. * @param {string} text output text */ const _emitPiece = (text) => { if (text === "") return; const w = /** @type {PrintContext} */ (_writer); const held = _printPendingNames.length; if (held !== 0) { const c = text.charCodeAt(0); if (c === 0x09 || c === 0x0a || c === 0x0c || c === 0x0d || c === 0x20) { w.setPending(held - 1, `<${_printPendingNames[held - 1]}>`); } _printPendingNames.length = 0; w.flushPending(); } w.emitStreamed(text); }; /** * Decide whether the node just entered prints in pieces, and emit its opener if * so. A node can only do that inside something that already is, so the decision * walks down from the root and stops at the first that cannot. * @param {HtmlNodeRef} node node * @param {number} depth its depth in the walk * @param {boolean} descending whether the walk may enter its children */ const _openStreamed = (node, depth, descending) => { const w = /** @type {PrintContext} */ (_writer); let streams = 0; _printPendingSlot[depth] = -1; if (descending && (depth === 0 || _printOpen[depth - 1] === 1)) { const ty = _nodeTypes[node]; if (ty === NodeType.Document || ty === NodeType.DocumentFragment) { // Nothing of its own to print: its text is its children's, in order. streams = 1; _printClose[depth] = ""; } else if (ty === NodeType.Element) { _currentNode = node; const tags = _streamTags(A, w.options.mode === "minify"); if (tags !== null) { streams = 1; _printClose[depth] = tags.close; if (tags.implied === "") { _emitPiece(tags.open); } else { _printPendingSlot[depth] = w.pushPending(""); _printPendingNames.push(tags.implied); } } } } _printOpen[depth] = streams; }; /** * Fire a node's `exit` visitors, then — when printing — its printer (the * post-order point: its children are already printed). `_writer` undefined = * walk only. * @param {HtmlNodeRef} node node */ const _exitNode = (node) => { const b = _visitors[_nodeTypes[node]]; if (b === undefined && _writer === undefined) return; _currentNode = node; const p = _nodeParents[node]; _currentParent = p === 0 ? null : p; if (b !== undefined) { const x = b.exit; for (let i = 0; i < x.length; i++) x[i](A); } if (_writer === undefined) return; const depth = _printDepth; if (_printOpen[depth] === 1) { // Printed in pieces: its opener went out as it opened and its children as // they finished, so only its closer is left. _emitPiece(_printClose[depth]); const slot = _printPendingSlot[depth]; // Nothing inside it printed, so its held-back opener never had to. if (slot !== -1 && _writer.isPending(slot)) { _printPendingNames.pop(); _writer.dropPending(); } return; } // Composed at once, but whatever holds it is being printed in pieces, so it // goes straight out rather than into the store for a read-back that will never // come. if (depth !== 0 && _printOpen[depth - 1] === 1) { _emitPiece(_writer.printPiece(A)); return; } _writer.printNode(node, A); }; /** * First node to visit inside `node` (template content before children). * @param {HtmlNodeRef} node node * @returns {HtmlNodeRef} first inner node (0 = leaf) */ const _firstInner = (node) => { const ty = _nodeTypes[node]; if (ty === NodeType.Element) { const tc = _templateContentOf(node); return tc !== 0 ? tc : _nodeFirstChildren[node]; } if (ty === NodeType.Document || ty === NodeType.DocumentFragment) { return _nodeFirstChildren[node]; } return 0; }; // === Streaming walk === // The walk follows the parser's open element stack: everything under the // deepest *entered* element is finished markup, so it is visited and its node // ids recycled, and peak storage is the open subtree rather than the whole // document — the HTML analog of the CSS grammar's per-top-level-rule recycling. // // Streaming starts only once the document passes `_STREAM_MIN_NODES`: below it // the walk is a single pass at EOF, which is what a small page wants anyway. // // Two rules keep it correct and cheap: // - An element is entered only when something beneath it has to be flushed // (`_FLUSH_BATCH` worth of nodes). Anything that opens and closes in between // is never entered and is walked as one completed subtree, so its visitors // see a final `end` and live child links. // - Nothing is visited unless `_canFlush()` holds. An unsafe stretch (the // adoption agency, tables, templates) just buffers, and the next sync sees // the final tree. // // Only walk-only passes stream: printing holds every node's text until its // parent prints. /** @type {boolean} whether a parse is in progress, so re-entry can be refused */ let _parsing = false; /** @type {boolean} whether this parse may stream its walk (owned by `grammar`) */ let _streamEligible = false; /** set when the root's `enter` called `skipChildren()`, so only its `exit` fires */ let _docSkipped = false; /** @type {boolean} whether the walk has started streaming (see `_STREAM_MIN_NODES`) */ let _streaming = false; /** @type {HtmlElement[]} open elements whose `enter` has already fired */ const _entered = []; /** `_entered` index whose `enter` called `skipChildren()` (-1 = none) */ let _skipFrom = -1; /** set when `open` was spliced mid-stack, so the next sync scans it in full */ let _openSpliced = false; // Set when a mid-stack `open.splice` removes an element the walk already // entered (`</form>` with content still open inside it). The element leaves the // open stack without its subtree ending, so the open stack no longer describes // what has finished and reconciling against it would close and re-enter live // elements. Rare enough to simply stop streaming for the rest of the parse: what // is already visited stays visited, and the remainder is walked at EOF. let _streamHalted = false; /** set by `popOpen`: the open stack changed, so the walk has work at token end */ let _openStackChanged = false; // Nodes that must pile up under the deepest entered element before a flush // earns its walk setup. Closing an entered element always flushes, so this only // bounds how much an un-entered subtree holds — a few KB of columns. const _FLUSH_BATCH = 4096; // Documents below this many nodes are walked once at EOF instead: recycling // only pays once the columns are big enough that not growing them beats the // per-token sync, and a page that never reaches it must not carry the cost. // Chosen so the streamed cap below stays under `_COLUMN_SHRINK_CAPACITY`, // keeping a streamed parse's columns reusable by the next one. const _STREAM_MIN_NODES = 49152; /** * Whether completed subtrees may be visited and recycled at this point in the * parse. An empty active-formatting-elements list is the load-bearing check: * the adoption agency only moves elements drawn from `open` / `afe` and the * direct children of an element that is still open, and table markers live in * `afe` too, so with `afe` empty neither it nor foster parenting can reach a * node that was already flushed. The rest keep node ids that outlive the open * stack out of the recycled range — template contents, pending table text, the * `<selectedcontent>` post-pass, the form pointer — and `MODE_IN_BODY` covers * the head, which "after head" reopens to insert into. * @returns {boolean} true when flushing is safe */ const _canFlush = () => !_streamHalted && afe.length === 0 && templateModes.length === 0 && pendingTableChars === null && !sawSelectedContent && mode === MODE_IN_BODY && open.length !== 0 && (form === 0 || open.includes(form)); /** * Depth-first walk of a whole run of `container`'s children, from `first` up to * (not including) `stop` (0 = to the end). One loop for the entire run instead * of a call per child — sibling-heavy markup flushes hundreds at a time. * @param {HtmlNodeRef} container parent the run hangs off * @param {HtmlNodeRef} first first child of the run * @param {HtmlNodeRef} stop child to stop before (0 = end of the run) */ const _walkRun = (container, first, stop) => { let node = first; descend: for (;;) { const descending = _enterNode(node); // Printing tracks the open path so a node can emit its own pieces; the // streamed walk-only runs never print, so they never pay for it. if (_writer !== undefined) _openStreamed(node, _printDepth, descending); if (descending) { const inner = _firstInner(node); if (inner !== 0) { node = inner; _printDepth++; continue; } } for (;;) { _exitNode(node); const parent = _nodeParents[node]; // Back at the run's own level: step to the next sibling, or stop. if (parent === container && _templateContentOf(container) !== node) { const next = _nodeNextSiblings[node]; if (next === 0 || next === stop) return; node = next; continue descend; } // Out of a template's content fragment: the element's children follow — // they stand in for it, so they share its depth. if (_templateContentOf(parent) === node) { const inner = _nodeFirstChildren[parent]; if (inner !== 0) { node = inner; continue descend; } } else { const next = _nodeNextSiblings[node]; if (next !== 0) { node = next; continue descend; } } node = parent; _printDepth--; } } }; /** * Rewind the node allocator to `to`, never past the deepest open element. Every * still-open element is live and its id must survive, so a flush that empties a * parent may not reclaim ids a deeper open element already took. * @param {number} to id to rewind to */ const _streamRewind = (to) => { const top = open.length !== 0 ? open[open.length - 1] : 0; if (to >= top && to < _nodeCount) { if (_nodeCount > _nodeHighWater) _nodeHighWater = _nodeCount; _nodeCount = to; } }; /** * Visit and recycle `parent`'s completed children — everything before * `openChild`, the child still on the open stack (0 = none, i.e. `parent` is * the deepest open element). A trailing text node is held back: * `appendTo` merges following text into the last child, so flushing it early * would split one text node in two. * @param {HtmlNodeRef} parent open element to flush under * @param {HtmlElement} openChild still-open child to stop before (0 = none) * @param {boolean} visit false to recycle without visiting (under `skipChildren`) * @param {boolean} hold true while `parent` can still receive text, so a * trailing text node stays put; false once it is closing (nothing follows) */ const _streamFlush = (parent, openChild, visit, hold) => { const container = effParent(parent); const first = _nodeFirstChildren[container]; if (first === 0 || first === openChild) return; // Where the run ends: before the still-open child, and before a trailing text // node while `parent` can still receive text (`appendTo` merges into the last // child, so flushing it early would split one text node in two). let child = openChild; if (hold && openChild === 0) { const lastChild = _nodeLastChildren[container]; if (lastChild !== 0 && _nodeTypes[lastChild] === NodeType.Text) { child = lastChild; } } if (first === child) return; if (visit) _walkRun(container, first, child); _nodeFirstChildren[container] = child; if (child === 0) _nodeLastChildren[container] = 0; // Ids are bump-allocated in tree order, so the visited run and its // descendants fill `[parent + 1, _nodeCount]` whenever nothing below // `parent` is still open — rewind the allocator over them. if (openChild !== 0 || container !== parent) return; if (child === 0) { _streamRewind(parent); } }; /** * Detach a closed element from its parent, releasing its id when it was the * parent's last remaining child. Earlier siblings are always flushed before an * element is entered, so it is the first child left. * @param {HtmlElement} el closed element */ const _streamDetach = (el) => { const parent = _nodeParents[el]; const next = _nodeNextSiblings[el]; if (parent !== 0) { _nodeFirstChildren[parent] = next; if (next === 0) _nodeLastChildren[parent] = 0; } _nodeNextSiblings[el] = 0; _nodeParents[el] = 0; if (next === 0 && _nodeCount === el) _streamRewind(el - 1); }; /** * Bring the streamed walk up to date with the tree built so far: `exit` the * elements that closed, `enter` the ones that opened, and flush the completed * subtrees in between. Called once per token but does nothing unless * `_canFlush()` holds, so an unsafe stretch (incorrectly nested formatting elements, * tables, templates) simply buffers and the next catch-up sees the final tree. */ /** * Index up to which `_entered` still matches the open stack. Pushes and pops * only ever diverge in a suffix; a mid-stack `open.splice` sets `_openSpliced` * and forces the full scan. * @returns {number} length of the common prefix */ const _enteredPrefix = () => { if (_openSpliced) { _openSpliced = false; let i = 0; while (i < _entered.length && i < open.length && _entered[i] === open[i]) { i++; } return i; } let i = _entered.length < open.length ? _entered.length : open.length; while (i > 0 && _entered[i - 1] !== open[i - 1]) i--; return i; }; const _streamSync = () => { const entered = _entered.length; const last = entered !== 0 ? _entered[entered - 1] : 0; // Still nothing to do: the entered prefix matches the open stack and too // little has piled up under it to be worth a flush. if ( !_openSpliced && open.length >= entered && (entered === 0 || open[entered - 1] === last) && _nodeCount - last < _FLUSH_BATCH ) { return; } if (!_canFlush()) return; let i = _enteredPrefix(); // Close: everything entered below the divergence has finished. A descendant // of a `skipChildren()` element was tracked without being entered, so it must // not exit either — the buffered walk fires `exit` for the skipped element // itself and nothing below it. for (let k = _entered.length - 1; k >= i; k--) { const el = _entered[k]; const belowSkip = _skipFrom !== -1 && k > _skipFrom; _streamFlush(el, 0, _skipFrom === -1 || k < _skipFrom, false); if (!belowSkip) _exitNode(el); if (_skipFrom === k) _skipFrom = -1; _streamDetach(el); } _entered.length = i; // Open: enter the elements now needed, flushing each level's finished // children before descending past them. for (; i < open.length; i++) { const el = open[i]; const parent = _nodeParents[el]; const suppressed = _skipFrom !== -1; if (parent !== 0) _streamFlush(parent, el, !suppressed, false); if (!suppressed && !_enterNode(el)) _skipFrom = i; _entered.push(el); } // Finally the current insertion point's own finished children. _streamFlush(open[open.length - 1], 0, _skipFrom === -1, true); }; /** * Finish a streamed walk at EOF: close everything still open (their never- * entered descendants are walked as completed children), then the root. * @param {HtmlNodeRef} root document / fragment root */ const _streamFinish = (root) => { for (let k = _entered.length - 1; k >= 0; k--) { const el = _entered[k]; const belowSkip = _skipFrom !== -1 && k > _skipFrom; _streamFlush(el, 0, _skipFrom === -1 || k < _skipFrom, false); if (!belowSkip) _exitNode(el); if (_skipFrom === k) _skipFrom = -1; _streamDetach(el); } _entered.length = 0; _streamFlush(root, 0, true, false); _exitNode(root); }; /** * The HTML `SourceProcessor` grammar: build the document AST (WHATWG tree * construction) and walk it, firing `enter` / `exit` in source order. The root * document / fragment node is visited too (with a `null` parent). When `writer` * is given the same walk also prints: each node's printer builds its text into * `writer` as the node finishes (post-order), and the root's text is taken as the * result — one parse, no re-tokenization. * @param {string} input source text * @param {CompiledVisitorMap} visitors compiled visitor map * @param {PrintContext | undefined} writer the print context to build output into, or undefined (walk only) * @param {HtmlProcessOptions} options process options */ const grammar = (input, visitors, writer, options) => { // The walk's state — the visitors, the print context, the open path, the AST // columns — is module-scoped so the tokenizer's call sites stay monomorphic, // which means one parse at a time: a visitor that started another would take // this one's state over and release its columns on the way out. Nesting the // *other* language is fine (a `<style>`'s CSS has its own module state), and // an `<iframe srcdoc>` is a separate module webpack parses on its own. if (_parsing) { throw new Error( "html syntax: process() is already running; a visitor cannot start another HTML parse" ); } _parsing = true; _visitors = visitors; _writer = writer; // Printing holds every node's text until its parent prints, so only // walk-only passes stream; the printing path builds the tree and walks it. _streamEligible = writer === undefined; _streaming = false; _printDepth = 0; try { const root = parseHtml(input, 0, options); if (writer !== undefined) { _walkRun(_nodeParents[root], root, _nodeNextSiblings[root]); // A single root: take its text as the output (the printer read every node's // source into the store during the walk, so this needs no source). A root // that printed in pieces has already emitted all of it. if (_printOpen[0] === 1) writer.dropStore(); else writer.take(root); } } finally { _parsing = false; _streamEligible = false; _streaming = false; _docSkipped = false; // Held across the walk, so they outlive it the way the visitors do. _printOpen.length = 0; _printClose.length = 0; _printPendingNames.length = 0; _printPendingSlot.length = 0; // Hoisting these to module scope outlives the call, so drop the caller's // visitors and print context here — they used to fall away with `grammar`. _visitors = EMPTY_VISITORS; _writer = undefined; // The walk consumed the tree: release the side arrays' heap references so // the reused columns don't pin this parse's strings until the next parse. _releaseAstColumns(); } }; /** * The generic visitor coordinator (`util/SourceProcessor`) bound to the HTML * `grammar`. Babel-style usage: * * ``` * new SourceProcessor().use({ [NodeType.Element]: (path) => {}, [NodeType.Comment]: { enter, exit } }).process(source, { skip }); * ``` * @experimental exposed as `webpack.html.syntax.SourceProcessor`; unstable API * @extends {GenericSourceProcessor<HtmlPath, HtmlNodeRef, HtmlProcessOptions>} */ class SourceProcessor extends GenericSourceProcessor { constructor() { super(grammar, printer); } } /** @typedef {[string, number, number]} ParsedSource */ // `parseSrcset` is a direct implementation of the WHATWG "parse a srcset // attribute" algorithm; it lives here with the other spec-level HTML parsing // so it can move into the WASM parser alongside the tokenizer in the future. const COMMA = ",".charCodeAt(0); const LEFT_PARENTHESIS = "(".charCodeAt(0); const RIGHT_PARENTHESIS = ")".charCodeAt(0); const SMALL_LETTER_W = "w".charCodeAt(0); const SMALL_LETTER_X = "x".charCodeAt(0); const SMALL_LETTER_H = "h".charCodeAt(0); // (Don't use \s, to avoid matching non-breaking space) // Sticky so `collectCharacters` matches at an offset without slicing the // whole remaining input per call (which made srcset parsing quadratic). // eslint-disable-next-line no-control-regex const LEADING_SPACES_REGEXP = /[ \t\n\r\u000C]+/y; // eslint-disable-next-line no-control-regex const LEADING_COMMAS_OR_SPACES_REGEXP = /[, \t\n\r\u000C]+/y; // eslint-disable-next-line no-control-regex const LEADING_NOT_SPACES = /[^ \t\n\r\u000C]+/y; const TRAILING_COMMAS_REGEXP = /[,]+$/; const NON_NEGATIVE_INTEGER_REGEXP = /^\d+$/; // ( Positive or negative or unsigned integers or decimals, without or without exponents. // Must include at least one digit. // According to spec tests any decimal point must be followed by a digit. // No leading plus sign is allowed.) // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-floating-point-number const FLOATING_POINT_REGEXP = /^-?(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/; /** * @param {string} input input * @returns {ParsedSource[]} parsed srcset */ const parseSrcset = (input) => { // 1. Let input be the value passed to this algorithm. const inputLength = input.length; /** @type {string | undefined} */ let url; /** @type {string[]} */ let descriptors; /** @type {number} */ let descriptorStart; /** @type {string} */ let state; /** @type {number} */ let charCode; /** @type {number} */ let position = 0; /** @type {number} */ let start; /** @type {[string, number, number][]} */ const candidates = []; /** * @param {RegExp} regExp sticky reg exp to collect characters * @returns {string | undefined} characters */ function collectCharacters(regExp) { regExp.lastIndex = Math.max(0, position); const match = regExp.exec(input); if (match) { const [chars] = match; position += chars.length; return chars; } } /** * @returns {void} */ function parseDescriptors() { // 9. Descriptor parser: Let error be no. let pError = false; // 10. Let width be absent. // 11. Let density be absent. // 12. Let future-compat-h be absent. (We're implementing it now as h) /** @type {number | undefined} */ let width; /** @type {number | undefined} */ let density; /** @type {number | undefined} */ let height; /** @type {string | undefined} */ let desc; // 13. For each descriptor in descriptors, run the appropriate set of steps // from the following list: for (let i = 0; i < descriptors.length; i++) { desc = descriptors[i]; const lastChar = desc[desc.length - 1].charCodeAt(0); const value = desc.slice(0, Math.max(0, desc.length - 1)); // If the descriptor consists of a valid non-negative integer followed by // a U+0077 LATIN SMALL LETTER W character if ( NON_NEGATIVE_INTEGER_REGEXP.test(value) && lastChar === SMALL_LETTER_W ) { // If width and density are not both absent, then let error be yes. if (width || density) { pError = true; } const intVal = Number.parseInt(value, 10); // Apply the rules for parsing non-negative integers to the descriptor. // If the result is zero, let error be yes. // Otherwise, let width be the result. if (intVal === 0) { pError = true; } else { width = intVal; } } // If the descriptor consists of a valid floating-point number followed by // a U+0078 LATIN SMALL LETTER X character else if ( FLOATING_POINT_REGEXP.test(value) && lastChar === SMALL_LETTER_X ) { // If width, density and future-compat-h are not all absent, then let error // be yes. if (width || density || height) { pError = true; } const floatVal = Number.parseFloat(value); // Apply the rules for parsing floating-point number values to the descriptor. // If the result is less than zero, let error be yes. Otherwise, let density // be the result. if (floatVal < 0) { pError = true; } else { density = floatVal; } } // If the descriptor consists of a valid non-negative integer followed by // a U+0068 LATIN SMALL LETTER H character else if ( NON_NEGATIVE_INTEGER_REGEXP.test(value) && lastChar === SMALL_LETTER_H ) { // If height and density are not both absent, then let error be yes. if (height || density) { pError = true; } const intVal = Number.parseInt(value, 10); // Apply the rules for parsing non-negative integers to the descriptor. // If the result is zero, let error be yes. Otherwise, let future-compat-h // be the result. if (intVal === 0) { pError = true; } else { height = intVal; } // Anything else, Let error be yes. } else { pError = true; } } // 15. If error is still no, then append a new image source to candidates whose // URL is url, associated with a width width if not absent and a pixel // density density if not absent. Otherwise, there is a parse error. if (!pError) { candidates.push([ /** @type {string} */ (url), start, start + /** @type {string} */ (url).length ]); } else { throw new Error( `Invalid srcset descriptor found in '${input}' at '${desc}'` ); } } /** * @returns {void} */ function tokenizeDescriptor() { // 8.1. Descriptor tokenizer: Skip whitespace collectCharacters(LEADING_SPACES_REGEXP); // 8.2. Let current descriptor be the empty string. // (Tracked as a start offset, `-1` = empty; sliced once per descriptor.) descriptorStart = -1; // 8.3. Let state be in descriptor. state = "in descriptor"; while (true) { // 8.4. Let charCode be the character at position. charCode = input.charCodeAt(position); // Do the following depending on the value of state. // For the purpose of this step, "EOF" is a special character representing // that position is past the end of input. // In descriptor if (state === "in descriptor") { // Do the following, depending on the value of charCode: // Space character // If current descriptor is not empty, append current descriptor to // descriptors and let current descriptor be the empty string. // Set state to after descriptor. if (isSpace(charCode)) { if (descriptorStart !== -1) { descriptors.push(input.slice(descriptorStart, position)); descriptorStart = -1; state = "after descriptor"; } } // U+002C COMMA (,) // Advance position to the next character in input. If current descriptor // is not empty, append current descriptor to descriptors. Jump to the step // labeled descriptor parser. else if (charCode === COMMA) { position += 1; if (descriptorStart !== -1) { descriptors.push(input.slice(descriptorStart, position - 1)); } parseDescriptors(); return; } // U+0028 LEFT PARENTHESIS (() // Append charCode to current descriptor. Set state to in parens. else if (charCode === LEFT_PARENTHESIS) { if (descriptorStart === -1) descriptorStart = position; state = "in parens"; } // EOF // If current descriptor is not empty, append current descriptor to // descriptors. Jump to the step labeled descriptor parser. else if (Number.isNaN(charCode)) { if (descriptorStart !== -1) { descriptors.push(input.slice(descriptorStart, position)); } parseDescriptors(); return; // Anything else // Append charCode to current descriptor. } else if (descriptorStart === -1) { descriptorStart = position; } } // In parens else if (state === "in parens") { // U+0029 RIGHT PARENTHESIS ()) // Append charCode to current descriptor. Set state to in descriptor. if (charCode === RIGHT_PARENTHESIS) { state = "in descriptor"; } // EOF // Append current descriptor to descriptors. Jump to the step labeled // descriptor parser. else if (Number.isNaN(charCode)) { descriptors.push(input.slice(descriptorStart, position)); parseDescriptors(); return; } // Anything else // Append charCode to current descriptor. (Covered by the tracked range.) } // After descriptor else if (state === "after descriptor") { // Do the following, depending on the value of charCode: if (isSpace(charCode)) { // Space character: Stay in this state. } // EOF: Jump to the step labeled descriptor parser. else if (Number.isNaN(charCode)) { parseDescriptors(); return; } // Anything else // Set state to in descriptor. Set position to the previous character in input. else { state = "in descriptor"; position -= 1; } } // Advance position to the next character in input. position += 1; } } // 3. Let candidates be an initially empty source set. // const candidates = []; // Moved to top // 4. Splitting loop: Collect a sequence of characters that are space // characters or U+002C COMMA characters. If any U+002C COMMA characters // were collected, that is a parse error. while (true) { collectCharacters(LEADING_COMMAS_OR_SPACES_REGEXP); // 5. If position is past the end of input, return candidates and abort these steps. if (position >= inputLength) { if (candidates.length === 0) { throw new Error("Must contain one or more image candidate strings"); } // (we're done, this is the sole return path) return candidates; } // 6. Collect a sequence of characters that are not space characters, // and let that be url. start = position; url = collectCharacters(LEADING_NOT_SPACES); // 7. Let descriptors be a new empty list. descriptors = []; // 8. If url ends with a U+002C COMMA character (,), follow these sub steps: // (1). Remove all trailing U+002C COMMA characters from url. If this removed // more than one character, that is a parse error. if (url && url.charCodeAt(url.length - 1) === COMMA) { url = url.replace(TRAILING_COMMAS_REGEXP, ""); // (Jump ahead to step 9 to skip tokenization and just push the candidate). parseDescriptors(); } // Otherwise, follow these sub steps: else { tokenizeDescriptor(); } // 16. Return to the step labeled splitting loop. } }; // The spec's "URL-potentially-surrounded-by-spaces" cleanup: C0 controls, // DEL..C1 and U+00A0 are stripped from the value after the whitespace trim. // eslint-disable-next-line no-control-regex const IGNORE_CHARS_REGEXP = /[\u0000-\u001F\u007F-\u009F\u00A0]/g; /** * Parse a `src`-like URL attribute value: trim ASCII whitespace / U+00A0 from * both ends (offsets preserved for source rewriting), then strip ignorable * control characters. Throws when nothing remains. * @param {string} input attribute value * @returns {ParsedSource[]} parsed src */ const parseSrc = (input) => { const len = input.length; if (len === 0) throw new Error("Must be non-empty"); let start = 0; let end = len; while (start < end) { const code = input.charCodeAt(start); if (code > 32 && code !== 160) break; start++; } if (start === end) throw new Error("Must be non-empty"); while (end > start) { const code = input.charCodeAt(end - 1); if (code > 32 && code !== 160) break; end--; } let value = input.slice(start, end); if (IGNORE_CHARS_REGEXP.test(value)) { value = value.replace(IGNORE_CHARS_REGEXP, ""); if (value.length === 0) throw new Error("Must be non-empty"); } return [[value, start, end]]; }; /** * Extracts the `icon-uri` value of an `msapplication-task` meta content * (`name=…;action-uri=…;icon-uri=…`) — the other parts are page URLs, not assets. * @param {string} input input * @returns {ParsedSource[]} parsed icon-uri */ const parseMsapplicationTask = (input) => { const len = input.length; let pos = 0; while (pos < len) { let sep = input.indexOf(";", pos); if (sep === -1) sep = len; const eq = input.indexOf("=", pos); if (eq !== -1 && eq < sep) { const key = input.slice(pos, eq).trim().toLowerCase(); if (key === "icon-uri") { let start = eq + 1; let end = sep; while (start < end && isSpace(input.charCodeAt(start))) { start++; } while (end > start && isSpace(input.charCodeAt(end - 1))) { end--; } if (start === end) return []; return [[input.slice(start, end), start, end]]; } } pos = sep + 1; } return []; }; // CSS syntax is loaded on first `parseCssUrls` call, not at module load — // `HtmlGenerator` deliberately defers it the same way, and most documents // never carry a URL-bearing SVG presentation attribute. /** @type {typeof import("../css/syntax") | undefined} */ let _cssSyntax; /** * Extracts `url(...)` references from a CSS value (an SVG presentation * attribute). Reuses webpack's CSS lexer so quoting/escaping match the CSS * spec; returns the `parseSrc` shape so the shared emit path maps and rewrites * the spans. Unquoted `url(path)` rewrites the content span; quoted * `url("path")` rewrites the inner string span (quotes preserved). * @param {string} input attribute value (a CSS component-value list) * @returns {ParsedSource[]} url references */ const parseCssUrls = (input) => { const { TT_EOF, TT_FUNCTION, TT_STRING, TT_URL, TT_WHITESPACE, TokenStream, equalsLowerCase } = _cssSyntax || (_cssSyntax = require("../css/syntax")); const ts = new TokenStream(input); /** @type {ParsedSource[]} */ const result = []; for (;;) { const t = ts.consume(); if (t.type === TT_EOF) break; if (t.type === TT_URL) { if (t.contentEnd > t.contentStart) { result.push([ input.slice(t.contentStart, t.contentEnd), t.contentStart, t.contentEnd ]); } } else if ( t.type === TT_FUNCTION && equalsLowerCase(input.slice(t.start, t.end - 1), "url") ) { let s = ts.consume(); while (s.type === TT_WHITESPACE) s = ts.consume(); if (s.type === TT_STRING) { const quote = input.charCodeAt(s.start); const innerStart = s.start + 1; // Drop the closing quote, unless the string is unterminated at EOF. const innerEnd = input.charCodeAt(s.end - 1) === quote ? s.end - 1 : s.end; if (innerEnd > innerStart) { result.push([ input.slice(innerStart, innerEnd), innerStart, innerEnd ]); } } } } return result; }; // Babel's `path.skip()`, children-only: set by `A.skipChildren()` during an // `enter` dispatch, consumed by the walk. let _walkSkip = false; // The walk's current position (`A.node` / `A.parent` read these; module-level // so the accessor methods' defaults avoid self-referential `this` typing). /** @type {HtmlNodeRef} */ let _currentNode = 0; /** @type {HtmlNodeRef | null} */ let _currentParent = null; /* eslint-disable jsdoc/require-template -- `A` below is the accessor const, not a type parameter */ /** * The HTML path (Babel's `path` shape): the AST accessor with the walk's * current position on it — the single argument every visitor receives. * @typedef {typeof A} HtmlPath */ /* eslint-enable jsdoc/require-template */ // AST field-access seam (mirrors the CSS parser's `A`): every AST field a // consumer reads goes through one of these accessors, so the node // representation can change underneath without touching consumers. `n` is an // `HtmlNodeRef`; results are valid until the next `parseHtml` call. const A = { // === path position (rebound by the walk before every visitor call) === /** * @returns {HtmlNodeRef} current node — only valid during a visitor callback */ get node() { return _currentNode; }, /** * @returns {HtmlNodeRef | null} enclosing node (null = the document root) */ get parent() { return _currentParent; }, /** Stop the walk descending into the current node (enter only). */ skipChildren() { _walkSkip = true; }, // === field reads — `n` defaults to the current node === /** * @param {HtmlNodeRef=} n node * @returns {number} `NodeType` */ type(n = _currentNode) { return _nodeTypes[n]; }, /** * @param {HtmlNodeRef=} n node * @returns {number} start offset */ start(n = _currentNode) { return _nodeStarts[n]; }, /** * @param {HtmlNodeRef=} n node * @returns {number} end offset */ end(n = _currentNode) { return _nodeEnds[n]; }, /** * Raw source slice `[start, end)` — valid only during the walk (the printer's * window), before `parseHtml` releases `_htmlSource`. * @param {HtmlNodeRef=} n node * @returns {string} raw source slice */ source(n = _currentNode) { return _htmlSource.slice(_nodeStarts[n], _nodeEnds[n]); }, /** * @param {HtmlElement=} n element * @returns {string} lowercased (foreign-content: adjusted) tag name */ tagName(n = _currentNode) { return _nodeStrings[n]; }, /** * @param {HtmlElement=} n element * @returns {number} `NS_*` namespace */ namespace(n = _currentNode) { return _nodeFlags[n] & NS_MASK; }, /** * @param {HtmlElement=} n element * @returns {boolean} true for void elements */ selfClosing(n = _currentNode) { return (_nodeFlags[n] & FLAG_SELF_CLOSING) !== 0; }, // materialized attribute list — test/tooling convenience, allocates; the // parser reads attributes through the scalar accessors below /** * @param {HtmlElement=} n element * @returns {HtmlAttribute[]} materialized attributes */ attributes(n = _currentNode) { const out = []; const start = _nodeAttrStarts[n]; for (let i = start; i < start + _nodeAttrCounts[n]; i++) { out.push({ name: _attrNames[i], value: _attrValueOf(i), serializedName: _attrSerializedName(i), nameStart: _attrNameStarts[i], nameEnd: _attrNameEnds[i], valueStart: _attrValueStarts[i], valueEnd: _attrValueEnds[i] }); } return out; }, /** * @param {HtmlElement=} n element * @returns {number} attribute count */ attributeCount(n = _currentNode) { return _nodeAttrCounts[n]; }, /** * The i-th attribute of an element, as an id for the `attribute*` reads. * @param {number} i attribute index * @param {HtmlElement=} n element * @returns {HtmlAttributeRef} attribute ref */ attributeAt(i, n = _currentNode) { return _nodeAttrStarts[n] + i; }, /** * Linear lookup by (lowercased) name. * @param {string} name attribute name * @param {HtmlElement=} n element * @returns {HtmlAttributeRef} attribute ref (0 = not present) */ findAttribute(name, n = _currentNode) { return _findAttr(_nodeAttrStarts[n], _nodeAttrCounts[n], name); }, /** * @param {HtmlAttributeRef} a attribute ref * @returns {string} lowercased (foreign-content: adjusted) attribute name */ attributeName(a) { return _attrNames[a]; }, /** * @param {HtmlAttributeRef} a attribute ref * @returns {string} raw (undecoded) attribute value ("" when valueless) */ attributeValue(a) { return _attrValueOf(a); }, /** * @param {HtmlAttributeRef} a attribute ref * @returns {number} name start offset */ attributeNameStart(a) { return _attrNameStarts[a]; }, /** * @param {HtmlAttributeRef} a attribute ref * @returns {number} name end offset */ attributeNameEnd(a) { return _attrNameEnds[a]; }, /** * @param {HtmlAttributeRef} a attribute ref * @returns {number} value start offset (-1 when valueless or on adoption-agency clones) */ attributeValueStart(a) { return _attrValueStarts[a]; }, /** * @param {HtmlAttributeRef} a attribute ref * @returns {number} value end offset */ attributeValueEnd(a) { return _attrValueEnds[a]; }, /** * @param {HtmlElement=} n element * @returns {number} end offset of the opening tag (after `>`) */ tagEnd(n = _currentNode) { return _nodeTagEnds[n]; }, /** * @param {HtmlElement=} n element * @returns {number} end offset of the tag name */ nameEnd(n = _currentNode) { return _nodeNameEnds[n]; }, /** * Raw source of an element's opening tag, `[start, tagEnd)` — attribute quoting * / spacing / case preserved byte-for-byte (walk-window only) — or `""` for a * parser-inserted element (auto `html`/`head`/`body`/`tbody`, …), which has no * real source tag: its offsets are zero-width or borrow the triggering token, * so the sliced name doesn't match this element. The empty string lets a printer * treat such an element as transparent. * @param {HtmlElement=} n element * @returns {string} opening-tag source, or `""` when parser-inserted */ openTag(n = _currentNode) { const start = _nodeStarts[n]; const name = _htmlSource.slice(start + 1, _nodeNameEnds[n]); return name.toLowerCase() === _nodeStrings[n].toLowerCase() ? _htmlSource.slice(start, _nodeTagEnds[n]) : ""; }, /** * An element's end tag, generated as `</name>` from the opening tag's own name * (exact source casing, correct for foreign camelCase elements). Generated, not * sliced: element `end` offsets don't span the end tag, and an omitted optional * end tag (`<li>`, `<p>`, …) still serializes to the same DOM. Not meaningful * for void / implied elements — the printer only calls it for the rest. * @param {HtmlElement=} n element * @returns {string} closing-tag text */ closeTag(n = _currentNode) { return `</${_htmlSource.slice(_nodeStarts[n] + 1, _nodeNameEnds[n])}>`; }, /** * @param {HtmlElement=} n element * @returns {number} under `skip.text`, end offset of a raw-text element's body (`tagEnd` when empty) */ contentEnd(n = _currentNode) { return (_nodeFlags[n] & FLAG_HAS_TEMPLATE) !== 0 ? _nodeTagEnds[n] : _nodeContentEnds[n]; }, /** * @param {HtmlElement=} n element * @returns {HtmlDocumentFragment} `<template>` content fragment (0 = none) */ templateContent(n = _currentNode) { return _templateContentOf(n); }, /** * @param {HtmlText | HtmlComment=} n text / comment node * @returns {string} decoded text / comment data */ data(n = _currentNode) { return _nodeStrings[n]; }, /** * @param {HtmlDoctype=} n doctype node * @returns {string} doctype name */ doctypeName(n = _currentNode) { return _nodeStrings[n]; }, // The doctype ids are per-parse scalars (a document has at most one // doctype node); the node parameter is accepted for call-shape uniformity. /** * @param {HtmlDoctype=} _n doctype node * @returns {string | null} doctype public id */ doctypePublicId(_n) { return _doctypePublicId; }, /** * @param {HtmlDoctype=} _n doctype node * @returns {string | null} doctype system id */ doctypeSystemId(_n) { return _doctypeSystemId; }, // === tree links (0 = none) === /** * @param {HtmlNodeRef=} n node * @returns {HtmlNodeRef} first child */ firstChild(n = _currentNode) { return _nodeFirstChildren[n]; }, /** * @param {HtmlNodeRef=} n node * @returns {HtmlNodeRef} next sibling */ nextSibling(n = _currentNode) { return _nodeNextSiblings[n]; }, /** * @param {HtmlNodeRef=} n node * @returns {HtmlNodeRef} parent node (a `<template>`'s content links to its fragment) */ parentOf(n = _currentNode) { return _nodeParents[n]; }, /** * @param {HtmlNodeRef=} n node * @returns {HtmlNodeRef[]} materialized child list — test/tooling convenience, allocates */ children(n = _currentNode) { const out = []; for (let c = _nodeFirstChildren[n]; c !== 0; c = _nodeNextSiblings[c]) { out.push(c); } return out; } }; module.exports.A = A; module.exports.NS_HTML = NS_HTML; module.exports.NS_MATHML = NS_MATHML; module.exports.NS_SVG = NS_SVG; module.exports.NodeType = NodeType; module.exports.QUOTE_DOUBLE = QUOTE_DOUBLE; module.exports.QUOTE_NONE = QUOTE_NONE; module.exports.QUOTE_SINGLE = QUOTE_SINGLE; // Exposed so HtmlParser can map user-configured (lowercased) tag names to // the adjusted camelCase names the AST carries for foreign content. module.exports.SVG_TAG_ADJUST = SVG_TAG_ADJUST; module.exports.SourceProcessor = SourceProcessor; module.exports.baseTag = baseTag; module.exports.buildHeadTags = buildHeadTags; module.exports.decodeEntities = decodeEntities; module.exports.escapeAttribute = escapeAttribute; module.exports.escapeText = escapeText; module.exports.isAsciiWhitespace = isSpace; module.exports.metaTag = metaTag; // WHATWG "ASCII whitespace" (tab / LF / FF / CR / space) — the tokenizer's // whitespace class, exported under the spec's name. module.exports.parseCssUrls = parseCssUrls; module.exports.parseHtml = parseHtml; module.exports.parseMsapplicationTask = parseMsapplicationTask; module.exports.parseSrc = parseSrc; module.exports.parseSrcset = parseSrcset; module.exports.printer = printer; module.exports.tokenize = tokenize;