@brandon_m_behring/book-scaffold-astro 4.30.0 → 5.0.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 (90) hide show
  1. package/CLAUDE.md +40 -3
  2. package/MIGRATION-v4-to-v5.md +183 -0
  3. package/README.md +30 -1
  4. package/bin/book-scaffold.mjs +1 -1
  5. package/components/AssessmentTest.astro +26 -5
  6. package/components/BookLink.astro +6 -3
  7. package/components/ChapterNav.astro +1 -1
  8. package/components/Cite.astro +37 -4
  9. package/components/ExerciseSolutions.astro +24 -4
  10. package/components/Figure.astro +7 -6
  11. package/components/Flashcards.tsx +19 -11
  12. package/components/NavContent.astro +60 -11
  13. package/components/ObjectiveMap.astro +14 -2
  14. package/components/PartReview.astro +32 -4
  15. package/components/PatternTimeline.astro +6 -2
  16. package/components/Rationale.astro +20 -3
  17. package/components/Sidebar.astro +11 -3
  18. package/components/SourceArchive.astro +33 -3
  19. package/components/Term.astro +18 -1
  20. package/components/Theorem.astro +15 -5
  21. package/components/TipsCard.astro +40 -3
  22. package/components/XRef.astro +14 -2
  23. package/dist/components/ExamRunner.d.ts +1 -1
  24. package/dist/components/Flashcards.d.ts +6 -1
  25. package/dist/components/Flashcards.mjs +14 -11
  26. package/dist/{exam-manifest-X9IrX1G3.d.ts → exam-manifest-DttY7kyZ.d.ts} +1 -1
  27. package/dist/index.d.ts +76 -8
  28. package/dist/index.mjs +517 -43
  29. package/dist/schemas.d.ts +1 -1
  30. package/dist/schemas.mjs +311 -32
  31. package/dist/{types-DgSlAew3.d.ts → types-D1QZgKMO.d.ts} +73 -21
  32. package/layouts/Base.astro +38 -9
  33. package/layouts/Chapter.astro +10 -2
  34. package/package.json +6 -2
  35. package/pages/answers.astro +25 -6
  36. package/pages/book.astro +75 -0
  37. package/pages/chapters/[...slug].astro +39 -7
  38. package/pages/chapters-book.astro +17 -0
  39. package/pages/chapters.astro +87 -9
  40. package/pages/convergence.astro +40 -10
  41. package/pages/corpus-apparatus/answers.astro +15 -0
  42. package/pages/corpus-apparatus/convergence.astro +15 -0
  43. package/pages/corpus-apparatus/exercises.astro +15 -0
  44. package/pages/corpus-apparatus/flashcards.astro +15 -0
  45. package/pages/corpus-apparatus/glossary.astro +15 -0
  46. package/pages/corpus-apparatus/practice-exam.astro +15 -0
  47. package/pages/corpus-apparatus/print.astro +15 -0
  48. package/pages/corpus-apparatus/references.astro +15 -0
  49. package/pages/corpus-apparatus/tips.astro +15 -0
  50. package/pages/exercises.astro +18 -5
  51. package/pages/flashcards.astro +26 -5
  52. package/pages/glossary.astro +20 -3
  53. package/pages/index.astro +54 -1
  54. package/pages/practice-exam.astro +8 -2
  55. package/pages/print.astro +7 -2
  56. package/pages/references.astro +26 -6
  57. package/pages/search.astro +65 -4
  58. package/pages/tips.astro +17 -4
  59. package/recipes/03-asset-pipelines.md +12 -4
  60. package/recipes/09-validation.md +1 -1
  61. package/recipes/15-defining-styles.md +6 -6
  62. package/recipes/16-tikz-figures.md +13 -2
  63. package/recipes/20-anki-export.md +15 -8
  64. package/recipes/21-multi-guide-single-app.md +293 -44
  65. package/recipes/22-responsive-nav-and-multibook-routing.md +7 -1
  66. package/recipes/24-figure-authoring-standard.md +241 -0
  67. package/recipes/README.md +3 -2
  68. package/scripts/build-bib.mjs +113 -35
  69. package/scripts/build-exercises.mjs +101 -24
  70. package/scripts/build-figures.mjs +13 -10
  71. package/scripts/build-labels.mjs +199 -194
  72. package/scripts/build-tips.mjs +95 -23
  73. package/scripts/corpus-tooling.mjs +268 -0
  74. package/scripts/render-notebooks.mjs +2 -1
  75. package/scripts/resolve-book-config.mjs +99 -1
  76. package/scripts/sync-figure-tokens.mjs +44 -0
  77. package/scripts/validate.mjs +676 -100
  78. package/scripts/walk-mdx.mjs +16 -4
  79. package/src/lib/book-link.ts +72 -0
  80. package/src/lib/chapters.ts +10 -4
  81. package/src/lib/corpus-collateral.ts +9 -0
  82. package/src/lib/corpus.ts +458 -0
  83. package/src/lib/define-style.ts +28 -15
  84. package/src/lib/exam-manifest.ts +4 -1
  85. package/src/lib/figure-palette.mjs +162 -0
  86. package/src/lib/figure.mjs +113 -37
  87. package/src/lib/patterns.ts +15 -6
  88. package/src/lib/questions.ts +8 -3
  89. package/src/types.ts +603 -0
  90. package/styles/tokens.css +73 -9
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import ConvergencePage from '../convergence.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'convergence', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <ConvergencePage bookId={bookId} />
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import ExercisesPage from '../exercises.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'exercises', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <ExercisesPage bookId={bookId} />
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import FlashcardsPage from '../flashcards.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'flashcards', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <FlashcardsPage bookId={bookId} />
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import GlossaryPage from '../glossary.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'glossary', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <GlossaryPage bookId={bookId} />
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import PracticeExamPage from '../practice-exam.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'practice-exam', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <PracticeExamPage bookId={bookId} />
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import PrintPage from '../print.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'print', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <PrintPage bookId={bookId} />
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import ReferencesPage from '../references.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'references', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <ReferencesPage bookId={bookId} />
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import TipsPage from '../tips.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'tips', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <TipsPage bookId={bookId} />
@@ -11,16 +11,29 @@
11
11
  */
12
12
  import Base from '../layouts/Base.astro';
13
13
  import { normalizeBase } from '../src/lib/nav-href';
14
+ import bookConfig from 'virtual:book-scaffold/book-config';
15
+ import { selectBookArtifact } from '../src/lib/corpus';
16
+
17
+ interface Props { bookId?: string; }
18
+ const { bookId } = Astro.props;
14
19
 
15
20
  // v4.4.0 fix: use Vite's import.meta.glob with a project-root-relative
16
21
  // path (same lesson as tips.astro). `/src/data/...` is consumer-project-
17
22
  // rooted in Vite's resolver and works across all consumer build contexts.
18
- const exModules = import.meta.glob<{ default: Record<string, Array<{ id: string; problem: string }>> }>(
23
+ type ExerciseIndex = Record<string, Array<{ id: string; problem: string }>>;
24
+ const exModules = import.meta.glob<{ default: unknown }>(
19
25
  '/src/data/exercises.json',
20
26
  { eager: true },
21
27
  );
22
28
  const exEntry = exModules['/src/data/exercises.json'];
23
- const byChapter = exEntry?.default ?? {};
29
+ const byChapter = exEntry
30
+ ? selectBookArtifact<ExerciseIndex>(
31
+ exEntry.default,
32
+ bookConfig.corpus,
33
+ bookId,
34
+ 'src/data/exercises.json',
35
+ )
36
+ : {};
24
37
  const loadError = exEntry
25
38
  ? null
26
39
  : 'src/data/exercises.json not found — run `npx book-scaffold build-exercises` to generate.';
@@ -31,7 +44,7 @@ const total = chapters.reduce((sum, c) => sum + byChapter[c].length, 0);
31
44
  // #142: chapter deep-links must prefix the deploy base.
32
45
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
33
46
  ---
34
- <Base title="Exercises" description="All exercises in this book, grouped by chapter with deep links.">
47
+ <Base title="Exercises" description="All exercises in this book, grouped by chapter with deep links." bookId={bookId}>
35
48
  <article class="prose">
36
49
  <h1>Exercises</h1>
37
50
  {loadError && (
@@ -51,12 +64,12 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
51
64
  {chapters.map((chapter) => (
52
65
  <section class="exercises-chapter" id={`chapter-${chapter}`}>
53
66
  <h2 class="exercises-chapter-title">
54
- Chapter: <a href={`${baseUrl}chapters/${chapter}/`}>{chapter}</a>
67
+ Chapter: <a href={`${baseUrl}chapters/${bookId ? `${bookId}/` : ''}${chapter}/`}>{chapter}</a>
55
68
  </h2>
56
69
  <ol class="exercises-list">
57
70
  {byChapter[chapter].map((ex) => (
58
71
  <li class="exercises-item">
59
- <a href={`${baseUrl}chapters/${chapter}/#exercise-${ex.id}`} class="exercises-link">
72
+ <a href={`${baseUrl}chapters/${bookId ? `${bookId}/` : ''}${chapter}/#exercise-${ex.id}`} class="exercises-link">
60
73
  <strong class="exercises-id">Exercise {ex.id}</strong>
61
74
  <span class="exercises-preview">{ex.problem.slice(0, 120)}{ex.problem.length > 120 ? '…' : ''}</span>
62
75
  </a>
@@ -24,30 +24,47 @@ import Base from '../layouts/Base.astro';
24
24
  import { getCollection, render } from 'astro:content';
25
25
  import Flashcards from '@brandon_m_behring/book-scaffold-astro/components/Flashcards';
26
26
  import { buildFlashcardDeck } from '../src/lib/flashcards';
27
+ import bookConfig from 'virtual:book-scaffold/book-config';
28
+ import { localCorpusEntryId } from '../src/lib/corpus';
27
29
  import '../styles/flashcards.css';
28
30
 
31
+ interface Props { bookId?: string; }
32
+ const { bookId } = Astro.props;
33
+
29
34
  // Presence-gate: only touch the collection when the directory holds files.
30
35
  const glossModules = import.meta.glob('/src/content/glossary/**/*.{md,mdx}', {
31
36
  query: '?raw',
32
37
  import: 'default',
33
38
  eager: true,
34
39
  });
35
- const hasGlossary = Object.keys(glossModules).length > 0;
40
+ const hasGlossary = bookId
41
+ ? Object.keys(glossModules).some((path) => path.includes(`/glossary/${bookId}/`))
42
+ : Object.keys(glossModules).length > 0;
36
43
 
37
- const entries = hasGlossary ? await getCollection('glossary', (e) => !e.data.draft) : [];
38
- const deck = buildFlashcardDeck(entries);
44
+ const allEntries = hasGlossary ? await getCollection('glossary', (e) => !e.data.draft) : [];
45
+ const entries = bookId
46
+ ? allEntries.filter((entry) => entry.id.startsWith(`${bookId}/`))
47
+ : allEntries;
48
+ const localId = (id: string) => bookId && bookConfig.corpus
49
+ ? localCorpusEntryId(bookConfig.corpus, bookId, id)
50
+ : id;
51
+ const deck = buildFlashcardDeck(entries.map((entry) => ({ ...entry, id: localId(entry.id) })));
39
52
  // Render definitions in deck (alphabetical) order so cards and manifest agree.
40
- const byId = new Map(entries.map((e) => [e.id, e]));
53
+ const byId = new Map(entries.map((e) => [localId(e.id), e]));
41
54
  const rendered = await Promise.all(
42
55
  deck.map(async (card) => {
43
56
  const entry = byId.get(card.id)!;
44
57
  return { ...card, Content: (await render(entry)).Content };
45
58
  }),
46
59
  );
60
+ const storageKey = bookId
61
+ ? `book:${import.meta.env.BASE_URL}:flashcards:${bookId}:known`
62
+ : undefined;
47
63
  ---
48
64
  <Base
49
65
  title="Flashcards"
50
66
  description="Glossary flashcards: shuffled recall practice with flip-to-check and known/still-learning buckets."
67
+ bookId={bookId}
51
68
  >
52
69
  <article class="prose flashcards-page" data-flashcards-root>
53
70
  <h1>Flashcards</h1>
@@ -65,7 +82,11 @@ const rendered = await Promise.all(
65
82
  {/* Without JS the island's deck controls would render dead — hide
66
83
  them; the full card list below stays readable. */}
67
84
  <noscript><style is:inline>.flashcards-runner { display: none; }</style></noscript>
68
- <Flashcards client:idle deck={deck.map(({ id, front }) => ({ id, front }))} />
85
+ <Flashcards
86
+ client:idle
87
+ deck={deck.map(({ id, front }) => ({ id, front }))}
88
+ storageKey={storageKey}
89
+ />
69
90
  <section class="flashcards-list">
70
91
  {rendered.map((card) => {
71
92
  const Content = card.Content;
@@ -16,6 +16,11 @@
16
16
  */
17
17
  import Base from '../layouts/Base.astro';
18
18
  import { getCollection, render } from 'astro:content';
19
+ import bookConfig from 'virtual:book-scaffold/book-config';
20
+ import { localCorpusEntryId } from '../src/lib/corpus';
21
+
22
+ interface Props { bookId?: string; }
23
+ const { bookId } = Astro.props;
19
24
 
20
25
  // Presence-gate: only touch the collection when the directory holds files.
21
26
  const glossModules = import.meta.glob('/src/content/glossary/**/*.{md,mdx}', {
@@ -23,14 +28,25 @@ const glossModules = import.meta.glob('/src/content/glossary/**/*.{md,mdx}', {
23
28
  import: 'default',
24
29
  eager: true,
25
30
  });
26
- const hasGlossary = Object.keys(glossModules).length > 0;
31
+ const hasGlossary = bookId
32
+ ? Object.keys(glossModules).some((path) => path.includes(`/glossary/${bookId}/`))
33
+ : Object.keys(glossModules).length > 0;
27
34
 
28
- const entries = hasGlossary ? await getCollection('glossary', (e) => !e.data.draft) : [];
35
+ const allEntries = hasGlossary ? await getCollection('glossary', (e) => !e.data.draft) : [];
36
+ const entries = bookId
37
+ ? allEntries.filter((entry) => entry.id.startsWith(`${bookId}/`))
38
+ : allEntries;
29
39
  // Alphabetical by display term (locale-aware so case/accents sort naturally).
30
40
  entries.sort((a, b) => a.data.term.localeCompare(b.data.term));
31
41
 
32
42
  const rendered = await Promise.all(
33
- entries.map(async (e) => ({ id: e.id, data: e.data, Content: (await render(e)).Content })),
43
+ entries.map(async (e) => ({
44
+ id: bookId && bookConfig.corpus
45
+ ? localCorpusEntryId(bookConfig.corpus, bookId, e.id)
46
+ : e.id,
47
+ data: e.data,
48
+ Content: (await render(e)).Content,
49
+ })),
34
50
  );
35
51
  const total = rendered.length;
36
52
  // id → display term, so "See also" links show the friendly term, not the kebab id.
@@ -39,6 +55,7 @@ const termById = new Map(rendered.map((t) => [t.id, t.data.term]));
39
55
  <Base
40
56
  title="Glossary"
41
57
  description="Searchable key-terms glossary, listed alphabetically."
58
+ bookId={bookId}
42
59
  >
43
60
  <article class="prose glossary">
44
61
  <h1>Glossary</h1>
package/pages/index.astro CHANGED
@@ -60,10 +60,17 @@ const ROUTE_LABELS: Record<string, { label: string; href: string }> = {
60
60
 
61
61
  const visibleRoutes = enabledRoutes
62
62
  .filter((name) => name in ROUTE_LABELS)
63
+ .filter((name) => !bookConfig.corpus || name === 'chapters' || name === 'search')
63
64
  .map((name) => ROUTE_LABELS[name]!);
64
65
  ---
65
66
 
66
- <Base title={title} description={description ?? undefined}>
67
+ <Base
68
+ title={title}
69
+ description={description ?? undefined}
70
+ showChrome={!bookConfig.corpus}
71
+ showSidebar={!bookConfig.corpus}
72
+ pagefindSurface={bookConfig.corpus ? 'corpus' : undefined}
73
+ >
67
74
  <article class="prose landing">
68
75
  <h1>{title}</h1>
69
76
  {description && <p class="lead">{description}</p>}
@@ -79,6 +86,22 @@ const visibleRoutes = enabledRoutes
79
86
  </>
80
87
  )}
81
88
 
89
+ {bookConfig.corpus && (
90
+ <section aria-labelledby="corpus-books-heading">
91
+ <h2 id="corpus-books-heading" class="read-heading">Books</h2>
92
+ <ol class="book-list">
93
+ {bookConfig.corpus.books.map((book) => (
94
+ <li>
95
+ <a href={`${baseUrl}${book.id}/`}>
96
+ <strong>{book.title}</strong>
97
+ {book.description && <span>{book.description}</span>}
98
+ </a>
99
+ </li>
100
+ ))}
101
+ </ol>
102
+ </section>
103
+ )}
104
+
82
105
  {portfolio && portfolio !== false && (
83
106
  <p class="portfolio-footer">
84
107
  <small>
@@ -117,6 +140,36 @@ const visibleRoutes = enabledRoutes
117
140
  font-family: var(--font-sans, sans-serif);
118
141
  font-size: 1rem;
119
142
  }
143
+ .book-list {
144
+ list-style: none;
145
+ padding: 0;
146
+ display: grid;
147
+ gap: var(--space-4);
148
+ }
149
+ .book-list li {
150
+ margin: 0;
151
+ }
152
+ .book-list a {
153
+ display: block;
154
+ padding: var(--space-4);
155
+ border: 1px solid var(--color-border);
156
+ border-radius: var(--radius-md);
157
+ color: inherit;
158
+ text-decoration: none;
159
+ background: var(--color-bg-subtle);
160
+ }
161
+ .book-list a:hover,
162
+ .book-list a:focus-visible {
163
+ border-color: var(--color-link);
164
+ }
165
+ .book-list strong,
166
+ .book-list span {
167
+ display: block;
168
+ }
169
+ .book-list span {
170
+ margin-top: var(--space-1);
171
+ color: var(--color-text-muted);
172
+ }
120
173
  .portfolio-footer {
121
174
  margin-top: 3rem;
122
175
  color: var(--color-text-muted, #555);
@@ -42,15 +42,20 @@ import { getAllQuestions, groupByDomain } from '../src/lib/questions';
42
42
  import { assertKnownDomain } from '../src/lib/exam-domains';
43
43
  import { buildExamManifest } from '../src/lib/exam-manifest';
44
44
 
45
+ interface Props { bookId?: string; }
46
+ const { bookId } = Astro.props;
47
+
45
48
  // Presence-gate: only touch the collection when the directory holds files.
46
49
  const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}', {
47
50
  query: '?raw',
48
51
  import: 'default',
49
52
  eager: true,
50
53
  });
51
- const hasQuestions = Object.keys(questionModules).length > 0;
54
+ const hasQuestions = bookId
55
+ ? Object.keys(questionModules).some((path) => path.includes(`/questions/${bookId}/`))
56
+ : Object.keys(questionModules).length > 0;
52
57
 
53
- const entries = hasQuestions ? await getAllQuestions() : [];
58
+ const entries = hasQuestions ? await getAllQuestions(bookId) : [];
54
59
 
55
60
  // Fail-loud: every question's domain must be in the examDomains registry.
56
61
  for (const entry of entries) {
@@ -78,6 +83,7 @@ const manifest = buildExamManifest(entries);
78
83
  <Base
79
84
  title="Practice question bank"
80
85
  description="All practice questions in this book, grouped by exam domain, with a scored practice-exam mode and answers behind a reveal."
86
+ bookId={bookId}
81
87
  >
82
88
  <article class="prose practice-exam" data-exam-root>
83
89
  <h1>Practice question bank</h1>
package/pages/print.astro CHANGED
@@ -26,7 +26,12 @@ import { getAllChapters } from '../src/lib/chapters';
26
26
  import ChapterHeader from '../components/ChapterHeader.astro';
27
27
  import mdxComponents from 'virtual:book-scaffold/mdx-components';
28
28
 
29
- const chapters = await getAllChapters();
29
+ interface Props { bookId?: string; }
30
+ const { bookId } = Astro.props;
31
+ const allChapters = await getAllChapters();
32
+ const chapters = bookId
33
+ ? allChapters.filter((entry) => entry.id.startsWith(`${bookId}/`))
34
+ : allChapters;
30
35
  const rendered = await Promise.all(
31
36
  chapters.map(async (entry) => {
32
37
  const { Content } = await render(entry);
@@ -35,7 +40,7 @@ const rendered = await Promise.all(
35
40
  );
36
41
  ---
37
42
 
38
- <Base title="Book (print edition)" description="Full book rendered as a single paginated document for PDF export.">
43
+ <Base title="Book (print edition)" description="Full book rendered as a single paginated document for PDF export." bookId={bookId}>
39
44
  <div class="prose print-edition">
40
45
  {rendered.map(({ entry, Content }) => (
41
46
  <section class="chapter-print">
@@ -17,6 +17,11 @@
17
17
  */
18
18
  import Base from '../layouts/Base.astro';
19
19
  import SourceArchive from '../components/SourceArchive.astro';
20
+ import bookConfig from 'virtual:book-scaffold/book-config';
21
+ import { selectBookArtifact } from '../src/lib/corpus';
22
+
23
+ interface Props { bookId?: string; }
24
+ const { bookId } = Astro.props;
20
25
 
21
26
  type CslAuthor = { family?: string; given?: string; literal?: string };
22
27
  type CslEntry = {
@@ -36,23 +41,38 @@ type CslEntry = {
36
41
  };
37
42
 
38
43
  // --- Bibliography: BibTeX → references.json (academic profile). ---
39
- const refsModules = import.meta.glob<{ default: Record<string, CslEntry> }>(
44
+ const refsModules = import.meta.glob<{ default: unknown }>(
40
45
  '/src/data/references.json',
41
46
  { eager: true },
42
47
  );
43
48
  const refsModule = refsModules['/src/data/references.json'];
44
- const map = (refsModule?.default ?? {}) as Record<string, CslEntry>;
49
+ const map = refsModule
50
+ ? selectBookArtifact<Record<string, CslEntry>>(
51
+ refsModule.default,
52
+ bookConfig.corpus,
53
+ bookId,
54
+ 'src/data/references.json',
55
+ )
56
+ : {};
45
57
  const entries = Object.values(map);
46
58
 
47
59
  // --- Cited sources: sources/manifest.yaml → sources.json (tools profile).
48
60
  // Presence-only gate; the actual render reads the live `sources` collection
49
61
  // via <SourceArchive>, which only runs when this gate is true (so it is never
50
62
  // reached on profiles without a `sources` collection). ---
51
- const srcModules = import.meta.glob<{ default: unknown[] }>(
63
+ const srcModules = import.meta.glob<{ default: unknown }>(
52
64
  '/src/data/sources.json',
53
65
  { eager: true },
54
66
  );
55
- const sourcesData = (srcModules['/src/data/sources.json']?.default ?? []) as unknown[];
67
+ const sourcesModule = srcModules['/src/data/sources.json'];
68
+ const sourcesData = sourcesModule
69
+ ? selectBookArtifact<unknown[]>(
70
+ sourcesModule.default,
71
+ bookConfig.corpus,
72
+ bookId,
73
+ 'src/data/sources.json',
74
+ )
75
+ : [];
56
76
  const hasSources = Array.isArray(sourcesData) && sourcesData.length > 0;
57
77
  const hasBib = entries.length > 0;
58
78
 
@@ -100,7 +120,7 @@ const lede =
100
120
  ? 'Every external source cited in this book, grouped by tier in descending authority.'
101
121
  : 'This book has no references yet.';
102
122
  ---
103
- <Base title="References" description="Bibliography and cited sources for this book.">
123
+ <Base title="References" description="Bibliography and cited sources for this book." bookId={bookId}>
104
124
  <article class="prose">
105
125
  <header>
106
126
  <h1>References</h1>
@@ -156,7 +176,7 @@ const lede =
156
176
  External sources cited inline via <code>&lt;Citation&gt;</code>, grouped
157
177
  by tier in descending authority.
158
178
  </p>
159
- <SourceArchive />
179
+ <SourceArchive sources={bookConfig.corpus ? sourcesData : undefined} />
160
180
  </section>
161
181
  )}
162
182
  </article>
@@ -12,15 +12,24 @@
12
12
  */
13
13
  import Base from '../layouts/Base.astro';
14
14
  import { normalizeBase } from '../src/lib/nav-href';
15
+ import bookConfig from 'virtual:book-scaffold/book-config';
15
16
 
16
17
  // #142: Pagefind writes its bundle to dist/pagefind/, served under the deploy
17
18
  // base — so the asset URLs must prefix BASE_URL or they 404 under a non-root base.
18
19
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
20
+ const corpusBooks = bookConfig.corpus?.books ?? [];
21
+ const corpusBookIds = corpusBooks.map((book) => book.id);
22
+ const searchOwner = bookConfig.title ?? (bookConfig.corpus ? 'Book corpus' : 'book-scaffold-astro');
23
+ const searchDescription = bookConfig.corpus
24
+ ? 'Full-text search across the books in this corpus. All queries run in the browser.'
25
+ : 'Full-text search across the book. Indexed via Pagefind at build time; all queries run in the browser.';
19
26
  ---
20
27
 
21
28
  <Base
22
- title="Search · book-template-astro"
23
- description="Full-text search across the book. Indexed via Pagefind at build time; all queries run in the browser."
29
+ title={`Search · ${searchOwner}`}
30
+ description={searchDescription}
31
+ showSidebar={!bookConfig.corpus}
32
+ pagefindSurface={bookConfig.corpus ? 'corpus' : undefined}
24
33
  >
25
34
  <link rel="stylesheet" href={`${baseUrl}pagefind/pagefind-ui.css`} slot="head" />
26
35
 
@@ -31,20 +40,55 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
31
40
  build time and is less than 100 KB total — queries never leave
32
41
  the page.
33
42
  </p>
43
+ {bookConfig.corpus && (
44
+ <label class="search-book-filter" for="search-book">
45
+ <span>Book</span>
46
+ <select id="search-book" name="book">
47
+ <option value="">All books</option>
48
+ {corpusBooks.map((book) => <option value={book.id}>{book.title}</option>)}
49
+ </select>
50
+ </label>
51
+ )}
34
52
  <div id="search"></div>
35
53
  </div>
36
54
 
37
55
  <script src={`${baseUrl}pagefind/pagefind-ui.js`} is:inline></script>
38
- <script is:inline>
56
+ <script is:inline define:vars={{ corpusBookIds }}>
39
57
  // Initialize after the Pagefind bundle has loaded.
40
58
  window.addEventListener('DOMContentLoaded', () => {
41
59
  if (typeof window.PagefindUI === 'function') {
42
- new window.PagefindUI({
60
+ var params = new URLSearchParams(window.location.search);
61
+ var requestedBook = params.get('book');
62
+ var selectedBook = requestedBook && corpusBookIds.indexOf(requestedBook) !== -1
63
+ ? requestedBook
64
+ : '';
65
+ if (requestedBook && !selectedBook) {
66
+ params.delete('book');
67
+ var canonicalQuery = params.toString();
68
+ history.replaceState(null, '', location.pathname + (canonicalQuery ? '?' + canonicalQuery : '') + location.hash);
69
+ }
70
+
71
+ var ui = new window.PagefindUI({
43
72
  element: '#search',
44
73
  showImages: false,
45
74
  showSubResults: true,
46
75
  resetStyles: false,
47
76
  });
77
+ if (selectedBook) ui.triggerFilters({ book: [selectedBook] });
78
+
79
+ var selector = document.getElementById('search-book');
80
+ if (selector) {
81
+ selector.value = selectedBook;
82
+ selector.addEventListener('change', function () {
83
+ selectedBook = selector.value;
84
+ var next = new URLSearchParams(window.location.search);
85
+ if (selectedBook) next.set('book', selectedBook);
86
+ else next.delete('book');
87
+ var query = next.toString();
88
+ history.replaceState(null, '', location.pathname + (query ? '?' + query : '') + location.hash);
89
+ ui.triggerFilters(selectedBook ? { book: [selectedBook] } : {});
90
+ });
91
+ }
48
92
  } else {
49
93
  document.getElementById('search').innerHTML =
50
94
  '<p><em>Search index not found. Run <code>npm run build</code> first; dev mode does not include the index.</em></p>';
@@ -57,6 +101,23 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
57
101
  .pagefind-ui__form {
58
102
  margin-top: var(--space-4);
59
103
  }
104
+ .search-book-filter {
105
+ display: grid;
106
+ gap: var(--space-1);
107
+ max-width: 24rem;
108
+ margin: var(--space-5) 0 var(--space-3);
109
+ font-family: var(--font-sans);
110
+ font-size: var(--text-sm);
111
+ font-weight: 600;
112
+ }
113
+ .search-book-filter select {
114
+ padding: var(--space-2) var(--space-3);
115
+ border: 1px solid var(--color-border);
116
+ border-radius: var(--radius-md);
117
+ background: var(--color-bg-subtle);
118
+ color: var(--color-text);
119
+ font: inherit;
120
+ }
60
121
  .pagefind-ui__search-input {
61
122
  font-family: var(--font-body) !important;
62
123
  font-size: var(--text-base) !important;