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:
@@ -14,3 +14,7 @@ coverage
|
|||||||
|
|
||||||
# M1.5 visual verification screenshots (local only)
|
# M1.5 visual verification screenshots (local only)
|
||||||
.m15-screenshots/
|
.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/*/
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
An interactive deep-dive into Vega-Lite, served at `/learn/`: a marketing surface separate
|
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
|
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
|
## A marketing surface, like the landing
|
||||||
|
|
||||||
@@ -14,6 +17,12 @@ teach the grammar's dormant power and funnel readers into the app.
|
|||||||
stays light.
|
stays light.
|
||||||
- **Out of PWA scope.** The service worker is scoped to `/app/`, so the learning pages stay
|
- **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.
|
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
|
## Lessons are markdown; the renderer is general
|
||||||
|
|
||||||
@@ -22,22 +31,30 @@ A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.g
|
|||||||
document of ordered blocks_, not a fixed template:
|
document of ordered blocks_, not a fixed template:
|
||||||
|
|
||||||
| Authored as | Block | Rendered by |
|
| Authored as | Block | Rendered by |
|
||||||
| ---------------------------------------------------- | ------------- | --------------------------- |
|
| ---------------------------------------------------- | ------------- | ----------------------------- |
|
||||||
| plain markdown | `prose` | `Markdown` |
|
| plain markdown | `prose` | `Markdown` |
|
||||||
| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
|
| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
|
||||||
| a bare fenced `vega-lite` block | `chart` | `LandingChart` |
|
| a bare fenced `vega-lite` block | `chart` | `LandingChart` |
|
||||||
| `:::name … :::` | `callout` | `Markdown` (styled by name) |
|
| `:::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,
|
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
|
the following fenced `vega-lite` block → spec. A `:::data` block names datasets once for the
|
||||||
is no shared-data construct.
|
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
|
## The pipeline
|
||||||
|
|
||||||
`lessons/*.md` → `import.meta.glob` (in `LearnPage`) → `parseLesson` (`core/lesson-parse`) →
|
`lessons/*.md` → `import.meta.glob` + `parseLesson` (`src/learn/lessons.ts`, using
|
||||||
`LessonBlock[]` → `LearnPage` block dispatch → `Markdown` | `SpecProgression` | `LandingChart`.
|
`core/lesson-parse`) → `LESSONS`. The `src/learn` entry reads the path: `/learn/` →
|
||||||
`SpecProgression` renders each stage's spec with `formatSpec` (`core/json-format`) and
|
`LearnIndex`, `/learn/<slug>/` → `LessonView`, both inside `LearnLayout` (shared
|
||||||
highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS line-diff).
|
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
|
## 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.
|
us), never user input — not an XSS surface.
|
||||||
- Lesson specs are fenced JSON parsed with `JSON.parse`; the source pane re-formats them with
|
- 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.
|
`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.
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseLesson, type LessonBlock } from './lesson-parse';
|
import { injectDatasets, parseLesson, type LessonBlock } from './lesson-parse';
|
||||||
|
|
||||||
const LESSON = `---
|
const LESSON = `---
|
||||||
slug: binning
|
slug: binning
|
||||||
@@ -105,6 +105,12 @@ describe('parseLesson — block model', () => {
|
|||||||
expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/);
|
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', () => {
|
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:::`;
|
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/);
|
expect(() => parseLesson(bad)).toThrow(/stage "a": invalid JSON/);
|
||||||
@@ -124,4 +130,76 @@ describe('parseLesson — block model', () => {
|
|||||||
'callout',
|
'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
@@ -22,11 +22,15 @@
|
|||||||
*
|
*
|
||||||
* Free intro prose (markdown)…
|
* Free intro prose (markdown)…
|
||||||
*
|
*
|
||||||
|
* :::data
|
||||||
|
* { "ev": [ {…row…}, {…row…} ] }
|
||||||
|
* :::
|
||||||
|
*
|
||||||
* :::progression
|
* :::progression
|
||||||
* ## raw counts
|
* ## raw counts
|
||||||
* Note prose for this stage (markdown)…
|
* Note prose for this stage (markdown)…
|
||||||
* ```vega-lite
|
* ```vega-lite
|
||||||
* { …spec… }
|
* { "data": { "name": "ev" }, …spec… }
|
||||||
* ```
|
* ```
|
||||||
* ## + bin
|
* ## + bin
|
||||||
* …
|
* …
|
||||||
@@ -42,10 +46,13 @@
|
|||||||
* A callout (markdown)…
|
* A callout (markdown)…
|
||||||
* :::
|
* :::
|
||||||
*
|
*
|
||||||
* A `:::progression` block holds `##`-delimited stages (heading → tab label, prose
|
* A `:::data` block names datasets once for the whole lesson (body: a JSON object
|
||||||
* → note, fenced `vega-lite` → spec). A bare fenced `vega-lite` block is a
|
* of `name → rows`); specs reference them by `{ "name": … }` and the rows are
|
||||||
* standalone chart. Any other `:::name` block is a callout. Everything else is
|
* injected at render time, so they aren't repeated per stage. A `:::progression`
|
||||||
* prose, emitted in the order it appears.
|
* 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 {
|
export interface ProgressionStage {
|
||||||
@@ -81,6 +88,12 @@ export interface ParsedLesson {
|
|||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
tagline: 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. */
|
/** The lesson body, in document order. */
|
||||||
blocks: LessonBlock[];
|
blocks: LessonBlock[];
|
||||||
}
|
}
|
||||||
@@ -100,14 +113,25 @@ export function parseLesson(source: string): ParsedLesson {
|
|||||||
if (!fm) throw new Error('lesson: missing `---` frontmatter block');
|
if (!fm) throw new Error('lesson: missing `---` frontmatter block');
|
||||||
const meta = parseFrontmatter(fm[1]);
|
const meta = parseFrontmatter(fm[1]);
|
||||||
const slug = required(meta, 'slug');
|
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 title = required(meta, 'title');
|
||||||
const tagline = required(meta, 'tagline');
|
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 blocks: LessonBlock[] = [];
|
||||||
|
const datasets: Record<string, unknown[]> = {};
|
||||||
const pushProse = (text: string): void => {
|
const pushProse = (text: string): void => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (trimmed) blocks.push({ type: 'prose', markdown: trimmed });
|
if (trimmed) blocks.push({ type: 'prose', markdown: trimmed });
|
||||||
@@ -121,13 +145,15 @@ function parseBlocks(body: string, slug: string): LessonBlock[] {
|
|||||||
if (m[1] !== undefined) {
|
if (m[1] !== undefined) {
|
||||||
const variant = m[1];
|
const variant = m[1];
|
||||||
const inner = m[2];
|
const inner = m[2];
|
||||||
// TODO: if lessons multiply, add a `:::data` directive whose values stages
|
if (variant === 'progression') {
|
||||||
// inherit — today a lesson's inline data is repeated in every fenced spec.
|
blocks.push({ type: 'progression', stages: parseStages(inner, slug) });
|
||||||
blocks.push(
|
} else if (variant === 'data') {
|
||||||
variant === 'progression'
|
// Lesson metadata, not a rendered block: fold the named rows into the
|
||||||
? { type: 'progression', stages: parseStages(inner, slug) }
|
// lesson's dataset map for render-time injection.
|
||||||
: { type: 'callout', variant, markdown: inner.trim() },
|
Object.assign(datasets, parseDatasets(inner, slug));
|
||||||
);
|
} else {
|
||||||
|
blocks.push({ type: 'callout', variant, markdown: inner.trim() });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
blocks.push({ type: 'chart', spec: parseSpec(m[3], slug, 'chart') });
|
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));
|
pushProse(body.slice(cursor));
|
||||||
|
|
||||||
if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`);
|
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[] {
|
function parseStages(inner: string, slug: string): ProgressionStage[] {
|
||||||
|
|||||||
@@ -353,6 +353,9 @@ export function Landing(): ReactNode {
|
|||||||
<a className={styles.navLink} href="#theme">
|
<a className={styles.navLink} href="#theme">
|
||||||
Theming
|
Theming
|
||||||
</a>
|
</a>
|
||||||
|
<a className={styles.navLink} href="/learn/">
|
||||||
|
Deep dives
|
||||||
|
</a>
|
||||||
<button className={`${styles.btn} ${styles.btnSm}`} type="button" onClick={toggleTheme}>
|
<button className={`${styles.btn} ${styles.btnSm}`} type="button" onClick={toggleTheme}>
|
||||||
{theme === 'dark' ? 'Light' : 'Dark'}
|
{theme === 'dark' ? 'Light' : 'Dark'}
|
||||||
</button>
|
</button>
|
||||||
@@ -648,6 +651,7 @@ export function Landing(): ReactNode {
|
|||||||
<Brand />
|
<Brand />
|
||||||
<span className={styles.footerSp} />
|
<span className={styles.footerSp} />
|
||||||
<a href="/app/">Open the app</a>
|
<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>
|
<a href="https://vega.github.io/vega-lite/">Built on Vega-Lite</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -27,10 +27,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
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 {
|
.kicker {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.ghost {
|
.ghost {
|
||||||
height: var(--control-height);
|
height: var(--control-height);
|
||||||
@@ -190,6 +193,86 @@
|
|||||||
color: var(--text-secondary);
|
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) {
|
@media (max-width: 720px) {
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||||
import type { Config } from 'vega-lite';
|
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 { formatSpec } from '@core/json-format';
|
||||||
import { changedLines } from '@core/spec-diff';
|
import { changedLines } from '@core/spec-diff';
|
||||||
import { LandingChart } from '../landing/LandingChart';
|
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
|
* `LandingChart` — the one canonical chart-embed — so Vega stays lazy-loaded and
|
||||||
* every render-lifecycle fix lives in one place.
|
* 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
|
* Stages are a fixed, ordered authored list that never reorders, so the array
|
||||||
* index is their identity (React key + tab/panel id wiring).
|
* index is their identity (React key + tab/panel id wiring).
|
||||||
*
|
*
|
||||||
@@ -22,9 +26,11 @@ import styles from './SpecProgression.module.css';
|
|||||||
*/
|
*/
|
||||||
export function SpecProgression({
|
export function SpecProgression({
|
||||||
stages,
|
stages,
|
||||||
|
datasets,
|
||||||
config,
|
config,
|
||||||
}: {
|
}: {
|
||||||
stages: ProgressionStage[];
|
stages: ProgressionStage[];
|
||||||
|
datasets: Record<string, unknown[]>;
|
||||||
config: Config;
|
config: Config;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
@@ -108,7 +114,11 @@ export function SpecProgression({
|
|||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
<div className={styles.chartPane}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Markdown className={styles.note} source={activeStage.note} />
|
<Markdown className={styles.note} source={activeStage.note} />
|
||||||
|
|||||||
@@ -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
@@ -1,10 +1,25 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { LearnPage } from './LearnPage';
|
import { LearnLayout } from './LearnLayout';
|
||||||
// Same base styles as the landing — design tokens and self-hosted IBM Plex — so
|
import { LearnIndex } from './LearnIndex';
|
||||||
// the section shares the app's visual language. Like the landing, this entry
|
import { LessonView } from './LessonView';
|
||||||
// wires no store, no persistence, and no service worker: the learning pages stay
|
import { LESSONS } from './lessons';
|
||||||
// uncontrolled and always-fresh (the SW is scoped to /app/), and indexable.
|
// 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/base.css';
|
||||||
import '../../styles/chart-fonts.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
@@ -3,11 +3,18 @@ import react from '@vitejs/plugin-react';
|
|||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 {
|
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as {
|
||||||
version: string;
|
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({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -24,11 +31,14 @@ export default defineConfig({
|
|||||||
// lightweight `src/landing` entry); the app at `/app/` (app/index.html →
|
// 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/.
|
// src/main.tsx). The app is hash-routed, so it runs unchanged under /app/.
|
||||||
// `/learn/` is the deep-dive learning section — another light marketing
|
// `/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: {
|
input: {
|
||||||
main: fileURLToPath(new URL('./index.html', import.meta.url)),
|
main: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||||
app: fileURLToPath(new URL('./app/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)),
|
learn: fileURLToPath(new URL('./learn/index.html', import.meta.url)),
|
||||||
|
...lessonInputs(),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
// Split the heavy vendors so the chunk graph stays legible and the PWA
|
// Split the heavy vendors so the chunk graph stays legible and the PWA
|
||||||
|
|||||||
Reference in New Issue
Block a user