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