/** * Parser for markdown lesson files (the deep-dive learning section). A lesson is * authored as a `.md` — prose is native markdown, specs are fenced JSON — and this * turns one into the ordered **blocks** the page renders in document order. * * A lesson is a free-form document, not a fixed template: prose, interactive * progressions, standalone charts, and callouts in any arrangement. The author * only ever writes markdown, fenced specs, and `:::directives` — never JSX. * * Pure and portable (string in → data out; no DOM, Vega, or React): the * structural parse and the spec `JSON.parse` live here and are tested hardest. * Rendering the prose markdown to HTML is the UI layer's job (`src/learn`), kept * out of core so core stays dependency-free. * * Format: * * --- * slug: binning * title: From bars to a real histogram * tagline: One word turns a noisy bar chart into a distribution. * --- * * Free intro prose (markdown)… * * :::data * { "ev": [ {…row…}, {…row…} ] } * ::: * * :::progression * ## raw counts * Note prose for this stage (markdown)… * ```vega-lite * { "data": { "name": "ev" }, …spec… } * ``` * ## + bin * … * ::: * * More prose, then a standalone chart: * * ```vega-lite * { …one-off chart… } * ``` * * :::sharp-edge * A callout (markdown)… * ::: * * A `:::data` block names datasets once for the whole lesson (body: a JSON object * of `name → rows`); specs reference them by `{ "name": … }` and the rows are * injected at render time, so they aren't repeated per stage. A `:::progression` * block holds `##`-delimited stages (heading → tab label, prose → note, fenced * `vega-lite` → spec). A bare fenced `vega-lite` block is a standalone chart. Any * other `:::name` block is a callout. Everything else is prose, emitted in the * order it appears. */ export interface ProgressionStage { /** Tab label — the `##` heading text. */ label: string; /** Markdown prose shown under the chart for this stage. */ note: string; /** The stage's Vega-Lite spec, parsed from its fenced block. */ spec: Record; } export interface ProseBlock { type: 'prose'; markdown: string; } export interface ChartBlock { type: 'chart'; spec: Record; } export interface ProgressionBlock { type: 'progression'; stages: ProgressionStage[]; } export interface CalloutBlock { type: 'callout'; /** The directive name, e.g. `sharp-edge` — the UI styles by variant. */ variant: string; markdown: string; } export type LessonBlock = ProseBlock | ChartBlock | ProgressionBlock | CalloutBlock; export interface ParsedLesson { slug: string; title: string; tagline: string; /** * Lesson-level named datasets, collected from `:::data` blocks. Specs reference * a dataset by `{ "data": { "name": … } }`; {@link injectDatasets} merges these * in at render time so the rows aren't repeated in every fenced spec. */ datasets: Record; /** The lesson body, in document order. */ blocks: LessonBlock[]; } const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/; // A top-level region: a `:::name … :::` directive (which may itself contain // vega-lite fences) OR a standalone vega-lite fence. The directive alternative is // tried first and its lazy body runs to the next `\n:::`, so a progression's inner // fences are consumed by its span rather than matched as standalone charts. const REGION = /:::([a-z-]+)\n([\s\S]*?)\n:::|```vega-lite\n([\s\S]*?)\n```/g; const STAGE_FENCE = /```vega-lite\n([\s\S]*?)\n```/; export function parseLesson(source: string): ParsedLesson { const text = source.replace(/\r\n/g, '\n'); const fm = FRONTMATTER.exec(text); if (!fm) throw new Error('lesson: missing `---` frontmatter block'); const meta = parseFrontmatter(fm[1]); const slug = required(meta, 'slug'); // The slug is the lesson's URL segment (`/learn//`) and the route key; keep it // to the charset the router and the page-shell generator both assume, so a typo fails // loudly here rather than silently serving the wrong page. if (!/^[a-z0-9-]+$/.test(slug)) { throw new Error(`lesson "${slug}": slug must be lowercase letters, digits, and hyphens`); } const title = required(meta, 'title'); const tagline = required(meta, 'tagline'); const { blocks, datasets } = parseBlocks(text.slice(fm[0].length), slug); return { slug, title, tagline, datasets, blocks }; } function parseBlocks( body: string, slug: string, ): { blocks: LessonBlock[]; datasets: Record } { const blocks: LessonBlock[] = []; const datasets: Record = {}; const pushProse = (text: string): void => { const trimmed = text.trim(); if (trimmed) blocks.push({ type: 'prose', markdown: trimmed }); }; REGION.lastIndex = 0; let cursor = 0; let m: RegExpExecArray | null; while ((m = REGION.exec(body)) !== null) { pushProse(body.slice(cursor, m.index)); if (m[1] !== undefined) { const variant = m[1]; const inner = m[2]; if (variant === 'progression') { blocks.push({ type: 'progression', stages: parseStages(inner, slug) }); } else if (variant === 'data') { // Lesson metadata, not a rendered block: fold the named rows into the // lesson's dataset map for render-time injection. Object.assign(datasets, parseDatasets(inner, slug)); } else { blocks.push({ type: 'callout', variant, markdown: inner.trim() }); } } else { blocks.push({ type: 'chart', spec: parseSpec(m[3], slug, 'chart') }); } cursor = m.index + m[0].length; } pushProse(body.slice(cursor)); if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`); return { blocks, datasets }; } /** Parse a `:::data` body — a JSON object of `name → rows[]` — into named datasets. */ function parseDatasets(inner: string, slug: string): Record { let parsed: unknown; try { parsed = JSON.parse(inner.trim()); } catch (e) { throw new Error(`lesson "${slug}", :::data: invalid JSON — ${(e as Error).message}`, { cause: e, }); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error(`lesson "${slug}", :::data: body must be a JSON object of { name: rows }`); } const out: Record = {}; for (const [name, rows] of Object.entries(parsed)) { if (!Array.isArray(rows)) { throw new Error(`lesson "${slug}", :::data "${name}": value must be an array of rows`); } out[name] = rows; } return out; } /** * Merge lesson-level named datasets into a spec for *rendering* — the authored * spec (shown in the source pane and diffed) keeps its `{ "data": { "name": … } }` * reference and never carries the rows. Returns a clone with the spec's own * `datasets` winning on a name clash; mirrors the clone-before-embed rule the app's * other render-time spec tweaks follow (never mutate the authored spec). A no-op * (same reference) when there are no datasets to inject. */ export function injectDatasets( spec: Record, datasets: Record, ): Record { if (Object.keys(datasets).length === 0) return spec; const own = (spec.datasets as Record | undefined) ?? {}; return { ...spec, datasets: { ...datasets, ...own } }; } function parseStages(inner: string, slug: string): ProgressionStage[] { const parts = inner.split(/^## +/m); parts.shift(); // text before the first `##` (blank by convention) if (parts.length === 0) { throw new Error(`lesson "${slug}": a :::progression has no \`##\` stages`); } return parts.map((part) => parseStage(part, slug)); } function parseStage(part: string, slug: string): ProgressionStage { const nl = part.indexOf('\n'); const label = (nl === -1 ? part : part.slice(0, nl)).trim(); const rest = nl === -1 ? '' : part.slice(nl + 1); const block = STAGE_FENCE.exec(rest); if (!block) throw new Error(`lesson "${slug}", stage "${label}": no \`\`\`vega-lite block`); const spec = parseSpec(block[1], slug, `stage "${label}"`); const note = (rest.slice(0, block.index) + rest.slice(block.index + block[0].length)).trim(); return { label, note, spec }; } function parseSpec(json: string, slug: string, where: string): Record { let spec: unknown; try { spec = JSON.parse(json); } catch (e) { throw new Error(`lesson "${slug}", ${where}: invalid JSON — ${(e as Error).message}`, { cause: e, }); } if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) { throw new Error(`lesson "${slug}", ${where}: spec must be a JSON object`); } return spec as Record; } function parseFrontmatter(raw: string): Map { const map = new Map(); for (const line of raw.split('\n')) { if (!line.trim()) continue; const i = line.indexOf(':'); if (i === -1) throw new Error(`lesson frontmatter: expected \`key: value\`, got "${line}"`); map.set(line.slice(0, i).trim(), line.slice(i + 1).trim()); } return map; } function required(meta: Map, key: string): string { const v = meta.get(key); if (!v) throw new Error(`lesson frontmatter: missing "${key}"`); return v; }