mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Learn: per-lesson URLs, a :::data shared-dataset construct, and a linked-views deep dive
This commit is contained in:
+81
-15
@@ -22,11 +22,15 @@
|
||||
*
|
||||
* Free intro prose (markdown)…
|
||||
*
|
||||
* :::data
|
||||
* { "ev": [ {…row…}, {…row…} ] }
|
||||
* :::
|
||||
*
|
||||
* :::progression
|
||||
* ## raw counts
|
||||
* Note prose for this stage (markdown)…
|
||||
* ```vega-lite
|
||||
* { …spec… }
|
||||
* { "data": { "name": "ev" }, …spec… }
|
||||
* ```
|
||||
* ## + bin
|
||||
* …
|
||||
@@ -42,10 +46,13 @@
|
||||
* 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.
|
||||
* 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 {
|
||||
@@ -81,6 +88,12 @@ 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<string, unknown[]>;
|
||||
/** The lesson body, in document order. */
|
||||
blocks: LessonBlock[];
|
||||
}
|
||||
@@ -100,14 +113,25 @@ export function parseLesson(source: string): ParsedLesson {
|
||||
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/<slug>/`) 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');
|
||||
|
||||
return { slug, title, tagline, blocks: parseBlocks(text.slice(fm[0].length), slug) };
|
||||
const { blocks, datasets } = parseBlocks(text.slice(fm[0].length), slug);
|
||||
return { slug, title, tagline, datasets, blocks };
|
||||
}
|
||||
|
||||
function parseBlocks(body: string, slug: string): LessonBlock[] {
|
||||
function parseBlocks(
|
||||
body: string,
|
||||
slug: string,
|
||||
): { blocks: LessonBlock[]; datasets: Record<string, unknown[]> } {
|
||||
const blocks: LessonBlock[] = [];
|
||||
const datasets: Record<string, unknown[]> = {};
|
||||
const pushProse = (text: string): void => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed) blocks.push({ type: 'prose', markdown: trimmed });
|
||||
@@ -121,13 +145,15 @@ function parseBlocks(body: string, slug: string): LessonBlock[] {
|
||||
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() },
|
||||
);
|
||||
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') });
|
||||
}
|
||||
@@ -136,7 +162,47 @@ function parseBlocks(body: string, slug: string): LessonBlock[] {
|
||||
pushProse(body.slice(cursor));
|
||||
|
||||
if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`);
|
||||
return blocks;
|
||||
return { blocks, datasets };
|
||||
}
|
||||
|
||||
/** Parse a `:::data` body — a JSON object of `name → rows[]` — into named datasets. */
|
||||
function parseDatasets(inner: string, slug: string): Record<string, unknown[]> {
|
||||
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<string, unknown[]> = {};
|
||||
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<string, unknown>,
|
||||
datasets: Record<string, unknown[]>,
|
||||
): Record<string, unknown> {
|
||||
if (Object.keys(datasets).length === 0) return spec;
|
||||
const own = (spec.datasets as Record<string, unknown> | undefined) ?? {};
|
||||
return { ...spec, datasets: { ...datasets, ...own } };
|
||||
}
|
||||
|
||||
function parseStages(inner: string, slug: string): ProgressionStage[] {
|
||||
|
||||
Reference in New Issue
Block a user