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
+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.