diff --git a/.agents/skills/ofb/SKILL.md b/.agents/skills/ofb/SKILL.md index c5c7605..ac599df 100644 --- a/.agents/skills/ofb/SKILL.md +++ b/.agents/skills/ofb/SKILL.md @@ -74,7 +74,7 @@ bun run build # production: JS + Zola + pagefind → public/ - **Component UI** only (`pagefind-component-ui.js`), not legacy `pagefind-ui.js`. - Index runs **after** `zola build`. Production: `bun run build`. Dev: `bun run index:search` copies bundle to `static/pagefind/` (gitignored) for `zola serve`. -- v1 indexes **live chapter pages only** (`data-pagefind-body` on `templates/math/chapter.html`); home, about, catalog, credits, and other site pages are excluded. +- v1 indexes **live chapter pages only** (`data-pagefind-body` on `templates/chapter.html`); home, about, catalog, credits, and other site pages are excluded. Detailed checklists: [curriculum-data.md](curriculum-data.md). Human-facing guide: [CONTRIBUTING.md](../../../CONTRIBUTING.md). @@ -105,16 +105,17 @@ Detailed checklists: [curriculum-data.md](curriculum-data.md). Human-facing guid Prerequisites: **only** `graph.edges` (required, global DAG, cross-strand OK). Longest-path levels. No implicit strand-list chains. List order ≠ map order. -## Chapter content (current pattern) +## Chapter content (colocated folder) -Live math example: +Live chapters live under `content/{subject}/{slug}/`: -1. `content/math/quadratic-equations/_index.md` — front matter, `template = "math/chapter.html"`, `[extra] chapter_id` -2. `templates/partials/math/quadratic-equations-core-content.html` — prose + KaTeX (supplement partial for history, derivation, etc.) -3. `templates/math/chapter.html` — `{% if section.extra.chapter_id == "…" %}` includes partial -4. Optional: `frontend/src/components/quadratic-explorer.tsx` + mount in `main.tsx` +1. `data/{subject}-curriculum.json` — catalog metadata (`status`, `curriculums`, `graph.edges`) +2. `_index.md` — front matter only (`template = "chapter.html"`, `[extra] subject`, `chapter_id`, `strand`) +3. `core.html` (required), optional `supplement.html`, `assets/`, `widgets/*.tsx`, optional `chapter.scss` +4. `bun run sync:chapters` merges HTML into `_index.md`, copies assets to `static/chapters/…`, generates `frontend/src/generated/chapter-widgets.ts` +5. `templates/chapter.html` renders `{{ section.content | safe }}`; widgets use `data-widget` + auto-mount in `main.tsx` -**Pain point:** each new chapter needs a template `if` branch until a generic chapter loader exists. +Reference: `content/math/quadratic-equations/`. ## Verify before PR diff --git a/.agents/skills/zola/zola-pagefind.md b/.agents/skills/zola/zola-pagefind.md index d76ad3e..13229bb 100644 --- a/.agents/skills/zola/zola-pagefind.md +++ b/.agents/skills/zola/zola-pagefind.md @@ -42,7 +42,7 @@ bun run build # build:js + zola build + index:search | Source | Indexed? | |--------|----------| -| `templates/math/chapter.html` → `
` | Yes | +| `templates/chapter.html` → `
` | Yes | | Home, about, catalog, credits, contributing, `/search/` | No | | Catalog chapter cards (Solid + JSON) | No | | Planned chapters (no `content/` page) | No | @@ -51,10 +51,10 @@ New subject chapter templates must include `data-pagefind-body` on the main less ### Per-chapter markup -[`themes/openfreebooks/templates/math/chapter.html`](../../../themes/openfreebooks/templates/math/chapter.html): +[`themes/openfreebooks/templates/chapter.html`](../../../themes/openfreebooks/templates/chapter.html): ```html -
+
…
diff --git a/.gitignore b/.gitignore index 9d8fcfe..ca877d8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,9 @@ Thumbs.db # Vite build output (bun run build:js → zola copies into public/) themes/openfreebooks/static/ + +# Chapter sync output (bun run sync:chapters) +frontend/src/generated/ +data/_generated/ +static/css/chapters/ +static/chapters/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de69391..e6e1b57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,32 +20,30 @@ Thank you for helping grow free, open textbooks. **Browse the catalog locally:** `bun run build:js && zola serve` → [http://127.0.0.1:1111/catalog/](http://127.0.0.1:1111/catalog/) -**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. Pagefind indexes **live chapter pages only** (not home, about, catalog, or credits). Without `index:search`, the search UI loads but returns no results. +**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. --- ## How the catalog fits together ```text -data/catalog.json → subjects in the sidebar + filter chips (DSE, IB, …) -data/{subject}-curriculum.json → strands, chapters, curriculum tags, graph.edges -content/{subject}/{slug}/ → actual chapter page (when status is "live") -themes/.../partials/... → chapter HTML content (today) +data/{subject}-curriculum.json → catalog list + map (status, curriculums, graph.edges) +content/{subject}/{slug}/ → everything for the live page (one folder) + _index.md, core.html, supplement.html, assets/, widgets/, chapter.scss ``` -- **Catalog** (`/catalog/`) — **List** (numbered cards; live = whole card links) and **Map** (`?view=tree`, Mermaid; live = title link only with `→` and hover underline). -- **`/math/`** redirects to `/catalog/?subject=math`. -- **Curriculum names** (DSE, IB, …) appear only in the catalog — not inside chapter text ([spec.md](spec.md)). +- **Catalog** (`/catalog/`) — list and map (`?view=tree`). Curriculum names (DSE, IB, …) appear only in the catalog — not inside chapter prose ([spec.md](spec.md)). - Every chapter needs a **`description`** in curriculum JSON (map clamps to three lines). +- **`bun run sync:chapters`** (also run automatically by `bun run build:js`) merges `core.html` + `supplement.html` into `_index.md` for Zola. Edit the HTML files, not the merged body in `_index.md`. ### Map graph rules (summary) | Rule | Detail | |------|--------| -| Edges | **Only** `graph.edges` — no automatic chain from strand `chapters[]` order | +| Edges | **Only** `graph.edges` — strand `chapters[]` order does not create arrows | | Meaning | Each edge is a **required** prerequisite (`from` → `to`) | | Scope | Cross-strand edges allowed | -| Layout | **Longest-path** levels; siblings at the same level stack vertically in a column | +| Layout | **Longest-path** levels; siblings stack vertically | | DAG | No cycles — build throws if the graph cycles | | `tier` | Optional `foundation` (default) or `non-foundation` (Extension badge) | @@ -53,11 +51,8 @@ themes/.../partials/... → chapter HTML content (today) ## Add a planned chapter (roadmap) -Use this when the chapter is not written yet but should appear in the catalog. - 1. Open `data/{subject}-curriculum.json` (e.g. `data/math-curriculum.json`). -2. Find the right **strand** (topic group). -3. Add a chapter object: +2. Add a chapter object under the right **strand**: ```json { @@ -70,136 +65,98 @@ Use this when the chapter is not written yet but should appear in the catalog. } ``` -(`tier` is optional — omit for foundation topics; `non-foundation` shows an **Extension** badge.) - -4. Use a **kebab-case** `slug` (letters, numbers, hyphens only). -5. Set `curriculums` to every syllabus tag that applies. Tags must exist in `data/catalog.json` → `curriculums`. -6. Run `zola build` and check `/catalog/?subject=math` (or your subject). - -No `content/` files needed for planned chapters. +3. Use **kebab-case** slugs. No `content/` folder needed for planned chapters. --- ## Publish a live chapter -A live chapter needs **catalog data** and a **page**. - ### 1. Catalog entry -In `data/{subject}-curriculum.json`, set `"status": "live"` and include a `description` (see [planned chapter](#add-a-planned-chapter-roadmap)). +In `data/{subject}-curriculum.json`, set `"status": "live"` and include `description`, `curriculums`, and optional `tier`. -### 2. Zola section +### 2. Chapter folder -Create `content/{subject}/{slug}/_index.md`: +Create `content/{subject}/{slug}/`: + +```text +content/math/your-chapter-slug/ +├── _index.md # page metadata (see below) +├── core.html # required — main lesson HTML +├── supplement.html # optional — history, checkpoints, extra demos +├── chapter.scss # optional — chapter-specific styles +├── assets/ # optional — images (synced to static/chapters/…) +└── widgets/ # optional — Solid widgets (auto-mounted) + └── my-widget.tsx # default export; use data-widget="my-widget" in HTML +``` + +**`_index.md` front matter** (page fields only — catalog fields stay in JSON): ```md +++ title = "Your chapter title" description = "One sentence for search and previews — no curriculum names here." -template = "math/chapter.html" +template = "chapter.html" weight = 10 [extra] +subject = "math" chapter_id = "your-chapter-slug" +strand = "Number & Algebra" +++ ``` -- `slug` in the path must match `slug` in the JSON file. -- `chapter_id` must match the slug (used by the template). -- `weight` controls order within the section (lower = earlier). +### 3. HTML and widgets -### 3. Chapter HTML content +- Use shared classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `content/math/quadratic-equations/`. +- **Do not** mention DSE, IB, or other curriculum names in chapter HTML. +- Mount widgets with `
` (filename without `.tsx` = `data-widget` value). +- Reference synced assets with root-relative paths: `/chapters/math/your-chapter-slug/image.png` (chapter HTML is not processed as Tera) -Today, chapter bodies live as HTML partials under the theme, not Markdown: +### 4. Build and check -1. Add `themes/openfreebooks/templates/partials/math/your-chapter-slug-content.html` with the lesson HTML. -2. In `themes/openfreebooks/templates/math/chapter.html`, include it when `chapter_id` matches (chapter content is inside `
` for search indexing): - -```html -{% if section.extra.chapter_id == "your-chapter-slug" %} -{% include "partials/math/your-chapter-slug-content.html" %} -{% endif %} +```bash +bun run build:js # sync:chapters + Vite bundle +zola serve ``` -Use existing classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `quadratic-equations-core-content.html`. +- `/catalog/?subject=math` — chapter appears and links correctly. +- `/math/your-chapter-slug/` — page renders. +- `bun run build` — full production build. -**Do not** mention DSE, IB, or other curriculum names in the chapter body. - -### 4. Interactive widgets (optional) - -If the chapter needs a Solid widget (like the quadratic explorer): - -1. Add a component under `frontend/src/components/`. -2. Mount it in `frontend/src/main.tsx` when a mount element exists. -3. Add `
` in the chapter template branch. - -Then run `bun run build:js`. - -### 5. Check - -- `/catalog/?subject=math` — chapter appears; live chapters link correctly. -- `/math/your-chapter-slug/` — page renders; breadcrumb goes to catalog. -- `bun run build` completes without errors. +No theme edits, no `main.tsx` edits, no per-chapter template branches. --- ## Chapter graph (branches) -The **Map** tab (`?view=tree`) shows chapters as a directed graph (strand = column, arrows = prerequisites). - -- Prerequisites are **only** declared in `graph.edges` (strand list order does not create arrows). -- For **branches** or **merges**, add edges: +Add edges in `data/{subject}-curriculum.json`: ```json "graph": { "edges": [ - { "from": "quadratic-equations", "to": "sequences-series" }, - { "from": "quadratic-equations", "to": "functions-graphs" }, - { "from": "functions-graphs", "to": "linear-programming" }, - { "from": "sequences-series", "to": "linear-programming" } + { "from": "quadratic-equations", "to": "sequences-series" } ] } ``` -Rules: - -- `from` and `to` are chapter **slugs**. -- The graph must **not contain cycles**. -- Edges may cross strands. - -Open `/catalog/?subject=math&view=tree` to verify. Use Ctrl/Cmd + wheel on the map to zoom. +`from` / `to` are chapter **slugs**. Graph must be acyclic. Verify at `/catalog/?subject=math&view=tree`. --- ## Add a new subject -Example: adding **Science**. - -1. **`data/catalog.json`** — add to `subjects`: - - ```json - { "id": "science", "name": "Science" } - ``` - -2. **`data/science-curriculum.json`** — create with `title`, `strands`, and chapters (copy structure from `math-curriculum.json`). - -3. **`themes/openfreebooks/templates/catalog.html`** — wire the new file into `#catalog-data` (see `math` block). - -4. **`content/science/`** — add `_index.md` when you have a section. - -5. **Homepage** — optional card in `themes/openfreebooks/templates/index.html`. - -6. Build and test `/catalog/?subject=science`. +1. `data/catalog.json` — add to `subjects`. +2. Create `data/{id}-curriculum.json` (copy structure from `math-curriculum.json`). +3. Wire the subject in `themes/openfreebooks/templates/catalog.html` (until auto-wire lands). +4. Add `content/{subject}/` when you have chapters. --- ## Add a curriculum label -Example: adding **GCSE**. - -1. Add `"GCSE"` to `curriculums` in `data/catalog.json`. -2. Tag chapters with `"GCSE"` in their `curriculums` arrays. -3. Add a badge style in `themes/openfreebooks/sass/_catalog.scss` (copy `.catalog-badge--igcse` pattern). -4. Add a mapping in `frontend/src/lib/catalog-badge.ts` → `curriculumBadgeClass()` (or rely on the default style). +1. Add the label to `curriculums` in `data/catalog.json`. +2. Tag chapters in their `curriculums` arrays. +3. Add badge style in `themes/openfreebooks/sass/_catalog.scss` and mapping in `frontend/src/lib/catalog-badge.ts`. --- @@ -207,22 +164,23 @@ Example: adding **GCSE**. ```bash bun install -bun run build:js # after frontend/ changes +bun run sync:chapters # after content/ chapter changes +bun run build:js # sync + Vite zola serve -bun test # catalog map generation (catalog-to-mermaid) +bun test bun run build ``` -- Do **not** commit `themes/openfreebooks/static/` (Vite output). Run `bun run build:js` before `zola build` or `zola serve`. +- Do **not** commit `themes/openfreebooks/static/` (Vite output). - Do **not** commit `public/`. --- ## Pull requests -- Keep changes focused (one subject or a few chapters per PR is fine). +- Keep changes focused. - Run `bun run build` before opening the PR. -- Describe what you added: subject, chapters, live vs planned, and which curricula apply. +- Describe subject, chapters, live vs planned, and curricula. Questions? Open a [GitHub discussion](https://github.com/hananoshikayomaru/openfreebooks) or issue. @@ -230,4 +188,4 @@ Questions? Open a [GitHub discussion](https://github.com/hananoshikayomaru/openf ## For maintainers & agents -Machine-oriented detail: [.agents/skills/ofb/SKILL.md](.agents/skills/ofb/SKILL.md) and [.agents/skills/ofb/curriculum-data.md](.agents/skills/ofb/curriculum-data.md). +Machine-oriented detail: [.agents/skills/ofb/SKILL.md](.agents/skills/ofb/SKILL.md). diff --git a/README.md b/README.md index 823ef5a..41f5905 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ These issues make contributions harder than they should be. PRs that fix any ite ### Chapters & content -- [ ] **Generic chapter template** — drop per-chapter `{% if chapter_id %}` branches; load `partials/{subject}/{slug}-content.html` by convention. -- [ ] **Strand kicker from data** — chapter header still hardcodes “Number & Algebra”; should come from strand in curriculum JSON. -- [ ] **Subject-agnostic chapter template** — breadcrumb/catalog URL and `load_data` path should use section path, not `math` only. +- [x] **Colocated chapter folders** — `content/{subject}/{slug}/` with `sync:chapters`, generic `chapter.html`, auto widget registry. +- [x] **Strand kicker from front matter** — `[extra] strand` in `_index.md`. +- [ ] **Subject-agnostic catalog load in chapter template** — breadcrumb still loads `math-curriculum.json` literally until more subjects go live. - [ ] **Markdown chapters (optional)** — README/spec disagree on format; if staying HTML, document clearly everywhere. - [ ] **Defuddle “copy as Markdown”** — spec calls for it on every page; not implemented. diff --git a/bun.lock b/bun.lock index 2cf57c7..6da812f 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,8 @@ "three": "^0.184.0", }, "devDependencies": { - "pagefind": "^1.3.0", + "pagefind": "^1.5.0", + "sass": "^1.89.2", "typescript": "^5.8.3", "vite": "^6.3.5", "vite-plugin-solid": "^2.11.6", @@ -147,6 +148,34 @@ "@pagefind/windows-x64": ["@pagefind/windows-x64@1.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg=="], + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], @@ -289,6 +318,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -383,6 +414,8 @@ "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -419,10 +452,16 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -455,6 +494,8 @@ "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], @@ -477,6 +518,8 @@ "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], @@ -487,6 +530,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], diff --git a/content/math/quadratic-equations/_index.md b/content/math/quadratic-equations/_index.md index cd595e0..85ae197 100644 --- a/content/math/quadratic-equations/_index.md +++ b/content/math/quadratic-equations/_index.md @@ -1,8 +1,235 @@ +++ title = "Quadratic equations" description = "The discriminant, nature of roots, and the graph of y = ax² + bx + c." -template = "math/chapter.html" +template = "chapter.html" weight = 1 [extra] +subject = "math" chapter_id = "quadratic-equations" +strand = "Number & Algebra" +++ + +
+

+ A quadratic equation in one unknown has the form \(ax^2 + bx + c = 0\), where + \(a \neq 0\). You can solve these by factorisation, the quadratic + formula, or by reading information from the graph of \(y = ax^2 + bx + c\). +

+ +

The discriminant

+

+ The discriminant \(\Delta\) (delta) is defined below. It tells you how many + real roots the equation has without fully solving it. +

+ +
+

Definition

+

\[ \Delta = b^2 - 4ac \]

+
+ +

Nature of roots

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
DiscriminantNature of rootsGraph of \(y = ax^2 + bx + c\)
\(\Delta > 0\)Two distinct real rootsParabola cuts the x-axis twice
\(\Delta = 0\)One repeated real root (equal roots)Vertex touches the x-axis
\(\Delta < 0\)No real rootsParabola lies entirely above or below the x-axis
+
+ +

+ When \(\Delta \geq 0\), the quadratic formula gives the roots: + \[ x = \frac{-b \pm \sqrt{\Delta}}{2a} \] +

+ +

Try it yourself

+

+ Use the explorer below to change \(a\), \(b\), and \(c\). Watch how \(\Delta\) and the + parabola change, and read off the nature of the roots. +

+
+ +
+ +
+

History

+

+ People have worked on problems that lead to quadratic equations for thousands of years. + Babylonian clay tablets from around 1800 BCE record area and length puzzles — for example, + finding the sides of a rectangle when you know its area and perimeter. Those situations + produce equations in one unknown whose highest power is two. +

+ +
+ Babylonian clay tablet with cuneiform text recording a quadratic area problem +
+ A Babylonian clay tablet (about 1800 BCE) with a problem equivalent to finding the + sides of a rectangle from its area and perimeter. +
+
+ +

+ Early methods were often geometric: you imagine completing a rectangle to make a square, + which is the same idea behind completing the square today. In the 9th century, + al-Khwarizmi wrote systematic rules for many types of quadratic problems, + sorted by which coefficients were present. He did not use modern symbols like \(x^2\), but + the logic matches what we still teach. +

+

+ During the Renaissance, mathematicians such as Cardano and + Tartaglia pushed further into solving cubics and quartics. Along the way they + met equations whose discriminants were negative — no real roots, yet the algebra still + demanded answers. That tension helped open the door to complex numbers, a + topic you will meet in a later chapter. +

+ +

Derivation

+

+ The quadratic formula comes from rearranging \(ax^2 + bx + c = 0\) and completing the square. + Below is a guided derivation; routine algebra between steps is left for you to check. +

+

+ Start with \(a \neq 0\) and divide every term by \(a\): + \[ x^2 + \frac{b}{a}x + \frac{c}{a} = 0. \] + Move the constant to the right: + \[ x^2 + \frac{b}{a}x = -\frac{c}{a}. \] +

+

+ The expression \(x^2 + \frac{b}{a}x\) is almost a perfect square. Add and subtract + \(\left(\frac{b}{2a}\right)^2\) on the left (equivalently, add that square to both sides): + \[ x^2 + \frac{b}{a}x + \left(\frac{b}{2a}\right)^2 = -\frac{c}{a} + \left(\frac{b}{2a}\right)^2. \] + The left side factors as a square: + \[ \left(x + \frac{b}{2a}\right)^2 = \frac{b^2 - 4ac}{4a^2}. \] +

+

+ Take square roots (when the right-hand side is non-negative you get real solutions): + \[ x + \frac{b}{2a} = \pm \frac{\sqrt{b^2 - 4ac}}{2a}. \] + Solve for \(x\): +

+
+

Quadratic formula

+

+ \[ x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} \] +

+
+

+ The quantity under the square root is exactly the discriminant: + \(\Delta = b^2 - 4ac\). So the formula is \(x = \frac{-b \pm \sqrt{\Delta}}{2a}\) whenever + \(\Delta \geq 0\). If \(\Delta > 0\) you get two distinct real roots; if \(\Delta = 0\) the + \(\pm\) gives the same root twice; if \(\Delta < 0\) there is no real square root and the + equation has no real solutions — matching the nature-of-roots table above. +

+ +

Checkpoints

+

+ Pause and think before moving on. There are no answers on this page — discuss with a + classmate or write your reasoning in a notebook. +

+ +
+

Checkpoint

+

+ In the explorer, keep \(a\) and \(b\) fixed and slowly change \(c\) so that \(\Delta\) goes + from positive to zero. What happens to the two x-intercepts on the graph? +

+
+ +
+

Checkpoint

+

+ Can \(ax^2 + bx + c = 0\) with \(a \neq 0\) have three distinct real solutions? Why or why not? +

+
+ +
+

Checkpoint

+

+ Suppose \(a > 0\) and \(\Delta < 0\). Where does the entire parabola \(y = ax^2 + bx + c\) + sit relative to the x-axis? +

+
+ +
+

Checkpoint

+

+ A ball is thrown straight upward from ground level. Without using numbers, describe a + situation where \(\Delta > 0\) for height versus time, and another where \(\Delta = 0\). +

+
+ +
+

Checkpoint

+

+ After completing the square you had \(\left(x + \frac{b}{2a}\right)^2 = \frac{\Delta}{4a^2}\). + Why must the sign of \(\Delta\) match whether the parabola crosses the x-axis? +

+
+ +

Applications

+ +

Projectile motion

+

+ Near Earth’s surface, height \(h\) (in metres) of an object thrown upward often follows + \[ h = -\tfrac{1}{2}gt^2 + v_0 t + h_0, \] + where \(t\) is time in seconds, \(g \approx 9.8\) is gravitational acceleration, + \(v_0\) is initial upward speed, and \(h_0\) is starting height. This is a quadratic in + \(t\). Setting \(h = 0\) asks when the object is at ground level; the solutions are the + landing times. The discriminant tells you whether there are two crossing times + (\(\Delta > 0\)), one (\(\Delta = 0\)), or none (\(\Delta < 0\)) — for example if the + model never reaches the ground in the time you care about. +

+ +
+ +

Profit and pricing

+

+ A shop might find that when it charges \(p\) dollars per item, revenue is \(p\) times + quantity sold, while cost has a fixed part plus a cost per item. Profit (revenue minus cost) + is often a quadratic in \(p\) that opens downward — there is a best price at the vertex. + For example, with profit \(\Pi\) in dollars, + \[ \Pi(p) = -2p^2 + 40p - 150. \] + Setting \(\Pi = 0\) asks for break-even prices; \(\Delta\) tells you whether two + break-even prices exist, one, or none. +

+ +
+ +

Area with fixed perimeter

+

+ A rectangle with perimeter \(P\) can be described by width \(x\) and length + \(\frac{P}{2} - x\). Its area is + \[ A(x) = x\left(\frac{P}{2} - x\right) = -\!x^2 + \frac{P}{2}x, \] + a downward-opening parabola. The largest area occurs at the vertex, not at the extremes + \(x = 0\) or \(x = \frac{P}{2}\) (which give zero area). +

+ +
+
diff --git a/content/math/quadratic-equations/assets/babylonian-quadratic-tablet.png b/content/math/quadratic-equations/assets/babylonian-quadratic-tablet.png new file mode 100644 index 0000000..e1c49dd Binary files /dev/null and b/content/math/quadratic-equations/assets/babylonian-quadratic-tablet.png differ diff --git a/themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html b/content/math/quadratic-equations/core.html similarity index 95% rename from themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html rename to content/math/quadratic-equations/core.html index ea9f3d8..ece67f4 100644 --- a/themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html +++ b/content/math/quadratic-equations/core.html @@ -57,3 +57,5 @@ parabola change, and read off the nature of the roots.

+ +
diff --git a/themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html b/content/math/quadratic-equations/supplement.html similarity index 95% rename from themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html rename to content/math/quadratic-equations/supplement.html index 216b7b5..babc295 100644 --- a/themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html +++ b/content/math/quadratic-equations/supplement.html @@ -10,7 +10,7 @@
Babylonian clay tablet with cuneiform text recording a quadratic area problem -
+

Profit and pricing

@@ -147,7 +147,7 @@ break-even prices exist, one, or none.

-
+

Area with fixed perimeter

@@ -158,5 +158,5 @@ \(x = 0\) or \(x = \frac{P}{2}\) (which give zero area).

-
+
diff --git a/frontend/src/components/quadratic-application-demos.tsx b/content/math/quadratic-equations/widgets/_application-demos.tsx similarity index 99% rename from frontend/src/components/quadratic-application-demos.tsx rename to content/math/quadratic-equations/widgets/_application-demos.tsx index d91b3d4..02f3a00 100644 --- a/frontend/src/components/quadratic-application-demos.tsx +++ b/content/math/quadratic-equations/widgets/_application-demos.tsx @@ -1,6 +1,6 @@ import katex from "katex"; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; -import { computeRoots, discriminant, formatNum } from "../lib/math-graph"; +import { computeRoots, discriminant, formatNum } from "../../../../frontend/src/lib/math-graph"; const HORIZONTAL_SPEED = 12; diff --git a/content/math/quadratic-equations/widgets/area-demo.tsx b/content/math/quadratic-equations/widgets/area-demo.tsx new file mode 100644 index 0000000..8d1f180 --- /dev/null +++ b/content/math/quadratic-equations/widgets/area-demo.tsx @@ -0,0 +1 @@ +export { AreaDemo as default } from "./_application-demos"; diff --git a/frontend/src/components/quadratic-explorer.tsx b/content/math/quadratic-equations/widgets/explorer.tsx similarity index 99% rename from frontend/src/components/quadratic-explorer.tsx rename to content/math/quadratic-equations/widgets/explorer.tsx index 3b3fe24..ac7abba 100644 --- a/frontend/src/components/quadratic-explorer.tsx +++ b/content/math/quadratic-equations/widgets/explorer.tsx @@ -340,7 +340,7 @@ function SliderRow(props: { ); } -export function QuadraticExplorer() { +function QuadraticExplorer() { const [a, setA] = createSignal(1); const [b, setB] = createSignal(0); const [c, setC] = createSignal(-4); @@ -556,3 +556,5 @@ export function QuadraticExplorer() { ); } + +export default QuadraticExplorer; diff --git a/content/math/quadratic-equations/widgets/profit-demo.tsx b/content/math/quadratic-equations/widgets/profit-demo.tsx new file mode 100644 index 0000000..60bb230 --- /dev/null +++ b/content/math/quadratic-equations/widgets/profit-demo.tsx @@ -0,0 +1 @@ +export { ProfitDemo as default } from "./_application-demos"; diff --git a/content/math/quadratic-equations/widgets/projectile-demo.tsx b/content/math/quadratic-equations/widgets/projectile-demo.tsx new file mode 100644 index 0000000..5b4b5ec --- /dev/null +++ b/content/math/quadratic-equations/widgets/projectile-demo.tsx @@ -0,0 +1 @@ +export { ProjectileDemo as default } from "./_application-demos"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1c1f754..49aba10 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -36,6 +36,40 @@ function mountFooterYear() { } } +function mountChapterWidgets() { + const article = document.querySelector("[data-chapter]"); + const chapterKey = article?.dataset.chapter; + const mounts = document.querySelectorAll("[data-widget]"); + if (!chapterKey || mounts.length === 0) return; + + void import("./generated/chapter-widgets").then(({ chapterWidgetLoaders }) => { + const loadPromises: Promise[] = []; + + for (const el of mounts) { + const widget = el.dataset.widget; + if (!widget) continue; + const loaderKey = `${chapterKey}/${widget}`; + const loader = chapterWidgetLoaders[loaderKey]; + if (!loader) { + console.warn(`No widget loader registered for ${loaderKey}`); + continue; + } + loadPromises.push( + loader().then((mod) => { + render(() => mod.default(), el); + }) + ); + } + + void Promise.all(loadPromises).then(() => { + if (!document.querySelector(".book-prose")) return; + void import("./lib/render-math").then(({ renderBookMath }) => { + requestAnimationFrame(() => renderBookMath()); + }); + }); + }); +} + initTheme(); const config = readConfig(); @@ -78,12 +112,6 @@ if (copyPageRoot) { }); } -const hasQuadraticChapter = - document.getElementById("quadratic-explorer") || - document.getElementById("projectile-demo") || - document.getElementById("profit-demo") || - document.getElementById("area-demo"); - const chapterNavRoot = document.getElementById("chapter-heading-nav"); if (chapterNavRoot) { void import("./components/chapter-heading-nav").then(({ ChapterHeadingNav }) => { @@ -91,30 +119,7 @@ if (chapterNavRoot) { }); } -if (hasQuadraticChapter) { - void Promise.all([ - import("./components/quadratic-explorer"), - import("./components/quadratic-application-demos"), - import("./lib/render-math"), - ]).then(([{ QuadraticExplorer }, demos, { renderBookMath }]) => { - const quadraticRoot = document.getElementById("quadratic-explorer"); - if (quadraticRoot) { - render(() => , quadraticRoot); - } - const appDemos: Array<[string, () => import("solid-js").JSX.Element]> = [ - ["projectile-demo", demos.ProjectileDemo], - ["profit-demo", demos.ProfitDemo], - ["area-demo", demos.AreaDemo], - ]; - for (const [id, Demo] of appDemos) { - const root = document.getElementById(id); - if (root) { - render(() => , root); - } - } - requestAnimationFrame(() => renderBookMath()); - }); -} +mountChapterWidgets(); const catalogRoot = document.getElementById("catalog-app"); if (catalogRoot) { diff --git a/package.json b/package.json index 3d62e74..bc30f33 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,11 @@ "private": true, "type": "module", "scripts": { - "build:js": "vite build", + "sync:chapters": "bun scripts/sync-chapters.ts", + "build:js": "bun run sync:chapters && vite build", "build": "rm -f themes/openfreebooks/static/main.css public/main.css && bun run build:js && zola build && bun run index:search", "index:search": "pagefind --site public && rm -rf static/pagefind && cp -R public/pagefind static/pagefind", - "dev:js": "vite build --watch", + "dev:js": "bun run sync:chapters && vite build --watch", "scan:size": "bun scripts/scan-output-size.ts", "test": "bun test frontend/src/lib" }, @@ -20,6 +21,7 @@ "three": "^0.184.0" }, "devDependencies": { + "sass": "^1.89.2", "pagefind": "^1.5.0", "typescript": "^5.8.3", "vite": "^6.3.5", diff --git a/scripts/sync-chapters.ts b/scripts/sync-chapters.ts new file mode 100644 index 0000000..9384c66 --- /dev/null +++ b/scripts/sync-chapters.ts @@ -0,0 +1,216 @@ +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"; + +const ROOT = join(import.meta.dir, ".."); +const CONTENT = join(ROOT, "content"); +const GENERATED_META = join(ROOT, "data/_generated/chapters-meta.json"); +const GENERATED_WIDGETS = join(ROOT, "frontend/src/generated/chapter-widgets.ts"); +const STATIC_ROOT = join(ROOT, "static"); +const CHAPTER_CSS_DIR = join(STATIC_ROOT, "css/chapters"); + +type ChapterMeta = { + hasSupplement: boolean; + hasCss: boolean; +}; + +type ChaptersMetaFile = { + chapters: Record; +}; + +function parseChapterTemplate(markdown: string): boolean { + return /template\s*=\s*["']chapter\.html["']/.test(markdown); +} + +/** Chapter HTML is Zola section body, not a Tera template — rewrite legacy get_url tags. */ +function normalizeChapterHtml(html: string): string { + return html + .replace( + /\{\{\s*get_url\(path=['"]([^'"]+)['"][^)]*\)\s*\|\s*safe\s*\}\}/g, + (_, assetPath: string) => `/${assetPath.replace(/^\//, "")}` + ) + .replace(/\{\{\s*get_url\([^}]+\)\s*\}\}/g, (tag) => { + console.warn(`Unresolved Tera in chapter HTML (use /chapters/… paths): ${tag}`); + return tag; + }); +} + +function splitFrontMatter(markdown: string): { frontMatter: string; body: string } { + const match = markdown.match(/^(\+\+\+[\s\S]*?\+\+\+)\s*([\s\S]*)$/); + if (!match) { + return { frontMatter: markdown.trimEnd(), body: "" }; + } + return { frontMatter: match[1], body: match[2].trim() }; +} + +async function copyFileEnsuringDir(src: string, dest: string) { + await mkdir(dirname(dest), { recursive: true }); + await copyFile(src, dest); +} + +async function copyDir(srcDir: string, destDir: string) { + let entries; + try { + entries = await readdir(srcDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const src = join(srcDir, entry.name); + const dest = join(destDir, entry.name); + if (entry.isDirectory()) { + await copyDir(src, dest); + } else { + await copyFileEnsuringDir(src, dest); + } + } +} + +async function compileChapterScss( + subject: string, + slug: string, + scssPath: string +): Promise { + const outPath = join(CHAPTER_CSS_DIR, `${subject}-${slug}.css`); + await mkdir(CHAPTER_CSS_DIR, { recursive: true }); + const result = await $`sass ${scssPath} ${outPath} --style=compressed --no-source-map`.quiet(); + if (result.exitCode !== 0) { + console.error(`Failed to compile ${scssPath}`); + process.exit(1); + } + return true; +} + +async function discoverChapters(): Promise< + Array<{ subject: string; slug: string; dir: string }> +> { + const chapters: Array<{ subject: string; slug: string; dir: string }> = []; + let subjects; + try { + subjects = await readdir(CONTENT, { withFileTypes: true }); + } catch { + return chapters; + } + + for (const subjectEntry of subjects) { + if (!subjectEntry.isDirectory() || subjectEntry.name.startsWith(".")) continue; + const subject = subjectEntry.name; + const subjectDir = join(CONTENT, subject); + const slugEntries = await readdir(subjectDir, { withFileTypes: true }); + for (const slugEntry of slugEntries) { + if (!slugEntry.isDirectory() || slugEntry.name.startsWith(".")) continue; + const slug = slugEntry.name; + const dir = join(subjectDir, slug); + const indexPath = join(dir, "_index.md"); + if (!existsSync(indexPath)) continue; + const markdown = await readFile(indexPath, "utf8"); + if (!parseChapterTemplate(markdown)) continue; + chapters.push({ subject, slug, dir }); + } + } + return chapters; +} + +async function syncChapter(chapter: { + subject: string; + slug: string; + dir: string; +}): Promise<{ key: string; meta: ChapterMeta; widgets: string[] }> { + const { subject, slug, dir } = chapter; + const key = `${subject}/${slug}`; + const corePath = join(dir, "core.html"); + if (!existsSync(corePath)) { + console.error(`Missing core.html for chapter ${key}`); + process.exit(1); + } + + const coreHtml = normalizeChapterHtml(await readFile(corePath, "utf8")); + const supplementPath = join(dir, "supplement.html"); + const hasSupplement = existsSync(supplementPath); + const supplementHtml = hasSupplement + ? normalizeChapterHtml(await readFile(supplementPath, "utf8")) + : ""; + const mergedBody = hasSupplement + ? `${coreHtml.trim()}\n\n${supplementHtml.trim()}\n` + : `${coreHtml.trim()}\n`; + + const indexPath = join(dir, "_index.md"); + const indexRaw = await readFile(indexPath, "utf8"); + const { frontMatter } = splitFrontMatter(indexRaw); + await writeFile(indexPath, `${frontMatter}\n\n${mergedBody}`); + + 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); + } + + const scssPath = join(dir, "chapter.scss"); + let hasCss = false; + if (existsSync(scssPath)) { + hasCss = await compileChapterScss(subject, slug, scssPath); + } + + const widgetsDir = join(dir, "widgets"); + const widgets: string[] = []; + if (existsSync(widgetsDir)) { + const files = await readdir(widgetsDir); + for (const file of files) { + if (file.endsWith(".tsx") && !file.startsWith("_")) { + widgets.push(basename(file, ".tsx")); + } + } + } + + return { key, meta: { hasSupplement, hasCss }, widgets }; +} + +function buildWidgetRegistry( + entries: Array<{ key: string; widgets: string[]; dir: string }> +): string { + const lines: string[] = [ + "// Auto-generated by scripts/sync-chapters.ts — do not edit.", + "", + "import type { Component } from \"solid-js\";", + "", + "export type ChapterWidgetModule = { default: Component };", + "", + "export const chapterWidgetLoaders: Record Promise> = {", + ]; + + for (const { key, widgets, dir } of entries) { + const relFromGenerated = relative(dirname(GENERATED_WIDGETS), dir).replace(/\\/g, "/"); + for (const widget of widgets) { + const importPath = `${relFromGenerated}/widgets/${widget}.tsx`; + lines.push(` "${key}/${widget}": () => import("${importPath}"),`); + } + } + + lines.push("};", ""); + return lines.join("\n"); +} + +async function main() { + 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); + 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 }); + await writeFile(GENERATED_META, `${JSON.stringify(metaFile, null, 2)}\n`); + + await mkdir(dirname(GENERATED_WIDGETS), { recursive: true }); + await writeFile(GENERATED_WIDGETS, buildWidgetRegistry(widgetEntries)); + + console.log(`Synced ${chapters.length} chapter(s).`); +} + +main(); diff --git a/spec.md b/spec.md index 739402f..bb7e91d 100644 --- a/spec.md +++ b/spec.md @@ -22,7 +22,7 @@ we need to have a question bank feature. since this is purely static sites, ques 4. choice 5. ranking -question bank not only show answer but also steps and calcultion +question bank not only show answer but also steps, explanation, calcultion. ## Chapters requirements diff --git a/themes/openfreebooks/templates/chapter.html b/themes/openfreebooks/templates/chapter.html new file mode 100644 index 0000000..34e9cfd --- /dev/null +++ b/themes/openfreebooks/templates/chapter.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block title %} +{% set subject_catalog = load_data(path="data/math-curriculum.json", format="json") %} +{{ section.title }} | {{ subject_catalog.title }} | {{ config.extra.site_title }} +{% endblock title %} + +{% block meta_description %} + +{% endblock meta_description %} + +{% block extra_head %} +{% set subject = section.extra.subject %} +{% set slug = section.extra.chapter_id | default(value=section.slug) %} +{% set chapter_key = subject ~ "/" ~ slug %} +{% set chapters_meta = load_data(path="data/_generated/chapters-meta.json", format="json") %} +{% set chapter_meta = chapters_meta.chapters[chapter_key] %} + +{% if chapter_meta.hasCss %} + +{% endif %} +{% endblock extra_head %} + +{% block content %} +{% set subject = section.extra.subject %} +{% set slug = section.extra.chapter_id | default(value=section.slug) %} +{% set chapter_key = subject ~ "/" ~ slug %} +{% set subject_catalog = load_data(path="data/math-curriculum.json", format="json") %} +{% set catalog_subject_url = get_url(path="/catalog/") ~ "?subject=" ~ subject %} +
+
+ + +
+
+
+ {% if section.extra.strand %} +

{{ section.extra.strand }}

+ {% endif %} +

{{ section.title }}

+ {% if section.description %} +

{{ section.description }}

+ {% endif %} +
+
+
+ +
+ {{ section.content | safe }} +
+
+
+{% endblock content %} diff --git a/themes/openfreebooks/templates/math/chapter.html b/themes/openfreebooks/templates/math/chapter.html deleted file mode 100644 index fc7e16e..0000000 --- a/themes/openfreebooks/templates/math/chapter.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ section.title }} | Mathematics | {{ config.extra.site_title }}{% endblock title %} -{% block meta_description %} - -{% endblock meta_description %} - -{% block extra_head %} - -{% endblock extra_head %} - -{% block content %} -{% set math_catalog = load_data(path="data/math-curriculum.json", format="json") %} -{% set catalog_math_url = get_url(path="/catalog/") ~ "?subject=math" %} -
-
- - -
-
-
-

Number & Algebra

-

{{ section.title }}

- {% if section.description %} -

{{ section.description }}

- {% endif %} -
-
-
- - {% if section.extra.chapter_id == "quadratic-equations" %} -
- {% include "partials/math/quadratic-equations-core-content.html" %} -
- {% include "partials/math/quadratic-equations-supplement-content.html" %} - {% endif %} -
-
-
-{% endblock content %} diff --git a/themes/openfreebooks/templates/partials/contributing-content.html b/themes/openfreebooks/templates/partials/contributing-content.html index 7e8a093..35dd5c1 100644 --- a/themes/openfreebooks/templates/partials/contributing-content.html +++ b/themes/openfreebooks/templates/partials/contributing-content.html @@ -28,10 +28,9 @@

How the catalog fits together

-
data/catalog.json              → subjects + filter chips (DSE, IB, …)
-data/{subject}-curriculum.json → strands, chapters, graph.edges
-content/{subject}/{slug}/      → chapter page when status is "live"
-themes/.../partials/...        → chapter HTML bodies (today)
+
data/{subject}-curriculum.json → catalog + graph (status, curriculums, edges)
+content/{subject}/{slug}/      → live page (one folder)
+  _index.md, core.html, supplement.html, assets/, widgets/
  • List — numbered cards; live chapters link from the whole card.
  • Map (?view=tree) — Mermaid map with strand columns; live chapters link from the title only (→ + hover underline).
  • @@ -67,15 +66,13 @@ themes/.../partials/... → chapter HTML bodies (today)

    Publish a live chapter

    -

    A live chapter needs catalog JSON and a Zola section.

    +

    A live chapter needs a catalog JSON entry and a folder under content/{subject}/{slug}/.

    1. Catalog entry

    -

    Set "status": "live" and include description in the curriculum JSON.

    -

    2. Zola section

    -

    Create content/{subject}/{slug}/_index.md with chapter_id matching the slug.

    -

    3. Chapter HTML

    -

    Add themes/openfreebooks/templates/partials/math/your-slug-content.html and wire it in math/chapter.html. Copy patterns from quadratic-equations-core-content.html.

    -

    4. Optional widget

    -

    Add a Solid component under frontend/src/components/ and mount it in main.tsx. Run bun run build:js.

    +

    Set "status": "live" in data/{subject}-curriculum.json with description and curriculums.

    +

    2. Chapter folder

    +

    Add _index.md, core.html, optional supplement.html, assets/, widgets/*.tsx. Copy content/math/quadratic-equations/.

    +

    3. Build

    +

    bun run build:js syncs HTML and bundles widgets — no theme or main.tsx edits.