mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-12 20:45:52 +00:00
Enhance contributing guidelines in CONTRIBUTING.md with a readable link, update curriculum JSON structure to include chapter tiers, and improve catalog chapter card display with tier badges. Update package.json for testing scripts and add contributing URL in zola.toml. Refactor frontend components and styles for better integration of new features.
This commit is contained in:
parent
8c986c1ed7
commit
81b316e08d
33 changed files with 6944 additions and 152 deletions
|
|
@ -87,7 +87,7 @@ Detailed checklists: [curriculum-data.md](curriculum-data.md). Human-facing guid
|
|||
|
||||
**Canvas viewer quirks:** use `container.get(internal.InteractionHandler).onClick` (not `viewer.onClick`); edge colors must be 8-digit hex (no `rgba` on node/edge color fields); wheel on map uses capture-phase `stopPropagation` unless Ctrl/Cmd so the page can scroll.
|
||||
|
||||
Implicit edges: sequential links inside each strand. Explicit: `graph.edges` for branches. Both merged; deduped.
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,9 @@ Example: `data/math-curriculum.json`.
|
|||
"intro": "Optional; was used by old book page",
|
||||
"graph": {
|
||||
"edges": [
|
||||
{ "from": "quadratic-equations", "to": "sequences" },
|
||||
{ "from": "quadratic-equations", "to": "functions-graphs" }
|
||||
{ "from": "quadratic-equations", "to": "sequences-series" },
|
||||
{ "from": "quadratic-equations", "to": "functions-graphs" },
|
||||
{ "from": "functions-graphs", "to": "linear-programming" }
|
||||
]
|
||||
},
|
||||
"strands": [
|
||||
|
|
@ -63,13 +64,26 @@ Example: `data/math-curriculum.json`.
|
|||
| `description` | yes | 1–2 sentences; shown in list and map cards (clamped to 3 lines on map) |
|
||||
| `status` | yes | `live` (link to chapter) or `planned` (no link) |
|
||||
| `curriculums` | yes | Non-empty array; values must exist in `catalog.json` `curriculums` |
|
||||
| `tier` | no | `foundation` (default) or `non-foundation` — shows an **Extension** badge on cards |
|
||||
|
||||
### Graph model (map)
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Scope | DSE compulsory topic units (~25–35 chapters); primary/junior not in JSON yet |
|
||||
| Edges | **Required** prerequisites only; `graph.edges` is the sole source (no auto chain from strand array order) |
|
||||
| Scope of `from`/`to` | Any chapter slug — **cross-strand** edges allowed (arrows may cross columns) |
|
||||
| Levels | **Longest-path** from roots: `level = 1 + max(level(parent))`; roots at 0 |
|
||||
| List order | `strands[].chapters[]` array order is for the **list** tab only (authoring / ToC) |
|
||||
| Roots | Multiple level-0 chapters per strand OK; edgeless placeholders OK while drafting |
|
||||
|
||||
### Graph edges
|
||||
|
||||
- `from` / `to` are **chapter slugs** (not node ids).
|
||||
- Edges must form a **DAG** (no cycles). Map builder runs cycle detection.
|
||||
- Strand order still adds implicit edges: each chapter → next chapter in the same strand (deduped).
|
||||
- Use explicit edges for **branches** (one chapter → multiple next chapters).
|
||||
- Edges must form a **DAG** (no cycles). Map builder throws on cycles.
|
||||
- One prerequisite → add one edge. Multiple parents → multiple edges into the same `to`.
|
||||
- Branches: one `from` → many `to` (e.g. quadratics → sequences and functions-graphs).
|
||||
- Merges: many `from` → one `to` (e.g. functions + sequences → linear programming).
|
||||
|
||||
### Wiring into catalog
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Contributing to Open Free Books
|
||||
|
||||
Thank you for helping grow free, open textbooks. This guide focuses on **curriculum, subjects, and chapters** — the most common contribution.
|
||||
Thank you for helping grow free, open textbooks.
|
||||
|
||||
**Readable guide (with examples):** [openfreebooks.org/contributing](https://openfreebooks.org/contributing/) — or locally after `zola serve` → [http://127.0.0.1:1111/contributing/](http://127.0.0.1:1111/contributing/)
|
||||
|
||||
**Prerequisites:** [Zola](https://www.getzola.org/), [Bun](https://bun.sh/). See [README.md](README.md) for dev setup.
|
||||
|
||||
|
|
@ -11,7 +13,7 @@ Thank you for helping grow free, open textbooks. This guide focuses on **curricu
|
|||
| I want to… | Start here |
|
||||
|------------|------------|
|
||||
| Add a **planned** chapter (roadmap only) | [§ Planned chapter](#add-a-planned-chapter-roadmap) |
|
||||
| Publish a **live** chapter (readable page) | [§ Live chapter](#publish-a-live-chapter) |
|
||||
| Publish a **live** chapter (readable page) | [§ Publish a live chapter](#publish-a-live-chapter) |
|
||||
| Add a **branch** in the catalog map | [§ Chapter graph](#chapter-graph-branches) |
|
||||
| Add a new **subject** | [§ New subject](#add-a-new-subject) |
|
||||
| Add a **curriculum** filter (e.g. GCSE) | [§ New curriculum label](#add-a-curriculum-label) |
|
||||
|
|
@ -24,17 +26,26 @@ Thank you for helping grow free, open textbooks. This guide focuses on **curricu
|
|||
|
||||
```text
|
||||
data/catalog.json → subjects in the sidebar + filter chips (DSE, IB, …)
|
||||
data/{subject}-curriculum.json → strands, chapters, curriculum tags, optional graph
|
||||
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)
|
||||
```
|
||||
|
||||
- **Catalog** (`/catalog/`) is the place to browse everything.
|
||||
- **List** — numbered chapter cards; live chapters link to the whole card.
|
||||
- **Map** — strand columns on a pannable JSON Canvas graph; live chapters link from the **title only** (with `→` and a hover underline).
|
||||
- **`/math/`** redirects to `/catalog/?subject=math` — there is no separate math book homepage.
|
||||
- **Catalog** (`/catalog/`) — **List** (numbered cards; live = whole card links) and **Map** (`?view=tree`; 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)).
|
||||
- Every chapter needs a short **`description`** in curriculum JSON (shown in list and map; map clamps description to three lines).
|
||||
- Every chapter needs a **`description`** in curriculum JSON (map clamps to three lines).
|
||||
|
||||
### Map graph rules (summary)
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Edges | **Only** `graph.edges` — no automatic chain from strand `chapters[]` order |
|
||||
| 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 |
|
||||
| DAG | No cycles — build throws if the graph cycles |
|
||||
| `tier` | Optional `foundation` (default) or `non-foundation` (Extension badge) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -52,10 +63,13 @@ Use this when the chapter is not written yet but should appear in the catalog.
|
|||
"title": "Human-readable title",
|
||||
"description": "One or two sentences for the catalog list and map card.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
}
|
||||
```
|
||||
|
||||
(`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).
|
||||
|
|
@ -70,7 +84,7 @@ A live chapter needs **catalog data** and a **page**.
|
|||
|
||||
### 1. Catalog entry
|
||||
|
||||
In `data/{subject}-curriculum.json`, set `"status": "live"` and include a `description` for the chapter (see [planned chapter](#add-a-planned-chapter-roadmap)).
|
||||
In `data/{subject}-curriculum.json`, set `"status": "live"` and include a `description` (see [planned chapter](#add-a-planned-chapter-roadmap)).
|
||||
|
||||
### 2. Zola section
|
||||
|
||||
|
|
@ -128,19 +142,18 @@ Then run `bun run build:js`.
|
|||
|
||||
## Chapter graph (branches)
|
||||
|
||||
The **Map** tab (`?view=tree`) shows chapters as a directed graph on a canvas (strand = column, arrows = prerequisites / sequence).
|
||||
The **Map** tab (`?view=tree`) shows chapters as a directed graph (strand = column, arrows = prerequisites).
|
||||
|
||||
- Chapters appear as cards with title, description, and curriculum badges.
|
||||
- **Live** chapters: click the **title** to open the chapter (not the whole card).
|
||||
- **Planned** chapters: shown with a “Coming soon” badge; no link.
|
||||
- By default, chapters in a strand link in **list order** (chapter 1 → chapter 2 → …).
|
||||
- For **branches** (one chapter leads to several next chapters), add `graph.edges`:
|
||||
- Prerequisites are **only** declared in `graph.edges` (strand list order does not create arrows).
|
||||
- For **branches** or **merges**, add edges:
|
||||
|
||||
```json
|
||||
"graph": {
|
||||
"edges": [
|
||||
{ "from": "quadratic-equations", "to": "sequences" },
|
||||
{ "from": "quadratic-equations", "to": "functions-graphs" }
|
||||
{ "from": "quadratic-equations", "to": "sequences-series" },
|
||||
{ "from": "quadratic-equations", "to": "functions-graphs" },
|
||||
{ "from": "functions-graphs", "to": "linear-programming" },
|
||||
{ "from": "sequences-series", "to": "linear-programming" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -148,12 +161,10 @@ The **Map** tab (`?view=tree`) shows chapters as a directed graph on a canvas (s
|
|||
Rules:
|
||||
|
||||
- `from` and `to` are chapter **slugs**.
|
||||
- The graph must **not contain cycles** (no circular prerequisites).
|
||||
- Edges must reference slugs that exist in `strands`.
|
||||
- The graph must **not contain cycles**.
|
||||
- Edges may cross strands.
|
||||
|
||||
Open `/catalog/?subject=math&view=tree` to verify layout. Scroll the page normally; use Ctrl/Cmd + wheel on the map to zoom. Pan by clicking the map first if the “click to interact” banner appears.
|
||||
|
||||
If a map card looks **clipped** at the bottom (badges or description cut off), that is a layout bug — long titles and descriptions need enough node height. Report it or see [curriculum-data.md](.agents/skills/ofb/curriculum-data.md) for how heights are measured.
|
||||
Open `/catalog/?subject=math&view=tree` to verify. Use Ctrl/Cmd + wheel on the map to zoom.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -169,9 +180,9 @@ Example: adding **Science**.
|
|||
|
||||
2. **`data/science-curriculum.json`** — create with `title`, `strands`, and chapters (copy structure from `math-curriculum.json`).
|
||||
|
||||
3. **`themes/openfreebooks/templates/catalog.html`** — today you must wire the new file into `#catalog-data` (see `math` block). *This step should get easier; see README contributor tasks.*
|
||||
3. **`themes/openfreebooks/templates/catalog.html`** — wire the new file into `#catalog-data` (see `math` block).
|
||||
|
||||
4. **`content/science/`** — add `_index.md` (redirect template like math) when you have a section.
|
||||
4. **`content/science/`** — add `_index.md` when you have a section.
|
||||
|
||||
5. **Homepage** — optional card in `themes/openfreebooks/templates/index.html`.
|
||||
|
||||
|
|
@ -190,13 +201,26 @@ Example: adding **GCSE**.
|
|||
|
||||
---
|
||||
|
||||
## Development & tests
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run build:js # after frontend/ changes
|
||||
zola serve
|
||||
bun test # catalog map layout (catalog-to-canvas)
|
||||
bun run build
|
||||
```
|
||||
|
||||
- Commit `themes/openfreebooks/static/js/` after `build:js`.
|
||||
- Do **not** commit `public/`.
|
||||
|
||||
---
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Keep changes focused (one subject or a few chapters per PR is fine).
|
||||
- Run `bun run build` before opening the PR.
|
||||
- Do **not** commit `public/` or copied HTML under `themes/openfreebooks/static/`.
|
||||
- **Do** commit Vite output under `themes/openfreebooks/static/js/` (`bundle.js` and hashed chunks such as `catalog-app-*.js`, `catalog-canvas-view-*.js`) after `bun run build:js`.
|
||||
- Describe what you added in the PR: subject, chapters, live vs planned, and which curricula apply.
|
||||
- Describe what you added: subject, chapters, live vs planned, and which curricula apply.
|
||||
|
||||
Questions? Open a [GitHub discussion](https://github.com/hananoshikayomaru/openfreebooks) or issue.
|
||||
|
||||
|
|
|
|||
4
content/contributing.md
Normal file
4
content/contributing.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
+++
|
||||
title = "Contribute"
|
||||
template = "contributing.html"
|
||||
+++
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
export type CatalogChapterTier = "foundation" | "non-foundation";
|
||||
|
||||
export type CatalogChapter = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: "live" | "planned";
|
||||
curriculums: string[];
|
||||
/** DSE compulsory tier; omit = foundation. */
|
||||
tier?: CatalogChapterTier;
|
||||
};
|
||||
|
||||
export type CatalogStrand = {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,46 @@
|
|||
{
|
||||
"title": "Mathematics",
|
||||
"subtitle": "Secondary level (S4–S6)",
|
||||
"intro": "Structured around three core strands. Chapters marked as available can be read now; others are on the roadmap.",
|
||||
"subtitle": "HKDSE Compulsory Part (S4–S6)",
|
||||
"intro": "Topic units across three strands. The map shows required prerequisites; the list follows a readable teaching order.",
|
||||
"graph": {
|
||||
"edges": [
|
||||
{ "from": "quadratic-equations", "to": "sequences" },
|
||||
{ "from": "directed-numbers-rates", "to": "polynomials-expansion-factorisation" },
|
||||
{ "from": "polynomials-expansion-factorisation", "to": "algebraic-fractions-formulas" },
|
||||
{ "from": "polynomials-expansion-factorisation", "to": "linear-equations-two-unknowns" },
|
||||
{ "from": "linear-equations-two-unknowns", "to": "linear-inequalities-one-unknown" },
|
||||
{ "from": "polynomials-expansion-factorisation", "to": "laws-of-indices" },
|
||||
{ "from": "laws-of-indices", "to": "quadratic-equations" },
|
||||
{ "from": "polynomials-expansion-factorisation", "to": "factor-remainder-theorems" },
|
||||
{ "from": "quadratic-equations", "to": "factor-remainder-theorems" },
|
||||
{ "from": "quadratic-equations", "to": "complex-numbers" },
|
||||
{ "from": "quadratic-equations", "to": "functions-graphs" },
|
||||
{ "from": "sequences", "to": "linear-programming" },
|
||||
{ "from": "functions-graphs", "to": "linear-programming" }
|
||||
{ "from": "quadratic-equations", "to": "sequences-series" },
|
||||
{ "from": "functions-graphs", "to": "exponential-logarithmic" },
|
||||
{ "from": "functions-graphs", "to": "variations" },
|
||||
{ "from": "functions-graphs", "to": "sequences-series" },
|
||||
{ "from": "functions-graphs", "to": "linear-programming" },
|
||||
{ "from": "sequences-series", "to": "linear-programming" },
|
||||
{ "from": "polynomials-expansion-factorisation", "to": "coordinate-geometry-foundations" },
|
||||
{ "from": "quadratic-equations", "to": "coordinate-circles" },
|
||||
{ "from": "functions-graphs", "to": "advanced-trigonometry" },
|
||||
{ "from": "coordinate-geometry-foundations", "to": "deductive-geometry" },
|
||||
{ "from": "coordinate-geometry-foundations", "to": "coordinate-circles" },
|
||||
{ "from": "deductive-geometry", "to": "congruence-similarity" },
|
||||
{ "from": "congruence-similarity", "to": "pythagoras-theorem" },
|
||||
{ "from": "pythagoras-theorem", "to": "introduction-trigonometry" },
|
||||
{ "from": "introduction-trigonometry", "to": "mensuration-3d" },
|
||||
{ "from": "introduction-trigonometry", "to": "advanced-trigonometry" },
|
||||
{ "from": "coordinate-circles", "to": "circle-properties" },
|
||||
{ "from": "deductive-geometry", "to": "circle-properties" },
|
||||
{ "from": "coordinate-circles", "to": "loci" },
|
||||
{ "from": "circle-properties", "to": "loci" },
|
||||
{ "from": "statistical-charts-averages", "to": "measures-dispersion" },
|
||||
{ "from": "statistical-charts-averages", "to": "basic-probability" },
|
||||
{ "from": "basic-probability", "to": "permutations-combinations-probability" },
|
||||
{ "from": "measures-dispersion", "to": "normal-distribution-z-scores" },
|
||||
{ "from": "measures-dispersion", "to": "statistical-literacy" },
|
||||
{ "from": "basic-probability", "to": "statistical-literacy" },
|
||||
{ "from": "permutations-combinations-probability", "to": "statistical-literacy" }
|
||||
]
|
||||
},
|
||||
"strands": [
|
||||
|
|
@ -15,6 +48,48 @@
|
|||
"id": "number-algebra",
|
||||
"title": "Number & Algebra",
|
||||
"chapters": [
|
||||
{
|
||||
"slug": "directed-numbers-rates",
|
||||
"title": "Directed numbers, rates & ratios",
|
||||
"description": "Operate with negative numbers and solve ratio, rate, and proportion problems in context.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "polynomials-expansion-factorisation",
|
||||
"title": "Polynomials: expansion & factorisation",
|
||||
"description": "Expand products, collect like terms, and factorise expressions using common factors and identities.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "algebraic-fractions-formulas",
|
||||
"title": "Algebraic fractions & formulas",
|
||||
"description": "Simplify algebraic fractions and change the subject of formulas in applied contexts.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "linear-equations-two-unknowns",
|
||||
"title": "Linear equations in two unknowns",
|
||||
"description": "Solve simultaneous linear equations by substitution, elimination, and graphical interpretation.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "linear-inequalities-one-unknown",
|
||||
"title": "Linear inequalities in one unknown",
|
||||
"description": "Solve and represent linear inequalities on the number line and in word problems.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "laws-of-indices",
|
||||
"title": "Laws of indices",
|
||||
"description": "Apply index laws to simplify expressions with integer and rational exponents.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "quadratic-equations",
|
||||
"title": "Quadratic equations",
|
||||
|
|
@ -23,11 +98,20 @@
|
|||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "sequences",
|
||||
"title": "Sequences & series",
|
||||
"description": "Work with arithmetic and geometric sequences, sigma notation, and simple convergence ideas.",
|
||||
"slug": "factor-remainder-theorems",
|
||||
"title": "Factor & remainder theorems",
|
||||
"description": "Use the factor and remainder theorems to factorise higher-degree polynomials and solve related equations.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
},
|
||||
{
|
||||
"slug": "complex-numbers",
|
||||
"title": "Complex numbers",
|
||||
"description": "Represent complex numbers in Cartesian form and perform operations including conjugates and division.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
},
|
||||
{
|
||||
"slug": "functions-graphs",
|
||||
|
|
@ -36,12 +120,35 @@
|
|||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "exponential-logarithmic",
|
||||
"title": "Exponential & logarithmic functions",
|
||||
"description": "Explore laws of logarithms, exponential growth and decay, and sketch related graphs.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
},
|
||||
{
|
||||
"slug": "sequences-series",
|
||||
"title": "Sequences & series",
|
||||
"description": "Work with arithmetic and geometric sequences, sigma notation, and finite summation formulas.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "variations",
|
||||
"title": "Variations",
|
||||
"description": "Model direct, inverse, joint, and partial variation and solve combined variation problems.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "linear-programming",
|
||||
"title": "Linear programming",
|
||||
"description": "Graph linear inequalities, identify feasible regions, and optimise objective functions in two variables.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -50,32 +157,77 @@
|
|||
"title": "Measures, Shape & Space",
|
||||
"chapters": [
|
||||
{
|
||||
"slug": "trigonometry",
|
||||
"title": "Advanced trigonometry",
|
||||
"description": "Use radians, identities, and laws of sines and cosines to solve triangles and model periodic phenomena.",
|
||||
"slug": "coordinate-geometry-foundations",
|
||||
"title": "Coordinate geometry foundations",
|
||||
"description": "Plot points, find distances and midpoints, and work with slopes of lines in the Cartesian plane.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "coordinate-geometry",
|
||||
"slug": "deductive-geometry",
|
||||
"title": "Deductive geometry",
|
||||
"description": "Prove angle and side relationships for intersecting lines, triangles, and polygons.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "congruence-similarity",
|
||||
"title": "Congruence & similarity",
|
||||
"description": "Apply congruence and similarity criteria to justify properties of triangles and scale figures.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "pythagoras-theorem",
|
||||
"title": "Pythagoras' theorem",
|
||||
"description": "Use Pythagoras' theorem and its converse in 2D problems and coordinate settings.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "introduction-trigonometry",
|
||||
"title": "Introduction to trigonometry",
|
||||
"description": "Define sine, cosine, and tangent ratios and solve right-angled triangle problems.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "mensuration-3d",
|
||||
"title": "Mensuration in 3D",
|
||||
"description": "Find volumes and surface areas of prisms, cylinders, pyramids, cones, and spheres.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "coordinate-circles",
|
||||
"title": "Coordinate geometry of circles",
|
||||
"description": "Find equations of circles, tangents, and intersections using coordinate methods and distance.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "advanced-trigonometry",
|
||||
"title": "Advanced trigonometry",
|
||||
"description": "Use sine and cosine rules, radians, and trigonometric graphs in 2D and 3D applications.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
},
|
||||
{
|
||||
"slug": "circle-properties",
|
||||
"title": "Properties of circles",
|
||||
"description": "Apply chord, tangent, and angle theorems in circles to prove results and solve geometry problems.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
},
|
||||
{
|
||||
"slug": "loci",
|
||||
"title": "Loci",
|
||||
"description": "Describe and construct loci in the plane, linking geometric constraints to algebraic equations.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -84,25 +236,41 @@
|
|||
"title": "Data Handling",
|
||||
"chapters": [
|
||||
{
|
||||
"slug": "dispersion",
|
||||
"slug": "statistical-charts-averages",
|
||||
"title": "Charts & averages",
|
||||
"description": "Organise data with tables and charts and calculate mean, median, and mode.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "measures-dispersion",
|
||||
"title": "Measures of dispersion",
|
||||
"description": "Interpret range, interquartile range, variance, and standard deviation for comparing data sets.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "probability",
|
||||
"title": "Permutations, combinations & probability",
|
||||
"description": "Count outcomes with permutations and combinations, then compute probabilities in finite sample spaces.",
|
||||
"slug": "basic-probability",
|
||||
"title": "Basic probability",
|
||||
"description": "Calculate theoretical and experimental probabilities for simple events and complements.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "normal-distribution",
|
||||
"slug": "permutations-combinations-probability",
|
||||
"title": "Permutations, combinations & probability",
|
||||
"description": "Count outcomes with permutations and combinations, then compute probabilities in finite sample spaces.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
},
|
||||
{
|
||||
"slug": "normal-distribution-z-scores",
|
||||
"title": "Normal distribution & z-scores",
|
||||
"description": "Model continuous data with the normal curve, standardise with z-scores, and read probabilities from tables.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
},
|
||||
{
|
||||
"slug": "statistical-literacy",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { For, Show } from "solid-js";
|
||||
import type { CatalogChapter } from "../../../data/catalog.types";
|
||||
import { curriculumBadgeClass } from "../lib/catalog-badge";
|
||||
import { curriculumBadgeClass, tierBadgeLabel } from "../lib/catalog-badge";
|
||||
|
||||
export type CatalogChapterCardProps = {
|
||||
chapter: CatalogChapter;
|
||||
|
|
@ -24,6 +24,14 @@ function appendMeta(parent: HTMLElement, chapter: CatalogChapter) {
|
|||
meta.appendChild(badge);
|
||||
}
|
||||
|
||||
const tierLabel = tierBadgeLabel(chapter.tier);
|
||||
if (tierLabel) {
|
||||
const tier = document.createElement("span");
|
||||
tier.className = "catalog-badge catalog-badge--extension";
|
||||
tier.textContent = tierLabel;
|
||||
meta.appendChild(tier);
|
||||
}
|
||||
|
||||
if (chapter.status === "planned") {
|
||||
const soon = document.createElement("span");
|
||||
soon.className = "book-chapter-badge book-chapter-badge--planned";
|
||||
|
|
@ -154,6 +162,9 @@ export function CatalogChapterCard(props: CatalogChapterCardProps) {
|
|||
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={tierBadgeLabel(props.chapter.tier)}>
|
||||
{(label) => <span class="catalog-badge catalog-badge--extension">{label()}</span>}
|
||||
</Show>
|
||||
<Show when={props.chapter.status === "planned"}>
|
||||
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
|
||||
</Show>
|
||||
|
|
|
|||
166
frontend/src/components/contributing-demos.tsx
Normal file
166
frontend/src/components/contributing-demos.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { createSignal, Show } from "solid-js";
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
const GRAPH_EDGES: Array<{ from: string; to: string }> = [
|
||||
{ from: "quadratic", to: "sequences" },
|
||||
{ from: "quadratic", to: "functions" },
|
||||
{ from: "sequences", to: "linear-programming" },
|
||||
{ from: "functions", to: "linear-programming" },
|
||||
];
|
||||
|
||||
function GraphPrereqDemo() {
|
||||
const [focus, setFocus] = createSignal<string | null>(null);
|
||||
|
||||
const nodeLit = (id: string) => {
|
||||
const active = focus();
|
||||
if (!active) return false;
|
||||
if (id === active) return true;
|
||||
return GRAPH_EDGES.some(
|
||||
(edge) =>
|
||||
(edge.from === active && edge.to === id) || (edge.from === id && edge.to === active)
|
||||
);
|
||||
};
|
||||
|
||||
const toggle = (id: string) => setFocus((prev) => (prev === id ? null : id));
|
||||
|
||||
return (
|
||||
<div class="contributing-graph-demo" aria-label="Interactive prerequisite graph example">
|
||||
<p class="contributing-demo__hint">
|
||||
Click a topic to highlight its prerequisites and follow-on chapters.
|
||||
</p>
|
||||
<div class="contributing-graph-demo__layout">
|
||||
<button
|
||||
type="button"
|
||||
class="contributing-graph-demo__node contributing-graph-demo__node--root"
|
||||
classList={{ "is-lit": nodeLit("quadratic") }}
|
||||
onClick={() => toggle("quadratic")}
|
||||
>
|
||||
Quadratic equations
|
||||
</button>
|
||||
<span class="contributing-graph-demo__fork" aria-hidden="true">
|
||||
↙
|
||||
<span class="contributing-graph-demo__fork-line" />
|
||||
↘
|
||||
</span>
|
||||
<div class="contributing-graph-demo__row">
|
||||
<button
|
||||
type="button"
|
||||
class="contributing-graph-demo__node"
|
||||
classList={{ "is-lit": nodeLit("sequences") }}
|
||||
onClick={() => toggle("sequences")}
|
||||
>
|
||||
Sequences
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="contributing-graph-demo__node"
|
||||
classList={{ "is-lit": nodeLit("functions") }}
|
||||
onClick={() => toggle("functions")}
|
||||
>
|
||||
Functions
|
||||
</button>
|
||||
</div>
|
||||
<span class="contributing-graph-demo__join" aria-hidden="true">
|
||||
↘
|
||||
<span class="contributing-graph-demo__join-line" />
|
||||
↙
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="contributing-graph-demo__node contributing-graph-demo__node--merge"
|
||||
classList={{ "is-lit": nodeLit("linear-programming") }}
|
||||
onClick={() => toggle("linear-programming")}
|
||||
>
|
||||
Linear programming
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogViewsDemo() {
|
||||
const [view, setView] = createSignal<"list" | "map">("list");
|
||||
|
||||
return (
|
||||
<div class="contributing-views-demo" aria-label="Catalog list and map views">
|
||||
<div class="contributing-views-demo__tabs" role="tablist" aria-label="Preview catalog views">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="contributing-views-demo__tab"
|
||||
classList={{ "is-active": view() === "list" }}
|
||||
aria-selected={view() === "list"}
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="contributing-views-demo__tab"
|
||||
classList={{ "is-active": view() === "map" }}
|
||||
aria-selected={view() === "map"}
|
||||
onClick={() => setView("map")}
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
</div>
|
||||
<div class="contributing-views-demo__panel" role="tabpanel">
|
||||
<Show
|
||||
when={view() === "list"}
|
||||
fallback={
|
||||
<div class="contributing-views-demo__map">
|
||||
<div class="contributing-views-demo__map-col">
|
||||
<span class="contributing-views-demo__strand">Number & Algebra</span>
|
||||
<span class="contributing-views-demo__map-node is-root">Directed numbers</span>
|
||||
<span class="contributing-views-demo__map-arrow" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
<span class="contributing-views-demo__map-node">Quadratic equations</span>
|
||||
<span class="contributing-views-demo__map-fork" aria-hidden="true">
|
||||
↙ ↘
|
||||
</span>
|
||||
<div class="contributing-views-demo__map-row">
|
||||
<span class="contributing-views-demo__map-node is-small">Sequences</span>
|
||||
<span class="contributing-views-demo__map-node is-small">Functions</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="contributing-views-demo__caption">
|
||||
Arrows come from <code>graph.edges</code> only — strand order in JSON does not draw
|
||||
links.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ol class="contributing-views-demo__list">
|
||||
<li>
|
||||
<span class="contributing-views-demo__list-num">01</span>
|
||||
<span class="contributing-views-demo__list-title">Quadratic equations</span>
|
||||
<span class="contributing-views-demo__list-badge">DSE</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="contributing-views-demo__list-num">02</span>
|
||||
<span class="contributing-views-demo__list-title">Sequences & series</span>
|
||||
<span class="contributing-views-demo__list-badge is-muted">Coming soon</span>
|
||||
</li>
|
||||
</ol>
|
||||
<p class="contributing-views-demo__caption">
|
||||
List order follows the <code>chapters[]</code> array — your table of contents.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function mountContributingDemos() {
|
||||
const graphRoot = document.getElementById("contributing-demo-graph");
|
||||
if (graphRoot) {
|
||||
render(() => <GraphPrereqDemo />, graphRoot);
|
||||
}
|
||||
|
||||
const viewsRoot = document.getElementById("contributing-demo-views");
|
||||
if (viewsRoot) {
|
||||
render(() => <CatalogViewsDemo />, viewsRoot);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import type { CatalogChapterTier } from "../../../data/catalog.types";
|
||||
|
||||
export function curriculumBadgeClass(curriculum: string): string {
|
||||
const map: Record<string, string> = {
|
||||
DSE: "catalog-badge--dse",
|
||||
|
|
@ -8,3 +10,9 @@ export function curriculumBadgeClass(curriculum: string): string {
|
|||
};
|
||||
return map[curriculum] ?? "catalog-badge--default";
|
||||
}
|
||||
|
||||
/** Shown on cards when tier is non-foundation; foundation chapters have no extra badge. */
|
||||
export function tierBadgeLabel(tier?: CatalogChapterTier): string | null {
|
||||
if (tier === "non-foundation") return "Extension";
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const CANVAS_NODE_DARK = {
|
|||
} as const;
|
||||
|
||||
export function siteCanvasTheme(): "light" | "dark" {
|
||||
if (typeof document === "undefined") return "light";
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
if (attr === "dark" || attr === "light") return attr;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
|
|
|
|||
193
frontend/src/lib/catalog-to-canvas.test.ts
Normal file
193
frontend/src/lib/catalog-to-canvas.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { JSONCanvas } from "json-canvas-viewer";
|
||||
import type {
|
||||
CatalogChapter,
|
||||
CatalogGraphEdge,
|
||||
CatalogSubject,
|
||||
} from "../../../data/catalog.types";
|
||||
import {
|
||||
CHAPTER_NODE_WIDTH,
|
||||
chapterNodeId,
|
||||
chapterSlugFromNodeId,
|
||||
relayoutCanvasChapterHeights,
|
||||
subjectToCanvas,
|
||||
} from "./catalog-to-canvas";
|
||||
|
||||
const GAP_Y = 48;
|
||||
|
||||
function chapter(
|
||||
slug: string,
|
||||
overrides: Partial<CatalogChapter> = {}
|
||||
): CatalogChapter {
|
||||
return {
|
||||
slug,
|
||||
title: slug,
|
||||
description: "Test chapter description for layout.",
|
||||
status: "planned",
|
||||
curriculums: ["DSE"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function miniSubject(chapters: CatalogChapter[], edges: CatalogGraphEdge[] = []): CatalogSubject {
|
||||
return {
|
||||
id: "math",
|
||||
name: "Mathematics",
|
||||
strands: [
|
||||
{
|
||||
id: "number-algebra",
|
||||
title: "Number & Algebra",
|
||||
chapters,
|
||||
},
|
||||
],
|
||||
graph: { edges },
|
||||
};
|
||||
}
|
||||
|
||||
function textNode(canvas: JSONCanvas, slug: string) {
|
||||
return (canvas.nodes ?? []).find((node) => node.id === chapterNodeId(slug) && node.type === "text");
|
||||
}
|
||||
|
||||
function bottomY(node: { y: number; height?: number }) {
|
||||
return node.y + (node.height ?? 0);
|
||||
}
|
||||
|
||||
describe("chapterNodeId", () => {
|
||||
test("maps slug to node id and back", () => {
|
||||
expect(chapterNodeId("quadratic-equations")).toBe("chapter-quadratic-equations");
|
||||
expect(chapterSlugFromNodeId("chapter-quadratic-equations")).toBe("quadratic-equations");
|
||||
expect(chapterSlugFromNodeId("group-strand")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subjectToCanvas", () => {
|
||||
test("returns null when no chapters match filter", () => {
|
||||
const subject = miniSubject([chapter("a")]);
|
||||
expect(subjectToCanvas(subject, () => false)).toBeNull();
|
||||
});
|
||||
|
||||
test("uses only explicit graph edges (no implicit strand chain)", () => {
|
||||
const subject = miniSubject(
|
||||
[chapter("first"), chapter("second")],
|
||||
[]
|
||||
);
|
||||
const canvas = subjectToCanvas(subject, () => true);
|
||||
expect(canvas?.edges ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("assigns longest-path levels on y axis", () => {
|
||||
const subject = miniSubject(
|
||||
[chapter("a"), chapter("b"), chapter("c")],
|
||||
[
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
]
|
||||
);
|
||||
const canvas = subjectToCanvas(subject, () => true)!;
|
||||
const a = textNode(canvas, "a")!;
|
||||
const b = textNode(canvas, "b")!;
|
||||
const c = textNode(canvas, "c")!;
|
||||
|
||||
expect(b.y).toBeGreaterThan(bottomY(a));
|
||||
expect(c.y).toBeGreaterThan(bottomY(b));
|
||||
});
|
||||
|
||||
test("stacks siblings that share a level in one column", () => {
|
||||
const subject = miniSubject(
|
||||
[chapter("alpha"), chapter("beta"), chapter("merge")],
|
||||
[
|
||||
{ from: "alpha", to: "merge" },
|
||||
{ from: "beta", to: "merge" },
|
||||
]
|
||||
);
|
||||
const canvas = subjectToCanvas(subject, () => true)!;
|
||||
const alpha = textNode(canvas, "alpha")!;
|
||||
const beta = textNode(canvas, "beta")!;
|
||||
const merge = textNode(canvas, "merge")!;
|
||||
|
||||
expect(alpha.x).toBe(beta.x);
|
||||
expect(beta.y).toBeGreaterThanOrEqual(bottomY(alpha) + GAP_Y);
|
||||
expect(merge.y).toBeGreaterThan(bottomY(alpha));
|
||||
expect(merge.y).toBeGreaterThan(bottomY(beta));
|
||||
});
|
||||
|
||||
test("places strand columns at increasing x", () => {
|
||||
const subject: CatalogSubject = {
|
||||
id: "math",
|
||||
name: "Mathematics",
|
||||
strands: [
|
||||
{
|
||||
id: "number-algebra",
|
||||
title: "Number & Algebra",
|
||||
chapters: [chapter("na")],
|
||||
},
|
||||
{
|
||||
id: "data-handling",
|
||||
title: "Data Handling",
|
||||
chapters: [chapter("dh")],
|
||||
},
|
||||
],
|
||||
graph: { edges: [] },
|
||||
};
|
||||
const canvas = subjectToCanvas(subject, () => true)!;
|
||||
const na = textNode(canvas, "na")!;
|
||||
const dh = textNode(canvas, "dh")!;
|
||||
|
||||
expect(dh.x).toBeGreaterThan(na.x);
|
||||
expect(dh.x - na.x).toBeGreaterThanOrEqual(CHAPTER_NODE_WIDTH);
|
||||
});
|
||||
|
||||
test("throws when the graph has a cycle", () => {
|
||||
const subject = miniSubject(
|
||||
[chapter("a"), chapter("b"), chapter("c")],
|
||||
[
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "c" },
|
||||
{ from: "c", to: "a" },
|
||||
]
|
||||
);
|
||||
expect(() => subjectToCanvas(subject, () => true)).toThrow(/cycle/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relayoutCanvasChapterHeights", () => {
|
||||
test("handles canvas with undefined nodes and edges", () => {
|
||||
expect(relayoutCanvasChapterHeights({}, new Map())).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when layout unchanged", () => {
|
||||
const subject = miniSubject([chapter("solo")], []);
|
||||
const canvas = subjectToCanvas(subject, () => true)!;
|
||||
const node = textNode(canvas, "solo")!;
|
||||
const heights = new Map([["solo", node.height ?? 0]]);
|
||||
expect(relayoutCanvasChapterHeights(canvas, heights)).toBeNull();
|
||||
});
|
||||
|
||||
test("bumps y when a taller height is supplied for a sibling stack", () => {
|
||||
const subject = miniSubject(
|
||||
[chapter("top"), chapter("bottom")],
|
||||
[]
|
||||
);
|
||||
const canvas = subjectToCanvas(subject, () => true)!;
|
||||
const top = textNode(canvas, "top")!;
|
||||
const bottom = textNode(canvas, "bottom")!;
|
||||
|
||||
const firstSlug = top.y <= bottom.y ? "top" : "bottom";
|
||||
const secondSlug = firstSlug === "top" ? "bottom" : "top";
|
||||
const first = textNode(canvas, firstSlug)!;
|
||||
const second = textNode(canvas, secondSlug)!;
|
||||
|
||||
const taller = (first.height ?? 0) + 80;
|
||||
const fitted = relayoutCanvasChapterHeights(
|
||||
canvas,
|
||||
new Map([
|
||||
[firstSlug, taller],
|
||||
[secondSlug, second.height ?? 0],
|
||||
])
|
||||
);
|
||||
|
||||
expect(fitted).not.toBeNull();
|
||||
const moved = textNode(fitted!, secondSlug)!;
|
||||
expect(moved.y).toBeGreaterThanOrEqual(first.y + taller + GAP_Y);
|
||||
});
|
||||
});
|
||||
|
|
@ -59,13 +59,6 @@ function buildEdgeList(
|
|||
add(edge.from, edge.to, edge.label);
|
||||
}
|
||||
|
||||
for (const strand of subject.strands) {
|
||||
const chapters = strand.chapters.filter((ch) => visible.has(ch.slug));
|
||||
for (let i = 0; i < chapters.length - 1; i++) {
|
||||
add(chapters[i].slug, chapters[i + 1].slug);
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
|
|
@ -100,64 +93,99 @@ function assertAcyclic(edges: CatalogGraphEdge[], slugs: Iterable<string>): void
|
|||
}
|
||||
}
|
||||
|
||||
/** Longest-path rank: level(to) >= level(from) + 1 for every required edge; roots at 0. */
|
||||
function assignLevels(edges: CatalogGraphEdge[], slugs: string[]): Map<string, number> {
|
||||
const inDegree = new Map<string, number>();
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
for (const slug of slugs) {
|
||||
inDegree.set(slug, 0);
|
||||
adj.set(slug, []);
|
||||
}
|
||||
|
||||
for (const { from, to } of edges) {
|
||||
adj.get(from)?.push(to);
|
||||
inDegree.set(to, (inDegree.get(to) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const levels = new Map<string, number>();
|
||||
const queue: Array<{ slug: string; level: number }> = [];
|
||||
|
||||
for (const slug of slugs) {
|
||||
if ((inDegree.get(slug) ?? 0) === 0) {
|
||||
queue.push({ slug, level: 0 });
|
||||
}
|
||||
levels.set(slug, 0);
|
||||
}
|
||||
|
||||
if (queue.length === 0 && slugs.length > 0) {
|
||||
queue.push({ slug: slugs[0], level: 0 });
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { slug, level } = queue.shift()!;
|
||||
levels.set(slug, Math.max(level, levels.get(slug) ?? 0));
|
||||
|
||||
for (const next of adj.get(slug) ?? []) {
|
||||
const nextLevel = level + 1;
|
||||
levels.set(next, Math.max(nextLevel, levels.get(next) ?? 0));
|
||||
inDegree.set(next, (inDegree.get(next) ?? 1) - 1);
|
||||
if ((inDegree.get(next) ?? 0) === 0) {
|
||||
queue.push({ slug: next, level: nextLevel });
|
||||
for (let pass = 0; pass < slugs.length; pass++) {
|
||||
let changed = false;
|
||||
for (const { from, to } of edges) {
|
||||
const next = (levels.get(from) ?? 0) + 1;
|
||||
if (next > (levels.get(to) ?? 0)) {
|
||||
levels.set(to, next);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const slug of slugs) {
|
||||
if (!levels.has(slug)) {
|
||||
levels.set(slug, 0);
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
function yOffsetInColumn(level: number, levelHeights: Map<number, number>): number {
|
||||
function yOffsetInColumn(level: number, levelRowHeights: Map<number, number>): number {
|
||||
let y = 0;
|
||||
for (let l = 0; l < level; l++) {
|
||||
y += (levelHeights.get(l) ?? 0) + GAP_Y;
|
||||
y += (levelRowHeights.get(l) ?? 0) + GAP_Y;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
type ChapterLayoutSlot = {
|
||||
slug: string;
|
||||
columnX: number;
|
||||
level: number;
|
||||
height: number;
|
||||
sortIndex: number;
|
||||
};
|
||||
|
||||
/** Stack chapters that share a level within one strand column (avoids overlapping cards). */
|
||||
function layoutChapterPositions(
|
||||
slots: ChapterLayoutSlot[]
|
||||
): Map<string, { x: number; y: number; height: number }> {
|
||||
const byColumn = new Map<number, ChapterLayoutSlot[]>();
|
||||
for (const slot of slots) {
|
||||
const column = byColumn.get(slot.columnX) ?? [];
|
||||
column.push(slot);
|
||||
byColumn.set(slot.columnX, column);
|
||||
}
|
||||
|
||||
const positions = new Map<string, { x: number; y: number; height: number }>();
|
||||
|
||||
for (const [columnX, columnSlots] of byColumn) {
|
||||
const byLevel = new Map<number, ChapterLayoutSlot[]>();
|
||||
for (const slot of columnSlots) {
|
||||
const levelSlots = byLevel.get(slot.level) ?? [];
|
||||
levelSlots.push(slot);
|
||||
byLevel.set(slot.level, levelSlots);
|
||||
}
|
||||
|
||||
const levelRowHeights = new Map<number, number>();
|
||||
for (const [level, levelSlots] of byLevel) {
|
||||
levelSlots.sort((a, b) => a.sortIndex - b.sortIndex);
|
||||
let rowHeight = 0;
|
||||
for (let i = 0; i < levelSlots.length; i++) {
|
||||
rowHeight += levelSlots[i].height;
|
||||
if (i < levelSlots.length - 1) rowHeight += GAP_Y;
|
||||
}
|
||||
levelRowHeights.set(level, rowHeight);
|
||||
}
|
||||
|
||||
for (const [level, levelSlots] of byLevel) {
|
||||
levelSlots.sort((a, b) => a.sortIndex - b.sortIndex);
|
||||
let y = yOffsetInColumn(level, levelRowHeights);
|
||||
for (const slot of levelSlots) {
|
||||
positions.set(slot.slug, { x: columnX, y, height: slot.height });
|
||||
y += slot.height + GAP_Y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function buildStrandChapterOrder(subject: CatalogSubject): Map<string, number> {
|
||||
const order = new Map<string, number>();
|
||||
for (const strand of subject.strands) {
|
||||
strand.chapters.forEach((chapter, index) => {
|
||||
order.set(chapter.slug, index);
|
||||
});
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function layoutNodes(
|
||||
subject: CatalogSubject,
|
||||
visible: Map<string, CatalogChapter>,
|
||||
|
|
@ -181,34 +209,23 @@ function layoutNodes(
|
|||
subject.id
|
||||
);
|
||||
|
||||
const levelMaxHeightByStrand = new Map<string, Map<number, number>>();
|
||||
for (const [slug] of visible) {
|
||||
const strandId = slugToStrandId.get(slug);
|
||||
if (!strandId) continue;
|
||||
const level = levels.get(slug) ?? 0;
|
||||
const height = heightBySlug.get(slug) ?? 0;
|
||||
if (!levelMaxHeightByStrand.has(strandId)) {
|
||||
levelMaxHeightByStrand.set(strandId, new Map());
|
||||
}
|
||||
const levelHeights = levelMaxHeightByStrand.get(strandId)!;
|
||||
levelHeights.set(level, Math.max(levelHeights.get(level) ?? 0, height));
|
||||
}
|
||||
|
||||
const positions = new Map<string, { x: number; y: number; height: number }>();
|
||||
const strandChapterOrder = buildStrandChapterOrder(subject);
|
||||
const slots: ChapterLayoutSlot[] = [];
|
||||
|
||||
for (const [slug] of visible) {
|
||||
const strandId = slugToStrandId.get(slug);
|
||||
const columnX = strandId ? strandColumnX.get(strandId) : 0;
|
||||
const level = levels.get(slug) ?? 0;
|
||||
const levelHeights = strandId ? levelMaxHeightByStrand.get(strandId) : undefined;
|
||||
const height = heightBySlug.get(slug) ?? 0;
|
||||
positions.set(slug, {
|
||||
x: columnX ?? 0,
|
||||
y: yOffsetInColumn(level, levelHeights ?? new Map()),
|
||||
height,
|
||||
const columnX = strandId ? (strandColumnX.get(strandId) ?? 0) : 0;
|
||||
slots.push({
|
||||
slug,
|
||||
columnX,
|
||||
level: levels.get(slug) ?? 0,
|
||||
height: heightBySlug.get(slug) ?? 0,
|
||||
sortIndex: strandChapterOrder.get(slug) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const positions = layoutChapterPositions(slots);
|
||||
|
||||
const groups: JSONCanvasNode[] = [];
|
||||
const nodes: JSONCanvasNode[] = [];
|
||||
const colors = canvasNodeColors();
|
||||
|
|
@ -281,7 +298,7 @@ function toCanvasEdges(edgeList: CatalogGraphEdge[]): JSONCanvasEdge[] {
|
|||
|
||||
function edgesFromCanvas(canvas: JSONCanvas): CatalogGraphEdge[] {
|
||||
const edges: CatalogGraphEdge[] = [];
|
||||
for (const edge of canvas.edges) {
|
||||
for (const edge of canvas.edges ?? []) {
|
||||
const from = chapterSlugFromNodeId(edge.fromNode);
|
||||
const to = chapterSlugFromNodeId(edge.toNode);
|
||||
if (!from || !to) continue;
|
||||
|
|
@ -295,7 +312,8 @@ export function relayoutCanvasChapterHeights(
|
|||
canvas: JSONCanvas,
|
||||
heightBySlug: Map<string, number>
|
||||
): JSONCanvas | null {
|
||||
const chapterNodes = canvas.nodes.filter(
|
||||
const canvasNodes = canvas.nodes ?? [];
|
||||
const chapterNodes = canvasNodes.filter(
|
||||
(node) => node.type === "text" && node.id.startsWith("chapter-")
|
||||
);
|
||||
if (chapterNodes.length === 0) return null;
|
||||
|
|
@ -312,36 +330,35 @@ export function relayoutCanvasChapterHeights(
|
|||
mergedHeights.set(slug, Math.max(node.height ?? 0, heightBySlug.get(slug) ?? 0));
|
||||
}
|
||||
|
||||
const levelMaxHeightByColumn = new Map<number, Map<number, number>>();
|
||||
for (const node of chapterNodes) {
|
||||
const slug = chapterSlugFromNodeId(node.id);
|
||||
if (!slug) continue;
|
||||
const level = levels.get(slug) ?? 0;
|
||||
const height = mergedHeights.get(slug) ?? 0;
|
||||
if (!levelMaxHeightByColumn.has(node.x)) {
|
||||
levelMaxHeightByColumn.set(node.x, new Map());
|
||||
}
|
||||
const levelHeights = levelMaxHeightByColumn.get(node.x)!;
|
||||
levelHeights.set(level, Math.max(levelHeights.get(level) ?? 0, height));
|
||||
}
|
||||
const slots: ChapterLayoutSlot[] = chapterNodes.map((node, index) => {
|
||||
const slug = chapterSlugFromNodeId(node.id) ?? "";
|
||||
return {
|
||||
slug,
|
||||
columnX: node.x,
|
||||
level: levels.get(slug) ?? 0,
|
||||
height: mergedHeights.get(slug) ?? node.height ?? 0,
|
||||
sortIndex: node.y * 1000 + index,
|
||||
};
|
||||
});
|
||||
|
||||
const positions = layoutChapterPositions(slots);
|
||||
|
||||
let changed = false;
|
||||
const updatedChapters = chapterNodes.map((node) => {
|
||||
const slug = chapterSlugFromNodeId(node.id);
|
||||
if (!slug) return node;
|
||||
const level = levels.get(slug) ?? 0;
|
||||
const levelHeights = levelMaxHeightByColumn.get(node.x) ?? new Map();
|
||||
const height = mergedHeights.get(slug) ?? node.height ?? 0;
|
||||
const y = yOffsetInColumn(level, levelHeights);
|
||||
if (y !== node.y || height !== node.height) changed = true;
|
||||
return { ...node, y, height };
|
||||
const pos = positions.get(slug);
|
||||
if (!pos) return node;
|
||||
const height = pos.height;
|
||||
if (pos.y !== node.y || height !== node.height) changed = true;
|
||||
return { ...node, y: pos.y, height };
|
||||
});
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
const chapterById = new Map(updatedChapters.map((node) => [node.id, node]));
|
||||
|
||||
let nodes = canvas.nodes.map((node) => {
|
||||
let nodes = canvasNodes.map((node) => {
|
||||
if (node.type === "text" && node.id.startsWith("chapter-")) {
|
||||
return chapterById.get(node.id) ?? node;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ function readConfig() {
|
|||
homeUrl: "/",
|
||||
browseUrl: "/catalog/",
|
||||
aboutUrl: "/about/",
|
||||
contributingUrl: "/contributing/",
|
||||
githubUrl: "https://github.com/openfreebooks/openfreebooks",
|
||||
};
|
||||
}
|
||||
|
|
@ -21,6 +22,7 @@ function readConfig() {
|
|||
homeUrl: string;
|
||||
browseUrl: string;
|
||||
aboutUrl: string;
|
||||
contributingUrl: string;
|
||||
githubUrl: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -92,4 +94,10 @@ if (catalogRoot) {
|
|||
});
|
||||
}
|
||||
|
||||
if (document.getElementById("contributing-demo-graph") || document.getElementById("contributing-demo-views")) {
|
||||
void import("./components/contributing-demos").then(({ mountContributingDemos }) => {
|
||||
mountContributingDemos();
|
||||
});
|
||||
}
|
||||
|
||||
mountFooterYear();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"build:js": "vite build",
|
||||
"build": "rm -f themes/openfreebooks/static/main.css public/main.css && bun run build:js && zola build",
|
||||
"dev:js": "vite build --watch",
|
||||
"scan:size": "bun scripts/scan-output-size.ts"
|
||||
"scan:size": "bun scripts/scan-output-size.ts",
|
||||
"test": "bun test frontend/src/lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"defuddle": "^0.18.1",
|
||||
|
|
|
|||
|
|
@ -607,6 +607,12 @@ a.catalog-chapter-card:focus-visible .catalog-chapter-card__number {
|
|||
border-color: rgba(244, 63, 94, 0.25);
|
||||
}
|
||||
|
||||
.catalog-badge--extension {
|
||||
color: #9a3412;
|
||||
background: rgba(234, 88, 12, 0.1);
|
||||
border-color: rgba(234, 88, 12, 0.28);
|
||||
}
|
||||
|
||||
.catalog-badge--default {
|
||||
color: var(--soft-ink);
|
||||
background: var(--paper);
|
||||
|
|
|
|||
378
themes/openfreebooks/sass/_contributing.scss
Normal file
378
themes/openfreebooks/sass/_contributing.scss
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
.contributing-page {
|
||||
padding: 54px 0 80px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.contributing-page__title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.contributing-page__lead {
|
||||
margin: 0 0 36px;
|
||||
max-width: var(--prose-width);
|
||||
color: var(--soft-ink);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.contributing-page__lead a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contributing-page__lead a:hover,
|
||||
.contributing-page__lead a:focus-visible {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.contributing-quick-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 14px;
|
||||
margin: 0 0 48px;
|
||||
}
|
||||
|
||||
.contributing-quick-nav__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: 0 10px 28px var(--shadow-soft);
|
||||
transition: border-color 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.contributing-quick-nav__card:hover,
|
||||
.contributing-quick-nav__card:focus-visible {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.contributing-quick-nav__label {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.contributing-quick-nav__text {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--soft-ink);
|
||||
}
|
||||
|
||||
.contributing-section {
|
||||
margin-bottom: 56px;
|
||||
scroll-margin-top: 96px;
|
||||
}
|
||||
|
||||
.contributing-section--cta {
|
||||
margin-bottom: 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.contributing-section__title {
|
||||
margin: 0 0 16px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(1.45rem, 2.4vw, 2rem);
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.contributing-section__subtitle {
|
||||
margin: 24px 0 8px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.contributing-prose {
|
||||
max-width: var(--prose-width);
|
||||
color: var(--soft-ink);
|
||||
font-size: 1rem;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.contributing-prose p,
|
||||
.contributing-prose ol,
|
||||
.contributing-prose ul {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.contributing-prose li + li {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.contributing-prose a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contributing-prose a:hover,
|
||||
.contributing-prose a:focus-visible {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.contributing-code-block {
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 16px 18px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.contributing-code-block code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.contributing-demo-slot {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.contributing-demo__hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.contributing-card-demo {
|
||||
margin-top: 28px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.contributing-card-demo .catalog-chapter-card--map {
|
||||
margin: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.contributing-page__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Interactive graph demo */
|
||||
.contributing-graph-demo__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 28px 20px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.contributing-graph-demo__node {
|
||||
min-width: 10rem;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
background: var(--card-bg);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
background-color 180ms ease;
|
||||
}
|
||||
|
||||
.contributing-graph-demo__node:hover,
|
||||
.contributing-graph-demo__node:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.contributing-graph-demo__node.is-lit {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(154, 107, 46, 0.2);
|
||||
background: rgba(154, 107, 46, 0.08);
|
||||
}
|
||||
|
||||
.contributing-graph-demo__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.contributing-graph-demo__fork,
|
||||
.contributing-graph-demo__join {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 48px;
|
||||
font-size: 1.1rem;
|
||||
color: var(--muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* List / map view demo */
|
||||
.contributing-views-demo {
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.contributing-views-demo__tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.contributing-views-demo__tab {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
|
||||
.contributing-views-demo__tab.is-active {
|
||||
color: var(--accent);
|
||||
background: rgba(154, 107, 46, 0.08);
|
||||
}
|
||||
|
||||
.contributing-views-demo__tab:hover,
|
||||
.contributing-views-demo__tab:focus-visible {
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.contributing-views-demo__panel {
|
||||
padding: 22px 24px 18px;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.contributing-views-demo__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.contributing-views-demo__list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.contributing-views-demo__list-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.contributing-views-demo__list-title {
|
||||
flex: 1;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.contributing-views-demo__list-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #1d4ed8;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.contributing-views-demo__list-badge.is-muted {
|
||||
color: var(--muted);
|
||||
background: rgba(118, 118, 118, 0.12);
|
||||
}
|
||||
|
||||
.contributing-views-demo__map-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contributing-views-demo__strand {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
background: rgba(154, 107, 46, 0.15);
|
||||
}
|
||||
|
||||
.contributing-views-demo__map-node {
|
||||
min-width: 9rem;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
background: var(--paper);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contributing-views-demo__map-node.is-small {
|
||||
min-width: 7rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.contributing-views-demo__map-arrow,
|
||||
.contributing-views-demo__map-fork {
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.contributing-views-demo__map-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.contributing-views-demo__caption {
|
||||
margin: 16px 0 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.contributing-views-demo__caption code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
@import "why-sections";
|
||||
@import "contributors";
|
||||
@import "credits";
|
||||
@import "contributing";
|
||||
@import "about-manifest";
|
||||
@import "book";
|
||||
@import "catalog";
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-Bi3XX9IX.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-Bi3XX9IX.js
Normal file
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-CKWl-z4W.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-CKWl-z4W.js
Normal file
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-DUOHXUDq.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-DUOHXUDq.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-BQ3Gt75K.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-BQ3Gt75K.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-Bwd11lkP.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-Bwd11lkP.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-ob17uQ4y.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-ob17uQ4y.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
import{r as f,d as p,i as y,h as q,g as S,j as C,S as x,s as $,t as g}from"./bundle.js";var L=g('<div class=contributing-graph-demo aria-label="Interactive prerequisite graph example"><p class=contributing-demo__hint>Click a topic to highlight its prerequisites and follow-on chapters.</p><div class=contributing-graph-demo__layout><button type=button class="contributing-graph-demo__node contributing-graph-demo__node--root">Quadratic equations</button><span class=contributing-graph-demo__fork aria-hidden=true>↙<span class=contributing-graph-demo__fork-line></span>↘</span><div class=contributing-graph-demo__row><button type=button class=contributing-graph-demo__node>Sequences</button><button type=button class=contributing-graph-demo__node>Functions</button></div><span class=contributing-graph-demo__join aria-hidden=true>↘<span class=contributing-graph-demo__join-line></span>↙</span><button type=button class="contributing-graph-demo__node contributing-graph-demo__node--merge">Linear programming'),E=g('<ol class=contributing-views-demo__list><li><span class=contributing-views-demo__list-num>01</span><span class=contributing-views-demo__list-title>Quadratic equations</span><span class=contributing-views-demo__list-badge>DSE</span></li><li><span class=contributing-views-demo__list-num>02</span><span class=contributing-views-demo__list-title>Sequences & series</span><span class="contributing-views-demo__list-badge is-muted">Coming soon'),D=g("<p class=contributing-views-demo__caption>List order follows the <code>chapters[]</code> array — your table of contents."),A=g('<div class=contributing-views-demo aria-label="Catalog list and map views"><div class=contributing-views-demo__tabs role=tablist aria-label="Preview catalog views"><button type=button role=tab class=contributing-views-demo__tab>List</button><button type=button role=tab class=contributing-views-demo__tab>Map</button></div><div class=contributing-views-demo__panel role=tabpanel>'),R=g('<div class=contributing-views-demo__map><div class=contributing-views-demo__map-col><span class=contributing-views-demo__strand>Number & Algebra</span><span class="contributing-views-demo__map-node is-root">Directed numbers</span><span class=contributing-views-demo__map-arrow aria-hidden=true>↓</span><span class=contributing-views-demo__map-node>Quadratic equations</span><span class=contributing-views-demo__map-fork aria-hidden=true>↙ ↘</span><div class=contributing-views-demo__map-row><span class="contributing-views-demo__map-node is-small">Sequences</span><span class="contributing-views-demo__map-node is-small">Functions</span></div></div><p class=contributing-views-demo__caption>Arrows come from <code>graph.edges</code> only — strand order in JSON does not draw links.');const j=[{from:"quadratic",to:"sequences"},{from:"quadratic",to:"functions"},{from:"sequences",to:"linear-programming"},{from:"functions",to:"linear-programming"}];function F(){const[n,a]=q(null),r=t=>{const e=n();return e?t===e?!0:j.some(o=>o.from===e&&o.to===t||o.from===t&&o.to===e):!1},l=t=>a(e=>e===t?null:t);return(()=>{var t=L(),e=t.firstChild,o=e.nextSibling,i=o.firstChild,m=i.nextSibling,u=m.nextSibling,c=u.firstChild,d=c.nextSibling,k=u.nextSibling,_=k.nextSibling;return i.$$click=()=>l("quadratic"),c.$$click=()=>l("sequences"),d.$$click=()=>l("functions"),_.$$click=()=>l("linear-programming"),S(s=>{var b=!!r("quadratic"),v=!!r("sequences"),w=!!r("functions"),h=!!r("linear-programming");return b!==s.e&&i.classList.toggle("is-lit",s.e=b),v!==s.t&&c.classList.toggle("is-lit",s.t=v),w!==s.a&&d.classList.toggle("is-lit",s.a=w),h!==s.o&&_.classList.toggle("is-lit",s.o=h),s},{e:void 0,t:void 0,a:void 0,o:void 0}),t})()}function G(){const[n,a]=q("list");return(()=>{var r=A(),l=r.firstChild,t=l.firstChild,e=t.nextSibling,o=l.nextSibling;return t.$$click=()=>a("list"),e.$$click=()=>a("map"),C(o,p(x,{get when(){return n()==="list"},get fallback(){return R()},get children(){return[E(),D()]}})),S(i=>{var m=n()==="list",u=n()==="list",c=n()==="map",d=n()==="map";return m!==i.e&&t.classList.toggle("is-active",i.e=m),u!==i.t&&$(t,"aria-selected",i.t=u),c!==i.a&&e.classList.toggle("is-active",i.a=c),d!==i.o&&$(e,"aria-selected",i.o=d),i},{e:void 0,t:void 0,a:void 0,o:void 0}),r})()}function P(){const n=document.getElementById("contributing-demo-graph");n&&f(()=>p(F,{}),n);const a=document.getElementById("contributing-demo-views");a&&f(()=>p(G,{}),a)}y(["click"]);export{P as mountContributingDemos};
|
||||
4265
themes/openfreebooks/static/js/particle-background-Rc9MuW13.js
Normal file
4265
themes/openfreebooks/static/js/particle-background-Rc9MuW13.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -46,6 +46,7 @@
|
|||
<span class="footer-copy">© <span id="footer-year"></span> {{ config.extra.site_title }} — Free, open textbooks for every learner</span>
|
||||
<nav class="footer-links" aria-label="Footer links">
|
||||
<a class="footer-link" href="{{ config.extra.about_url | safe }}">About</a>
|
||||
<a class="footer-link" href="{{ config.extra.contributing_url | safe }}">Contribute</a>
|
||||
<a class="footer-link" href="{{ config.extra.credits_url | safe }}">Credits</a>
|
||||
<a class="footer-link" href="{{ config.extra.github_url | safe }}">GitHub</a>
|
||||
</nav>
|
||||
|
|
|
|||
14
themes/openfreebooks/templates/contributing.html
Normal file
14
themes/openfreebooks/templates/contributing.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Contribute | {{ config.extra.site_title }}{% endblock title %}
|
||||
{% block meta_description %}
|
||||
<meta name="description" content="How to add chapters, curriculum data, and prerequisite graphs to {{ config.extra.site_title }}." />
|
||||
{% endblock meta_description %}
|
||||
|
||||
{% block content %}
|
||||
<section class="contributing-page">
|
||||
<div class="container">
|
||||
{% include "partials/contributing-content.html" %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
<p class="tree-cta__lead">Let's provide good, free education that anyone can read, share, and improve.</p>
|
||||
<div class="tree-cta__actions">
|
||||
<a class="button button-primary" href="{{ config.extra.browse_url | safe }}">Start reading</a>
|
||||
<a class="button button-secondary" href="{{ config.extra.github_url | safe }}">Help grow the library</a>
|
||||
<a class="button button-secondary" href="{{ config.extra.contributing_url | safe }}">Help grow the library</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
<p class="section-kicker">Community</p>
|
||||
<h1 class="section-title contributing-page__title">{{ page.title }}</h1>
|
||||
<p class="contributing-page__lead">
|
||||
Help grow free, open textbooks. Most contributions add <strong>curriculum data</strong> (what exists on the
|
||||
<a href="{{ config.extra.browse_url | safe }}">catalog</a>) or publish a <strong>live chapter</strong> (readable
|
||||
lesson pages). This guide matches the repo’s <a href="https://github.com/hananoshikayomaru/openfreebooks/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>.
|
||||
</p>
|
||||
|
||||
<nav class="contributing-quick-nav" aria-label="On this page">
|
||||
<a class="contributing-quick-nav__card" href="#overview">
|
||||
<span class="contributing-quick-nav__label">Overview</span>
|
||||
<span class="contributing-quick-nav__text">How catalog data and chapters connect</span>
|
||||
</a>
|
||||
<a class="contributing-quick-nav__card" href="#planned-chapter">
|
||||
<span class="contributing-quick-nav__label">Planned chapter</span>
|
||||
<span class="contributing-quick-nav__text">Roadmap entry only — JSON</span>
|
||||
</a>
|
||||
<a class="contributing-quick-nav__card" href="#live-chapter">
|
||||
<span class="contributing-quick-nav__label">Live chapter</span>
|
||||
<span class="contributing-quick-nav__text">Catalog + Zola page + HTML</span>
|
||||
</a>
|
||||
<a class="contributing-quick-nav__card" href="#chapter-graph">
|
||||
<span class="contributing-quick-nav__label">Chapter graph</span>
|
||||
<span class="contributing-quick-nav__text">Branches & prerequisites on the map</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<section id="overview" class="contributing-section">
|
||||
<h2 class="contributing-section__title">How the catalog fits together</h2>
|
||||
<div class="contributing-prose">
|
||||
<pre class="contributing-code-block"><code>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)</code></pre>
|
||||
<ul>
|
||||
<li><strong>List</strong> — numbered cards; live chapters link from the whole card.</li>
|
||||
<li><strong>Map</strong> (<code>?view=tree</code>) — strand columns; live chapters link from the <strong>title</strong> only (<code>→</code> + hover underline).</li>
|
||||
<li>Curriculum names (DSE, IB, …) appear <strong>only in the catalog</strong>, never inside lesson prose.</li>
|
||||
<li>Every chapter needs a <code>description</code> (1–2 sentences; map clamps to three lines).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="contributing-demo-views" class="contributing-demo-slot"></div>
|
||||
</section>
|
||||
|
||||
<section id="planned-chapter" class="contributing-section">
|
||||
<h2 class="contributing-section__title">Add a planned chapter</h2>
|
||||
<div class="contributing-prose">
|
||||
<p>Use this when the lesson is not written yet but should appear in the catalog roadmap.</p>
|
||||
<ol>
|
||||
<li>Open <code>data/{subject}-curriculum.json</code> (e.g. <code>data/math-curriculum.json</code>).</li>
|
||||
<li>Find the right <strong>strand</strong> and add a chapter object:</li>
|
||||
</ol>
|
||||
<pre class="contributing-code-block"><code>{
|
||||
"slug": "your-chapter-slug",
|
||||
"title": "Human-readable title",
|
||||
"description": "One or two sentences for list and map cards.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"],
|
||||
"tier": "non-foundation"
|
||||
}</code></pre>
|
||||
<p><code>tier</code> is optional — omit for foundation topics; <code>non-foundation</code> shows an <strong>Extension</strong> badge.</p>
|
||||
<p>Use <strong>kebab-case</strong> slugs. No <code>content/</code> files needed for planned chapters.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="live-chapter" class="contributing-section">
|
||||
<h2 class="contributing-section__title">Publish a live chapter</h2>
|
||||
<div class="contributing-prose">
|
||||
<p>A live chapter needs catalog JSON <strong>and</strong> a Zola section.</p>
|
||||
<h3 class="contributing-section__subtitle">1. Catalog entry</h3>
|
||||
<p>Set <code>"status": "live"</code> and include <code>description</code> in the curriculum JSON.</p>
|
||||
<h3 class="contributing-section__subtitle">2. Zola section</h3>
|
||||
<p>Create <code>content/{subject}/{slug}/_index.md</code> with <code>chapter_id</code> matching the slug.</p>
|
||||
<h3 class="contributing-section__subtitle">3. Chapter HTML</h3>
|
||||
<p>Add <code>themes/openfreebooks/templates/partials/math/your-slug-content.html</code> and wire it in <code>math/chapter.html</code>. Copy patterns from <code>quadratic-equations-content.html</code>.</p>
|
||||
<h3 class="contributing-section__subtitle">4. Optional widget</h3>
|
||||
<p>Add a Solid component under <code>frontend/src/components/</code> and mount it in <code>main.tsx</code>. Run <code>bun run build:js</code>.</p>
|
||||
</div>
|
||||
|
||||
<aside class="contributing-card-demo" aria-label="Map chapter card example">
|
||||
<p class="contributing-demo__hint">On the map, only the title is clickable — hover to preview the underline.</p>
|
||||
<div class="catalog-chapter-card catalog-chapter-card--map">
|
||||
<div class="catalog-chapter-card__body">
|
||||
<h3 class="catalog-chapter-card__title">
|
||||
<a class="catalog-chapter-card__title-link" href="{{ config.extra.browse_url | safe }}">
|
||||
<span class="catalog-chapter-card__title-text">Quadratic equations</span>
|
||||
<span class="catalog-chapter-card__title-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</h3>
|
||||
<p class="catalog-chapter-card__description">
|
||||
Solve quadratics by factorising, completing the square, and the formula.
|
||||
</p>
|
||||
<div class="catalog-chapter-card__meta">
|
||||
<span class="catalog-badge catalog-badge--dse">DSE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section id="chapter-graph" class="contributing-section">
|
||||
<h2 class="contributing-section__title">Chapter graph (map prerequisites)</h2>
|
||||
<div class="contributing-prose">
|
||||
<p>Prerequisites are <strong>required</strong> edges in <code>graph.edges</code>. Strand <code>chapters[]</code> order is for the list only — it does not create arrows.</p>
|
||||
<ul>
|
||||
<li><code>from</code> / <code>to</code> are chapter <strong>slugs</strong> (any strand).</li>
|
||||
<li>The graph must be acyclic (no cycles).</li>
|
||||
<li>Branch: one parent → many children. Merge: many parents → one child (multiple edges).</li>
|
||||
<li>Vertical position uses <strong>longest-path</strong> layering — a chapter sits below all ancestors.</li>
|
||||
</ul>
|
||||
<pre class="contributing-code-block"><code>"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" }
|
||||
]
|
||||
}</code></pre>
|
||||
<p>Verify at <a href="{{ config.extra.browse_url | safe }}?subject=math&view=tree">catalog map</a>. Use Ctrl/Cmd + scroll to zoom the canvas.</p>
|
||||
</div>
|
||||
|
||||
<div id="contributing-demo-graph" class="contributing-demo-slot"></div>
|
||||
</section>
|
||||
|
||||
<section id="dev-setup" class="contributing-section">
|
||||
<h2 class="contributing-section__title">Local development</h2>
|
||||
<div class="contributing-prose">
|
||||
<p>Prerequisites: <a href="https://www.getzola.org/">Zola</a> and <a href="https://bun.sh/">Bun</a>.</p>
|
||||
<pre class="contributing-code-block"><code>bun install
|
||||
bun run build:js # after frontend/ changes
|
||||
zola serve # http://127.0.0.1:1111
|
||||
bun test # layout tests for catalog map
|
||||
bun run build # production build</code></pre>
|
||||
<p>Commit <code>themes/openfreebooks/static/js/</code> after <code>build:js</code>. Do not commit <code>public/</code>.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="contributing-section contributing-section--cta">
|
||||
<h2 class="contributing-section__title">Ready to open a pull request?</h2>
|
||||
<p class="contributing-page__lead">
|
||||
Keep PRs focused. Describe subject, chapters, live vs planned, and curricula. Questions welcome on
|
||||
<a href="{{ config.extra.github_url | safe }}">GitHub</a>.
|
||||
</p>
|
||||
<div class="contributing-page__actions">
|
||||
<a class="button button-primary" href="{{ config.extra.github_url | safe }}">View repository</a>
|
||||
<a class="button button-secondary" href="{{ config.extra.browse_url | safe }}">Browse catalog</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
{% endfor %}
|
||||
|
||||
<p class="section-note">
|
||||
Want to contribute? Open a pull request on <a href="{{ config.extra.github_url | safe }}">GitHub</a>.
|
||||
Want to contribute? See the <a href="{{ config.extra.contributing_url | safe }}">contributing guide</a> or open a pull request on <a href="{{ config.extra.github_url | safe }}">GitHub</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ github_url = "https://github.com/hananoshikayomaru/openfreebooks"
|
|||
browse_url = "/catalog/"
|
||||
about_url = "/about/"
|
||||
credits_url = "/credits/"
|
||||
contributing_url = "/contributing/"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue