diff --git a/.agents/skills/zola/SKILL.md b/.agents/skills/zola/SKILL.md
index 7590ce7..479d7ce 100644
--- a/.agents/skills/zola/SKILL.md
+++ b/.agents/skills/zola/SKILL.md
@@ -28,15 +28,24 @@ See [reference.md](reference.md) for Tera pitfalls and build checks.
## Commands
```bash
-bun run build:js # Vite → themes/openfreebooks/static/js/ (bundle.js + lazy chunks)
+bun run build:js # sync chapters + Vite build (JS/CSS bundle outputs)
+bun run build # smart local build (incremental; uses build cache)
+bun run build:full # full deterministic rebuild (use in CI / clean validation)
+bun run dev # smart local dev startup (incremental chapter sync + watch + zola serve)
+bun run dev:full # force full chapter sync before starting dev server
zola build # required before indexing
bun run index:search # pagefind on public/ → copies to static/pagefind/ for zola serve
-zola serve # dev at http://127.0.0.1:1111
-bun run build # build:js + zola build + index:search
+zola serve # dev at http://127.0.0.1:1111
```
After Sass or template changes, run `zola build` (or `zola serve`). Do **not** copy `public/` into `themes/openfreebooks/static/`.
+### Build mode behavior
+
+- **First clone / cold start:** `bun run build` behaves like a full pipeline because caches are absent.
+- **Continuous local development:** `bun run build` reuses caches and skips unchanged steps where possible.
+- **CI:** prefer `bun run build:full` for deterministic, correctness-first builds.
+
**Search (Pagefind):** read [zola-pagefind.md](zola-pagefind.md) before changing search UI, indexing, or `data-pagefind-*` attributes.
## Templates (Tera)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b80b026..c219962 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -23,6 +23,8 @@ Thank you for helping grow free, open textbooks.
**Search locally:** After `bun run build:js`, run `bun run index:search` once (or again after changing chapter HTML), then `zola serve`. Try [http://127.0.0.1:1111/search/](http://127.0.0.1:1111/search/) or press ⌘K / Ctrl+K.
+**Build modes:** `bun run build` is smart/incremental for local iteration; use `bun run build:full` for a deterministic full rebuild (CI/release checks).
+
---
## How the catalog fits together
@@ -228,9 +230,12 @@ Omit `banner` for subjects that should stay unchanged (e.g. roadmap-only placeho
bun install
bun run sync:chapters # after content/ chapter changes
bun run build:js # sync + Vite
+bun run dev # incremental sync + watch + zola serve
+bun run dev:full # force full sync before dev
zola serve
bun test
bun run build
+bun run build:full
```
- Do **not** commit `themes/openfreebooks/static/` (Vite output).
diff --git a/README.md b/README.md
index 0ec41b6..6ac3df5 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,8 @@ bun run dev
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.
+Use `bun run dev:full` when you want to force a full chapter sync before starting dev servers.
+
Search UI is available immediately; results require the index step above (`static/pagefind/` is gitignored and copied from the build output).
## Production build
@@ -71,7 +73,11 @@ Search UI is available immediately; results require the index step above (`stati
bun run build
```
-This runs `vite build` (outputs to `themes/openfreebooks/static/js/bundle.js`), `zola build` (outputs to `public/`), then Pagefind (`public/pagefind/`).
+`bun run build` is smart/incremental for local development:
+- first run (or missing cache) behaves like a full build,
+- repeat runs skip unchanged steps.
+
+Use `bun run build:full` for a deterministic full rebuild (recommended for CI/release validation).
## Deploy to Cloudflare
diff --git a/content/math/integer-arithmetic/_index.md b/content/math/integer-arithmetic/_index.md
deleted file mode 100644
index 0e8cd56..0000000
--- a/content/math/integer-arithmetic/_index.md
+++ /dev/null
@@ -1,83 +0,0 @@
-+++
-title = "Integer arithmetic"
-description = "Multiply and divide integers from models to symbolic rules; apply order of operations with signed numbers in multi-step expressions."
-template = "chapter.html"
-weight = 9
-[extra]
-subject = "math"
-chapter_id = "integer-arithmetic"
-strand = "Number & Algebra"
-+++
-
-
-
- This chapter extends integer work to multiplication, division, and multi-step expressions.
-
-
-
Sign rules for multiplication and division
-
-
-
-
- Operation
- Result sign
-
-
-
-
- same signs
- positive
-
-
- different signs
- negative
-
-
-
-
-
- Examples: \((-3)(-4)=12\), \(18\div(-3)=-6\).
-
-
-
Why negative times negative is positive
-
- Arithmetic rules must stay consistent with distribution. This leads to
- \((-a)(-b)=ab\).
-
-
-
Order of operations with signed numbers
-
- Use brackets, exponents, multiplication/division, then addition/subtraction.
-
-
- \(5-(-2)\times 3 = 5-(-6)=11\)
-
-
-
Multi-step integer expressions
-
- Keep signs with their numbers and rewrite subtraction as adding opposites when needed.
-
-
-
- Checkpoint: evaluate \((-6)\times 7\)
-
-
-
-
- Checkpoint: evaluate \(36\div(-9)\)
-
-
-
-
- Checkpoint: evaluate \(8-3(2-5)\)
-
-
- \(2-5=-3\), then \(8-3(-3)=8-(-9)=17\).
-
-
-
-
diff --git a/content/math/integer-arithmetic/core.html b/content/math/integer-arithmetic/core.html
deleted file mode 100644
index cc8add4..0000000
--- a/content/math/integer-arithmetic/core.html
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
- This chapter extends integer work to multiplication, division, and multi-step expressions.
-
-
-
Sign rules for multiplication and division
-
-
-
-
- Operation
- Result sign
-
-
-
-
- same signs
- positive
-
-
- different signs
- negative
-
-
-
-
-
- Examples: \((-3)(-4)=12\), \(18\div(-3)=-6\).
-
-
-
Why negative times negative is positive
-
- Arithmetic rules must stay consistent with distribution. This leads to
- \((-a)(-b)=ab\).
-
-
-
Order of operations with signed numbers
-
- Use brackets, exponents, multiplication/division, then addition/subtraction.
-
-
- \(5-(-2)\times 3 = 5-(-6)=11\)
-
-
-
Multi-step integer expressions
-
- Keep signs with their numbers and rewrite subtraction as adding opposites when needed.
-
-
-
- Checkpoint: evaluate \((-6)\times 7\)
-
-
-
-
- Checkpoint: evaluate \(36\div(-9)\)
-
-
-
-
- Checkpoint: evaluate \(8-3(2-5)\)
-
-
- \(2-5=-3\), then \(8-3(-3)=8-(-9)=17\).
-
-
-
-
diff --git a/content/math/pythagoras-theorem/_index.md b/content/math/pythagoras-theorem/_index.md
index ae3410e..024c8fd 100644
--- a/content/math/pythagoras-theorem/_index.md
+++ b/content/math/pythagoras-theorem/_index.md
@@ -116,6 +116,36 @@ strand = "Measures, Shape & Space"
+
Vocabulary
+
+
+
+
+ Term
+ Definition
+
+
+
+
+ Right triangle
+ A triangle with one angle equal to \(90^\circ\).
+
+
+ Legs
+ The two sides that form the right angle.
+
+
+ Hypotenuse
+ The side opposite the right angle; the longest side in a right triangle.
+
+
+ Pythagorean Theorem
+ The relationship \(a^2+b^2=c^2\) for right triangles.
+
+
+
+
+
History
The theorem is associated with Pythagoras, but right-triangle relationships appeared in earlier
@@ -202,31 +232,4 @@ strand = "Measures, Shape & Space"
- Vocabulary
-
-
-
- Term
- Definition
-
-
-
-
- Right triangle
- A triangle with one angle equal to \(90^\circ\).
-
-
- Legs
- The two sides that form the right angle.
-
-
- Hypotenuse
- The side opposite the right angle; the longest side in a right triangle.
-
-
- Pythagorean Theorem
- The relationship \(a^2+b^2=c^2\) for right triangles.
-
-
-
diff --git a/content/math/pythagoras-theorem/supplement.html b/content/math/pythagoras-theorem/supplement.html
index 35122a8..dcffee4 100644
--- a/content/math/pythagoras-theorem/supplement.html
+++ b/content/math/pythagoras-theorem/supplement.html
@@ -1,4 +1,34 @@
+
Vocabulary
+
+
+
+
+ Term
+ Definition
+
+
+
+
+ Right triangle
+ A triangle with one angle equal to \(90^\circ\).
+
+
+ Legs
+ The two sides that form the right angle.
+
+
+ Hypotenuse
+ The side opposite the right angle; the longest side in a right triangle.
+
+
+ Pythagorean Theorem
+ The relationship \(a^2+b^2=c^2\) for right triangles.
+
+
+
+
+
History
The theorem is associated with Pythagoras, but right-triangle relationships appeared in earlier
@@ -85,31 +115,4 @@
- Vocabulary
-
-
-
- Term
- Definition
-
-
-
-
- Right triangle
- A triangle with one angle equal to \(90^\circ\).
-
-
- Legs
- The two sides that form the right angle.
-
-
- Hypotenuse
- The side opposite the right angle; the longest side in a right triangle.
-
-
- Pythagorean Theorem
- The relationship \(a^2+b^2=c^2\) for right triangles.
-
-
-
diff --git a/data/math-curriculum.json b/data/math-curriculum.json
index e1caebb..540de35 100644
--- a/data/math-curriculum.json
+++ b/data/math-curriculum.json
@@ -29,7 +29,7 @@
},
{
"from": "integer-foundations",
- "to": "integer-arithmetic"
+ "to": "polynomials-expansion-factorisation"
},
{
"from": "rational-number-operations",
@@ -43,10 +43,6 @@
"from": "percentage-applications",
"to": "ratios-and-rates-foundation"
},
- {
- "from": "integer-arithmetic",
- "to": "polynomials-expansion-factorisation"
- },
{
"from": "polynomials-expansion-factorisation",
"to": "algebraic-fractions-formulas"
@@ -310,24 +306,6 @@
"Gaokao": "related"
}
},
- {
- "slug": "integer-arithmetic",
- "title": "Integer arithmetic",
- "description": "Multiply and divide integers from models to symbolic rules; apply order of operations with signed numbers in multi-step expressions.",
- "status": "live",
- "curriculumCoverage": {
- "Foundation": "core",
- "IGCSE": "core",
- "Common Core": "core",
- "Singapore": "core",
- "UK GCSE": "core",
- "IB": "related",
- "A-Level": "related",
- "AP": "related",
- "DSE": "related",
- "Gaokao": "related"
- }
- },
{
"slug": "rational-number-operations",
"title": "Fractions and decimals",
diff --git a/package.json b/package.json
index f42b142..5ea30af 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,11 @@
"generate:site-metadata": "bun scripts/generate-site-metadata.ts",
"build:js": "bun run sync:chapters && vite build",
"build:site": "bun run generate:site-metadata && 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 && bun run build:site",
+ "build:full": "bun run check:static-html && bun run validate:curriculum && rm -f themes/openfreebooks/static/main.css public/main.css && bun run sync:chapters -- --full && vite build && bun run build:site",
+ "build": "bun scripts/build-smart.ts",
"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:js": "vite build --watch",
+ "dev:full": "bash scripts/dev.sh --full",
"dev": "bash scripts/dev.sh",
"check:static-html": "bash scripts/check-static-html.sh",
"scan:size": "bun scripts/scan-output-size.ts",
diff --git a/scripts/build-change-detector.ts b/scripts/build-change-detector.ts
new file mode 100644
index 0000000..caeb1ad
--- /dev/null
+++ b/scripts/build-change-detector.ts
@@ -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 {
+ 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 {
+ 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");
+}
diff --git a/scripts/build-smart.ts b/scripts/build-smart.ts
new file mode 100644
index 0000000..f089f4b
--- /dev/null
+++ b/scripts/build-smart.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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();
diff --git a/scripts/dev.sh b/scripts/dev.sh
index 0a1763a..cb2e091 100644
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -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=$!
diff --git a/scripts/sync-chapters.ts b/scripts/sync-chapters.ts
index 1cbf913..d0c27be 100644
--- a/scripts/sync-chapters.ts
+++ b/scripts/sync-chapters.ts
@@ -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;
};
+type ChapterCacheEntry = {
+ fingerprint: string;
+ meta: ChapterMeta;
+ widgets: string[];
+};
+
+type SubjectCacheEntry = {
+ fingerprint: string;
+ meta: SubjectMeta;
+};
+
+type SyncCacheFile = {
+ version: 1;
+ chapters: Record;
+ subjects: Record;
+};
+
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 {
+ 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 {
+ 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 {
+ 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 {
@@ -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).`);
}
diff --git a/static/feed.xml b/static/feed.xml
index 1537bde..a85ae9a 100644
--- a/static/feed.xml
+++ b/static/feed.xml
@@ -4,7 +4,35 @@
Open Free Books
https://openfreebooks.org/
Recent updates to Open Free Books pages and live chapters.
- Fri, 22 May 2026 05:18:34 GMT
+ Fri, 22 May 2026 06:25:20 GMT
+ -
+
Algebraic fractions & formulas
+ https://openfreebooks.org/math/algebraic-fractions-formulas/
+ https://openfreebooks.org/math/algebraic-fractions-formulas/
+ Fri, 22 May 2026 06:25:20 GMT
+ Simplify and combine algebraic fractions; rearrange formulas in science contexts.
+
+ -
+
Counting and numbers
+ https://openfreebooks.org/math/counting-and-numbers/
+ https://openfreebooks.org/math/counting-and-numbers/
+ Fri, 22 May 2026 06:25:20 GMT
+ Count objects, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions.
+
+ -
+
Zero and one
+ https://openfreebooks.org/math/zero-and-one/
+ https://openfreebooks.org/math/zero-and-one/
+ Fri, 22 May 2026 05:57:59 GMT
+ 0 is nothing; 1 is one. Learn to tell them apart — the same skill as telling false from true — before counting larger amounts.
+
+ -
+
Pythagoras' theorem
+ https://openfreebooks.org/math/pythagoras-theorem/
+ https://openfreebooks.org/math/pythagoras-theorem/
+ Fri, 22 May 2026 05:22:44 GMT
+ Use Pythagoras' theorem and its converse in 2D problems and coordinate settings.
+
-
Coordinate geometry foundations
https://openfreebooks.org/math/coordinate-geometry-foundations/
@@ -12,13 +40,6 @@
Fri, 22 May 2026 05:18:34 GMT
Plot points, find distances and midpoints, and work with slopes of lines in the Cartesian plane.
- -
-
Pythagoras' theorem
- https://openfreebooks.org/math/pythagoras-theorem/
- https://openfreebooks.org/math/pythagoras-theorem/
- Fri, 22 May 2026 05:18:34 GMT
- Use Pythagoras' theorem and its converse in 2D problems and coordinate settings.
-
-
Basic math notation
https://openfreebooks.org/math/basic-math-notation/
@@ -47,13 +68,6 @@
Thu, 21 May 2026 18:40:42 GMT
Contribute
- -
-
Integer arithmetic
- https://openfreebooks.org/math/integer-arithmetic/
- https://openfreebooks.org/math/integer-arithmetic/
- Thu, 21 May 2026 17:54:08 GMT
- Multiply and divide integers from models to symbolic rules; apply order of operations with signed numbers in multi-step expressions.
-
-
Fractions and decimals
https://openfreebooks.org/math/rational-number-operations/
@@ -236,13 +250,6 @@
Thu, 21 May 2026 06:54:57 GMT
Solve, represent, and apply linear inequalities in one unknown using number lines and interval notation.
- -
-
Counting and numbers
- https://openfreebooks.org/math/counting-and-numbers/
- https://openfreebooks.org/math/counting-and-numbers/
- Thu, 21 May 2026 05:05:38 GMT
- Count objects, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions.
-
-
Written calculation
https://openfreebooks.org/math/written-calculation/
@@ -271,20 +278,6 @@
Thu, 21 May 2026 02:10:55 GMT
Use free-body diagrams, F = ma, and motion graphs to predict how objects move.
- -
-
Zero and one
- https://openfreebooks.org/math/zero-and-one/
- https://openfreebooks.org/math/zero-and-one/
- Thu, 21 May 2026 01:07:39 GMT
- 0 is nothing; 1 is one. Learn to tell them apart — the same skill as telling false from true — before counting larger amounts.
-
- -
-
Algebraic fractions & formulas
- https://openfreebooks.org/math/algebraic-fractions-formulas/
- https://openfreebooks.org/math/algebraic-fractions-formulas/
- Wed, 20 May 2026 20:17:25 GMT
- Simplify and combine algebraic fractions; rearrange formulas in science contexts.
-
-
Polynomials: expansion & factorisation
https://openfreebooks.org/math/polynomials-expansion-factorisation/
diff --git a/static/sitemap.xml b/static/sitemap.xml
index 23533f0..34ae5f6 100644
--- a/static/sitemap.xml
+++ b/static/sitemap.xml
@@ -34,7 +34,7 @@
https://openfreebooks.org/math/algebraic-fractions-formulas/
- 2026-05-20T20:17:25.000Z
+ 2026-05-22T06:25:20.000Z
https://openfreebooks.org/math/basic-math-notation/
@@ -66,7 +66,7 @@
https://openfreebooks.org/math/counting-and-numbers/
- 2026-05-21T05:05:38.000Z
+ 2026-05-22T06:25:20.000Z
https://openfreebooks.org/math/deductive-geometry/
@@ -84,10 +84,6 @@
https://openfreebooks.org/math/functions-graphs/
2026-05-21T07:33:01.000Z
-
- https://openfreebooks.org/math/integer-arithmetic/
- 2026-05-21T17:54:08.000Z
-
https://openfreebooks.org/math/integer-foundations/
2026-05-21T23:42:19.000Z
@@ -146,7 +142,7 @@
https://openfreebooks.org/math/pythagoras-theorem/
- 2026-05-22T05:18:34.000Z
+ 2026-05-22T05:22:44.000Z
https://openfreebooks.org/math/quadratic-equations/
@@ -182,7 +178,7 @@
https://openfreebooks.org/math/zero-and-one/
- 2026-05-21T01:07:39.000Z
+ 2026-05-22T05:57:59.000Z
https://openfreebooks.org/physics/
diff --git a/themes/openfreebooks/sass/_catalog.scss b/themes/openfreebooks/sass/_catalog.scss
index 5188550..47d40db 100644
--- a/themes/openfreebooks/sass/_catalog.scss
+++ b/themes/openfreebooks/sass/_catalog.scss
@@ -984,6 +984,22 @@ a.catalog-chapter-card {
align-items: center;
gap: 8px;
padding-left: 0px;
+ max-height: 0;
+ opacity: 0;
+ overflow: hidden;
+ pointer-events: none;
+ transform: translateY(-4px);
+ transition:
+ max-height 220ms ease,
+ opacity 180ms ease,
+ transform 220ms ease;
+}
+
+.catalog-chapter-card:is(:hover, :focus-within, :active) .catalog-chapter-card__meta {
+ max-height: 120px;
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateY(0);
}
// -----------------------------------------------------------------------------