@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
@@ -125,10 +125,17 @@ export async function* walkMdx(dir, baseDir = dir) {
125
125
  let entries;
126
126
  try {
127
127
  entries = await readdir(dir, { withFileTypes: true });
128
- } catch {
129
- return; // dir missing or unreadable treat as zero chapters
128
+ } catch (error) {
129
+ if (error?.code === 'ENOENT') return; // optional content dir absent
130
+ throw error;
130
131
  }
132
+ entries.sort((a, b) => a.name.localeCompare(b.name));
131
133
  for (const entry of entries) {
134
+ // Astro's content glob excludes underscore-prefixed paths through the
135
+ // explicit `!**/_*` pattern and dot-prefixed paths through tinyglobby's
136
+ // default `dot: false`. Apply both rules centrally so every artifact
137
+ // producer and validate see the same content set, including nested drafts.
138
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
132
139
  const full = join(dir, entry.name);
133
140
  if (entry.isDirectory()) {
134
141
  yield* walkMdx(full, baseDir);
@@ -242,12 +249,17 @@ export async function readBookSchemaConfig(projectRoot) {
242
249
  *
243
250
  * Honors env override: BOOK_CHAPTERS_DIR (when set) wins over config parse.
244
251
  */
245
- export async function readChaptersBase(projectRoot) {
252
+ export async function readChaptersBase(projectRoot, { corpus = null } = {}) {
246
253
  const envOverride = process.env.BOOK_CHAPTERS_DIR;
247
254
  if (envOverride) {
248
255
  return resolve(projectRoot, envOverride);
249
256
  }
250
- const DEFAULT_BASE = resolve(projectRoot, 'src/content/chapters');
257
+ // A corpus registers first-segment book ids under one shared content root;
258
+ // single-book projects retain Astro's historical `chapters/` directory.
259
+ const DEFAULT_BASE = resolve(
260
+ projectRoot,
261
+ corpus ? 'src/content' : 'src/content/chapters',
262
+ );
251
263
  for (const ext of ['ts', 'mjs', 'js']) {
252
264
  const configPath = join(projectRoot, `src/content.config.${ext}`);
253
265
  if (!existsSync(configPath)) continue;
@@ -16,6 +16,78 @@
16
16
  */
17
17
  import type { SiblingBookEntry, SiblingBooks } from '../types.js';
18
18
 
19
+ const CORPUS_BOOK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
20
+
21
+ function normalizedBase(baseUrl: string): string {
22
+ const inner = baseUrl.replace(/^\/+|\/+$/g, '');
23
+ return inner.length === 0 ? '/' : `/${inner}/`;
24
+ }
25
+
26
+ function invalidLocalTarget(book: string, to: string, reason: string): never {
27
+ throw new Error(
28
+ `<BookLink book="${book}" to=${JSON.stringify(to)}>: invalid local corpus target (${reason}).`,
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Resolve a link to another book inside the same corpus application.
34
+ *
35
+ * Chapter targets retain the scaffold's `/chapters/<book>/…` namespace;
36
+ * every other relative target lives below the book landing namespace.
37
+ */
38
+ export function resolveCorpusBookHref(
39
+ book: string,
40
+ to: string,
41
+ baseUrl = '/',
42
+ ): string {
43
+ if (!CORPUS_BOOK_ID.test(book)) {
44
+ throw new Error(`<BookLink book=${JSON.stringify(book)}>: invalid corpus book id.`);
45
+ }
46
+ if (typeof to !== 'string' || to.trim().length === 0) {
47
+ invalidLocalTarget(book, String(to), 'target must be non-empty');
48
+ }
49
+ if (to !== to.trim()) invalidLocalTarget(book, to, 'surrounding whitespace is not allowed');
50
+ if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith('/') || to.includes('\\')) {
51
+ invalidLocalTarget(book, to, 'absolute URLs and paths are not allowed');
52
+ }
53
+ if (/[\u0000-\u001f\u007f]/.test(to)) {
54
+ invalidLocalTarget(book, to, 'control characters are not allowed');
55
+ }
56
+
57
+ const suffixIndex = [...[to.indexOf('?'), to.indexOf('#')]]
58
+ .filter((index) => index >= 0)
59
+ .reduce((minimum, index) => Math.min(minimum, index), to.length);
60
+ const path = to.slice(0, suffixIndex);
61
+ const suffix = to.slice(suffixIndex);
62
+ if (path.length === 0) invalidLocalTarget(book, to, 'query-only and fragment-only targets are not allowed');
63
+
64
+ const segments = path.replace(/\/+$/, '').split('/');
65
+ if (segments.some((segment) => segment.length === 0)) {
66
+ invalidLocalTarget(book, to, 'empty path segments are not allowed');
67
+ }
68
+ for (const segment of segments) {
69
+ let decoded: string;
70
+ try {
71
+ decoded = decodeURIComponent(segment);
72
+ } catch {
73
+ invalidLocalTarget(book, to, 'malformed percent encoding');
74
+ }
75
+ if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) {
76
+ invalidLocalTarget(book, to, 'path traversal is not allowed');
77
+ }
78
+ }
79
+
80
+ const base = normalizedBase(baseUrl);
81
+ const local = segments.join('/');
82
+ const destination =
83
+ local === 'chapters'
84
+ ? `chapters/${book}`
85
+ : local.startsWith('chapters/')
86
+ ? `chapters/${book}/${local.slice('chapters/'.length)}`
87
+ : `${book}/${local}`;
88
+ return `${base}${destination}/`.replace(/\/{2,}/g, '/') + suffix;
89
+ }
90
+
19
91
  function entryUrl(entry: SiblingBookEntry | undefined): string | undefined {
20
92
  if (typeof entry === 'string') return entry;
21
93
  return entry?.url;
@@ -12,6 +12,8 @@
12
12
  import { getCollection, type CollectionEntry } from 'astro:content';
13
13
  import { chapterSortKey } from './chapter-sort.js';
14
14
  import { bookOf } from './nav-href.js';
15
+ import { corpusBookIdOf } from './corpus.js';
16
+ import type { BookCorpus } from '../types.js';
15
17
 
16
18
  export type Chapter = CollectionEntry<'chapters'>;
17
19
 
@@ -38,15 +40,19 @@ export async function getAllChapters(): Promise<Chapter[]> {
38
40
  */
39
41
  export async function getNeighbors(
40
42
  id: string,
41
- opts: { bookField?: string } = {},
43
+ opts: { bookField?: string; corpus?: BookCorpus | null } = {},
42
44
  ): Promise<{ prev: Chapter | null; next: Chapter | null }> {
43
- const { bookField = 'book' } = opts;
45
+ const { bookField = 'book', corpus = null } = opts;
44
46
  const all = await getAllChapters();
45
47
  const self = all.find((c) => c.id === id);
46
48
  if (!self) return { prev: null, next: null };
47
- const selfBook = bookOf({ id: self.id, data: self.data as Record<string, unknown> }, bookField);
49
+ const entryBook = (entry: Chapter) =>
50
+ corpus
51
+ ? corpusBookIdOf(corpus, entry.id)
52
+ : bookOf({ id: entry.id, data: entry.data as Record<string, unknown> }, bookField);
53
+ const selfBook = entryBook(self);
48
54
  const scoped = selfBook
49
- ? all.filter((c) => bookOf({ id: c.id, data: c.data as Record<string, unknown> }, bookField) === selfBook)
55
+ ? all.filter((c) => entryBook(c) === selfBook)
50
56
  : all;
51
57
  const idx = scoped.findIndex((c) => c.id === id);
52
58
  if (idx === -1) return { prev: null, next: null };
@@ -0,0 +1,9 @@
1
+ /** Internal collection names for per-book convergence collateral (#80). */
2
+ export function corpusPatternsCollection(bookId: string): string {
3
+ return `corpus-patterns-${bookId}`;
4
+ }
5
+
6
+ /** Internal collection names for per-book tool timelines (#80). */
7
+ export function corpusChangelogCollection(bookId: string): string {
8
+ return `corpus-changelog-${bookId}`;
9
+ }
@@ -0,0 +1,458 @@
1
+ /** Pure corpus-manifest validation and lookup helpers (#80). */
2
+ import {
3
+ BOOK_PRESETS,
4
+ CORPUS_APPARATUS_ROUTES,
5
+ BookConfigError,
6
+ type BookCorpus,
7
+ type BookCorpusInput,
8
+ type CorpusApparatusRoute,
9
+ type CorpusBook,
10
+ type CorpusBookInput,
11
+ } from '../types.js';
12
+
13
+ const BOOK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
+ const BOOK_CORPUS_BRAND = Symbol.for(
15
+ '@brandon_m_behring/book-scaffold-astro/BookCorpus/v1',
16
+ );
17
+
18
+ /**
19
+ * First-segment names owned by application routes/assets or shared content
20
+ * collection roots rather than a corpus book.
21
+ */
22
+ export const RESERVED_CORPUS_BOOK_IDS = Object.freeze([
23
+ 'assets',
24
+ 'chapters',
25
+ 'search',
26
+ 'questions',
27
+ 'glossary',
28
+ 'frontmatter',
29
+ '_astro',
30
+ '_og',
31
+ 'pagefind',
32
+ ] as const);
33
+
34
+ /** Route metadata derived from the corpus contract, never consumer-overridden. */
35
+ export const CORPUS_OWNED_ROUTE_FIELDS = Object.freeze([
36
+ 'chapterRoute',
37
+ 'bookField',
38
+ 'apparatusRoute',
39
+ 'apparatusRoutes',
40
+ ] as const);
41
+
42
+ /**
43
+ * Public URL slug -> `RouteToggles` key. Keep this explicit: the public
44
+ * `practice-exam` route is intentionally not the camel-cased config key.
45
+ */
46
+ export const CORPUS_APPARATUS_TOGGLE_BY_ROUTE = Object.freeze({
47
+ references: 'references',
48
+ print: 'print',
49
+ convergence: 'convergence',
50
+ tips: 'tips',
51
+ exercises: 'exercises',
52
+ 'practice-exam': 'practiceExam',
53
+ glossary: 'glossary',
54
+ flashcards: 'flashcards',
55
+ answers: 'answers',
56
+ } as const satisfies Record<CorpusApparatusRoute, string>);
57
+
58
+ const RESERVED = new Set<string>(RESERVED_CORPUS_BOOK_IDS);
59
+ const APPARATUS = new Set<string>(CORPUS_APPARATUS_ROUTES);
60
+ const RESERVED_CONTENT_ROOTS: Readonly<Record<string, string>> = Object.freeze({
61
+ questions: 'src/content/questions/ is the dedicated questions collection root',
62
+ glossary: 'src/content/glossary/ is the dedicated glossary collection root',
63
+ frontmatter: 'src/content/frontmatter/ is the shared frontmatter collection root',
64
+ });
65
+ const TOP_LEVEL_KEYS = new Set(['preset', 'books']);
66
+ const BRANDED_CORPUS_KEYS = new Set(['__bookCorpusVersion', 'preset', 'books']);
67
+ const CORPUS_ARTIFACT_KEYS = new Set(['schemaVersion', 'books']);
68
+ const BOOK_KEYS = new Set([
69
+ 'id',
70
+ 'title',
71
+ 'subtitle',
72
+ 'description',
73
+ 'author',
74
+ 'image',
75
+ 'apparatus',
76
+ ]);
77
+
78
+ function objectRecord(value: unknown, label: string): Record<string, unknown> {
79
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
80
+ throw new BookConfigError(`${label} must be an object.`);
81
+ }
82
+ return value as Record<string, unknown>;
83
+ }
84
+
85
+ function rejectUnknownKeys(
86
+ value: Record<string, unknown>,
87
+ allowed: ReadonlySet<string>,
88
+ label: string,
89
+ ): void {
90
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
91
+ if (unknown.length > 0) {
92
+ throw new BookConfigError(
93
+ `${label} has unknown field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}.`,
94
+ );
95
+ }
96
+ }
97
+
98
+ function optionalNonBlankString(
99
+ value: unknown,
100
+ label: string,
101
+ ): string | undefined {
102
+ if (value === undefined) return undefined;
103
+ if (typeof value !== 'string' || value.trim().length === 0) {
104
+ throw new BookConfigError(`${label} must be a non-blank string when provided.`);
105
+ }
106
+ return value;
107
+ }
108
+
109
+ function validateBook(value: unknown, index: number): CorpusBook {
110
+ const label = `defineBookCorpus books[${index}]`;
111
+ const input = objectRecord(value, label);
112
+ rejectUnknownKeys(input, BOOK_KEYS, label);
113
+
114
+ const id = optionalNonBlankString(input.id, `${label}.id`);
115
+ if (id === undefined || !BOOK_ID.test(id)) {
116
+ throw new BookConfigError(
117
+ `${label}.id must match [a-z0-9]+(?:-[a-z0-9]+)* (got ${JSON.stringify(input.id)}).`,
118
+ );
119
+ }
120
+ if (RESERVED.has(id)) {
121
+ const reason = RESERVED_CONTENT_ROOTS[id] ?? 'the scaffold owns that route or asset namespace';
122
+ throw new BookConfigError(
123
+ `${label}.id ${JSON.stringify(id)} is reserved because ${reason}; ` +
124
+ 'choose a book-specific id.',
125
+ );
126
+ }
127
+
128
+ const title = optionalNonBlankString(input.title, `${label}.title`);
129
+ if (title === undefined) {
130
+ throw new BookConfigError(`${label}.title must be a non-blank string.`);
131
+ }
132
+
133
+ let apparatus: readonly CorpusApparatusRoute[] | undefined;
134
+ if (input.apparatus !== undefined) {
135
+ if (!Array.isArray(input.apparatus)) {
136
+ throw new BookConfigError(`${label}.apparatus must be an array when provided.`);
137
+ }
138
+ const seen = new Set<string>();
139
+ const routes: CorpusApparatusRoute[] = [];
140
+ for (const route of input.apparatus) {
141
+ if (typeof route !== 'string' || !APPARATUS.has(route)) {
142
+ throw new BookConfigError(
143
+ `${label}.apparatus contains ${JSON.stringify(route)}; expected a subset of ` +
144
+ CORPUS_APPARATUS_ROUTES.join(' | ') +
145
+ '.',
146
+ );
147
+ }
148
+ if (seen.has(route)) {
149
+ throw new BookConfigError(`${label}.apparatus contains duplicate route ${JSON.stringify(route)}.`);
150
+ }
151
+ seen.add(route);
152
+ routes.push(route as CorpusApparatusRoute);
153
+ }
154
+ apparatus = Object.freeze(routes);
155
+ }
156
+
157
+ const book: CorpusBook = {
158
+ id,
159
+ title,
160
+ ...optionalField('subtitle', optionalNonBlankString(input.subtitle, `${label}.subtitle`)),
161
+ ...optionalField('description', optionalNonBlankString(input.description, `${label}.description`)),
162
+ ...optionalField('author', optionalNonBlankString(input.author, `${label}.author`)),
163
+ ...optionalField('image', optionalNonBlankString(input.image, `${label}.image`)),
164
+ ...(apparatus === undefined ? {} : { apparatus }),
165
+ };
166
+ return Object.freeze(book);
167
+ }
168
+
169
+ function optionalField<Key extends string>(
170
+ key: Key,
171
+ value: string | undefined,
172
+ ): {} | Record<Key, string> {
173
+ return value === undefined ? {} : ({ [key]: value } as Record<Key, string>);
174
+ }
175
+
176
+ /**
177
+ * Define and eagerly validate one homogeneous-preset book corpus.
178
+ *
179
+ * The returned value is safe to share between `astro.config.mjs` and
180
+ * `src/content.config.ts`; no filesystem or Astro virtual module is touched.
181
+ */
182
+ export function defineBookCorpus(inputValue: BookCorpusInput): BookCorpus {
183
+ const input = objectRecord(inputValue, 'defineBookCorpus input');
184
+ rejectUnknownKeys(input, TOP_LEVEL_KEYS, 'defineBookCorpus input');
185
+
186
+ if (typeof input.preset !== 'string' || !BOOK_PRESETS.includes(input.preset as never)) {
187
+ throw new BookConfigError(
188
+ `defineBookCorpus preset must be one of ${BOOK_PRESETS.join(' | ')} ` +
189
+ `(got ${JSON.stringify(input.preset)}).`,
190
+ );
191
+ }
192
+ if (!Array.isArray(input.books) || input.books.length === 0) {
193
+ throw new BookConfigError('defineBookCorpus books must be a non-empty array.');
194
+ }
195
+
196
+ const books = input.books.map(validateBook);
197
+ const seen = new Set<string>();
198
+ for (const book of books) {
199
+ if (seen.has(book.id)) {
200
+ throw new BookConfigError(`defineBookCorpus book id ${JSON.stringify(book.id)} is duplicated.`);
201
+ }
202
+ seen.add(book.id);
203
+ }
204
+
205
+ const corpus = {
206
+ __bookCorpusVersion: 1 as const,
207
+ preset: input.preset as BookCorpus['preset'],
208
+ books: Object.freeze(books),
209
+ };
210
+ Object.defineProperty(corpus, BOOK_CORPUS_BRAND, {
211
+ value: true,
212
+ enumerable: false,
213
+ configurable: false,
214
+ writable: false,
215
+ });
216
+ return Object.freeze(corpus);
217
+ }
218
+
219
+ /** Validate that a value came from this major's `defineBookCorpus`. */
220
+ export function assertBookCorpus(value: unknown): asserts value is BookCorpus {
221
+ const invalid = () =>
222
+ new BookConfigError(
223
+ 'corpus must be created by defineBookCorpus() from this book-scaffold major.',
224
+ );
225
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
226
+ throw invalid();
227
+ }
228
+
229
+ const input = value as Record<PropertyKey, unknown>;
230
+ if (
231
+ input[BOOK_CORPUS_BRAND] !== true ||
232
+ input.__bookCorpusVersion !== 1 ||
233
+ !Object.isFrozen(value) ||
234
+ !Object.isFrozen(input.books)
235
+ ) {
236
+ throw invalid();
237
+ }
238
+
239
+ try {
240
+ rejectUnknownKeys(input as Record<string, unknown>, BRANDED_CORPUS_KEYS, 'corpus');
241
+ if (typeof input.preset !== 'string' || !BOOK_PRESETS.includes(input.preset as never)) {
242
+ throw invalid();
243
+ }
244
+ if (!Array.isArray(input.books) || input.books.length === 0) throw invalid();
245
+
246
+ const seen = new Set<string>();
247
+ for (const [index, rawBook] of input.books.entries()) {
248
+ if (!Object.isFrozen(rawBook)) throw invalid();
249
+ const book = validateBook(rawBook, index);
250
+ if (seen.has(book.id)) throw invalid();
251
+ seen.add(book.id);
252
+ if (book.apparatus !== undefined) {
253
+ const original = (rawBook as { apparatus?: unknown }).apparatus;
254
+ if (!Object.isFrozen(original)) throw invalid();
255
+ }
256
+ }
257
+ } catch (error) {
258
+ if (error instanceof BookConfigError && error.message.startsWith('corpus must be created')) {
259
+ throw error;
260
+ }
261
+ throw invalid();
262
+ }
263
+ }
264
+
265
+ /** Return the effective apparatus routes for one manifest book. */
266
+ export function corpusApparatusRoutesForBook(
267
+ corpus: BookCorpus,
268
+ bookId: string,
269
+ inheritedRoutes: readonly CorpusApparatusRoute[] = [],
270
+ ): readonly CorpusApparatusRoute[] {
271
+ const book = resolveCorpusBook(corpus, bookId);
272
+ return book.apparatus ?? inheritedRoutes;
273
+ }
274
+
275
+ /** True when a public apparatus route is enabled for one manifest book. */
276
+ export function corpusBookHasApparatusRoute(
277
+ corpus: BookCorpus,
278
+ bookId: string,
279
+ route: CorpusApparatusRoute,
280
+ inheritedRoutes: readonly CorpusApparatusRoute[] = [],
281
+ ): boolean {
282
+ return corpusApparatusRoutesForBook(corpus, bookId, inheritedRoutes).includes(route);
283
+ }
284
+
285
+ /**
286
+ * Select the current book's payload from a v1 corpus artifact envelope.
287
+ * Single-book mode deliberately returns the input unchanged, preserving the
288
+ * legacy JSON contract byte-for-byte.
289
+ */
290
+ export function selectBookArtifact<Payload>(
291
+ value: unknown,
292
+ corpus: BookCorpus | null | undefined,
293
+ bookId: string | null | undefined,
294
+ artifact = 'artifact',
295
+ ): Payload {
296
+ if (!corpus) return value as Payload;
297
+ if (!bookId) {
298
+ throw new Error(`${artifact}: cannot select a corpus payload without a current book.`);
299
+ }
300
+ resolveCorpusBook(corpus, bookId);
301
+
302
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
303
+ throw new Error(
304
+ `${artifact} is not a corpus artifact envelope; expected ` +
305
+ '`{ "schemaVersion": 1, "books": { ... } }`.',
306
+ );
307
+ }
308
+ const envelopeRecord = value as Record<string, unknown>;
309
+ rejectUnknownKeys(envelopeRecord, CORPUS_ARTIFACT_KEYS, artifact);
310
+ const envelope = envelopeRecord as { schemaVersion?: unknown; books?: unknown };
311
+ if (
312
+ envelope.schemaVersion !== 1 ||
313
+ envelope.books === null ||
314
+ typeof envelope.books !== 'object' ||
315
+ Array.isArray(envelope.books)
316
+ ) {
317
+ throw new BookConfigError(
318
+ `${artifact} is not a corpus artifact envelope; expected ` +
319
+ '`{ "schemaVersion": 1, "books": { ... } }`.',
320
+ );
321
+ }
322
+
323
+ const books = envelope.books as Record<string, unknown>;
324
+ const expected = corpus.books.map((book) => book.id);
325
+ const actual = Object.keys(books);
326
+ const missing = expected.filter((id) => !Object.prototype.hasOwnProperty.call(books, id));
327
+ const unknown = actual.filter((id) => !expected.includes(id));
328
+ if (missing.length > 0 || unknown.length > 0) {
329
+ throw new Error(
330
+ `${artifact} book keys do not match the corpus manifest` +
331
+ (missing.length > 0 ? `; missing ${missing.join(', ')}` : '') +
332
+ (unknown.length > 0 ? `; unknown ${unknown.join(', ')}` : '') +
333
+ '.',
334
+ );
335
+ }
336
+ return books[bookId] as Payload;
337
+ }
338
+
339
+ /** Resolve one manifest book or fail with the complete known-id set. */
340
+ export function resolveCorpusBook(corpus: BookCorpus, id: string): CorpusBook {
341
+ const book = corpus.books.find((candidate) => candidate.id === id);
342
+ if (!book) {
343
+ throw new BookConfigError(
344
+ `Unknown corpus book ${JSON.stringify(id)}; expected one of ` +
345
+ `${corpus.books.map((candidate) => candidate.id).join(' | ')}.`,
346
+ );
347
+ }
348
+ return book;
349
+ }
350
+
351
+ /** Return the registered first entry-id segment, or null outside the corpus. */
352
+ export function corpusBookIdOf(corpus: BookCorpus, entryId: string): string | null {
353
+ const candidate = entryId.split('/')[0] ?? '';
354
+ return corpus.books.some((book) => book.id === candidate) ? candidate : null;
355
+ }
356
+
357
+ /** Strip the registered `<book>/` prefix from one collection entry id. */
358
+ export function localCorpusEntryId(corpus: BookCorpus, bookId: string, entryId: string): string {
359
+ resolveCorpusBook(corpus, bookId);
360
+ const prefix = `${bookId}/`;
361
+ if (!entryId.startsWith(prefix) || entryId.length === prefix.length) {
362
+ throw new Error(
363
+ `Collection entry ${JSON.stringify(entryId)} is outside corpus book ${JSON.stringify(bookId)}.`,
364
+ );
365
+ }
366
+ return entryId.slice(prefix.length);
367
+ }
368
+
369
+ /** Filter content entries to the current registered book. */
370
+ export function filterCorpusEntries<Entry extends { id: string }>(
371
+ entries: readonly Entry[],
372
+ corpus: BookCorpus | null | undefined,
373
+ bookId: string | null | undefined,
374
+ ): Entry[] {
375
+ if (!corpus) return [...entries];
376
+ if (!bookId) throw new Error('Cannot scope corpus content without a current book.');
377
+ resolveCorpusBook(corpus, bookId);
378
+ const prefix = `${bookId}/`;
379
+ return entries.filter((entry) => entry.id.startsWith(prefix));
380
+ }
381
+
382
+ /**
383
+ * Derive a collision-safe collection id from a corpus-relative source path.
384
+ * A legacy `book:` field may agree with the path-derived owner, but can never
385
+ * override it; disagreement fails before schema parsing can silently strip it.
386
+ */
387
+ export function corpusCollectionEntryId(
388
+ corpus: BookCorpus,
389
+ entry: string,
390
+ data: Record<string, unknown>,
391
+ options: { label?: string; slugField?: string } = {},
392
+ ): string {
393
+ const label = options.label ?? 'Content entry';
394
+ const normalized = entry.replaceAll('\\', '/').replace(/^\.\//, '');
395
+ const [bookId, ...localParts] = normalized.split('/');
396
+ if (!bookId || !corpus.books.some((book) => book.id === bookId)) {
397
+ throw new Error(`${label} ${JSON.stringify(entry)} is outside the registered corpus books.`);
398
+ }
399
+
400
+ if (
401
+ Object.prototype.hasOwnProperty.call(data, 'book') &&
402
+ data.book !== bookId
403
+ ) {
404
+ throw new Error(
405
+ `${label} ${JSON.stringify(entry)} has frontmatter book ${JSON.stringify(data.book)}, ` +
406
+ `but its registered path owner is ${JSON.stringify(bookId)}.`,
407
+ );
408
+ }
409
+
410
+ const fileId = localParts.join('/').replace(/\.(?:md|mdx)$/i, '');
411
+ const configured = options.slugField ? data[options.slugField] : undefined;
412
+ const localId = typeof configured === 'string' ? configured : fileId;
413
+ const localSegments = localId.split('/');
414
+ let hasInvalidEncodedSegment = false;
415
+ for (const segment of localSegments) {
416
+ try {
417
+ const decoded = decodeURIComponent(segment);
418
+ if (
419
+ decoded === '.' ||
420
+ decoded === '..' ||
421
+ decoded.includes('/') ||
422
+ decoded.includes('\\')
423
+ ) {
424
+ hasInvalidEncodedSegment = true;
425
+ }
426
+ } catch {
427
+ hasInvalidEncodedSegment = true;
428
+ }
429
+ }
430
+ if (
431
+ localId.length === 0 ||
432
+ localId.startsWith('/') ||
433
+ localId.endsWith('/') ||
434
+ localId.includes('\\') ||
435
+ localSegments.some((part) => part === '.' || part === '..' || part.length === 0) ||
436
+ hasInvalidEncodedSegment
437
+ ) {
438
+ throw new Error(
439
+ `${label} ${JSON.stringify(entry)} resolved invalid corpus id ${JSON.stringify(localId)}.`,
440
+ );
441
+ }
442
+ return `${bookId}/${localId}`;
443
+ }
444
+
445
+ /** Resolve current book identity from canonical corpus routes. */
446
+ export function corpusBookIdFromPath(
447
+ corpus: BookCorpus,
448
+ pathname: string,
449
+ baseUrl = '/',
450
+ ): string | null {
451
+ const normalizedBase = baseUrl === '/' ? '/' : `/${baseUrl.replace(/^\/+|\/+$/g, '')}/`;
452
+ const relative = pathname.startsWith(normalizedBase)
453
+ ? pathname.slice(normalizedBase.length)
454
+ : pathname.replace(/^\/+/, '');
455
+ const segments = relative.split('/').filter(Boolean);
456
+ const candidate = segments[0] === 'chapters' ? segments[1] : segments[0];
457
+ return candidate && corpus.books.some((book) => book.id === candidate) ? candidate : null;
458
+ }