Entry point
There is exactly one implementation, src/lib/mdframes/index.ts, exporting one function
(src/lib/mdframes/index.ts:197):
export function parse(md: string, opts?: ParseOptions): FrameDeck
export interface ParseOptions {
includeText?: boolean;
id?: string;
source?: string;
}ParseOptions is declared at src/lib/mdframes/index.ts:36-40. The pipeline is unified with
remark-parse and remark-frontmatter restricted to YAML
(src/lib/mdframes/index.ts:210-213). One GithubSlugger instance is created per call
(src/lib/mdframes/index.ts:215), which is what makes duplicate-heading disambiguation
document-scoped.
Schema
FrameDeck, Section and Frame are declared in src/lib/mdframes/types.ts.
Citation shorthand from here on: a bare index.ts, types.ts or mdframes.test.ts always means
the file of that name in src/lib/mdframes/. Every other path is written in full.
Deck envelope (types.ts:13-15):
| Field | Type | Meaning |
|---|---|---|
deck.id | string | opts.id if given, otherwise the slug of the resolved title (index.ts:470) |
deck.title | string | frontmatter title, else first H1, else source basename, else Untitled (index.ts:464-469) |
deck.source | string | opts.source verbatim, empty string when absent (index.ts:204) |
deck.createdAt | string | ISO stamp taken at parse time (index.ts:476) |
deck.meta | Record<string, unknown> | coerced frontmatter keys (index.ts:157-175) |
sections | Section[] | document order |
Section (types.ts:9-12):
| Field | Type | Meaning |
|---|---|---|
id | string | github-slug of the heading text (index.ts:358) |
title | string | heading text with inline markup flattened (index.ts:349) |
level | number | heading depth; 0 for the implicit pre-heading section (index.ts:218, index.ts:360) |
tags | string[] | inline-code chips plus a trailing parenthetical (index.ts:350-356) |
findings | string[] | collected from callout blockquotes (index.ts:288-335) |
frames | Frame[] | frames attached to this heading, in document order |
Frame (types.ts:2-8):
| Field | Type | Meaning |
|---|---|---|
id | string | `${sectionId}/${seq}` (index.ts:228) |
seq | number | 1-based, per section, contiguous (index.ts:226) |
type | image, table, code or text | types.ts:1 |
src | string? | image frames only (index.ts:375) |
alt | string? | image alt, omitted when empty (index.ts:376) |
caption | string? | see the caption quirk below |
sub | string? | the <sub> chip line, image frames only (index.ts:444) |
tags | string[] | chips split from sub, or the fence language for code frames |
badges | string[] | finding when a chip says so (index.ts:454) |
line | number | 1-based source line of the producing node (index.ts:237) |
Parsing rules
| Rule | Behaviour | Source |
|---|---|---|
| Headings become sections | every heading opens a section; subsequent frames attach to the most recent one | index.ts:348-371 |
| Section ids are github slugs | duplicate headings get a numeric suffix, so a repeated Alpha (beta) yields alpha-beta then alpha-beta-1 | index.ts:358, pinned at mdframes.test.ts:261-264 |
| Section tags | inline-code chips inside the heading, deduplicated in first-seen order, plus a trailing parenthetical | index.ts:252-265, index.ts:352-356 |
| Deck title | frontmatter title wins; the first depth-1 heading wins even when its text is empty | index.ts:365-368, index.ts:464-469 |
| Frontmatter coercion | top-level keys only; indented and commented lines skipped; true, false, null, ~, quoted strings, bracketed lists and numbers are coerced, everything else stays a string | index.ts:131-175 |
| Images | become image frames carrying src, non-empty alt, and the markdown title as caption | index.ts:373-381 |
Trailing <sub> | attached as frame.sub, then split on the middle dot into tags | index.ts:433-450 |
| Finding badge | a tag matching ⚠️ finding case-insensitively, or a tag that is exactly finding, pushes badge finding once and stops | index.ts:451-457 |
| HTML entities | amp, lt, gt, quot, nbsp, #39, #x27 are decoded inside sub | index.ts:80-89, index.ts:442 |
| Callouts | > [!warning] and > [!note] blockquotes contribute to the enclosing section’s findings | index.ts:288-335 |
| Pipe tables | become table frames whose caption is the raw markdown block | index.ts:267-286 |
| Fenced code | becomes a code frame; the fence language, when present, is tags[0] | index.ts:383-390 |
| Text frames | only produced with includeText, only for top-level paragraphs that produced no other frame | index.ts:414-422 |
| Implicit section | content before the first heading collects into a level-0 section that is emitted only if it ends up holding a frame | index.ts:218, index.ts:473 |
Two details are easy to get wrong when reading quickly.
Callout extraction has three tiers (index.ts:296-333). If the blockquote contains a list, each
list item becomes one finding. If it does not, every body line after the first becomes a finding.
If neither produced anything, the callout’s own title text is used as a fallback, so a one-line
callout is never silently dropped.
The <sub> attachment is an anchored offset window, not a lookahead heuristic. In a single reverse
pass over the frame-producing nodes, each image’s window runs from its own end offset to the next
frame node’s start offset, and the regex is anchored at the start of that slice
(index.ts:70, index.ts:433-441). Any intervening prose, heading or second image therefore kills
the match, which is exactly the “this chip belongs to that shot” semantics the format needs. The
test pins the negative case (mdframes.test.ts:334-339).
Pipe tables without remark-gfm
remark-gfm is deliberately not a dependency; package.json:29-36 lists only @lucide/svelte,
github-slugger, remark, remark-frontmatter, remark-parse and unified. Plain remark-parse
therefore emits zero table nodes, and a GFM table arrives in the AST as an ordinary top-level
paragraph.
The parser handles this itself. For each top-level paragraph it slices the raw source span and
checks two conditions: the first line contains a pipe, and the second line matches the GFM
delimiter-row pattern. If both hold, the paragraph is emitted as a table frame carrying that raw
slice (index.ts:267-286, delimiter pattern at index.ts:68, dispatch at index.ts:403-412).
The unambiguous statement: pipe tables are recognised by the parser’s own paragraph sniffing, not by
a GFM plugin.
Two consequences worth knowing. The table-node branch at index.ts:391-398 is unreachable today
and is documented as such — it is retained so the code stays correct if a GFM plugin is ever added
(index.ts:13-18). And because table detection does not consume the paragraph’s children, an image
inside a table cell still yields its own frame after the table frame that quotes the whole block
(index.ts:408-411).
The caption quirk
An image frame points at a resource, so it carries src. A table or code frame is the payload, so
its raw markdown lives in caption and src, alt and sub all stay undefined. This is
deliberate and documented at the top of the module (index.ts:5-11).
- table frames:
captionis the raw source slice of the block (index.ts:282-286) - code frames:
captionis the fence’snode.value(index.ts:385)
Both are pinned by tests: the fixture’s Coverage table keeps its header row inside caption
(mdframes.test.ts:130-136), and the micro-document case asserts src is undefined for a table
frame (mdframes.test.ts:294-301) and that a code frame’s caption is the raw code
(mdframes.test.ts:303-311). The client relies on it: FrameCard.svelte renders code and table
frames straight from frame.caption (src/lib/components/FrameCard.svelte:88-90).
Never throws
parse is documented as never throwing (index.ts:192-196). Nothing above the try touches md
or opts, so even a throwing property accessor cannot escape (index.ts:198-207), and the catch
returns a well-formed deck with zero sections (index.ts:479-490).
Observable fallback shape for empty input, as asserted at mdframes.test.ts:187-193: sections is
empty, deck.title is Untitled, deck.createdAt is a non-empty string and deck.meta is an
empty object. The robustness suite also covers binary-ish junk containing null bytes, a replacement
character and unbalanced markdown delimiters (mdframes.test.ts:195-212), and pins that a
frameless implicit section is dropped unless includeText is set, in which case it appears at level
0 with one text frame (mdframes.test.ts:214-224).
CLI
scripts/mdframes.mjs wraps the same parser and duplicates none of its logic
(scripts/mdframes.mjs:12-13).
usage: node --experimental-strip-types scripts/mdframes.mjs <file.md> [--json-out <path>] [--include-text]
Plain node scripts/mdframes.mjs <file.md> also works: importing the TypeScript source without
type stripping fails with ERR_UNKNOWN_FILE_EXTENSION on Node 22.16, so the script re-execs itself
once with --experimental-strip-types, guarded by MDFRAMES_RESPAWNED against exec loops
(scripts/mdframes.mjs:61-88). Node 22.18 and newer never need the fallback
(scripts/mdframes.mjs:7-10).
Behaviour: unknown flags, a missing file argument and a --json-out without a path all exit 1 with
the usage line (scripts/mdframes.mjs:29-58); an unreadable or non-file argument exits 1
(scripts/mdframes.mjs:127-133). With --json-out it writes the pretty-printed deck, creating
parent directories, and prints the path and byte count (scripts/mdframes.mjs:151-161). Otherwise
it prints an aligned summary of id, title, section and frame counts by level and type, findings and
badges (scripts/mdframes.mjs:102-121). The deck id defaults to the file’s basename without
extension (scripts/mdframes.mjs:147).
Observed on this host with Node v22.16.0, running plain node against the frozen fixture:
id qwizz-tour
title 🎞️ QuizWizz UI/UX Feedback Tour — Complete Shot Gallery
sections 76
by level level 2: 11, level 3: 65
frames 324
by type image: 321, table: 2, code: 1
findings 139
badges finding: 66
The frozen fixture
The contract fixture is src/lib/mdframes/fixtures/qwizz-tour.md, md5
a15fb4f0ffb6d07e8256402563193d15, 1619 lines. The md5 and line count are recorded in the test
header (mdframes.test.ts:1-10) and the line bound is enforced on every frame
(mdframes.test.ts:151-156); the md5 was re-verified with md5sum while writing this page.
| Quantity | Value | Assertion |
|---|---|---|
| sections | 76 | mdframes.test.ts:54-56 |
| sections at level 2 | 11 | mdframes.test.ts:61 |
| sections at level 3 | 65 | mdframes.test.ts:60 |
| sections at level 1 or 0 | 0 | mdframes.test.ts:62-63 |
| total frames | 324 | mdframes.test.ts:94 |
| image frames | 321 | mdframes.test.ts:66-68 |
| table frames | 2 | mdframes.test.ts:95 |
| code frames | 1, tagged yaml | mdframes.test.ts:96-97 |
text frames without includeText | 0 | mdframes.test.ts:98 |
images under /shots/ | 316 | mdframes.test.ts:71 |
images under /boards/ | 5 | mdframes.test.ts:72 |
images badged finding | 66 | mdframes.test.ts:75-77 |
images with a non-empty sub | 321, and zero empty-string subs | mdframes.test.ts:79-84 |
| section findings, total | 139 | mdframes.test.ts:165 |
| level-3 sections with at least one finding | all 65 | mdframes.test.ts:158-163 |
The suite additionally pins that every image frame’s first tag is its .png filename
(mdframes.test.ts:86-91), that seq runs contiguously per section with section-scoped ids
(mdframes.test.ts:138-149), that the eleven level-2 titles appear in document order
(mdframes.test.ts:112-114), and that includeText adds text frames without disturbing the 321
images (mdframes.test.ts:174-178).
Why it is frozen: the fixture is a byte-for-byte snapshot of the WRDP gallery document taken before
that document was edited further. The numbers above are measured ground truth for that exact input,
so a disagreement means the parser changed behaviour, never that the constants need updating — which
is precisely what the test header states (mdframes.test.ts:4-6). Re-baselining the constants
against a newer copy of the source document would convert the only regression detector into a
tautology.
The live deck demonstrates why that matters. GET /api/decks/qwizz-2026-08-20 currently measures
320 image frames, 315 shots and 65 finding badges against the fixture’s 321, 316 and 66. The
difference is one withdrawn frame, v01-d-p02-player-intro.png: still present in the fixture at
src/lib/mdframes/fixtures/qwizz-tour.md:1480-1481, listed as blocked(no-such-state) in the live
source document at /home/loca/dev/wrdp/q5vault/audits/qwizz/2026-08-20-feedback-tour.md:51 with
the withdrawal recorded at lines 1493-1494. Section counts, table and code frames, and the 139
findings are unchanged. Fixture numbers and live numbers are different measurements of different
inputs and must never be quoted interchangeably.
Test status: npx vitest run reports 33 passed of 33, one test file
(src/lib/mdframes/mdframes.test.ts), observed green while writing this page. Vitest discovery is
configured at vite.config.ts:17-19.
Read next: api for how a parsed deck is imported and served, architecture for where
deck.json lives, or back to index.