diff --git a/.agents/skills/ofb/SKILL.md b/.agents/skills/ofb/SKILL.md index ab042c8..9ee9dd5 100644 --- a/.agents/skills/ofb/SKILL.md +++ b/.agents/skills/ofb/SKILL.md @@ -2,7 +2,7 @@ name: ofb description: >- Develop Open Free Books (OFB): Zola static site, catalog/curriculum JSON, - HTML chapters, Solid.js, JSON Canvas maps, Sass. Use when editing openfreebooks, + HTML chapters, Solid.js, Mermaid catalog maps, Sass. Use when editing openfreebooks, adding subjects/chapters/curriculum, catalog, math content, or deploying to Cloudflare. --- @@ -20,8 +20,8 @@ data/catalog.json → subject list + global curriculum filter chips data/{subject}-curriculum.json → strands, chapters, curriculums[], graph.edges content/{subject}/… → Zola sections (chapter pages at /{subject}/{slug}/) themes/…/partials/{subject}/ → chapter HTML bodies (not Markdown today) -frontend/ → catalog-app, canvas viewer, site chrome -/catalog/ → browse UI (list + JSON Canvas map) +frontend/ → catalog-app, Mermaid map view, site chrome +/catalog/ → browse UI (list + Mermaid map) ``` **Catalog is the subject index.** `/math/` redirects to `/catalog/?subject=math`. Do not revive a separate book index unless asked. @@ -47,7 +47,7 @@ bun run build # production: JS + Zola + pagefind → public/ | Theme static | **Only** commit `themes/openfreebooks/static/js/bundle.js` (+ fonts if any). **Never** commit `public/` or stale HTML under theme `static/` | | Tera | Never name a loaded JSON variable `math` (reserved). Use `math_catalog`, etc. | | Chapter URLs | `/{subjectId}/{chapter-slug}/` (e.g. `/math/quadratic-equations/`) | -| Canvas | [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/); viewer: `json-canvas-viewer` (full build, lazy-loaded on catalog) | +| Map | Mermaid DAG view (`?view=tree`) driven by `graph.edges` | | Graph | `graph.edges` must be **acyclic**; builder throws on cycles | ## Common tasks → where to edit @@ -55,7 +55,7 @@ bun run build # production: JS + Zola + pagefind → public/ | Task | Files | |------|--------| | New global curriculum chip (e.g. GCSE) | `data/catalog.json` → `curriculums[]`; `_catalog.scss` badge; `frontend/src/lib/catalog-badge.ts`; rebuild | -| New subject (sidebar) | `data/catalog.json` → `subjects[]`; `data/{id}-curriculum.json`; **wire catalog template** (today: manual `if subject.id` in `catalog.html` — prefer fixing per [CONTRIBUTING.md](../../../CONTRIBUTING.md)) | +| New subject (sidebar) | `data/catalog.json` → `subjects[]`; `data/{id}-curriculum.json` (catalog template auto-loads by `id`) | | New chapter (planned) | `{subject}-curriculum.json` strand entry; optional `graph.edges` | | New chapter (live) | Above + `content/{subject}/{slug}/_index.md` + HTML partial + `chapter.html` wiring + `main.tsx` widget mount if interactive | | Homepage subject card | `themes/.../templates/index.html` (not yet data-driven) | @@ -78,16 +78,16 @@ bun run build # production: JS + Zola + pagefind → public/ Detailed checklists: [curriculum-data.md](curriculum-data.md). Human-facing guide: [CONTRIBUTING.md](../../../CONTRIBUTING.md). -## Catalog / canvas +## Catalog / map | Piece | Path | |-------|------| | Shell UI (subject, filters, list/map toggle) | `frontend/src/components/catalog-app.tsx` | | Shared chapter card (list + map markup) | `frontend/src/components/catalog-chapter-card.tsx` | -| Map viewer (json-canvas-viewer) | `frontend/src/components/catalog-canvas-view.tsx` (lazy chunk) | -| Canvas layout (strands as columns, DAG levels, node positions) | `frontend/src/lib/catalog-to-canvas.ts` | -| Card height measure + post-render fit | `frontend/src/lib/catalog-chapter-card-layout.ts` | -| Viewer theme (light/dark hex colors) | `frontend/src/lib/catalog-canvas-theme.ts` | +| Map viewer (Mermaid) | `frontend/src/components/catalog-mermaid-view.tsx` (lazy chunk) | +| Mermaid source builder | `frontend/src/lib/catalog-to-mermaid.ts` | +| Mermaid theme (light/dark hex colors) | `frontend/src/lib/catalog-mermaid-theme.ts` | +| Theme-mode helpers used by map | `frontend/src/lib/catalog-canvas-theme.ts` | | Curriculum badge CSS class map | `frontend/src/lib/catalog-badge.ts` | | Types | `data/catalog.types.ts` | | Styles | `themes/openfreebooks/sass/_catalog.scss` | @@ -97,11 +97,11 @@ Detailed checklists: [curriculum-data.md](curriculum-data.md). Human-facing guid **List view:** full-card link for live chapters; number + title row on wide screens. -**Map view:** custom `nodeComponents.text` renders the same card DOM as list (`catalog-chapter-card--map`). Only the **title** is a link (with `→` and animated underline on hover/focus). Planned chapters are non-clickable. Do not make the whole card an ``. +**Map view:** `CatalogMermaidView` renders a Mermaid DAG from `graph.edges`. Only live chapter titles become links; planned chapters are non-clickable. -**Map layout:** one column per strand; Y from DAG level + per-level max card height. Node width `300px` (`CHAPTER_NODE_WIDTH`). Heights: off-screen `scrollHeight` per card, then after viewer paints, `measureRenderedMapChapterCards()` + `relayoutCanvasChapterHeights()` once if any node was too short. Waits for `document.fonts.ready` before first layout. +**Map layout:** generated in `catalog-to-mermaid.ts` with strand subgraphs + edges from curriculum DAG data. -**Canvas viewer quirks:** use `container.get(internal.InteractionHandler).onClick` (not `viewer.onClick`); edge colors must be 8-digit hex (no `rgba` on node/edge color fields); wheel on map uses capture-phase `stopPropagation` unless Ctrl/Cmd so the page can scroll. +**Mermaid quirks:** use `theme: "base"` + hex `themeVariables`; Mermaid applies inline fills, so cluster fills are controlled in Mermaid config, not plain Sass overrides. Prerequisites: **only** `graph.edges` (required, global DAG, cross-strand OK). Longest-path levels. No implicit strand-list chains. List order ≠ map order. @@ -136,10 +136,10 @@ bun run build ## Planned / not implemented (do not assume) -- Defuddle “copy as Markdown” on chapters -- Markdown-based chapters (spec says HTML; README may be outdated) +- Defuddle “copy as Markdown” on non-chapter pages - Data-driven homepage cards -- Single-file subject registration (catalog template still special-cases `math`) +- Single source for subject metadata (`catalog.json` + `*-curriculum.json`) +- Subject scaffold command (`scaffold:subject`) See README **Contributor experience** task list for tracked fixes. diff --git a/.agents/skills/ofb/curriculum-data.md b/.agents/skills/ofb/curriculum-data.md index fe12d12..043a57e 100644 --- a/.agents/skills/ofb/curriculum-data.md +++ b/.agents/skills/ofb/curriculum-data.md @@ -89,16 +89,16 @@ Example: `data/math-curriculum.json`. ### Wiring into catalog -`themes/openfreebooks/templates/catalog.html` must expose strands + graph in `#catalog-data`. **Today only `math` is wired** — adding a subject requires a Tera block like: +`themes/openfreebooks/templates/catalog.html` exposes strands + graph in `#catalog-data` by loading `data/{subject.id}-curriculum.json` for each catalog subject. ```tera -{% set subject_catalog = load_data(path="data/science-curriculum.json", format="json") %} +{% for subject in catalog.subjects %} +{% set subject_curriculum = load_data(path="data/" ~ subject.id ~ "-curriculum.json", format="json") %} ... -"strands": {% if subject.id == "science" %}{{ subject_catalog.strands | json_encode() | safe }}{% else %}[]{% endif %} +"strands": {{ subject_curriculum.strands | default(value=[]) | json_encode() | safe }} +{% endfor %} ``` -**Goal (tracked in README):** auto-load `data/{id}-curriculum.json` by subject id. - ## Status: planned vs live | status | Catalog list | Map node | Content required | @@ -106,7 +106,7 @@ Example: `data/math-curriculum.json`. | `planned` | Shown, “Coming soon” badge | Card shown; title not linked | Only JSON entry | | `live` | Full card links to chapter | Title links to chapter (`→` + underline); rest of card not clickable | `content/` + HTML partial + template wiring | -Map nodes are `type: "text"` in JSON Canvas; the viewer renders custom HTML via `catalog-chapter-card.tsx` (`renderCatalogChapterCardElement`), not markdown. +Map view is Mermaid-based (`CatalogMermaidView` + `catalog-to-mermaid.ts`) and uses `graph.edges` as the source-of-truth DAG. ## Adding a curriculum label @@ -117,18 +117,15 @@ Map nodes are `type: "text"` in JSON Canvas; the viewer renders custom HTML via ## Map card heights (agents) -Do not use fixed node heights or font-metrics libraries for layout. The pipeline is: +Do not hand-place map node positions. The pipeline is: -1. `measureAllMapChapterCardHeights()` — render each card off-screen; read `scrollHeight`. -2. `subjectToCanvas()` — assign node `height` and column Y from DAG levels. -3. After `json-canvas-viewer` paints, `measureRenderedMapChapterCards()` + `relayoutCanvasChapterHeights()` — bump any node still too short and reflow Y / strand groups. +1. `subjectToMermaid()` builds Mermaid source from strands + `graph.edges`. +2. `CatalogMermaidView` renders with Mermaid (`theme: "base"` + hex theme variables). +3. `enhanceStrandClusterHeaders()` post-processes strand header chrome in SVG. Card markup must stay in sync between `catalog-chapter-card.tsx` (Solid + `renderCatalogChapterCardElement`) and `_catalog.scss` (especially `.catalog-chapter-card--map`). -## Validation (manual until scripted) +## Validation -- JSON parses (`jq . data/math-curriculum.json`). -- Every `live` slug has `content/{subject}/{slug}/_index.md`. -- Every `curriculums[]` value is listed in `catalog.json`. -- Graph edges reference existing slugs; no cycles (open catalog map view). +- `bun run validate:curriculum` (scripted checks for slugs, curricula labels, live content paths, and DAG acyclicity). - `bun run build` succeeds. diff --git a/README.md b/README.md index 7b513a1..5b4caac 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Free, open-source textbooks for every learner — from elementary school through | Search | [Pagefind](https://pagefind.app/) (post-build index, Component UI) | | Hosting | Cloudflare Workers (static assets via Wrangler) | -Chapter content is **HTML partials** in the theme today (not Markdown). Canvas maps use the [JSON Canvas](https://jsoncanvas.org/) standard (Obsidian-compatible) via [json-canvas-viewer](https://github.com/Hesprs/JSON-Canvas-Viewer). +Chapter content is **HTML partials** in the theme today (not Markdown). Catalog maps use a Mermaid-powered interactive tree view (pan/zoom). **Contributing curriculum, subjects, or chapters?** See [CONTRIBUTING.md](CONTRIBUTING.md). @@ -52,15 +52,13 @@ Install JS dependencies: bun install ``` -Build the Solid bundle, index search (once per session or after content changes), then start Zola’s dev server: +For day-to-day local development, run JS watch + Zola serve together: ```bash -bun run build:js -bun run index:search -zola serve +bun run dev ``` -Open [http://127.0.0.1:1111](http://127.0.0.1:1111). Re-run `bun run build:js` (or `bun run dev:js` in another terminal) when you change `frontend/`. Re-run `bun run index:search` when you change chapter HTML or add pages so search stays up to date. +Open [http://127.0.0.1:1111](http://127.0.0.1:1111). Re-run `bun run index:search` when you change chapter HTML or add pages so search stays up to date. Search UI is available immediately; results require the index step above (`static/pagefind/` is gitignored and copied from the build output). @@ -98,38 +96,6 @@ The visual design is inspired by [manlung.work](https://manlung.work/): paper ba These issues make contributions harder than they should be. PRs that fix any item are welcome. -### Catalog & data - -- [ ] **Auto-wire subjects in `catalog.html`** — stop hardcoding `if subject.id == "math"`; load `data/{id}-curriculum.json` by subject `id` convention. -- [ ] **Single source for subjects** — merge `catalog.json` subject stubs and `{subject}-curriculum.json` or generate one from the other at build time. -- [ ] **`scripts/validate-curriculum.ts`** — JSON schema check: slugs, `curriculums` ⊆ global list, live chapters have `content/`, `graph.edges` acyclic. -- [ ] **Document or script new curriculum labels** — today requires `catalog.json`, Sass badge, and TS `curriculumBadgeClass()` mapping. - -### Chapters & content - -- [x] **Colocated chapter folders** — `content/{subject}/{slug}/` with `sync:chapters`, generic `chapter.html`, auto widget registry. -- [x] **Strand kicker from front matter** — `[extra] strand` in `_index.md`. -- [ ] **Subject-agnostic catalog load in chapter template** — breadcrumb still loads `math-curriculum.json` literally until more subjects go live. -- [ ] **Markdown chapters (optional)** — README/spec disagree on format; if staying HTML, document clearly everywhere. -- [ ] **Defuddle “copy as Markdown”** — spec calls for it on every page; not implemented. - -### Site & discoverability - -- [ ] **Data-driven homepage cards** — “Browse by subject” / featured blocks duplicate manual edits in `index.html`. -- [x] **Pagefind search** — `/search/`, header modal (⌘K), `bun run index:search` for local dev. -- [ ] **CONTRIBUTING wizard or checklist in CI** — fail PR if live chapter missing partial or catalog entry. - -### Build & repo hygiene - -- [ ] **Prevent stale theme `static/**/*.html`** — committed copies under `themes/openfreebooks/static/` override Zola output (index, catalog, math); add CI check or pre-commit. -- [ ] **Clarify package manager** — project uses **Bun** (`bun run build`); align docs/tooling if pnpm is preferred elsewhere. -- [ ] **`dev` one-liner** — e.g. `bun run dev` running `vite build --watch` + `zola serve` together. - -### New subject onboarding - -- [ ] **Subject scaffold command** — e.g. `bun run scaffold:subject science` creating `data/science-curriculum.json`, `content/science/_index.md`, and catalog entry. -- [ ] **Redirect template generic** — `math-redirect.html` → `subject-redirect.html` using `?subject=` from section path. - ## Agent skills | Skill | Path | Use | diff --git a/package.json b/package.json index 6677565..964df5f 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,14 @@ "scripts": { "sync:chapters": "bun scripts/sync-chapters.ts", "build:js": "bun run sync:chapters && vite build", - "build": "find themes/openfreebooks/static -name '*.html' -delete && rm -f themes/openfreebooks/static/main.css public/main.css && bun run build:js && zola build && bun run index:search", + "build": "bun run check:static-html && bun run validate:curriculum && rm -f themes/openfreebooks/static/main.css public/main.css && bun run build:js && zola build && bun run index:search", "index:search": "pagefind --site public --glob \"**/index.html\" && rm -rf static/pagefind && cp -R public/pagefind static/pagefind", "dev:js": "bun run sync:chapters && vite build --watch", + "dev": "bash scripts/dev.sh", + "check:static-html": "bash scripts/check-static-html.sh", "scan:size": "bun scripts/scan-output-size.ts", - "test": "bun test frontend/src/lib" + "test": "bun test", + "validate:curriculum": "bun scripts/validate-curriculum.ts" }, "dependencies": { "defuddle": "^0.18.1", diff --git a/scripts/check-static-html.sh b/scripts/check-static-html.sh new file mode 100644 index 0000000..4553733 --- /dev/null +++ b/scripts/check-static-html.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +matches="$(find themes/openfreebooks/static -type f -name '*.html' -print)" + +if [[ -n "${matches}" ]]; then + echo "Error: stale HTML files found under themes/openfreebooks/static:" + echo "${matches}" + echo "Remove them so Zola output stays source-of-truth in public/." + exit 1 +fi diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100644 index 0000000..0a1763a --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +cleanup() { + if [[ -n "${JS_PID:-}" ]] && kill -0 "${JS_PID}" 2>/dev/null; then + kill "${JS_PID}" + fi +} + +trap cleanup EXIT INT TERM + +bun run dev:js & +JS_PID=$! + +zola serve diff --git a/scripts/validate-curriculum.ts b/scripts/validate-curriculum.ts new file mode 100644 index 0000000..89436de --- /dev/null +++ b/scripts/validate-curriculum.ts @@ -0,0 +1,277 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +type CatalogFile = { + curriculums?: unknown; +}; + +type Chapter = { + slug?: unknown; + status?: unknown; + curriculums?: unknown; +}; + +type Strand = { + chapters?: unknown; +}; + +type GraphEdge = { + from?: unknown; + to?: unknown; +}; + +type CurriculumFile = { + strands?: unknown; + graph?: { + edges?: unknown; + }; +}; + +type ValidationError = { + file: string; + message: string; +}; + +const ROOT = join(import.meta.dir, ".."); +const DATA_DIR = join(ROOT, "data"); +const CONTENT_DIR = join(ROOT, "content"); + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function chapterRef(strandIndex: number, chapterIndex: number): string { + return `strands[${strandIndex}].chapters[${chapterIndex}]`; +} + +function findCycle(nodes: string[], adjacency: Map): string[] | null { + const color = new Map(); + const stack: string[] = []; + + for (const node of nodes) { + color.set(node, 0); + } + + const visit = (node: string): string[] | null => { + color.set(node, 1); + stack.push(node); + + const nextNodes = adjacency.get(node) ?? []; + for (const nextNode of nextNodes) { + const nextColor = color.get(nextNode) ?? 0; + if (nextColor === 0) { + const cycle = visit(nextNode); + if (cycle) return cycle; + } else if (nextColor === 1) { + const startIndex = stack.indexOf(nextNode); + if (startIndex >= 0) { + return [...stack.slice(startIndex), nextNode]; + } + } + } + + stack.pop(); + color.set(node, 2); + return null; + }; + + for (const node of nodes) { + if ((color.get(node) ?? 0) === 0) { + const cycle = visit(node); + if (cycle) return cycle; + } + } + + return null; +} + +async function readJsonFile(path: string): Promise { + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as T; + } catch (error) { + console.error(`Failed to parse JSON: ${path}`); + console.error(error instanceof Error ? error.message : String(error)); + return null; + } +} + +async function main() { + const errors: ValidationError[] = []; + + const catalogPath = join(DATA_DIR, "catalog.json"); + const catalog = await readJsonFile(catalogPath); + if (!catalog || !Array.isArray(catalog.curriculums)) { + console.error("Cannot validate curriculums: data/catalog.json must contain a curriculums array."); + process.exit(1); + } + + const globalCurriculums = new Set( + catalog.curriculums.filter((id): id is string => isNonEmptyString(id)) + ); + + const entries = await readdir(DATA_DIR, { withFileTypes: true }); + const curriculumFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith("-curriculum.json")) + .map((entry) => entry.name) + .sort(); + + for (const fileName of curriculumFiles) { + const filePath = join(DATA_DIR, fileName); + const subject = fileName.replace(/-curriculum\.json$/, ""); + const data = await readJsonFile(filePath); + + if (!data) { + errors.push({ file: fileName, message: "Invalid JSON." }); + continue; + } + + if (!Array.isArray(data.strands)) { + errors.push({ file: fileName, message: "Missing strands array." }); + continue; + } + + const knownChapterIds = new Set(); + const chapterLocations = new Map(); + + data.strands.forEach((strand, strandIndex) => { + const typedStrand = strand as Strand; + if (!Array.isArray(typedStrand.chapters)) { + errors.push({ + file: fileName, + message: `strands[${strandIndex}].chapters must be an array.`, + }); + return; + } + + typedStrand.chapters.forEach((chapter, chapterIndex) => { + const typedChapter = chapter as Chapter; + const ref = chapterRef(strandIndex, chapterIndex); + + if (!isNonEmptyString(typedChapter.slug)) { + errors.push({ + file: fileName, + message: `${ref}.slug is required and must be a non-empty string.`, + }); + return; + } + + const slug = typedChapter.slug; + knownChapterIds.add(slug); + chapterLocations.set(slug, [...(chapterLocations.get(slug) ?? []), ref]); + + if (typedChapter.curriculums !== undefined) { + if (!Array.isArray(typedChapter.curriculums)) { + errors.push({ + file: fileName, + message: `${ref}.curriculums must be an array of curriculum ids when present.`, + }); + } else { + for (const curriculumId of typedChapter.curriculums) { + if (!isNonEmptyString(curriculumId)) { + errors.push({ + file: fileName, + message: `${ref}.curriculums contains a non-string value.`, + }); + continue; + } + + if (!globalCurriculums.has(curriculumId)) { + errors.push({ + file: fileName, + message: `${ref}.curriculums includes "${curriculumId}", which is not in data/catalog.json curriculums.`, + }); + } + } + } + } + + if (typedChapter.status === "live") { + const chapterIndexPath = join(CONTENT_DIR, subject, slug, "_index.md"); + if (!existsSync(chapterIndexPath)) { + errors.push({ + file: fileName, + message: `${ref} is live but missing content/${subject}/${slug}/_index.md.`, + }); + } + } + }); + }); + + for (const [slug, locations] of chapterLocations) { + if (locations.length > 1) { + errors.push({ + file: fileName, + message: `Duplicate chapter slug "${slug}" found at ${locations.join(", ")}. Slugs must be unique within a subject curriculum file.`, + }); + } + } + + const rawEdges = data.graph?.edges; + if (rawEdges !== undefined && !Array.isArray(rawEdges)) { + errors.push({ file: fileName, message: "graph.edges must be an array when present." }); + continue; + } + + const adjacency = new Map(); + for (const chapterId of knownChapterIds) { + adjacency.set(chapterId, []); + } + + if (Array.isArray(rawEdges)) { + rawEdges.forEach((edge, edgeIndex) => { + const typedEdge = edge as GraphEdge; + const edgeRef = `graph.edges[${edgeIndex}]`; + + if (!isNonEmptyString(typedEdge.from) || !isNonEmptyString(typedEdge.to)) { + errors.push({ + file: fileName, + message: `${edgeRef} must contain non-empty string "from" and "to" chapter ids.`, + }); + return; + } + + const from = typedEdge.from; + const to = typedEdge.to; + + if (!knownChapterIds.has(from)) { + errors.push({ + file: fileName, + message: `${edgeRef}.from "${from}" does not match any chapter slug in this file.`, + }); + } + if (!knownChapterIds.has(to)) { + errors.push({ + file: fileName, + message: `${edgeRef}.to "${to}" does not match any chapter slug in this file.`, + }); + } + + if (knownChapterIds.has(from) && knownChapterIds.has(to)) { + adjacency.set(from, [...(adjacency.get(from) ?? []), to]); + } + }); + } + + const cycle = findCycle([...knownChapterIds], adjacency); + if (cycle) { + errors.push({ + file: fileName, + message: `graph.edges must be acyclic. Found cycle: ${cycle.join(" -> ")}.`, + }); + } + } + + if (errors.length > 0) { + console.error(`\n✖ Curriculum validation failed with ${errors.length} error(s):`); + for (const error of errors) { + console.error(`- [${error.file}] ${error.message}`); + } + process.exit(1); + } + + console.log(`✓ Curriculum validation passed for ${curriculumFiles.length} curriculum file(s).`); +} + +main();