@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
@@ -69,8 +69,12 @@ import NavContent from '../components/NavContent.astro';
69
69
  // book-config virtual module (was landing-config in v4.5.1).
70
70
  import bookConfig from 'virtual:book-scaffold/book-config';
71
71
  import { normalizeBase } from '../src/lib/nav-href';
72
+ import { resolveCorpusBook } from '../src/lib/corpus';
72
73
 
73
- const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
74
+ const profile = import.meta.env.BOOK_PROFILE;
75
+ if (!profile) {
76
+ throw new Error('book-scaffold runtime is missing the integration-defined BOOK_PROFILE.');
77
+ }
74
78
 
75
79
  interface Props {
76
80
  title: string;
@@ -86,6 +90,10 @@ interface Props {
86
90
  ogImage?: string;
87
91
  /** v4.6.0: Open Graph type. Defaults to 'website'; Chapter.astro passes 'article'. */
88
92
  ogType?: string;
93
+ /** v5.0.0 (#80): resolved manifest book for metadata, search, and Pagefind. */
94
+ bookId?: string;
95
+ /** v5.0.0 (#80): non-book Pagefind surface such as the corpus landing. */
96
+ pagefindSurface?: string;
89
97
  }
90
98
 
91
99
  const {
@@ -96,8 +104,14 @@ const {
96
104
  showChrome = true,
97
105
  ogImage,
98
106
  ogType = 'website',
107
+ bookId,
108
+ pagefindSurface,
99
109
  } = Astro.props;
100
110
 
111
+ const corpusBook = bookId && bookConfig.corpus
112
+ ? resolveCorpusBook(bookConfig.corpus, bookId)
113
+ : null;
114
+
101
115
  // Automatic tools chrome (ToolFilter) renders only for non-academic
102
116
  // profiles AND when the page opts in via showChrome (default true). A
103
117
  // landing/hub page (no chapter filtering) passes showChrome={false} to
@@ -114,14 +128,22 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site).toString();
114
128
  // per-page Astro.props.ogImage or book-level bookConfig.seo.ogImage. No
115
129
  // '/og-default.png' fallback to avoid broken-link meta tags on consumers
116
130
  // without an OG image authored.
117
- const resolvedOgImage = ogImage ?? bookConfig.seo?.ogImage ?? null;
131
+ const resolvedOgImage = ogImage ?? corpusBook?.image ?? bookConfig.seo?.ogImage ?? null;
118
132
  const absoluteOgImage = resolvedOgImage
119
133
  ? new URL(resolvedOgImage, Astro.site).toString()
120
134
  : null;
121
135
  const twitterHandle = bookConfig.seo?.twitterHandle ?? null;
122
- const ogSiteName = bookConfig.title ?? title;
123
- const ogDescription = description ?? bookConfig.description ?? '';
136
+ const ogSiteName = corpusBook?.title ?? bookConfig.title ?? title;
137
+ const ogDescription = description ?? corpusBook?.description ?? bookConfig.description ?? '';
124
138
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
139
+ const searchHref = bookId
140
+ ? `${baseUrl}search/?book=${encodeURIComponent(bookId)}`
141
+ : `${baseUrl}search/`;
142
+ const pagefindFilter = bookId
143
+ ? `book:${bookId}`
144
+ : pagefindSurface
145
+ ? `surface:${pagefindSurface}`
146
+ : undefined;
125
147
  ---
126
148
 
127
149
  <!doctype html>
@@ -142,7 +164,7 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
142
164
  <meta name="color-scheme" content="light dark" />
143
165
  <meta name="generator" content={Astro.generator} />
144
166
  <title>{title}</title>
145
- {description && <meta name="description" content={description} />}
167
+ {ogDescription && <meta name="description" content={ogDescription} />}
146
168
 
147
169
  {/* v4.6.0: SEO meta-tag block (issue #76 Primary). canonical + og:* +
148
170
  twitter:* on every page; article:* lives in Chapter.astro via the
@@ -150,7 +172,7 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
150
172
  only when an image is explicitly set (per D3, avoids broken-link
151
173
  OG tags). sitemap link points at @astrojs/sitemap's auto-emitted
152
174
  index. */}
153
- <link rel="canonical" href={canonicalURL} />
175
+ <link rel="canonical" href={canonicalURL} data-pagefind-meta="url[href]" />
154
176
  <link rel="sitemap" type="application/xml" href={`${baseUrl}sitemap-index.xml`} />
155
177
  <meta property="og:title" content={title} />
156
178
  {ogDescription && <meta property="og:description" content={ogDescription} />}
@@ -207,7 +229,7 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
207
229
  )}
208
230
  {showToolsChrome && <ToolFilter client:idle />}
209
231
  <a
210
- href={`${baseUrl}search/`}
232
+ href={searchHref}
211
233
  class="chrome-button"
212
234
  aria-label="Search the book"
213
235
  title="Search"
@@ -243,10 +265,17 @@ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
243
265
  {showSidebar ? (
244
266
  <div class="layout-with-sidebar">
245
267
  <Sidebar />
246
- <main class="layout-main"><slot /></main>
268
+ <main
269
+ class="layout-main"
270
+ data-pagefind-body={pagefindFilter ? true : undefined}
271
+ data-pagefind-filter={pagefindFilter}
272
+ ><slot /></main>
247
273
  </div>
248
274
  ) : (
249
- <main><slot /></main>
275
+ <main
276
+ data-pagefind-body={pagefindFilter ? true : undefined}
277
+ data-pagefind-filter={pagefindFilter}
278
+ ><slot /></main>
250
279
  )}
251
280
  {/*
252
281
  Theme-change hook (#103, v4.14.2). Whenever the EFFECTIVE theme changes,
@@ -31,8 +31,10 @@ import ChapterNav from '../components/ChapterNav.astro';
31
31
  interface Props {
32
32
  entry: CollectionEntry<'chapters'>;
33
33
  headings: MarkdownHeading[];
34
+ /** v5.0.0 (#80): manifest identity derived from the entry id. */
35
+ bookId?: string;
34
36
  }
35
- const { entry, headings } = Astro.props;
37
+ const { entry, headings, bookId } = Astro.props;
36
38
 
37
39
  // v4.6.0: article:* meta-tag data. All fields are Zod-optional in the
38
40
  // chapter schemas (academic / tools / course-notes / research-portfolio),
@@ -40,7 +42,12 @@ const { entry, headings } = Astro.props;
40
42
  // (the top-level defineBookConfig field) so single-author books don't
41
43
  // repeat themselves per chapter.
42
44
  const articleAuthor =
43
- (entry.data as { author?: string }).author ?? bookConfig.author ?? null;
45
+ (entry.data as { author?: string }).author ??
46
+ (bookId && bookConfig.corpus
47
+ ? bookConfig.corpus.books.find((book) => book.id === bookId)?.author
48
+ : undefined) ??
49
+ bookConfig.author ??
50
+ null;
44
51
  const articlePublished = (entry.data as { published?: Date }).published;
45
52
  const articleUpdated = (entry.data as { updated?: Date }).updated;
46
53
  const articleTags = ((entry.data as { tags?: string[] }).tags ?? []) as string[];
@@ -85,6 +92,7 @@ const sectionMapLabel = labelParts.length > 0 ? labelParts.join(' · ') : undefi
85
92
  description={entry.data.description}
86
93
  ogType="article"
87
94
  ogImage={chapterImage}
95
+ bookId={bookId}
88
96
  >
89
97
  <Fragment slot="head">
90
98
  {articleAuthor && <meta property="article:author" content={articleAuthor} />}
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 with five typed presets, Tufte typography, citations, search, PDF, and Cloudflare deployment.",
4
- "version": "4.30.0",
4
+ "version": "5.0.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -156,6 +156,7 @@
156
156
  "src/profiles",
157
157
  "src/profile-kit.ts",
158
158
  "src/schemas.ts",
159
+ "src/types.ts",
159
160
  "components",
160
161
  "styles",
161
162
  "layouts",
@@ -170,10 +171,13 @@
170
171
  "README.md",
171
172
  "LICENSE",
172
173
  "LICENSE-CONTENT",
173
- "LATEX_TO_MDX_MAPPING.md"
174
+ "LATEX_TO_MDX_MAPPING.md",
175
+ "MIGRATION-v4-to-v5.md"
174
176
  ],
175
177
  "scripts": {
176
178
  "build": "tsup",
179
+ "sync:figure-tokens": "node scripts/sync-figure-tokens.mjs --write",
180
+ "check:figure-tokens": "node scripts/sync-figure-tokens.mjs --check",
177
181
  "check:types": "tsc -p tests/types/tsconfig.json",
178
182
  "prepublishOnly": "npm run build && npm run check:types",
179
183
  "test": "node --test tests/*.test.mjs"
@@ -27,6 +27,10 @@ import bookConfig from 'virtual:book-scaffold/book-config';
27
27
  import { getAllQuestions, groupByChapter } from '../src/lib/questions';
28
28
  import { assertKnownDomain } from '../src/lib/exam-domains';
29
29
  import { normalizeBase } from '../src/lib/nav-href';
30
+ import { corpusBookHasApparatusRoute } from '../src/lib/corpus';
31
+
32
+ interface Props { bookId?: string; }
33
+ const { bookId } = Astro.props;
30
34
 
31
35
  // Presence-gate: only touch the collection when the directory holds files.
32
36
  const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}', {
@@ -34,9 +38,11 @@ const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}',
34
38
  import: 'default',
35
39
  eager: true,
36
40
  });
37
- const hasQuestions = Object.keys(questionModules).length > 0;
41
+ const hasQuestions = bookId
42
+ ? Object.keys(questionModules).some((path) => path.includes(`/questions/${bookId}/`))
43
+ : Object.keys(questionModules).length > 0;
38
44
 
39
- const entries = hasQuestions ? await getAllQuestions() : [];
45
+ const entries = hasQuestions ? await getAllQuestions(bookId) : [];
40
46
  for (const entry of entries) {
41
47
  assertKnownDomain(bookConfig.examDomains, entry.data.domain, { id: entry.data.id });
42
48
  }
@@ -53,18 +59,30 @@ const byChapter = groupByChapter(rendered);
53
59
  const total = rendered.length;
54
60
  // #142: practice-exam + chapter links must prefix the deploy base.
55
61
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
56
- const practiceHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
57
- ? `${baseUrl}practice-exam`
62
+ const bookRoutePrefix = bookId ? `${bookId}/` : '';
63
+ const hasPracticeExam = bookId && bookConfig.corpus
64
+ ? corpusBookHasApparatusRoute(
65
+ bookConfig.corpus,
66
+ bookId,
67
+ 'practice-exam',
68
+ bookConfig.apparatusRoutes,
69
+ )
70
+ : (bookConfig.enabledRoutes ?? []).includes('practiceExam');
71
+ const practiceHref = hasPracticeExam
72
+ ? `${baseUrl}${bookRoutePrefix}practice-exam`
58
73
  : null;
59
74
 
60
75
  /** A string chapter ref is a slug (schema kebab-case branch) → linkable. */
61
76
  function chapterHref(chapter: number | string): string | null {
62
- return typeof chapter === 'string' ? `${baseUrl}chapters/${chapter}/` : null;
77
+ return typeof chapter === 'string'
78
+ ? `${baseUrl}chapters/${bookRoutePrefix}${chapter}/`
79
+ : null;
63
80
  }
64
81
  ---
65
82
  <Base
66
83
  title="Answers & rationales"
67
84
  description="Answer appendix: every practice question with its correct answer and full rationale, grouped by chapter."
85
+ bookId={bookId}
68
86
  >
69
87
  <article class="prose answers-appendix">
70
88
  <h1>Answers &amp; rationales</h1>
@@ -87,7 +105,8 @@ function chapterHref(chapter: number | string): string | null {
87
105
  )}
88
106
 
89
107
  {[...byChapter.entries()].map(([chapterKey, qs]) => {
90
- const href = chapterHref(qs[0]!.data.chapter);
108
+ const firstQuestion = qs[0];
109
+ const href = firstQuestion ? chapterHref(firstQuestion.data.chapter) : null;
91
110
  return (
92
111
  <section class="answers-chapter" id={`chapter-${chapterKey}`}>
93
112
  <h2 class="answers-chapter-title">
@@ -0,0 +1,75 @@
1
+ ---
2
+ /** v5.0.0 (#80): manifest-backed per-book landing page. */
3
+ import Base from '../layouts/Base.astro';
4
+ import bookConfig from 'virtual:book-scaffold/book-config';
5
+ import { apparatusHref, normalizeBase } from '../src/lib/nav-href';
6
+
7
+ export function getStaticPaths() {
8
+ if (!bookConfig.corpus) return [];
9
+ return bookConfig.corpus.books.map((book) => ({
10
+ params: { book: book.id },
11
+ props: { book },
12
+ }));
13
+ }
14
+
15
+ const { book } = Astro.props;
16
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
17
+ const globalApparatus = bookConfig.apparatusRoutes ?? [];
18
+ const routes = book.apparatus ?? globalApparatus;
19
+ const routeLabels: Record<string, string> = {
20
+ references: 'References',
21
+ print: 'Print view',
22
+ convergence: 'Convergence',
23
+ tips: 'Tips',
24
+ exercises: 'Exercises',
25
+ 'practice-exam': 'Practice exam',
26
+ glossary: 'Glossary',
27
+ flashcards: 'Flashcards',
28
+ answers: 'Answers',
29
+ };
30
+ ---
31
+
32
+ <Base
33
+ title={book.title}
34
+ description={book.description}
35
+ ogImage={book.image}
36
+ bookId={book.id}
37
+ showChrome={false}
38
+ >
39
+ <article class="prose book-landing">
40
+ <h1>{book.title}</h1>
41
+ {book.subtitle && <p class="book-subtitle">{book.subtitle}</p>}
42
+ {book.description && <p class="lead">{book.description}</p>}
43
+
44
+ <h2>Read</h2>
45
+ <ul>
46
+ <li><a href={`${baseUrl}chapters/${book.id}/`}>Chapters</a></li>
47
+ {routes.map((route: string) => (
48
+ <li>
49
+ <a href={apparatusHref(route, book.id, '/:book/:route/', baseUrl)}>
50
+ {routeLabels[route] ?? route.replaceAll('-', ' ')}
51
+ </a>
52
+ </li>
53
+ ))}
54
+ </ul>
55
+
56
+ {bookConfig.corpus && bookConfig.corpus.books.length > 1 && (
57
+ <p><a href={baseUrl}>← All books</a></p>
58
+ )}
59
+ </article>
60
+ </Base>
61
+
62
+ <style>
63
+ .book-landing {
64
+ max-width: 65ch;
65
+ margin: 2rem auto;
66
+ }
67
+ .book-subtitle {
68
+ color: var(--color-text-muted);
69
+ font-family: var(--font-sans);
70
+ font-size: var(--text-lg);
71
+ }
72
+ .lead {
73
+ font-size: var(--text-lg);
74
+ }
75
+ </style>
@@ -21,21 +21,53 @@ import type { Provenance } from '../../src/schemas.js';
21
21
  import Chapter from '../../layouts/Chapter.astro';
22
22
  import Base from '../../layouts/Base.astro';
23
23
  import ProvenanceBlock from '../../components/Provenance.astro';
24
+ import bookConfig from 'virtual:book-scaffold/book-config';
25
+ import { corpusBookIdOf, resolveCorpusBook } from '../../src/lib/corpus';
24
26
 
25
- const BOOK_PRESET = import.meta.env.BOOK_PRESET ?? 'minimal';
27
+ const BOOK_PRESET = import.meta.env.BOOK_PRESET;
28
+ if (!BOOK_PRESET) {
29
+ throw new Error('book-scaffold runtime is missing the integration-defined BOOK_PRESET.');
30
+ }
26
31
  const USE_CHAPTER_LAYOUT = ['academic', 'research-portfolio'].includes(BOOK_PRESET);
27
32
 
28
33
  export async function getStaticPaths() {
29
34
  const chapters = await getCollection('chapters', (entry) => !entry.data.draft);
30
- return chapters.map((entry) => ({
31
- params: { slug: entry.id },
32
- props: { entry },
33
- }));
35
+ return chapters.map((entry) => {
36
+ if (!bookConfig.corpus) {
37
+ return {
38
+ params: { slug: entry.id },
39
+ props: { entry, book: null },
40
+ };
41
+ }
42
+ const bookId = corpusBookIdOf(bookConfig.corpus, entry.id);
43
+ if (!bookId) {
44
+ throw new Error(
45
+ `Chapter entry ${JSON.stringify(entry.id)} does not start with a registered corpus book id.`,
46
+ );
47
+ }
48
+ const slug = entry.id.slice(bookId.length + 1);
49
+ if (!slug) {
50
+ throw new Error(`Chapter entry ${JSON.stringify(entry.id)} has no book-local slug.`);
51
+ }
52
+ return {
53
+ params: { book: bookId, slug },
54
+ props: { entry, book: resolveCorpusBook(bookConfig.corpus, bookId) },
55
+ };
56
+ });
34
57
  }
35
58
 
36
- const { entry } = Astro.props;
59
+ const { entry, book } = Astro.props;
37
60
  const { Content, headings } = await render(entry);
38
61
  const Layout = USE_CHAPTER_LAYOUT ? Chapter : Base;
62
+ const layoutProps = USE_CHAPTER_LAYOUT
63
+ ? { entry, headings, bookId: book?.id }
64
+ : {
65
+ title: entry.data.title,
66
+ description: entry.data.description,
67
+ ogImage: (entry.data as { image?: string }).image,
68
+ ogType: 'article',
69
+ bookId: book?.id,
70
+ };
39
71
 
40
72
  // v4.8.0: per-chapter provenance audit trail. Rendered here (not in a single
41
73
  // layout) so it reaches BOTH layout paths — Chapter.astro (academic/
@@ -43,7 +75,7 @@ const Layout = USE_CHAPTER_LAYOUT ? Chapter : Base;
43
75
  // always rendered; absent `provenance` → the component's fallback.
44
76
  const provenance = (entry.data as { provenance?: Provenance }).provenance ?? null;
45
77
  ---
46
- <Layout entry={entry} headings={headings}>
78
+ <Layout {...layoutProps}>
47
79
  <Content />
48
80
  <ProvenanceBlock data={provenance} />
49
81
  </Layout>
@@ -0,0 +1,17 @@
1
+ ---
2
+ /** v5.0.0 (#80): one rich chapter index per registered corpus book. */
3
+ import bookConfig from 'virtual:book-scaffold/book-config';
4
+ import ChaptersIndex from './chapters.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books.map((book) => ({
9
+ params: { book: book.id },
10
+ props: { bookId: book.id },
11
+ }));
12
+ }
13
+
14
+ const { bookId } = Astro.props;
15
+ ---
16
+
17
+ <ChaptersIndex bookId={bookId} />
@@ -27,17 +27,39 @@ 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
  import { normalizeBase } from '../src/lib/nav-href';
30
+ import bookConfig from 'virtual:book-scaffold/book-config';
31
+ import { resolveCorpusBook } from '../src/lib/corpus';
32
+
33
+ interface Props {
34
+ /** v5.0.0 (#80): supplied by the injected /chapters/[book] wrapper. */
35
+ bookId?: string;
36
+ }
37
+
38
+ const { bookId } = Astro.props;
39
+ const currentBook = bookId && bookConfig.corpus
40
+ ? resolveCorpusBook(bookConfig.corpus, bookId)
41
+ : null;
42
+ const isCorpusRoot = Boolean(bookConfig.corpus && !currentBook);
30
43
 
31
44
  // #142: prefix BASE_URL so chapter-card links stay inside a non-root deploy base.
32
45
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
33
46
 
34
- const profileName = (import.meta.env.BOOK_PROFILE ?? 'minimal') as keyof typeof PROFILES;
47
+ const configuredProfile = import.meta.env.BOOK_PROFILE;
48
+ if (!configuredProfile) {
49
+ throw new Error('book-scaffold runtime is missing the integration-defined BOOK_PROFILE.');
50
+ }
51
+ const profileName = configuredProfile as keyof typeof PROFILES;
35
52
  const profileDef = PROFILES[profileName];
36
53
  const renderer: ChaptersRenderer =
37
54
  (profileDef as { chaptersRenderer?: ChaptersRenderer } | undefined)?.chaptersRenderer
38
55
  ?? fallbackChaptersRenderer;
39
56
 
40
- const chapters = await getAllChapters();
57
+ const allChapters = await getAllChapters();
58
+ const chapters = currentBook
59
+ ? allChapters.filter((chapter) => chapter.id.startsWith(`${currentBook.id}/`))
60
+ : isCorpusRoot
61
+ ? []
62
+ : allChapters;
41
63
 
42
64
  // Precompute each card's render-ready fields in the frontmatter block.
43
65
  // This keeps TypeScript generics (Record<string, unknown> casts, the
@@ -94,21 +116,45 @@ for (const c of chapters) {
94
116
  }
95
117
  ---
96
118
  <Base
97
- title="Chapters"
98
- description="All chapters grouped by Part."
119
+ title={currentBook ? `${currentBook.title} · Chapters` : 'Chapters'}
120
+ description={
121
+ currentBook?.description ??
122
+ (isCorpusRoot ? 'All books in this corpus.' : 'All chapters grouped by Part.')
123
+ }
124
+ bookId={currentBook?.id}
125
+ showSidebar={!isCorpusRoot}
126
+ pagefindSurface={isCorpusRoot ? 'corpus' : undefined}
99
127
  >
100
128
  <article class="prose chapters-index">
101
129
  <header class="chapters-index-header">
102
- <h1>Chapters</h1>
130
+ <h1>{currentBook ? `${currentBook.title} chapters` : 'Chapters'}</h1>
103
131
  <p class="chapters-index-lede">
104
- Every chapter, grouped by Part. Use the card metadata to calibrate
105
- how much trust to place in a chapter's specific claims.
132
+ {isCorpusRoot
133
+ ? 'Choose a book. Each book keeps its own chapter order, references, and readiness state.'
134
+ : 'Every chapter, grouped by Part. Use the card metadata to calibrate how much trust to place in a chapter’s specific claims.'}
106
135
  </p>
107
136
  </header>
108
137
 
109
- <p class="chapters-filter-hint" id="filter-hint" aria-live="polite"></p>
138
+ {isCorpusRoot && bookConfig.corpus ? (
139
+ <ol class="corpus-book-list">
140
+ {bookConfig.corpus.books.map((book) => {
141
+ const count = allChapters.filter((chapter) => chapter.id.startsWith(`${book.id}/`)).length;
142
+ return (
143
+ <li class="corpus-book-card">
144
+ <a href={`${baseUrl}chapters/${book.id}/`}>
145
+ <h2>{book.title}</h2>
146
+ {book.description && <p>{book.description}</p>}
147
+ <span>{count} {count === 1 ? 'chapter' : 'chapters'}</span>
148
+ </a>
149
+ </li>
150
+ );
151
+ })}
152
+ </ol>
153
+ ) : (
154
+ <p class="chapters-filter-hint" id="filter-hint" aria-live="polite"></p>
155
+ )}
110
156
 
111
- {Array.from(byPart.entries()).map(([part, cards]) => (
157
+ {!isCorpusRoot && Array.from(byPart.entries()).map(([part, cards]) => (
112
158
  <section class="part-group">
113
159
  <h2 class="part-heading">
114
160
  <span class="part-label">{renderer.formatPartLabel(part)}</span>
@@ -248,6 +294,38 @@ for (const c of chapters) {
248
294
  </script>
249
295
 
250
296
  <style>
297
+ .corpus-book-list {
298
+ display: grid;
299
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
300
+ gap: var(--space-5);
301
+ list-style: none;
302
+ padding: 0;
303
+ }
304
+ .corpus-book-card {
305
+ margin: 0;
306
+ }
307
+ .corpus-book-card > a {
308
+ display: block;
309
+ height: 100%;
310
+ padding: var(--space-5);
311
+ border: 1px solid var(--color-border);
312
+ border-radius: var(--radius-md);
313
+ color: inherit;
314
+ text-decoration: none;
315
+ background: var(--color-bg-subtle);
316
+ }
317
+ .corpus-book-card > a:hover,
318
+ .corpus-book-card > a:focus-visible {
319
+ border-color: var(--color-link);
320
+ }
321
+ .corpus-book-card h2 {
322
+ margin-top: 0;
323
+ }
324
+ .corpus-book-card span {
325
+ color: var(--color-text-muted);
326
+ font-family: var(--font-code);
327
+ font-size: var(--text-sm);
328
+ }
251
329
  .chapters-index-header {
252
330
  margin-bottom: var(--space-8);
253
331
  border-bottom: 1px solid var(--color-border);
@@ -7,8 +7,9 @@
7
7
  * placeholder so readers see the coverage gaps rather than inferring
8
8
  * the registry is exhaustive.
9
9
  *
10
- * Driven entirely from changelog/patterns.yaml + changelog/tools/*.yaml.
11
- * No code change is needed to grow the dashboard add manifest rows.
10
+ * Driven from changelog/patterns.yaml + changelog/tools/*.yaml in a
11
+ * single-book app, or changelog/<book>/ in corpus mode. No code change is
12
+ * needed to grow the dashboard — add manifest rows.
12
13
  */
13
14
  import Base from '../layouts/Base.astro';
14
15
  import PatternTimeline from '../components/PatternTimeline.astro';
@@ -19,6 +20,9 @@ import {
19
20
  } from '../src/lib/patterns';
20
21
  import { patternCategories } from '@brandon_m_behring/book-scaffold-astro';
21
22
 
23
+ interface Props { bookId?: string; }
24
+ const { bookId } = Astro.props;
25
+
22
26
  // A tools-profile book may use the inline <Convergence> component yet define
23
27
  // no `changelog/patterns.yaml` — then the `patterns` collection is never
24
28
  // registered and getCollection('patterns') errors with "The collection
@@ -27,14 +31,37 @@ import { patternCategories } from '@brandon_m_behring/book-scaffold-astro';
27
31
  // presence-gate references.astro uses for the optional `sources` collection.
28
32
  // When absent, render the dashboard's honest empty-state from a pure
29
33
  // all-empty map (no content-collection access).
30
- const patternsManifest = import.meta.glob('/changelog/patterns.yaml', {
34
+ const rootPatternsManifest = import.meta.glob('/changelog/patterns.yaml', {
35
+ query: '?raw',
36
+ import: 'default',
37
+ eager: true,
38
+ });
39
+ const corpusPatternManifests = import.meta.glob('/changelog/*/patterns.yaml', {
40
+ query: '?raw',
41
+ import: 'default',
42
+ eager: true,
43
+ });
44
+ const rootToolFiles = import.meta.glob('/changelog/tools/*.yaml', {
45
+ query: '?raw',
46
+ import: 'default',
47
+ eager: true,
48
+ });
49
+ const corpusToolFiles = import.meta.glob('/changelog/*/tools/*.yaml', {
31
50
  query: '?raw',
32
51
  import: 'default',
33
52
  eager: true,
34
53
  });
35
- const hasPatterns = '/changelog/patterns.yaml' in patternsManifest;
54
+ const patternsPath = bookId
55
+ ? `/changelog/${bookId}/patterns.yaml`
56
+ : '/changelog/patterns.yaml';
57
+ const hasPatterns = bookId
58
+ ? patternsPath in corpusPatternManifests
59
+ : patternsPath in rootPatternsManifest;
60
+ const hasChangelog = bookId
61
+ ? Object.keys(corpusToolFiles).some((path) => path.startsWith(`/changelog/${bookId}/tools/`))
62
+ : Object.keys(rootToolFiles).length > 0;
36
63
  const grouped = hasPatterns
37
- ? await getPatternsByCategory()
64
+ ? await getPatternsByCategory(bookId)
38
65
  : emptyPatternsByCategory();
39
66
  const totalPatterns = Object.values(grouped).reduce(
40
67
  (n, arr) => n + arr.length,
@@ -46,11 +73,12 @@ const totalPatterns = Object.values(grouped).reduce(
46
73
  // Surface an actionable hint instead — matching tips.astro / references.astro.
47
74
  const noManifestHint = hasPatterns
48
75
  ? null
49
- : 'No changelog/patterns.yaml found — create it to populate this dashboard, or set routes: { convergence: false } to drop this page.';
76
+ : `No ${patternsPath.slice(1)} found — create it to populate this dashboard, or remove convergence from this book's apparatus.`;
50
77
  ---
51
78
  <Base
52
79
  title="Convergence — Agentic Coding"
53
80
  description="Which agentic-coding patterns have converged across Claude Code, Gemini CLI, and Codex CLI — and when? A live timeline driven from the changelog manifest."
81
+ bookId={bookId}
54
82
  >
55
83
  <article class="prose convergence-dashboard">
56
84
  <header class="convergence-header">
@@ -91,7 +119,9 @@ const noManifestHint = hasPatterns
91
119
  </p>
92
120
  ) : (
93
121
  <div class="convergence-card-list">
94
- {patterns.map((p) => <PatternTimeline pattern={p} />)}
122
+ {patterns.map((p) => (
123
+ <PatternTimeline pattern={p} bookId={bookId} hasChangelog={hasChangelog} />
124
+ ))}
95
125
  </div>
96
126
  )}
97
127
  </section>
@@ -101,9 +131,9 @@ const noManifestHint = hasPatterns
101
131
  <section class="convergence-method">
102
132
  <h2>How to read this</h2>
103
133
  <p>
104
- The registry lives at <code>changelog/patterns.yaml</code>.
105
- Each tool's adoption timeline lives at
106
- <code>changelog/tools/&lt;tool&gt;.yaml</code>. The dashboard
134
+ The registry lives at <code>{patternsPath.slice(1)}</code>.
135
+ Each tool's adoption timeline lives beside it under
136
+ <code>{bookId ? `changelog/${bookId}/tools/` : 'changelog/tools/'}</code>. The dashboard
107
137
  joins them at build time — no code edits needed to add new
108
138
  patterns or new adoption events.
109
139
  </p>
@@ -0,0 +1,15 @@
1
+ ---
2
+ import bookConfig from 'virtual:book-scaffold/book-config';
3
+ import { corpusBookHasApparatusRoute } from '../../src/lib/corpus';
4
+ import AnswersPage from '../answers.astro';
5
+
6
+ export function getStaticPaths() {
7
+ if (!bookConfig.corpus) return [];
8
+ return bookConfig.corpus.books
9
+ .filter((book) => corpusBookHasApparatusRoute(bookConfig.corpus!, book.id, 'answers', bookConfig.apparatusRoutes))
10
+ .map((book) => ({ params: { book: book.id }, props: { bookId: book.id } }));
11
+ }
12
+
13
+ const { bookId } = Astro.props;
14
+ ---
15
+ <AnswersPage bookId={bookId} />