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:
Hananoshika Yomaru 2026-05-20 22:22:08 -07:00
parent 3f3ea4a36c
commit 562c225884
7 changed files with 340 additions and 71 deletions

View 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
View 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

View 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();