@brandon_m_behring/book-scaffold-astro 4.26.3 → 4.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CLAUDE.md +35 -10
  3. package/LATEX_TO_MDX_MAPPING.md +1 -1
  4. package/LICENSE +27 -0
  5. package/LICENSE-CONTENT +19 -0
  6. package/README.md +33 -0
  7. package/components/AssessmentTest.astro +2 -1
  8. package/components/ChapterNav.astro +2 -2
  9. package/components/Cite.astro +2 -1
  10. package/components/NavContent.astro +2 -2
  11. package/components/PartReview.astro +2 -1
  12. package/components/Rationale.astro +3 -2
  13. package/components/Sidebar.astro +2 -1
  14. package/components/Term.astro +2 -1
  15. package/components/TipsCard.astro +2 -1
  16. package/components/VersionSelector.tsx +39 -36
  17. package/components/WeekRef.astro +2 -1
  18. package/components/XRef.astro +2 -1
  19. package/dist/components/VersionSelector.d.ts +16 -2
  20. package/dist/components/VersionSelector.mjs +18 -17
  21. package/dist/index.d.ts +24 -6
  22. package/dist/index.mjs +89 -5
  23. package/dist/schemas.d.ts +1 -1
  24. package/dist/schemas.mjs +8 -1
  25. package/dist/{types-CWXP1S4b.d.ts → types-CZrkqzpC.d.ts} +58 -26
  26. package/layouts/Base.astro +20 -19
  27. package/package.json +7 -4
  28. package/pages/answers.astro +2 -1
  29. package/pages/chapters.astro +2 -1
  30. package/pages/exercises.astro +2 -1
  31. package/pages/index.astro +4 -3
  32. package/pages/search.astro +2 -1
  33. package/pages/tips.astro +2 -1
  34. package/recipes/01-add-math.md +40 -28
  35. package/recipes/04-component-library.md +14 -4
  36. package/recipes/05-deploy-cloudflare.md +44 -0
  37. package/recipes/06-mobile-first-layout.md +25 -2
  38. package/recipes/09-validation.md +18 -9
  39. package/recipes/15-defining-styles.md +5 -2
  40. package/recipes/19-prevalidate-hook.md +29 -83
  41. package/scripts/build-bib.mjs +7 -3
  42. package/scripts/build-labels.mjs +33 -16
  43. package/scripts/read-env.mjs +25 -0
  44. package/scripts/resolve-book-config.mjs +96 -0
  45. package/scripts/validate.mjs +125 -84
  46. package/src/lib/define-style.ts +15 -6
  47. package/src/lib/nav-href.ts +24 -2
  48. package/styles/layout.css +14 -8
  49. package/styles/tokens.css +8 -10
package/AGENTS.md ADDED
@@ -0,0 +1,6 @@
1
+ # AGENTS.md
2
+
3
+ The canonical, cross-tool authoring guide is [`CLAUDE.md`](CLAUDE.md) in this
4
+ directory. Read and follow it before changing a book. The guide is
5
+ agent-agnostic despite its filename; this pointer exists so tools that discover
6
+ `AGENTS.md` load the same instructions without maintaining two copies.
package/CLAUDE.md CHANGED
@@ -14,14 +14,22 @@ If the hub isn't available in your environment (e.g. external contributor), the
14
14
 
15
15
  ## Profile
16
16
 
17
- Read `BOOK_PROFILE` from the environment or `.env`. It controls:
17
+ Read `BOOK_PRESET` (preferred) or its `BOOK_PROFILE` compatibility alias from
18
+ the environment or `.env`. It controls:
18
19
 
19
- - Which content-collection schema is enforced (`academic` / `tools` / `minimal`)
20
- - Which markdown integrations run (KaTeX gated on `academic`)
20
+ - Which of the five content-collection schemas is enforced
21
+ - Which markdown integrations run (KaTeX for `academic` and `research-portfolio`)
21
22
  - Which callout family is the "default" import in templates
22
- - Whether ToolFilter / VersionSelector Preact islands mount in the chrome row
23
+ - Whether the ToolFilter Preact island mounts in the automatic chrome row
23
24
 
24
- When in doubt, run `grep BOOK_PROFILE .env astro.config.mjs src/content.config.ts` to see the wiring.
25
+ When in doubt, run `grep -E 'BOOK_(PRESET|PROFILE)' .env astro.config.mjs src/content.config.ts` to see the wiring.
26
+
27
+ `VersionSelector` is different: it is a manual, prop-driven island because only
28
+ the consuming book knows which versions are actually deployed. Import it from
29
+ `@brandon_m_behring/book-scaffold-astro/components/VersionSelector`, pass
30
+ `versions: [{ href, label, date, current? }]`, and hydrate it where your own
31
+ navigation belongs. It renders nothing for an omitted or empty manifest;
32
+ `Base.astro` never invents or auto-mounts version links.
25
33
 
26
34
  ## Frontmatter schemas
27
35
 
@@ -94,9 +102,9 @@ sources:
94
102
 
95
103
  Two callout families coexist. Authors import what they need.
96
104
 
97
- **Tools family** (`src/components/callouts/`, 8 components): `SkillBox`, `CaseStudy`, `ConceptBox`, `KeyIdea`, `TryThis`, `Recovery`, `Convergence`, `Divergence`.
105
+ **Tools family** (8 components, imported from the flat `@brandon_m_behring/book-scaffold-astro/components/<Name>.astro` path): `SkillBox`, `CaseStudy`, `ConceptBox`, `KeyIdea`, `TryThis`, `Recovery`, `Convergence`, `Divergence`.
98
106
 
99
- **Academic family** (`src/components/callouts/`, 10 components): `NoteBox`, `ExampleBox`, `DynConnect`, `InsightBox`, `WarnBox`, `CounterBox`, `TipBox`, `OpenQuestion`, `PaperBox`, `ResultBox`. Plus `Theorem` (unified for theorem/proposition/lemma/corollary/definition/example/exercise/remark/proof). **Props (v4.14.3, #121):** `kind=` is canonical; `type=` is accepted as a legacy alias (likewise `title=`/`label=` alias `name=`). An absent or unknown kind **throws at build** (via `src/lib/theorem-label`) rather than rendering an empty label; `book-scaffold validate` flags a `<Theorem>` with neither `kind=` nor `type=` even earlier. **Numbering (v4.18.0, #126):** a theorem with an `id` auto-numbers from `labels.json` — the same index `<XRef>` reads — so the heading number equals every cross-reference to it by construction; explicit `n=` is a fallback for un-id'd theorems. `build-labels` indexes the kind-accurate word (`Proposition 8.1`, not a kind-blind `Theorem 8.1`) and throws on an unknown kind.
107
+ **Academic family** (10 components, using the same flat import path): `NoteBox`, `ExampleBox`, `DynConnect`, `InsightBox`, `WarnBox`, `CounterBox`, `TipBox`, `OpenQuestion`, `PaperBox`, `ResultBox`. Plus `Theorem` (unified for theorem/proposition/lemma/corollary/definition/example/exercise/remark/proof). **Props (v4.14.3, #121):** `kind=` is canonical; `type=` is accepted as a legacy alias (likewise `title=`/`label=` alias `name=`). An absent or unknown kind **throws at build** (via `src/lib/theorem-label`) rather than rendering an empty label; `book-scaffold validate` flags a `<Theorem>` with neither `kind=` nor `type=` even earlier. **Numbering (v4.18.0+, #126/#175):** a theorem with an `id` auto-numbers from `labels.json` — the same index `<XRef>` reads — so the heading number equals every cross-reference to it by construction; explicit `n=` is a fallback for un-id'd theorems. `build-labels` indexes the kind-accurate word (`Proposition 8.1`, not a kind-blind `Theorem 8.1`) and throws on an unknown kind. The theorem family shares one counter by default; set `numberStyle: 'per-kind'` in `defineBookConfig` or a composed `defineStyle` for independent theorem/proposition/lemma/etc. sequences. A `label=` override stays unnumbered and consumes no counter.
100
108
 
101
109
  **Pedagogy family** (v4.1.0+, any profile, 4 components): `Pitfall` (rose; "common mistake" — distinct from `WarnBox`'s preemptive warning), `WorkedExample` (plum; collapsible `<details>` block with `#worked-example-{id}` anchor for deep links), `YouWillLearn` (gold; chapter-opener with optional `prerequisites` prop), `Diagnostic` (v4.19.0, #110; teal; pre-reading "Do I Know This Already?" DIKTA self-check — a slotted question list + a skip/skim/read routing rubric via `skimTo`, plus an optional collapsible answer key via `slot="answers"`). Slot bullets/code freely; render at any preset.
102
110
 
@@ -162,24 +170,41 @@ npm run build:bib # rebuild references.json after .bib edit
162
170
  npm run pdf # render dist-pdf/book.pdf via Paged.js
163
171
  ```
164
172
 
165
- `prebuild` chains: `build:assets` (bib + figures + notebooks) → `validate` `astro build`.
173
+ Every generated preset uses the same `prevalidate` hook to rebuild applicable
174
+ bibliography and label indexes, while `prebuild` delegates to validation.
175
+ Figure and notebook conversion remain explicit authoring commands because
176
+ their system tools are optional. `prepdf` always runs the full site build
177
+ before previewing `/print/` and rendering `dist-pdf/book.pdf`.
166
178
 
167
179
  ## Deploy
168
180
 
169
- Cloudflare Workers + Static Assets via `wrangler.toml`. Recipe 05 has the dashboard flow. URL after first deploy: `https://<book-name>.<account>.workers.dev`.
181
+ The generated `wrangler.toml` uses Cloudflare Workers + Static Assets for
182
+ academic/tools/minimal presets and Cloudflare Pages for course-notes and
183
+ research-portfolio. Recipe 05 documents both flows.
170
184
 
171
185
  For monorepo Astro projects (Astro project in subdir), prefix build + deploy commands with `cd <subdir> &&`.
172
186
 
187
+ Every build emits an audited Cloudflare-format `dist/_headers` by default
188
+ (HSTS, XCTO, Referrer-Policy, Permissions-Policy, and a CSP compatible with
189
+ the shipped inline UI, Pagefind WASM, Cloudflare analytics, and HTTPS images).
190
+ A consumer `public/_headers` wins unchanged. Use
191
+ `securityHeaders: { contentSecurityPolicy: "..." }` to replace only the CSP,
192
+ or `securityHeaders: false` when another layer owns all headers. Recipe 05 has
193
+ the full precedence and customization contract.
194
+
173
195
  ## Validation
174
196
 
175
197
  `npm run validate` (also runs in prebuild) catches:
176
198
 
177
199
  - Unknown `<Cite key>` (academic) — bibkey not in `references.json`
178
200
  - Unknown `<XRef id>` — id not in `labels.json` (XRef silently renders `[?label]` otherwise)
201
+ - Literal `<Theorem n>` values that disagree with `labels.json` (dynamic expressions and `label=` overrides are skipped)
179
202
  - Missing `<Figure src>` files under `public/`
180
203
  - Internal markdown links that don't resolve
181
204
  - Study-guide questions (v4.17.0, #112) — a question whose frontmatter `domain` isn't in `examDomains`, and duplicate question `id`s
182
205
 
206
+ Missing `src/data/labels.json` or `references.json` self-heals before checks by running the corresponding package build script; an existing artifact is never rewritten implicitly.
207
+
183
208
  See `recipes/09-validation.md` to extend.
184
209
 
185
210
  ## Common authoring tasks
@@ -250,6 +275,6 @@ Types: `feat` / `fix` / `refactor` / `docs` / `test` / `chore` / `release`. One
250
275
 
251
276
  ## Reading this guide didn't help?
252
277
 
253
- - `recipes/README.md` — index of all 11 recipes
278
+ - `recipes/README.md` — index of all recipes
254
279
  - `recipes/08-decisions-ledger.md` — why everything is shaped the way it is
255
280
  - `~/.claude/plans/i-want-to-investigate-recursive-yao.md` — full design discussion
@@ -43,7 +43,7 @@ Component subset table for tools-profile-specific UI (volatility dashboards, con
43
43
  | Volatility badge | `Tag` | `volatility` enum chip in chapter meta |
44
44
  | Practice tag (`\official{}` / `\practitioner{}` / `\convergence{}`, also `\tagofficial{}` / `\tagpractitioner{}` / `\tagconvergence{}`) | `Tag` | `kind="official" \| "practitioner" \| "convergence"` — inline assertion of source authority. Both the bare and `\tag*` prefixed LaTeX forms map to the same component (see `package/components/Tag.astro`). |
45
45
  | Tool comparison | `ToolFilter` (island) | Interactive comparison gate |
46
- | Version selector | `VersionSelector` (island) | Switch between tool versions |
46
+ | Version selector | `VersionSelector` (island) | Manual opt-in; pass real `{ href, label, date, current? }` deployment entries |
47
47
  | Convergence event | `Convergence` | "All tools converged here" timeline marker |
48
48
  | Divergence event | `Divergence` | "Tool X went its own way" annotation |
49
49
  | Pattern timeline | `PatternTimeline` | Multi-event convergence dashboard |
package/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Behring
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This license covers the toolkit's code, configuration, scripts, and tooling.
26
+ Recipe, pedagogy, example, and substantive guide content is licensed
27
+ separately under CC BY 4.0; see LICENSE-CONTENT.
@@ -0,0 +1,19 @@
1
+ Creative Commons Attribution 4.0 International License (CC BY 4.0)
2
+
3
+ Copyright (c) 2026 Brandon Behring
4
+
5
+ This license covers prose and authored content shipped in this package,
6
+ including `recipes/`, `pedagogy/`, `examples/`, and substantive prose in
7
+ README.md, CLAUDE.md, and AGENTS.md. Toolkit code and configuration are
8
+ licensed separately under MIT; see LICENSE.
9
+
10
+ You are free to share and adapt this material for any purpose, including
11
+ commercially, provided that you give appropriate credit, link to the license,
12
+ and indicate whether changes were made. You may not apply additional legal or
13
+ technological restrictions.
14
+
15
+ Full legal code: https://creativecommons.org/licenses/by/4.0/legalcode
16
+ Human-readable summary: https://creativecommons.org/licenses/by/4.0/
17
+
18
+ Suggested attribution: "Based on book-scaffold-astro by Brandon Behring,
19
+ licensed under CC BY 4.0."
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # `@brandon_m_behring/book-scaffold-astro`
2
+
3
+ Astro 6 + MDX infrastructure for long-form technical books: typed presets,
4
+ Tufte-inspired layouts, citations and cross-references, search, print/PDF
5
+ output, study-guide components, and fail-loud authoring checks.
6
+
7
+ Start a book with the lock-step CLI:
8
+
9
+ ```bash
10
+ npx @brandon_m_behring/create-book my-book --preset=academic
11
+ cd my-book
12
+ npm install
13
+ npm run dev
14
+ ```
15
+
16
+ Available presets are `academic`, `tools`, `minimal`, `course-notes`, and
17
+ `research-portfolio`. See the
18
+ [repository README](https://github.com/brandon-behring/book-scaffold-astro)
19
+ for the current release overview, and
20
+ [PACKAGE_DESIGN.md](https://github.com/brandon-behring/book-scaffold-astro/blob/main/PACKAGE_DESIGN.md)
21
+ for the complete API contract.
22
+
23
+ Astro builds emit a Cloudflare-compatible `dist/_headers` with audited
24
+ security defaults. A consumer-owned `public/_headers` wins unchanged;
25
+ `defineBookConfig({ securityHeaders: false })` disables scaffold emission,
26
+ and `securityHeaders.contentSecurityPolicy` replaces only the default CSP.
27
+ See [Recipe 05](./recipes/05-deploy-cloudflare.md#default-security-headers).
28
+
29
+ ## Licensing
30
+
31
+ Code, configuration, and scripts are MIT-licensed. Recipes, pedagogy, examples,
32
+ and substantive documentation are CC BY 4.0. Both scoped license files ship in
33
+ the package as `LICENSE` and `LICENSE-CONTENT`.
@@ -31,6 +31,7 @@ import { getAllQuestions } from '../src/lib/questions';
31
31
  import { assertKnownDomain } from '../src/lib/exam-domains';
32
32
  import { buildExamManifest, deriveDomainRouting } from '../src/lib/exam-manifest';
33
33
  import '../styles/exam-runner.css';
34
+ import { normalizeBase } from '../src/lib/nav-href';
34
35
 
35
36
  interface Props {
36
37
  /** Heading above the test. Defaults to "Assessment test". */
@@ -83,7 +84,7 @@ const scoreable = entries.filter(
83
84
  // #142: thread the deploy base through the routing map + practice-exam link.
84
85
  // deriveDomainRouting takes baseUrl as a param (not import.meta.env) because it
85
86
  // ships pre-compiled in dist/ where Vite's env replacement does not reach.
86
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
87
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
87
88
  const manifest = buildExamManifest(entries);
88
89
  const domainRouting = deriveDomainRouting(entries, baseUrl);
89
90
  const practiceExamHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
@@ -8,7 +8,7 @@
8
8
  * Single-book consumers are byte-identical (defaults → global order + `/chapters/<id>/`).
9
9
  */
10
10
  import { getNeighbors } from '../src/lib/chapters';
11
- import { chapterHref } from '../src/lib/nav-href';
11
+ import { chapterHref, normalizeBase } from '../src/lib/nav-href';
12
12
  import bookConfig from 'virtual:book-scaffold/book-config';
13
13
 
14
14
  interface Props {
@@ -18,7 +18,7 @@ const { currentId } = Astro.props;
18
18
 
19
19
  const chapterRoute = (bookConfig as { chapterRoute?: string }).chapterRoute ?? '/chapters/:id/';
20
20
  const bookField = (bookConfig as { bookField?: string }).bookField ?? 'book';
21
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
21
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
22
22
 
23
23
  const { prev, next } = await getNeighbors(currentId, { bookField });
24
24
  // Resolve hrefs in frontmatter (casts here, never in the JSX expression container).
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { normalizeBase } from '../src/lib/nav-href';
2
3
  /**
3
4
  * Cite — renders a `\cite{bibkey}` LaTeX citation as a clickable
4
5
  * inline reference to the bibliography page.
@@ -45,7 +46,7 @@ interface Props {
45
46
  }
46
47
 
47
48
  const { key, page } = Astro.props;
48
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
49
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
49
50
  const entry = map[key];
50
51
  if (!entry) {
51
52
  throw new Error(
@@ -22,11 +22,11 @@ import { getCollection } from 'astro:content';
22
22
  import bookConfig from 'virtual:book-scaffold/book-config';
23
23
  import { academicParts } from '../src/schemas';
24
24
  import { academicPartHeading } from '../src/lib/academic-parts';
25
- import { chapterHref, apparatusHref, bookOf, isCurrentChapter, type ChapterLike } from '../src/lib/nav-href';
25
+ import { chapterHref, apparatusHref, bookOf, isCurrentChapter, type ChapterLike, normalizeBase } from '../src/lib/nav-href';
26
26
 
27
27
  const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
28
28
  // #142: normalize the base trailing slash (Astro allows base:'/foo' with none).
29
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
29
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
30
30
 
31
31
  // v4.26.0 (#80): nav route config (defaults reproduce single-book behavior).
32
32
  const chapterRoute = (bookConfig as { chapterRoute?: string }).chapterRoute ?? '/chapters/:id/';
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { getCollection } from 'astro:content';
21
21
  import { selectPartExercises } from '../src/lib/part-review';
22
+ import { normalizeBase } from '../src/lib/nav-href';
22
23
 
23
24
  interface Props {
24
25
  /** The part to aggregate. Number (tools/minimal/…) or string (academic enum). */
@@ -27,7 +28,7 @@ interface Props {
27
28
  title?: string;
28
29
  }
29
30
  const { part, title } = Astro.props;
30
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
31
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
31
32
 
32
33
  // Reuse the build-exercises index (keyed by chapter slug). Project-root-relative
33
34
  // glob (the tips.astro / exercises.astro lesson) so it resolves across consumers.
@@ -17,6 +17,7 @@
17
17
  * inside src/content/questions/**.mdx. Slot freely: prose, math, <Cite>, code.
18
18
  */
19
19
  import bookConfig from 'virtual:book-scaffold/book-config';
20
+ import { baseNoSlash } from '../src/lib/nav-href';
20
21
 
21
22
  interface Props {
22
23
  /** Summary label for the reveal. Defaults to "Answer & rationale". */
@@ -48,8 +49,8 @@ if (appendix && !(bookConfig.enabledRoutes ?? []).includes('answers')) {
48
49
  // full rationale must render (the appendix re-renders question bodies via
49
50
  // render()). BASE_URL-prefixed + trailing-slash tolerant; an endsWith()
50
51
  // heuristic would also fire on e.g. a chapter whose slug is "answers".
51
- const basePath = (import.meta.env.BASE_URL ?? '/').replace(/\/+$/, '');
52
- const onAnswersRoute = Astro.url.pathname.replace(/\/+$/, '') === `${basePath}/answers`;
52
+ const basePath = baseNoSlash(import.meta.env.BASE_URL);
53
+ const onAnswersRoute = baseNoSlash(Astro.url.pathname) === `${basePath}/answers`;
53
54
  const asLink = appendix && !onAnswersRoute;
54
55
  ---
55
56
  {asLink ? (
@@ -17,12 +17,13 @@
17
17
  */
18
18
  import bookConfig from 'virtual:book-scaffold/book-config';
19
19
  import NavContent from './NavContent.astro';
20
+ import { normalizeBase } from '../src/lib/nav-href';
20
21
 
21
22
  const siteTitle = bookConfig.title ?? 'Book';
22
23
  const siteSubtitle = bookConfig.subtitle ?? 'A scaffold-astro book';
23
24
  // #142: Astro does not guarantee a trailing slash on `base` — normalize so the
24
25
  // home link is `${base}/`, not a slash-less `/foo`.
25
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/*$/, '/');
26
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
26
27
  ---
27
28
 
28
29
  <aside class="sidebar" aria-label="Chapter navigation">
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { normalizeBase } from '../src/lib/nav-href';
2
3
  /**
3
4
  * Term — inline glossary cross-link (v4.19.0, #115).
4
5
  *
@@ -17,7 +18,7 @@ interface Props {
17
18
  id: string;
18
19
  }
19
20
  const { id } = Astro.props;
20
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
21
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
21
22
  ---
22
23
  <a href={`${baseUrl}glossary#term-${id}`} class="term-link"><slot /></a>
23
24
 
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { normalizeBase } from '../src/lib/nav-href';
2
3
  /**
3
4
  * TipsCard — print-friendly pull-out card listing all numbered tips
4
5
  * (v4.3.0, closes #70).
@@ -20,7 +21,7 @@ const tipsModules = import.meta.glob<{ default: Array<{ n: number; title: string
20
21
  { eager: true },
21
22
  );
22
23
  const tips = tipsModules['/src/data/tips.json']?.default ?? [];
23
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
24
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
24
25
  ---
25
26
  <aside class="tips-card" role="contentinfo">
26
27
  <h2 class="tips-card-title">Tips</h2>
@@ -1,33 +1,30 @@
1
1
  /**
2
2
  * VersionSelector — Preact island for the version dropdown.
3
3
  *
4
- * Stage 0: stub data. The real version list arrives in Stage 3 when
5
- * multi-version CI deploys exist; the component is wired now to prove
6
- * the islands architecture works with hydration, state, and event
7
- * handlers.
8
- *
9
- * Hydrated with `client:idle` from Base.astro — won't run until the
10
- * main thread is idle, keeping initial paint fast.
11
- *
12
- * Stage 3 will replace the STUB_VERSIONS constant with a runtime fetch
13
- * or a build-time-injected prop that lists all deployed versions.
4
+ * Multi-version deployment is owned by the consuming book, so this component
5
+ * accepts its complete version manifest as a prop. It is a manual opt-in and
6
+ * is not mounted by Base.astro. With no manifest it renders nothing rather
7
+ * than presenting package-owned placeholder releases as real navigation.
14
8
  */
15
9
  import { useState, useRef, useEffect } from 'preact/hooks';
16
10
 
17
- type VersionEntry = {
18
- id: string; // URL subpath segment ('' for latest)
19
- label: string; // display label
20
- date: string; // release date
21
- current: boolean; // mark the version being viewed
22
- };
11
+ export interface VersionEntry {
12
+ /** Resolved destination for this deployed version. */
13
+ href: string;
14
+ /** Human-readable release label, for example `v4.27`. */
15
+ label: string;
16
+ /** Human-readable release date. */
17
+ date: string;
18
+ /** Marks the version represented by the current page. */
19
+ current?: boolean;
20
+ }
23
21
 
24
- // Stub data for Stage 0. Stage 3 will inject the real list.
25
- const STUB_VERSIONS: VersionEntry[] = [
26
- { id: '', label: 'Latest (main)', date: '2026-04-17', current: true },
27
- { id: 'v1.0', label: 'v1.0', date: '2026-05-01', current: false },
28
- ];
22
+ export interface VersionSelectorProps {
23
+ /** Consumer-owned deployed-version manifest. Empty/omitted renders nothing. */
24
+ versions?: readonly VersionEntry[];
25
+ }
29
26
 
30
- export default function VersionSelector() {
27
+ export default function VersionSelector({ versions = [] }: VersionSelectorProps) {
31
28
  const [open, setOpen] = useState(false);
32
29
  const ref = useRef<HTMLDivElement>(null);
33
30
 
@@ -50,9 +47,11 @@ export default function VersionSelector() {
50
47
  };
51
48
  }, [open]);
52
49
 
53
- // STUB_VERSIONS is a const literal with >=1 entry so the fallback is
54
- // always defined; the bang silences TS's noUncheckedIndexedAccess.
55
- const current = STUB_VERSIONS.find((v) => v.current) ?? STUB_VERSIONS[0]!;
50
+ if (versions.length === 0) return null;
51
+
52
+ // If the consumer omits `current`, present the first supplied release as
53
+ // the current label. The non-empty guard above makes the fallback defined.
54
+ const current = versions.find((version) => version.current) ?? versions[0]!;
56
55
 
57
56
  return (
58
57
  <div class="version-selector" ref={ref}>
@@ -69,17 +68,21 @@ export default function VersionSelector() {
69
68
  </button>
70
69
  {open && (
71
70
  <ul class="version-selector-menu" role="listbox">
72
- {STUB_VERSIONS.map((v) => (
73
- <li role="option" aria-selected={v.current}>
74
- <a
75
- href={v.id ? `/${v.id}/` : '/'}
76
- class={v.current ? 'version-current' : ''}
77
- >
78
- <span class="version-label">{v.label}</span>
79
- <span class="version-date">{v.date}</span>
80
- </a>
81
- </li>
82
- ))}
71
+ {versions.map((version) => {
72
+ const isCurrent = version === current;
73
+ return (
74
+ <li key={version.href} role="option" aria-selected={isCurrent}>
75
+ <a
76
+ href={version.href}
77
+ class={isCurrent ? 'version-current' : ''}
78
+ aria-current={isCurrent ? 'page' : undefined}
79
+ >
80
+ <span class="version-label">{version.label}</span>
81
+ <span class="version-date">{version.date}</span>
82
+ </a>
83
+ </li>
84
+ );
85
+ })}
83
86
  </ul>
84
87
  )}
85
88
  </div>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { normalizeBase } from '../src/lib/nav-href';
2
3
  /**
3
4
  * WeekRef — inline pointer to another chapter, mapping LaTeX `\weekref{N}{body}`.
4
5
  *
@@ -21,7 +22,7 @@ interface Props {
21
22
  const { week } = Astro.props;
22
23
  const slug = `week${String(week).padStart(2, '0')}`;
23
24
  // #142: prefix BASE_URL so the chapter link stays inside a non-root deploy base.
24
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
25
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
25
26
  const href = `${baseUrl}chapters/${slug}/`;
26
27
  ---
27
28
  <aside class="week-ref">
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { normalizeBase } from '../src/lib/nav-href';
2
3
  // XRef — resolves a `\cref{label}` LaTeX reference to a hyperlink.
3
4
  //
4
5
  // Reads src/data/labels.json built by scripts/build-labels.mjs
@@ -47,7 +48,7 @@ const entry = map[id];
47
48
  // #142: labels.json stores a base-less `chapters/<slug>#<id>` ref; prefix
48
49
  // BASE_URL at render. The leading-slash strip tolerates a labels.json built
49
50
  // by an older (pre-#142) build-labels that still emits a root-absolute href.
50
- const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
51
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
51
52
  ---
52
53
  {entry ? (
53
54
  <a href={`${baseUrl}${entry.href.replace(/^\//, '')}`} class="xref">{entry.display}</a>
@@ -1,5 +1,19 @@
1
1
  import * as preact from 'preact';
2
2
 
3
- declare function VersionSelector(): preact.JSX.Element;
3
+ interface VersionEntry {
4
+ /** Resolved destination for this deployed version. */
5
+ href: string;
6
+ /** Human-readable release label, for example `v4.27`. */
7
+ label: string;
8
+ /** Human-readable release date. */
9
+ date: string;
10
+ /** Marks the version represented by the current page. */
11
+ current?: boolean;
12
+ }
13
+ interface VersionSelectorProps {
14
+ /** Consumer-owned deployed-version manifest. Empty/omitted renders nothing. */
15
+ versions?: readonly VersionEntry[];
16
+ }
17
+ declare function VersionSelector({ versions }: VersionSelectorProps): preact.JSX.Element | null;
4
18
 
5
- export { VersionSelector as default };
19
+ export { type VersionEntry, type VersionSelectorProps, VersionSelector as default };
@@ -1,11 +1,7 @@
1
1
  // components/VersionSelector.tsx
2
2
  import { useState, useRef, useEffect } from "preact/hooks";
3
3
  import { jsx, jsxs } from "preact/jsx-runtime";
4
- var STUB_VERSIONS = [
5
- { id: "", label: "Latest (main)", date: "2026-04-17", current: true },
6
- { id: "v1.0", label: "v1.0", date: "2026-05-01", current: false }
7
- ];
8
- function VersionSelector() {
4
+ function VersionSelector({ versions = [] }) {
9
5
  const [open, setOpen] = useState(false);
10
6
  const ref = useRef(null);
11
7
  useEffect(() => {
@@ -25,7 +21,8 @@ function VersionSelector() {
25
21
  window.removeEventListener("keydown", onKey);
26
22
  };
27
23
  }, [open]);
28
- const current = STUB_VERSIONS.find((v) => v.current) ?? STUB_VERSIONS[0];
24
+ if (versions.length === 0) return null;
25
+ const current = versions.find((version) => version.current) ?? versions[0];
29
26
  return /* @__PURE__ */ jsxs("div", { class: "version-selector", ref, children: [
30
27
  /* @__PURE__ */ jsx(
31
28
  "button",
@@ -40,17 +37,21 @@ function VersionSelector() {
40
37
  children: /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "v" })
41
38
  }
42
39
  ),
43
- open && /* @__PURE__ */ jsx("ul", { class: "version-selector-menu", role: "listbox", children: STUB_VERSIONS.map((v) => /* @__PURE__ */ jsx("li", { role: "option", "aria-selected": v.current, children: /* @__PURE__ */ jsxs(
44
- "a",
45
- {
46
- href: v.id ? `/${v.id}/` : "/",
47
- class: v.current ? "version-current" : "",
48
- children: [
49
- /* @__PURE__ */ jsx("span", { class: "version-label", children: v.label }),
50
- /* @__PURE__ */ jsx("span", { class: "version-date", children: v.date })
51
- ]
52
- }
53
- ) })) })
40
+ open && /* @__PURE__ */ jsx("ul", { class: "version-selector-menu", role: "listbox", children: versions.map((version) => {
41
+ const isCurrent = version === current;
42
+ return /* @__PURE__ */ jsx("li", { role: "option", "aria-selected": isCurrent, children: /* @__PURE__ */ jsxs(
43
+ "a",
44
+ {
45
+ href: version.href,
46
+ class: isCurrent ? "version-current" : "",
47
+ "aria-current": isCurrent ? "page" : void 0,
48
+ children: [
49
+ /* @__PURE__ */ jsx("span", { class: "version-label", children: version.label }),
50
+ /* @__PURE__ */ jsx("span", { class: "version-date", children: version.date })
51
+ ]
52
+ }
53
+ ) }, version.href);
54
+ }) })
54
55
  ] });
55
56
  }
56
57
  export {
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { AstroUserConfig, AstroIntegration, MarkdownHeading } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, m as Style } from './types-CWXP1S4b.js';
3
- export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, P as PartKey, j as PartialRouteToggles, k as ProfileDefinition, R as ReleaseStatusConfig, l as RouteToggles, S as StatusBadge, n as StyleInput, V as VolatilityBadge, o as composeStyles, p as defineProfile, q as defineStyle, r as normalizeFrontmatterConfig, s as resolvePreset, t as resolveProfile } from './types-CWXP1S4b.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, o as Style } from './types-CZrkqzpC.js';
3
+ export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, N as NUMBER_STYLES, j as NumberStyle, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ReleaseStatusConfig, m as RouteToggles, S as SecurityHeadersConfig, n as StatusBadge, p as StyleInput, V as VolatilityBadge, q as composeStyles, r as defineProfile, s as defineStyle, t as normalizeFrontmatterConfig, u as resolvePreset, v as resolveProfile } from './types-CZrkqzpC.js';
4
4
  import { E as volatilityLevels, c as academicParts, Q as Question } from './schemas-CKipJ5Ie.js';
5
5
  export { A as AcademicChapter, B as BloomLevel, C as CourseNotesChapter, G as GlossaryTerm, M as MinimalChapter, P as Provenance, a as QuestionType, R as ResearchPortfolioChapter, T as ToolsChapter, b as academicChapterSchema, d as bloomLevels, e as changeKinds, f as changelogSchema, g as chapterStatus, h as citationBackstops, i as courseNotesChapterSchema, j as glossarySchema, l as layoutModes, m as minimalChapterSchema, p as patternCategories, k as patternsSchema, n as provenanceObject, o as provenanceSchema, q as questionDifficulties, r as questionSchema, s as questionTypes, t as refineQuestion, u as refinedQuestionSchema, v as researchPortfolioChapterSchema, w as sourceTiers, x as sourceTiersResearch, y as sourcesSchema, z as toolSlugs, D as toolsChapterSchema } from './schemas-CKipJ5Ie.js';
6
6
  export { KIND_LABEL, ResolvedTheoremLabel, THEOREM_KINDS, TheoremKind, TheoremLabelProps, resolveTheoremNumber, theoremLabel } from './lib/theorem-label.js';
7
+ export { ssmMacros } from './lib/katex-macros.js';
7
8
  export { D as DomainScore, E as ExamBlueprint, a as ExamQuestion, b as ExamResult, R as RoutingChapter, c as buildExamManifest, d as deriveDomainRouting, s as sampleExam, e as scoreExam, f as shuffle, g as spreadBlueprint } from './exam-manifest-X9IrX1G3.js';
8
9
  export { F as FlashcardRef, b as buildFlashcardDeck } from './flashcards-okekZcl8.js';
9
10
  import 'astro/zod';
@@ -336,6 +337,24 @@ interface ChapterLike {
336
337
  id: string;
337
338
  data: Record<string, unknown>;
338
339
  }
340
+ /**
341
+ * Normalize a base URL to exactly one trailing slash (`''`/`undefined` → `'/'`).
342
+ *
343
+ * v4.27.0 (#182): promoted from this module's private helper to THE shared
344
+ * normalizer — 18 `.astro` files previously inlined the same normalization in
345
+ * three regex idioms. Astro does not guarantee a trailing slash on `base`
346
+ * (`'/foo'` is a documented form), so every `import.meta.env.BASE_URL` read
347
+ * must pass through here before href composition: `${normalizeBase(...)}chapters/`.
348
+ * Takes the base as a PARAMETER because src/lib ships pre-compiled in dist/,
349
+ * where Vite's import.meta.env replacement cannot reach (see exam-manifest.ts).
350
+ */
351
+ declare function normalizeBase(baseUrl: string | undefined): string;
352
+ /**
353
+ * The composing variant (#182): strip ALL trailing slashes (`'/'` → `''`,
354
+ * `'/foo/'` → `'/foo'`) for `${baseNoSlash(...)}/answers`-style templates
355
+ * where the literal supplies the slash (Rationale.astro's route detection).
356
+ */
357
+ declare function baseNoSlash(baseUrl: string | undefined): string;
339
358
  /**
340
359
  * The entry's book name from `data[bookField]`, or `null` when absent/blank.
341
360
  * Single-book schemas (academic/tools/minimal) have no such field → `null`,
@@ -540,11 +559,10 @@ declare const toolsStyle: Style;
540
559
  /** Minimal preset — single-author essays / manifestos. */
541
560
  declare const minimalStyle: Style;
542
561
  /** Course-notes preset — chapters derived from a video course / MOOC / book.
543
- * Content-heavy static sites defaults to Cloudflare Pages deploy. */
562
+ * `deploy` is retained as deprecated metadata only (#180). */
544
563
  declare const courseNotesStyle: Style;
545
564
  /** Research-portfolio preset — academic structure + tools-style provenance + portfolio components.
546
- * Defaults to Pages deploy + frontmatter route enabled (portfolios universally need
547
- * title-page / disclosure / banner pages). */
565
+ * `deploy` is retained as deprecated metadata only (#180); frontmatter routes remain enabled. */
548
566
  declare const researchPortfolioStyle: Style;
549
567
  /**
550
568
  * Registry of all toolkit-shipped styles, keyed by their preset name.
@@ -611,4 +629,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
611
629
  */
612
630
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
613
631
 
614
- export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, type ChapterLike, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, type PartReviewGroup, type PartReviewSelection, Question, type ReviewChapter, type ReviewExercise, Style, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VisibleHeading, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, apparatusHref, assertEnumProp, assertKnownDomain, bookOf, bookScaffoldIntegration, buildGithubUrl, chapterHref, chapterLabel, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, deriveObjectiveMap, distinctChaptersSorted, fallbackChaptersRenderer, freshnessLabel, getFreshness, groupByChapter, groupByDomain, isCurrentChapter, minimalStyle, originUrlFromGitConfig, parseRepoSlug, pickActive, researchPortfolioChaptersRenderer, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, selectPartExercises, slugOf, sortQuestions, tocHeadings, toolsChaptersRenderer, toolsStyle, volatilityLevels };
632
+ export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, type ChapterLike, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, type PartReviewGroup, type PartReviewSelection, Question, type ReviewChapter, type ReviewExercise, Style, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VisibleHeading, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, apparatusHref, assertEnumProp, assertKnownDomain, baseNoSlash, bookOf, bookScaffoldIntegration, buildGithubUrl, chapterHref, chapterLabel, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, deriveObjectiveMap, distinctChaptersSorted, fallbackChaptersRenderer, freshnessLabel, getFreshness, groupByChapter, groupByDomain, isCurrentChapter, minimalStyle, normalizeBase, originUrlFromGitConfig, parseRepoSlug, pickActive, researchPortfolioChaptersRenderer, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, selectPartExercises, slugOf, sortQuestions, tocHeadings, toolsChaptersRenderer, toolsStyle, volatilityLevels };