mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-12 20:45:52 +00:00
Update build scripts and README: Enhance build command to include static HTML checks and curriculum validation, streamline development process with new dev script, and clarify local development instructions. Update curriculum wiring in catalog to support dynamic subject loading and improve Mermaid map integration.
This commit is contained in:
parent
3f3ea4a36c
commit
562c225884
7 changed files with 340 additions and 71 deletions
|
|
@ -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 `<a>`.
|
||||
**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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
42
README.md
42
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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
11
scripts/check-static-html.sh
Normal file
11
scripts/check-static-html.sh
Normal file
|
|
@ -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
|
||||
15
scripts/dev.sh
Normal file
15
scripts/dev.sh
Normal file
|
|
@ -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
|
||||
277
scripts/validate-curriculum.ts
Normal file
277
scripts/validate-curriculum.ts
Normal file
|
|
@ -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, string[]>): string[] | null {
|
||||
const color = new Map<string, 0 | 1 | 2>();
|
||||
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<T>(path: string): Promise<T | null> {
|
||||
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<CatalogFile>(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<CurriculumFile>(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<string>();
|
||||
const chapterLocations = new Map<string, string[]>();
|
||||
|
||||
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<string, string[]>();
|
||||
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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue