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.

ObjectFieldsContract
FrameDeck.deckid, title, source, createdAt, metaid 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.sectionsSection[]Document order.
Sectionid, title, level, tags, findings, framesGitHub-slug id; flattened heading; heading depth, or 0 for the implicit section; tags and findings in source order; ordered frames (index.ts:348-371).
Frameid, seq, type, src?, alt?, caption?, sub?, tags, badges, lineId 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 constructWhat it becomesNotes
YAML frontmatterdeck.meta; possibly deck.titleTop-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).
HeadingA new Section; later frames attach to itId 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).
H1Section plus title candidateThe 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).
Imageimage frameCarries src, non-empty alt, and markdown title as caption (index.ts:373-381).
Immediately trailing <sub>Enriches the preceding imageBecomes 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.findingsCallouts 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 tabletable frameDetected by raw paragraph sniffing because remark-gfm is absent; caption is the whole raw markdown block (index.ts:267-286, 403-412).
AST table nodetable frameUnreachable with today’s dependencies, retained so adding a GFM plugin remains correct (index.ts:13-18, 391-398).
Image inside a detected tableA second image frame after the table frameTable detection does not consume paragraph children (index.ts:408-411).
Fenced codecode frameRaw node.value goes in caption; fence language, when present, is tags[0] (index.ts:383-390).
Top-level paragraphOptional text frameOnly with includeText, and only when that paragraph produced no other frame (index.ts:414-422).
Content before the first headingImplicit level-0 sectionEmitted 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]
CaseBehavior
Plain Node 22.16An 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 inputUnknown 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-outCreates parent directories, writes pretty JSON, then prints target path and byte count (scripts/mdframes.mjs:151-161).
Default outputPrints 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).

QuantityFrozen valueAssertion
Sections76mdframes.test.ts:54-56
Heading levels11 at level 2; 65 at level 3; 0 at levels 1 and 0mdframes.test.ts:58-64
Frames324mdframes.test.ts:93-99
Images321: 316 under /shots/, 5 under /boards/mdframes.test.ts:66-72
Other frame types2 tables; 1 code tagged yaml; 0 text by defaultmdframes.test.ts:93-99
Finding-badged images66mdframes.test.ts:75-77
Image <sub> values321 non-empty; zero empty stringsmdframes.test.ts:79-84
Section findings139 total; every one of 65 level-3 sections has at least onemdframes.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.