From 30b4f4cf53a64f51f14e75170ca3f63e02e6ca44 Mon Sep 17 00:00:00 2001 From: Hananoshika Yomaru Date: Thu, 21 May 2026 23:55:33 -0700 Subject: [PATCH] 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. --- .agents/skills/zola/SKILL.md | 15 +- CONTRIBUTING.md | 5 + README.md | 8 +- content/math/integer-arithmetic/_index.md | 83 ------- content/math/integer-arithmetic/core.html | 72 ------ content/math/pythagoras-theorem/_index.md | 57 ++--- .../math/pythagoras-theorem/supplement.html | 57 ++--- data/math-curriculum.json | 24 +- package.json | 6 +- scripts/build-change-detector.ts | 45 ++++ scripts/build-smart.ts | 206 ++++++++++++++++++ scripts/dev.sh | 6 + scripts/sync-chapters.ts | 110 +++++++++- static/feed.xml | 65 +++--- static/sitemap.xml | 12 +- themes/openfreebooks/sass/_catalog.scss | 16 ++ 16 files changed, 496 insertions(+), 291 deletions(-) delete mode 100644 content/math/integer-arithmetic/_index.md delete mode 100644 content/math/integer-arithmetic/core.html create mode 100644 scripts/build-change-detector.ts create mode 100644 scripts/build-smart.ts 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

-
- - - - - - - - - - - - - - - - - -
OperationResult sign
same signspositive
different signsnegative
-
-

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

\(-42\).

-
-
- -
- Checkpoint: evaluate \(36\div(-9)\) -
-

\(-4\).

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

-
- - - - - - - - - - - - - - - - - -
OperationResult sign
same signspositive
different signsnegative
-
-

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

\(-42\).

-
-
- -
- Checkpoint: evaluate \(36\div(-9)\) -
-

\(-4\).

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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TermDefinition
Right triangleA triangle with one angle equal to \(90^\circ\).
LegsThe two sides that form the right angle.
HypotenuseThe side opposite the right angle; the longest side in a right triangle.
Pythagorean TheoremThe 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

- - - - - - - - - - - - - - - - - - - - - - - - - -
TermDefinition
Right triangleA triangle with one angle equal to \(90^\circ\).
LegsThe two sides that form the right angle.
HypotenuseThe side opposite the right angle; the longest side in a right triangle.
Pythagorean TheoremThe 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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TermDefinition
Right triangleA triangle with one angle equal to \(90^\circ\).
LegsThe two sides that form the right angle.
HypotenuseThe side opposite the right angle; the longest side in a right triangle.
Pythagorean TheoremThe 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

- - - - - - - - - - - - - - - - - - - - - - - - - -
TermDefinition
Right triangleA triangle with one angle equal to \(90^\circ\).
LegsThe two sides that form the right angle.
HypotenuseThe side opposite the right angle; the longest side in a right triangle.
Pythagorean TheoremThe 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); } // -----------------------------------------------------------------------------