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
+4
View File
@@ -14,3 +14,7 @@ coverage
# M1.5 visual verification screenshots (local only)
.m15-screenshots/
# Generated per-lesson page shells (learn/<slug>/index.html) — produced from the
# lesson .md frontmatter by scripts/learn-pages.ts on every dev/build.
learn/*/
+34 -13
View File
@@ -2,7 +2,10 @@
An interactive deep-dive into Vega-Lite, served at `/learn/`: a marketing surface separate
from the app, where each lesson walks a spec from an 80%-naive version to a polished one to
teach the grammar's dormant power and funnel readers into the app.
teach the grammar's dormant power and funnel readers into the app. It is pitched past the
basics — fundamentals are left to the official Vega-Lite docs (linked from the index), so
the section leads with advanced cases (interaction, composition, transforms) rather than
fundamentals.
## A marketing surface, like the landing
@@ -14,6 +17,12 @@ teach the grammar's dormant power and funnel readers into the app.
stays light.
- **Out of PWA scope.** The service worker is scoped to `/app/`, so the learning pages stay
uncontrolled, always-fresh, and indexable — the point for organic-reach content.
- **An index plus a page per lesson.** `/learn/` is the index (a card per lesson); each
lesson is its own URL `/learn/<slug>/` — a separate indexable document with its
frontmatter-derived `<title>`/`<meta>`. The per-lesson HTML shells are generated from the
lesson frontmatter by `scripts/learn-pages.ts` (run from `vite.config` on every dev/build,
so "drop a file" still holds; the shells are git-ignored). The single `src/learn` entry
renders the index or one lesson from `location.pathname`.
## Lessons are markdown; the renderer is general
@@ -21,23 +30,31 @@ A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.g
**adding a lesson is dropping a file**, with no registry to edit. A lesson is a _free-form
document of ordered blocks_, not a fixed template:
| Authored as | Block | Rendered by |
| ---------------------------------------------------- | ------------- | --------------------------- |
| plain markdown | `prose` | `Markdown` |
| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
| a bare fenced `vega-lite` block | `chart` | `LandingChart` |
| `:::name … :::` | `callout` | `Markdown` (styled by name) |
| Authored as | Block | Rendered by |
| ---------------------------------------------------- | ------------- | ----------------------------- |
| plain markdown | `prose` | `Markdown` |
| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
| a bare fenced `vega-lite` block | `chart` | `LandingChart` |
| `:::name … :::` | `callout` | `Markdown` (styled by name) |
| `:::data` wrapping a `{ name: rows }` JSON object | — metadata | injected into specs at render |
Inside a `:::progression`, each `##` heading is a stage: heading → tab label, prose → note,
the following fenced `vega-lite` block → spec. Inline data repeats per fenced block — there
is no shared-data construct.
the following fenced `vega-lite` block → spec. A `:::data` block names datasets once for the
whole lesson; specs reference them with `{ "data": { "name": … } }` and `injectDatasets`
merges the rows in at render time — so a shared dataset isn't repeated per stage, and the
source pane keeps a stage's grammar legible instead of burying it under data.
## The pipeline
`lessons/*.md``import.meta.glob` (in `LearnPage`) → `parseLesson` (`core/lesson-parse`) →
`LessonBlock[]``LearnPage` block dispatch → `Markdown` | `SpecProgression` | `LandingChart`.
`SpecProgression` renders each stage's spec with `formatSpec` (`core/json-format`) and
highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS line-diff).
`lessons/*.md``import.meta.glob` + `parseLesson` (`src/learn/lessons.ts`, using
`core/lesson-parse`)`LESSONS`. The `src/learn` entry reads the path: `/learn/`
`LearnIndex`, `/learn/<slug>/``LessonView`, both inside `LearnLayout` (shared
header/footer/theme). `LessonView` dispatches each block → `Markdown` | `SpecProgression` |
`LandingChart`. `SpecProgression` renders each stage's spec with `formatSpec`
(`core/json-format`) and highlights what the stage changed with `changedLines`
(`core/spec-diff`, an LCS line-diff). `parseLesson` also returns `datasets` (the `:::data`
blocks); `LessonView`/`SpecProgression` call `injectDatasets` so only the _rendered_ spec
carries the rows — the displayed-and-diffed spec keeps its by-name reference.
## Rules
@@ -52,3 +69,7 @@ highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS
us), never user input — not an XSS surface.
- Lesson specs are fenced JSON parsed with `JSON.parse`; the source pane re-formats them with
`formatSpec` so the shown JSON matches the editor's house style.
- **Lesson charts render through `LandingChart` with `fitMode: 'width'`** — which sets
`width: "container"` and drops fixed heights on every view, and container width only works
for a single or layered view, not side-by-side. A multi-view lesson is therefore a
`vconcat` (a stacked column), not an `hconcat` dashboard, which would fight the sizing.
+93
View File
@@ -0,0 +1,93 @@
/**
* Generate one static `learn/<slug>/index.html` shell per lesson, so each lesson is
* its own indexable URL (`/learn/<slug>/`) carrying lesson-specific `<title>` and
* `<meta description>`. The shared `src/learn` entry renders the right view from the
* path. Driven by the lesson `.md` frontmatter — adding a lesson stays "drop a
* file" — and run from `vite.config.ts` at load (dev and build) so the shells stay
* in sync; the generated dirs are git-ignored.
*
* Plain content-shells only (per-URL metadata, client-rendered body). Prerendering
* the prose into the HTML would help non-JS crawlers but is deliberately deferred.
*/
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const ROOT = new URL('../', import.meta.url);
const LESSONS_DIR = fileURLToPath(new URL('src/learn/lessons/', ROOT));
const LEARN_DIR = fileURLToPath(new URL('learn/', ROOT));
interface LessonMeta {
slug: string;
title: string;
tagline: string;
}
// A minimal frontmatter reader, separate from `core/lesson-parse`'s `parseLesson`:
// this runs at vite-config load time, before the `@core` alias is registered, and
// only needs the three header fields (not the parsed body). The two must agree on
// the frontmatter shape — both read `slug`/`title`/`tagline` from `--- … ---`.
/** Pull `slug`/`title`/`tagline` from a lesson's `--- … ---` frontmatter. */
function frontmatter(md: string): LessonMeta | null {
const block = md.match(/^---\n([\s\S]*?)\n---/);
if (!block) return null;
const field = (key: string): string | undefined =>
block[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1].trim();
const slug = field('slug');
const title = field('title');
const tagline = field('tagline');
return slug && title && tagline ? { slug, title, tagline } : null;
}
/** Lesson metadata for every `src/learn/lessons/*.md`, sorted by slug. */
function readLessons(): LessonMeta[] {
return readdirSync(LESSONS_DIR)
.filter((f) => f.endsWith('.md'))
.map((f) => frontmatter(readFileSync(LESSONS_DIR + f, 'utf8')))
.filter((l): l is LessonMeta => l !== null)
.sort((a, b) => a.slug.localeCompare(b.slug));
}
/** Rollup inputs (one per lesson) → the generated `learn/<slug>/index.html` shells. */
export function lessonInputs(): Record<string, string> {
const input: Record<string, string> = {};
for (const { slug } of readLessons()) input[`learn-${slug}`] = `${LEARN_DIR}${slug}/index.html`;
return input;
}
const escapeHtml = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
function shell({ title, tagline }: LessonMeta): string {
return `<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="${escapeHtml(tagline)}" />
<title>${escapeHtml(title)} — Astrolabe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/learn/main.tsx"></script>
</body>
</html>
`;
}
/** Write a shell per lesson and prune dirs for lessons that no longer exist. */
export function generateLearnPages(): void {
const lessons = readLessons();
const wanted = new Set(lessons.map((l) => l.slug));
for (const lesson of lessons) {
mkdirSync(`${LEARN_DIR}${lesson.slug}/`, { recursive: true });
writeFileSync(`${LEARN_DIR}${lesson.slug}/index.html`, shell(lesson));
}
for (const entry of readdirSync(LEARN_DIR, { withFileTypes: true })) {
if (entry.isDirectory() && !wanted.has(entry.name)) {
rmSync(`${LEARN_DIR}${entry.name}`, { recursive: true, force: true });
}
}
}
+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>,
);
+11 -1
View File
@@ -3,11 +3,18 @@ import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { generateLearnPages, lessonInputs } from './scripts/learn-pages';
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as {
version: string;
};
// Write a static `learn/<slug>/index.html` shell per lesson (from the lesson `.md`
// frontmatter) before the inputs below reference them. Regenerated whenever Vite
// (re)loads its config — every dev start and build — so a newly added lesson needs a
// dev restart to get its page; the generated dirs are git-ignored.
generateLearnPages();
export default defineConfig({
resolve: {
alias: {
@@ -24,11 +31,14 @@ export default defineConfig({
// lightweight `src/landing` entry); the app at `/app/` (app/index.html →
// src/main.tsx). The app is hash-routed, so it runs unchanged under /app/.
// `/learn/` is the deep-dive learning section — another light marketing
// entry (src/learn), reusing core + the landing's chart embed only.
// entry (src/learn), reusing core + the landing's chart embed only. Its index
// is `learn/index.html`; each lesson is its own page (`learn/<slug>/`) from a
// generated shell, added to the inputs here via `lessonInputs()`.
input: {
main: fileURLToPath(new URL('./index.html', import.meta.url)),
app: fileURLToPath(new URL('./app/index.html', import.meta.url)),
learn: fileURLToPath(new URL('./learn/index.html', import.meta.url)),
...lessonInputs(),
},
output: {
// Split the heavy vendors so the chunk graph stays legible and the PWA