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):

FieldTypeMeaning
deck.idstringopts.id if given, otherwise the slug of the resolved title (index.ts:470)
deck.titlestringfrontmatter title, else first H1, else source basename, else Untitled (index.ts:464-469)
deck.sourcestringopts.source verbatim, empty string when absent (index.ts:204)
deck.createdAtstringISO stamp taken at parse time (index.ts:476)
deck.metaRecord<string, unknown>coerced frontmatter keys (index.ts:157-175)
sectionsSection[]document order

Section (types.ts:9-12):

FieldTypeMeaning
idstringgithub-slug of the heading text (index.ts:358)
titlestringheading text with inline markup flattened (index.ts:349)
levelnumberheading depth; 0 for the implicit pre-heading section (index.ts:218, index.ts:360)
tagsstring[]inline-code chips plus a trailing parenthetical (index.ts:350-356)
findingsstring[]collected from callout blockquotes (index.ts:288-335)
framesFrame[]frames attached to this heading, in document order

Frame (types.ts:2-8):

FieldTypeMeaning
idstring`${sectionId}/${seq}` (index.ts:228)
seqnumber1-based, per section, contiguous (index.ts:226)
typeimage, table, code or texttypes.ts:1
srcstring?image frames only (index.ts:375)
altstring?image alt, omitted when empty (index.ts:376)
captionstring?see the caption quirk below
substring?the <sub> chip line, image frames only (index.ts:444)
tagsstring[]chips split from sub, or the fence language for code frames
badgesstring[]finding when a chip says so (index.ts:454)
linenumber1-based source line of the producing node (index.ts:237)

Parsing rules

RuleBehaviourSource
Headings become sectionsevery heading opens a section; subsequent frames attach to the most recent oneindex.ts:348-371
Section ids are github slugsduplicate headings get a numeric suffix, so a repeated Alpha (beta) yields alpha-beta then alpha-beta-1index.ts:358, pinned at mdframes.test.ts:261-264
Section tagsinline-code chips inside the heading, deduplicated in first-seen order, plus a trailing parentheticalindex.ts:252-265, index.ts:352-356
Deck titlefrontmatter title wins; the first depth-1 heading wins even when its text is emptyindex.ts:365-368, index.ts:464-469
Frontmatter coerciontop-level keys only; indented and commented lines skipped; true, false, null, ~, quoted strings, bracketed lists and numbers are coerced, everything else stays a stringindex.ts:131-175
Imagesbecome image frames carrying src, non-empty alt, and the markdown title as captionindex.ts:373-381
Trailing <sub>attached as frame.sub, then split on the middle dot into tagsindex.ts:433-450
Finding badgea tag matching ⚠️ finding case-insensitively, or a tag that is exactly finding, pushes badge finding once and stopsindex.ts:451-457
HTML entitiesamp, lt, gt, quot, nbsp, #39, #x27 are decoded inside subindex.ts:80-89, index.ts:442
Callouts> [!warning] and > [!note] blockquotes contribute to the enclosing section’s findingsindex.ts:288-335
Pipe tablesbecome table frames whose caption is the raw markdown blockindex.ts:267-286
Fenced codebecomes a code frame; the fence language, when present, is tags[0]index.ts:383-390
Text framesonly produced with includeText, only for top-level paragraphs that produced no other frameindex.ts:414-422
Implicit sectioncontent before the first heading collects into a level-0 section that is emitted only if it ends up holding a frameindex.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: caption is the raw source slice of the block (index.ts:282-286)
  • code frames: caption is the fence’s node.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.

QuantityValueAssertion
sections76mdframes.test.ts:54-56
sections at level 211mdframes.test.ts:61
sections at level 365mdframes.test.ts:60
sections at level 1 or 00mdframes.test.ts:62-63
total frames324mdframes.test.ts:94
image frames321mdframes.test.ts:66-68
table frames2mdframes.test.ts:95
code frames1, tagged yamlmdframes.test.ts:96-97
text frames without includeText0mdframes.test.ts:98
images under /shots/316mdframes.test.ts:71
images under /boards/5mdframes.test.ts:72
images badged finding66mdframes.test.ts:75-77
images with a non-empty sub321, and zero empty-string subsmdframes.test.ts:79-84
section findings, total139mdframes.test.ts:165
level-3 sections with at least one findingall 65mdframes.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.