diff --git a/eksml/BENCHMARKS.md b/eksml/BENCHMARKS.md
index dff6b19..e33bdda 100644
--- a/eksml/BENCHMARKS.md
+++ b/eksml/BENCHMARKS.md
@@ -25,67 +25,67 @@ Parse an XML string into a tree structure.
| Eksml |
- 1,221,950 op/s |
- 87,714 op/s |
- 66,135 op/s |
- 35,922 op/s |
- 13,943 op/s |
- 16,917 op/s |
- 14,420 op/s |
+ 1,687,050 op/s |
+ 137,518 op/s |
+ 91,376 op/s |
+ 52,689 op/s |
+ 20,580 op/s |
+ 23,075 op/s |
+ 18,117 op/s |
| tXml |
- 1,007,229 op/s |
+ 880,187 op/s |
-- |
- 45,389 op/s |
- 27,982 op/s |
- 9,383 op/s |
- 13,520 op/s |
- 11,369 op/s |
+ 49,093 op/s |
+ 23,746 op/s |
+ 11,110 op/s |
+ 11,358 op/s |
+ 10,858 op/s |
| htmlparser2 |
- 523,318 op/s |
- 25,204 op/s |
- 17,709 op/s |
- 10,151 op/s |
- 3,590 op/s |
- 4,942 op/s |
- 3,886 op/s |
+ 540,625 op/s |
+ 24,978 op/s |
+ 16,564 op/s |
+ 9,935 op/s |
+ 3,237 op/s |
+ 4,910 op/s |
+ 3,935 op/s |
| fast-xml-parser |
- 171,272 op/s |
- 9,932 op/s |
- 7,520 op/s |
- 3,745 op/s |
- 1,360 op/s |
- 2,358 op/s |
- 1,282 op/s |
+ 174,929 op/s |
+ 9,687 op/s |
+ 7,397 op/s |
+ 3,277 op/s |
+ 1,332 op/s |
+ 2,193 op/s |
+ 1,113 op/s |
| xml2js |
- 133,748 op/s |
- 9,809 op/s |
- 6,727 op/s |
- 3,951 op/s |
- 1,563 op/s |
- 2,080 op/s |
- 1,510 op/s |
+ 120,589 op/s |
+ 8,880 op/s |
+ 7,095 op/s |
+ 3,905 op/s |
+ 1,591 op/s |
+ 2,060 op/s |
+ 1,275 op/s |
| @xmldom/xmldom |
- 89,126 op/s |
- 5,640 op/s |
- 5,112 op/s |
- 2,604 op/s |
- 895 op/s |
- 1,414 op/s |
- 702 op/s |
+ 96,819 op/s |
+ 6,170 op/s |
+ 5,033 op/s |
+ 2,563 op/s |
+ 881 op/s |
+ 1,398 op/s |
+ 718 op/s |
-Eksml is **1.2-1.5x faster than tXml**, **2.3-3.9x faster than htmlparser2**, **7-11x faster than fast-xml-parser**, and **12-21x faster than xmldom**.
+Eksml is **1.7-2.2x faster than tXml**, **3-6x faster than htmlparser2**, **10-16x faster than fast-xml-parser**, and **16-25x faster than xmldom**.
> [!note]
> tXml crashed on the RSS fixture
diff --git a/eksml/README.md b/eksml/README.md
index 043a904..854fc7d 100644
--- a/eksml/README.md
+++ b/eksml/README.md
@@ -533,7 +533,7 @@ import {
Eksml is consistently the fastest at parsing, streaming, and tokenization, and leads serialization with `validate: false`. Benchmarks run via [Vitest bench](https://vitest.dev/guide/features.html#benchmarking) against real-world XML fixtures from ~100 B to ~30 KB, plus a synthetic attribute-heavy stress document.
-- **DOM parsing**: 1.2-1.5x faster than tXml, 2.3-3.9x faster than htmlparser2, 7-21x faster than fast-xml-parser/xml2js/xmldom
+- **DOM parsing**: 1.7-2.2x faster than tXml, 3-6x faster than htmlparser2, 10-25x faster than fast-xml-parser/xml2js/xmldom
- **SAX streaming**: 1.2-1.4x faster than easysax, 2-3.6x faster than htmlparser2/saxes, 3.9-6.4x faster than sax
- **Raw tokenization**: 1.3-1.7x faster than easysax, 1.8-3.4x faster than saxes/htmlparser2, 4.3-6.8x faster than sax
- **Serialization**: Fastest on every fixture with `validate: false`; with validation on (the default), wins RSS/SOAP/Atom/POM while tXml edges small/EPG; 2-7x faster than xmldom and 7-19x faster than fast-xml-parser/xml2js
diff --git a/eksml/src/parser.ts b/eksml/src/parser.ts
index 5f22913..739fb86 100644
--- a/eksml/src/parser.ts
+++ b/eksml/src/parser.ts
@@ -12,6 +12,7 @@ const GT = 62; // >
const SLASH = 47; // /
const BANG = 33; // !
const QUESTION = 63; // ?
+const EQ = 61; // =
const LBRACKET = 91; // [
const RBRACKET = 93; // ]
const SQUOTE = 39; // '
@@ -116,9 +117,11 @@ interface InternalParseOptions extends ParseOptions {
parseNode?: boolean;
}
-// Pre-computed lookup table: 1 for characters that terminate a name token
+// Pre-computed lookup table: 1 for characters that terminate a name token.
+// All name-ending chars are <= 62, so the scan first checks charCode > 62
+// (letters and non-ASCII) and only consults the table for low codes.
// Name-ending chars: \t(9) \n(10) \r(13) space(32) /(47) =(61) >(62)
-const NAME_END = new Uint8Array(128);
+const NAME_END = new Uint8Array(64);
NAME_END[9] = 1; // \t
NAME_END[10] = 1; // \n
NAME_END[13] = 1; // \r
@@ -127,6 +130,622 @@ NAME_END[47] = 1; // /
NAME_END[61] = 1; // =
NAME_END[62] = 1; // >
+// Child-kind flags, tracked incrementally as children are pushed so that
+// "strip ignorable whitespace" needs no re-scan of the children array:
+// 1 = has element child, 2 = has whitespace-only text, 4 = has non-ws text.
+// Strip condition (element container with only ignorable whitespace) is
+// (flags & 7) === 3.
+// Bit 8 marks ws-only strings that were actually pushed into the array
+// (CDATA, decoded text, promoted PI children) as opposed to deferred
+// ws-only text runs, which are kept as index pairs and only materialized
+// into substrings if the container turns out to keep them.
+const HAS_ELEMENT = 1;
+const HAS_WS_TEXT = 2;
+const HAS_TEXT = 4;
+const HAS_WS_IN_ARRAY = 8;
+const KIND_MASK = HAS_ELEMENT | HAS_WS_TEXT | HAS_TEXT;
+const STRIP_STATE = HAS_ELEMENT | HAS_WS_TEXT;
+
+/** Same as trim()-whitespace test but over an arbitrary string (decoded text, promoted children). */
+function isWsString(text: string): boolean {
+ for (let i = 0; i < text.length; i++) {
+ const c = text.charCodeAt(i);
+ if (c === 32 || (c >= 9 && c <= 13)) continue;
+ if (c < 128) return false;
+ return text.trim().length === 0;
+ }
+ return true;
+}
+
+/**
+ * Remove all string children. Only called when (flags & 7) === 3 (element
+ * container whose every string child is whitespace-only), so removing
+ * all strings is exactly "strip ignorable whitespace". Mixed-content
+ * elements are never compacted.
+ * Mutates the array in-place for performance.
+ */
+function compactWhitespace(children: (TNode | string)[]): void {
+ let writeIndex = 0;
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i]!;
+ if (typeof child === 'string') continue;
+ children[writeIndex++] = child;
+ }
+ children.length = writeIndex;
+}
+
+// Module-level scratch buffers for the tree-building stack and deferred
+// whitespace runs. parse() is synchronous and never re-entered (no user
+// callbacks run mid-parse), so reusing these across calls is safe and
+// saves ~9 array allocations per parse call. Object slots are cleared on
+// parseChildren exit so no parsed tree is retained between calls.
+const frameTagNames: string[] = [];
+const frameAttributes: (Record | null)[] = [];
+const frameChildren: (TNode | string)[][] = [];
+const frameFlags: number[] = [];
+const frameNameStarts: number[] = [];
+const framePendingBases: number[] = [];
+const pendStart: number[] = [];
+const pendEnd: number[] = [];
+const pendAt: number[] = [];
+
+const EMPTY_CHILDREN: (TNode | string)[] = [];
+
+/** Clear object references held by the scratch stack (keeps capacity). */
+function clearScratch(): void {
+ for (let i = 0; i < frameChildren.length; i++) {
+ frameTagNames[i] = '';
+ frameAttributes[i] = null;
+ frameChildren[i] = EMPTY_CHILDREN;
+ }
+}
+
+let pendingCount = 0;
+
+/**
+ * Insert the deferred ws-only substrings [base..pendingCount) into
+ * `children` at their recorded positions (nondecreasing), allocating the
+ * substrings only now. Single backward in-place merge pass.
+ */
+function materializePending(
+ source: string,
+ children: (TNode | string)[],
+ base: number,
+): void {
+ const insertCount = pendingCount - base;
+ const oldLength = children.length;
+ children.length = oldLength + insertCount;
+ let read = oldLength - 1;
+ let write = oldLength + insertCount - 1;
+ for (let p = pendingCount - 1; p >= base; p--) {
+ const at = pendAt[p]!;
+ while (read >= at) children[write--] = children[read--]!;
+ children[write--] = source.substring(pendStart[p]!, pendEnd[p]!);
+ }
+ pendingCount = base;
+}
+
+/** Build an error with line/column info (module-level, closure-free). */
+function positionedError(
+ source: string,
+ message: string,
+ atPos: number,
+): Error {
+ const before = source.substring(0, atPos);
+ const lines = before.split('\n');
+ const line = lines.length;
+ const column = lines[lines.length - 1]!.length + 1;
+ return new Error(`${message} at line ${line}, column ${column}`);
+}
+
+/**
+ * True when source[start..end) is whitespace-only per String#trim
+ * semantics (module-level variant of the parse-scope isWsRange).
+ */
+function isWsRangeIn(source: string, start: number, end: number): boolean {
+ for (let i = start; i < end; i++) {
+ const c = source.charCodeAt(i);
+ if (c === 32 || (c >= 9 && c <= 13)) continue;
+ if (c < 128) return false;
+ return source.substring(start, end).trim().length === 0;
+ }
+ return true;
+}
+
+/**
+ * Specialized root parser for the default option configuration:
+ * strict=false, keepComments=false, trimWhitespace=false, decode=null,
+ * selfClosingSet=null, rawContentSet=null. Logic mirrors parseChildren
+ * with every option check constant-folded away. Module-level with a local
+ * `pos`, so a default-options parse() call allocates no closures and the
+ * hot loop reads locals instead of context slots.
+ */
+function parseRootFast(src: string): (TNode | string)[] {
+ const srcEnd = src.length;
+ let pos = 0;
+ let depth = 0;
+ let currentTagName = '';
+ // Offset in src where the current open tag's name begins, so close tags
+ // are verified by comparing two flat-source ranges (no allocation).
+ let currentNameStart = 0;
+ let children: (TNode | string)[] = [];
+ let flags = 0;
+ pendingCount = 0;
+ let pendingBase = 0;
+
+ while (pos < srcEnd) {
+ if (src.charCodeAt(pos) === LT) {
+ const nextCharCode = src.charCodeAt(pos + 1);
+ if (nextCharCode === SLASH) {
+ // ---- Close tag ----
+ const closeStart = pos + 2;
+
+ // Fast path: exact `` — check '>' right after the name,
+ // then compare the name range against the open tag's name range.
+ const nameLen = currentTagName.length;
+ let matched = false;
+ if (src.charCodeAt(closeStart + nameLen) === GT) {
+ let k = 0;
+ while (
+ k < nameLen &&
+ src.charCodeAt(closeStart + k) ===
+ src.charCodeAt(currentNameStart + k)
+ )
+ k++;
+ if (k === nameLen) {
+ pos = closeStart + nameLen; // at the '>'
+ matched = true;
+ }
+ }
+
+ if (!matched) {
+ // Slow path: trailing whitespace, genuine mismatch, or EOF.
+ pos = src.indexOf('>', pos);
+
+ if (pos === -1) {
+ pos = srcEnd;
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(src, children, pendingBase);
+ }
+ // Unwind: if we are inside a stacked frame, pop back
+ while (depth > 0) {
+ depth--;
+ const node: TNode = {
+ tagName: currentTagName,
+ attributes: frameAttributes[depth]!,
+ children,
+ };
+ currentTagName = frameTagNames[depth]!;
+ children = frameChildren[depth]!;
+ flags = frameFlags[depth]! | HAS_ELEMENT;
+ pendingBase = framePendingBases[depth]!;
+ children[children.length] = node;
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(src, children, pendingBase);
+ }
+ }
+ return children;
+ }
+
+ const closeTag = src.substring(closeStart, pos).trimEnd();
+ if (closeTag !== currentTagName) {
+ throw positionedError(
+ src,
+ `Unexpected close tag ${closeTag}> (expected ${currentTagName}>)`,
+ pos,
+ );
+ }
+ }
+
+ pos += 1;
+
+ // Finalize this children array: drop deferred ignorable ws (never
+ // allocated) when this is a pure element container, otherwise
+ // materialize the deferred runs into real substrings.
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(src, children, pendingBase);
+ }
+
+ if (depth === 0) {
+ // We've closed the root tag — return
+ return children;
+ }
+
+ // Pop frame: finalize node and add to parent's children
+ depth--;
+ const node: TNode = {
+ tagName: currentTagName,
+ attributes: frameAttributes[depth]!,
+ children,
+ };
+ currentTagName = frameTagNames[depth]!;
+ currentNameStart = frameNameStarts[depth]!;
+ children = frameChildren[depth]!;
+ flags = frameFlags[depth]! | HAS_ELEMENT;
+ pendingBase = framePendingBases[depth]!;
+ children[children.length] = node;
+ // Handle processing instruction children promotion
+ if (node.tagName.charCodeAt(0) === QUESTION) {
+ const promoted = node.children;
+ for (let i = 0; i < promoted.length; i++) {
+ const promotedChild = promoted[i]!;
+ if (typeof promotedChild === 'string') {
+ if ((flags & HAS_TEXT) === 0) {
+ if (isWsString(promotedChild)) {
+ flags |= HAS_WS_TEXT | HAS_WS_IN_ARRAY;
+ } else {
+ if (pendingCount > pendingBase)
+ materializePending(src, children, pendingBase);
+ flags |= HAS_TEXT;
+ }
+ }
+ children.push(promotedChild);
+ } else {
+ flags |= HAS_ELEMENT;
+ children.push(promotedChild);
+ }
+ }
+ node.children = [];
+ }
+ continue;
+ } else if (nextCharCode === BANG) {
+ if (src.charCodeAt(pos + 2) === DASH) {
+ // comment: skipped entirely (keepComments is off on this path)
+ pos = src.indexOf('-->', pos + 3);
+ pos = pos === -1 ? srcEnd : pos + 2; // point to the '>'
+ } else if (
+ src.charCodeAt(pos + 2) === LBRACKET &&
+ src.charCodeAt(pos + 8) === LBRACKET &&
+ src.substring(pos + 3, pos + 8).toLowerCase() === 'cdata'
+ ) {
+ const cdataEndIndex = src.indexOf(']]>', pos);
+ const cdataEnd = cdataEndIndex === -1 ? srcEnd : cdataEndIndex;
+ if ((flags & HAS_TEXT) === 0) {
+ if (isWsRangeIn(src, pos + 9, cdataEnd)) {
+ flags |= HAS_WS_TEXT | HAS_WS_IN_ARRAY;
+ } else {
+ if (pendingCount > pendingBase)
+ materializePending(src, children, pendingBase);
+ flags |= HAS_TEXT;
+ }
+ }
+ children.push(src.substring(pos + 9, cdataEnd));
+ pos = cdataEndIndex === -1 ? srcEnd : cdataEndIndex + 3;
+ continue;
+ } else {
+ // doctype / other declarations: parse as TNode
+ pos += 2; // skip ' | null = null;
+ while (pos < srcEnd) {
+ const cc = src.charCodeAt(pos);
+ if (cc === GT || cc === LBRACKET) break;
+ if (cc <= 32) {
+ pos++;
+ continue;
+ }
+ // Quoted token — capture including quotes as the key
+ if (cc === SQUOTE || cc === DQUOTE) {
+ const closePos = src.indexOf(cc === SQUOTE ? "'" : '"', pos + 1);
+ if (closePos === -1) {
+ pos = srcEnd;
+ break;
+ }
+ const token = src.substring(pos + 1, closePos);
+ if (declAttributes === null) declAttributes = {};
+ setOwnProperty(declAttributes!, token, null);
+ pos = closePos + 1;
+ continue;
+ }
+ // Unquoted token
+ const tokenStart = pos;
+ while (pos < srcEnd) {
+ const tc = src.charCodeAt(pos);
+ if (tc <= 32 || tc === GT || tc === LBRACKET) break;
+ pos++;
+ }
+ const token = src.substring(tokenStart, pos);
+ if (declAttributes === null) declAttributes = {};
+ setOwnProperty(declAttributes!, token, null);
+ }
+
+ // Skip internal DTD subset ([...]) if present
+ if (pos < srcEnd && src.charCodeAt(pos) === LBRACKET) {
+ pos++; // skip '['
+ let insideBracketSection = true;
+ while (insideBracketSection && pos < srcEnd) {
+ if (src.charCodeAt(pos) === RBRACKET) {
+ insideBracketSection = false;
+ } else {
+ const quoteCharCode = src.charCodeAt(pos);
+ if (quoteCharCode === SQUOTE || quoteCharCode === DQUOTE) {
+ pos = src.indexOf(
+ quoteCharCode === SQUOTE ? "'" : '"',
+ pos + 1,
+ );
+ if (pos === -1) {
+ pos = srcEnd;
+ break;
+ }
+ }
+ }
+ pos++;
+ }
+ while (pos < srcEnd && src.charCodeAt(pos) <= 32) pos++;
+ }
+
+ children.push({
+ tagName: declTagName,
+ attributes: declAttributes,
+ children: [],
+ } as TNode);
+ flags |= HAS_ELEMENT;
+ }
+ pos++;
+ continue;
+ }
+ // ---- Open tag ----
+ pos++;
+ const tagFirstCharCode = nextCharCode;
+ const tagNameStart = pos;
+ // Inline name scan (see parseName for the charCode > 62 trick)
+ let tagCode = src.charCodeAt(pos);
+ while (tagCode > 62 || NAME_END[tagCode] === 0) {
+ tagCode = src.charCodeAt(++pos);
+ }
+ const tagName = src.substring(tagNameStart, pos);
+ let attributes: Record | null = null;
+
+ // parsing attributes — attrCode mirrors src.charCodeAt(pos); the
+ // NaN self-check replaces the bounds test (charCodeAt is NaN only
+ // past end of string). Ported from expA.
+ let attrCode = tagCode;
+ while (attrCode !== GT && attrCode === attrCode) {
+ if (
+ (attrCode > 96 && attrCode < 123) ||
+ (attrCode > 64 && attrCode < 91) ||
+ attrCode === UNDERSCORE ||
+ attrCode === COLON ||
+ attrCode > 127
+ ) {
+ // Inline attribute-name scan
+ const nameStart = pos;
+ let code = attrCode;
+ while (code > 62 || NAME_END[code] === 0) {
+ code = src.charCodeAt(++pos);
+ }
+ const name = src.substring(nameStart, pos);
+ // Fast path (from expA): `="value"` / `='value'` directly after
+ // the name — one indexOf for the closing quote + one substring.
+ if (code === EQ) {
+ const quote = src.charCodeAt(pos + 1);
+ if (quote === DQUOTE || quote === SQUOTE) {
+ const valueStart = pos + 2;
+ pos = src.indexOf(quote === DQUOTE ? '"' : "'", valueStart);
+ if (pos === -1) {
+ // Unterminated attribute string — emit node with what we
+ // have; materialize deferred ws at every level (no
+ // stripping happens on this path).
+ if (pendingCount > pendingBase)
+ materializePending(src, children, pendingBase);
+ const node: TNode = { tagName, attributes, children: [] };
+ children[children.length] = node;
+ while (depth > 0) {
+ depth--;
+ const parent: TNode = {
+ tagName: currentTagName,
+ attributes: frameAttributes[depth]!,
+ children,
+ };
+ currentTagName = frameTagNames[depth]!;
+ children = frameChildren[depth]!;
+ pendingBase = framePendingBases[depth]!;
+ if (pendingCount > pendingBase)
+ materializePending(src, children, pendingBase);
+ children.push(parent);
+ }
+ return children;
+ }
+ const value = src.substring(valueStart, pos);
+ if (attributes === null) attributes = {};
+ setOwnProperty(attributes!, name, value);
+ pos++;
+ attrCode = src.charCodeAt(pos);
+ continue;
+ }
+ }
+ while (
+ code &&
+ code !== SQUOTE &&
+ code !== DQUOTE &&
+ !(
+ (code > 64 && code < 91) ||
+ (code > 96 && code < 123) ||
+ code === UNDERSCORE ||
+ code === COLON ||
+ code > 127
+ ) &&
+ code !== GT
+ ) {
+ pos++;
+ code = src.charCodeAt(pos);
+ }
+ let value: string | null;
+ if (code === SQUOTE || code === DQUOTE) {
+ // Inline parseString
+ const stringStart = pos + 1;
+ pos = src.indexOf(code === SQUOTE ? "'" : '"', stringStart);
+ if (pos === -1) {
+ // Unterminated attribute string — emit node with what we
+ // have. No whitespace stripping happens on this path, so
+ // deferred ws runs are materialized at every level.
+ if (pendingCount > pendingBase)
+ materializePending(src, children, pendingBase);
+ const node: TNode = { tagName, attributes, children: [] };
+ children[children.length] = node;
+ // Unwind remaining stack frames
+ while (depth > 0) {
+ depth--;
+ const parent: TNode = {
+ tagName: currentTagName,
+ attributes: frameAttributes[depth]!,
+ children,
+ };
+ currentTagName = frameTagNames[depth]!;
+ children = frameChildren[depth]!;
+ pendingBase = framePendingBases[depth]!;
+ if (pendingCount > pendingBase)
+ materializePending(src, children, pendingBase);
+ children.push(parent);
+ }
+ return children;
+ }
+ value = src.substring(stringStart, pos);
+ } else {
+ value = null;
+ pos--;
+ }
+ if (attributes === null) attributes = {};
+ setOwnProperty(attributes!, name, value);
+ }
+ pos++;
+ attrCode = src.charCodeAt(pos);
+ }
+
+ // Determine if this node has children or is self-closing
+ if (
+ src.charCodeAt(pos - 1) !== SLASH &&
+ src.charCodeAt(pos - 1) !== QUESTION &&
+ tagFirstCharCode !== BANG
+ ) {
+ // Node has children — push frame and descend (no raw-content or
+ // self-closing tag lists on this path)
+ pos++;
+ frameTagNames[depth] = currentTagName;
+ frameAttributes[depth] = attributes;
+ frameChildren[depth] = children;
+ frameFlags[depth] = flags;
+ frameNameStarts[depth] = currentNameStart;
+ framePendingBases[depth] = pendingBase;
+ depth++;
+ currentTagName = tagName;
+ currentNameStart = tagNameStart;
+ children = [];
+ flags = 0;
+ pendingBase = pendingCount;
+ } else {
+ // Explicit self-closing (/>) or processing instruction (?>) or declaration
+ pos++;
+ const node: TNode = { tagName, attributes, children: [] };
+ children[children.length] = node;
+ flags |= HAS_ELEMENT;
+ // PI promotion is a no-op here: node.children is always empty
+ }
+ } else {
+ // ---- Text: fused scan + classification ----
+ const textStart = pos;
+ let textEnd: number;
+ let wsOnly = true;
+ let needExact = false;
+ let j = pos;
+ for (;;) {
+ if (j >= srcEnd) {
+ textEnd = srcEnd;
+ pos = srcEnd + 1; // matches original post-increment past EOF
+ break;
+ }
+ const c = src.charCodeAt(j);
+ if (c === LT) {
+ textEnd = j;
+ pos = j;
+ break;
+ }
+ if (c === 32 || (c >= 9 && c <= 13)) {
+ j++;
+ continue;
+ }
+ // Non-ws (or possibly Unicode ws if >= 128): classification
+ // settled (or needs the exact fallback); finish with indexOf
+ if (c >= 128) needExact = true;
+ wsOnly = false;
+ const idx = src.indexOf('<', j + 1);
+ if (idx === -1) {
+ textEnd = srcEnd;
+ pos = srcEnd + 1;
+ } else {
+ textEnd = idx;
+ pos = idx;
+ }
+ break;
+ }
+ if (needExact) {
+ wsOnly = src.substring(textStart, textEnd).trim().length === 0;
+ }
+ if ((flags & HAS_TEXT) !== 0) {
+ // Mixed content already established — push directly
+ children[children.length] = src.substring(textStart, textEnd);
+ } else if (wsOnly) {
+ // Defer: record the range, allocate only if the container
+ // turns out to keep its whitespace
+ pendStart[pendingCount] = textStart;
+ pendEnd[pendingCount] = textEnd;
+ pendAt[pendingCount] = children.length;
+ pendingCount++;
+ flags |= HAS_WS_TEXT;
+ } else {
+ if (pendingCount > pendingBase)
+ materializePending(src, children, pendingBase);
+ children[children.length] = src.substring(textStart, textEnd);
+ flags |= HAS_TEXT;
+ }
+ }
+ }
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(src, children, pendingBase);
+ }
+ // Unwind any remaining stack frames (unclosed tags in non-strict mode)
+ while (depth > 0) {
+ depth--;
+ const node: TNode = {
+ tagName: currentTagName,
+ attributes: frameAttributes[depth]!,
+ children,
+ };
+ currentTagName = frameTagNames[depth]!;
+ children = frameChildren[depth]!;
+ flags = frameFlags[depth]! | HAS_ELEMENT;
+ pendingBase = framePendingBases[depth]!;
+ children[children.length] = node;
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(src, children, pendingBase);
+ }
+ }
+ return children;
+}
+
/**
* Parse XML/HTML into a DOM Object with minimal validation and fault tolerance
* @param S - The XML string to parse
@@ -137,9 +756,22 @@ export function parse(
S: string,
options?: ParseOptions | InternalParseOptions,
): (TNode | string)[] {
+ if (options === undefined) {
+ // Thin dispatch: the default-options case runs the specialized
+ // module-level parser — no per-call closures, no option resolution.
+ try {
+ return parseRootFast(S);
+ } finally {
+ // Drop tree/tagName references held by the module-level scratch
+ // stack so no parsed data outlives this call.
+ clearScratch();
+ }
+ }
+
const resolvedOptions = (options || {}) as InternalParseOptions;
let pos = resolvedOptions.pos || 0;
+ let srcLength = S.length;
const keepComments = !!resolvedOptions.keepComments;
const trimWhitespace = !!resolvedOptions.trimWhitespace;
const strict = !!resolvedOptions.strict;
@@ -173,163 +805,237 @@ export function parse(
}
/**
- * Strip whitespace-only text nodes from a children array when it
- * contains only element nodes and whitespace-only text (i.e. "ignorable
- * whitespace" per XML spec). Mixed-content elements — those with at
- * least one non-whitespace text child — are left untouched so that
- * whitespace formatting is preserved.
- * Mutates the array in-place for performance.
+ * True when S[start..end) is whitespace-only per String#trim semantics.
+ * ASCII fast path with charCodeAt on the flat source string; falls back
+ * to trim() only when a non-ASCII char is seen before any ASCII non-ws
+ * (Unicode whitespace like \u00a0 / \u3000 is trimmed by trim()).
*/
- function stripIgnorableWhitespace(children: (TNode | string)[]): void {
- let hasElement = false;
- let hasWhitespaceOnlyText = false;
- let hasNonWhitespaceText = false;
- for (let i = 0; i < children.length; i++) {
- const child = children[i]!;
- if (typeof child !== 'string') {
- hasElement = true;
- } else if (child.trim().length === 0) {
- hasWhitespaceOnlyText = true;
- } else {
- hasNonWhitespaceText = true;
- }
- }
- // Only strip when children are exclusively elements + whitespace-only
- // text (pure element containers). Mixed content is left intact.
- if (hasElement && hasWhitespaceOnlyText && !hasNonWhitespaceText) {
- // Compact in-place with a write pointer (avoids O(n) splice per removal)
- let writeIndex = 0;
- for (let i = 0; i < children.length; i++) {
- const child = children[i]!;
- if (typeof child === 'string' && child.trim().length === 0) continue;
- children[writeIndex++] = child;
- }
- children.length = writeIndex;
+ function isWsRange(start: number, end: number): boolean {
+ for (let i = start; i < end; i++) {
+ const c = S.charCodeAt(i);
+ if (c === 32 || (c >= 9 && c <= 13)) continue;
+ if (c < 128) return false;
+ return S.substring(start, end).trim().length === 0;
}
+ return true;
}
- function parseChildren(rootTagName: string): (TNode | string)[] {
+ function parseChildren(
+ rootTagName: string,
+ rootNameStart: number,
+ ): (TNode | string)[] {
// Iterative tree-building using an explicit stack to avoid
// stack overflow on deeply nested XML (the old recursive
// parseNode → parseChildren → parseNode chain blew up at ~2000 levels).
- interface Frame {
- tagName: string;
- attributes: Record | null;
- children: (TNode | string)[];
- }
- const stack: Frame[] = [];
+ // The stack is kept as module-level parallel arrays instead of
+ // per-frame objects to avoid heap allocations per element with
+ // children (see scratch buffer comment at module scope).
+ let depth = 0;
let currentTagName = rootTagName;
+ // Offset in S where the current open tag's name begins, so close tags
+ // can be verified by comparing two flat-S ranges (no string reads on
+ // possibly-sliced tagName strings, no allocation).
+ let currentNameStart = rootNameStart;
let children: (TNode | string)[] = [];
+ let flags = 0;
+ pendingCount = 0;
+ let pendingBase = 0;
+ // Local aliases: register reads in the hot loop instead of context-slot
+ // loads (same technique as the SAX engine's processChunk).
+ const src = S;
+ const srcEnd = srcLength;
- while (S[pos]) {
- if (S.charCodeAt(pos) === LT) {
- if (S.charCodeAt(pos + 1) === SLASH) {
+ while (pos < srcEnd) {
+ if (src.charCodeAt(pos) === LT) {
+ const nextCharCode = src.charCodeAt(pos + 1);
+ if (nextCharCode === SLASH) {
// ---- Close tag ----
const closeStart = pos + 2;
- pos = S.indexOf('>', pos);
- if (pos === -1) {
- if (strict) throw strictError('Unclosed close tag', closeStart - 2);
- pos = S.length;
- stripIgnorableWhitespace(children);
- // Unwind: if we are inside a stacked frame, pop back
- while (stack.length > 0) {
- const frame = stack.pop()!;
- const node: TNode = {
- tagName: currentTagName,
- attributes: frame.attributes,
- children,
- };
- // Restore from stack
- currentTagName = frame.tagName;
- children = frame.children;
- children.push(node);
- stripIgnorableWhitespace(children);
+ // Fast path: exact `` — check '>' right after the name,
+ // then compare the name range against the open tag's name range
+ // (both reads on the flat source string; no allocation, and no
+ // indexOf scan). False GT positives (e.g. `>` vs "name")
+ // fail the char compare and fall through to the slow path.
+ const nameLen = currentTagName.length;
+ let matched = false;
+ if (src.charCodeAt(closeStart + nameLen) === GT) {
+ let k = 0;
+ while (
+ k < nameLen &&
+ src.charCodeAt(closeStart + k) ===
+ src.charCodeAt(currentNameStart + k)
+ )
+ k++;
+ if (k === nameLen) {
+ pos = closeStart + nameLen; // at the '>'
+ matched = true;
}
- return children;
}
- const closeTag = S.substring(closeStart, pos).trimEnd();
- if (closeTag !== currentTagName) {
- throw strictError(
- `Unexpected close tag ${closeTag}> (expected ${currentTagName}>)`,
- );
+ if (!matched) {
+ // Slow path: trailing whitespace in the close tag, a genuine
+ // mismatch (exact error preserved), or EOF without '>'.
+ pos = src.indexOf('>', pos);
+
+ if (pos === -1) {
+ if (strict)
+ throw strictError('Unclosed close tag', closeStart - 2);
+ pos = src.length;
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(S, children, pendingBase);
+ }
+ // Unwind: if we are inside a stacked frame, pop back
+ while (depth > 0) {
+ depth--;
+ const node: TNode = {
+ tagName: currentTagName,
+ attributes: frameAttributes[depth]!,
+ children,
+ };
+ // Restore from stack
+ currentTagName = frameTagNames[depth]!;
+ children = frameChildren[depth]!;
+ flags = frameFlags[depth]! | HAS_ELEMENT;
+ pendingBase = framePendingBases[depth]!;
+ children[children.length] = node;
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(S, children, pendingBase);
+ }
+ }
+ return children;
+ }
+
+ const closeTag = src.substring(closeStart, pos).trimEnd();
+ if (closeTag !== currentTagName) {
+ throw strictError(
+ `Unexpected close tag ${closeTag}> (expected ${currentTagName}>)`,
+ );
+ }
}
- if (pos + 1) pos += 1;
+ pos += 1;
- stripIgnorableWhitespace(children);
+ // Finalize this children array: drop deferred ignorable ws (never
+ // allocated) when this is a pure element container, otherwise
+ // materialize the deferred runs into real substrings.
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(S, children, pendingBase);
+ }
- if (stack.length === 0) {
+ if (depth === 0) {
// We've closed the root tag — return
return children;
}
// Pop frame: finalize node and add to parent's children
- const frame = stack.pop()!;
+ depth--;
const node: TNode = {
tagName: currentTagName,
- attributes: frame.attributes,
+ attributes: frameAttributes[depth]!,
children,
};
- currentTagName = frame.tagName;
- children = frame.children;
- children.push(node);
+ currentTagName = frameTagNames[depth]!;
+ currentNameStart = frameNameStarts[depth]!;
+ children = frameChildren[depth]!;
+ flags = frameFlags[depth]! | HAS_ELEMENT;
+ pendingBase = framePendingBases[depth]!;
+ children[children.length] = node;
// Handle processing instruction children promotion
if (node.tagName.charCodeAt(0) === QUESTION) {
- children.push(...node.children);
+ const promoted = node.children;
+ for (let i = 0; i < promoted.length; i++) {
+ const promotedChild = promoted[i]!;
+ if (typeof promotedChild === 'string') {
+ if ((flags & HAS_TEXT) === 0) {
+ if (isWsString(promotedChild)) {
+ flags |= HAS_WS_TEXT | HAS_WS_IN_ARRAY;
+ } else {
+ if (pendingCount > pendingBase)
+ materializePending(S, children, pendingBase);
+ flags |= HAS_TEXT;
+ }
+ }
+ children.push(promotedChild);
+ } else {
+ flags |= HAS_ELEMENT;
+ children.push(promotedChild);
+ }
+ }
node.children = [];
}
continue;
- } else if (S.charCodeAt(pos + 1) === BANG) {
- if (S.charCodeAt(pos + 2) === DASH) {
+ } else if (nextCharCode === BANG) {
+ if (src.charCodeAt(pos + 2) === DASH) {
// comment: use indexOf("-->") for fast scanning
const startCommentPos = pos;
- pos = S.indexOf('-->', pos + 3);
+ pos = src.indexOf('-->', pos + 3);
if (pos === -1) {
if (strict)
throw strictError('Unclosed comment', startCommentPos);
- pos = S.length;
+ pos = src.length;
if (keepComments) {
- children.push(S.substring(startCommentPos));
+ // Comment strings start with '<' — always non-whitespace text
+ if (pendingCount > pendingBase)
+ materializePending(S, children, pendingBase);
+ children.push(src.substring(startCommentPos));
+ flags |= HAS_TEXT;
}
} else {
pos += 2; // point to the '>'
if (keepComments) {
- children.push(S.substring(startCommentPos, pos + 1));
+ if (pendingCount > pendingBase)
+ materializePending(S, children, pendingBase);
+ children.push(src.substring(startCommentPos, pos + 1));
+ flags |= HAS_TEXT;
}
}
} else if (
- S.charCodeAt(pos + 2) === LBRACKET &&
- S.charCodeAt(pos + 8) === LBRACKET &&
- S.substring(pos + 3, pos + 8).toLowerCase() === 'cdata'
+ src.charCodeAt(pos + 2) === LBRACKET &&
+ src.charCodeAt(pos + 8) === LBRACKET &&
+ src.substring(pos + 3, pos + 8).toLowerCase() === 'cdata'
) {
- const cdataEndIndex = S.indexOf(']]>', pos);
- if (cdataEndIndex === -1) {
- if (strict) throw strictError('Unclosed CDATA section');
- children.push(S.substring(pos + 9));
- pos = S.length;
- } else {
- children.push(S.substring(pos + 9, cdataEndIndex));
- pos = cdataEndIndex + 3;
+ const cdataEndIndex = src.indexOf(']]>', pos);
+ const cdataEnd = cdataEndIndex === -1 ? srcEnd : cdataEndIndex;
+ if (cdataEndIndex === -1 && strict)
+ throw strictError('Unclosed CDATA section');
+ if ((flags & HAS_TEXT) === 0) {
+ if (isWsRange(pos + 9, cdataEnd)) {
+ flags |= HAS_WS_TEXT | HAS_WS_IN_ARRAY;
+ } else {
+ if (pendingCount > pendingBase)
+ materializePending(S, children, pendingBase);
+ flags |= HAS_TEXT;
+ }
}
+ children.push(src.substring(pos + 9, cdataEnd));
+ pos = cdataEndIndex === -1 ? src.length : cdataEndIndex + 3;
continue;
} else {
// doctype / other declarations: parse as TNode
// Read the declaration keyword (e.g. "!DOCTYPE")
pos += 2; // skip ' | null = null;
- while (pos < S.length) {
- const cc = S.charCodeAt(pos);
+ while (pos < src.length) {
+ const cc = src.charCodeAt(pos);
if (cc === GT || cc === LBRACKET) break;
// Skip whitespace
if (cc <= 32) {
@@ -338,13 +1044,16 @@ export function parse(
}
// Quoted token — capture including quotes as the key
if (cc === SQUOTE || cc === DQUOTE) {
- const closePos = S.indexOf(cc === SQUOTE ? "'" : '"', pos + 1);
+ const closePos = src.indexOf(
+ cc === SQUOTE ? "'" : '"',
+ pos + 1,
+ );
if (closePos === -1) {
if (strict) throw strictError('Unclosed declaration');
- pos = S.length;
+ pos = src.length;
break;
}
- const token = S.substring(pos + 1, closePos);
+ const token = src.substring(pos + 1, closePos);
if (declAttributes === null) declAttributes = {};
setOwnProperty(declAttributes!, token, null);
pos = closePos + 1;
@@ -352,33 +1061,33 @@ export function parse(
}
// Unquoted token
const tokenStart = pos;
- while (pos < S.length) {
- const tc = S.charCodeAt(pos);
+ while (pos < src.length) {
+ const tc = src.charCodeAt(pos);
if (tc <= 32 || tc === GT || tc === LBRACKET) break;
pos++;
}
- const token = S.substring(tokenStart, pos);
+ const token = src.substring(tokenStart, pos);
if (declAttributes === null) declAttributes = {};
setOwnProperty(declAttributes!, token, null);
}
// Skip internal DTD subset ([...]) if present
- if (pos < S.length && S.charCodeAt(pos) === LBRACKET) {
+ if (pos < src.length && src.charCodeAt(pos) === LBRACKET) {
pos++; // skip '['
let insideBracketSection = true;
- while (insideBracketSection && pos < S.length) {
- if (S.charCodeAt(pos) === RBRACKET) {
+ while (insideBracketSection && pos < src.length) {
+ if (src.charCodeAt(pos) === RBRACKET) {
insideBracketSection = false;
} else {
// Skip quoted strings inside internal DTD subset
- const quoteCharCode = S.charCodeAt(pos);
+ const quoteCharCode = src.charCodeAt(pos);
if (quoteCharCode === SQUOTE || quoteCharCode === DQUOTE) {
- pos = S.indexOf(
+ pos = src.indexOf(
quoteCharCode === SQUOTE ? "'" : '"',
pos + 1,
);
if (pos === -1) {
- pos = S.length;
+ pos = src.length;
break;
}
}
@@ -386,10 +1095,10 @@ export function parse(
pos++;
}
// Skip any whitespace between ] and >
- while (pos < S.length && S.charCodeAt(pos) <= 32) pos++;
+ while (pos < src.length && src.charCodeAt(pos) <= 32) pos++;
}
- if (strict && (pos >= S.length || S.charCodeAt(pos) !== GT))
+ if (strict && (pos >= src.length || src.charCodeAt(pos) !== GT))
throw strictError('Unclosed declaration');
children.push({
@@ -397,18 +1106,23 @@ export function parse(
attributes: declAttributes,
children: [],
} as TNode);
+ flags |= HAS_ELEMENT;
}
pos++;
continue;
}
// ---- Open tag (inline parseNode logic) ----
pos++;
+ // First char of the name read from the flat source string (the
+ // extracted tagName may be a SlicedString with slower charCodeAt)
+ const tagFirstCharCode = nextCharCode;
+ const tagNameStart = pos;
const tagName = parseName();
let attributes: Record | null = null;
// parsing attributes
- while (S.charCodeAt(pos) !== GT && S[pos]) {
- let charCode = S.charCodeAt(pos);
+ while (pos < srcEnd && src.charCodeAt(pos) !== GT) {
+ let charCode = src.charCodeAt(pos);
if (
(charCode > 64 && charCode < 91) ||
(charCode > 96 && charCode < 123) ||
@@ -417,7 +1131,7 @@ export function parse(
charCode > 127
) {
const name = parseName();
- let code = S.charCodeAt(pos);
+ let code = src.charCodeAt(pos);
while (
code &&
code !== SQUOTE &&
@@ -432,30 +1146,39 @@ export function parse(
code !== GT
) {
pos++;
- code = S.charCodeAt(pos);
+ code = src.charCodeAt(pos);
}
let value: string | null;
if (code === SQUOTE || code === DQUOTE) {
value = parseString();
if (pos === -1) {
- // Unterminated attribute string — emit node with what we have
+ // Unterminated attribute string — emit node with what we
+ // have. No whitespace stripping happens on this path, so
+ // deferred ws runs are materialized at every level.
+ if (pendingCount > pendingBase)
+ materializePending(S, children, pendingBase);
const node: TNode = { tagName, attributes, children: [] };
- children.push(node);
+ children[children.length] = node;
// Unwind remaining stack frames
- while (stack.length > 0) {
- const frame = stack.pop()!;
+ while (depth > 0) {
+ depth--;
const parent: TNode = {
tagName: currentTagName,
- attributes: frame.attributes,
+ attributes: frameAttributes[depth]!,
children,
};
- currentTagName = frame.tagName;
- children = frame.children;
+ currentTagName = frameTagNames[depth]!;
+ children = frameChildren[depth]!;
+ pendingBase = framePendingBases[depth]!;
+ if (pendingCount > pendingBase)
+ materializePending(S, children, pendingBase);
children.push(parent);
}
return children;
}
- if (decode) value = decode(value);
+ // decode of a string without '&' is identity, skip the call
+ if (decode !== null && value.indexOf('&') !== -1)
+ value = decode(value);
} else {
value = null;
pos--;
@@ -465,123 +1188,279 @@ export function parse(
}
pos++;
}
- if (strict && !S[pos]) {
+ if (strict && pos >= srcEnd) {
throw strictError(`Unclosed tag <${tagName}>`);
}
// Determine if this node has children or is self-closing
if (
- S.charCodeAt(pos - 1) !== SLASH &&
- S.charCodeAt(pos - 1) !== QUESTION &&
- tagName.charCodeAt(0) !== BANG
+ src.charCodeAt(pos - 1) !== SLASH &&
+ src.charCodeAt(pos - 1) !== QUESTION &&
+ tagFirstCharCode !== BANG
) {
if (rawContentSet !== null && rawContentSet.has(tagName)) {
// Raw content tag
const closeTagStr = '' + tagName + '>';
const start = pos + 1;
- pos = S.indexOf(closeTagStr, start);
+ pos = src.indexOf(closeTagStr, start);
let rawChildren: (TNode | string)[];
if (pos === -1) {
if (strict) throw strictError(`Unclosed tag <${tagName}>`);
- rawChildren = [S.substring(start)];
- pos = S.length;
+ rawChildren = [src.substring(start)];
+ pos = src.length;
} else {
- rawChildren = [S.substring(start, pos)];
+ rawChildren = [src.substring(start, pos)];
pos += closeTagStr.length;
}
const node: TNode = { tagName, attributes, children: rawChildren };
- children.push(node);
- if (tagName.charCodeAt(0) === QUESTION) {
- children.push(...node.children);
+ children[children.length] = node;
+ flags |= HAS_ELEMENT;
+ if (tagFirstCharCode === QUESTION) {
+ const promoted = node.children;
+ for (let i = 0; i < promoted.length; i++) {
+ const promotedChild = promoted[i]!;
+ if (typeof promotedChild === 'string') {
+ if ((flags & HAS_TEXT) === 0) {
+ if (isWsString(promotedChild)) {
+ flags |= HAS_WS_TEXT | HAS_WS_IN_ARRAY;
+ } else {
+ if (pendingCount > pendingBase)
+ materializePending(S, children, pendingBase);
+ flags |= HAS_TEXT;
+ }
+ }
+ children.push(promotedChild);
+ } else {
+ flags |= HAS_ELEMENT;
+ children.push(promotedChild);
+ }
+ }
node.children = [];
}
} else if (selfClosingSet === null || !selfClosingSet.has(tagName)) {
// Node has children — push frame and descend
pos++;
- stack.push({ tagName: currentTagName, attributes, children });
+ frameTagNames[depth] = currentTagName;
+ frameAttributes[depth] = attributes;
+ frameChildren[depth] = children;
+ frameFlags[depth] = flags;
+ frameNameStarts[depth] = currentNameStart;
+ framePendingBases[depth] = pendingBase;
+ depth++;
currentTagName = tagName;
+ currentNameStart = tagNameStart;
children = [];
+ flags = 0;
+ pendingBase = pendingCount;
} else {
// Self-closing tag (from selfClosingTags list)
pos++;
const node: TNode = { tagName, attributes, children: [] };
- children.push(node);
- if (tagName.charCodeAt(0) === QUESTION) {
- children.push(...node.children);
- node.children = [];
- }
+ children[children.length] = node;
+ flags |= HAS_ELEMENT;
+ // PI promotion is a no-op here: node.children is always empty
}
} else {
// Explicit self-closing (/>) or processing instruction (?>) or declaration
pos++;
const node: TNode = { tagName, attributes, children: [] };
- children.push(node);
- if (tagName.charCodeAt(0) === QUESTION) {
- children.push(...node.children);
- node.children = [];
- }
+ children[children.length] = node;
+ flags |= HAS_ELEMENT;
+ // PI promotion is a no-op here: node.children is always empty
}
} else {
+ // ---- Text (inline parseText) ----
const textStart = pos;
- let text = parseText();
- if (decode) text = decode(text);
- if (
- strict &&
- stack.length === 0 &&
- currentTagName === '' &&
- text.trim().length > 0
- ) {
- throw strictError('Text content outside of a tag', textStart);
+ if (decode === null && !trimWhitespace) {
+ // Fused scan + classification: walk the (typically short)
+ // leading whitespace in JS; the first non-ws char both settles
+ // the classification and hands the rest of the scan to the
+ // SIMD indexOf. Ws-only runs never call indexOf and are
+ // deferred as index pairs (substring allocated only if kept).
+ let textEnd: number;
+ let wsOnly = true;
+ let needExact = false;
+ let j = pos;
+ for (;;) {
+ if (j >= srcEnd) {
+ textEnd = srcEnd;
+ pos = srcEnd + 1; // matches original post-increment past EOF
+ break;
+ }
+ const c = src.charCodeAt(j);
+ if (c === LT) {
+ textEnd = j;
+ pos = j;
+ break;
+ }
+ if (c === 32 || (c >= 9 && c <= 13)) {
+ j++;
+ continue;
+ }
+ // Non-ws (or possibly Unicode ws if >= 128): classification
+ // settled (or needs the exact fallback); finish with indexOf
+ if (c >= 128) needExact = true;
+ wsOnly = false;
+ const idx = src.indexOf('<', j + 1);
+ if (idx === -1) {
+ textEnd = srcEnd;
+ pos = srcEnd + 1;
+ } else {
+ textEnd = idx;
+ pos = idx;
+ }
+ break;
+ }
+ if (needExact) {
+ // A non-ASCII char was seen before any ASCII non-ws: defer to
+ // trim() for exact Unicode-whitespace semantics
+ wsOnly = src.substring(textStart, textEnd).trim().length === 0;
+ }
+ if (strict && depth === 0 && currentTagName === '' && !wsOnly) {
+ throw strictError('Text content outside of a tag', textStart);
+ }
+ if ((flags & HAS_TEXT) !== 0) {
+ // Mixed content already established — push directly
+ children[children.length] = src.substring(textStart, textEnd);
+ } else if (wsOnly) {
+ // Defer: record the range, allocate only if the container
+ // turns out to keep its whitespace
+ pendStart[pendingCount] = textStart;
+ pendEnd[pendingCount] = textEnd;
+ pendAt[pendingCount] = children.length;
+ pendingCount++;
+ flags |= HAS_WS_TEXT;
+ } else {
+ if (pendingCount > pendingBase)
+ materializePending(S, children, pendingBase);
+ children[children.length] = src.substring(textStart, textEnd);
+ flags |= HAS_TEXT;
+ }
+ continue;
+ }
+ let textEnd = src.indexOf('<', pos);
+ if (textEnd === -1) {
+ textEnd = srcEnd;
+ pos = srcEnd + 1; // matches the original post-increment past EOF
+ } else {
+ pos = textEnd;
}
- if (trimWhitespace) {
- const trimmed = text.trim();
- if (trimmed.length > 0) {
- children.push(trimmed);
+ if (decode === null) {
+ // No decoding, trimWhitespace mode (the non-trim case took the
+ // fused path above).
+ if (strict && depth === 0 && currentTagName === '') {
+ if (!isWsRange(textStart, textEnd)) {
+ throw strictError('Text content outside of a tag', textStart);
+ }
+ }
+ // Fused trim: compute bounds on the source, substring once;
+ // ws-only runs allocate nothing. Falls back to substring+trim
+ // when a non-ASCII char sits at a boundary (Unicode whitespace).
+ let trimStart = textStart;
+ let trimLast = textEnd - 1;
+ let exact = true;
+ while (trimStart <= trimLast) {
+ const c = src.charCodeAt(trimStart);
+ if (c === 32 || (c >= 9 && c <= 13)) {
+ trimStart++;
+ continue;
+ }
+ if (c >= 128) exact = false;
+ break;
+ }
+ if (exact && trimStart <= trimLast) {
+ while (trimLast > trimStart) {
+ const c = src.charCodeAt(trimLast);
+ if (c === 32 || (c >= 9 && c <= 13)) {
+ trimLast--;
+ continue;
+ }
+ if (c >= 128) exact = false;
+ break;
+ }
+ }
+ if (!exact) {
+ const trimmed = src.substring(textStart, textEnd).trim();
+ if (trimmed.length > 0) {
+ children.push(trimmed);
+ flags |= HAS_TEXT;
+ }
+ } else if (trimStart <= trimLast) {
+ children.push(src.substring(trimStart, trimLast + 1));
+ flags |= HAS_TEXT;
}
} else {
- if (text.length > 0) {
- children.push(text);
+ // Entity decoding active: classification must look at the
+ // decoded string, so no deferral on this path. decode of a
+ // string without '&' is identity, so gate on indexOf.
+ let text = src.substring(textStart, textEnd);
+ if (text.indexOf('&') !== -1) text = decode(text);
+ if (
+ strict &&
+ depth === 0 &&
+ currentTagName === '' &&
+ text.trim().length > 0
+ ) {
+ throw strictError('Text content outside of a tag', textStart);
+ }
+ if (trimWhitespace) {
+ const trimmed = text.trim();
+ if (trimmed.length > 0) {
+ children.push(trimmed);
+ flags |= HAS_TEXT;
+ }
+ } else if (text.length > 0) {
+ if ((flags & HAS_TEXT) === 0) {
+ flags |= isWsString(text)
+ ? HAS_WS_TEXT | HAS_WS_IN_ARRAY
+ : HAS_TEXT;
+ }
+ children[children.length] = text;
}
}
- pos++;
}
}
// If we exit the loop for a named tag, input ended without a close tag
if (strict && currentTagName !== '') {
throw strictError(`Unclosed tag <${currentTagName}>`);
}
- stripIgnorableWhitespace(children);
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(S, children, pendingBase);
+ }
// Unwind any remaining stack frames (unclosed tags in non-strict mode)
- while (stack.length > 0) {
- const frame = stack.pop()!;
+ while (depth > 0) {
+ depth--;
const node: TNode = {
tagName: currentTagName,
- attributes: frame.attributes,
+ attributes: frameAttributes[depth]!,
children,
};
- currentTagName = frame.tagName;
- children = frame.children;
- children.push(node);
- stripIgnorableWhitespace(children);
+ currentTagName = frameTagNames[depth]!;
+ children = frameChildren[depth]!;
+ flags = frameFlags[depth]! | HAS_ELEMENT;
+ pendingBase = framePendingBases[depth]!;
+ children[children.length] = node;
+ if ((flags & KIND_MASK) === STRIP_STATE) {
+ pendingCount = pendingBase;
+ if (flags & HAS_WS_IN_ARRAY) compactWhitespace(children);
+ } else if (pendingCount > pendingBase) {
+ materializePending(S, children, pendingBase);
+ }
}
return children;
}
- function parseText(): string {
- const start = pos;
- pos = S.indexOf('<', pos) - 1;
- if (pos === -2) pos = S.length;
- return S.substring(start, pos + 1);
- }
-
function parseName(): string {
const start = pos;
+ // charCode > 62 covers letters and all non-ASCII in one compare; the
+ // table is only consulted for low codes (digits, '-', '.', ':', and
+ // the enders). Past-end charCodeAt yields NaN: NaN > 62 is false and
+ // NAME_END[NaN] is undefined !== 0, which terminates the loop.
let charCode = S.charCodeAt(pos);
- while (
- charCode < 128
- ? NAME_END[charCode] === 0
- : charCode === charCode /* not NaN = not past end */
- ) {
+ while (charCode > 62 || NAME_END[charCode] === 0) {
charCode = S.charCodeAt(++pos);
}
return S.substring(start, pos);
@@ -589,6 +1468,7 @@ export function parse(
function parseNode(): TNode {
pos++;
+ const tagNameStart = pos;
const tagName = parseName();
// Defer attributes allocation until first attribute is found
let attributes: Record | null = null;
@@ -630,7 +1510,9 @@ export function parse(
if (pos === -1) {
return { tagName, attributes, children };
}
- if (decode) value = decode(value);
+ // decode of a string without '&' is identity, skip the call
+ if (decode !== null && value.indexOf('&') !== -1)
+ value = decode(value);
} else {
value = null;
pos--;
@@ -666,7 +1548,7 @@ export function parse(
}
} else if (selfClosingSet === null || !selfClosingSet.has(tagName)) {
pos++;
- children = parseChildren(tagName);
+ children = parseChildren(tagName, tagNameStart);
} else {
pos++;
}
@@ -702,30 +1584,37 @@ export function parse(
let out: (TNode | string)[] | TNode;
- if (resolvedOptions.attrValue !== undefined) {
- resolvedOptions.attrName = resolvedOptions.attrName || 'id';
- const results: (TNode | string)[] = [];
+ try {
+ if (resolvedOptions.attrValue !== undefined) {
+ resolvedOptions.attrName = resolvedOptions.attrName || 'id';
+ const results: (TNode | string)[] = [];
- while ((pos = findElements()) !== -1) {
- pos = S.lastIndexOf('<', pos);
- if (pos !== -1) {
- results.push(parseNode());
+ while ((pos = findElements()) !== -1) {
+ pos = S.lastIndexOf('<', pos);
+ if (pos !== -1) {
+ results.push(parseNode());
+ }
+ S = S.slice(pos);
+ srcLength = S.length;
+ pos = 0;
}
- S = S.slice(pos);
- pos = 0;
- }
- out = results;
- } else if (resolvedOptions.parseNode) {
- out = parseNode();
- } else {
- out = parseChildren('');
- // In strict mode, reject non-whitespace text after the root element
- if (strict && pos < S.length) {
- const trailing = S.substring(pos);
- if (trailing.trim().length > 0) {
- throw strictError('Text content outside of a tag');
+ out = results;
+ } else if (resolvedOptions.parseNode) {
+ out = parseNode();
+ } else {
+ out = parseChildren('', 0);
+ // In strict mode, reject non-whitespace text after the root element
+ if (strict && pos < S.length) {
+ const trailing = S.substring(pos);
+ if (trailing.trim().length > 0) {
+ throw strictError('Text content outside of a tag');
+ }
}
}
+ } finally {
+ // Drop tree/tagName references held by the module-level scratch stack
+ // so no parsed data outlives this call.
+ clearScratch();
}
if (resolvedOptions.filter && Array.isArray(out)) {