Learn: per-lesson URLs, a :::data shared-dataset construct, and a linked-views deep dive

This commit is contained in:
2026-06-28 12:34:30 +03:00
parent fd5c51a761
commit 20e70bee0e
16 changed files with 1818 additions and 178 deletions
+79 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { parseLesson, type LessonBlock } from './lesson-parse';
import { injectDatasets, parseLesson, type LessonBlock } from './lesson-parse';
const LESSON = `---
slug: binning
@@ -105,6 +105,12 @@ describe('parseLesson — block model', () => {
expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/);
});
it('throws on a slug outside [a-z0-9-] (router/shell contract)', () => {
expect(() => parseLesson(`---\nslug: Bad_Slug\ntitle: t\ntagline: g\n---\nhi`)).toThrow(
/slug must be lowercase/,
);
});
it('throws with stage context on invalid JSON', () => {
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::progression\n## a\n\`\`\`vega-lite\n{ bad }\n\`\`\`\n:::`;
expect(() => parseLesson(bad)).toThrow(/stage "a": invalid JSON/);
@@ -124,4 +130,76 @@ describe('parseLesson — block model', () => {
'callout',
]);
});
it('defaults to no datasets when there is no :::data block', () => {
expect(parseLesson(LESSON).datasets).toEqual({});
});
});
describe('parseLesson — :::data blocks', () => {
const withData = `---\nslug: x\ntitle: t\ntagline: g\n---
:::data
{ "ev": [{ "arm": "A", "n": 1 }, { "arm": "B", "n": 2 }] }
:::
A chart that references it by name:
\`\`\`vega-lite
{ "data": { "name": "ev" }, "mark": "bar" }
\`\`\``;
it('collects named rows into lesson.datasets, not as a block', () => {
const lesson = parseLesson(withData);
expect(lesson.datasets).toEqual({
ev: [
{ arm: 'A', n: 1 },
{ arm: 'B', n: 2 },
],
});
// The data directive is metadata — only the prose + chart are blocks.
expect(types(lesson.blocks)).toEqual(['prose', 'chart']);
});
it('merges multiple :::data blocks', () => {
const two = `---\nslug: x\ntitle: t\ntagline: g\n---
:::data
{ "a": [{ "v": 1 }] }
:::
:::data
{ "b": [{ "v": 2 }] }
:::
Words.`;
expect(parseLesson(two).datasets).toEqual({ a: [{ v: 1 }], b: [{ v: 2 }] });
});
it('throws on a non-object :::data body', () => {
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n[1, 2]\n:::`;
expect(() => parseLesson(bad)).toThrow(/:::data: body must be a JSON object/);
});
it('throws when a dataset value is not an array', () => {
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n{ "ev": 5 }\n:::\nhi`;
expect(() => parseLesson(bad)).toThrow(/:::data "ev": value must be an array/);
});
});
describe('injectDatasets', () => {
const datasets = { ev: [{ n: 1 }] };
it('merges lesson datasets into a clone, leaving the source spec untouched', () => {
const spec = { data: { name: 'ev' }, mark: 'bar' };
const out = injectDatasets(spec, datasets);
expect(out).toEqual({ data: { name: 'ev' }, mark: 'bar', datasets: { ev: [{ n: 1 }] } });
expect(spec).not.toHaveProperty('datasets');
});
it("lets a spec's own datasets win on a name clash", () => {
const spec = { datasets: { ev: [{ n: 99 }] }, mark: 'bar' };
expect(injectDatasets(spec, datasets).datasets).toEqual({ ev: [{ n: 99 }] });
});
it('returns the spec unchanged when there are no datasets', () => {
const spec = { mark: 'bar' };
expect(injectDatasets(spec, {})).toBe(spec);
});
});
+81 -15
View File
@@ -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[] {
+4
View File
@@ -353,6 +353,9 @@ export function Landing(): ReactNode {
<a className={styles.navLink} href="#theme">
Theming
</a>
<a className={styles.navLink} href="/learn/">
Deep dives
</a>
<button className={`${styles.btn} ${styles.btnSm}`} type="button" onClick={toggleTheme}>
{theme === 'dark' ? 'Light' : 'Dark'}
</button>
@@ -648,6 +651,7 @@ export function Landing(): ReactNode {
<Brand />
<span className={styles.footerSp} />
<a href="/app/">Open the app</a>
<a href="/learn/">Deep dives</a>
<a href="https://vega.github.io/vega-lite/">Built on Vega-Lite</a>
</div>
</footer>
@@ -27,10 +27,13 @@
align-items: center;
gap: var(--space-4);
}
/* A subtle link to the section index (`/learn/`); resets the default underline to
match the header's other links (.brand, .appLink). */
.kicker {
font-size: 12px;
color: var(--text-secondary);
letter-spacing: 0.02em;
text-decoration: none;
}
.ghost {
height: var(--control-height);
@@ -190,6 +193,86 @@
color: var(--text-secondary);
}
/* ---- index (lesson list at /learn/) ---- */
.index {
width: 100%;
max-width: 760px;
}
.indexLede {
margin: 0 0 var(--space-4);
max-width: 64ch;
font-size: 17px;
line-height: 1.5;
color: var(--text-secondary);
}
.indexAside {
margin: 0 0 var(--space-7);
max-width: 64ch;
font-size: 14px;
line-height: 1.55;
color: var(--text-secondary);
}
.indexAside a {
color: var(--accent);
}
.cards {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.card {
display: block;
padding: var(--space-6);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--layer-01);
color: inherit;
text-decoration: none;
}
.card:hover {
border-color: var(--border-strong);
background: var(--control-hover-fill);
}
.cardKicker {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
.cardTitle {
margin: var(--space-2) 0;
font-size: 22px;
font-weight: 600;
color: var(--text);
}
.cardTagline {
margin: 0 0 var(--space-4);
font-size: 15px;
line-height: 1.55;
color: var(--text-secondary);
}
.cardMore {
font-size: 14px;
font-weight: 500;
color: var(--accent);
}
/* ---- back link (lesson → index) ---- */
.backLink {
display: inline-block;
margin: 0 0 var(--space-5);
font-size: 13px;
color: var(--text-secondary);
text-decoration: none;
}
.backLink:hover {
color: var(--text);
}
@media (max-width: 720px) {
.title {
font-size: 24px;
+49
View File
@@ -0,0 +1,49 @@
import type { ReactNode } from 'react';
import type { ParsedLesson } from '@core/lesson-parse';
import styles from './Learn.module.css';
/**
* The `/learn/` index: the section intro plus a card per lesson linking to its own
* page (`/learn/<slug>/`). Lessons arrive from the markdown glob (see `lessons.ts`);
* each card's URL matches a generated shell.
*/
export function LearnIndex({ lessons }: { lessons: ParsedLesson[] }): ReactNode {
return (
<div className={styles.index}>
<p className={styles.kickerLine}>Deep dives</p>
<h1 className={styles.title}>Vega-Lite, deeper</h1>
<p className={styles.indexLede}>
Advanced Vega-Lite, worked end to end: linked selections, composition, and the transforms
that do the real work. Each page starts from a spec that already renders and takes it
somewhere the reference docs stop short of.
</p>
<p className={styles.indexAside}>
New to Vega-Lite? The{' '}
<a href="https://vega.github.io/vega-lite/docs/" target="_blank" rel="noopener noreferrer">
official docs
</a>{' '}
and{' '}
<a
href="https://vega.github.io/vega-lite/tutorials/getting_started.html"
target="_blank"
rel="noopener noreferrer"
>
getting-started tutorial
</a>{' '}
cover the basics these pages pick up after that.
</p>
<ul className={styles.cards}>
{lessons.map((lesson) => (
<li key={lesson.slug}>
<a className={styles.card} href={`/learn/${lesson.slug}/`}>
<span className={styles.cardKicker}>Deep dive</span>
<h2 className={styles.cardTitle}>{lesson.title}</h2>
<p className={styles.cardTagline}>{lesson.tagline}</p>
<span className={styles.cardMore}>Read </span>
</a>
</li>
))}
</ul>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useMemo, useState, type ReactNode } from 'react';
import { chartConfigForSelection } from '@core/vega-themes';
import type { UiTheme } from '@core/theme';
import styles from './Learn.module.css';
type ChartConfig = ReturnType<typeof chartConfigForSelection>;
/**
* Shared chrome for the learning section (header, footer, light/dark theme) wrapping
* either the lesson index or one lesson. The theme state lives here because both the
* header toggle and the chart config depend on it; it's handed to the page via a
* render prop so a lesson's charts re-theme with the toggle.
*
* Theme handling mirrors the landing (light/dark via `data-theme` on the root),
* duplicated rather than shared because the two entries are independent pages.
*/
export function LearnLayout({
children,
}: {
children: (config: ChartConfig) => ReactNode;
}): ReactNode {
const [theme, setTheme] = useState<UiTheme>('light');
const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
// TODO: this light/dark toggle is duplicated in landing/Landing.tsx — extract a
// shared `useUiTheme()` hook into a home both marketing entries can import.
function toggleTheme(): void {
const next: UiTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
document.documentElement.dataset.theme = next;
}
return (
<div className={styles.page}>
<header className={styles.header}>
<a className={styles.brand} href="/">
Astrolabe
</a>
<nav className={styles.nav}>
<a className={styles.kicker} href="/learn/">
Vega-Lite, deeper
</a>
<button className={styles.ghost} type="button" onClick={toggleTheme}>
{theme === 'dark' ? 'Light' : 'Dark'}
</button>
<a className={styles.appLink} href="/app/">
Open the app
</a>
</nav>
</header>
<main className={styles.main}>{children(config)}</main>
<footer className={styles.footer}>
<span>An interactive deep dive into the parts of Vega-Lite the docs gloss over.</span>
<a href="/"> Back to Astrolabe</a>
</footer>
</div>
);
}
-140
View File
@@ -1,140 +0,0 @@
import { useMemo, useState, type ReactNode } from 'react';
import { chartConfigForSelection } from '@core/vega-themes';
import type { UiTheme } from '@core/theme';
import { parseLesson, type LessonBlock, type ParsedLesson } from '@core/lesson-parse';
import { SpecProgression } from './SpecProgression';
import { LandingChart } from '../landing/LandingChart';
import { Markdown } from './Markdown';
import styles from './LearnPage.module.css';
// Lessons are markdown files: drop a `.md` in lessons/ and it's picked up here —
// no registry to edit. Loaded raw at build (Vite glob) and parsed by the pure
// core parser into ordered blocks; the prose renders to HTML in <Markdown>.
const LESSON_SOURCES = import.meta.glob<string>('./lessons/*.md', {
query: '?raw',
import: 'default',
eager: true,
});
const LESSONS: ParsedLesson[] = Object.values(LESSON_SOURCES).map(parseLesson);
/**
* The deep-dive learning section — a marketing surface at `/learn/`, separate
* from the app, reusing only core + the landing's chart embed. One lesson for
* now; the glob above means more arrive by adding files.
*
* Theme handling mirrors the landing (light/dark via `data-theme` on the root).
* It's duplicated rather than shared because the two entries are independent
* pages; a `useUiTheme` hook could unify them if a third surface appears.
*/
export function LearnPage(): ReactNode {
const [theme, setTheme] = useState<UiTheme>('light');
const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
// TODO: this light/dark toggle is now duplicated verbatim in landing/Landing.tsx —
// extract a shared `useUiTheme()` hook into a home both marketing entries can import
// (not src/app/hooks, which is app-only).
function toggleTheme(): void {
const next: UiTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
document.documentElement.dataset.theme = next;
}
return (
<div className={styles.page}>
<header className={styles.header}>
<a className={styles.brand} href="/">
Astrolabe
</a>
<nav className={styles.nav}>
<span className={styles.kicker}>Vega-Lite, deeper</span>
<button className={styles.ghost} type="button" onClick={toggleTheme}>
{theme === 'dark' ? 'Light' : 'Dark'}
</button>
<a className={styles.appLink} href="/app/">
Open the app
</a>
</nav>
</header>
<main className={styles.main}>
{LESSONS.map((lesson) => (
<LessonArticle key={lesson.slug} lesson={lesson} config={config} />
))}
</main>
<footer className={styles.footer}>
<span>An interactive deep dive into the parts of Vega-Lite the docs gloss over.</span>
<a href="/"> Back to Astrolabe</a>
</footer>
</div>
);
}
type ChartConfig = ReturnType<typeof chartConfigForSelection>;
function LessonArticle({
lesson,
config,
}: {
lesson: ParsedLesson;
config: ChartConfig;
}): ReactNode {
return (
<article className={styles.article}>
<p className={styles.kickerLine}>Deep dive</p>
<h1 className={styles.title}>{lesson.title}</h1>
<p className={styles.tagline}>{lesson.tagline}</p>
{lesson.blocks.map((block, i) => (
<LessonBlockView key={i} block={block} config={config} />
))}
<div className={styles.cta}>
<p>
Open this spec in Astrolabe and keep tweaking edit the JSON, swap the data, theme it.
</p>
<a className={styles.ctaButton} href="/app/">
Open in Astrolabe
</a>
</div>
</article>
);
}
/** Render one lesson block by type — the document is assembled in this order. */
function LessonBlockView({
block,
config,
}: {
block: LessonBlock;
config: ChartConfig;
}): ReactNode {
switch (block.type) {
case 'prose':
return <Markdown className={styles.prose} source={block.markdown} />;
case 'progression':
return (
<div className={styles.block}>
<SpecProgression stages={block.stages} config={config} />
</div>
);
case 'chart':
return (
<div className={styles.block}>
<LandingChart spec={block.spec} config={config} className={styles.chart} />
</div>
);
case 'callout':
return (
<Markdown
className={block.variant === 'sharp-edge' ? styles.sharpEdge : styles.callout}
source={block.markdown}
/>
);
default: {
const exhaustive: never = block;
return exhaustive;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from 'react';
import { injectDatasets, type LessonBlock, type ParsedLesson } from '@core/lesson-parse';
import { chartConfigForSelection } from '@core/vega-themes';
import { SpecProgression } from './SpecProgression';
import { LandingChart } from '../landing/LandingChart';
import { Markdown } from './Markdown';
import styles from './Learn.module.css';
type ChartConfig = ReturnType<typeof chartConfigForSelection>;
/** One lesson, rendered from its parsed blocks in document order, at `/learn/<slug>/`. */
export function LessonView({
lesson,
config,
}: {
lesson: ParsedLesson;
config: ChartConfig;
}): ReactNode {
return (
<article className={styles.article}>
<a className={styles.backLink} href="/learn/">
All deep dives
</a>
<p className={styles.kickerLine}>Deep dive</p>
<h1 className={styles.title}>{lesson.title}</h1>
<p className={styles.tagline}>{lesson.tagline}</p>
{lesson.blocks.map((block, i) => (
<LessonBlockView key={i} block={block} datasets={lesson.datasets} config={config} />
))}
<div className={styles.cta}>
<p>
Open this spec in Astrolabe and keep tweaking edit the JSON, swap the data, theme it.
</p>
<a className={styles.ctaButton} href="/app/">
Open in Astrolabe
</a>
</div>
</article>
);
}
/** Render one lesson block by type — the document is assembled in this order. */
function LessonBlockView({
block,
datasets,
config,
}: {
block: LessonBlock;
datasets: Record<string, unknown[]>;
config: ChartConfig;
}): ReactNode {
switch (block.type) {
case 'prose':
return <Markdown className={styles.prose} source={block.markdown} />;
case 'progression':
return (
<div className={styles.block}>
<SpecProgression stages={block.stages} datasets={datasets} config={config} />
</div>
);
case 'chart':
return (
<div className={styles.block}>
<LandingChart
spec={injectDatasets(block.spec, datasets)}
config={config}
className={styles.chart}
/>
</div>
);
case 'callout':
return (
<Markdown
className={block.variant === 'sharp-edge' ? styles.sharpEdge : styles.callout}
source={block.markdown}
/>
);
default: {
const exhaustive: never = block;
return exhaustive;
}
}
}
+12 -2
View File
@@ -1,6 +1,6 @@
import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import type { Config } from 'vega-lite';
import type { ProgressionStage } from '@core/lesson-parse';
import { injectDatasets, type ProgressionStage } from '@core/lesson-parse';
import { formatSpec } from '@core/json-format';
import { changedLines } from '@core/spec-diff';
import { LandingChart } from '../landing/LandingChart';
@@ -14,6 +14,10 @@ import styles from './SpecProgression.module.css';
* `LandingChart` — the one canonical chart-embed — so Vega stays lazy-loaded and
* every render-lifecycle fix lives in one place.
*
* The source pane and diff show the *authored* spec (data referenced by name);
* only the rendered chart gets the lesson's `datasets` injected, so a shared
* dataset isn't repeated in every stage's visible source.
*
* Stages are a fixed, ordered authored list that never reorders, so the array
* index is their identity (React key + tab/panel id wiring).
*
@@ -22,9 +26,11 @@ import styles from './SpecProgression.module.css';
*/
export function SpecProgression({
stages,
datasets,
config,
}: {
stages: ProgressionStage[];
datasets: Record<string, unknown[]>;
config: Config;
}): ReactNode {
const [active, setActive] = useState(0);
@@ -108,7 +114,11 @@ export function SpecProgression({
</code>
</pre>
<div className={styles.chartPane}>
<LandingChart spec={activeStage.spec} config={config} className={styles.chart} />
<LandingChart
spec={injectDatasets(activeStage.spec, datasets)}
config={config}
className={styles.chart}
/>
</div>
</div>
<Markdown className={styles.note} source={activeStage.note} />
+17
View File
@@ -0,0 +1,17 @@
import { parseLesson, type ParsedLesson } from '@core/lesson-parse';
// Lessons are markdown files: drop a `.md` in lessons/ and it's picked up here —
// no registry to edit. Loaded raw at build (Vite glob) and parsed by the pure core
// parser into ordered blocks. The per-lesson HTML shells (one indexable URL each)
// are generated from the same files in scripts/learn-pages.ts; both read the lesson
// frontmatter, so the routes and the rendered set stay in agreement.
const SOURCES = import.meta.glob<string>('./lessons/*.md', {
query: '?raw',
import: 'default',
eager: true,
});
/** Every parsed lesson, ordered by file path (stable, matches the route generator). */
export const LESSONS: ParsedLesson[] = Object.entries(SOURCES)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, source]) => parseLesson(source));
File diff suppressed because it is too large Load Diff
+21 -6
View File
@@ -1,10 +1,25 @@
import { createRoot } from 'react-dom/client';
import { LearnPage } from './LearnPage';
// Same base styles as the landing — design tokens and self-hosted IBM Plex — so
// the section shares the app's visual language. Like the landing, this entry
// wires no store, no persistence, and no service worker: the learning pages stay
// uncontrolled and always-fresh (the SW is scoped to /app/), and indexable.
import { LearnLayout } from './LearnLayout';
import { LearnIndex } from './LearnIndex';
import { LessonView } from './LessonView';
import { LESSONS } from './lessons';
// Same base styles as the landing — design tokens and self-hosted IBM Plex. Like
// the landing, this entry wires no store, no persistence, and no service worker:
// the learning pages stay uncontrolled and always-fresh (the SW is scoped to
// /app/), and indexable.
import '../../styles/base.css';
import '../../styles/chart-fonts.css';
createRoot(document.getElementById('root')!).render(<LearnPage />);
// Each lesson is its own URL — `/learn/<slug>/`, a generated static shell; the index
// is `/learn/`. One entry renders the right view from the path. An unknown slug
// falls back to the index.
const slug = /\/learn\/([a-z0-9-]+)\/?$/.exec(location.pathname)?.[1];
const lesson = slug ? LESSONS.find((l) => l.slug === slug) : undefined;
createRoot(document.getElementById('root')!).render(
<LearnLayout>
{(config) =>
lesson ? <LessonView lesson={lesson} config={config} /> : <LearnIndex lessons={LESSONS} />
}
</LearnLayout>,
);