mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Learn: a /learn/ deep-dive section — markdown lessons as before/after spec progressions
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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)…
|
||||
*
|
||||
* :::progression
|
||||
* ## raw counts
|
||||
* Note prose for this stage (markdown)…
|
||||
* ```vega-lite
|
||||
* { …spec… }
|
||||
* ```
|
||||
* ## + bin
|
||||
* …
|
||||
* :::
|
||||
*
|
||||
* More prose, then a standalone chart:
|
||||
*
|
||||
* ```vega-lite
|
||||
* { …one-off chart… }
|
||||
* ```
|
||||
*
|
||||
* :::sharp-edge
|
||||
* A callout (markdown)…
|
||||
* :::
|
||||
*
|
||||
* 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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProseBlock {
|
||||
type: 'prose';
|
||||
markdown: string;
|
||||
}
|
||||
export interface ChartBlock {
|
||||
type: 'chart';
|
||||
spec: Record<string, unknown>;
|
||||
}
|
||||
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;
|
||||
/** 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');
|
||||
const title = required(meta, 'title');
|
||||
const tagline = required(meta, 'tagline');
|
||||
|
||||
return { slug, title, tagline, blocks: parseBlocks(text.slice(fm[0].length), slug) };
|
||||
}
|
||||
|
||||
function parseBlocks(body: string, slug: string): LessonBlock[] {
|
||||
const blocks: LessonBlock[] = [];
|
||||
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];
|
||||
// TODO: if lessons multiply, add a `:::data` directive whose values stages
|
||||
// inherit — today a lesson's inline data is repeated in every fenced spec.
|
||||
blocks.push(
|
||||
variant === 'progression'
|
||||
? { type: 'progression', stages: parseStages(inner, slug) }
|
||||
: { 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;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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<string, string>, key: string): string {
|
||||
const v = meta.get(key);
|
||||
if (!v) throw new Error(`lesson frontmatter: missing "${key}"`);
|
||||
return v;
|
||||
}
|
||||
Reference in New Issue
Block a user