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

@ -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)

View file

@ -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).

View file

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

View file

@ -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"
+++
<div class="book-prose">
<p>
This chapter extends integer work to multiplication, division, and multi-step expressions.
</p>
<h2 id="sign-rules" class="book-prose__heading">Sign rules for multiplication and division</h2>
<div class="book-table-wrap">
<table class="book-table">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Result sign</th>
</tr>
</thead>
<tbody>
<tr>
<td>same signs</td>
<td>positive</td>
</tr>
<tr>
<td>different signs</td>
<td>negative</td>
</tr>
</tbody>
</table>
</div>
<p>
Examples: \((-3)(-4)=12\), \(18\div(-3)=-6\).
</p>
<h2 id="patterns" class="book-prose__heading">Why negative times negative is positive</h2>
<p>
Arithmetic rules must stay consistent with distribution. This leads to
\((-a)(-b)=ab\).
</p>
<h2 id="order" class="book-prose__heading">Order of operations with signed numbers</h2>
<p>
Use brackets, exponents, multiplication/division, then addition/subtraction.
</p>
<p class="book-formula">
\(5-(-2)\times 3 = 5-(-6)=11\)
</p>
<h2 id="multi-step" class="book-prose__heading">Multi-step integer expressions</h2>
<p>
Keep signs with their numbers and rewrite subtraction as adding opposites when needed.
</p>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: evaluate \((-6)\times 7\)</summary>
<div class="book-question__solution">
<p>\(-42\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: evaluate \(36\div(-9)\)</summary>
<div class="book-question__solution">
<p>\(-4\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: evaluate \(8-3(2-5)\)</summary>
<div class="book-question__solution">
<p>
\(2-5=-3\), then \(8-3(-3)=8-(-9)=17\).
</p>
</div>
</details>
</div>

View file

@ -1,72 +0,0 @@
<div class="book-prose">
<p>
This chapter extends integer work to multiplication, division, and multi-step expressions.
</p>
<h2 id="sign-rules" class="book-prose__heading">Sign rules for multiplication and division</h2>
<div class="book-table-wrap">
<table class="book-table">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Result sign</th>
</tr>
</thead>
<tbody>
<tr>
<td>same signs</td>
<td>positive</td>
</tr>
<tr>
<td>different signs</td>
<td>negative</td>
</tr>
</tbody>
</table>
</div>
<p>
Examples: \((-3)(-4)=12\), \(18\div(-3)=-6\).
</p>
<h2 id="patterns" class="book-prose__heading">Why negative times negative is positive</h2>
<p>
Arithmetic rules must stay consistent with distribution. This leads to
\((-a)(-b)=ab\).
</p>
<h2 id="order" class="book-prose__heading">Order of operations with signed numbers</h2>
<p>
Use brackets, exponents, multiplication/division, then addition/subtraction.
</p>
<p class="book-formula">
\(5-(-2)\times 3 = 5-(-6)=11\)
</p>
<h2 id="multi-step" class="book-prose__heading">Multi-step integer expressions</h2>
<p>
Keep signs with their numbers and rewrite subtraction as adding opposites when needed.
</p>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: evaluate \((-6)\times 7\)</summary>
<div class="book-question__solution">
<p>\(-42\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: evaluate \(36\div(-9)\)</summary>
<div class="book-question__solution">
<p>\(-4\).</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: evaluate \(8-3(2-5)\)</summary>
<div class="book-question__solution">
<p>
\(2-5=-3\), then \(8-3(-3)=8-(-9)=17\).
</p>
</div>
</details>
</div>

View file

@ -116,6 +116,36 @@ strand = "Measures, Shape & Space"
</div>
<div class="book-prose">
<h2 id="vocabulary" class="book-prose__heading">Vocabulary</h2>
<div class="book-table-wrap">
<table class="book-table">
<thead>
<tr>
<th>Term</th>
<th>Definition</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Right triangle</strong></td>
<td>A triangle with one angle equal to \(90^\circ\).</td>
</tr>
<tr>
<td><strong>Legs</strong></td>
<td>The two sides that form the right angle.</td>
</tr>
<tr>
<td><strong>Hypotenuse</strong></td>
<td>The side opposite the right angle; the longest side in a right triangle.</td>
</tr>
<tr>
<td><strong>Pythagorean Theorem</strong></td>
<td>The relationship \(a^2+b^2=c^2\) for right triangles.</td>
</tr>
</tbody>
</table>
</div>
<h2 id="history" class="book-prose__heading">History</h2>
<p>
The theorem is associated with Pythagoras, but right-triangle relationships appeared in earlier
@ -202,31 +232,4 @@ strand = "Measures, Shape & Space"
</div>
</details>
<h2 id="vocabulary" class="book-prose__heading">Vocabulary</h2>
<table>
<thead>
<tr>
<th>Term</th>
<th>Definition</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Right triangle</strong></td>
<td>A triangle with one angle equal to \(90^\circ\).</td>
</tr>
<tr>
<td><strong>Legs</strong></td>
<td>The two sides that form the right angle.</td>
</tr>
<tr>
<td><strong>Hypotenuse</strong></td>
<td>The side opposite the right angle; the longest side in a right triangle.</td>
</tr>
<tr>
<td><strong>Pythagorean Theorem</strong></td>
<td>The relationship \(a^2+b^2=c^2\) for right triangles.</td>
</tr>
</tbody>
</table>
</div>

View file

@ -1,4 +1,34 @@
<div class="book-prose">
<h2 id="vocabulary" class="book-prose__heading">Vocabulary</h2>
<div class="book-table-wrap">
<table class="book-table">
<thead>
<tr>
<th>Term</th>
<th>Definition</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Right triangle</strong></td>
<td>A triangle with one angle equal to \(90^\circ\).</td>
</tr>
<tr>
<td><strong>Legs</strong></td>
<td>The two sides that form the right angle.</td>
</tr>
<tr>
<td><strong>Hypotenuse</strong></td>
<td>The side opposite the right angle; the longest side in a right triangle.</td>
</tr>
<tr>
<td><strong>Pythagorean Theorem</strong></td>
<td>The relationship \(a^2+b^2=c^2\) for right triangles.</td>
</tr>
</tbody>
</table>
</div>
<h2 id="history" class="book-prose__heading">History</h2>
<p>
The theorem is associated with Pythagoras, but right-triangle relationships appeared in earlier
@ -85,31 +115,4 @@
</div>
</details>
<h2 id="vocabulary" class="book-prose__heading">Vocabulary</h2>
<table>
<thead>
<tr>
<th>Term</th>
<th>Definition</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Right triangle</strong></td>
<td>A triangle with one angle equal to \(90^\circ\).</td>
</tr>
<tr>
<td><strong>Legs</strong></td>
<td>The two sides that form the right angle.</td>
</tr>
<tr>
<td><strong>Hypotenuse</strong></td>
<td>The side opposite the right angle; the longest side in a right triangle.</td>
</tr>
<tr>
<td><strong>Pythagorean Theorem</strong></td>
<td>The relationship \(a^2+b^2=c^2\) for right triangles.</td>
</tr>
</tbody>
</table>
</div>

View file

@ -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",

View file

@ -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",

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).`);
}

View file

@ -4,7 +4,35 @@
<title>Open Free Books</title>
<link>https://openfreebooks.org/</link>
<description>Recent updates to Open Free Books pages and live chapters.</description>
<lastBuildDate>Fri, 22 May 2026 05:18:34 GMT</lastBuildDate>
<lastBuildDate>Fri, 22 May 2026 06:25:20 GMT</lastBuildDate>
<item>
<title>Algebraic fractions &amp; formulas</title>
<link>https://openfreebooks.org/math/algebraic-fractions-formulas/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/algebraic-fractions-formulas/</guid>
<pubDate>Fri, 22 May 2026 06:25:20 GMT</pubDate>
<description>Simplify and combine algebraic fractions; rearrange formulas in science contexts.</description>
</item>
<item>
<title>Counting and numbers</title>
<link>https://openfreebooks.org/math/counting-and-numbers/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/counting-and-numbers/</guid>
<pubDate>Fri, 22 May 2026 06:25:20 GMT</pubDate>
<description>Count objects, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions.</description>
</item>
<item>
<title>Zero and one</title>
<link>https://openfreebooks.org/math/zero-and-one/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/zero-and-one/</guid>
<pubDate>Fri, 22 May 2026 05:57:59 GMT</pubDate>
<description>0 is nothing; 1 is one. Learn to tell them apart — the same skill as telling false from true — before counting larger amounts.</description>
</item>
<item>
<title>Pythagoras&apos; theorem</title>
<link>https://openfreebooks.org/math/pythagoras-theorem/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/pythagoras-theorem/</guid>
<pubDate>Fri, 22 May 2026 05:22:44 GMT</pubDate>
<description>Use Pythagoras&apos; theorem and its converse in 2D problems and coordinate settings.</description>
</item>
<item>
<title>Coordinate geometry foundations</title>
<link>https://openfreebooks.org/math/coordinate-geometry-foundations/</link>
@ -12,13 +40,6 @@
<pubDate>Fri, 22 May 2026 05:18:34 GMT</pubDate>
<description>Plot points, find distances and midpoints, and work with slopes of lines in the Cartesian plane.</description>
</item>
<item>
<title>Pythagoras&apos; theorem</title>
<link>https://openfreebooks.org/math/pythagoras-theorem/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/pythagoras-theorem/</guid>
<pubDate>Fri, 22 May 2026 05:18:34 GMT</pubDate>
<description>Use Pythagoras&apos; theorem and its converse in 2D problems and coordinate settings.</description>
</item>
<item>
<title>Basic math notation</title>
<link>https://openfreebooks.org/math/basic-math-notation/</link>
@ -47,13 +68,6 @@
<pubDate>Thu, 21 May 2026 18:40:42 GMT</pubDate>
<description>Contribute</description>
</item>
<item>
<title>Integer arithmetic</title>
<link>https://openfreebooks.org/math/integer-arithmetic/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/integer-arithmetic/</guid>
<pubDate>Thu, 21 May 2026 17:54:08 GMT</pubDate>
<description>Multiply and divide integers from models to symbolic rules; apply order of operations with signed numbers in multi-step expressions.</description>
</item>
<item>
<title>Fractions and decimals</title>
<link>https://openfreebooks.org/math/rational-number-operations/</link>
@ -236,13 +250,6 @@
<pubDate>Thu, 21 May 2026 06:54:57 GMT</pubDate>
<description>Solve, represent, and apply linear inequalities in one unknown using number lines and interval notation.</description>
</item>
<item>
<title>Counting and numbers</title>
<link>https://openfreebooks.org/math/counting-and-numbers/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/counting-and-numbers/</guid>
<pubDate>Thu, 21 May 2026 05:05:38 GMT</pubDate>
<description>Count objects, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions.</description>
</item>
<item>
<title>Written calculation</title>
<link>https://openfreebooks.org/math/written-calculation/</link>
@ -271,20 +278,6 @@
<pubDate>Thu, 21 May 2026 02:10:55 GMT</pubDate>
<description>Use free-body diagrams, F = ma, and motion graphs to predict how objects move.</description>
</item>
<item>
<title>Zero and one</title>
<link>https://openfreebooks.org/math/zero-and-one/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/zero-and-one/</guid>
<pubDate>Thu, 21 May 2026 01:07:39 GMT</pubDate>
<description>0 is nothing; 1 is one. Learn to tell them apart — the same skill as telling false from true — before counting larger amounts.</description>
</item>
<item>
<title>Algebraic fractions &amp; formulas</title>
<link>https://openfreebooks.org/math/algebraic-fractions-formulas/</link>
<guid isPermaLink="true">https://openfreebooks.org/math/algebraic-fractions-formulas/</guid>
<pubDate>Wed, 20 May 2026 20:17:25 GMT</pubDate>
<description>Simplify and combine algebraic fractions; rearrange formulas in science contexts.</description>
</item>
<item>
<title>Polynomials: expansion &amp; factorisation</title>
<link>https://openfreebooks.org/math/polynomials-expansion-factorisation/</link>

View file

@ -34,7 +34,7 @@
</url>
<url>
<loc>https://openfreebooks.org/math/algebraic-fractions-formulas/</loc>
<lastmod>2026-05-20T20:17:25.000Z</lastmod>
<lastmod>2026-05-22T06:25:20.000Z</lastmod>
</url>
<url>
<loc>https://openfreebooks.org/math/basic-math-notation/</loc>
@ -66,7 +66,7 @@
</url>
<url>
<loc>https://openfreebooks.org/math/counting-and-numbers/</loc>
<lastmod>2026-05-21T05:05:38.000Z</lastmod>
<lastmod>2026-05-22T06:25:20.000Z</lastmod>
</url>
<url>
<loc>https://openfreebooks.org/math/deductive-geometry/</loc>
@ -84,10 +84,6 @@
<loc>https://openfreebooks.org/math/functions-graphs/</loc>
<lastmod>2026-05-21T07:33:01.000Z</lastmod>
</url>
<url>
<loc>https://openfreebooks.org/math/integer-arithmetic/</loc>
<lastmod>2026-05-21T17:54:08.000Z</lastmod>
</url>
<url>
<loc>https://openfreebooks.org/math/integer-foundations/</loc>
<lastmod>2026-05-21T23:42:19.000Z</lastmod>
@ -146,7 +142,7 @@
</url>
<url>
<loc>https://openfreebooks.org/math/pythagoras-theorem/</loc>
<lastmod>2026-05-22T05:18:34.000Z</lastmod>
<lastmod>2026-05-22T05:22:44.000Z</lastmod>
</url>
<url>
<loc>https://openfreebooks.org/math/quadratic-equations/</loc>
@ -182,7 +178,7 @@
</url>
<url>
<loc>https://openfreebooks.org/math/zero-and-one/</loc>
<lastmod>2026-05-21T01:07:39.000Z</lastmod>
<lastmod>2026-05-22T05:57:59.000Z</lastmod>
</url>
<url>
<loc>https://openfreebooks.org/physics/</loc>

View file

@ -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);
}
// -----------------------------------------------------------------------------