Update contributing guidelines and enhance build scripts: Add detailed instructions for build modes in CONTRIBUTING.md, clarifying the use of incremental and full rebuilds. Revise package.json to streamline build commands and introduce new dev scripts for better development workflow. Remove deprecated integer arithmetic chapter files and update curriculum JSON to reflect the latest structure. Enhance Pythagorean theorem content with a new vocabulary section for improved clarity.

This commit is contained in:
Hananoshika Yomaru 2026-05-21 23:55:33 -07:00
parent 669fbd59fa
commit 30b4f4cf53
16 changed files with 496 additions and 291 deletions

View file

@ -0,0 +1,45 @@
import { existsSync } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import { join, relative } from "node:path";
import { createHash } from "node:crypto";
type FingerprintRecord = {
path: string;
mtimeMs: number;
size: number;
};
async function collectRecords(root: string, absPath: string): Promise<FingerprintRecord[]> {
if (!existsSync(absPath)) return [];
const info = await stat(absPath);
if (info.isDirectory()) {
const entries = await readdir(absPath, { withFileTypes: true });
const records: FingerprintRecord[] = [];
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const child = join(absPath, entry.name);
records.push(...(await collectRecords(root, child)));
}
return records;
}
return [
{
path: relative(root, absPath).replace(/\\/g, "/"),
mtimeMs: Math.trunc(info.mtimeMs),
size: info.size,
},
];
}
export async function fingerprintPaths(root: string, paths: string[]): Promise<string> {
const all: FingerprintRecord[] = [];
for (const absPath of paths) {
all.push(...(await collectRecords(root, absPath)));
}
all.sort((a, b) => a.path.localeCompare(b.path));
const hash = createHash("sha256");
for (const record of all) {
hash.update(`${record.path}|${record.mtimeMs}|${record.size}\n`);
}
return hash.digest("hex");
}

206
scripts/build-smart.ts Normal file
View file

@ -0,0 +1,206 @@
import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { $ } from "bun";
import { fingerprintPaths } from "./build-change-detector";
const ROOT = join(import.meta.dir, "..");
const CACHE_PATH = join(ROOT, "data/_generated/build-smart-cache.json");
const GENERATED_WIDGETS = join(ROOT, "frontend/src/generated/chapter-widgets.ts");
const GENERATED_SUBJECT_MODULES = join(ROOT, "frontend/src/generated/subject-modules.ts");
const BUNDLE_PATH = join(ROOT, "themes/openfreebooks/static/js/bundle.js");
const PUBLIC_INDEX = join(ROOT, "public/index.html");
const CHAPTER_LASTMOD_PATH = join(ROOT, "data/_generated/chapter-lastmod.json");
const PAGEFIND_DIR = join(ROOT, "static/pagefind");
type BuildCache = {
version: 1;
fingerprints: {
syncInputs: string;
jsInputs: string;
siteInputs: string;
metadataInputs: string;
generatedModules: string;
};
};
async function listCurriculumFiles(): Promise<string[]> {
const dataDir = join(ROOT, "data");
const entries = await readdir(dataDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => join(dataDir, entry.name));
}
async function listContentJsInputs(): Promise<string[]> {
const contentDir = join(ROOT, "content");
const inputs: string[] = [];
const queue = [contentDir];
while (queue.length) {
const current = queue.pop();
if (!current) continue;
const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const abs = join(current, entry.name);
if (entry.isDirectory()) {
queue.push(abs);
continue;
}
if (entry.name === "subject.ts" || abs.includes("/widgets/")) {
inputs.push(abs);
}
}
}
return inputs;
}
async function listStaticSourceInputs(): Promise<string[]> {
const staticDir = join(ROOT, "static");
if (!existsSync(staticDir)) return [];
const inputs: string[] = [];
const queue = [staticDir];
while (queue.length) {
const current = queue.pop();
if (!current) continue;
const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const abs = join(current, entry.name);
if (entry.isDirectory()) {
if (abs === join(staticDir, "pagefind")) continue;
if (abs === join(staticDir, "chapters")) continue;
if (abs === join(staticDir, "css")) continue;
queue.push(abs);
continue;
}
inputs.push(abs);
}
}
return inputs;
}
async function readCache(): Promise<BuildCache | null> {
if (!existsSync(CACHE_PATH)) return null;
try {
const parsed = JSON.parse(await readFile(CACHE_PATH, "utf8")) as BuildCache;
if (parsed.version !== 1) return null;
return parsed;
} catch {
return null;
}
}
async function runStep(command: string) {
const result = await $`bash -lc ${command}`;
if (result.exitCode !== 0) {
process.exit(result.exitCode);
}
}
async function runFullBuild() {
await runStep("bun run check:static-html");
await runStep("bun run validate:curriculum");
await runStep("rm -f themes/openfreebooks/static/main.css public/main.css");
await runStep("bun run sync:chapters -- --full");
await runStep("vite build");
await runStep("bun run generate:site-metadata");
await runStep("zola build");
await runStep("bun run index:search");
}
async function main() {
if (process.env.CI === "true") {
await runFullBuild();
return;
}
const curriculumFiles = await listCurriculumFiles();
const contentJsInputs = await listContentJsInputs();
const staticSourceInputs = await listStaticSourceInputs();
const fingerprints = {
syncInputs: await fingerprintPaths(ROOT, [
join(ROOT, "content"),
join(ROOT, "scripts/sync-chapters.ts"),
join(ROOT, "scripts/build-change-detector.ts"),
...curriculumFiles,
]),
jsInputs: await fingerprintPaths(ROOT, [
join(ROOT, "frontend"),
join(ROOT, "vite.config.ts"),
...contentJsInputs,
]),
siteInputs: await fingerprintPaths(ROOT, [
join(ROOT, "content"),
join(ROOT, "themes/openfreebooks/templates"),
join(ROOT, "themes/openfreebooks/sass"),
join(ROOT, "zola.toml"),
...staticSourceInputs,
]),
metadataInputs: await fingerprintPaths(ROOT, [
join(ROOT, "content"),
join(ROOT, "zola.toml"),
join(ROOT, "scripts/generate-site-metadata.ts"),
...curriculumFiles,
]),
generatedModules: "",
};
const previous = await readCache();
const previousFingerprints = previous?.fingerprints;
const firstRun = !previous;
const hasGeneratedModules = existsSync(GENERATED_WIDGETS) && existsSync(GENERATED_SUBJECT_MODULES);
const needsSync =
firstRun || !hasGeneratedModules || previousFingerprints?.syncInputs !== fingerprints.syncInputs;
await runStep("bun run check:static-html");
await runStep("bun run validate:curriculum");
if (needsSync) {
await runStep("bun run sync:chapters");
}
fingerprints.generatedModules = await fingerprintPaths(ROOT, [GENERATED_WIDGETS, GENERATED_SUBJECT_MODULES]);
const needsJs =
firstRun ||
!existsSync(BUNDLE_PATH) ||
previousFingerprints?.jsInputs !== fingerprints.jsInputs ||
previousFingerprints?.generatedModules !== fingerprints.generatedModules;
if (needsJs) {
await runStep("rm -f themes/openfreebooks/static/main.css public/main.css");
await runStep("vite build");
}
const needsMetadata =
firstRun ||
!existsSync(CHAPTER_LASTMOD_PATH) ||
previousFingerprints?.metadataInputs !== fingerprints.metadataInputs;
if (needsMetadata) {
await runStep("bun run generate:site-metadata");
}
const needsSite =
firstRun ||
!existsSync(PUBLIC_INDEX) ||
needsJs ||
needsMetadata ||
previousFingerprints?.siteInputs !== fingerprints.siteInputs;
if (needsSite) {
await runStep("zola build");
}
if (needsSite || !existsSync(PAGEFIND_DIR)) {
await runStep("bun run index:search");
}
console.log(
`[build-smart] sync=${needsSync} js=${needsJs} metadata=${needsMetadata} site=${needsSite} firstRun=${firstRun}`
);
const nextCache: BuildCache = { version: 1, fingerprints };
await mkdir(dirname(CACHE_PATH), { recursive: true });
await writeFile(CACHE_PATH, `${JSON.stringify(nextCache, null, 2)}\n`);
}
main();

View file

@ -9,6 +9,12 @@ cleanup() {
trap cleanup EXIT INT TERM
if [[ "${1:-}" == "--full" ]]; then
bun run sync:chapters -- --full
else
bun run sync:chapters
fi
bun run dev:js &
JS_PID=$!

View file

@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, rm, writeFile, copyFile } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
import { $ } from "bun";
import { fingerprintPaths } from "./build-change-detector";
const ROOT = join(import.meta.dir, "..");
const CONTENT = join(ROOT, "content");
@ -10,6 +11,7 @@ const GENERATED_NAV = join(ROOT, "data/_generated/chapter-nav.json");
const GENERATED_WIDGETS = join(ROOT, "frontend/src/generated/chapter-widgets.ts");
const GENERATED_SUBJECT_MODULES = join(ROOT, "frontend/src/generated/subject-modules.ts");
const GENERATED_SUBJECTS_META = join(ROOT, "data/_generated/subjects-meta.json");
const SYNC_CACHE_PATH = join(ROOT, "data/_generated/sync-chapters-cache.json");
const STATIC_ROOT = join(ROOT, "static");
const CHAPTER_CSS_DIR = join(STATIC_ROOT, "css/chapters");
const SUBJECT_CSS_DIR = join(STATIC_ROOT, "css/subjects");
@ -56,6 +58,23 @@ type SubjectsMetaFile = {
subjects: Record<string, SubjectMeta>;
};
type ChapterCacheEntry = {
fingerprint: string;
meta: ChapterMeta;
widgets: string[];
};
type SubjectCacheEntry = {
fingerprint: string;
meta: SubjectMeta;
};
type SyncCacheFile = {
version: 1;
chapters: Record<string, ChapterCacheEntry>;
subjects: Record<string, SubjectCacheEntry>;
};
function parseChapterTemplate(markdown: string): boolean {
return /template\s*=\s*["']chapter\.html["']/.test(markdown);
}
@ -159,6 +178,10 @@ async function syncSubject(subject: { id: string; dir: string }): Promise<Subjec
return { hasCss, hasJs };
}
async function computeSubjectFingerprint(subject: { id: string; dir: string }): Promise<string> {
return fingerprintPaths(ROOT, [join(subject.dir, "subject.scss"), join(subject.dir, "subject.ts")]);
}
function buildSubjectModuleRegistry(
subjects: Array<{ id: string; dir: string; hasJs: boolean }>
): string {
@ -234,13 +257,18 @@ async function syncChapter(chapter: {
const indexPath = join(dir, "_index.md");
const indexRaw = await readFile(indexPath, "utf8");
const { frontMatter } = splitFrontMatter(indexRaw);
await writeFile(indexPath, `${frontMatter}\n\n${mergedBody}`);
const nextIndex = `${frontMatter}\n\n${mergedBody}`;
if (indexRaw !== nextIndex) {
await writeFile(indexPath, nextIndex);
}
const assetsDir = join(dir, "assets");
const staticAssetsDir = join(STATIC_ROOT, "chapters", subject, slug);
if (existsSync(assetsDir)) {
await rm(staticAssetsDir, { recursive: true, force: true });
await copyDir(assetsDir, staticAssetsDir);
} else {
await rm(staticAssetsDir, { recursive: true, force: true });
}
const scssPath = join(dir, "chapter.scss");
@ -263,6 +291,35 @@ async function syncChapter(chapter: {
return { key, meta: { hasSupplement, hasCss }, widgets };
}
async function computeChapterFingerprint(chapter: {
subject: string;
slug: string;
dir: string;
}): Promise<string> {
return fingerprintPaths(ROOT, [
join(chapter.dir, "core.html"),
join(chapter.dir, "supplement.html"),
join(chapter.dir, "chapter.scss"),
join(chapter.dir, "assets"),
join(chapter.dir, "widgets"),
]);
}
async function readSyncCache(): Promise<SyncCacheFile> {
if (!existsSync(SYNC_CACHE_PATH)) {
return { version: 1, chapters: {}, subjects: {} };
}
try {
const parsed = JSON.parse(await readFile(SYNC_CACHE_PATH, "utf8")) as SyncCacheFile;
if (parsed.version !== 1) {
return { version: 1, chapters: {}, subjects: {} };
}
return parsed;
} catch {
return { version: 1, chapters: {}, subjects: {} };
}
}
async function buildChapterNav(
discovered: Array<{ subject: string; slug: string }>
): Promise<ChapterNavFile> {
@ -324,15 +381,34 @@ function buildWidgetRegistry(
}
async function main() {
const forceFull = process.argv.includes("--full");
const prevCache = forceFull ? { version: 1, chapters: {}, subjects: {} } : await readSyncCache();
const nextCache: SyncCacheFile = { version: 1, chapters: {}, subjects: {} };
const chapters = await discoverChapters();
const metaFile: ChaptersMetaFile = { chapters: {} };
const widgetEntries: Array<{ key: string; widgets: string[]; dir: string }> = [];
for (const chapter of chapters) {
const { key, meta, widgets } = await syncChapter(chapter);
const key = `${chapter.subject}/${chapter.slug}`;
const fingerprint = await computeChapterFingerprint(chapter);
const cached = prevCache.chapters[key];
let meta: ChapterMeta;
let widgets: string[];
if (!forceFull && cached && cached.fingerprint === fingerprint) {
meta = cached.meta;
widgets = cached.widgets;
console.log(`Unchanged chapter ${key} (skipped)`);
} else {
const synced = await syncChapter(chapter);
meta = synced.meta;
widgets = synced.widgets;
console.log(`Synced chapter ${key} (${widgets.length} widget(s))`);
}
nextCache.chapters[key] = { fingerprint, meta, widgets };
metaFile.chapters[key] = meta;
widgetEntries.push({ key, widgets, dir: chapter.dir });
console.log(`Synced chapter ${key} (${widgets.length} widget(s))`);
}
await mkdir(dirname(GENERATED_META), { recursive: true });
@ -347,18 +423,34 @@ async function main() {
const subjectEntries: Array<{ id: string; dir: string; hasJs: boolean }> = [];
const subjectsMeta: SubjectsMetaFile = { subjects: {} };
for (const subject of await discoverSubjects()) {
const meta = await syncSubject(subject);
const fingerprint = await computeSubjectFingerprint(subject);
const cached = prevCache.subjects[subject.id];
let meta: SubjectMeta;
if (!forceFull && cached && cached.fingerprint === fingerprint) {
meta = cached.meta;
if (meta.hasCss || meta.hasJs) {
console.log(
`Unchanged subject ${subject.id} (css: ${meta.hasCss ? "yes" : "no"}, js: ${meta.hasJs ? "yes" : "no"})`
);
}
} else {
meta = await syncSubject(subject);
if (meta.hasCss || meta.hasJs) {
console.log(
`Synced subject ${subject.id} (css: ${meta.hasCss ? "yes" : "no"}, js: ${meta.hasJs ? "yes" : "no"})`
);
}
}
nextCache.subjects[subject.id] = { fingerprint, meta };
subjectsMeta.subjects[subject.id] = meta;
subjectEntries.push({ id: subject.id, dir: subject.dir, hasJs: meta.hasJs });
if (meta.hasCss || meta.hasJs) {
console.log(
`Synced subject ${subject.id} (css: ${meta.hasCss ? "yes" : "no"}, js: ${meta.hasJs ? "yes" : "no"})`
);
}
}
await writeFile(GENERATED_SUBJECTS_META, `${JSON.stringify(subjectsMeta, null, 2)}\n`);
await mkdir(dirname(GENERATED_SUBJECT_MODULES), { recursive: true });
await writeFile(GENERATED_SUBJECT_MODULES, buildSubjectModuleRegistry(subjectEntries));
await mkdir(dirname(SYNC_CACHE_PATH), { recursive: true });
await writeFile(SYNC_CACHE_PATH, `${JSON.stringify(nextCache, null, 2)}\n`);
console.log(`Synced ${chapters.length} chapter(s).`);
}