@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
@@ -23,8 +23,12 @@ 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
25
  import { chapterHref, apparatusHref, bookOf, isCurrentChapter, type ChapterLike, normalizeBase } from '../src/lib/nav-href';
26
+ import { corpusBookIdFromPath, corpusBookIdOf } from '../src/lib/corpus';
26
27
 
27
- const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
28
+ const profile = import.meta.env.BOOK_PROFILE;
29
+ if (!profile) {
30
+ throw new Error('book-scaffold runtime is missing the integration-defined BOOK_PROFILE.');
31
+ }
28
32
  // #142: normalize the base trailing slash (Astro allows base:'/foo' with none).
29
33
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
30
34
 
@@ -45,16 +49,30 @@ const rawChapters = await getCollection('chapters', (entry) => !entry.data.draft
45
49
  function entryOf(e: (typeof rawChapters)[number]): ChapterLike {
46
50
  return { id: e.id, data: e.data as Record<string, unknown> };
47
51
  }
52
+ const entryBook = (entry: (typeof rawChapters)[number]) =>
53
+ bookConfig.corpus
54
+ ? corpusBookIdOf(bookConfig.corpus, entry.id)
55
+ : bookOf(entryOf(entry), bookField);
48
56
  const knownBooks = new Set(
49
- rawChapters.map((e) => bookOf(entryOf(e), bookField)).filter((b): b is string => b !== null),
57
+ bookConfig.corpus
58
+ ? bookConfig.corpus.books.map((book) => book.id)
59
+ : rawChapters.map(entryBook).filter((book): book is string => book !== null),
50
60
  );
51
- const relPath = currentPath.startsWith(baseUrl) ? currentPath.slice(baseUrl.length) : currentPath.replace(/^\//, '');
52
- const urlBook = relPath.split('/')[0] ?? '';
53
- const currentBook = knownBooks.has(urlBook) ? urlBook : null;
61
+ const currentBook = bookConfig.corpus
62
+ ? corpusBookIdFromPath(bookConfig.corpus, currentPath, baseUrl)
63
+ : (() => {
64
+ const relPath = currentPath.startsWith(baseUrl)
65
+ ? currentPath.slice(baseUrl.length)
66
+ : currentPath.replace(/^\//, '');
67
+ const urlBook = relPath.split('/')[0] ?? '';
68
+ return knownBooks.has(urlBook) ? urlBook : null;
69
+ })();
54
70
 
55
71
  const scopedChapters = currentBook
56
- ? rawChapters.filter((e) => bookOf(entryOf(e), bookField) === currentBook)
57
- : rawChapters;
72
+ ? rawChapters.filter((entry) => entryBook(entry) === currentBook)
73
+ : bookConfig.corpus
74
+ ? []
75
+ : rawChapters;
58
76
 
59
77
  // Pre-shape into render-ready rows (all TS casts in frontmatter — Astro's JSX
60
78
  // parser mis-tokenizes casts inside expression containers).
@@ -116,8 +134,15 @@ for (const r of rows) {
116
134
  }
117
135
 
118
136
  // Index link: multi-book → the book's TOC at /<book>/; single-book → /chapters/.
119
- const chaptersIndexHref = currentBook ? `${baseUrl}${currentBook}/` : `${baseUrl}chapters/`;
120
- const referencesHref = `${baseUrl}references/`;
137
+ const chaptersIndexHref = currentBook
138
+ ? `${baseUrl}chapters/${currentBook}/`
139
+ : `${baseUrl}chapters/`;
140
+ const referencesHref = apparatusHref(
141
+ 'references',
142
+ currentBook,
143
+ bookConfig.corpus ? '/:book/:route/' : '/:route/',
144
+ baseUrl,
145
+ );
121
146
 
122
147
  // Apparatus links (study-guide routes) — surfaced only when the consumer opts in
123
148
  // via `apparatusRoutes`. Book-scoped via the same resolver as chapters.
@@ -127,7 +152,11 @@ const APPARATUS_LABELS: Record<string, string> = {
127
152
  flashcards: 'Flashcards',
128
153
  answers: 'Answers',
129
154
  };
130
- const apparatusLinks = apparatusRoutes.map((route) => {
155
+ const currentBookConfig = currentBook && bookConfig.corpus
156
+ ? bookConfig.corpus.books.find((book) => book.id === currentBook)
157
+ : null;
158
+ const visibleApparatusRoutes = currentBookConfig?.apparatus ?? apparatusRoutes;
159
+ const apparatusLinks = visibleApparatusRoutes.map((route) => {
131
160
  const href = apparatusHref(route, currentBook, apparatusRoute, baseUrl);
132
161
  return {
133
162
  href,
@@ -135,6 +164,7 @@ const apparatusLinks = apparatusRoutes.map((route) => {
135
164
  isCurrent: currentPath === href || currentPath === href.replace(/\/$/, ''),
136
165
  };
137
166
  });
167
+ const corpusBooks = bookConfig.corpus?.books ?? [];
138
168
 
139
169
  function partLabel(key: string): string {
140
170
  if (profile === 'academic') return academicPartHeading(key);
@@ -149,7 +179,7 @@ function partLabel(key: string): string {
149
179
  >
150
180
  All chapters
151
181
  </a>
152
- {profile === 'academic' && (
182
+ {profile === 'academic' && !bookConfig.corpus && (
153
183
  <a
154
184
  href={referencesHref}
155
185
  class={`sidebar-link sidebar-link-index ${currentPath === referencesHref ? 'is-current' : ''}`}
@@ -196,6 +226,25 @@ function partLabel(key: string): string {
196
226
  </ol>
197
227
  </section>
198
228
  )}
229
+
230
+ {corpusBooks.length > 1 && (
231
+ <section class="sidebar-part sidebar-corpus-books">
232
+ <h3 class="sidebar-part-heading">Books</h3>
233
+ <ol class="sidebar-list">
234
+ {corpusBooks.map((book) => (
235
+ <li>
236
+ <a
237
+ href={`${baseUrl}${book.id}/`}
238
+ aria-current={book.id === currentBook ? 'page' : undefined}
239
+ class={`sidebar-link sidebar-link-book ${book.id === currentBook ? 'is-current' : ''}`}
240
+ >
241
+ <span class="sidebar-chapter-title">{book.title}</span>
242
+ </a>
243
+ </li>
244
+ ))}
245
+ </ol>
246
+ </section>
247
+ )}
199
248
  </nav>
200
249
 
201
250
  <style>
@@ -22,6 +22,7 @@ import bookConfig from 'virtual:book-scaffold/book-config';
22
22
  import { getCollection } from 'astro:content';
23
23
  import { deriveObjectiveMap, distinctChaptersSorted } from '../src/lib/questions';
24
24
  import { assertKnownDomain } from '../src/lib/exam-domains';
25
+ import { corpusBookIdFromPath } from '../src/lib/corpus';
25
26
 
26
27
  interface Props {
27
28
  /** Heading above the table. Defaults to "Exam objective coverage". */
@@ -35,11 +36,22 @@ const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}',
35
36
  import: 'default',
36
37
  eager: true,
37
38
  });
38
- const hasQuestions = Object.keys(questionModules).length > 0;
39
+ const currentBook = bookConfig.corpus
40
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
41
+ : null;
42
+ if (bookConfig.corpus && !currentBook) {
43
+ throw new Error('<ObjectiveMap> requires a canonical corpus book route.');
44
+ }
45
+ const hasQuestions = currentBook
46
+ ? Object.keys(questionModules).some((path) => path.includes(`/questions/${currentBook}/`))
47
+ : Object.keys(questionModules).length > 0;
39
48
 
40
- const entries = hasQuestions
49
+ const allEntries = hasQuestions
41
50
  ? await getCollection('questions', (e) => !e.data.draft)
42
51
  : [];
52
+ const entries = currentBook
53
+ ? allEntries.filter((entry) => entry.id.startsWith(`${currentBook}/`))
54
+ : allEntries;
43
55
  // Fail loud (like /practice-exam): a question with an unregistered / typo'd
44
56
  // domain must throw at build, not silently vanish from the coverage matrix.
45
57
  for (const e of entries) {
@@ -20,6 +20,12 @@
20
20
  import { getCollection } from 'astro:content';
21
21
  import { selectPartExercises } from '../src/lib/part-review';
22
22
  import { normalizeBase } from '../src/lib/nav-href';
23
+ import bookConfig from 'virtual:book-scaffold/book-config';
24
+ import {
25
+ corpusBookIdFromPath,
26
+ localCorpusEntryId,
27
+ selectBookArtifact,
28
+ } from '../src/lib/corpus';
23
29
 
24
30
  interface Props {
25
31
  /** The part to aggregate. Number (tools/minimal/…) or string (academic enum). */
@@ -32,18 +38,40 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
32
38
 
33
39
  // Reuse the build-exercises index (keyed by chapter slug). Project-root-relative
34
40
  // glob (the tips.astro / exercises.astro lesson) so it resolves across consumers.
35
- const exModules = import.meta.glob<{ default: Record<string, Array<{ id: string; problem: string }>> }>(
41
+ type ExerciseIndex = Record<string, Array<{ id: string; problem: string }>>;
42
+ const exModules = import.meta.glob<{ default: unknown }>(
36
43
  '/src/data/exercises.json',
37
44
  { eager: true },
38
45
  );
39
46
  const exEntry = exModules['/src/data/exercises.json'];
40
- const byChapter = exEntry?.default ?? {};
47
+ const currentBook = bookConfig.corpus
48
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, baseUrl)
49
+ : null;
50
+ if (bookConfig.corpus && !currentBook) {
51
+ throw new Error('<PartReview> requires a canonical corpus book route.');
52
+ }
53
+ const byChapter = exEntry
54
+ ? selectBookArtifact<ExerciseIndex>(
55
+ exEntry.default,
56
+ bookConfig.corpus,
57
+ currentBook,
58
+ 'src/data/exercises.json',
59
+ )
60
+ : {};
41
61
  const hasIndex = Boolean(exEntry);
42
62
 
43
63
  // Filter this part's chapters, sort to book order, and join the index — pure +
44
64
  // unit-tested in src/lib/part-review.ts (`part` is String-coerced, so a
45
65
  // number-vs-string prop can't silently miss). This component just renders it.
46
- const chapters = await getCollection('chapters', (e) => !e.data.draft);
66
+ const allChapters = await getCollection('chapters', (e) => !e.data.draft);
67
+ const chapters = currentBook && bookConfig.corpus
68
+ ? allChapters
69
+ .filter((entry) => entry.id.startsWith(`${currentBook}/`))
70
+ .map((entry) => ({
71
+ ...entry,
72
+ id: localCorpusEntryId(bookConfig.corpus!, currentBook, entry.id),
73
+ }))
74
+ : allChapters;
47
75
  const { groups, total } = selectPartExercises(chapters, byChapter, part);
48
76
  const heading = title ?? `Part ${part} Review`;
49
77
  ---
@@ -67,7 +95,7 @@ const heading = title ?? `Part ${part} Review`;
67
95
  </p>
68
96
  {groups.map((g) => (
69
97
  <section class="part-review-chapter">
70
- <h3 class="part-review-chapter-title"><a href={`${baseUrl}chapters/${g.chapter}/`}>{g.chapter}</a></h3>
98
+ <h3 class="part-review-chapter-title"><a href={`${baseUrl}chapters/${currentBook ? `${currentBook}/` : ''}${g.chapter}/`}>{g.chapter}</a></h3>
71
99
  <ol class="part-review-list">
72
100
  {g.exercises.map((ex) => (
73
101
  <li id={`review-${ex.id}`} class="part-review-item">
@@ -27,11 +27,15 @@ import {
27
27
 
28
28
  interface Props {
29
29
  pattern: PatternEntry;
30
+ bookId?: string;
31
+ hasChangelog?: boolean;
30
32
  }
31
- const { pattern } = Astro.props;
33
+ const { pattern, bookId, hasChangelog = true } = Astro.props;
32
34
  const data = pattern.data;
33
35
 
34
- const timeline = await getPatternTimeline(pattern.id);
36
+ const timeline = hasChangelog
37
+ ? await getPatternTimeline(pattern.id, bookId)
38
+ : [];
35
39
  const adopters = toolsInTimeline(timeline);
36
40
 
37
41
  function formatDate(d: Date): string {
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import bookConfig from 'virtual:book-scaffold/book-config';
20
20
  import { baseNoSlash } from '../src/lib/nav-href';
21
+ import { corpusBookHasApparatusRoute, corpusBookIdFromPath } from '../src/lib/corpus';
21
22
 
22
23
  interface Props {
23
24
  /** Summary label for the reveal. Defaults to "Answer & rationale". */
@@ -37,7 +38,21 @@ if (appendix && !forId) {
37
38
  `/answers#answer-<id> anchor target.`,
38
39
  );
39
40
  }
40
- if (appendix && !(bookConfig.enabledRoutes ?? []).includes('answers')) {
41
+ const currentBook = bookConfig.corpus
42
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
43
+ : null;
44
+ const answersEnabled = bookConfig.corpus
45
+ ? Boolean(
46
+ currentBook &&
47
+ corpusBookHasApparatusRoute(
48
+ bookConfig.corpus,
49
+ currentBook,
50
+ 'answers',
51
+ bookConfig.apparatusRoutes,
52
+ ),
53
+ )
54
+ : (bookConfig.enabledRoutes ?? []).includes('answers');
55
+ if (appendix && !answersEnabled) {
41
56
  throw new Error(
42
57
  `<Rationale appendix for="${forId}"> — the answers route is not enabled, so ` +
43
58
  `the appendix link would 404. Set routes: { answers: true } in ` +
@@ -50,12 +65,14 @@ if (appendix && !(bookConfig.enabledRoutes ?? []).includes('answers')) {
50
65
  // render()). BASE_URL-prefixed + trailing-slash tolerant; an endsWith()
51
66
  // heuristic would also fire on e.g. a chapter whose slug is "answers".
52
67
  const basePath = baseNoSlash(import.meta.env.BASE_URL);
53
- const onAnswersRoute = baseNoSlash(Astro.url.pathname) === `${basePath}/answers`;
68
+ const answersBookPrefix = currentBook ? `${currentBook}/` : '';
69
+ const answersPath = `${basePath}/${answersBookPrefix}answers`;
70
+ const onAnswersRoute = baseNoSlash(Astro.url.pathname) === answersPath;
54
71
  const asLink = appendix && !onAnswersRoute;
55
72
  ---
56
73
  {asLink ? (
57
74
  <p class="question-rationale-ref">
58
- <a href={`${basePath}/answers#answer-${forId}`}>{title} →</a>
75
+ <a href={`${answersPath}#answer-${forId}`}>{title} →</a>
59
76
  </p>
60
77
  ) : (
61
78
  <details class="question-rationale">
@@ -18,17 +18,25 @@
18
18
  import bookConfig from 'virtual:book-scaffold/book-config';
19
19
  import NavContent from './NavContent.astro';
20
20
  import { normalizeBase } from '../src/lib/nav-href';
21
+ import { corpusBookIdFromPath, resolveCorpusBook } from '../src/lib/corpus';
21
22
 
22
- const siteTitle = bookConfig.title ?? 'Book';
23
- const siteSubtitle = bookConfig.subtitle ?? 'A scaffold-astro book';
24
23
  // #142: Astro does not guarantee a trailing slash on `base` — normalize so the
25
24
  // home link is `${base}/`, not a slash-less `/foo`.
26
25
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
26
+ const currentBookId = bookConfig.corpus
27
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, baseUrl)
28
+ : null;
29
+ const currentBook = currentBookId && bookConfig.corpus
30
+ ? resolveCorpusBook(bookConfig.corpus, currentBookId)
31
+ : null;
32
+ const siteTitle = currentBook?.title ?? bookConfig.title ?? 'Book';
33
+ const siteSubtitle = currentBook?.subtitle ?? bookConfig.subtitle ?? 'A scaffold-astro book';
34
+ const homeHref = currentBook ? `${baseUrl}${currentBook.id}/` : baseUrl;
27
35
  ---
28
36
 
29
37
  <aside class="sidebar" aria-label="Chapter navigation">
30
38
  <div class="sidebar-inner">
31
- <a href={baseUrl} class="sidebar-home">
39
+ <a href={homeHref} class="sidebar-home">
32
40
  <strong>{siteTitle}</strong>
33
41
  <span class="sidebar-subtitle">{siteSubtitle}</span>
34
42
  </a>
@@ -19,14 +19,44 @@ import {
19
19
  TIER_DESCRIPTIONS,
20
20
  } from '../src/lib/sources';
21
21
 
22
- const grouped = await getSourcesByTier();
22
+ interface SerializedSource {
23
+ id: string;
24
+ tier: (typeof sourceTiers)[number];
25
+ title: string;
26
+ url: string;
27
+ author?: string;
28
+ publish_date?: string | Date;
29
+ captured_at?: string | Date;
30
+ tool?: string;
31
+ perma_cc?: string;
32
+ }
33
+
34
+ interface Props { sources?: unknown[]; }
35
+ const { sources } = Astro.props;
36
+ const grouped = sources
37
+ ? Object.fromEntries(
38
+ sourceTiers.map((tier) => [
39
+ tier,
40
+ (sources as SerializedSource[])
41
+ .filter((source) => source.tier === tier)
42
+ .map((source) => ({ id: source.id, data: source })),
43
+ ]),
44
+ ) as Record<(typeof sourceTiers)[number], Array<{ id: string; data: SerializedSource }>>
45
+ : await getSourcesByTier();
46
+
47
+ function asDate(d: string | Date | undefined): Date | undefined {
48
+ if (!d) return undefined;
49
+ return d instanceof Date ? d : new Date(d);
50
+ }
23
51
 
24
- function formatDate(d: Date | undefined): string {
52
+ function formatDate(value: string | Date | undefined): string {
53
+ const d = asDate(value);
25
54
  if (!d) return '—';
26
55
  return d.toISOString().slice(0, 10);
27
56
  }
28
57
 
29
- function year(d: Date | undefined): string | null {
58
+ function year(value: string | Date | undefined): string | null {
59
+ const d = asDate(value);
30
60
  if (!d) return null;
31
61
  return String(d.getUTCFullYear());
32
62
  }
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import { normalizeBase } from '../src/lib/nav-href';
3
+ import bookConfig from 'virtual:book-scaffold/book-config';
4
+ import { corpusBookHasApparatusRoute, corpusBookIdFromPath } from '../src/lib/corpus';
3
5
  /**
4
6
  * Term — inline glossary cross-link (v4.19.0, #115).
5
7
  *
@@ -19,8 +21,23 @@ interface Props {
19
21
  }
20
22
  const { id } = Astro.props;
21
23
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
24
+ const currentBook = bookConfig.corpus
25
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, baseUrl)
26
+ : null;
27
+ if (
28
+ bookConfig.corpus &&
29
+ (!currentBook ||
30
+ !corpusBookHasApparatusRoute(
31
+ bookConfig.corpus,
32
+ currentBook,
33
+ 'glossary',
34
+ bookConfig.apparatusRoutes,
35
+ ))
36
+ ) {
37
+ throw new Error(`<Term id="${id}"> requires the current corpus book's glossary route.`);
38
+ }
22
39
  ---
23
- <a href={`${baseUrl}glossary#term-${id}`} class="term-link"><slot /></a>
40
+ <a href={`${baseUrl}${currentBook ? `${currentBook}/` : ''}glossary#term-${id}`} class="term-link"><slot /></a>
24
41
 
25
42
  <style>
26
43
  .term-link {
@@ -30,6 +30,8 @@
30
30
  * </Theorem>
31
31
  */
32
32
  import { theoremLabel, resolveTheoremNumber, type TheoremLabelProps } from '../src/lib/theorem-label';
33
+ import bookConfig from 'virtual:book-scaffold/book-config';
34
+ import { corpusBookIdFromPath, selectBookArtifact } from '../src/lib/corpus';
33
35
 
34
36
  interface Props extends TheoremLabelProps {
35
37
  id?: string;
@@ -42,14 +44,22 @@ interface Props extends TheoremLabelProps {
42
44
  // a missing file → empty map → fall back to explicit n= (the same soft-degrade
43
45
  // path as XRef — the validator catches unknown ids at CI).
44
46
  type LabelEntry = { href: string; display: string; number?: string | null };
45
- const labelsModules = import.meta.glob<{ default: Record<string, LabelEntry> }>(
47
+ const labelsModules = import.meta.glob<{ default: unknown }>(
46
48
  '/src/data/labels.json',
47
49
  { eager: true },
48
50
  );
49
- const labelsMap = (labelsModules['/src/data/labels.json']?.default ?? {}) as Record<
50
- string,
51
- LabelEntry
52
- >;
51
+ const labelsModule = labelsModules['/src/data/labels.json'];
52
+ const currentBook = bookConfig.corpus
53
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
54
+ : null;
55
+ const labelsMap = labelsModule
56
+ ? selectBookArtifact<Record<string, LabelEntry>>(
57
+ labelsModule.default,
58
+ bookConfig.corpus,
59
+ currentBook,
60
+ 'src/data/labels.json',
61
+ )
62
+ : {};
53
63
 
54
64
  const { id } = Astro.props;
55
65
  // labels.json (by id) is the number source; explicit n= is the fallback when no
@@ -1,5 +1,11 @@
1
1
  ---
2
2
  import { normalizeBase } from '../src/lib/nav-href';
3
+ import bookConfig from 'virtual:book-scaffold/book-config';
4
+ import {
5
+ corpusBookHasApparatusRoute,
6
+ corpusBookIdFromPath,
7
+ selectBookArtifact,
8
+ } from '../src/lib/corpus';
3
9
  /**
4
10
  * TipsCard — print-friendly pull-out card listing all numbered tips
5
11
  * (v4.3.0, closes #70).
@@ -16,11 +22,37 @@ import { normalizeBase } from '../src/lib/nav-href';
16
22
  */
17
23
  // v4.4.0 fix: project-root-relative import via Vite's import.meta.glob
18
24
  // (the previous `../../../src/data/tips.json` failed in node_modules contexts).
19
- const tipsModules = import.meta.glob<{ default: Array<{ n: number; title: string; chapter: string; preview: string }> }>(
25
+ type TipIndexEntry = { n: number; title: string; chapter: string; preview: string };
26
+ const tipsModules = import.meta.glob<{ default: unknown }>(
20
27
  '/src/data/tips.json',
21
28
  { eager: true },
22
29
  );
23
- const tips = tipsModules['/src/data/tips.json']?.default ?? [];
30
+ const tipsModule = tipsModules['/src/data/tips.json'];
31
+ const tipsPayload = tipsModule?.default ?? [];
32
+ const currentBook = bookConfig.corpus
33
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
34
+ : null;
35
+ const tips = tipsModule
36
+ ? selectBookArtifact<TipIndexEntry[]>(
37
+ tipsPayload,
38
+ bookConfig.corpus,
39
+ currentBook,
40
+ 'src/data/tips.json',
41
+ )
42
+ : [];
43
+ if (
44
+ tips.length > 0 &&
45
+ bookConfig.corpus &&
46
+ (!currentBook ||
47
+ !corpusBookHasApparatusRoute(
48
+ bookConfig.corpus,
49
+ currentBook,
50
+ 'tips',
51
+ bookConfig.apparatusRoutes,
52
+ ))
53
+ ) {
54
+ throw new Error(`<TipsCard> requires the current corpus book's tips route.`);
55
+ }
24
56
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
25
57
  ---
26
58
  <aside class="tips-card" role="contentinfo">
@@ -35,7 +67,12 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
35
67
  <ol class="tips-card-list">
36
68
  {tips.map((tip) => (
37
69
  <li class="tips-card-item">
38
- <a href={`${baseUrl}tips#tip-${tip.n}`} class="tips-card-link">
70
+ <a
71
+ href={currentBook
72
+ ? `${baseUrl}${currentBook}/tips#tip-${tip.n}`
73
+ : `${baseUrl}tips#tip-${tip.n}`}
74
+ class="tips-card-link"
75
+ >
39
76
  <span class="tips-card-number">{tip.n}.</span>
40
77
  <strong class="tips-card-rule">{tip.title}</strong>
41
78
  </a>
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import { normalizeBase } from '../src/lib/nav-href';
3
+ import bookConfig from 'virtual:book-scaffold/book-config';
4
+ import { corpusBookIdFromPath, selectBookArtifact } from '../src/lib/corpus';
3
5
  // XRef — resolves a `\cref{label}` LaTeX reference to a hyperlink.
4
6
  //
5
7
  // Reads src/data/labels.json built by scripts/build-labels.mjs
@@ -32,12 +34,22 @@ type LabelEntry = { href: string; display: string };
32
34
  // Resolve labels.json from the consumer's project root (Vite resolves `/`
33
35
  // to project root, not the package). Missing file -> empty map -> [?id]
34
36
  // placeholders (silent degrade for dev ergonomics; validator catches at CI).
35
- const labelsModules = import.meta.glob<{ default: Record<string, LabelEntry> }>(
37
+ const labelsModules = import.meta.glob<{ default: unknown }>(
36
38
  '/src/data/labels.json',
37
39
  { eager: true },
38
40
  );
39
41
  const labelsModule = labelsModules['/src/data/labels.json'];
40
- const map = (labelsModule?.default ?? {}) as Record<string, LabelEntry>;
42
+ const currentBook = bookConfig.corpus
43
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
44
+ : null;
45
+ const map = labelsModule
46
+ ? selectBookArtifact<Record<string, LabelEntry>>(
47
+ labelsModule.default,
48
+ bookConfig.corpus,
49
+ currentBook,
50
+ 'src/data/labels.json',
51
+ )
52
+ : {};
41
53
 
42
54
  interface Props {
43
55
  id: string;
@@ -1,5 +1,5 @@
1
1
  import * as preact from 'preact';
2
- import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-X9IrX1G3.js';
2
+ import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-DttY7kyZ.js';
3
3
  import '../schemas-CKipJ5Ie.js';
4
4
  import 'astro/zod';
5
5
 
@@ -6,7 +6,12 @@ import 'astro/zod';
6
6
  interface Props {
7
7
  /** Deck manifest (buildFlashcardDeck output) — card ids + fronts only. */
8
8
  deck: FlashcardRef[];
9
+ /**
10
+ * Persistence namespace. Corpus routes pass a base- and book-specific key
11
+ * so repeated local glossary ids never share or erase another book's state.
12
+ */
13
+ storageKey?: string;
9
14
  }
10
- declare function Flashcards({ deck }: Props): preact.JSX.Element;
15
+ declare function Flashcards({ deck, storageKey }: Props): preact.JSX.Element;
11
16
 
12
17
  export { Flashcards as default };
@@ -15,10 +15,10 @@ function shuffle(arr, rng = Math.random) {
15
15
 
16
16
  // components/Flashcards.tsx
17
17
  import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
18
- var STORAGE_KEY = "book:flashcards:known";
19
- function readKnown() {
18
+ var DEFAULT_STORAGE_KEY = "book:flashcards:known";
19
+ function readKnown(storageKey) {
20
20
  try {
21
- const raw = localStorage.getItem(STORAGE_KEY);
21
+ const raw = localStorage.getItem(storageKey);
22
22
  if (!raw) return /* @__PURE__ */ new Set();
23
23
  const parsed = JSON.parse(raw);
24
24
  if (!Array.isArray(parsed)) return /* @__PURE__ */ new Set();
@@ -27,13 +27,16 @@ function readKnown() {
27
27
  return /* @__PURE__ */ new Set();
28
28
  }
29
29
  }
30
- function writeKnown(known) {
30
+ function writeKnown(storageKey, known) {
31
31
  try {
32
- localStorage.setItem(STORAGE_KEY, JSON.stringify([...known]));
32
+ localStorage.setItem(storageKey, JSON.stringify([...known]));
33
33
  } catch {
34
34
  }
35
35
  }
36
- function Flashcards({ deck }) {
36
+ function Flashcards({ deck, storageKey = DEFAULT_STORAGE_KEY }) {
37
+ if (typeof storageKey !== "string" || storageKey.trim().length === 0) {
38
+ throw new Error("Flashcards: storageKey must be a non-empty string.");
39
+ }
37
40
  const [phase, setPhase] = useState("idle");
38
41
  const [order, setOrder] = useState([]);
39
42
  const [pos, setPos] = useState(0);
@@ -42,12 +45,12 @@ function Flashcards({ deck }) {
42
45
  const [unknownOnly, setUnknownOnly] = useState(false);
43
46
  const ref = useRef(null);
44
47
  useEffect(() => {
45
- const stored = readKnown();
48
+ const stored = readKnown(storageKey);
46
49
  const deckIds = new Set(deck.map((c) => c.id));
47
50
  const cleaned = new Set([...stored].filter((id) => deckIds.has(id)));
48
- if (cleaned.size !== stored.size) writeKnown(cleaned);
51
+ if (cleaned.size !== stored.size) writeKnown(storageKey, cleaned);
49
52
  setKnown(cleaned);
50
- }, []);
53
+ }, [deck, storageKey]);
51
54
  function requireRoot() {
52
55
  const r = ref.current?.closest("[data-flashcards-root]");
53
56
  if (!r) {
@@ -105,7 +108,7 @@ function Flashcards({ deck }) {
105
108
  if (knewIt) next.add(id);
106
109
  else next.delete(id);
107
110
  setKnown(next);
108
- writeKnown(next);
111
+ writeKnown(storageKey, next);
109
112
  if (pos < order.length - 1) goTo(pos + 1);
110
113
  else end();
111
114
  }
@@ -124,7 +127,7 @@ function Flashcards({ deck }) {
124
127
  function resetKnown() {
125
128
  const next = /* @__PURE__ */ new Set();
126
129
  setKnown(next);
127
- writeKnown(next);
130
+ writeKnown(storageKey, next);
128
131
  }
129
132
  if (deck.length === 0) {
130
133
  return /* @__PURE__ */ jsx("p", { class: "flashcards-empty", children: "No glossary terms to study yet." });
@@ -124,7 +124,7 @@ interface RoutingChapter {
124
124
  */
125
125
  declare function deriveDomainRouting(entries: readonly (QuestionLike & {
126
126
  data: Pick<Question, 'id' | 'chapter' | 'domain'>;
127
- })[], baseUrl?: string): Record<string, RoutingChapter[]>;
127
+ })[], baseUrl?: string, bookId?: string): Record<string, RoutingChapter[]>;
128
128
  /**
129
129
  * Build the cross-domain blueprint for assessment mode: spread `count` evenly
130
130
  * across the domains present in the pool (floor, minimum 1 per domain), letting