From 8fe2cd215f1d25ce87b56c30113ce5e1c2f650e2 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Sun, 19 Jul 2026 23:39:18 +0100 Subject: [PATCH 1/2] demo: sync parser stepper docs with the dual-path parser The stepper described itself as a direct copy of parseChildren, but the default algorithm now lives in parseRootFast with parseChildren as the general path. Behavior was verified unchanged against ten probes (mismatch throws, unwind recoveries, CDATA case-insensitivity, DOCTYPE subsets, PI handling, whitespace semantics), so the steps themselves stay accurate. The header now explains the two implementations and the fast path's mechanical differences, stale line-number cross-references are replaced with function and branch names that survive refactors, and the whitespace step description documents the drop rule. --- demo/app/utils/parser-stepper.ts | 79 +++++++++++++++++++------------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/demo/app/utils/parser-stepper.ts b/demo/app/utils/parser-stepper.ts index 1eae7b1..0b8c957 100644 --- a/demo/app/utils/parser-stepper.ts +++ b/demo/app/utils/parser-stepper.ts @@ -2,13 +2,28 @@ * A stepping XML parser that yields at each interesting point in the parse, * designed for the visualisation demo. * - * This is a direct copy of the real parser's parseChildren loop (parser.ts) - * with snap() calls inserted at each interesting point. Features not needed - * for visualisation (strict mode, decode, trimWhitespace, selfClosingTags, + * The real parser (eksml/src/parser.ts) has two implementations of the same + * algorithm: `parseRootFast` (the specialized default-options path) and + * `parseChildren` inside the general path. Both make identical scanning + * decisions and produce identical trees; they differ only in mechanics + * (the fast path defers whitespace-only text as index ranges, verifies + * close tags with an offset range-compare instead of a substring, and has + * an inline name="value" attribute shortcut). This stepper mirrors the + * shared algorithm, following the general path's structure because it is + * the easier of the two to read alongside; step descriptions call out the + * fast-path mechanics where they differ. Features not needed for + * visualisation (strict mode, decode, trimWhitespace, selfClosingTags, * rawContentTags, keepComments, filter) are stripped. * - * When the real parser changes, this file should be updated to match by - * re-copying and re-adding the snap() calls. + * When the real parser's ALGORITHM changes (what is scanned, matched, or + * emitted — not micro-optimizations), this file must be updated to match. + * Cross-references below name functions and branches rather than line + * numbers so they survive refactors. Behavioral contract last verified + * against parser.ts @ perf(parser) fast-path rewrite: mismatched close + * tags throw even in non-strict mode; unclosed constructs recover by + * unwinding; CDATA matching is case-insensitive; comments are dropped + * unless keepComments; DOCTYPE internal subsets are skipped; PIs + * self-close; whitespace-only text is dropped in element-only containers. */ export type Phase = @@ -184,7 +199,7 @@ export function generateSteps(S: string): Step[] { while (S[pos]) { if (S.charCodeAt(pos) === LT) { if (S.charCodeAt(pos + 1) === SLASH) { - // ---- Close tag ---- (parser.ts line ~224) + // ---- Close tag ---- (parseChildren: close-tag branch; parseRootFast verifies the name with an offset range-compare instead of a substring) const closeStart = pos + 2; const startPos = pos; @@ -204,7 +219,7 @@ export function generateSteps(S: string): Step[] { }, ); - // Unwind entire stack (mirrors parser.ts line ~234-247) + // Unwind entire stack (mirrors parseChildren: unclosed-close-tag unwind) while (stack.length > 0) { stack.pop(); nodesEmitted++; @@ -220,7 +235,7 @@ export function generateSteps(S: string): Step[] { const closeTag = S.substring(closeStart, pos).trimEnd(); - // Real parser always throws on mismatch (parser.ts line ~251-254) + // Real parser always throws on mismatch (parseChildren & parseRootFast: mismatch check) // even in non-strict mode. Show this as an error step. const expectedTag = stack[stack.length - 1]?.tagName ?? ''; @@ -240,7 +255,7 @@ export function generateSteps(S: string): Step[] { currentTagName: closeTag, }); - // Pop the stack (mirrors parser.ts line ~267) + // Pop the stack (mirrors parseChildren: frame pop) if (stack.length > 0) { stack.pop(); } @@ -253,11 +268,11 @@ export function generateSteps(S: string): Step[] { nodesEmitted++; } - if (pos + 1) pos += 1; // skip > (mirrors parser.ts line 257) + if (pos + 1) pos += 1; // skip > (mirrors parseChildren: skip >) continue; } else if (S.charCodeAt(pos + 1) === BANG) { if (S.charCodeAt(pos + 2) === DASH) { - // ---- Comment ---- (parser.ts line ~283) + // ---- Comment ---- (parseChildren: comment branch) const startCommentPos = pos; pos = S.indexOf('-->', pos + 3); @@ -282,14 +297,14 @@ export function generateSteps(S: string): Step[] { commentParent.textCount++; } - pos++; // mirrors parser.ts line 400 + pos++; // mirrors parseChildren: trailing pos++ continue; } else if ( S.charCodeAt(pos + 2) === LBRACKET && S.charCodeAt(pos + 8) === LBRACKET && S.substring(pos + 3, pos + 8).toLowerCase() === 'cdata' ) { - // ---- CDATA ---- (parser.ts line ~300) + // ---- CDATA ---- (parseChildren: CDATA branch — case-insensitive match) const cdataStart = pos; const cdataEndIndex = S.indexOf(']]>', pos); @@ -312,10 +327,10 @@ export function generateSteps(S: string): Step[] { cdataParent.textCount++; } - continue; // mirrors parser.ts line 314 (continue skips line 400 pos++) + continue; // mirrors parseChildren: CDATA continue (skips the trailing pos++) } else { // ---- Declaration (, ) ---- - // (parser.ts line ~316) + // (parseChildren: declaration branch) const declStart = pos; pos += 2; // skip ') ---- - // (parser.ts line ~403) + // (parseChildren: open-tag name scan) const tagStart = pos; snap('open-tag-start', 'Found < — beginning of tag', [pos, pos + 1]); @@ -492,7 +507,7 @@ export function generateSteps(S: string): Step[] { let attributes: Record | null = null; - // ---- Attribute loop ---- (parser.ts line ~409) + // ---- Attribute loop ---- (parseChildren: attribute loop; parseRootFast adds an inline name="value" shortcut) // Copied verbatim: while (S.charCodeAt(pos) !== GT && S[pos]) while (S.charCodeAt(pos) !== GT && S[pos]) { const charCode = S.charCodeAt(pos); @@ -519,7 +534,7 @@ export function generateSteps(S: string): Step[] { }, ); - // search beginning of the string (parser.ts line ~419) + // search beginning of the string (parseChildren: attr-name scan) let code = S.charCodeAt(pos); while ( @@ -548,7 +563,7 @@ export function generateSteps(S: string): Step[] { if (pos === -1) { // Unterminated attribute string — emit node with what we have - // and unwind entire stack (mirrors parser.ts line ~439-455) + // and unwind entire stack (mirrors parseChildren: unterminated-attribute unwind) if (attributes === null) attributes = Object.create(null) as Record< string, @@ -598,7 +613,7 @@ export function generateSteps(S: string): Step[] { ); } else { value = null; - pos--; // (parser.ts line 460) + pos--; // (parseChildren: boolean-attribute backtrack) } if (attributes === null) @@ -606,11 +621,11 @@ export function generateSteps(S: string): Step[] { attributes[name] = value; } - pos++; // unconditional (parser.ts line 465) + pos++; // unconditional (parseChildren: unconditional pos++) } // After the attribute loop, pos is at '>' (or past end) - // (parser.ts line ~471) + // (parseChildren: self-closing/children decision) // Determine if this node has children or is self-closing if ( @@ -619,7 +634,7 @@ export function generateSteps(S: string): Step[] { tagName.charCodeAt(0) !== BANG ) { // Node has children — push frame and descend - // (parser.ts line ~497: selfClosingSet check omitted for visualiser) + // (parseChildren: descend into children; selfClosingSet check omitted for visualiser) snap( 'open-tag-end', `Opened <${tagName}> — descending into children`, @@ -627,7 +642,7 @@ export function generateSteps(S: string): Step[] { { currentTagName: tagName, currentAttributes: attributes ?? {} }, ); - pos++; // skip > (parser.ts line 499) + pos++; // skip > (parseChildren: skip >) stack.push({ tagName, attributes: attributes ?? {}, @@ -636,7 +651,7 @@ export function generateSteps(S: string): Step[] { }); } else { // Explicit self-closing (/>) or processing instruction (?>) or declaration - // (parser.ts line ~513) + // (parseChildren: self-close/PI emit) const isPI = tagName.charCodeAt(0) === QUESTION; const label = isPI ? `Processing instruction: <${tagName}?>` @@ -647,7 +662,7 @@ export function generateSteps(S: string): Step[] { currentAttributes: attributes ?? {}, }); - pos++; // skip > (parser.ts line 515) + pos++; // skip > (parseChildren: skip >) const parent = stack[stack.length - 1]; @@ -658,7 +673,7 @@ export function generateSteps(S: string): Step[] { } } } else { - // ---- Text content ---- (parser.ts line ~523) + // ---- Text content ---- (parseChildren: text branch; parseRootFast fuses the scan with ws classification) const textStart = pos; const text = parseText(); @@ -667,7 +682,7 @@ export function generateSteps(S: string): Step[] { const label = trimmed.length > 0 ? `Text: "${truncate(trimmed, 50)}"` - : 'Whitespace text node'; + : 'Whitespace-only text — dropped later unless its container is text-only'; snap(trimmed.length > 0 ? 'reading-text' : 'scanning', label, [ textStart, @@ -687,12 +702,12 @@ export function generateSteps(S: string): Step[] { } } - pos++; // advance to '<' (parser.ts line 536) + pos++; // advance to '<' (parseChildren: advance to <) } } // Unwind any remaining stack frames — unclosed tags - // (mirrors parser.ts line ~544-556) + // (mirrors parseChildren: end-of-input unwind) while (stack.length > 0) { stack.pop(); nodesEmitted++; From c0739a274f75db4b50920f67ae8ad92facea3f19 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Sun, 19 Jul 2026 23:39:21 +0100 Subject: [PATCH 2/2] demo: fix import order lint in generated entry files pnpm lint failed on main for app-ssr.ts and entry.ts, unrelated to the stepper change. --- demo/app/app-ssr.ts | 2 ++ demo/app/entry.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/demo/app/app-ssr.ts b/demo/app/app-ssr.ts index 001ebd7..3033ab7 100644 --- a/demo/app/app-ssr.ts +++ b/demo/app/app-ssr.ts @@ -1,7 +1,9 @@ import PageTitleService from 'ember-page-title/services/page-title'; import Application from 'ember-strict-application-resolver'; + import config from './config.ts'; import Router from './router.ts'; + export { settled } from '@ember/test-helpers'; class App extends Application { diff --git a/demo/app/entry.ts b/demo/app/entry.ts index fb30b71..1835aab 100644 --- a/demo/app/entry.ts +++ b/demo/app/entry.ts @@ -1,5 +1,6 @@ +import { bootRehydrated } from 'vite-ember-ssr/client'; + import Application from './app.ts'; import config from './config.ts'; -import { bootRehydrated } from 'vite-ember-ssr/client'; bootRehydrated(Application, config);