mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-12 20:45:52 +00:00
Update dependencies in package.json and bun.lock, enhance README with curriculum contribution guidelines, and improve catalog structure with graph data for mathematics. Add new styles for chapter headers and catalog view toggles in SASS, and implement copy page button functionality in frontend components.
This commit is contained in:
parent
778b2093f8
commit
8c986c1ed7
64 changed files with 8435 additions and 106 deletions
129
.agents/skills/ofb/SKILL.md
Normal file
129
.agents/skills/ofb/SKILL.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
---
|
||||
name: ofb
|
||||
description: >-
|
||||
Develop Open Free Books (OFB): Zola static site, catalog/curriculum JSON,
|
||||
HTML chapters, Solid.js, JSON Canvas maps, Sass. Use when editing openfreebooks,
|
||||
adding subjects/chapters/curriculum, catalog, math content, or deploying to
|
||||
Cloudflare.
|
||||
---
|
||||
|
||||
# Open Free Books (OFB)
|
||||
|
||||
Free, static, open-source textbooks. **Zola** + theme `openfreebooks` + **Solid.js** + **Bun/Vite**. Deploy: `public/` via Wrangler.
|
||||
|
||||
Read [spec.md](../../../spec.md) for product rules. Pair with [zola](../zola/SKILL.md) for template/Sass details.
|
||||
|
||||
## Architecture (mental model)
|
||||
|
||||
```text
|
||||
data/catalog.json → subject list + global curriculum filter chips
|
||||
data/{subject}-curriculum.json → strands, chapters, curriculums[], graph.edges
|
||||
content/{subject}/… → Zola sections (chapter pages at /{subject}/{slug}/)
|
||||
themes/…/partials/{subject}/ → chapter HTML bodies (not Markdown today)
|
||||
frontend/ → catalog-app, canvas viewer, site chrome
|
||||
/catalog/ → browse UI (list + JSON Canvas map)
|
||||
```
|
||||
|
||||
**Catalog is the subject index.** `/math/` redirects to `/catalog/?subject=math`. Do not revive a separate book index unless asked.
|
||||
|
||||
**Curriculum labels** appear only in catalog data (badges + filters), **never** in chapter page prose ([spec.md](../../../spec.md)).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run build:js # after frontend/ changes
|
||||
zola serve # http://127.0.0.1:1111
|
||||
bun run build # production: JS + Zola → public/
|
||||
```
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Styling | Sass + `class=""` in templates/JSX — **no Tailwind** |
|
||||
| React | Functional **Solid** only; mount via `main.tsx` |
|
||||
| Theme static | **Only** commit `themes/openfreebooks/static/js/bundle.js` (+ fonts if any). **Never** commit `public/` or stale HTML under theme `static/` |
|
||||
| Tera | Never name a loaded JSON variable `math` (reserved). Use `math_catalog`, etc. |
|
||||
| Chapter URLs | `/{subjectId}/{chapter-slug}/` (e.g. `/math/quadratic-equations/`) |
|
||||
| Canvas | [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/); viewer: `json-canvas-viewer` (full build, lazy-loaded on catalog) |
|
||||
| Graph | `graph.edges` must be **acyclic**; builder throws on cycles |
|
||||
|
||||
## Common tasks → where to edit
|
||||
|
||||
| Task | Files |
|
||||
|------|--------|
|
||||
| New global curriculum chip (e.g. GCSE) | `data/catalog.json` → `curriculums[]`; `_catalog.scss` badge; `frontend/src/lib/catalog-badge.ts`; rebuild |
|
||||
| New subject (sidebar) | `data/catalog.json` → `subjects[]`; `data/{id}-curriculum.json`; **wire catalog template** (today: manual `if subject.id` in `catalog.html` — prefer fixing per [CONTRIBUTING.md](../../../CONTRIBUTING.md)) |
|
||||
| New chapter (planned) | `{subject}-curriculum.json` strand entry; optional `graph.edges` |
|
||||
| New chapter (live) | Above + `content/{subject}/{slug}/_index.md` + HTML partial + `chapter.html` wiring + `main.tsx` widget mount if interactive |
|
||||
| Homepage subject card | `themes/.../templates/index.html` (not yet data-driven) |
|
||||
| Browse / nav URL | `zola.toml` `[extra] browse_url` + `base.html` site-config + `main.tsx` fallback |
|
||||
|
||||
Detailed checklists: [curriculum-data.md](curriculum-data.md). Human-facing guide: [CONTRIBUTING.md](../../../CONTRIBUTING.md).
|
||||
|
||||
## Catalog / canvas
|
||||
|
||||
| Piece | Path |
|
||||
|-------|------|
|
||||
| Shell UI (subject, filters, list/map toggle) | `frontend/src/components/catalog-app.tsx` |
|
||||
| Shared chapter card (list + map markup) | `frontend/src/components/catalog-chapter-card.tsx` |
|
||||
| Map viewer (json-canvas-viewer) | `frontend/src/components/catalog-canvas-view.tsx` (lazy chunk) |
|
||||
| Canvas layout (strands as columns, DAG levels, node positions) | `frontend/src/lib/catalog-to-canvas.ts` |
|
||||
| Card height measure + post-render fit | `frontend/src/lib/catalog-chapter-card-layout.ts` |
|
||||
| Viewer theme (light/dark hex colors) | `frontend/src/lib/catalog-canvas-theme.ts` |
|
||||
| Curriculum badge CSS class map | `frontend/src/lib/catalog-badge.ts` |
|
||||
| Types | `data/catalog.types.ts` |
|
||||
| Styles | `themes/openfreebooks/sass/_catalog.scss` |
|
||||
|
||||
- Embedded data: `#catalog-data` in `templates/catalog.html` (merged at build time)
|
||||
- URL: `/catalog/?subject=math` (list), `&view=tree` (map)
|
||||
|
||||
**List view:** full-card link for live chapters; number + title row on wide screens.
|
||||
|
||||
**Map view:** custom `nodeComponents.text` renders the same card DOM as list (`catalog-chapter-card--map`). Only the **title** is a link (with `→` and animated underline on hover/focus). Planned chapters are non-clickable. Do not make the whole card an `<a>`.
|
||||
|
||||
**Map layout:** one column per strand; Y from DAG level + per-level max card height. Node width `300px` (`CHAPTER_NODE_WIDTH`). Heights: off-screen `scrollHeight` per card, then after viewer paints, `measureRenderedMapChapterCards()` + `relayoutCanvasChapterHeights()` once if any node was too short. Waits for `document.fonts.ready` before first layout.
|
||||
|
||||
**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.
|
||||
|
||||
## Chapter content (current pattern)
|
||||
|
||||
Live math example:
|
||||
|
||||
1. `content/math/quadratic-equations/_index.md` — front matter, `template = "math/chapter.html"`, `[extra] chapter_id`
|
||||
2. `templates/partials/math/quadratic-equations-content.html` — prose + KaTeX
|
||||
3. `templates/math/chapter.html` — `{% if section.extra.chapter_id == "…" %}` includes partial
|
||||
4. Optional: `frontend/src/components/quadratic-explorer.tsx` + mount in `main.tsx`
|
||||
|
||||
**Pain point:** each new chapter needs a template `if` branch until a generic chapter loader exists.
|
||||
|
||||
## Verify before PR
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
- `public/catalog/index.html` includes your JSON changes in `#catalog-data`
|
||||
- No stale `themes/openfreebooks/static/**/*.html` (delete if present — they override Zola)
|
||||
- `public/main.css` contains new Sass selectors
|
||||
- Catalog: subject appears, filters work, map loads without cycle error; map cards not clipped (check long titles e.g. permutations); live map titles link and show underline on hover
|
||||
- Live chapter: breadcrumb → catalog, no curriculum mentions in body
|
||||
|
||||
## Planned / not implemented (do not assume)
|
||||
|
||||
- Pagefind search (`build_search_index = false`)
|
||||
- Defuddle “copy as Markdown” on chapters
|
||||
- Markdown-based chapters (spec says HTML; README may be outdated)
|
||||
- Data-driven homepage cards
|
||||
- Single-file subject registration (catalog template still special-cases `math`)
|
||||
|
||||
See README **Contributor experience** task list for tracked fixes.
|
||||
|
||||
## Additional resources
|
||||
|
||||
- [curriculum-data.md](curriculum-data.md) — JSON schemas, examples, graph rules
|
||||
- [zola/SKILL.md](../zola/SKILL.md) — Tera, Sass, Solid mount points
|
||||
- [zola/zola-solid.md](../zola/zola-solid.md) — `main.tsx`, bundle, dialogs
|
||||
118
.agents/skills/ofb/curriculum-data.md
Normal file
118
.agents/skills/ofb/curriculum-data.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Curriculum & catalog data
|
||||
|
||||
## `data/catalog.json`
|
||||
|
||||
Site-wide catalog chrome.
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Curriculum Catalog",
|
||||
"subtitle": "Browse chapters by subject and curriculum",
|
||||
"curriculums": ["DSE", "IB", "A-Level", "AP", "IGCSE"],
|
||||
"subjects": [
|
||||
{ "id": "math", "name": "Mathematics" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Rules |
|
||||
|-------|--------|
|
||||
| `id` | kebab-case, URL-safe; becomes `?subject=` and path prefix `/{id}/` |
|
||||
| `name` | Display label in sidebar |
|
||||
| `curriculums` | Union of all filter chips; add new labels here first |
|
||||
|
||||
## `data/{subject}-curriculum.json`
|
||||
|
||||
Example: `data/math-curriculum.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Mathematics",
|
||||
"subtitle": "Short tagline for data (not shown on every page)",
|
||||
"intro": "Optional; was used by old book page",
|
||||
"graph": {
|
||||
"edges": [
|
||||
{ "from": "quadratic-equations", "to": "sequences" },
|
||||
{ "from": "quadratic-equations", "to": "functions-graphs" }
|
||||
]
|
||||
},
|
||||
"strands": [
|
||||
{
|
||||
"id": "number-algebra",
|
||||
"title": "Number & Algebra",
|
||||
"chapters": [
|
||||
{
|
||||
"slug": "quadratic-equations",
|
||||
"title": "Quadratic equations",
|
||||
"description": "One or two sentences shown in list and map views.",
|
||||
"status": "live",
|
||||
"curriculums": ["DSE", "IB"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Chapter fields
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `slug` | yes | Matches `content/{subject}/{slug}/`; kebab-case |
|
||||
| `title` | yes | Catalog list + map node label |
|
||||
| `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` |
|
||||
|
||||
### 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).
|
||||
|
||||
### Wiring into catalog
|
||||
|
||||
`themes/openfreebooks/templates/catalog.html` must expose strands + graph in `#catalog-data`. **Today only `math` is wired** — adding a subject requires a Tera block like:
|
||||
|
||||
```tera
|
||||
{% set subject_catalog = load_data(path="data/science-curriculum.json", format="json") %}
|
||||
...
|
||||
"strands": {% if subject.id == "science" %}{{ subject_catalog.strands | json_encode() | safe }}{% else %}[]{% endif %}
|
||||
```
|
||||
|
||||
**Goal (tracked in README):** auto-load `data/{id}-curriculum.json` by subject id.
|
||||
|
||||
## Status: planned vs live
|
||||
|
||||
| status | Catalog list | Map node | Content required |
|
||||
|--------|--------------|----------|------------------|
|
||||
| `planned` | Shown, “Coming soon” badge | Card shown; title not linked | Only JSON entry |
|
||||
| `live` | Full card links to chapter | Title links to chapter (`→` + underline); rest of card not clickable | `content/` + HTML partial + template wiring |
|
||||
|
||||
Map nodes are `type: "text"` in JSON Canvas; the viewer renders custom HTML via `catalog-chapter-card.tsx` (`renderCatalogChapterCardElement`), not markdown.
|
||||
|
||||
## Adding a curriculum label
|
||||
|
||||
1. Add string to `data/catalog.json` → `curriculums`.
|
||||
2. Tag chapters with that string in `curriculums` arrays.
|
||||
3. Add `.catalog-badge--{name}` in `_catalog.scss` (see existing `--dse`, `--ib`, …).
|
||||
4. Optional: `curriculumBadgeClass()` in `frontend/src/lib/catalog-badge.ts` (defaults exist).
|
||||
|
||||
## Map card heights (agents)
|
||||
|
||||
Do not use fixed node heights or font-metrics libraries for layout. The pipeline is:
|
||||
|
||||
1. `measureAllMapChapterCardHeights()` — render each card off-screen; read `scrollHeight`.
|
||||
2. `subjectToCanvas()` — assign node `height` and column Y from DAG levels.
|
||||
3. After `json-canvas-viewer` paints, `measureRenderedMapChapterCards()` + `relayoutCanvasChapterHeights()` — bump any node still too short and reflow Y / strand groups.
|
||||
|
||||
Card markup must stay in sync between `catalog-chapter-card.tsx` (Solid + `renderCatalogChapterCardElement`) and `_catalog.scss` (especially `.catalog-chapter-card--map`).
|
||||
|
||||
## Validation (manual until scripted)
|
||||
|
||||
- JSON parses (`jq . data/math-curriculum.json`).
|
||||
- Every `live` slug has `content/{subject}/{slug}/_index.md`.
|
||||
- Every `curriculums[]` value is listed in `catalog.json`.
|
||||
- Graph edges reference existing slugs; no cycles (open catalog map view).
|
||||
- `bun run build` succeeds.
|
||||
|
|
@ -21,14 +21,14 @@ Static site: **Zola** + custom theme `openfreebooks`. Output goes to `public/` (
|
|||
| `static/` | Copied to site root (`favicons`, `icons/`, etc.) |
|
||||
| `themes/openfreebooks/templates/` | Tera HTML, partials, macros |
|
||||
| `themes/openfreebooks/sass/` | Source styles → compiled `public/main.css` |
|
||||
| `themes/openfreebooks/static/` | **Only** committed build artifact: `js/bundle.js` |
|
||||
| `themes/openfreebooks/static/js/` | Vite output: `bundle.js` (site chrome) + hashed chunks (`catalog-app-*.js`, `catalog-canvas-view-*.js`, …) |
|
||||
|
||||
See [reference.md](reference.md) for Tera pitfalls and build checks.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run build:js # Vite → themes/openfreebooks/static/js/bundle.js
|
||||
bun run build:js # Vite → themes/openfreebooks/static/js/ (bundle.js + lazy chunks)
|
||||
zola serve # dev at http://127.0.0.1:1111
|
||||
bun run build # build:js + zola build → public/
|
||||
```
|
||||
|
|
@ -66,10 +66,22 @@ On Zola 0.22+, use the `data/…` path (not bare `contributors.json` at repo roo
|
|||
|
||||
## Solid.js
|
||||
|
||||
Client behavior (header, marquee, scroll reveal, dialogs, theme toggle) lives in `frontend/` and mounts from `base.html` via `js/bundle.js`.
|
||||
Client behavior lives in `frontend/` and mounts from `base.html`:
|
||||
|
||||
| Entry | Mount | Notes |
|
||||
|-------|--------|--------|
|
||||
| `js/bundle.js` | `main.tsx` | Header, theme, homepage, chapter widgets |
|
||||
| `js/catalog-app-*.js` | `#catalog-app` on `/catalog/` | Lazy-loaded with `catalog-canvas-view-*.js` for Map view |
|
||||
|
||||
**When changing `frontend/` or adding interactivity**, read [zola-solid.md](zola-solid.md) before editing JS.
|
||||
|
||||
### Catalog page
|
||||
|
||||
- `content/catalog.md` → `template = "catalog.html"`
|
||||
- `templates/catalog.html` — empty `#catalog-app` + `#catalog-data` JSON embed (merged subject curricula at build time)
|
||||
- `sass/_catalog.scss` — list + map styles; map card title link underline lives under `.catalog-canvas`
|
||||
- Run `bun run build:js` after any `frontend/src/components/catalog-*` or `frontend/src/lib/catalog-*` change
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
|
|
|
|||
207
CONTRIBUTING.md
Normal file
207
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# 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.
|
||||
|
||||
**Prerequisites:** [Zola](https://www.getzola.org/), [Bun](https://bun.sh/). See [README.md](README.md) for dev setup.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
**Browse the catalog locally:** `bun run build:js && zola serve` → [http://127.0.0.1:1111/catalog/](http://127.0.0.1:1111/catalog/)
|
||||
|
||||
---
|
||||
|
||||
## 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, optional graph
|
||||
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.
|
||||
- **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).
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "your-chapter-slug",
|
||||
"title": "Human-readable title",
|
||||
"description": "One or two sentences for the catalog list and map card.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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` for the chapter (see [planned chapter](#add-a-planned-chapter-roadmap)).
|
||||
|
||||
### 2. Zola section
|
||||
|
||||
Create `content/{subject}/{slug}/_index.md`:
|
||||
|
||||
```md
|
||||
+++
|
||||
title = "Your chapter title"
|
||||
description = "One sentence for search and previews — no curriculum names here."
|
||||
template = "math/chapter.html"
|
||||
weight = 10
|
||||
[extra]
|
||||
chapter_id = "your-chapter-slug"
|
||||
+++
|
||||
```
|
||||
|
||||
- `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. Chapter HTML content
|
||||
|
||||
Today, chapter bodies live as HTML partials under the theme, not Markdown:
|
||||
|
||||
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:
|
||||
|
||||
```html
|
||||
{% if section.extra.chapter_id == "your-chapter-slug" %}
|
||||
{% include "partials/math/your-chapter-slug-content.html" %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
Use existing classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `quadratic-equations-content.html`.
|
||||
|
||||
**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 `<div id="your-widget" class="math-widget-mount"></div>` 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.
|
||||
|
||||
---
|
||||
|
||||
## Chapter graph (branches)
|
||||
|
||||
The **Map** tab (`?view=tree`) shows chapters as a directed graph on a canvas (strand = column, arrows = prerequisites / sequence).
|
||||
|
||||
- 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`:
|
||||
|
||||
```json
|
||||
"graph": {
|
||||
"edges": [
|
||||
{ "from": "quadratic-equations", "to": "sequences" },
|
||||
{ "from": "quadratic-equations", "to": "functions-graphs" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `from` and `to` are chapter **slugs**.
|
||||
- The graph must **not contain cycles** (no circular prerequisites).
|
||||
- Edges must reference slugs that exist in `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.
|
||||
|
||||
---
|
||||
|
||||
## 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`** — today you must wire the new file into `#catalog-data` (see `math` block). *This step should get easier; see README contributor tasks.*
|
||||
|
||||
4. **`content/science/`** — add `_index.md` (redirect template like math) when you have a section.
|
||||
|
||||
5. **Homepage** — optional card in `themes/openfreebooks/templates/index.html`.
|
||||
|
||||
6. Build and test `/catalog/?subject=science`.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Questions? Open a [GitHub discussion](https://github.com/hananoshikayomaru/openfreebooks) or issue.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
52
README.md
52
README.md
|
|
@ -14,7 +14,9 @@ Free, open-source textbooks for every learner — from elementary school through
|
|||
| JS build | [Bun](https://bun.sh/) + [Vite](https://vite.dev/) |
|
||||
| Hosting | Cloudflare Workers (static assets via Wrangler) |
|
||||
|
||||
Book content will use Markdown. Canvas features will follow the [JSON Canvas](https://jsoncanvas.org/) standard (Obsidian-compatible).
|
||||
Chapter content is **HTML partials** in the theme today (not Markdown). Canvas maps use the [JSON Canvas](https://jsoncanvas.org/) standard (Obsidian-compatible) via [json-canvas-viewer](https://github.com/Hesprs/JSON-Canvas-Viewer).
|
||||
|
||||
**Contributing curriculum, subjects, or chapters?** See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -25,8 +27,9 @@ Book content will use Markdown. Canvas features will follow the [JSON Canvas](ht
|
|||
## Project layout
|
||||
|
||||
```
|
||||
content/ # Pages and sections (Markdown)
|
||||
static/ # Site assets copied to public/ (favicons, manifest)
|
||||
content/ # Pages and sections (Markdown front matter)
|
||||
data/ # Catalog + per-subject curriculum JSON
|
||||
static/ # Site assets copied to public/ (favicons, _redirects)
|
||||
themes/openfreebooks/ # Zola theme
|
||||
templates/ # HTML (Tera)
|
||||
sass/ # Stylesheets (compiled to main.css)
|
||||
|
|
@ -83,6 +86,49 @@ The visual design is inspired by [manlung.work](https://manlung.work/): paper ba
|
|||
|
||||
**Keep `themes/openfreebooks/static/` clean.** Only committed built assets belong there (currently `js/bundle.js`). Do not copy Zola’s `public/` output into the theme `static/` folder — that pollutes `public/js/` on the next build.
|
||||
|
||||
## Contributor experience — known friction (task list)
|
||||
|
||||
These issues make contributions harder than they should be. PRs that fix any item are welcome.
|
||||
|
||||
### Catalog & data
|
||||
|
||||
- [ ] **Auto-wire subjects in `catalog.html`** — stop hardcoding `if subject.id == "math"`; load `data/{id}-curriculum.json` by subject `id` convention.
|
||||
- [ ] **Single source for subjects** — merge `catalog.json` subject stubs and `{subject}-curriculum.json` or generate one from the other at build time.
|
||||
- [ ] **`scripts/validate-curriculum.ts`** — JSON schema check: slugs, `curriculums` ⊆ global list, live chapters have `content/`, `graph.edges` acyclic.
|
||||
- [ ] **Document or script new curriculum labels** — today requires `catalog.json`, Sass badge, and TS `curriculumBadgeClass()` mapping.
|
||||
|
||||
### 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.
|
||||
- [ ] **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.
|
||||
|
||||
### Site & discoverability
|
||||
|
||||
- [ ] **Data-driven homepage cards** — “Browse by subject” / featured blocks duplicate manual edits in `index.html`.
|
||||
- [ ] **Pagefind search** — spec mentions search; `build_search_index = false` and no index step in build.
|
||||
- [ ] **CONTRIBUTING wizard or checklist in CI** — fail PR if live chapter missing partial or catalog entry.
|
||||
|
||||
### Build & repo hygiene
|
||||
|
||||
- [ ] **Prevent stale theme `static/**/*.html`** — committed copies under `themes/openfreebooks/static/` override Zola output (index, catalog, math); add CI check or pre-commit.
|
||||
- [ ] **Clarify package manager** — project uses **Bun** (`bun run build`); align docs/tooling if pnpm is preferred elsewhere.
|
||||
- [ ] **`dev` one-liner** — e.g. `bun run dev` running `vite build --watch` + `zola serve` together.
|
||||
|
||||
### New subject onboarding
|
||||
|
||||
- [ ] **Subject scaffold command** — e.g. `bun run scaffold:subject science` creating `data/science-curriculum.json`, `content/science/_index.md`, and catalog entry.
|
||||
- [ ] **Redirect template generic** — `math-redirect.html` → `subject-redirect.html` using `?subject=` from section path.
|
||||
|
||||
## Agent skills
|
||||
|
||||
| Skill | Path | Use |
|
||||
|-------|------|-----|
|
||||
| **OFB (this project)** | [.agents/skills/ofb/SKILL.md](.agents/skills/ofb/SKILL.md) | Catalog, curriculum JSON, chapters, product rules |
|
||||
| **Zola + Solid** | [.agents/skills/zola/SKILL.md](.agents/skills/zola/SKILL.md) | Templates, Sass, bundle, `zola serve` |
|
||||
|
||||
## License
|
||||
|
||||
Open source — see the repository license file when added.
|
||||
|
|
|
|||
64
bun.lock
64
bun.lock
|
|
@ -5,6 +5,8 @@
|
|||
"": {
|
||||
"name": "openfreebooks",
|
||||
"dependencies": {
|
||||
"defuddle": "^0.18.1",
|
||||
"json-canvas-viewer": "^4.3.2",
|
||||
"katex": "^0.16.47",
|
||||
"solid-js": "^1.9.5",
|
||||
"three": "^0.184.0",
|
||||
|
|
@ -17,6 +19,8 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ahmedsemih/color-fns": ["@ahmedsemih/color-fns@1.3.0", "", {}, "sha512-KjPMB+bUqOrJRvCCHVtbpHaCy621xD/Pn2LlJyAZx2lVwuc8d1FzDXSDIzM4iTNu/xUWoT/yfKQAuB2vFmeBnQ=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
|
||||
|
|
@ -115,6 +119,10 @@
|
|||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@needle-di/core": ["@needle-di/core@1.1.2", "", {}, "sha512-9CVnXjK33JeqUwAz5k/D+ti4ZfJ17m5AXsUcOwErbQ8baTNDPt1erVFGjipxxwcUj86Oxap3dALz6U70xsUsFg=="],
|
||||
|
||||
"@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=="],
|
||||
|
|
@ -175,27 +183,51 @@
|
|||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="],
|
||||
|
||||
"babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
|
||||
|
||||
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"defuddle": ["defuddle@0.18.1", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "linkedom": "^0.18.12", "mathml-to-latex": "^1.5.0", "temml": "^0.13.1", "turndown": "^7.2.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-AvFPFOsoDjt5xUOA1QxzafSSzJ5dqEIC63yO72tHYtSjj1DYY/XM0XTPUCsHkm5A2f1X9ulBvoSVFJrd4s2ckA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.357", "", {}, "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
|
|
@ -209,18 +241,30 @@
|
|||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-canvas-viewer": ["json-canvas-viewer@4.3.2", "", { "dependencies": { "@ahmedsemih/color-fns": "^1.3.0", "@needle-di/core": "^1.1.2", "dompurify": "^3.4.1", "marked": "^18.0.2", "pointeract": "^1.2.0" } }, "sha512-zCUHfD3jnvAsqmJvuENWxWEolSntmkp1cRRG8HNyfo2HGkfHMgzmaZfMkH/NMbC7Jj3cQ3l14zYj9rlXpPNX/Q=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"linkedom": ["linkedom@0.18.12", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="],
|
||||
|
||||
"mathml-to-latex": ["mathml-to-latex@1.5.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.10" } }, "sha512-rrWn0eEvcEcdMM4xfHcSGIy+i01DX9byOdXTLWg+w1iJ6O6ohP5UXY1dVzNUZLhzfl3EGcRekWLhY7JT5Omaew=="],
|
||||
|
||||
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
|
@ -229,12 +273,16 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pointeract": ["pointeract@1.2.0", "", {}, "sha512-rmc1IoG+B+OyYoRibFwUgPqG28CYRpMaWQZPHdEnHLG6zrLC6ThduE5ss5FqxCPtUf6gw/WqmJ2D2gqD7T0vrg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
|
@ -251,12 +299,18 @@
|
|||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"temml": ["temml@0.13.3", "", {}, "sha512-GLNEdf5qBWux3adbOxFus4jlds8nCdEIkkKq99m/4GGTfqnsjlVlK/i371Ux7yYSg/WNmOyAkNT/GJlZoJ0v+w=="],
|
||||
|
||||
"three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||
|
|
@ -268,5 +322,11 @@
|
|||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export type CatalogChapter = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: "live" | "planned";
|
||||
curriculums: string[];
|
||||
};
|
||||
|
|
@ -11,10 +12,21 @@ export type CatalogStrand = {
|
|||
chapters: CatalogChapter[];
|
||||
};
|
||||
|
||||
export type CatalogGraphEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type CatalogGraph = {
|
||||
edges: CatalogGraphEdge[];
|
||||
};
|
||||
|
||||
export type CatalogSubject = {
|
||||
id: string;
|
||||
name: string;
|
||||
strands: CatalogStrand[];
|
||||
graph?: CatalogGraph;
|
||||
};
|
||||
|
||||
export type CatalogData = {
|
||||
|
|
@ -23,3 +35,5 @@ export type CatalogData = {
|
|||
curriculums: string[];
|
||||
subjects: CatalogSubject[];
|
||||
};
|
||||
|
||||
export type CatalogViewMode = "linear" | "tree";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@
|
|||
"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.",
|
||||
"graph": {
|
||||
"edges": [
|
||||
{ "from": "quadratic-equations", "to": "sequences" },
|
||||
{ "from": "quadratic-equations", "to": "functions-graphs" },
|
||||
{ "from": "sequences", "to": "linear-programming" },
|
||||
{ "from": "functions-graphs", "to": "linear-programming" }
|
||||
]
|
||||
},
|
||||
"strands": [
|
||||
{
|
||||
"id": "number-algebra",
|
||||
|
|
@ -10,24 +18,28 @@
|
|||
{
|
||||
"slug": "quadratic-equations",
|
||||
"title": "Quadratic equations",
|
||||
"description": "Solve quadratics by factorising, completing the square, and the formula. Model real problems with parabolas and roots.",
|
||||
"status": "live",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "sequences",
|
||||
"title": "Sequences & series",
|
||||
"description": "Work with arithmetic and geometric sequences, sigma notation, and simple convergence ideas.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "functions-graphs",
|
||||
"title": "Functions & graphs",
|
||||
"description": "Study domains, ranges, transformations, and graphs of common functions including quadratics and rationals.",
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -40,24 +52,28 @@
|
|||
{
|
||||
"slug": "trigonometry",
|
||||
"title": "Advanced trigonometry",
|
||||
"description": "Use radians, identities, and laws of sines and cosines to solve triangles and model periodic phenomena.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "coordinate-geometry",
|
||||
"title": "Coordinate geometry of circles",
|
||||
"description": "Find equations of circles, tangents, and intersections using coordinate methods and distance.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"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"]
|
||||
},
|
||||
{
|
||||
"slug": "loci",
|
||||
"title": "Loci",
|
||||
"description": "Describe and construct loci in the plane, linking geometric constraints to algebraic equations.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
}
|
||||
|
|
@ -70,24 +86,28 @@
|
|||
{
|
||||
"slug": "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.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
},
|
||||
{
|
||||
"slug": "normal-distribution",
|
||||
"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"]
|
||||
},
|
||||
{
|
||||
"slug": "statistical-literacy",
|
||||
"title": "Statistical literacy",
|
||||
"description": "Evaluate claims in media and research: sampling bias, correlation versus causation, and misleading charts.",
|
||||
"status": "planned",
|
||||
"curriculums": ["DSE"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
import { createMemo, createSignal, For, Show } from "solid-js";
|
||||
import type { CatalogChapter, CatalogData, CatalogStrand, CatalogSubject } from "../../../data/catalog.types";
|
||||
import { createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";
|
||||
import { subjectToCanvas } from "../lib/catalog-to-canvas";
|
||||
import { watchSiteTheme } from "../lib/catalog-canvas-theme";
|
||||
import { CatalogChapterCard } from "./catalog-chapter-card";
|
||||
import type {
|
||||
CatalogChapter,
|
||||
CatalogData,
|
||||
CatalogStrand,
|
||||
CatalogSubject,
|
||||
CatalogViewMode,
|
||||
} from "../../../data/catalog.types";
|
||||
|
||||
const CatalogCanvasView = lazy(() =>
|
||||
import("./catalog-canvas-view").then((m) => ({ default: m.CatalogCanvasView }))
|
||||
);
|
||||
|
||||
function readCatalogData(): CatalogData | null {
|
||||
const el = document.getElementById("catalog-data");
|
||||
|
|
@ -16,15 +29,9 @@ function initialSubjectId(subjects: CatalogSubject[]): string {
|
|||
return subjects[0]?.id ?? "math";
|
||||
}
|
||||
|
||||
function curriculumBadgeClass(curriculum: string): string {
|
||||
const map: Record<string, string> = {
|
||||
DSE: "catalog-badge--dse",
|
||||
IB: "catalog-badge--ib",
|
||||
"A-Level": "catalog-badge--a-level",
|
||||
AP: "catalog-badge--ap",
|
||||
IGCSE: "catalog-badge--igcse",
|
||||
};
|
||||
return map[curriculum] ?? "catalog-badge--default";
|
||||
function initialViewMode(): CatalogViewMode {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("view") === "tree" ? "tree" : "linear";
|
||||
}
|
||||
|
||||
function chapterMatchesFilters(chapter: CatalogChapter, activeFilters: Set<string>): boolean {
|
||||
|
|
@ -38,9 +45,14 @@ function countVisibleChapters(subject: CatalogSubject, activeFilters: Set<string
|
|||
}, 0);
|
||||
}
|
||||
|
||||
function syncSubjectUrl(subjectId: string) {
|
||||
function syncUrl(subjectId: string, viewMode: CatalogViewMode) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("subject", subjectId);
|
||||
if (viewMode === "tree") {
|
||||
url.searchParams.set("view", "tree");
|
||||
} else {
|
||||
url.searchParams.delete("view");
|
||||
}
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +64,17 @@ export function CatalogApp() {
|
|||
|
||||
const [currentSubjectId, setCurrentSubjectId] = createSignal(initialSubjectId(data.subjects));
|
||||
const [activeFilters, setActiveFilters] = createSignal<Set<string>>(new Set());
|
||||
const [viewMode, setViewMode] = createSignal<CatalogViewMode>(initialViewMode());
|
||||
const [canvasThemeTick, setCanvasThemeTick] = createSignal(0);
|
||||
const [mapLayoutReady, setMapLayoutReady] = createSignal(!document.fonts);
|
||||
|
||||
onMount(() => {
|
||||
const stopThemeWatch = watchSiteTheme(() => setCanvasThemeTick((tick) => tick + 1));
|
||||
if (document.fonts) {
|
||||
void document.fonts.ready.then(() => setMapLayoutReady(true));
|
||||
}
|
||||
onCleanup(stopThemeWatch);
|
||||
});
|
||||
|
||||
const currentSubject = createMemo(
|
||||
() => data.subjects.find((s) => s.id === currentSubjectId()) ?? data.subjects[0]
|
||||
|
|
@ -61,6 +84,28 @@ export function CatalogApp() {
|
|||
|
||||
const hasChapterTree = createMemo(() => currentSubject().strands.length > 0);
|
||||
|
||||
const chapterFilter = createMemo(
|
||||
() => (chapter: CatalogChapter) => chapterMatchesFilters(chapter, activeFilters())
|
||||
);
|
||||
|
||||
const canvasData = createMemo(() => {
|
||||
canvasThemeTick();
|
||||
if (!mapLayoutReady()) return undefined;
|
||||
return subjectToCanvas(currentSubject(), chapterFilter());
|
||||
});
|
||||
|
||||
const visibleChapters = createMemo(() => {
|
||||
const map: Record<string, CatalogChapter> = {};
|
||||
for (const strand of currentSubject().strands) {
|
||||
for (const chapter of strand.chapters) {
|
||||
if (chapterFilter()(chapter)) {
|
||||
map[chapter.slug] = chapter;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const statsLabel = createMemo(() => {
|
||||
const count = visibleCount();
|
||||
const filters = activeFilters();
|
||||
|
|
@ -71,7 +116,12 @@ export function CatalogApp() {
|
|||
|
||||
const selectSubject = (subjectId: string) => {
|
||||
setCurrentSubjectId(subjectId);
|
||||
syncSubjectUrl(subjectId);
|
||||
syncUrl(subjectId, viewMode());
|
||||
};
|
||||
|
||||
const setCatalogView = (mode: CatalogViewMode) => {
|
||||
setViewMode(mode);
|
||||
syncUrl(currentSubjectId(), mode);
|
||||
};
|
||||
|
||||
const toggleFilter = (curriculum: string) => {
|
||||
|
|
@ -129,34 +179,59 @@ export function CatalogApp() {
|
|||
<h1 class="catalog-header__title">{currentSubject().name}</h1>
|
||||
<p class="catalog-header__stats">{statsLabel()}</p>
|
||||
</div>
|
||||
<div class="catalog-filters-wrap">
|
||||
<p class="catalog-filters__label">Filter by curriculum</p>
|
||||
<div class="catalog-filters" role="group" aria-label="Curriculum filters">
|
||||
<div class="catalog-header__controls">
|
||||
<div class="catalog-view-toggle" role="tablist" aria-label="Chapter layout">
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-filter-btn"
|
||||
classList={{ "is-active": activeFilters().size === 0 }}
|
||||
onClick={() => toggleFilter("ALL")}
|
||||
class="catalog-view-toggle__btn"
|
||||
role="tab"
|
||||
aria-selected={viewMode() === "linear" ? "true" : "false"}
|
||||
onClick={() => setCatalogView("linear")}
|
||||
>
|
||||
All
|
||||
List
|
||||
</button>
|
||||
<For each={data.curriculums}>
|
||||
{(curriculum) => (
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-filter-btn"
|
||||
classList={{ "is-active": activeFilters().has(curriculum) }}
|
||||
onClick={() => toggleFilter(curriculum)}
|
||||
>
|
||||
{curriculum}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-view-toggle__btn"
|
||||
role="tab"
|
||||
aria-selected={viewMode() === "tree" ? "true" : "false"}
|
||||
onClick={() => setCatalogView("tree")}
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
</div>
|
||||
<div class="catalog-filters-wrap">
|
||||
<p class="catalog-filters__label">Filter by curriculum</p>
|
||||
<div class="catalog-filters" role="group" aria-label="Curriculum filters">
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-filter-btn"
|
||||
classList={{ "is-active": activeFilters().size === 0 }}
|
||||
onClick={() => toggleFilter("ALL")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<For each={data.curriculums}>
|
||||
{(curriculum) => (
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-filter-btn"
|
||||
classList={{ "is-active": activeFilters().has(curriculum) }}
|
||||
onClick={() => toggleFilter(curriculum)}
|
||||
>
|
||||
{curriculum}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="catalog-content">
|
||||
<div
|
||||
class="catalog-content"
|
||||
classList={{ "catalog-content--tree": viewMode() === "tree" }}
|
||||
>
|
||||
<Show
|
||||
when={hasChapterTree() && visibleCount() > 0}
|
||||
fallback={
|
||||
|
|
@ -172,64 +247,55 @@ export function CatalogApp() {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredStrands()}>
|
||||
{(strand: CatalogStrand) => (
|
||||
<section class="catalog-strand" aria-labelledby={`strand-${strand.id}`}>
|
||||
<h2 id={`strand-${strand.id}`} class="catalog-strand__title">
|
||||
<span class="catalog-strand__marker" aria-hidden="true">
|
||||
✦
|
||||
</span>
|
||||
{strand.title}
|
||||
</h2>
|
||||
<ol class="catalog-tree">
|
||||
<For each={strand.chapters}>
|
||||
{(chapter, index) => (
|
||||
<li class="catalog-tree__item">
|
||||
<ChapterCard chapter={chapter} index={index()} />
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
<Show
|
||||
when={viewMode() === "tree"}
|
||||
fallback={
|
||||
<For each={filteredStrands()}>
|
||||
{(strand: CatalogStrand) => (
|
||||
<section class="catalog-strand" aria-labelledby={`strand-${strand.id}`}>
|
||||
<h2 id={`strand-${strand.id}`} class="catalog-strand__title">
|
||||
<span class="catalog-strand__marker" aria-hidden="true">
|
||||
✦
|
||||
</span>
|
||||
{strand.title}
|
||||
</h2>
|
||||
<ol class="catalog-tree">
|
||||
<For each={strand.chapters}>
|
||||
{(chapter, index) => (
|
||||
<li class="catalog-tree__item">
|
||||
<CatalogChapterCard
|
||||
chapter={chapter}
|
||||
index={index()}
|
||||
subjectId={currentSubjectId()}
|
||||
variant="list"
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={canvasData()}
|
||||
fallback={<p class="catalog-canvas-loading">Loading chapter map…</p>}
|
||||
>
|
||||
{(canvas) => (
|
||||
<Suspense fallback={<p class="catalog-canvas-loading">Loading chapter map…</p>}>
|
||||
<CatalogCanvasView
|
||||
canvas={canvas()}
|
||||
subjectId={currentSubjectId()}
|
||||
chapters={visibleChapters()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterCard(props: { chapter: CatalogChapter; index: number }) {
|
||||
const number = () => String(props.index + 1).padStart(2, "0");
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div class="catalog-chapter-card__main">
|
||||
<span class="catalog-chapter-card__number">{number()}</span>
|
||||
<span class="catalog-chapter-card__title">{props.chapter.title}</span>
|
||||
</div>
|
||||
<div class="catalog-chapter-card__meta">
|
||||
<For each={props.chapter.curriculums}>
|
||||
{(curriculum) => (
|
||||
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.chapter.status === "planned"}>
|
||||
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.chapter.status === "live"}
|
||||
fallback={<div class="catalog-chapter-card catalog-chapter-card--planned">{inner}</div>}
|
||||
>
|
||||
<a class="catalog-chapter-card" href={`/math/${props.chapter.slug}/`}>
|
||||
{inner}
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
181
frontend/src/components/catalog-canvas-view.tsx
Normal file
181
frontend/src/components/catalog-canvas-view.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { createEffect, onCleanup, onMount } from "solid-js";
|
||||
import type {
|
||||
JSONCanvas,
|
||||
JSONCanvasTextNode,
|
||||
JSONCanvasViewerInterface,
|
||||
} from "json-canvas-viewer";
|
||||
import {
|
||||
JSONCanvasViewer,
|
||||
MistouchPreventer,
|
||||
Controls,
|
||||
Minimap,
|
||||
parser,
|
||||
} from "json-canvas-viewer";
|
||||
import type { CatalogChapter } from "../../../data/catalog.types";
|
||||
import { renderCatalogChapterCardElement } from "./catalog-chapter-card";
|
||||
import { chapterSlugFromNodeId, relayoutCanvasChapterHeights } from "../lib/catalog-to-canvas";
|
||||
import { measureRenderedMapChapterCards } from "../lib/catalog-chapter-card-layout";
|
||||
import {
|
||||
syncViewerTheme,
|
||||
VIEWER_THEME_COLORS,
|
||||
siteCanvasTheme,
|
||||
watchSiteTheme,
|
||||
} from "../lib/catalog-canvas-theme";
|
||||
|
||||
type CatalogCanvasViewProps = {
|
||||
canvas: JSONCanvas;
|
||||
subjectId: string;
|
||||
chapters: Record<string, CatalogChapter>;
|
||||
};
|
||||
|
||||
function serializeCanvas(canvas: JSONCanvas): string {
|
||||
return JSON.stringify(canvas);
|
||||
}
|
||||
|
||||
function waitForLayout(el: HTMLElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (el.clientWidth >= 1 && el.clientHeight >= 1) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function bindPageScrollWheel(container: HTMLElement): () => void {
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) return;
|
||||
event.stopPropagation();
|
||||
};
|
||||
container.addEventListener("wheel", onWheel, { capture: true, passive: true });
|
||||
return () => container.removeEventListener("wheel", onWheel, { capture: true });
|
||||
}
|
||||
|
||||
function waitForFrames(count = 2): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let remaining = count;
|
||||
const step = () => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
function buildNodeComponents(
|
||||
subjectId: string,
|
||||
chapters: Record<string, CatalogChapter>
|
||||
) {
|
||||
return {
|
||||
text: ({
|
||||
container,
|
||||
node,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
node: JSONCanvasTextNode;
|
||||
}) => {
|
||||
const slug = chapterSlugFromNodeId(node.id);
|
||||
if (!slug) return;
|
||||
const chapter = chapters[slug];
|
||||
if (!chapter) return;
|
||||
container.replaceChildren();
|
||||
container.appendChild(renderCatalogChapterCardElement(chapter, subjectId, "map"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function CatalogCanvasView(props: CatalogCanvasViewProps) {
|
||||
let container!: HTMLDivElement;
|
||||
let viewer: JSONCanvasViewerInterface | undefined;
|
||||
let lastSerialized = "";
|
||||
let loadGeneration = 0;
|
||||
let unbindWheel: (() => void) | undefined;
|
||||
|
||||
const chaptersKey = (chapters: Record<string, CatalogChapter>) =>
|
||||
JSON.stringify(
|
||||
Object.entries(chapters).map(([slug, ch]) => [slug, ch.title, ch.description, ch.status])
|
||||
);
|
||||
|
||||
const loadCanvas = async (canvas: JSONCanvas, chapters: Record<string, CatalogChapter>) => {
|
||||
const cacheKey = `${serializeCanvas(canvas)}|${chaptersKey(chapters)}`;
|
||||
if (!viewer || cacheKey === lastSerialized) return;
|
||||
|
||||
const generation = ++loadGeneration;
|
||||
|
||||
await waitForLayout(container);
|
||||
if (generation !== loadGeneration || !viewer) return;
|
||||
|
||||
lastSerialized = cacheKey;
|
||||
viewer.load({ canvas });
|
||||
syncViewerTheme(viewer);
|
||||
|
||||
await waitForFrames();
|
||||
if (generation !== loadGeneration || !viewer) return;
|
||||
|
||||
const measured = measureRenderedMapChapterCards(container);
|
||||
const fitted = relayoutCanvasChapterHeights(canvas, measured);
|
||||
if (!fitted) return;
|
||||
|
||||
const fittedKey = `${serializeCanvas(fitted)}|${chaptersKey(chapters)}`;
|
||||
if (fittedKey === lastSerialized) return;
|
||||
|
||||
lastSerialized = fittedKey;
|
||||
viewer.load({ canvas: fitted });
|
||||
syncViewerTheme(viewer);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
let stopThemeWatch: (() => void) | undefined;
|
||||
|
||||
unbindWheel = bindPageScrollWheel(container);
|
||||
|
||||
viewer = new JSONCanvasViewer(
|
||||
{
|
||||
container,
|
||||
parser,
|
||||
theme: siteCanvasTheme(),
|
||||
colors: VIEWER_THEME_COLORS,
|
||||
loading: "none",
|
||||
preventMistouchAtStart: false,
|
||||
mistouchPreventerBannerText: "Click the map to pan and zoom",
|
||||
minimapCollapsed: true,
|
||||
nodeComponents: buildNodeComponents(props.subjectId, props.chapters),
|
||||
},
|
||||
[MistouchPreventer, Controls, Minimap]
|
||||
);
|
||||
|
||||
stopThemeWatch = watchSiteTheme(() => syncViewerTheme(viewer));
|
||||
|
||||
void loadCanvas(props.canvas, props.chapters);
|
||||
|
||||
onCleanup(() => {
|
||||
loadGeneration += 1;
|
||||
stopThemeWatch?.();
|
||||
unbindWheel?.();
|
||||
unbindWheel = undefined;
|
||||
viewer?.dispose();
|
||||
viewer = undefined;
|
||||
lastSerialized = "";
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!viewer) return;
|
||||
viewer.options.nodeComponents = buildNodeComponents(
|
||||
props.subjectId,
|
||||
props.chapters
|
||||
);
|
||||
void loadCanvas(props.canvas, props.chapters);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={container} class="catalog-canvas-view" role="application" aria-label="Chapter map" />
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/catalog-chapter-card.tsx
Normal file
189
frontend/src/components/catalog-chapter-card.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { For, Show } from "solid-js";
|
||||
import type { CatalogChapter } from "../../../data/catalog.types";
|
||||
import { curriculumBadgeClass } from "../lib/catalog-badge";
|
||||
|
||||
export type CatalogChapterCardProps = {
|
||||
chapter: CatalogChapter;
|
||||
subjectId: string;
|
||||
index?: number;
|
||||
variant?: "list" | "map";
|
||||
};
|
||||
|
||||
function chapterHref(subjectId: string, slug: string): string {
|
||||
return `/${subjectId}/${slug}/`;
|
||||
}
|
||||
|
||||
function appendMeta(parent: HTMLElement, chapter: CatalogChapter) {
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "catalog-chapter-card__meta";
|
||||
|
||||
for (const curriculum of chapter.curriculums) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `catalog-badge ${curriculumBadgeClass(curriculum)}`;
|
||||
badge.textContent = curriculum;
|
||||
meta.appendChild(badge);
|
||||
}
|
||||
|
||||
if (chapter.status === "planned") {
|
||||
const soon = document.createElement("span");
|
||||
soon.className = "book-chapter-badge book-chapter-badge--planned";
|
||||
soon.textContent = "Coming soon";
|
||||
meta.appendChild(soon);
|
||||
}
|
||||
|
||||
parent.appendChild(meta);
|
||||
}
|
||||
|
||||
function appendMapTitle(
|
||||
body: HTMLElement,
|
||||
chapter: CatalogChapter,
|
||||
subjectId: string
|
||||
) {
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "catalog-chapter-card__title";
|
||||
|
||||
if (chapter.status === "live") {
|
||||
const link = document.createElement("a");
|
||||
link.className = "catalog-chapter-card__title-link";
|
||||
link.href = chapterHref(subjectId, chapter.slug);
|
||||
link.addEventListener("click", (event) => event.stopPropagation());
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "catalog-chapter-card__title-text";
|
||||
text.textContent = chapter.title;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "catalog-chapter-card__title-arrow";
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
arrow.textContent = "→";
|
||||
|
||||
link.append(text, arrow);
|
||||
heading.appendChild(link);
|
||||
} else {
|
||||
heading.textContent = chapter.title;
|
||||
}
|
||||
|
||||
body.appendChild(heading);
|
||||
}
|
||||
|
||||
/** DOM builder for json-canvas-viewer `nodeComponents.text` (same markup/classes as list view). */
|
||||
export function renderCatalogChapterCardElement(
|
||||
chapter: CatalogChapter,
|
||||
subjectId: string,
|
||||
variant: "list" | "map" = "map"
|
||||
): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
root.className = [
|
||||
"catalog-chapter-card",
|
||||
variant === "map" ? "catalog-chapter-card--map" : "",
|
||||
chapter.status === "planned" ? "catalog-chapter-card--planned" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
root.dataset.chapterSlug = chapter.slug;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "catalog-chapter-card__body";
|
||||
|
||||
if (variant === "map") {
|
||||
appendMapTitle(body, chapter, subjectId);
|
||||
} else {
|
||||
const title = document.createElement("h3");
|
||||
title.className = "catalog-chapter-card__title";
|
||||
title.textContent = chapter.title;
|
||||
body.appendChild(title);
|
||||
}
|
||||
|
||||
if (chapter.description) {
|
||||
const description = document.createElement("p");
|
||||
description.className = "catalog-chapter-card__description";
|
||||
description.textContent = chapter.description;
|
||||
body.appendChild(description);
|
||||
}
|
||||
|
||||
appendMeta(body, chapter);
|
||||
root.appendChild(body);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function MapChapterTitle(props: {
|
||||
chapter: CatalogChapter;
|
||||
subjectId: string;
|
||||
}) {
|
||||
return (
|
||||
<h3 class="catalog-chapter-card__title">
|
||||
<Show
|
||||
when={props.chapter.status === "live"}
|
||||
fallback={props.chapter.title}
|
||||
>
|
||||
<a class="catalog-chapter-card__title-link" href={chapterHref(props.subjectId, props.chapter.slug)}>
|
||||
<span class="catalog-chapter-card__title-text">{props.chapter.title}</span>
|
||||
<span class="catalog-chapter-card__title-arrow" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
</Show>
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalogChapterCard(props: CatalogChapterCardProps) {
|
||||
const variant = () => props.variant ?? "list";
|
||||
const href = () => chapterHref(props.subjectId, props.chapter.slug);
|
||||
const number = () =>
|
||||
props.index === undefined ? undefined : String(props.index + 1).padStart(2, "0");
|
||||
|
||||
const inner = (
|
||||
<div class="catalog-chapter-card__body">
|
||||
<Show when={variant() === "list" && number()}>
|
||||
<div class="catalog-chapter-card__main">
|
||||
<span class="catalog-chapter-card__number">{number()}</span>
|
||||
<h3 class="catalog-chapter-card__title">{props.chapter.title}</h3>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={variant() === "map"}>
|
||||
<MapChapterTitle chapter={props.chapter} subjectId={props.subjectId} />
|
||||
</Show>
|
||||
<Show when={props.chapter.description}>
|
||||
<p class="catalog-chapter-card__description">{props.chapter.description}</p>
|
||||
</Show>
|
||||
<div class="catalog-chapter-card__meta">
|
||||
<For each={props.chapter.curriculums}>
|
||||
{(curriculum) => (
|
||||
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.chapter.status === "planned"}>
|
||||
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.chapter.status === "live" && variant() === "list"}
|
||||
fallback={
|
||||
<div
|
||||
class="catalog-chapter-card"
|
||||
classList={{
|
||||
"catalog-chapter-card--map": variant() === "map",
|
||||
"catalog-chapter-card--planned": props.chapter.status === "planned",
|
||||
}}
|
||||
data-chapter-slug={props.chapter.slug}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
class="catalog-chapter-card"
|
||||
href={href()}
|
||||
data-chapter-slug={props.chapter.slug}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/copy-page-button.tsx
Normal file
97
frontend/src/components/copy-page-button.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import { copyPageAsMarkdown } from "../lib/copy-page-as-markdown";
|
||||
|
||||
type ButtonState = "idle" | "loading" | "copied" | "error";
|
||||
|
||||
const COPIED_RESET_MS = 2000;
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg
|
||||
class="copy-page-button__icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.25" />
|
||||
<path
|
||||
d="M3 11V3.5A1.5 1.5 0 0 1 4.5 2H11"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.25"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyPageButton() {
|
||||
const [state, setState] = createSignal<ButtonState>("idle");
|
||||
const [errorMessage, setErrorMessage] = createSignal("");
|
||||
|
||||
const label = () => {
|
||||
switch (state()) {
|
||||
case "loading":
|
||||
return "Preparing…";
|
||||
case "copied":
|
||||
return "Copied!";
|
||||
case "error":
|
||||
return "Copy failed";
|
||||
default:
|
||||
return "Copy page";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
if (state() === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
setState("loading");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
await copyPageAsMarkdown();
|
||||
setState("copied");
|
||||
window.setTimeout(() => {
|
||||
if (state() === "copied") {
|
||||
setState("idle");
|
||||
}
|
||||
}, COPIED_RESET_MS);
|
||||
} catch (err) {
|
||||
setState("error");
|
||||
setErrorMessage(err instanceof Error ? err.message : "Could not copy page.");
|
||||
window.setTimeout(() => {
|
||||
if (state() === "error") {
|
||||
setState("idle");
|
||||
setErrorMessage("");
|
||||
}
|
||||
}, COPIED_RESET_MS);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="copy-page-button-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="copy-page-button"
|
||||
classList={{
|
||||
"copy-page-button--loading": state() === "loading",
|
||||
"copy-page-button--copied": state() === "copied",
|
||||
"copy-page-button--error": state() === "error",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
disabled={state() === "loading"}
|
||||
aria-label={label()}
|
||||
title="Copy page as Markdown for LLMs"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span class="copy-page-button__label">{label()}</span>
|
||||
</button>
|
||||
<p class="copy-page-button__status" aria-live="polite">
|
||||
{state() === "error" ? errorMessage() : state() === "copied" ? "Page copied to clipboard." : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/src/lib/catalog-badge.ts
Normal file
10
frontend/src/lib/catalog-badge.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function curriculumBadgeClass(curriculum: string): string {
|
||||
const map: Record<string, string> = {
|
||||
DSE: "catalog-badge--dse",
|
||||
IB: "catalog-badge--ib",
|
||||
"A-Level": "catalog-badge--a-level",
|
||||
AP: "catalog-badge--ap",
|
||||
IGCSE: "catalog-badge--igcse",
|
||||
};
|
||||
return map[curriculum] ?? "catalog-badge--default";
|
||||
}
|
||||
70
frontend/src/lib/catalog-canvas-theme.ts
Normal file
70
frontend/src/lib/catalog-canvas-theme.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { JSONCanvasViewerInterface } from "json-canvas-viewer";
|
||||
|
||||
/** Node/edge colors (hex) aligned with OFB tokens in _tokens.scss */
|
||||
export const CANVAS_NODE = {
|
||||
groupBorder: "#9a6b2e",
|
||||
live: "#9a6b2e",
|
||||
planned: "#8f8a80",
|
||||
/** 8-digit hex — json-canvas-viewer only accepts hex for node/edge colors */
|
||||
edge: "#9a6b2e66",
|
||||
} as const;
|
||||
|
||||
export const CANVAS_NODE_DARK = {
|
||||
groupBorder: "#c4924a",
|
||||
live: "#c4924a",
|
||||
planned: "#8f8a80",
|
||||
edge: "#c4924a73",
|
||||
} as const;
|
||||
|
||||
export function siteCanvasTheme(): "light" | "dark" {
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
if (attr === "dark" || attr === "light") return attr;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
/** Passed to JSONCanvasViewer `colors` option (named theme tokens). */
|
||||
export const VIEWER_THEME_COLORS = {
|
||||
light: {
|
||||
background: "#f5f4f1",
|
||||
"background-secondary": "#ffffff",
|
||||
border: "rgba(154, 107, 46, 0.22)",
|
||||
text: "#1a1a1a",
|
||||
dots: "rgba(26, 26, 26, 0.09)",
|
||||
shadow: "0 4px 18px rgba(20, 20, 20, 0.06)",
|
||||
},
|
||||
dark: {
|
||||
background: "#1a1917",
|
||||
"background-secondary": "#24221f",
|
||||
border: "rgba(196, 146, 74, 0.3)",
|
||||
text: "#eceae4",
|
||||
dots: "rgba(236, 234, 228, 0.07)",
|
||||
shadow: "0 4px 18px rgba(0, 0, 0, 0.28)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function watchSiteTheme(onChange: (theme: "light" | "dark") => void): () => void {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const observer = new MutationObserver(() => onChange(siteCanvasTheme()));
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
const onMedia = () => {
|
||||
if (!document.documentElement.getAttribute("data-theme")) {
|
||||
onChange(siteCanvasTheme());
|
||||
}
|
||||
};
|
||||
media.addEventListener("change", onMedia);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
media.removeEventListener("change", onMedia);
|
||||
};
|
||||
}
|
||||
|
||||
export function syncViewerTheme(viewer: JSONCanvasViewerInterface | undefined) {
|
||||
viewer?.changeTheme?.(siteCanvasTheme());
|
||||
}
|
||||
|
||||
export function canvasNodeColors(): typeof CANVAS_NODE | typeof CANVAS_NODE_DARK {
|
||||
return siteCanvasTheme() === "dark" ? CANVAS_NODE_DARK : CANVAS_NODE;
|
||||
}
|
||||
73
frontend/src/lib/catalog-chapter-card-layout.ts
Normal file
73
frontend/src/lib/catalog-chapter-card-layout.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { CatalogChapter } from "../../../data/catalog.types";
|
||||
import { renderCatalogChapterCardElement } from "../components/catalog-chapter-card";
|
||||
|
||||
export const MAP_CARD_NODE_WIDTH = 300;
|
||||
|
||||
/** Slack for json-canvas-viewer overlay rounding and border. */
|
||||
const MAP_NODE_HEIGHT_FUDGE = 10;
|
||||
|
||||
let measureHost: HTMLElement | null = null;
|
||||
|
||||
function getMeasureHost(): HTMLElement {
|
||||
if (!measureHost) {
|
||||
const host = document.createElement("div");
|
||||
host.className = "catalog-canvas catalog-map-measure-host";
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
Object.assign(host.style, {
|
||||
position: "fixed",
|
||||
left: "-10000px",
|
||||
top: "0",
|
||||
opacity: "0",
|
||||
pointerEvents: "none",
|
||||
overflow: "visible",
|
||||
});
|
||||
document.body.appendChild(host);
|
||||
measureHost = host;
|
||||
}
|
||||
return measureHost;
|
||||
}
|
||||
|
||||
function measureCardInShell(card: HTMLElement, nodeWidth: number): number {
|
||||
const shell = document.createElement("div");
|
||||
shell.className = "catalog-map-measure-shell";
|
||||
shell.style.width = `${nodeWidth}px`;
|
||||
card.style.width = "100%";
|
||||
shell.appendChild(card);
|
||||
getMeasureHost().replaceChildren(shell);
|
||||
return Math.ceil(card.scrollHeight) + MAP_NODE_HEIGHT_FUDGE;
|
||||
}
|
||||
|
||||
/** Measure every visible chapter card (same markup as the map). */
|
||||
export function measureAllMapChapterCardHeights(
|
||||
visible: Map<string, CatalogChapter>,
|
||||
nodeWidth: number,
|
||||
subjectId: string
|
||||
): Map<string, number> {
|
||||
const heights = new Map<string, number>();
|
||||
if (typeof document === "undefined") {
|
||||
for (const [slug] of visible) {
|
||||
heights.set(slug, 200);
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
|
||||
for (const [slug, chapter] of visible) {
|
||||
const card = renderCatalogChapterCardElement(chapter, subjectId, "map");
|
||||
heights.set(slug, measureCardInShell(card, nodeWidth));
|
||||
}
|
||||
|
||||
return heights;
|
||||
}
|
||||
|
||||
/** Heights from cards already mounted in the canvas viewer (ground truth). */
|
||||
export function measureRenderedMapChapterCards(container: HTMLElement): Map<string, number> {
|
||||
const heights = new Map<string, number>();
|
||||
for (const card of container.querySelectorAll<HTMLElement>(".catalog-chapter-card--map")) {
|
||||
const slug = card.dataset.chapterSlug;
|
||||
if (!slug) continue;
|
||||
const measured = Math.ceil(card.scrollHeight) + MAP_NODE_HEIGHT_FUDGE;
|
||||
const prev = heights.get(slug) ?? 0;
|
||||
heights.set(slug, Math.max(prev, measured));
|
||||
}
|
||||
return heights;
|
||||
}
|
||||
397
frontend/src/lib/catalog-to-canvas.ts
Normal file
397
frontend/src/lib/catalog-to-canvas.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import type { JSONCanvas, JSONCanvasEdge, JSONCanvasNode } from "json-canvas-viewer";
|
||||
import type {
|
||||
CatalogChapter,
|
||||
CatalogGraphEdge,
|
||||
CatalogSubject,
|
||||
} from "../../../data/catalog.types";
|
||||
import { canvasNodeColors } from "./catalog-canvas-theme";
|
||||
import { measureAllMapChapterCardHeights } from "./catalog-chapter-card-layout";
|
||||
|
||||
export const CHAPTER_NODE_WIDTH = 300;
|
||||
/** Horizontal gap between strand columns (groups must not overlap). */
|
||||
const STRAND_GAP_X = 112;
|
||||
const GAP_Y = 48;
|
||||
const GROUP_PAD = 28;
|
||||
|
||||
export function chapterNodeId(slug: string): string {
|
||||
return `chapter-${slug}`;
|
||||
}
|
||||
|
||||
export function chapterSlugFromNodeId(nodeId: string): string | null {
|
||||
return nodeId.startsWith("chapter-") ? nodeId.slice("chapter-".length) : null;
|
||||
}
|
||||
|
||||
function strandGroupId(strandId: string): string {
|
||||
return `strand-${strandId}`;
|
||||
}
|
||||
|
||||
function collectVisibleChapters(
|
||||
subject: CatalogSubject,
|
||||
matches: (chapter: CatalogChapter) => boolean
|
||||
): Map<string, CatalogChapter> {
|
||||
const map = new Map<string, CatalogChapter>();
|
||||
for (const strand of subject.strands) {
|
||||
for (const chapter of strand.chapters) {
|
||||
if (matches(chapter)) {
|
||||
map.set(chapter.slug, chapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildEdgeList(
|
||||
subject: CatalogSubject,
|
||||
visible: Map<string, CatalogChapter>
|
||||
): CatalogGraphEdge[] {
|
||||
const seen = new Set<string>();
|
||||
const edges: CatalogGraphEdge[] = [];
|
||||
|
||||
const add = (from: string, to: string, label?: string) => {
|
||||
if (!visible.has(from) || !visible.has(to)) return;
|
||||
const key = `${from}->${to}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
edges.push({ from, to, label });
|
||||
};
|
||||
|
||||
for (const edge of subject.graph?.edges ?? []) {
|
||||
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;
|
||||
}
|
||||
|
||||
function assertAcyclic(edges: CatalogGraphEdge[], slugs: Iterable<string>): void {
|
||||
const nodes = new Set(slugs);
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const slug of nodes) {
|
||||
adj.set(slug, []);
|
||||
}
|
||||
for (const { from, to } of edges) {
|
||||
adj.get(from)?.push(to);
|
||||
}
|
||||
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
const visit = (node: string) => {
|
||||
if (visited.has(node)) return;
|
||||
if (visiting.has(node)) {
|
||||
throw new Error(`Catalog graph has a cycle involving "${node}"`);
|
||||
}
|
||||
visiting.add(node);
|
||||
for (const next of adj.get(node) ?? []) {
|
||||
visit(next);
|
||||
}
|
||||
visiting.delete(node);
|
||||
visited.add(node);
|
||||
};
|
||||
|
||||
for (const node of nodes) {
|
||||
visit(node);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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 (const slug of slugs) {
|
||||
if (!levels.has(slug)) {
|
||||
levels.set(slug, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
function yOffsetInColumn(level: number, levelHeights: Map<number, number>): number {
|
||||
let y = 0;
|
||||
for (let l = 0; l < level; l++) {
|
||||
y += (levelHeights.get(l) ?? 0) + GAP_Y;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
function layoutNodes(
|
||||
subject: CatalogSubject,
|
||||
visible: Map<string, CatalogChapter>,
|
||||
edges: CatalogGraphEdge[]
|
||||
): JSONCanvasNode[] {
|
||||
const slugs = [...visible.keys()];
|
||||
const levels = assignLevels(edges, slugs);
|
||||
|
||||
const slugToStrandId = new Map<string, string>();
|
||||
const strandColumnX = new Map<string, number>();
|
||||
subject.strands.forEach((strand, index) => {
|
||||
strandColumnX.set(strand.id, index * (CHAPTER_NODE_WIDTH + STRAND_GAP_X));
|
||||
for (const chapter of strand.chapters) {
|
||||
slugToStrandId.set(chapter.slug, strand.id);
|
||||
}
|
||||
});
|
||||
|
||||
const heightBySlug = measureAllMapChapterCardHeights(
|
||||
visible,
|
||||
CHAPTER_NODE_WIDTH,
|
||||
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 }>();
|
||||
|
||||
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 groups: JSONCanvasNode[] = [];
|
||||
const nodes: JSONCanvasNode[] = [];
|
||||
const colors = canvasNodeColors();
|
||||
|
||||
for (const strand of subject.strands) {
|
||||
const strandSlugs = strand.chapters
|
||||
.map((ch) => ch.slug)
|
||||
.filter((slug) => visible.has(slug));
|
||||
if (strandSlugs.length === 0) continue;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const slug of strandSlugs) {
|
||||
const pos = positions.get(slug);
|
||||
if (!pos) continue;
|
||||
minX = Math.min(minX, pos.x);
|
||||
minY = Math.min(minY, pos.y);
|
||||
maxX = Math.max(maxX, pos.x + CHAPTER_NODE_WIDTH);
|
||||
maxY = Math.max(maxY, pos.y + pos.height);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX)) continue;
|
||||
|
||||
groups.push({
|
||||
id: strandGroupId(strand.id),
|
||||
type: "group",
|
||||
label: strand.title,
|
||||
x: minX - GROUP_PAD,
|
||||
y: minY - GROUP_PAD,
|
||||
width: maxX - minX + GROUP_PAD * 2,
|
||||
height: maxY - minY + GROUP_PAD * 2,
|
||||
color: colors.groupBorder,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [slug, chapter] of visible) {
|
||||
const pos = positions.get(slug);
|
||||
if (!pos) continue;
|
||||
nodes.push({
|
||||
id: chapterNodeId(slug),
|
||||
type: "text",
|
||||
text: chapter.slug,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: CHAPTER_NODE_WIDTH,
|
||||
height: pos.height,
|
||||
color: chapter.status === "live" ? colors.live : colors.planned,
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups, ...nodes];
|
||||
}
|
||||
|
||||
function toCanvasEdges(edgeList: CatalogGraphEdge[]): JSONCanvasEdge[] {
|
||||
const edgeColor = canvasNodeColors().edge;
|
||||
return edgeList.map((edge) => ({
|
||||
id: `edge-${edge.from}-${edge.to}`,
|
||||
fromNode: chapterNodeId(edge.from),
|
||||
toNode: chapterNodeId(edge.to),
|
||||
fromSide: "bottom",
|
||||
toSide: "top",
|
||||
toEnd: "arrow",
|
||||
label: edge.label,
|
||||
color: edgeColor,
|
||||
}));
|
||||
}
|
||||
|
||||
function edgesFromCanvas(canvas: JSONCanvas): CatalogGraphEdge[] {
|
||||
const edges: CatalogGraphEdge[] = [];
|
||||
for (const edge of canvas.edges) {
|
||||
const from = chapterSlugFromNodeId(edge.fromNode);
|
||||
const to = chapterSlugFromNodeId(edge.toNode);
|
||||
if (!from || !to) continue;
|
||||
edges.push({ from, to, label: edge.label });
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Recompute chapter Y/height (and strand groups) after measuring rendered cards. */
|
||||
export function relayoutCanvasChapterHeights(
|
||||
canvas: JSONCanvas,
|
||||
heightBySlug: Map<string, number>
|
||||
): JSONCanvas | null {
|
||||
const chapterNodes = canvas.nodes.filter(
|
||||
(node) => node.type === "text" && node.id.startsWith("chapter-")
|
||||
);
|
||||
if (chapterNodes.length === 0) return null;
|
||||
|
||||
const slugs = chapterNodes
|
||||
.map((node) => chapterSlugFromNodeId(node.id))
|
||||
.filter((slug): slug is string => Boolean(slug));
|
||||
const levels = assignLevels(edgesFromCanvas(canvas), slugs);
|
||||
|
||||
const mergedHeights = new Map<string, number>();
|
||||
for (const node of chapterNodes) {
|
||||
const slug = chapterSlugFromNodeId(node.id);
|
||||
if (!slug) continue;
|
||||
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));
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
const chapterById = new Map(updatedChapters.map((node) => [node.id, node]));
|
||||
|
||||
let nodes = canvas.nodes.map((node) => {
|
||||
if (node.type === "text" && node.id.startsWith("chapter-")) {
|
||||
return chapterById.get(node.id) ?? node;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
nodes = nodes.map((node) => {
|
||||
if (node.type !== "group") return node;
|
||||
const inGroup = updatedChapters.filter(
|
||||
(chapter) =>
|
||||
chapter.x + chapter.width / 2 >= node.x + GROUP_PAD &&
|
||||
chapter.x + chapter.width / 2 <= node.x + node.width - GROUP_PAD
|
||||
);
|
||||
if (inGroup.length === 0) return node;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const chapter of inGroup) {
|
||||
minX = Math.min(minX, chapter.x);
|
||||
minY = Math.min(minY, chapter.y);
|
||||
maxX = Math.max(maxX, chapter.x + chapter.width);
|
||||
maxY = Math.max(maxY, chapter.y + chapter.height);
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
x: minX - GROUP_PAD,
|
||||
y: minY - GROUP_PAD,
|
||||
width: maxX - minX + GROUP_PAD * 2,
|
||||
height: maxY - minY + GROUP_PAD * 2,
|
||||
};
|
||||
});
|
||||
|
||||
return { ...canvas, nodes };
|
||||
}
|
||||
|
||||
export function subjectToCanvas(
|
||||
subject: CatalogSubject,
|
||||
matches: (chapter: CatalogChapter) => boolean
|
||||
): JSONCanvas | null {
|
||||
const visible = collectVisibleChapters(subject, matches);
|
||||
if (visible.size === 0) return null;
|
||||
|
||||
const edgeList = buildEdgeList(subject, visible);
|
||||
assertAcyclic(edgeList, visible.keys());
|
||||
|
||||
return {
|
||||
nodes: layoutNodes(subject, visible, edgeList),
|
||||
edges: toCanvasEdges(edgeList),
|
||||
};
|
||||
}
|
||||
80
frontend/src/lib/copy-page-as-markdown.ts
Normal file
80
frontend/src/lib/copy-page-as-markdown.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
const SITE_NAME = "Open Free Books";
|
||||
|
||||
let defuddleModule: Promise<typeof import("defuddle/full")> | null = null;
|
||||
|
||||
function loadDefuddle() {
|
||||
if (!defuddleModule) {
|
||||
defuddleModule = import("defuddle/full");
|
||||
}
|
||||
return defuddleModule;
|
||||
}
|
||||
|
||||
function escapeYamlValue(value: string): string {
|
||||
if (/[\n:"\\]/.test(value) || value.startsWith(" ") || value.endsWith(" ")) {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatFrontMatter(fields: Record<string, string>): string {
|
||||
const lines = Object.entries(fields).map(
|
||||
([key, value]) => `${key}: ${escapeYamlValue(value)}`
|
||||
);
|
||||
return `---\n${lines.join("\n")}\n---\n\n`;
|
||||
}
|
||||
|
||||
function prepareChapterRoot(): HTMLElement {
|
||||
const article = document.querySelector<HTMLElement>(".book-chapter");
|
||||
if (!article) {
|
||||
throw new Error("Chapter content not found on this page.");
|
||||
}
|
||||
|
||||
const clone = article.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll(".math-widget-mount, [data-copy-exclude]").forEach((el) => {
|
||||
el.remove();
|
||||
});
|
||||
return clone;
|
||||
}
|
||||
|
||||
function buildParseDocument(chapterRoot: HTMLElement): Document {
|
||||
const doc = document.implementation.createHTMLDocument("copy");
|
||||
doc.body.appendChild(chapterRoot);
|
||||
return doc;
|
||||
}
|
||||
|
||||
function resolveTitle(resultTitle: string, chapterRoot: HTMLElement): string {
|
||||
const trimmed = resultTitle.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
const heading = chapterRoot.querySelector("h1");
|
||||
return heading?.textContent?.trim() ?? "Untitled chapter";
|
||||
}
|
||||
|
||||
export async function copyPageAsMarkdown(): Promise<void> {
|
||||
const chapterRoot = prepareChapterRoot();
|
||||
const parseDoc = buildParseDocument(chapterRoot);
|
||||
const source = window.location.href;
|
||||
|
||||
const { default: Defuddle } = await loadDefuddle();
|
||||
const result = new Defuddle(parseDoc, {
|
||||
markdown: true,
|
||||
url: source,
|
||||
useAsync: false,
|
||||
contentSelector: ".book-chapter",
|
||||
}).parse();
|
||||
|
||||
const title = resolveTitle(result.title, chapterRoot);
|
||||
const body = result.content.trim();
|
||||
const markdown = formatFrontMatter({
|
||||
title,
|
||||
source,
|
||||
site: SITE_NAME,
|
||||
}) + body;
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error("Clipboard is not available in this browser.");
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
}
|
||||
|
|
@ -66,6 +66,13 @@ if (particleRoot && !window.matchMedia("(prefers-reduced-motion: reduce)").match
|
|||
});
|
||||
}
|
||||
|
||||
const copyPageRoot = document.getElementById("copy-page-button");
|
||||
if (copyPageRoot) {
|
||||
void import("./components/copy-page-button").then(({ CopyPageButton }) => {
|
||||
render(() => <CopyPageButton />, copyPageRoot);
|
||||
});
|
||||
}
|
||||
|
||||
const quadraticRoot = document.getElementById("quadratic-explorer");
|
||||
if (quadraticRoot) {
|
||||
void Promise.all([
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
"scan:size": "bun scripts/scan-output-size.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"defuddle": "^0.18.1",
|
||||
"json-canvas-viewer": "^4.3.2",
|
||||
"katex": "^0.16.47",
|
||||
"solid-js": "^1.9.5",
|
||||
"three": "^0.184.0"
|
||||
|
|
|
|||
|
|
@ -126,9 +126,103 @@ a.book-chapter-link:focus-visible {
|
|||
}
|
||||
|
||||
.book-chapter__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.book-chapter__header-main {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.book-chapter__actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.copy-page-button-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.copy-page-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 9rem;
|
||||
padding: 8px 10px 8px 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.copy-page-button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.copy-page-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.copy-page-button--copied,
|
||||
.copy-page-button--copied:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--success) 55%, var(--line));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.copy-page-button-wrap:has(.copy-page-button--copied) .copy-page-button__status {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.copy-page-button--error,
|
||||
.copy-page-button--error:hover:not(:disabled) {
|
||||
border-color: #b54a4a;
|
||||
color: #b54a4a;
|
||||
}
|
||||
|
||||
.copy-page-button__icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
flex-shrink: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.copy-page-button__label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-page-button__status {
|
||||
margin: 0;
|
||||
max-width: 220px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.book-chapter__lead {
|
||||
margin: 16px 0 0;
|
||||
font-size: 1.05rem;
|
||||
|
|
|
|||
|
|
@ -124,6 +124,44 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.catalog-header__controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.catalog-view-toggle {
|
||||
display: inline-flex;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.catalog-view-toggle__btn {
|
||||
padding: 6px 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--soft-ink);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease, color 200ms ease;
|
||||
}
|
||||
|
||||
.catalog-view-toggle__btn:hover,
|
||||
.catalog-view-toggle__btn:focus-visible {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.catalog-view-toggle__btn[aria-selected="true"] {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.catalog-filters-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -177,6 +215,144 @@
|
|||
padding: 32px;
|
||||
}
|
||||
|
||||
.catalog-content--tree {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 560px;
|
||||
height: 560px;
|
||||
}
|
||||
|
||||
.catalog-canvas-view {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border-radius: 0;
|
||||
background: var(--paper);
|
||||
|
||||
:global(.JSON-Canvas-Viewer) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:global(.JCV-controls) {
|
||||
--jcv-control-bg: var(--card-bg);
|
||||
--jcv-control-border: var(--card-border);
|
||||
--jcv-control-fg: var(--ink);
|
||||
}
|
||||
|
||||
/* Override json-canvas-viewer default markdown accent colors */
|
||||
:global(.JCV-markdown-content) {
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
:global(.JCV-markdown-content p) {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
:global(.JCV-markdown-content p + p) {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
:global(.JCV-markdown-content strong) {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.JCV-markdown-content em) {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:global(.JCV-markdown-content a) {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.JCV-markdown-content a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:global(.JCV-minimap-container) {
|
||||
border-color: var(--card-border);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
:global(.JCV-overlay-container .JCV-content) {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.catalog-chapter-card--map) {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:global(.catalog-chapter-card--map .catalog-chapter-card__title-link) {
|
||||
display: inline;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: linear-gradient(var(--accent), var(--accent)) 0 calc(100% - 2px) / 0% 1px
|
||||
no-repeat;
|
||||
transition: background-size 180ms ease;
|
||||
}
|
||||
|
||||
:global(.catalog-chapter-card--map .catalog-chapter-card__title-link:hover),
|
||||
:global(.catalog-chapter-card--map .catalog-chapter-card__title-link:focus-visible) {
|
||||
background-size: 100% 1px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.catalog-chapter-card--map .catalog-chapter-card__title-arrow) {
|
||||
display: inline-block;
|
||||
margin-left: 0.2em;
|
||||
color: var(--accent);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
:global(.catalog-chapter-card--map .catalog-chapter-card__title-link:hover .catalog-chapter-card__title-arrow),
|
||||
:global(
|
||||
.catalog-chapter-card--map .catalog-chapter-card__title-link:focus-visible .catalog-chapter-card__title-arrow
|
||||
) {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
:global(.catalog-chapter-card--map .catalog-chapter-card__meta) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
:global(.catalog-chapter-card--map .catalog-chapter-card__description) {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.catalog-canvas-loading {
|
||||
margin: auto;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.catalog-strand {
|
||||
margin-bottom: 48px;
|
||||
animation: catalog-fade-in 0.4s ease-out forwards;
|
||||
|
|
@ -280,11 +456,16 @@
|
|||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.catalog-chapter-card {
|
||||
.catalog-chapter-card:not(.catalog-chapter-card--map) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.catalog-chapter-card:not(.catalog-chapter-card--map) .catalog-chapter-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a.catalog-chapter-card:hover,
|
||||
|
|
@ -320,8 +501,55 @@ a.catalog-chapter-card:focus-visible .catalog-chapter-card__number {
|
|||
}
|
||||
|
||||
.catalog-chapter-card__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.catalog-chapter-card__title-link {
|
||||
display: inline;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: linear-gradient(var(--accent), var(--accent)) 0 calc(100% - 2px) / 0% 1px no-repeat;
|
||||
transition: background-size 180ms ease;
|
||||
}
|
||||
|
||||
.catalog-chapter-card__title-link:hover,
|
||||
.catalog-chapter-card__title-link:focus-visible {
|
||||
background-size: 100% 1px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.catalog-chapter-card__title-arrow {
|
||||
display: inline-block;
|
||||
margin-left: 0.2em;
|
||||
color: var(--accent);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.catalog-chapter-card__title-link:hover .catalog-chapter-card__title-arrow,
|
||||
.catalog-chapter-card__title-link:focus-visible .catalog-chapter-card__title-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.catalog-map-measure-shell {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.catalog-chapter-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.catalog-chapter-card__description {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: var(--soft-ink);
|
||||
}
|
||||
|
||||
.catalog-chapter-card__meta {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
--soft-ink: #555;
|
||||
--muted: #767676;
|
||||
--accent: #9a6b2e;
|
||||
--success: #2a7d52;
|
||||
--line: #e8e8e8;
|
||||
--paper: #f5f4f1;
|
||||
--header-bg-scrolled: rgba(252, 252, 253, 0.9);
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
--soft-ink: #b8b4ab;
|
||||
--muted: #8f8a80;
|
||||
--accent: #c4924a;
|
||||
--success: #5cbf7a;
|
||||
--line: #3a3834;
|
||||
--paper: #1a1917;
|
||||
--header-bg-scrolled: rgba(26, 25, 23, 0.92);
|
||||
|
|
@ -36,6 +38,7 @@
|
|||
--soft-ink: #b8b4ab;
|
||||
--muted: #8f8a80;
|
||||
--accent: #c4924a;
|
||||
--success: #5cbf7a;
|
||||
--line: #3a3834;
|
||||
--paper: #1a1917;
|
||||
--header-bg-scrolled: rgba(26, 25, 23, 0.92);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
6
themes/openfreebooks/static/js/catalog-app-B1MOkQow.js
Normal file
6
themes/openfreebooks/static/js/catalog-app-B1MOkQow.js
Normal file
File diff suppressed because one or more lines are too long
5
themes/openfreebooks/static/js/catalog-app-BCuSdcie.js
Normal file
5
themes/openfreebooks/static/js/catalog-app-BCuSdcie.js
Normal file
File diff suppressed because one or more lines are too long
6
themes/openfreebooks/static/js/catalog-app-BIrH7hod.js
Normal file
6
themes/openfreebooks/static/js/catalog-app-BIrH7hod.js
Normal file
File diff suppressed because one or more lines are too long
6
themes/openfreebooks/static/js/catalog-app-BcQQfsdM.js
Normal file
6
themes/openfreebooks/static/js/catalog-app-BcQQfsdM.js
Normal file
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-Bm5-NG2c.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-Bm5-NG2c.js
Normal file
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-BrIcjXRw.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-BrIcjXRw.js
Normal file
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-Bu0M6E2K.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-Bu0M6E2K.js
Normal file
File diff suppressed because one or more lines are too long
6
themes/openfreebooks/static/js/catalog-app-CWjEwciu.js
Normal file
6
themes/openfreebooks/static/js/catalog-app-CWjEwciu.js
Normal file
File diff suppressed because one or more lines are too long
6
themes/openfreebooks/static/js/catalog-app-CXNp0tU1.js
Normal file
6
themes/openfreebooks/static/js/catalog-app-CXNp0tU1.js
Normal file
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-CeHC6_lE.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-CeHC6_lE.js
Normal file
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-Cwzk9GlX.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-Cwzk9GlX.js
Normal file
File diff suppressed because one or more lines are too long
6
themes/openfreebooks/static/js/catalog-app-CyBS2xCx.js
Normal file
6
themes/openfreebooks/static/js/catalog-app-CyBS2xCx.js
Normal file
File diff suppressed because one or more lines are too long
5
themes/openfreebooks/static/js/catalog-app-DKeRk13T.js
Normal file
5
themes/openfreebooks/static/js/catalog-app-DKeRk13T.js
Normal file
File diff suppressed because one or more lines are too long
6
themes/openfreebooks/static/js/catalog-app-DpYZyKPB.js
Normal file
6
themes/openfreebooks/static/js/catalog-app-DpYZyKPB.js
Normal file
File diff suppressed because one or more lines are too long
6
themes/openfreebooks/static/js/catalog-app-Dqp9K-N-.js
Normal file
6
themes/openfreebooks/static/js/catalog-app-Dqp9K-N-.js
Normal file
File diff suppressed because one or more lines are too long
2
themes/openfreebooks/static/js/catalog-app-Mj7-t4sK.js
Normal file
2
themes/openfreebooks/static/js/catalog-app-Mj7-t4sK.js
Normal file
File diff suppressed because one or more lines are too long
5
themes/openfreebooks/static/js/catalog-app-esJwCEKT.js
Normal file
5
themes/openfreebooks/static/js/catalog-app-esJwCEKT.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-0BLZy31h.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-0BLZy31h.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-B1LnNXjE.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-B1LnNXjE.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-BLc10pJC.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-BLc10pJC.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
import{e as m,k as p,_ as h,o as C,u as _,t as w}from"./bundle.js";import{V as S,s as E,w as g,a as f}from"./catalog-app-Dqp9K-N-.js";var y=w('<div class=catalog-canvas-view role=application aria-label="Chapter map">');function P(t){return JSON.stringify(t)}function O(t){return new Promise(r=>{const e=()=>{if(t.clientWidth>=1&&t.clientHeight>=1){r();return}requestAnimationFrame(e)};e()})}function V(t){const r=e=>{e.ctrlKey||e.metaKey||e.stopPropagation()};return t.addEventListener("wheel",r,{capture:!0,passive:!0}),()=>t.removeEventListener("wheel",r,{capture:!0})}function k(t){let r,e,s="",c=0;t.canvas;let o;m(()=>{t.canvas});const d=async n=>{const a=P(n);if(!e||a===s)return;const i=++c;await O(r),!(i!==c||!e)&&(s=a,e.load({canvas:JSON.parse(a)}),f(e))};return p(()=>{let n=!1,a;o=V(r),h(async()=>{const{JSONCanvasViewer:i,MistouchPreventer:l,Controls:u,parser:v}=await import("./index-DlHCjYDS.js");return{JSONCanvasViewer:i,MistouchPreventer:l,Controls:u,parser:v}},[]).then(({JSONCanvasViewer:i,MistouchPreventer:l,Controls:u,parser:v})=>{n||(e=new i({container:r,parser:v,theme:E(),colors:S,loading:"none",preventMistouchAtStart:!0,mistouchPreventerBannerText:"Click the map to pan and zoom",pointeract:{proControlSchema:!0,lockControlSchema:!0}},[l,u]),a=g(()=>f(e)),d(t.canvas))}),C(()=>{n=!0,c+=1,a==null||a(),o==null||o(),o=void 0,e==null||e.dispose(),e=void 0,s=""})}),m(()=>{const n=t.canvas;e&&d(n)}),(()=>{var n=y(),a=r;return typeof a=="function"?_(a,n):r=n,n})()}export{k as CatalogCanvasView};
|
||||
|
|
@ -0,0 +1 @@
|
|||
import{e as p,k as h,_ as C,o as _,u as w,t as S}from"./bundle.js";import{V as E,s as g,w as y,a as f}from"./catalog-app-BcQQfsdM.js";var P=S('<div class=catalog-canvas-view role=application aria-label="Chapter map">');function O(t){return JSON.stringify(t)}function V(t){return new Promise(n=>{const e=()=>{if(t.clientWidth>=1&&t.clientHeight>=1){n();return}requestAnimationFrame(e)};e()})}function L(t){const n=e=>{e.ctrlKey||e.metaKey||e.stopPropagation()};return t.addEventListener("wheel",n,{capture:!0,passive:!0}),()=>t.removeEventListener("wheel",n,{capture:!0})}function k(t){let n,e,s="",c=0;t.canvas;let o;p(()=>{t.canvas});const d=async r=>{const a=O(r);if(!e||a===s)return;const i=++c;await V(n),!(i!==c||!e)&&(s=a,e.load({canvas:JSON.parse(a)}),f(e))};return h(()=>{let r=!1,a;o=L(n),C(async()=>{const{JSONCanvasViewer:i,MistouchPreventer:l,Controls:u,Minimap:v,parser:m}=await import("./index-CMnGoexk.js");return{JSONCanvasViewer:i,MistouchPreventer:l,Controls:u,Minimap:v,parser:m}},[]).then(({JSONCanvasViewer:i,MistouchPreventer:l,Controls:u,Minimap:v,parser:m})=>{r||(e=new i({container:n,parser:m,theme:g(),colors:E,loading:"none",preventMistouchAtStart:!0,mistouchPreventerBannerText:"Click the map to pan and zoom",minimapCollapsed:!1,pointeract:{proControlSchema:!0,lockControlSchema:!0}},[l,u,v]),a=y(()=>f(e)),d(t.canvas))}),_(()=>{r=!0,c+=1,a==null||a(),o==null||o(),o=void 0,e==null||e.dispose(),e=void 0,s=""})}),p(()=>{const r=t.canvas;e&&d(r)}),(()=>{var r=P(),a=n;return typeof a=="function"?w(a,r):n=r,r})()}export{k as CatalogCanvasView};
|
||||
450
themes/openfreebooks/static/js/catalog-canvas-view-CyP2FYRp.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-CyP2FYRp.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-DPTmhr7j.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-DPTmhr7j.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
import{e as f,k as _,_ as C,o as w,u as h,t as p}from"./bundle.js";import{V as E,s as g,w as y,a as m}from"./catalog-app-DpYZyKPB.js";var O=p('<div class=catalog-canvas-view role=application aria-label="Chapter map">');function S(n){return JSON.stringify(n)}function V(n){return new Promise(r=>{const a=()=>{if(n.clientWidth>=1&&n.clientHeight>=1){r();return}requestAnimationFrame(a)};a()})}function z(n){let r,a,s="",o=0,d=n.canvas;f(()=>{d=n.canvas});const c=async t=>{const e=S(t);if(!a||e===s)return;const i=++o;await V(r),!(i!==o||!a)&&(s=e,a.load({canvas:JSON.parse(e)}),m(a))};return _(()=>{let t=!1,e;C(async()=>{const{JSONCanvasViewer:i,MistouchPreventer:l,Controls:v,parser:u}=await import("./index-DlHCjYDS.js");return{JSONCanvasViewer:i,MistouchPreventer:l,Controls:v,parser:u}},[]).then(({JSONCanvasViewer:i,MistouchPreventer:l,Controls:v,parser:u})=>{t||(a=new i({container:r,parser:u,theme:g(),colors:E,loading:"none"},[l,v]),e=y(()=>{m(a),s="",c(d)}),c(n.canvas))}),w(()=>{t=!0,o+=1,e==null||e(),a==null||a.dispose(),a=void 0,s=""})}),f(()=>{const t=n.canvas;a&&c(t)}),(()=>{var t=O(),e=r;return typeof e=="function"?h(e,t):r=t,t})()}export{z as CatalogCanvasView};
|
||||
450
themes/openfreebooks/static/js/catalog-canvas-view-DwLLgk7V.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-DwLLgk7V.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-U0hmF7qX.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-U0hmF7qX.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
import{e as f,k as _,_ as C,o as w,u as h,t as p}from"./bundle.js";import{V as E,s as g,w as y,a as m}from"./catalog-app-B1MOkQow.js";var O=p('<div class=catalog-canvas-view role=application aria-label="Chapter map">');function S(n){return JSON.stringify(n)}function V(n){return new Promise(r=>{const a=()=>{if(n.clientWidth>=1&&n.clientHeight>=1){r();return}requestAnimationFrame(a)};a()})}function z(n){let r,a,s="",o=0,d=n.canvas;f(()=>{d=n.canvas});const c=async t=>{const e=S(t);if(!a||e===s)return;const i=++o;await V(r),!(i!==o||!a)&&(s=e,a.load({canvas:JSON.parse(e)}),m(a))};return _(()=>{let t=!1,e;C(async()=>{const{JSONCanvasViewer:i,MistouchPreventer:l,Controls:v,parser:u}=await import("./index-DlHCjYDS.js");return{JSONCanvasViewer:i,MistouchPreventer:l,Controls:v,parser:u}},[]).then(({JSONCanvasViewer:i,MistouchPreventer:l,Controls:v,parser:u})=>{t||(a=new i({container:r,parser:u,theme:g(),colors:E,loading:"none"},[l,v]),e=y(()=>{m(a),s="",c(d)}),c(n.canvas))}),w(()=>{t=!0,o+=1,e==null||e(),a==null||a.dispose(),a=void 0,s=""})}),f(()=>{const t=n.canvas;a&&c(t)}),(()=>{var t=O(),e=r;return typeof e=="function"?h(e,t):r=t,t})()}export{z as CatalogCanvasView};
|
||||
450
themes/openfreebooks/static/js/catalog-canvas-view-aUFfDZV4.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-aUFfDZV4.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-cNxGxyzM.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-cNxGxyzM.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
import{e as p,k as h,_,o as w,u as C,t as E}from"./bundle.js";import{V as g,s as y,w as S,a as f}from"./catalog-app-CyBS2xCx.js";var P=E('<div class=catalog-canvas-view role=application aria-label="Chapter map">');function O(a){return JSON.stringify(a)}function V(a){return new Promise(n=>{const e=()=>{if(a.clientWidth>=1&&a.clientHeight>=1){n();return}requestAnimationFrame(e)};e()})}function L(a){const n=e=>{e.ctrlKey||e.metaKey||e.stopPropagation()};return a.addEventListener("wheel",n,{capture:!0,passive:!0}),()=>a.removeEventListener("wheel",n,{capture:!0})}function z(a){let n,e,s="",c=0;a.canvas;let i;p(()=>{a.canvas});const m=async r=>{const t=O(r);if(!e||t===s)return;const o=++c;await V(n),!(o!==c||!e)&&(s=t,e.load({canvas:JSON.parse(t)}),f(e))};return h(()=>{let r=!1,t;i=L(n),_(async()=>{const{JSONCanvasViewer:o,MistouchPreventer:l,Controls:u,Minimap:v,parser:d}=await import("./index-CMnGoexk.js");return{JSONCanvasViewer:o,MistouchPreventer:l,Controls:u,Minimap:v,parser:d}},[]).then(({JSONCanvasViewer:o,MistouchPreventer:l,Controls:u,Minimap:v,parser:d})=>{r||(e=new o({container:n,parser:d,theme:y(),colors:g,loading:"none",preventMistouchAtStart:!0,mistouchPreventerBannerText:"Click the map to pan and zoom",minimapCollapsed:!0},[l,u,v]),t=S(()=>f(e)),m(a.canvas))}),w(()=>{r=!0,c+=1,t==null||t(),i==null||i(),i=void 0,e==null||e.dispose(),e=void 0,s=""})}),p(()=>{const r=a.canvas;e&&m(r)}),(()=>{var r=P(),t=n;return typeof t=="function"?C(t,r):n=r,r})()}export{z as CatalogCanvasView};
|
||||
450
themes/openfreebooks/static/js/catalog-canvas-view-jEhL_044.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-jEhL_044.js
Normal file
File diff suppressed because one or more lines are too long
450
themes/openfreebooks/static/js/catalog-canvas-view-nGvtVrEu.js
Normal file
450
themes/openfreebooks/static/js/catalog-canvas-view-nGvtVrEu.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
import{k as l,_ as c,o as v,e as u,u as p,t as _}from"./bundle.js";var d=_('<div class=catalog-canvas-view role=application aria-label="Chapter map">');function m(i){let n,a;return l(()=>{let t=!1;c(async()=>{const{JSONCanvasViewer:e,MistouchPreventer:o,Controls:r,parser:s}=await import("./index-DlHCjYDS.js");return{JSONCanvasViewer:e,MistouchPreventer:o,Controls:r,parser:s}},[]).then(({JSONCanvasViewer:e,MistouchPreventer:o,Controls:r,parser:s})=>{t||(a=new e({container:n,canvas:i.canvas,parser:s},[o,r]))}),v(()=>{t=!0,a==null||a.dispose(),a=void 0})}),u(()=>{const t=i.canvas;a==null||a.load({canvas:t})}),(()=>{var t=d(),e=n;return typeof e=="function"?p(e,t):n=t,t})()}export{m as CatalogCanvasView};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import{_ as C,h as b,j as d,d as _,m as k,g as E,t as y,i as M,s as S}from"./bundle.js";const x="Open Free Books";let u=null;function T(){return u||(u=C(()=>import("./index.full-Bc57Lj__.js").then(e=>e.i),[])),u}function P(e){return/[\n:"\\]/.test(e)||e.startsWith(" ")||e.endsWith(" ")?`"${e.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`:e}function $(e){return`---
|
||||
${Object.entries(e).map(([r,n])=>`${r}: ${P(n)}`).join(`
|
||||
`)}
|
||||
---
|
||||
|
||||
`}function A(){const e=document.querySelector(".book-chapter");if(!e)throw new Error("Chapter content not found on this page.");const t=e.cloneNode(!0);return t.querySelectorAll(".math-widget-mount, [data-copy-exclude]").forEach(r=>{r.remove()}),t}function D(e){const t=document.implementation.createHTMLDocument("copy");return t.body.appendChild(e),t}function L(e,t){var i;const r=e.trim();if(r)return r;const n=t.querySelector("h1");return((i=n==null?void 0:n.textContent)==null?void 0:i.trim())??"Untitled chapter"}async function R(){var s;const e=A(),t=D(e),r=window.location.href,{default:n}=await T(),i=new n(t,{markdown:!0,url:r,useAsync:!1,contentSelector:".book-chapter"}).parse(),l=L(i.title,e),c=i.content.trim(),a=$({title:l,source:r,site:x})+c;if(!((s=navigator.clipboard)!=null&&s.writeText))throw new Error("Clipboard is not available in this browser.");await navigator.clipboard.writeText(a)}var I=y('<svg class=copy-page-button__icon width=16 height=16 viewBox="0 0 16 16"aria-hidden=true><rect x=5 y=5 width=9 height=9 rx=1.5 fill=none stroke=currentColor stroke-width=1.25></rect><path d="M3 11V3.5A1.5 1.5 0 0 1 4.5 2H11"fill=none stroke=currentColor stroke-width=1.25 stroke-linecap=round>'),O=y('<div class=copy-page-button-wrap><button type=button class=copy-page-button title="Copy page as Markdown for LLMs"><span class=copy-page-button__label></span></button><p class=copy-page-button__status aria-live=polite>');const w=2e3;function j(){return I()}function B(){const[e,t]=b("idle"),[r,n]=b(""),i=()=>{switch(e()){case"loading":return"Preparing…";case"copied":return"Copied!";case"error":return"Copy failed";default:return"Copy page"}},l=async()=>{if(e()!=="loading"){t("loading"),n("");try{await R(),t("copied"),window.setTimeout(()=>{e()==="copied"&&t("idle")},w)}catch(c){t("error"),n(c instanceof Error?c.message:"Could not copy page."),window.setTimeout(()=>{e()==="error"&&(t("idle"),n(""))},w)}}};return(()=>{var c=O(),a=c.firstChild,s=a.firstChild,v=a.nextSibling;return a.$$click=l,d(a,_(j,{}),s),d(s,i),d(v,(()=>{var o=k(()=>e()==="error");return()=>o()?r():e()==="copied"?"Page copied to clipboard.":""})()),E(o=>{var p=e()==="loading",g=e()==="copied",f=e()==="error",h=e()==="loading",m=i();return p!==o.e&&a.classList.toggle("copy-page-button--loading",o.e=p),g!==o.t&&a.classList.toggle("copy-page-button--copied",o.t=g),f!==o.a&&a.classList.toggle("copy-page-button--error",o.a=f),h!==o.o&&(a.disabled=o.o=h),m!==o.i&&S(a,"aria-label",o.i=m),o},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0}),c})()}M(["click"]);export{B as CopyPageButton};
|
||||
450
themes/openfreebooks/static/js/index-CMnGoexk.js
Normal file
450
themes/openfreebooks/static/js/index-CMnGoexk.js
Normal file
File diff suppressed because one or more lines are too long
396
themes/openfreebooks/static/js/index-DlHCjYDS.js
Normal file
396
themes/openfreebooks/static/js/index-DlHCjYDS.js
Normal file
File diff suppressed because one or more lines are too long
338
themes/openfreebooks/static/js/index.full-Bc57Lj__.js
Normal file
338
themes/openfreebooks/static/js/index.full-Bc57Lj__.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
|
|
@ -18,7 +18,8 @@
|
|||
{
|
||||
"id": {{ subject.id | json_encode() | safe }},
|
||||
"name": {{ subject.name | json_encode() | safe }},
|
||||
"strands": {% if subject.id == "math" %}{{ math_catalog.strands | json_encode() | safe }}{% else %}[]{% endif %}
|
||||
"strands": {% if subject.id == "math" %}{{ math_catalog.strands | json_encode() | safe }}{% else %}[]{% endif %}{% if subject.id == "math" and math_catalog.graph %},
|
||||
"graph": {{ math_catalog.graph | json_encode() | safe }}{% endif %}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -24,11 +24,14 @@
|
|||
|
||||
<article class="book-chapter">
|
||||
<header class="book-chapter__header">
|
||||
<p class="section-kicker">Number & Algebra</p>
|
||||
<h1 class="section-title">{{ section.title }}</h1>
|
||||
{% if section.description %}
|
||||
<p class="book-chapter__lead">{{ section.description }}</p>
|
||||
{% endif %}
|
||||
<div class="book-chapter__header-main">
|
||||
<p class="section-kicker">Number & Algebra</p>
|
||||
<h1 class="section-title">{{ section.title }}</h1>
|
||||
{% if section.description %}
|
||||
<p class="book-chapter__lead">{{ section.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="copy-page-button" class="book-chapter__actions"></div>
|
||||
</header>
|
||||
|
||||
{% if section.extra.chapter_id == "quadratic-equations" %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue