Contract at a glance
flowchart TD MD["Markdown string"] --> REMARK["unified<br/>remark-parse + YAML frontmatter"] REMARK --> AST["remark AST"] AST --> HEADINGS["Heading tree"] --> SECTIONS["Sections"] AST --> CONTENT["Images · pipe tables · code<br/>optional text"] --> FRAMES["Frames"] AST --> CALLOUTS["warning / note callouts"] --> FINDINGS["section.findings"] SECTIONS --> DECK["FrameDeck"] FRAMES --> DECK FINDINGS --> DECK MD -.->|malformed input that triggers failure| FALLBACK["Well-formed FrameDeck<br/>zero sections"]
src/lib/mdframes/index.ts exports the only implementation, parse(md, opts?) (index.ts:192-197). The 2026-08-21 UI polish commit 09fa602 did not touch this parser, its output or its fixture. Gate result on that date: npx vitest run passed 33 of 33 cases in the one test file src/lib/mdframes/mdframes.test.ts; Vitest discovery remains configured at vite.config.ts:17-19.
Citation shorthand: index.ts, types.ts and mdframes.test.ts mean the files under src/lib/mdframes/.
export function parse(md: string, opts?: ParseOptions): FrameDeck
export interface ParseOptions { includeText?: boolean; id?: string; source?: string }The pipeline uses remark-parse plus YAML-only remark-frontmatter (index.ts:203-215). One GithubSlugger per call makes duplicate-heading suffixes document-scoped.
Output shape
Types live in src/lib/mdframes/types.ts:1-15.
| Object | Fields | Contract |
|---|---|---|
FrameDeck.deck | id, title, source, createdAt, meta | id is opts.id or the title slug; title precedence is non-empty frontmatter title, first H1, source basename, Untitled; source is verbatim or empty; createdAt is an ISO parse-time stamp; meta holds coerced top-level frontmatter (index.ts:464-476). |
FrameDeck.sections | Section[] | Document order. |
Section | id, title, level, tags, findings, frames | GitHub-slug id; flattened heading; heading depth, or 0 for the implicit section; tags and findings in source order; ordered frames (index.ts:348-371). |
Frame | id, seq, type, src?, alt?, caption?, sub?, tags, badges, line | Id is `${sectionId}/${seq}`; seq is contiguous and 1-based per section; type is image|table|code|text; line is the producing node’s 1-based source line (index.ts:225-240). |
Construct rules
| Markdown construct | What it becomes | Notes |
|---|---|---|
| YAML frontmatter | deck.meta; possibly deck.title | Top-level keys only. Indented and commented lines are skipped. Booleans, null/~, quoted strings, bracketed lists and numbers are coerced; other values remain strings (index.ts:131-175). |
| Heading | A new Section; later frames attach to it | Id is a GitHub slug; duplicates gain -1, -2, and so on. Inline-code chips plus a trailing parenthetical become first-seen, deduplicated tags (index.ts:252-265, 348-371; mdframes.test.ts:261-275). |
| H1 | Section plus title candidate | The first depth-1 heading wins even when its text is empty. An empty winner resolves to Untitled rather than using the source basename or promoting a later H1 (index.ts:365-370, 464-470). |
| Image | image frame | Carries src, non-empty alt, and markdown title as caption (index.ts:373-381). |
Immediately trailing <sub> | Enriches the preceding image | Becomes sub; middle-dot chips become tags; selected entities decode; ⚠️ finding case-insensitively or bare finding adds one finding badge (index.ts:433-457). |
> [!warning] or > [!note] | Entries in enclosing section.findings | Callouts do not become frames. List items win; otherwise body lines after the marker; otherwise the marker title is the fallback (index.ts:288-335). |
| Top-level GFM-style pipe table | table frame | Detected by raw paragraph sniffing because remark-gfm is absent; caption is the whole raw markdown block (index.ts:267-286, 403-412). |
AST table node | table frame | Unreachable with today’s dependencies, retained so adding a GFM plugin remains correct (index.ts:13-18, 391-398). |
| Image inside a detected table | A second image frame after the table frame | Table detection does not consume paragraph children (index.ts:408-411). |
| Fenced code | code frame | Raw node.value goes in caption; fence language, when present, is tags[0] (index.ts:383-390). |
| Top-level paragraph | Optional text frame | Only with includeText, and only when that paragraph produced no other frame (index.ts:414-422). |
| Content before the first heading | Implicit level-0 section | Emitted only if it contains a frame (index.ts:217-220, 472-477). |
Exhaustive edge rules and pinned cases
Callouts and image chips
Callout extraction has three tiers. A list contributes one finding per item. Without a list, each non-empty body line after the marker contributes one. If neither path yields text, the callout title is retained, so a one-line callout is not lost (index.ts:296-333; mdframes.test.ts:313-318).
<sub> attachment is an anchored offset window, not open-ended lookahead. A reverse pass slices from each image end to the next frame-producing node start, then matches only a <sub> at that slice’s beginning. Intervening prose, a heading or another image prevents attachment (index.ts:20-25, 433-461); the negative case is pinned at mdframes.test.ts:334-339. Decoded entities are amp, lt, gt, quot, nbsp, #39 and #x27 (index.ts:80-89).
Tables and captions
remark-gfm is not a dependency (package.json:29-36), so plain remark emits a pipe table as a top-level paragraph. Detection requires a pipe in the first line and a GFM delimiter pattern in the second (index.ts:68, 267-280).
For table and code, the payload itself lives in caption; src, alt and sub stay undefined. Images instead point through src, with optional alt and markdown-title caption (index.ts:5-11). Tests pin raw table headers, undefined table src, and raw code (mdframes.test.ts:130-136, 294-311). FrameCard.svelte renders table and code payloads from caption (src/lib/components/FrameCard.svelte).
Never throws
Nothing touches md or opts before the try; even a throwing property accessor is caught. A pipeline failure returns a valid deck with zero sections, an ISO createdAt, empty meta, and the best identity resolved before failure (index.ts:192-207, 479-490). Empty input specifically returns title Untitled and zero sections (mdframes.test.ts:181-193). Binary-like null bytes, replacement characters and unbalanced delimiters do not escape; frameless prose is dropped unless includeText creates one level-0 text frame (mdframes.test.ts:195-224).
CLI
scripts/mdframes.mjs imports the same parse; it duplicates no parser logic (scripts/mdframes.mjs:7-13).
usage: node --experimental-strip-types scripts/mdframes.mjs <file.md> [--json-out <path>] [--include-text]| Case | Behavior |
|---|---|
| Plain Node 22.16 | An initial ERR_UNKNOWN_FILE_EXTENSION re-execs once with --experimental-strip-types; MDFRAMES_RESPAWNED prevents loops. Node 22.18+ needs no fallback (scripts/mdframes.mjs:61-88). |
| Bad arguments or input | Unknown flags, missing file, missing --json-out path, unreadable path or non-file exit 1; usage errors print the usage line (scripts/mdframes.mjs:29-58, 127-139). |
--json-out | Creates parent directories, writes pretty JSON, then prints target path and byte count (scripts/mdframes.mjs:151-161). |
| Default output | Prints aligned id, title, sections, level/type counts, findings and badges; deck id defaults to the source basename without extension (scripts/mdframes.mjs:102-121, 145-164). |
Observed on Node v22.16.0 against the frozen fixture: id qwizz-tour; title 🎞️ QuizWizz UI/UX Feedback Tour — Complete Shot Gallery; 76 sections (11 level 2, 65 level 3); 324 frames (321 image, 2 table, 1 code); 139 findings; 66 finding badges.
Frozen fixture
src/lib/mdframes/fixtures/qwizz-tour.md is a 1,619-line byte snapshot with md5 a15fb4f0ffb6d07e8256402563193d15. The test header records both values and treats every constant as measured ground truth (mdframes.test.ts:1-10).
| Quantity | Frozen value | Assertion |
|---|---|---|
| Sections | 76 | mdframes.test.ts:54-56 |
| Heading levels | 11 at level 2; 65 at level 3; 0 at levels 1 and 0 | mdframes.test.ts:58-64 |
| Frames | 324 | mdframes.test.ts:93-99 |
| Images | 321: 316 under /shots/, 5 under /boards/ | mdframes.test.ts:66-72 |
| Other frame types | 2 tables; 1 code tagged yaml; 0 text by default | mdframes.test.ts:93-99 |
| Finding-badged images | 66 | mdframes.test.ts:75-77 |
Image <sub> values | 321 non-empty; zero empty strings | mdframes.test.ts:79-84 |
| Section findings | 139 total; every one of 65 level-3 sections has at least one | mdframes.test.ts:158-171 |
The fixture also pins every image’s first tag as its .png filename, contiguous per-section sequence and ids, all eleven level-2 titles in document order, source lines within 1–1,619, and includeText adding text without changing the 321 images (mdframes.test.ts:86-91, 112-114, 138-156, 174-178).
It is frozen because it captured the 2026-08-20 WRDP gallery before later edits. Re-baselining against a newer source would turn the regression detector into a tautology; disagreement means parser behavior changed, not that constants should move (mdframes.test.ts:4-6).
Keep fixture and live-deck numbers separate. The live deck qwizz-2026-08-20 has 76 sections, 323 frames and 320 images; the 2026-08-20 evidence measurement further split those images into 315 shots and 5 boards, with 65 finding badges. One withdrawn fixture frame explains the delta: v01-d-p02-player-intro.png remains at src/lib/mdframes/fixtures/qwizz-tour.md:1480-1481 but is blocked(no-such-state) in the later WRDP source, with its withdrawal recorded at q5vault/audits/qwizz/2026-08-20-feedback-tour.md:51,1493-1494. Tables, code and 139 section findings are unchanged.
Read next: api for import and serving, private page (sign-in required) for deck.json, or private page (sign-in required) for the map.