@brandon_m_behring/book-scaffold-astro 4.25.0 → 4.25.2

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.
@@ -80,10 +80,14 @@ for (const e of entries) {
80
80
  const scoreable = entries.filter(
81
81
  (e) => e.data.type === 'mcq' && (e.data.options?.length ?? 0) > 0,
82
82
  );
83
+ // #142: thread the deploy base through the routing map + practice-exam link.
84
+ // deriveDomainRouting takes baseUrl as a param (not import.meta.env) because it
85
+ // ships pre-compiled in dist/ where Vite's env replacement does not reach.
86
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
83
87
  const manifest = buildExamManifest(entries);
84
- const domainRouting = deriveDomainRouting(entries);
88
+ const domainRouting = deriveDomainRouting(entries, baseUrl);
85
89
  const practiceExamHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
86
- ? '/practice-exam'
90
+ ? `${baseUrl}practice-exam`
87
91
  : null;
88
92
 
89
93
  const rendered = await Promise.all(
@@ -10,7 +10,7 @@ interface Props {
10
10
  }
11
11
  const { currentId } = Astro.props;
12
12
  const { prev, next } = await getNeighbors(currentId);
13
- const baseUrl = import.meta.env.BASE_URL ?? '/';
13
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
14
14
  ---
15
15
  {(prev || next) && (
16
16
  <nav class="chapter-nav" aria-label="Chapter navigation">
@@ -45,7 +45,7 @@ interface Props {
45
45
  }
46
46
 
47
47
  const { key, page } = Astro.props;
48
- const baseUrl = import.meta.env.BASE_URL ?? '/';
48
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
49
49
  const entry = map[key];
50
50
  if (!entry) {
51
51
  throw new Error(
@@ -27,7 +27,7 @@ interface Props {
27
27
  title?: string;
28
28
  }
29
29
  const { part, title } = Astro.props;
30
- const baseUrl = import.meta.env.BASE_URL ?? '/';
30
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
31
31
 
32
32
  // Reuse the build-exercises index (keyed by chapter slug). Project-root-relative
33
33
  // glob (the tips.astro / exercises.astro lesson) so it resolves across consumers.
@@ -28,7 +28,7 @@ import { academicPartHeading } from '../src/lib/academic-parts';
28
28
  const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
29
29
  const siteTitle = bookConfig.title ?? 'Book';
30
30
  const siteSubtitle = bookConfig.subtitle ?? 'A scaffold-astro book';
31
- const baseUrl = import.meta.env.BASE_URL ?? '/';
31
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
32
32
 
33
33
  // Academic profile: part is a string enum. `academicParts` (schemas.ts) is
34
34
  // the canonical order, shared with the renderer and ChapterHeader (#95); the
@@ -17,7 +17,7 @@ interface Props {
17
17
  id: string;
18
18
  }
19
19
  const { id } = Astro.props;
20
- const baseUrl = import.meta.env.BASE_URL ?? '/';
20
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
21
21
  ---
22
22
  <a href={`${baseUrl}glossary#term-${id}`} class="term-link"><slot /></a>
23
23
 
@@ -20,7 +20,7 @@ const tipsModules = import.meta.glob<{ default: Array<{ n: number; title: string
20
20
  { eager: true },
21
21
  );
22
22
  const tips = tipsModules['/src/data/tips.json']?.default ?? [];
23
- const baseUrl = import.meta.env.BASE_URL ?? '/';
23
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
24
24
  ---
25
25
  <aside class="tips-card" role="contentinfo">
26
26
  <h2 class="tips-card-title">Tips</h2>
@@ -20,7 +20,9 @@ interface Props {
20
20
  }
21
21
  const { week } = Astro.props;
22
22
  const slug = `week${String(week).padStart(2, '0')}`;
23
- const href = `/chapters/${slug}/`;
23
+ // #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 href = `${baseUrl}chapters/${slug}/`;
24
26
  ---
25
27
  <aside class="week-ref">
26
28
  <span class="week-ref-arrow" aria-hidden="true">→</span>
@@ -44,9 +44,13 @@ interface Props {
44
44
 
45
45
  const { id } = Astro.props;
46
46
  const entry = map[id];
47
+ // #142: labels.json stores a base-less `chapters/<slug>#<id>` ref; prefix
48
+ // BASE_URL at render. The leading-slash strip tolerates a labels.json built
49
+ // by an older (pre-#142) build-labels that still emits a root-absolute href.
50
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
47
51
  ---
48
52
  {entry ? (
49
- <a href={entry.href} class="xref">{entry.display}</a>
53
+ <a href={`${baseUrl}${entry.href.replace(/^\//, '')}`} class="xref">{entry.display}</a>
50
54
  ) : (
51
55
  <span class="xref xref-unknown" title={`Unknown label: ${id}`}>[?{id}]</span>
52
56
  )}
@@ -1,5 +1,5 @@
1
1
  import * as preact from 'preact';
2
- import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-DnqyzZ9I.js';
2
+ import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-X9IrX1G3.js';
3
3
  import '../schemas-CKipJ5Ie.js';
4
4
  import 'astro/zod';
5
5
 
@@ -115,11 +115,16 @@ interface RoutingChapter {
115
115
  * Derive the weak-domain → chapters routing map for `<AssessmentTest>` (#113):
116
116
  * for each domain, the distinct chapters (in book order) that carry ≥1 question
117
117
  * in it. String chapters are slugs (the schema's kebab-case branch) and link to
118
- * `/chapters/<slug>/`; numeric chapters render as plain labels.
118
+ * `${baseUrl}chapters/<slug>/`; numeric chapters render as plain labels.
119
+ *
120
+ * `baseUrl` (default '/') is the deploy base, injected by the .astro caller from
121
+ * import.meta.env.BASE_URL — NOT read here, because this lib ships pre-compiled
122
+ * in dist/ where Vite's env replacement does not reach, so a self-read would
123
+ * silently drop the base under a non-root deploy (#142).
119
124
  */
120
125
  declare function deriveDomainRouting(entries: readonly (QuestionLike & {
121
126
  data: Pick<Question, 'id' | 'chapter' | 'domain'>;
122
- })[]): Record<string, RoutingChapter[]>;
127
+ })[], baseUrl?: string): Record<string, RoutingChapter[]>;
123
128
  /**
124
129
  * Build the cross-domain blueprint for assessment mode: spread `count` evenly
125
130
  * across the domains present in the pool (floor, minimum 1 per domain), letting
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookP
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 { 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-DnqyzZ9I.js';
7
+ 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
8
  export { F as FlashcardRef, b as buildFlashcardDeck } from './flashcards-okekZcl8.js';
9
9
  import 'astro/zod';
10
10
 
package/dist/index.mjs CHANGED
@@ -1729,7 +1729,8 @@ function buildExamManifest(entries) {
1729
1729
  options: (e.data.options ?? []).map((o) => ({ id: o.id, correct: o.correct === true }))
1730
1730
  }));
1731
1731
  }
1732
- function deriveDomainRouting(entries) {
1732
+ function deriveDomainRouting(entries, baseUrl = "/") {
1733
+ const base = baseUrl.replace(/\/?$/, "/");
1733
1734
  const out = {};
1734
1735
  const seen = /* @__PURE__ */ new Set();
1735
1736
  for (const e of sortQuestions(entries)) {
@@ -1739,7 +1740,7 @@ function deriveDomainRouting(entries) {
1739
1740
  seen.add(key);
1740
1741
  (out[e.data.domain] ??= []).push({
1741
1742
  label,
1742
- href: typeof e.data.chapter === "string" ? `/chapters/${e.data.chapter}/` : null
1743
+ href: typeof e.data.chapter === "string" ? `${base}chapters/${e.data.chapter}/` : null
1743
1744
  });
1744
1745
  }
1745
1746
  return out;
@@ -16,10 +16,14 @@
16
16
  * showSidebar — render the left chapter-navigation sidebar (default: true).
17
17
  * Set false for the landing page or any full-bleed surface.
18
18
  * Below 1024px the sidebar is auto-hidden via CSS regardless.
19
+ * showChrome — render the tools chrome (ToolFilter + VersionSelector)
20
+ * (default: true). Set false on a landing/hub page with no
21
+ * chapters/tools/versions; search + theme toggle always render.
19
22
  *
20
23
  * Tools chrome (ToolFilter, VersionSelector): only mounted when
21
- * BOOK_PROFILE !== 'academic'. Academic-profile books get no JS islands
22
- * in the chrome row (search + theme toggle only).
24
+ * BOOK_PROFILE !== 'academic' AND showChrome !== false. Academic-profile books
25
+ * (and any page with showChrome={false}) get no JS islands in the chrome row
26
+ * (search + theme toggle only).
23
27
  *
24
28
  * Usage:
25
29
  * ---
@@ -61,13 +65,17 @@ import Sidebar from '../components/Sidebar.astro';
61
65
  import bookConfig from 'virtual:book-scaffold/book-config';
62
66
 
63
67
  const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
64
- const showToolsChrome = profile !== 'academic';
65
68
 
66
69
  interface Props {
67
70
  title: string;
68
71
  description?: string;
69
72
  lang?: string;
70
73
  showSidebar?: boolean;
74
+ /** #163: render the optional "tools chrome" — the ToolFilter +
75
+ * VersionSelector islands (default true). Set false on landing/hub pages
76
+ * with no chapters/tools/versions; the search + theme-toggle cluster always
77
+ * renders. (No-op for the academic profile, which never shows them.) */
78
+ showChrome?: boolean;
71
79
  /** v4.6.0: Open Graph image URL (relative to site root, or absolute). */
72
80
  ogImage?: string;
73
81
  /** v4.6.0: Open Graph type. Defaults to 'website'; Chapter.astro passes 'article'. */
@@ -79,10 +87,18 @@ const {
79
87
  description,
80
88
  lang = 'en',
81
89
  showSidebar = true,
90
+ showChrome = true,
82
91
  ogImage,
83
92
  ogType = 'website',
84
93
  } = Astro.props;
85
94
 
95
+ // Tools chrome (ToolFilter + VersionSelector) renders only for non-academic
96
+ // profiles AND when the page opts in via showChrome (default true). A
97
+ // landing/hub page (no chapters/tools/versions) passes showChrome={false} to
98
+ // get the search + theme-toggle cluster only — the chrome-free path that
99
+ // previously forced borrowing the academic profile (and its katex deps). #163.
100
+ const showToolsChrome = (profile !== 'academic') && showChrome;
101
+
86
102
  // v4.6.0: canonical + og:url need Astro.site. defineBookConfig throws at
87
103
  // config-load if `site` is missing, so Astro.site is guaranteed populated
88
104
  // here. `new URL` resolves the relative pathname against it.
@@ -99,7 +115,7 @@ const absoluteOgImage = resolvedOgImage
99
115
  const twitterHandle = bookConfig.seo?.twitterHandle ?? null;
100
116
  const ogSiteName = bookConfig.title ?? title;
101
117
  const ogDescription = description ?? bookConfig.description ?? '';
102
- const baseUrl = import.meta.env.BASE_URL ?? '/';
118
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
103
119
  ---
104
120
 
105
121
  <!doctype html>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.25.0",
4
+ "version": "4.25.2",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -50,13 +50,15 @@ const rendered = await Promise.all(
50
50
 
51
51
  const byChapter = groupByChapter(rendered);
52
52
  const total = rendered.length;
53
+ // #142: practice-exam + chapter links must prefix the deploy base.
54
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
53
55
  const practiceHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
54
- ? '/practice-exam'
56
+ ? `${baseUrl}practice-exam`
55
57
  : null;
56
58
 
57
59
  /** A string chapter ref is a slug (schema kebab-case branch) → linkable. */
58
60
  function chapterHref(chapter: number | string): string | null {
59
- return typeof chapter === 'string' ? `/chapters/${chapter}/` : null;
61
+ return typeof chapter === 'string' ? `${baseUrl}chapters/${chapter}/` : null;
60
62
  }
61
63
  ---
62
64
  <Base
@@ -27,6 +27,9 @@ import { PROFILES } from '../src/profiles/index';
27
27
  import { fallbackChaptersRenderer } from '../src/profiles/renderers/fallback-chapters';
28
28
  import type { ChaptersRenderer, PartKey } from '../src/lib/chapters-renderer';
29
29
 
30
+ // #142: prefix BASE_URL so chapter-card links stay inside a non-root deploy base.
31
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
32
+
30
33
  const profileName = (import.meta.env.BOOK_PROFILE ?? 'minimal') as keyof typeof PROFILES;
31
34
  const profileDef = PROFILES[profileName];
32
35
  const renderer: ChaptersRenderer =
@@ -112,7 +115,7 @@ for (const c of chapters) {
112
115
  <ol class="chapter-list">
113
116
  {cards.map((card) => (
114
117
  <li class="chapter-card" data-tools={card.toolsAttr}>
115
- <a href={`/chapters/${card.c.id}/`} class="chapter-card-link">
118
+ <a href={`${baseUrl}chapters/${card.c.id}/`} class="chapter-card-link">
116
119
  <div class="chapter-card-meta">
117
120
  <span class="chapter-card-number">{card.number}</span>
118
121
  {card.volatility && (
@@ -26,6 +26,9 @@ const loadError = exEntry
26
26
 
27
27
  const chapters = Object.keys(byChapter).sort();
28
28
  const total = chapters.reduce((sum, c) => sum + byChapter[c].length, 0);
29
+
30
+ // #142: chapter deep-links must prefix the deploy base.
31
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
29
32
  ---
30
33
  <Base title="Exercises" description="All exercises in this book, grouped by chapter with deep links.">
31
34
  <article class="prose">
@@ -47,12 +50,12 @@ const total = chapters.reduce((sum, c) => sum + byChapter[c].length, 0);
47
50
  {chapters.map((chapter) => (
48
51
  <section class="exercises-chapter" id={`chapter-${chapter}`}>
49
52
  <h2 class="exercises-chapter-title">
50
- Chapter: <a href={`/chapters/${chapter}/`}>{chapter}</a>
53
+ Chapter: <a href={`${baseUrl}chapters/${chapter}/`}>{chapter}</a>
51
54
  </h2>
52
55
  <ol class="exercises-list">
53
56
  {byChapter[chapter].map((ex) => (
54
57
  <li class="exercises-item">
55
- <a href={`/chapters/${chapter}/#exercise-${ex.id}`} class="exercises-link">
58
+ <a href={`${baseUrl}chapters/${chapter}/#exercise-${ex.id}`} class="exercises-link">
56
59
  <strong class="exercises-id">Exercise {ex.id}</strong>
57
60
  <span class="exercises-preview">{ex.problem.slice(0, 120)}{ex.problem.length > 120 ? '…' : ''}</span>
58
61
  </a>
package/pages/index.astro CHANGED
@@ -40,17 +40,21 @@ const description = bookConfig.description;
40
40
  const portfolio = bookConfig.portfolio;
41
41
  const enabledRoutes = bookConfig.enabledRoutes;
42
42
 
43
+ // Astro guarantees BASE_URL carries a trailing slash ('/' or '/foo/'), so
44
+ // `${baseUrl}chapters/` resolves correctly under any deploy base (#142).
45
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
46
+
43
47
  // Map from internal route name → display label + URL. Only routes that
44
48
  // produce a single landing-list entry are listed here (frontmatter is a
45
49
  // slug pattern; chaptersSlug is dynamic; landing IS this page).
46
50
  const ROUTE_LABELS: Record<string, { label: string; href: string }> = {
47
- chapters: { label: 'Chapters', href: '/chapters/' },
48
- search: { label: 'Search', href: '/search/' },
49
- references: { label: 'References', href: '/references/' },
50
- print: { label: 'Print view', href: '/print/' },
51
- convergence: { label: 'Convergence', href: '/convergence/' },
52
- tips: { label: 'Tips', href: '/tips/' },
53
- exercises: { label: 'Exercises', href: '/exercises/' },
51
+ chapters: { label: 'Chapters', href: `${baseUrl}chapters/` },
52
+ search: { label: 'Search', href: `${baseUrl}search/` },
53
+ references: { label: 'References', href: `${baseUrl}references/` },
54
+ print: { label: 'Print view', href: `${baseUrl}print/` },
55
+ convergence: { label: 'Convergence', href: `${baseUrl}convergence/` },
56
+ tips: { label: 'Tips', href: `${baseUrl}tips/` },
57
+ exercises: { label: 'Exercises', href: `${baseUrl}exercises/` },
54
58
  };
55
59
 
56
60
  const visibleRoutes = enabledRoutes
@@ -11,13 +11,17 @@
11
11
  * `astro dev`. See README for dev-search workflow.
12
12
  */
13
13
  import Base from '../layouts/Base.astro';
14
+
15
+ // #142: Pagefind writes its bundle to dist/pagefind/, served under the deploy
16
+ // base — so the asset URLs must prefix BASE_URL or they 404 under a non-root base.
17
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
14
18
  ---
15
19
 
16
20
  <Base
17
21
  title="Search · book-template-astro"
18
22
  description="Full-text search across the book. Indexed via Pagefind at build time; all queries run in the browser."
19
23
  >
20
- <link rel="stylesheet" href="/pagefind/pagefind-ui.css" slot="head" />
24
+ <link rel="stylesheet" href={`${baseUrl}pagefind/pagefind-ui.css`} slot="head" />
21
25
 
22
26
  <div class="prose search-page">
23
27
  <h1>Search</h1>
@@ -29,7 +33,7 @@ import Base from '../layouts/Base.astro';
29
33
  <div id="search"></div>
30
34
  </div>
31
35
 
32
- <script src="/pagefind/pagefind-ui.js" is:inline></script>
36
+ <script src={`${baseUrl}pagefind/pagefind-ui.js`} is:inline></script>
33
37
  <script is:inline>
34
38
  // Initialize after the Pagefind bundle has loaded.
35
39
  window.addEventListener('DOMContentLoaded', () => {
package/pages/tips.astro CHANGED
@@ -25,6 +25,9 @@ const tips = tipsEntry?.default ?? [];
25
25
  const loadError = tipsEntry
26
26
  ? null
27
27
  : 'src/data/tips.json not found — run `npx book-scaffold build-tips` to generate.';
28
+
29
+ // #142: chapter back-links must prefix the deploy base.
30
+ const baseUrl = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
28
31
  ---
29
32
  <Base title="Tips" description="Numbered tips from this book, drawn from <Tip> instances in chapters.">
30
33
  <article class="prose">
@@ -47,7 +50,7 @@ const loadError = tipsEntry
47
50
  <p class="tips-index-preview">{tip.preview}</p>
48
51
  )}
49
52
  <p class="tips-index-meta">
50
- From <a href={`/chapters/${tip.chapter}/`}>{tip.chapter}</a>
53
+ From <a href={`${baseUrl}chapters/${tip.chapter}/`}>{tip.chapter}</a>
51
54
  </p>
52
55
  </li>
53
56
  ))}
@@ -153,7 +153,7 @@ TLS provides confidentiality and integrity via the negotiated cipher suite… <C
153
153
 
154
154
  - **`/flashcards` route** (v4.22.0, #116; `routes: { flashcards: true }`) — Sybex-style electronic flashcards generated from the glossary collection (front = term, back = the rendered MDX definition). The island shuffles a deck, shows one card at a time (recall first — the back hides until Flip), sorts cards into knew-it/still-learning buckets persisted to localStorage (`book:flashcards:known`), and offers a "review unknown only" pass. Appendix-style surface, deliberately not inline (Bjork: recall practice, not re-reading). No JS → the full front+back list stays readable. Question/objective-derived cards are a later increment.
155
155
 
156
- The `/practice-exam` route renders the bank grouped by domain with each answer behind a "Show answer" reveal — and (v4.21.0, #112-UI) mounts the **ExamRunner island** (`client:idle`): Start samples a form client-side (pure `sampleExam`), hides the rest of the bank, hides answer reveals while the exam is active, scores checked radio options on submit (`scoreExam`), and renders a per-domain readout with weak-domain anchors. No JS → the static bank with radios + reveals is the fallback. The `/answers` route (v4.21.0, #114; `routes: { answers: true }`) is the back-appendix: every question grouped by chapter with options, the correct answer, and rationales pre-expanded. `cloze` questions are reserved (schema-accepted, render-deferred). (`<Diagnostic>` #110, `<PartReview>` #111, and `/glossary` #115 shipped in v4.19.0; the interactive layer #112-UI/#113/#114 in v4.21.0; flashcards #116 in v4.22.0 — completing epic #122. History: `docs/plans/active/study-guide-epic_*.md`.)
156
+ The `/practice-exam` route renders the bank grouped by domain with each answer behind a "Show answer" reveal — and (v4.21.0, #112-UI) mounts the **ExamRunner island** (`client:idle`): Start samples a form client-side (pure `sampleExam`), hides the rest of the bank, hides answer reveals while the exam is active, scores checked radio options on submit (`scoreExam`), and renders a per-domain readout with weak-domain anchors. No JS → the static bank with radios + reveals is the fallback. The `/answers` route (v4.21.0, #114; `routes: { answers: true }`) is the back-appendix: every question grouped by chapter with options, the correct answer, and rationales pre-expanded. `cloze` questions are reserved (schema-accepted, render-deferred). (`<Diagnostic>` #110, `<PartReview>` #111, and `/glossary` #115 shipped in v4.19.0; the interactive layer #112-UI/#113/#114 in v4.21.0; flashcards #116 in v4.22.0 — completing epic #122. History: `docs/plans/implemented/study-guide-epic_*.md`.)
157
157
 
158
158
  ## Mixing families
159
159
 
@@ -152,7 +152,7 @@ if (!byBook.has(book)) byBook.set(book, []);
152
152
  byBook.get(book).push({ ... });
153
153
  ```
154
154
 
155
- Then write one file per book key. Multi-book corpus routing is itself out of scope at v4.x ([deferred, see #15](../../PACKAGE_DESIGN.md#15a-deferred-scope-post-v4x)) — if you need it, the same consumer-side pattern applies.
155
+ Then write one file per book key. Multi-book corpus routing is itself out of scope at v4.x ([deferred, see §15a](../../PACKAGE_DESIGN.md#15a-deferred-scope-post-v4x); tracked on #80) — if you need it, the same consumer-side pattern applies.
156
156
 
157
157
  ## Common gotchas
158
158
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Profile**: any (proven on research-portfolio).
4
4
 
5
- **TL;DR**: To host more than one guide/book in a single Astro app, keep ONE `chapters` collection rooted at `src/content/` and namespace every entry id by its guide folder via a `generateId` in the glob loader. The scaffold's existing rest-param route (`/chapters/[...slug]/`) then serves `/chapters/<guide>/<slug>/` for every guide with **no scaffold changes**. This is the blessed interim pattern until multibook (#15) ships first-class support.
5
+ **TL;DR**: To host more than one guide/book in a single Astro app, keep ONE `chapters` collection rooted at `src/content/` and namespace every entry id by its guide folder via a `generateId` in the glob loader. The scaffold's existing rest-param route (`/chapters/[...slug]/`) then serves `/chapters/<guide>/<slug>/` for every guide with **no scaffold changes**. This is the blessed interim pattern until multibook (#80) ships first-class support.
6
6
 
7
7
  ## The pattern
8
8
 
@@ -52,15 +52,15 @@ The scaffold's auto-injected `pages/chapters/[...slug].astro` routes on `params:
52
52
 
53
53
  ## Gotcha 2 — `validate` counts everything under the base
54
54
 
55
- With `base: './src/content'`, `book-scaffold validate` walks the whole base — shared folders like `frontmatter/` are counted as "chapters" (e.g. `16 chapter(s)` for 13 + 2 real chapters + an authors page). The checks still run correctly per file; only the count is inflated. Excluding non-guide folders from the *loader* pattern does not affect the validator's walk. Guide-aware validation is part of the first-class multibook design (#15).
55
+ With `base: './src/content'`, `book-scaffold validate` walks the whole base — shared folders like `frontmatter/` are counted as "chapters" (e.g. `16 chapter(s)` for 13 + 2 real chapters + an authors page). The checks still run correctly per file; only the count is inflated. Excluding non-guide folders from the *loader* pattern does not affect the validator's walk. Guide-aware validation is part of the first-class multibook design (#80).
56
56
 
57
57
  ## Gotcha 3 — the `/chapters/` index mixes guides
58
58
 
59
- The auto-injected `/chapters/` index lists every entry in the collection — i.e. all guides interleaved. If you want per-guide landing pages, add consumer-owned pages (e.g. `src/pages/evaluation/index.astro`) that `getCollection('chapters', (e) => e.id.startsWith('evaluation/'))`. A grouped-by-guide index is also on the #15 wishlist.
59
+ The auto-injected `/chapters/` index lists every entry in the collection — i.e. all guides interleaved. If you want per-guide landing pages, add consumer-owned pages (e.g. `src/pages/evaluation/index.astro`) that `getCollection('chapters', (e) => e.id.startsWith('evaluation/'))`. A grouped-by-guide index is also on the #80 wishlist.
60
60
 
61
61
  ## When to expect first-class support
62
62
 
63
- This recipe is the interim, zero-scaffold-change path. Issue [#15 (multibook)](https://github.com/brandon-behring/book-scaffold-astro/issues/15) tracks a `books`/`guides` registry that would emit per-guide indexes, a guides landing, and guide-scoped `validate`. It was deferred pending 2–3 independent consumers; `guides-ai-engineering` (#132) is the second — file your use case on #15 to add weight.
63
+ This recipe is the interim, zero-scaffold-change path. Issue [#80 (multibook)](https://github.com/brandon-behring/book-scaffold-astro/issues/80) tracks a `books`/`guides` registry that would emit per-guide indexes, a guides landing, and guide-scoped `validate` (the original #15 is closed; #80 is the live v5.x tracker). It was deferred pending 2–3 independent consumers; `guides-ai-engineering` (#132) is the second — file your use case on #80 to add weight.
64
64
 
65
65
  ## Canonical files
66
66
 
package/recipes/README.md CHANGED
@@ -26,7 +26,7 @@ Terse pointers into canonical code for the most common book-authoring workflows.
26
26
  | 18 | [Chapter route ownership](18-chapter-route-ownership.md) | any | When to override the auto-injected `/chapters/[...slug]/` route |
27
27
  | 19 | [Prevalidate hook](19-prevalidate-hook.md) | any | Wire `prevalidate` to run `build:bib` + `build:labels` before `validate` |
28
28
  | 20 | [Anki deck export (consumer-side)](20-anki-export.md) | any (esp. course-notes, research-portfolio) | Roll-your-own `<AnkiCard>` + extractor; scaffold deliberately doesn't ship this |
29
- | 21 | [Multiple guides in one app](21-multi-guide-single-app.md) | any | `generateId` namespacing over one collection; flat-slug footgun; interim until multibook (#15) |
29
+ | 21 | [Multiple guides in one app](21-multi-guide-single-app.md) | any | `generateId` namespacing over one collection; flat-slug footgun; interim until multibook (#80) |
30
30
 
31
31
  ## How to read recipes
32
32
 
@@ -259,7 +259,9 @@ async function main() {
259
259
  );
260
260
  }
261
261
  labels[id] = {
262
- href: `/chapters/${slug}#${id}`,
262
+ // #142: base-less ref — XRef.astro prefixes BASE_URL at render so one
263
+ // labels.json serves any deploy base (root or path-proxied series).
264
+ href: `chapters/${slug}#${id}`,
263
265
  display,
264
266
  number: labelOverride ? null : number,
265
267
  };
@@ -49,11 +49,20 @@ export interface RoutingChapter {
49
49
  * Derive the weak-domain → chapters routing map for `<AssessmentTest>` (#113):
50
50
  * for each domain, the distinct chapters (in book order) that carry ≥1 question
51
51
  * in it. String chapters are slugs (the schema's kebab-case branch) and link to
52
- * `/chapters/<slug>/`; numeric chapters render as plain labels.
52
+ * `${baseUrl}chapters/<slug>/`; numeric chapters render as plain labels.
53
+ *
54
+ * `baseUrl` (default '/') is the deploy base, injected by the .astro caller from
55
+ * import.meta.env.BASE_URL — NOT read here, because this lib ships pre-compiled
56
+ * in dist/ where Vite's env replacement does not reach, so a self-read would
57
+ * silently drop the base under a non-root deploy (#142).
53
58
  */
54
59
  export function deriveDomainRouting(
55
60
  entries: readonly (QuestionLike & { data: Pick<Question, 'id' | 'chapter' | 'domain'> })[],
61
+ baseUrl = '/',
56
62
  ): Record<string, RoutingChapter[]> {
63
+ // #142: normalize so a no-trailing-slash base ('/foo') still yields '/foo/chapters/'
64
+ // rather than '/foochapters/' (Astro does NOT guarantee a trailing slash on base).
65
+ const base = baseUrl.replace(/\/?$/, '/');
57
66
  const out: Record<string, RoutingChapter[]> = {};
58
67
  const seen = new Set<string>(); // domain\u0000label pairs already routed
59
68
  for (const e of sortQuestions(entries)) {
@@ -63,7 +72,7 @@ export function deriveDomainRouting(
63
72
  seen.add(key);
64
73
  (out[e.data.domain] ??= []).push({
65
74
  label,
66
- href: typeof e.data.chapter === 'string' ? `/chapters/${e.data.chapter}/` : null,
75
+ href: typeof e.data.chapter === 'string' ? `${base}chapters/${e.data.chapter}/` : null,
67
76
  });
68
77
  }
69
78
  return out;
@@ -230,3 +230,18 @@
230
230
  }
231
231
  .chapter-nav .next { grid-column: 1; text-align: left; }
232
232
  }
233
+
234
+ /* Display math (KaTeX) — scroll a too-wide equation WITHIN its own block instead
235
+ of forcing a horizontal scroll on the whole page. KaTeX sets no `overflow` on
236
+ `.katex-display`, so it defaults to `visible`; a wide equation (long
237
+ derivations, big matrices) then overflows the viewport and the page scrolls
238
+ sideways on mobile. WCAG 1.4.10 Reflow. See book-scaffold-astro#162.
239
+ overflow-y is deliberately NOT set: once overflow-x is non-visible the CSS
240
+ overflow rule already computes overflow-y to `auto`, so there is no spurious
241
+ vertical scrollbar — and an explicit `hidden` would silently clip tall
242
+ equations if a consumer ever constrains .katex-display's height. */
243
+ .katex-display {
244
+ overflow-x: auto;
245
+ /* a little room so the horizontal scrollbar doesn't clip the equation's descenders */
246
+ padding-block-end: 0.25rem;
247
+ }