@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
@@ -51,6 +51,13 @@ import remarkMdx from 'remark-mdx';
51
51
  import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
52
52
  import { readEnvFile } from './read-env.mjs';
53
53
  import { loadResolvedBookConfig } from './resolve-book-config.mjs';
54
+ import {
55
+ assertCorpusEnvelope,
56
+ frontmatterSlug,
57
+ legacyFrontmatterBook,
58
+ parseFrontmatter,
59
+ resolveBookSelection,
60
+ } from './corpus-tooling.mjs';
54
61
  import {
55
62
  findAuthoredTargets,
56
63
  normalizeAstroBase,
@@ -67,7 +74,8 @@ internal authored links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
67
74
 
68
75
  Options:
69
76
  --preset <name> academic | tools | minimal | course-notes | research-portfolio
70
- Legacy override when no scaffold integration is resolved.
77
+ Explicit override when no scaffold integration is resolved.
78
+ --book <id> In corpus mode, validate only one registered book.
71
79
  --help, -h Print this message and exit (non-mutating).
72
80
 
73
81
  Env:
@@ -76,6 +84,7 @@ Env:
76
84
  BOOK_REPO_ROOT Absolute path to a sibling code repo for CodeRef checks.
77
85
 
78
86
  Exit code = total failure count, capped at 255 so failures never wrap to success.
87
+ No preset is inferred: configure a Style/corpus, content schema preset, flag, or env.
79
88
  `;
80
89
 
81
90
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
@@ -93,11 +102,6 @@ const presetFromFlag = presetFlagIdx >= 0 ? argv[presetFlagIdx + 1] : undefined;
93
102
  // Resolves issue #8 — three reference consumers reported "0 chapter(s) checked"
94
103
  // because ROOT was the package directory inside node_modules.
95
104
  const ROOT = process.cwd();
96
- // v4.1.1 (closes #63): read the consumer's content.config.{ts,mjs,js} to
97
- // honor `loader.base` overrides (multi-guide pattern uses
98
- // `src/content/<guide-slug>/` instead of the Astro 5 default).
99
- // Falls back to `src/content/chapters` when no override / no config file.
100
- const CHAPTERS_DIR = await readChaptersBase(ROOT);
101
105
  const PUBLIC_DIR = resolve(ROOT, 'public');
102
106
  const DATA_DIR = resolve(ROOT, 'src/data');
103
107
 
@@ -108,13 +112,32 @@ try {
108
112
  process.stderr.write(`validate: fatal: ${error?.message ?? error}\n`);
109
113
  process.exit(1);
110
114
  }
115
+ let BOOK_SELECTION;
116
+ try {
117
+ BOOK_SELECTION = resolveBookSelection(TOOLING_CONFIG, argv, 'validate');
118
+ } catch (error) {
119
+ const prefix = TOOLING_CONFIG.corpus ? '[book:corpus] ' : '';
120
+ process.stderr.write(`${prefix}validate: fatal: ${error?.message ?? error}\n`);
121
+ process.exit(1);
122
+ }
123
+ const CORPUS_DIAGNOSTIC_PREFIX = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
124
+ // Corpus content uses one root with a registered first segment per book;
125
+ // single-book consumers retain the historical chapters directory.
126
+ let CHAPTERS_DIR;
127
+ try {
128
+ CHAPTERS_DIR = await readChaptersBase(ROOT, { corpus: BOOK_SELECTION.corpus });
129
+ } catch (error) {
130
+ process.stderr.write(
131
+ `${CORPUS_DIAGNOSTIC_PREFIX}validate: fatal: ${error?.message ?? error}\n`,
132
+ );
133
+ process.exit(1);
134
+ }
111
135
 
112
136
  // Preset resolution:
113
137
  // composed Astro-config preset > --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
114
138
  // .env BOOK_PRESET > .env BOOK_PROFILE >
115
139
  // defineBookSchemas({ preset }) in content.config.ts >
116
- // defineBookSchemas({ profile }) in content.config.ts (alias) >
117
- // warned v4 compatibility fallback 'minimal'.
140
+ // defineBookSchemas({ profile }) in content.config.ts (alias).
118
141
  // .env fallback closes #20 — without it, consumers who set BOOK_PROFILE in
119
142
  // .env (the documented convenience in SKILL.md + create-book defaults) saw
120
143
  // the CLI silently default to minimal, hiding academic-profile errors.
@@ -134,27 +157,99 @@ const PRESET_CANDIDATE =
134
157
  schemaConfig.preset;
135
158
  const PRESETS = ['academic', 'tools', 'minimal', 'course-notes', 'research-portfolio'];
136
159
  if (presetFlagIdx >= 0 && !presetFromFlag) {
137
- process.stderr.write('validate: --preset requires a value.\n');
160
+ process.stderr.write(`${CORPUS_DIAGNOSTIC_PREFIX}validate: --preset requires a value.\n`);
138
161
  process.exit(2);
139
162
  }
140
163
  if (PRESET_CANDIDATE && !PRESETS.includes(PRESET_CANDIDATE)) {
141
164
  process.stderr.write(
142
- `validate: preset must be one of ${PRESETS.join(' | ')} ` +
165
+ `${CORPUS_DIAGNOSTIC_PREFIX}validate: preset must be one of ${PRESETS.join(' | ')} ` +
143
166
  `(got ${JSON.stringify(PRESET_CANDIDATE)}).\n`,
144
167
  );
145
168
  process.exit(1);
146
169
  }
147
- const PRESET = PRESET_CANDIDATE ?? 'minimal';
148
170
  if (!PRESET_CANDIDATE) {
149
171
  process.stderr.write(
150
- "validate: no preset resolved; falling back to 'minimal' for v4 compatibility. " +
151
- 'This fallback will be removed in v5; configure a built-in style or BOOK_PRESET.\n',
172
+ `${CORPUS_DIAGNOSTIC_PREFIX}validate: no book preset was resolved. Add a built-in Style to ` +
173
+ '`defineBookConfig({ styles: [...] })` and pass the same preset to ' +
174
+ '`defineBookSchemas({ preset: "..." })`, pass one `defineBookCorpus` manifest ' +
175
+ 'to both entrypoints, use --preset, or set BOOK_PRESET in the environment or .env. ' +
176
+ `Valid presets: ${PRESETS.join(' | ')}. See ` +
177
+ 'https://github.com/brandon-behring/book-scaffold-astro/blob/main/' +
178
+ 'package/MIGRATION-v4-to-v5.md.\n',
152
179
  );
180
+ process.exit(1);
153
181
  }
182
+ const PRESET = PRESET_CANDIDATE;
154
183
  // Alias kept for downstream message text only; the resolution above is canonical.
155
184
  const PROFILE = PRESET;
156
185
  const MATH_ENABLED = PROFILE === 'academic' || PROFILE === 'research-portfolio';
157
186
  const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
187
+ const corpusDependencyParser = unified().use(remarkParse).use(remarkMdx);
188
+ if (MATH_ENABLED) corpusDependencyParser.use(remarkMath);
189
+
190
+ let selectedCorpusDependencies = null;
191
+ async function resolveSelectedCorpusDependencies() {
192
+ if (selectedCorpusDependencies) return selectedCorpusDependencies;
193
+
194
+ const routeBooks = new Set(BOOK_SELECTION.books.map((book) => book.id));
195
+ const labelBooks = new Set(BOOK_SELECTION.books.map((book) => book.id));
196
+ if (!BOOK_SELECTION.corpus || BOOK_SELECTION.requestedBook === null) {
197
+ selectedCorpusDependencies = { routeBooks, labelBooks };
198
+ return selectedCorpusDependencies;
199
+ }
200
+
201
+ const selectedBook = BOOK_SELECTION.books[0];
202
+ const selectedDir = join(CHAPTERS_DIR, selectedBook.id);
203
+ const corpusIds = new Set(BOOK_SELECTION.corpus.books.map((book) => book.id));
204
+ const configuredBase = normalizeAstroBase(TOOLING_CONFIG.base);
205
+ for await (const file of walkMdx(selectedDir)) {
206
+ const content = await readFile(join(selectedDir, file), 'utf8');
207
+
208
+ // Root-absolute Markdown chapter links need only the referenced book's
209
+ // slug index. Structural collection avoids examples in code/comments.
210
+ try {
211
+ const targets = findAuthoredTargets(content, {
212
+ format: authoredFormat(file),
213
+ math: MATH_ENABLED,
214
+ });
215
+ for (const target of targets) {
216
+ if (target.kind !== 'Markdown link destination') continue;
217
+ const pathname = rootTargetPathname(target.target);
218
+ if (pathname === null) continue;
219
+ const localPath =
220
+ configuredBase !== '/' &&
221
+ (pathname === configuredBase || pathname.startsWith(`${configuredBase}/`))
222
+ ? pathname.slice(configuredBase.length) || '/'
223
+ : pathname;
224
+ const match = localPath.match(/^\/chapters\/([^/]+)(?:\/|$)/);
225
+ if (match && corpusIds.has(match[1])) routeBooks.add(match[1]);
226
+ }
227
+ } catch {
228
+ // The main validation pass reports the parse failure with source context.
229
+ }
230
+
231
+ if (!content.includes('<BookLink')) continue;
232
+ try {
233
+ for (const element of mdxElements(corpusDependencyParser.parse(content), 'BookLink')) {
234
+ const bookAttr = mdxStringProp(element, 'book');
235
+ const toAttr = mdxStringProp(element, 'to');
236
+ if (!bookAttr.literal || !toAttr.literal || !corpusIds.has(bookAttr.value)) continue;
237
+ const target = normalizeCorpusTarget(
238
+ BOOK_SELECTION.corpus.books.find((book) => book.id === bookAttr.value),
239
+ toAttr.value,
240
+ );
241
+ if (target.error || !target.chapterSlug) continue;
242
+ routeBooks.add(bookAttr.value);
243
+ if (target.fragment !== null) labelBooks.add(bookAttr.value);
244
+ }
245
+ } catch {
246
+ // The main validation pass reports the parse failure with source context.
247
+ }
248
+ }
249
+
250
+ selectedCorpusDependencies = { routeBooks, labelBooks };
251
+ return selectedCorpusDependencies;
252
+ }
158
253
 
159
254
  // v4.6.0 (issue #76 Layer 3b): chapter-route shadow warning. Detect a
160
255
  // consumer-owned `src/pages/chapters/[...slug].astro` that shadows the
@@ -185,7 +280,10 @@ const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
185
280
  }
186
281
  if (!chaptersDisabled) {
187
282
  console.warn(
188
- `\n⚠ Consumer-owned chapter route at src/pages/chapters/[...slug].astro\n` +
283
+ (BOOK_SELECTION.corpus
284
+ ? '[book:corpus] validate: WARN consumer-owned chapter route at '
285
+ : '\n⚠ Consumer-owned chapter route at ') +
286
+ `src/pages/chapters/[...slug].astro\n` +
189
287
  ` shadows the scaffold v4.3.0+ auto-injected route. Either:\n` +
190
288
  ` • Delete the consumer file to defer to the scaffold (recommended), OR\n` +
191
289
  ` • Set 'routes: { chapters: false }' in defineBookConfig to keep\n` +
@@ -213,7 +311,10 @@ const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
213
311
  }
214
312
  if (!landingDisabled) {
215
313
  console.warn(
216
- `\n⚠ Consumer-owned landing page at src/pages/index.astro shadows the\n` +
314
+ (BOOK_SELECTION.corpus
315
+ ? '[book:corpus] validate: WARN consumer-owned landing page at '
316
+ : '\n⚠ Consumer-owned landing page at ') +
317
+ `src/pages/index.astro shadows the\n` +
217
318
  ` scaffold's auto-injected "/" route. Your page wins today, but Astro\n` +
218
319
  ` has announced route collisions become a HARD ERROR in a future\n` +
219
320
  ` version. Set 'routes: { landing: false }' in defineBookConfig to\n` +
@@ -226,18 +327,36 @@ const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
226
327
 
227
328
  const errors = [];
228
329
  const warnings = [];
229
- const fail = (file, line, msg) => errors.push({ file, line, msg });
230
- const warn = (file, line, msg) => warnings.push({ file, line, msg });
330
+ let ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
331
+ const fail = (file, line, msg, book = ACTIVE_BOOK_ID) => errors.push({ file, line, msg, book });
332
+ const warn = (file, line, msg, book = ACTIVE_BOOK_ID) => warnings.push({ file, line, msg, book });
333
+ const invalidFrontmatterFiles = new Set();
334
+ function recordFrontmatterFailure(error, file, book) {
335
+ invalidFrontmatterFiles.add(file);
336
+ fail(
337
+ file,
338
+ error?.frontmatterLine ?? 1,
339
+ error?.frontmatterDetail ?? `Invalid YAML frontmatter: ${error?.message ?? error}`,
340
+ book,
341
+ );
342
+ }
231
343
 
232
344
  // ===== Self-heal missing generated artifacts (#186) =====
233
345
  // These files are intentionally gitignored. Direct `book-scaffold validate`
234
346
  // bypasses npm's prevalidate lifecycle, so rebuild each missing artifact before
235
- // loading it. Existing files remain untouched; child diagnostics and failures
236
- // are propagated verbatim instead of becoming downstream unknown-id noise.
347
+ // loading it. In selected corpus runs, placeholder-empty requested/dependency
348
+ // namespaces are also materialized to keep results independent of run order.
349
+ // Child diagnostics and failures are propagated verbatim instead of becoming
350
+ // downstream unknown-id noise.
237
351
  const scriptDir = dirname(fileURLToPath(import.meta.url));
238
- function regenerate(scriptName, artifact) {
239
- process.stdout.write(`validate: ${artifact} is missing regenerating via ${scriptName} (#186)\n`);
240
- const result = spawnSync(process.execPath, [join(scriptDir, scriptName)], {
352
+ function regenerate(scriptName, artifact, book = null, reason = null) {
353
+ const prefix = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
354
+ process.stdout.write(
355
+ `${prefix}validate: ${reason ?? `${artifact} is missing — regenerating via ${scriptName} (#186)`}\n`,
356
+ );
357
+ const childArgs = [join(scriptDir, scriptName)];
358
+ if (book) childArgs.push('--book', book);
359
+ const result = spawnSync(process.execPath, childArgs, {
241
360
  cwd: ROOT,
242
361
  env: process.env,
243
362
  encoding: 'utf8',
@@ -247,17 +366,112 @@ function regenerate(scriptName, artifact) {
247
366
  if (result.error || result.status !== 0) {
248
367
  const detail = result.error ? `: ${result.error.message}` : '';
249
368
  process.stderr.write(
250
- `validate: ${scriptName} failed (exit ${result.status ?? 1})${detail} — cannot self-heal.\n`,
369
+ `${prefix}validate: ${scriptName} failed (exit ${result.status ?? 1})${detail} — cannot self-heal.\n`,
251
370
  );
252
371
  process.exit(result.status ?? 1);
253
372
  }
254
373
  }
255
374
 
256
- if (!existsSync(join(DATA_DIR, 'labels.json'))) {
257
- regenerate('build-labels.mjs', 'src/data/labels.json');
375
+ async function readableCorpusArtifactBooks(path, artifact) {
376
+ try {
377
+ const value = JSON.parse(await readFile(path, 'utf8'));
378
+ return assertCorpusEnvelope(
379
+ value,
380
+ BOOK_SELECTION.corpus,
381
+ artifact,
382
+ isRecord,
383
+ ).books;
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ function isEmptyRecord(value) {
390
+ return isRecord(value) && Object.keys(value).length === 0;
391
+ }
392
+
393
+ const labelsPath = join(DATA_DIR, 'labels.json');
394
+ const materializedLabels = new Set();
395
+ let materializedAllLabels = false;
396
+ if (!existsSync(labelsPath)) {
397
+ regenerate(
398
+ 'build-labels.mjs',
399
+ 'src/data/labels.json',
400
+ BOOK_SELECTION.requestedBook,
401
+ );
402
+ if (BOOK_SELECTION.requestedBook) materializedLabels.add(BOOK_SELECTION.requestedBook);
403
+ else if (BOOK_SELECTION.corpus) materializedAllLabels = true;
404
+ }
405
+ if (BOOK_SELECTION.requestedBook) {
406
+ const dependencies = await resolveSelectedCorpusDependencies();
407
+ const books = await readableCorpusArtifactBooks(labelsPath, 'src/data/labels.json');
408
+ if (books) {
409
+ for (const book of dependencies.labelBooks) {
410
+ if (materializedLabels.has(book) || !isEmptyRecord(books[book])) continue;
411
+ regenerate(
412
+ 'build-labels.mjs',
413
+ 'src/data/labels.json',
414
+ book,
415
+ `${book === BOOK_SELECTION.requestedBook
416
+ ? `selected book ${book}`
417
+ : `local BookLink from ${BOOK_SELECTION.requestedBook} requires ${book}`} ` +
418
+ 'has an empty labels namespace — materializing it (#186)',
419
+ );
420
+ }
421
+ }
422
+ } else if (BOOK_SELECTION.corpus && !materializedAllLabels) {
423
+ const books = await readableCorpusArtifactBooks(labelsPath, 'src/data/labels.json');
424
+ if (books && BOOK_SELECTION.corpus.books.some((book) => isEmptyRecord(books[book.id]))) {
425
+ regenerate(
426
+ 'build-labels.mjs',
427
+ 'src/data/labels.json',
428
+ null,
429
+ 'corpus labels contain placeholder-empty namespaces — rebuilding all books (#186)',
430
+ );
431
+ }
432
+ }
433
+
434
+ const referencesPath = join(DATA_DIR, 'references.json');
435
+ let materializedReferences = false;
436
+ let materializedAllReferences = false;
437
+ if (!existsSync(referencesPath)) {
438
+ regenerate(
439
+ 'build-bib.mjs',
440
+ 'src/data/references.json',
441
+ BOOK_SELECTION.requestedBook,
442
+ );
443
+ materializedReferences = BOOK_SELECTION.requestedBook !== null;
444
+ materializedAllReferences = Boolean(BOOK_SELECTION.corpus && !BOOK_SELECTION.requestedBook);
258
445
  }
259
- if (!existsSync(join(DATA_DIR, 'references.json'))) {
260
- regenerate('build-bib.mjs', 'src/data/references.json');
446
+ if (BOOK_SELECTION.requestedBook) {
447
+ if (!materializedReferences) {
448
+ const books = await readableCorpusArtifactBooks(
449
+ referencesPath,
450
+ 'src/data/references.json',
451
+ );
452
+ if (books && isEmptyRecord(books[BOOK_SELECTION.requestedBook])) {
453
+ regenerate(
454
+ 'build-bib.mjs',
455
+ 'src/data/references.json',
456
+ BOOK_SELECTION.requestedBook,
457
+ `selected book ${BOOK_SELECTION.requestedBook} has an empty references namespace — ` +
458
+ 'materializing it (#186)',
459
+ );
460
+ }
461
+ }
462
+ } else if (BOOK_SELECTION.corpus && !materializedAllReferences) {
463
+ const books = await readableCorpusArtifactBooks(
464
+ referencesPath,
465
+ 'src/data/references.json',
466
+ );
467
+ if (books && BOOK_SELECTION.corpus.books.some((book) => isEmptyRecord(books[book.id]))) {
468
+ regenerate(
469
+ 'build-bib.mjs',
470
+ 'src/data/references.json',
471
+ null,
472
+ 'corpus references contain placeholder-empty namespaces — rebuilding all books (#186)',
473
+ );
474
+ }
261
475
  }
262
476
 
263
477
  // ===== Load reference data (graceful when missing) =====
@@ -268,8 +482,28 @@ async function loadJson(path) {
268
482
  return {};
269
483
  }
270
484
  }
271
- const refs = await loadJson(join(DATA_DIR, 'references.json'));
272
- const labels = await loadJson(join(DATA_DIR, 'labels.json'));
485
+ function isRecord(value) {
486
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
487
+ }
488
+
489
+ async function loadGeneratedArtifact(name) {
490
+ const path = join(DATA_DIR, name);
491
+ if (!BOOK_SELECTION.corpus) return loadJson(path);
492
+ try {
493
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
494
+ return assertCorpusEnvelope(parsed, BOOK_SELECTION.corpus, `src/data/${name}`, isRecord);
495
+ } catch (error) {
496
+ process.stderr.write(
497
+ `[book:corpus] validate: fatal: ${error?.message ?? error}. ` +
498
+ `Run book-scaffold ${name === 'labels.json' ? 'build-labels' : 'build-bib'} ` +
499
+ 'to regenerate the corpus envelope.\n',
500
+ );
501
+ process.exit(1);
502
+ }
503
+ }
504
+
505
+ const refsArtifact = await loadGeneratedArtifact('references.json');
506
+ const labelsArtifact = await loadGeneratedArtifact('labels.json');
273
507
 
274
508
  // #147: eagerly load every labels index explicitly declared by the evaluated
275
509
  // siblingBooks registry. Unlike this book's generated labels.json, sibling
@@ -304,15 +538,88 @@ for (const [book, entry] of Object.entries(TOOLING_CONFIG.siblingBooks)) {
304
538
  // Node 20 — `npm run validate` crashed on every consumer's prebuild hook.
305
539
  // Walker uses readdir + path only; works on Node 18+.
306
540
  const chapterFiles = [];
307
- for await (const f of walkMdx(CHAPTERS_DIR)) {
308
- if (!f.split('/').pop().startsWith('_')) chapterFiles.push(f);
541
+ const chapterBookByFile = new Map();
542
+ const chapterCountsByBook = new Map();
543
+ if (BOOK_SELECTION.corpus) {
544
+ for (const book of BOOK_SELECTION.books) {
545
+ let count = 0;
546
+ for await (const file of walkMdx(join(CHAPTERS_DIR, book.id))) {
547
+ if (file.split('/').pop().startsWith('_')) continue;
548
+ const scoped = `${book.id}/${file}`;
549
+ chapterFiles.push(scoped);
550
+ chapterBookByFile.set(scoped, book.id);
551
+ count += 1;
552
+ }
553
+ chapterCountsByBook.set(book.id, count);
554
+ }
555
+ } else {
556
+ for await (const file of walkMdx(CHAPTERS_DIR)) {
557
+ if (!file.split('/').pop().startsWith('_')) chapterFiles.push(file);
558
+ }
559
+ }
560
+
561
+ function bookArtifacts(rel) {
562
+ const book = chapterBookByFile.get(rel) ?? null;
563
+ if (!book) return { book: null, refs: refsArtifact, labels: labelsArtifact };
564
+ return {
565
+ book,
566
+ refs: refsArtifact.books[book],
567
+ labels: labelsArtifact.books[book],
568
+ };
309
569
  }
310
570
 
311
571
  // ===== Build slug set from chapter filenames (for internal-link check) =====
312
- const validSlugs = new Set(chapterFiles.map((f) => f.replace(/\.(md|mdx)$/, '')));
313
- const validTopLevelRoutes = new Set([
314
- '/', '/chapters/', '/references/', '/search/', '/print/', '/convergence/',
572
+ const validSlugs = new Set(
573
+ BOOK_SELECTION.corpus
574
+ ? []
575
+ : chapterFiles.map((file) => file.replace(/\.(md|mdx)$/, '')),
576
+ );
577
+ if (BOOK_SELECTION.corpus) {
578
+ // Full validation indexes every book. A selected run indexes its own book
579
+ // plus literal chapter-link dependencies only, so unrelated malformed
580
+ // content cannot make `validate --book <id>` cache-dependent.
581
+ const dependencies = await resolveSelectedCorpusDependencies();
582
+ for (const book of BOOK_SELECTION.corpus.books.filter((candidate) =>
583
+ dependencies.routeBooks.has(candidate.id))) {
584
+ for await (const file of walkMdx(join(CHAPTERS_DIR, book.id))) {
585
+ if (!file.split('/').pop().startsWith('_')) {
586
+ const fileLabel = `[book:${book.id}] ${book.id}/${file}`;
587
+ const source = await readFile(join(CHAPTERS_DIR, book.id, file), 'utf8');
588
+ try {
589
+ const localId = frontmatterSlug(source, fileLabel) ?? file.replace(/\.(md|mdx)$/, '');
590
+ validSlugs.add(`${book.id}/${localId}`);
591
+ } catch (error) {
592
+ recordFrontmatterFailure(error, `${book.id}/${file}`, book.id);
593
+ }
594
+ }
595
+ }
596
+ }
597
+ }
598
+ const validTopLevelRoutes = new Set(
599
+ BOOK_SELECTION.corpus
600
+ ? ['/', '/chapters/', '/search/']
601
+ : ['/', '/chapters/', '/references/', '/search/', '/print/', '/convergence/'],
602
+ );
603
+ const KNOWN_CORPUS_APPARATUS_ROUTES = new Set([
604
+ 'references',
605
+ 'print',
606
+ 'convergence',
607
+ 'tips',
608
+ 'exercises',
609
+ 'glossary',
610
+ 'practice-exam',
611
+ 'flashcards',
612
+ 'answers',
315
613
  ]);
614
+ if (BOOK_SELECTION.corpus) {
615
+ for (const book of BOOK_SELECTION.corpus.books) {
616
+ validTopLevelRoutes.add(`/${book.id}/`);
617
+ validTopLevelRoutes.add(`/chapters/${book.id}/`);
618
+ for (const route of book.apparatus ?? TOOLING_CONFIG.apparatusRoutes) {
619
+ validTopLevelRoutes.add(`/${book.id}/${route}/`);
620
+ }
621
+ }
622
+ }
316
623
 
317
624
  // ===== Pattern helpers for component-specific checks =====
318
625
  const RE_CITE = /<Cite[^>]+key=["']([^"']+)["']/g;
@@ -504,10 +811,80 @@ function siblingTargetCandidates(index, fragment) {
504
811
  return candidates;
505
812
  }
506
813
 
814
+ function normalizeCorpusTarget(book, to) {
815
+ if (typeof to !== 'string' || to.trim().length === 0) {
816
+ return { error: 'target must be non-empty' };
817
+ }
818
+ if (to !== to.trim()) return { error: 'surrounding whitespace is not allowed' };
819
+ if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith('/') || to.includes('\\')) {
820
+ return { error: 'absolute URLs and paths are not allowed' };
821
+ }
822
+ if (/[\u0000-\u001f\u007f]/.test(to)) return { error: 'control characters are not allowed' };
823
+
824
+ const query = to.indexOf('?');
825
+ const hash = to.indexOf('#');
826
+ const suffixIndex = [query, hash]
827
+ .filter((index) => index >= 0)
828
+ .reduce((minimum, index) => Math.min(minimum, index), to.length);
829
+ const path = to.slice(0, suffixIndex).replace(/\/+$/, '');
830
+ if (!path) return { error: 'query-only and fragment-only targets are not allowed' };
831
+ const segments = path.split('/');
832
+ if (segments.some((segment) => segment.length === 0)) {
833
+ return { error: 'empty path segments are not allowed' };
834
+ }
835
+ for (const segment of segments) {
836
+ let decoded;
837
+ try {
838
+ decoded = decodeURIComponent(segment);
839
+ } catch {
840
+ return { error: 'malformed percent encoding' };
841
+ }
842
+ if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) {
843
+ return { error: 'path traversal is not allowed' };
844
+ }
845
+ }
846
+
847
+ const localPath = segments.join('/');
848
+ const canonicalPath = localPath === 'chapters'
849
+ ? `chapters/${book.id}`
850
+ : localPath.startsWith('chapters/')
851
+ ? `chapters/${book.id}/${localPath.slice('chapters/'.length)}`
852
+ : `${book.id}/${localPath}`;
853
+ const fragment = hash >= 0 ? to.slice(hash + 1) : null;
854
+ return {
855
+ error: null,
856
+ localPath,
857
+ canonicalPath,
858
+ fragment,
859
+ href: fragment ? `${canonicalPath}#${fragment}` : canonicalPath,
860
+ chapterSlug: localPath.startsWith('chapters/')
861
+ ? `${book.id}/${localPath.slice('chapters/'.length)}`
862
+ : null,
863
+ };
864
+ }
865
+
507
866
  // ===== Run all checks on each chapter =====
508
867
  for (const rel of chapterFiles) {
509
868
  const abs = join(CHAPTERS_DIR, rel);
510
869
  const content = await readFile(abs, 'utf8');
870
+ const artifacts = bookArtifacts(rel);
871
+ const { refs, labels } = artifacts;
872
+ ACTIVE_BOOK_ID = artifacts.book;
873
+ if (artifacts.book && !invalidFrontmatterFiles.has(rel)) {
874
+ try {
875
+ const legacy = legacyFrontmatterBook(content, `[book:${artifacts.book}] ${rel}`);
876
+ if (legacy && legacy.value !== artifacts.book) {
877
+ fail(
878
+ rel,
879
+ legacy.line,
880
+ `frontmatter book ${JSON.stringify(legacy.value)} does not match ` +
881
+ `path-derived corpus book ${JSON.stringify(artifacts.book)}.`,
882
+ );
883
+ }
884
+ } catch (error) {
885
+ recordFrontmatterFailure(error, rel, artifacts.book);
886
+ }
887
+ }
511
888
  const authoredTargets = collectAuthoredTargets(rel, content);
512
889
 
513
890
  // 10. Root-absolute authored targets under a non-root Astro base (#190).
@@ -653,12 +1030,108 @@ for (const rel of chapterFiles) {
653
1030
  }
654
1031
 
655
1032
  const book = bookAttr.value;
1033
+ const corpusBook = BOOK_SELECTION.corpus?.books.find((candidate) => candidate.id === book);
1034
+ if (corpusBook) {
1035
+ if (!toAttr.literal) {
1036
+ warn(
1037
+ rel,
1038
+ line,
1039
+ `<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
1040
+ );
1041
+ continue;
1042
+ }
1043
+
1044
+ const target = normalizeCorpusTarget(corpusBook, toAttr.value);
1045
+ if (target.error) {
1046
+ fail(
1047
+ rel,
1048
+ line,
1049
+ `<BookLink book="${book}" to="${toAttr.value}"> has invalid local corpus target: ` +
1050
+ `${target.error}.`,
1051
+ );
1052
+ continue;
1053
+ }
1054
+
1055
+ if (target.chapterSlug && !validSlugs.has(target.chapterSlug)) {
1056
+ fail(
1057
+ rel,
1058
+ line,
1059
+ `<BookLink book="${book}" to="${toAttr.value}"> — unknown local corpus target ` +
1060
+ `"${target.canonicalPath}".`,
1061
+ );
1062
+ continue;
1063
+ }
1064
+
1065
+ // Exact apparatus roots are known statically and may be checked against
1066
+ // the target book's route set. Nested/custom non-chapter paths remain a
1067
+ // deliberate runtime escape hatch (for example about/team or a custom
1068
+ // glossary detail route), so validate must not reject them merely
1069
+ // because they are absent from the scaffold apparatus registry.
1070
+ if (
1071
+ !target.chapterSlug &&
1072
+ KNOWN_CORPUS_APPARATUS_ROUTES.has(target.localPath) &&
1073
+ !(corpusBook.apparatus ?? TOOLING_CONFIG.apparatusRoutes).includes(target.localPath)
1074
+ ) {
1075
+ fail(
1076
+ rel,
1077
+ line,
1078
+ `<BookLink book="${book}" to="${toAttr.value}"> — local corpus apparatus route ` +
1079
+ `"${target.localPath}" is not enabled for book "${book}".`,
1080
+ );
1081
+ continue;
1082
+ }
1083
+
1084
+ if (target.fragment !== null && target.chapterSlug) {
1085
+ if (target.fragment.length === 0) {
1086
+ fail(
1087
+ rel,
1088
+ line,
1089
+ `<BookLink book="${book}" to="${toAttr.value}"> — local fragment is empty.`,
1090
+ );
1091
+ continue;
1092
+ }
1093
+ const localIndex = labelsArtifact.books[book];
1094
+ const candidates = siblingTargetCandidates(localIndex, target.fragment);
1095
+ if (!candidates.some((candidate) => candidate.normalized.href === target.href)) {
1096
+ const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
1097
+ fail(
1098
+ rel,
1099
+ line,
1100
+ `<BookLink book="${book}" to="${toAttr.value}"> — local fragment ` +
1101
+ `"${target.fragment}" does not resolve in labels.json book namespace "${book}"` +
1102
+ (indexedHrefs ? ` (indexed at ${indexedHrefs})` : '') +
1103
+ '.',
1104
+ );
1105
+ }
1106
+ } else if (target.fragment !== null) {
1107
+ // labels.json indexes chapter prose/components only. Runtime accepts
1108
+ // fragments on arbitrary book-local routes, so a non-chapter fragment
1109
+ // cannot be proven (or disproven) from this artifact.
1110
+ warn(
1111
+ rel,
1112
+ line,
1113
+ `<BookLink book="${book}" to="${toAttr.value}"> non-chapter fragment ` +
1114
+ 'validation skipped; the target route owns its anchors.',
1115
+ );
1116
+ }
1117
+ continue;
1118
+ }
1119
+
656
1120
  const registered = Object.prototype.hasOwnProperty.call(TOOLING_CONFIG.siblingBooks, book);
657
1121
  if (!registered) {
1122
+ const known = [
1123
+ ...(BOOK_SELECTION.corpus?.books.map((candidate) => candidate.id) ?? []),
1124
+ ...Object.keys(TOOLING_CONFIG.siblingBooks),
1125
+ ];
658
1126
  fail(
659
1127
  rel,
660
1128
  line,
661
- `<BookLink book="${book}"> — not in evaluated defineBookConfig siblingBooks (${Object.keys(TOOLING_CONFIG.siblingBooks).join(', ') || 'none'}). Register it or fix the key.`,
1129
+ BOOK_SELECTION.corpus
1130
+ ? `<BookLink book="${book}"> — not a registered local corpus or sibling book ` +
1131
+ `(${known.join(', ') || 'none'}). Register it or fix the key.`
1132
+ : `<BookLink book="${book}"> — not in evaluated defineBookConfig siblingBooks ` +
1133
+ `(${Object.keys(TOOLING_CONFIG.siblingBooks).join(', ') || 'none'}). ` +
1134
+ 'Register it or fix the key.',
662
1135
  );
663
1136
  continue;
664
1137
  }
@@ -805,6 +1278,7 @@ for (const rel of chapterFiles) {
805
1278
  }
806
1279
  }
807
1280
  }
1281
+ ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
808
1282
 
809
1283
  // ===== 8. Questions collection (#112): domain membership + unique ids =====
810
1284
  //
@@ -817,6 +1291,33 @@ for (const rel of chapterFiles) {
817
1291
  // examDomains can't be read from astro.config.mjs, membership is left to the
818
1292
  // build-time throw.
819
1293
  let questionsChecked = 0;
1294
+ const questionsCheckedByBook = new Map();
1295
+
1296
+ if (BOOK_SELECTION.corpus) {
1297
+ const manifestIds = new Set(BOOK_SELECTION.corpus.books.map((book) => book.id));
1298
+ for (const [collection, label] of [
1299
+ ['questions', 'Question'],
1300
+ ['glossary', 'Glossary term'],
1301
+ ]) {
1302
+ const collectionDir = resolve(ROOT, 'src/content', collection);
1303
+ if (!existsSync(collectionDir)) continue;
1304
+ // Collection ownership is path-derived. Scan the complete non-hidden root
1305
+ // even for `--book` so direct files and unknown first segments cannot
1306
+ // silently disappear from every per-book run.
1307
+ for await (const file of walkMdx(collectionDir)) {
1308
+ const owner = file.split('/')[0] ?? '';
1309
+ if (manifestIds.has(owner)) continue;
1310
+ fail(
1311
+ `${collection}/${file}`,
1312
+ 1,
1313
+ `${label} content must be nested under a registered corpus book id ` +
1314
+ `(${[...manifestIds].join(' | ')}); found first segment ${JSON.stringify(owner)}.`,
1315
+ 'corpus',
1316
+ );
1317
+ }
1318
+ }
1319
+ }
1320
+
820
1321
  {
821
1322
  const QUESTIONS_DIR = resolve(ROOT, 'src/content/questions');
822
1323
  if (existsSync(QUESTIONS_DIR)) {
@@ -830,85 +1331,160 @@ let questionsChecked = 0;
830
1331
  }
831
1332
  }
832
1333
 
833
- const seenIds = new Map(); // question id → first file that declared it
834
- const questionFiles = [];
835
- for await (const f of walkMdx(QUESTIONS_DIR)) {
836
- if (!f.split('/').pop().startsWith('_')) questionFiles.push(f);
837
- }
838
- for (const rel of questionFiles) {
839
- const abs = join(QUESTIONS_DIR, rel);
840
- const content = await readFile(abs, 'utf8');
841
- const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
842
- const front = fm ? fm[1] : '';
843
- const qrel = `questions/${rel}`;
1334
+ const runs = BOOK_SELECTION.corpus
1335
+ ? BOOK_SELECTION.books.map((book) => ({
1336
+ book,
1337
+ dir: join(QUESTIONS_DIR, book.id),
1338
+ prefix: `questions/${book.id}`,
1339
+ }))
1340
+ : [{ book: null, dir: QUESTIONS_DIR, prefix: 'questions' }];
844
1341
 
845
- // Questions do not run the legacy link-resolution advisory, so keep the
846
- // #190 root-base contract fully inert by parsing only for a non-root base.
847
- const authoredTargets = ASTRO_BASE === '/' ? [] : collectAuthoredTargets(qrel, content);
848
- validateAuthoredTargets(qrel, content, authoredTargets);
849
-
850
- const idMatch = front.match(/^id\s*:\s*["']?([^"'\n]+?)["']?\s*$/m);
851
- if (idMatch) {
852
- const id = idMatch[1].trim();
853
- if (seenIds.has(id)) {
854
- fail(
855
- qrel,
856
- 1,
857
- `Duplicate question id "${id}" — also declared in ${seenIds.get(id)}. Question ids must be unique (cross-ref key for the appendix / flashcards).`,
1342
+ for (const run of runs) {
1343
+ ACTIVE_BOOK_ID = run.book?.id ?? null;
1344
+ const seenIds = new Map(); // ids are book-local in corpus mode
1345
+ const questionFiles = [];
1346
+ for await (const file of walkMdx(run.dir)) {
1347
+ if (!file.split('/').pop().startsWith('_')) questionFiles.push(file);
1348
+ }
1349
+ for (const rel of questionFiles) {
1350
+ const abs = join(run.dir, rel);
1351
+ const content = await readFile(abs, 'utf8');
1352
+ const qrel = `${run.prefix}/${rel}`;
1353
+ let parsedFrontmatter;
1354
+ try {
1355
+ parsedFrontmatter = parseFrontmatter(
1356
+ content,
1357
+ run.book ? `[book:${run.book.id}] ${qrel}` : qrel,
858
1358
  );
859
- } else {
860
- seenIds.set(id, qrel);
1359
+ } catch (error) {
1360
+ recordFrontmatterFailure(error, qrel, run.book?.id ?? null);
1361
+ continue;
1362
+ }
1363
+ const frontmatter = parsedFrontmatter.frontmatter;
1364
+ if (run.book) {
1365
+ if (
1366
+ Object.prototype.hasOwnProperty.call(frontmatter, 'book') &&
1367
+ frontmatter.book !== run.book.id
1368
+ ) {
1369
+ fail(
1370
+ qrel,
1371
+ parsedFrontmatter.lines.book ?? 2,
1372
+ `frontmatter book ${JSON.stringify(frontmatter.book)} does not match ` +
1373
+ `path-derived corpus book ${JSON.stringify(run.book.id)}.`,
1374
+ );
1375
+ }
861
1376
  }
862
- }
863
1377
 
864
- if (examDomains) {
865
- const domainMatch = front.match(/^domain\s*:\s*["']?([^"'\n]+?)["']?\s*$/m);
866
- if (domainMatch && !examDomains.has(domainMatch[1].trim())) {
867
- fail(
868
- qrel,
869
- 1,
870
- `Question domain "${domainMatch[1].trim()}" not in defineBookConfig examDomains (${[...examDomains].join(', ') || 'none'}). Register it or fix the value.`,
871
- );
1378
+ // Questions do not run the legacy link-resolution advisory, so keep the
1379
+ // #190 root-base contract fully inert by parsing only for a non-root base.
1380
+ const authoredTargets = ASTRO_BASE === '/' ? [] : collectAuthoredTargets(qrel, content);
1381
+ validateAuthoredTargets(qrel, content, authoredTargets);
1382
+
1383
+ const normalizedQuestionId = typeof frontmatter.id === 'string'
1384
+ ? frontmatter.id.trim()
1385
+ : '';
1386
+ const questionId = normalizedQuestionId.length > 0 ? normalizedQuestionId : null;
1387
+ if (questionId) {
1388
+ const id = questionId;
1389
+ if (seenIds.has(id)) {
1390
+ fail(
1391
+ qrel,
1392
+ parsedFrontmatter.lines.id ?? 1,
1393
+ `Duplicate question id "${id}" — also declared in ${seenIds.get(id)}. Question ids must be unique (cross-ref key for the appendix / flashcards).`,
1394
+ );
1395
+ } else {
1396
+ seenIds.set(id, qrel);
1397
+ }
872
1398
  }
873
- }
874
1399
 
875
- // v4.21.0 (#114): <Rationale appendix> needs for= (the /answers#answer-<id>
876
- // anchor target) the component throws at build; flag it here, earlier,
877
- // the same way #7 pre-flights BookLink. Inside a question's own body the
878
- // natural invariant is for= === this file's frontmatter id — a mismatch
879
- // (copy-paste drift) anchors the reader to the wrong (or no) answer,
880
- // which the component CAN'T check (it has no collection access).
881
- // `(^|\s)appendix(\s|=|$)` anchors the bare prop so prose like
882
- // title="See the appendix" can't false-fire.
883
- for (const m of content.matchAll(/<Rationale\b([^>]*)>/g)) {
884
- const attrs = m[1];
885
- if (!/(^|\s)appendix(\s|=|$)/.test(attrs)) continue;
886
- const forMatch = attrs.match(/\bfor\s*=\s*["']([^"']+)["']/);
887
- if (!forMatch) {
888
- fail(
889
- qrel,
890
- lineOf(content, m.index),
891
- `<Rationale appendix> without for="<question-id>" no appendix anchor target; throws at build.`,
892
- );
893
- } else if (idMatch && forMatch[1] !== idMatch[1].trim()) {
894
- fail(
895
- qrel,
896
- lineOf(content, m.index),
897
- `<Rationale appendix for="${forMatch[1]}"> does not match this question's id "${idMatch[1].trim()}" — the /answers anchor would land on the wrong (or no) answer.`,
898
- );
1400
+ if (examDomains) {
1401
+ const normalizedDomain = typeof frontmatter.domain === 'string'
1402
+ ? frontmatter.domain.trim()
1403
+ : '';
1404
+ const domain = normalizedDomain.length > 0 ? normalizedDomain : null;
1405
+ if (domain && !examDomains.has(domain)) {
1406
+ fail(
1407
+ qrel,
1408
+ parsedFrontmatter.lines.domain ?? 1,
1409
+ `Question domain "${domain}" not in defineBookConfig examDomains (${[...examDomains].join(', ') || 'none'}). Register it or fix the value.`,
1410
+ );
1411
+ }
1412
+ }
1413
+
1414
+ // v4.21.0 (#114): <Rationale appendix> needs for= (the /answers#answer-<id>
1415
+ // anchor target) — the component throws at build; flag it here, earlier.
1416
+ for (const m of content.matchAll(/<Rationale\b([^>]*)>/g)) {
1417
+ const attrs = m[1];
1418
+ if (!/(^|\s)appendix(\s|=|$)/.test(attrs)) continue;
1419
+ const forMatch = attrs.match(/\bfor\s*=\s*["']([^"']+)["']/);
1420
+ if (!forMatch) {
1421
+ fail(
1422
+ qrel,
1423
+ lineOf(content, m.index),
1424
+ `<Rationale appendix> without for="<question-id>" — no appendix anchor target; throws at build.`,
1425
+ );
1426
+ } else if (questionId && forMatch[1] !== questionId) {
1427
+ fail(
1428
+ qrel,
1429
+ lineOf(content, m.index),
1430
+ `<Rationale appendix for="${forMatch[1]}"> does not match this question's id "${questionId}" — the /answers anchor would land on the wrong (or no) answer.`,
1431
+ );
1432
+ }
899
1433
  }
900
1434
  }
1435
+ questionsChecked += questionFiles.length;
1436
+ if (run.book) questionsCheckedByBook.set(run.book.id, questionFiles.length);
901
1437
  }
902
- questionsChecked = questionFiles.length;
1438
+ ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
903
1439
  }
904
1440
  }
905
1441
 
906
1442
  // ===== Report =====
907
- const format = ({ file, line, msg }) => ` ${file}:${line} ${msg}`;
1443
+ const format = ({ file, line, msg, book }) =>
1444
+ `${book ? `[book:${book}] ` : ' '}${file}:${line} ${msg}`;
908
1445
  if (warnings.length > 0) {
909
- console.warn(`validate: ${warnings.length} warning(s):`);
910
- warnings.forEach((w) => console.warn(format(w)));
1446
+ const prefix = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
1447
+ console.warn(`${prefix}validate: ${warnings.length} warning(s):`);
1448
+ warnings.forEach((warning) => console.warn(format(warning)));
1449
+ }
1450
+
1451
+ if (BOOK_SELECTION.corpus) {
1452
+ for (const book of BOOK_SELECTION.books) {
1453
+ const count = chapterCountsByBook.get(book.id) ?? 0;
1454
+ const questionCount = questionsCheckedByBook.get(book.id) ?? 0;
1455
+ const questionNote = questionCount > 0 ? ` + ${questionCount} question(s)` : '';
1456
+ const bookErrors = errors.filter((error) => error.book === book.id).length;
1457
+ const bookWarnings = warnings.filter((warning) => warning.book === book.id).length;
1458
+ const result = bookErrors === 0 ? '✓' : '✗';
1459
+ const detail = bookErrors === 0
1460
+ ? 'no errors'
1461
+ : `${bookErrors} error${bookErrors === 1 ? '' : 's'}`;
1462
+ console.log(
1463
+ `[book:${book.id}] validate: ${result} ${count} chapter(s)${questionNote} checked; ${detail}, ` +
1464
+ `${bookWarnings} warning${bookWarnings === 1 ? '' : 's'} ` +
1465
+ `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}).`,
1466
+ );
1467
+ }
1468
+
1469
+ const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
1470
+ const aggregateResult = errors.length === 0 ? '✓' : '✗';
1471
+ const aggregateDetail = errors.length === 0
1472
+ ? 'no errors'
1473
+ : `${errors.length} error${errors.length === 1 ? '' : 's'}`;
1474
+ const aggregateMessage =
1475
+ `[book:corpus] validate: ${aggregateResult} ${chapterFiles.length} chapter(s)${qNote} ` +
1476
+ `across ${BOOK_SELECTION.books.length} book${BOOK_SELECTION.books.length === 1 ? '' : 's'}; ` +
1477
+ `${aggregateDetail}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'} ` +
1478
+ `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle})`;
1479
+ if (errors.length === 0) {
1480
+ console.log(`${aggregateMessage}.`);
1481
+ process.exit(0);
1482
+ }
1483
+ console.error(`${aggregateMessage}:`);
1484
+ errors.forEach((error) => console.error(format(error)));
1485
+ process.exit(Math.min(errors.length, 255));
911
1486
  }
1487
+
912
1488
  if (errors.length === 0) {
913
1489
  const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
914
1490
  console.log(
@@ -921,5 +1497,5 @@ console.error(
921
1497
  `validate: ✗ ${errors.length} error(s) in ${chapterFiles.length} chapter(s) ` +
922
1498
  `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):`,
923
1499
  );
924
- errors.forEach((e) => console.error(format(e)));
1500
+ errors.forEach((error) => console.error(format(error)));
925
1501
  process.exit(Math.min(errors.length, 255));