@brandon_m_behring/book-scaffold-astro 4.31.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 (79) hide show
  1. package/CLAUDE.md +37 -2
  2. package/MIGRATION-v4-to-v5.md +183 -0
  3. package/README.md +30 -1
  4. package/components/AssessmentTest.astro +26 -5
  5. package/components/BookLink.astro +6 -3
  6. package/components/ChapterNav.astro +1 -1
  7. package/components/Cite.astro +37 -4
  8. package/components/ExerciseSolutions.astro +24 -4
  9. package/components/Flashcards.tsx +19 -11
  10. package/components/NavContent.astro +60 -11
  11. package/components/ObjectiveMap.astro +14 -2
  12. package/components/PartReview.astro +32 -4
  13. package/components/PatternTimeline.astro +6 -2
  14. package/components/Rationale.astro +20 -3
  15. package/components/Sidebar.astro +11 -3
  16. package/components/SourceArchive.astro +33 -3
  17. package/components/Term.astro +18 -1
  18. package/components/Theorem.astro +15 -5
  19. package/components/TipsCard.astro +40 -3
  20. package/components/XRef.astro +14 -2
  21. package/dist/components/ExamRunner.d.ts +1 -1
  22. package/dist/components/Flashcards.d.ts +6 -1
  23. package/dist/components/Flashcards.mjs +14 -11
  24. package/dist/{exam-manifest-X9IrX1G3.d.ts → exam-manifest-DttY7kyZ.d.ts} +1 -1
  25. package/dist/index.d.ts +76 -8
  26. package/dist/index.mjs +517 -43
  27. package/dist/schemas.d.ts +1 -1
  28. package/dist/schemas.mjs +311 -32
  29. package/dist/{types-DgSlAew3.d.ts → types-D1QZgKMO.d.ts} +73 -21
  30. package/layouts/Base.astro +38 -9
  31. package/layouts/Chapter.astro +10 -2
  32. package/package.json +4 -2
  33. package/pages/answers.astro +25 -6
  34. package/pages/book.astro +75 -0
  35. package/pages/chapters/[...slug].astro +39 -7
  36. package/pages/chapters-book.astro +17 -0
  37. package/pages/chapters.astro +87 -9
  38. package/pages/convergence.astro +40 -10
  39. package/pages/corpus-apparatus/answers.astro +15 -0
  40. package/pages/corpus-apparatus/convergence.astro +15 -0
  41. package/pages/corpus-apparatus/exercises.astro +15 -0
  42. package/pages/corpus-apparatus/flashcards.astro +15 -0
  43. package/pages/corpus-apparatus/glossary.astro +15 -0
  44. package/pages/corpus-apparatus/practice-exam.astro +15 -0
  45. package/pages/corpus-apparatus/print.astro +15 -0
  46. package/pages/corpus-apparatus/references.astro +15 -0
  47. package/pages/corpus-apparatus/tips.astro +15 -0
  48. package/pages/exercises.astro +18 -5
  49. package/pages/flashcards.astro +26 -5
  50. package/pages/glossary.astro +20 -3
  51. package/pages/index.astro +54 -1
  52. package/pages/practice-exam.astro +8 -2
  53. package/pages/print.astro +7 -2
  54. package/pages/references.astro +26 -6
  55. package/pages/search.astro +65 -4
  56. package/pages/tips.astro +17 -4
  57. package/recipes/09-validation.md +1 -1
  58. package/recipes/15-defining-styles.md +6 -6
  59. package/recipes/20-anki-export.md +15 -8
  60. package/recipes/21-multi-guide-single-app.md +293 -44
  61. package/recipes/22-responsive-nav-and-multibook-routing.md +7 -1
  62. package/recipes/README.md +2 -2
  63. package/scripts/build-bib.mjs +113 -35
  64. package/scripts/build-exercises.mjs +101 -24
  65. package/scripts/build-labels.mjs +199 -194
  66. package/scripts/build-tips.mjs +95 -23
  67. package/scripts/corpus-tooling.mjs +268 -0
  68. package/scripts/resolve-book-config.mjs +99 -1
  69. package/scripts/validate.mjs +676 -100
  70. package/scripts/walk-mdx.mjs +16 -4
  71. package/src/lib/book-link.ts +72 -0
  72. package/src/lib/chapters.ts +10 -4
  73. package/src/lib/corpus-collateral.ts +9 -0
  74. package/src/lib/corpus.ts +458 -0
  75. package/src/lib/define-style.ts +28 -15
  76. package/src/lib/exam-manifest.ts +4 -1
  77. package/src/lib/patterns.ts +15 -6
  78. package/src/lib/questions.ts +8 -3
  79. package/src/types.ts +603 -0
@@ -40,6 +40,8 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
40
40
  import { dirname, resolve } from 'node:path';
41
41
  import { fileURLToPath } from 'node:url';
42
42
  import { readEnvFile } from './read-env.mjs';
43
+ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
44
+ import { mergeCorpusArtifact, resolveBookSelection } from './corpus-tooling.mjs';
43
45
 
44
46
  // --help / -h: non-mutating (closes #14).
45
47
  const USAGE = `Usage: book-scaffold build-bib
@@ -54,6 +56,7 @@ Env:
54
56
  .env; default: ./bibliography.bib).
55
57
 
56
58
  Options:
59
+ --book <id> In corpus mode, rebuild only one registered book.
57
60
  --help, -h Print this message and exit (non-mutating).
58
61
  `;
59
62
 
@@ -77,8 +80,13 @@ const BIB_PATH = configuredBibPath
77
80
  const OUT_PATH = resolve(PROJECT_ROOT, 'src/data/references.json');
78
81
  const SOURCES_PATH = resolve(PROJECT_ROOT, 'sources/manifest.yaml');
79
82
  const SOURCES_OUT = resolve(PROJECT_ROOT, 'src/data/sources.json');
83
+ let DIAGNOSTIC_SCOPE = null;
80
84
 
81
- async function buildReferences() {
85
+ function isRecord(value) {
86
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
87
+ }
88
+
89
+ async function buildReferences(selection) {
82
90
  // Graceful skip when the .bib file is absent (minimal/tools profile, or
83
91
  // an academic book that hasn't authored citations yet). Emits an empty
84
92
  // references.json so consumers can still `import refs from '...'`.
@@ -87,15 +95,10 @@ async function buildReferences() {
87
95
  bibText = await readFile(BIB_PATH, 'utf8');
88
96
  } catch (err) {
89
97
  if (err.code === 'ENOENT') {
90
- console.log(
91
- `build-bib: ${BIB_PATH.replace(PROJECT_ROOT + '/', '')} not found — ` +
92
- `emitting empty references.json (no citations to process).`,
93
- );
94
- await mkdir(dirname(OUT_PATH), { recursive: true });
95
- await writeFile(OUT_PATH, '{}\n', 'utf8');
96
- return;
98
+ bibText = null;
99
+ } else {
100
+ throw err;
97
101
  }
98
- throw err;
99
102
  }
100
103
  // v4.0.0 (closes #54): strip `%`-comment lines before passing to citation-js.
101
104
  // The plugin-bibtex lexer doesn't honor BibTeX's %-line-comment semantics —
@@ -107,13 +110,14 @@ async function buildReferences() {
107
110
  // BibTeX's real comment grammar is "% at the start of a line (after optional
108
111
  // whitespace) to end of line". Mid-line `%` (e.g., `note = {50% confidence}`)
109
112
  // is NOT a comment and is preserved.
110
- const sanitizedBib = bibText
111
- .split(/\r?\n/)
112
- .map((line) => (line.trimStart().startsWith('%') ? '' : line))
113
- .join('\n');
114
-
115
- const cite = new Cite(sanitizedBib);
116
- const data = cite.data;
113
+ const data = bibText === null
114
+ ? []
115
+ : new Cite(
116
+ bibText
117
+ .split(/\r?\n/)
118
+ .map((line) => (line.trimStart().startsWith('%') ? '' : line))
119
+ .join('\n'),
120
+ ).data;
117
121
 
118
122
  // Detect duplicates the way biber would (citation-js silently
119
123
  // overwrites the earlier entry, which is the opposite of what we want).
@@ -124,19 +128,53 @@ async function buildReferences() {
124
128
  seen.add(entry.id);
125
129
  }
126
130
  if (dupes.length > 0) {
127
- console.error(`build-bib: ${dupes.length} duplicate bibkeys:`);
128
- for (const id of dupes) console.error(` - ${id}`);
131
+ const prefix = selection.corpus ? '[book:corpus] ' : '';
132
+ console.error(`${prefix}build-bib: ${dupes.length} duplicate bibkeys:`);
133
+ for (const id of dupes) console.error(`${prefix}build-bib: - ${id}`);
129
134
  process.exit(1);
130
135
  }
131
136
 
132
137
  const byKey = Object.fromEntries(data.map((entry) => [entry.id, entry]));
133
138
 
139
+ if (selection.corpus) {
140
+ DIAGNOSTIC_SCOPE = 'corpus';
141
+ }
142
+ const output = selection.corpus
143
+ ? await mergeCorpusArtifact({
144
+ path: OUT_PATH,
145
+ corpus: selection.corpus,
146
+ requestedBook: selection.requestedBook,
147
+ values: new Map(selection.books.map((book) => [book.id, byKey])),
148
+ emptyValue: () => ({}),
149
+ artifact: 'src/data/references.json',
150
+ validateValue: isRecord,
151
+ })
152
+ : byKey;
134
153
  await mkdir(dirname(OUT_PATH), { recursive: true });
135
- await writeFile(OUT_PATH, JSON.stringify(byKey, null, 2) + '\n', 'utf8');
154
+ await writeFile(OUT_PATH, JSON.stringify(output, null, 2) + '\n', 'utf8');
136
155
 
137
- console.log(
138
- `build-bib: ${data.length} entries -> ${OUT_PATH.replace(PROJECT_ROOT + '/', '')}`,
139
- );
156
+ if (selection.corpus) {
157
+ for (const book of selection.books) {
158
+ console.log(
159
+ `[book:${book.id}] build-bib: ${data.length} entries -> ` +
160
+ OUT_PATH.replace(PROJECT_ROOT + '/', ''),
161
+ );
162
+ }
163
+ console.log(
164
+ `[book:corpus] build-bib: ${data.length * selection.books.length} namespaced entries ` +
165
+ `across ${selection.books.length} book${selection.books.length === 1 ? '' : 's'} -> ` +
166
+ OUT_PATH.replace(PROJECT_ROOT + '/', ''),
167
+ );
168
+ } else if (bibText === null) {
169
+ console.log(
170
+ `build-bib: ${BIB_PATH.replace(PROJECT_ROOT + '/', '')} not found — ` +
171
+ `emitting empty references.json (no citations to process).`,
172
+ );
173
+ } else {
174
+ console.log(
175
+ `build-bib: ${data.length} entries -> ${OUT_PATH.replace(PROJECT_ROOT + '/', '')}`,
176
+ );
177
+ }
140
178
  }
141
179
 
142
180
  // v4.10.0 (closes #85): tools-profile books keep their sources in
@@ -147,17 +185,24 @@ async function buildReferences() {
147
185
  // references.json. Absent manifest -> no file written (academic/minimal books
148
186
  // degrade to empty, exactly like a missing .bib). YAML is lazy-imported so the
149
187
  // --help / no-manifest paths stay dependency-free.
150
- async function buildSources() {
188
+ async function buildSources(selection) {
151
189
  let yamlText;
152
190
  try {
153
191
  yamlText = await readFile(SOURCES_PATH, 'utf8');
154
192
  } catch (err) {
155
- if (err.code === 'ENOENT') return; // no manifest — nothing to emit
156
- throw err;
193
+ if (err.code === 'ENOENT') {
194
+ // A corpus artifact must be a complete, deterministic envelope. Clear
195
+ // stale source data when the shared manifest disappears; legacy
196
+ // single-book mode retains its historical no-file/no-write behavior.
197
+ if (!selection.corpus) return;
198
+ yamlText = null;
199
+ } else {
200
+ throw err;
201
+ }
157
202
  }
158
203
 
159
204
  const { parse } = await import('yaml');
160
- const parsed = parse(yamlText);
205
+ const parsed = yamlText === null ? null : parse(yamlText);
161
206
  // The manifest is a YAML array of source objects. Keep only well-formed
162
207
  // entries (a string `id` is the citation key + the /references anchor target).
163
208
  // A blank or comments-only manifest parses to null/undefined/[].
@@ -165,21 +210,54 @@ async function buildSources() {
165
210
  ? parsed.filter((s) => s && typeof s.id === 'string')
166
211
  : [];
167
212
 
213
+ const output = selection.corpus
214
+ ? await mergeCorpusArtifact({
215
+ path: SOURCES_OUT,
216
+ corpus: selection.corpus,
217
+ requestedBook: selection.requestedBook,
218
+ values: new Map(selection.books.map((book) => [book.id, sources])),
219
+ emptyValue: () => [],
220
+ artifact: 'src/data/sources.json',
221
+ validateValue: Array.isArray,
222
+ })
223
+ : sources;
168
224
  await mkdir(dirname(SOURCES_OUT), { recursive: true });
169
- await writeFile(SOURCES_OUT, JSON.stringify(sources, null, 2) + '\n', 'utf8');
170
- console.log(
171
- `build-bib: ${sources.length} source${sources.length === 1 ? '' : 's'} -> ` +
172
- `${SOURCES_OUT.replace(PROJECT_ROOT + '/', '')}`,
173
- );
225
+ await writeFile(SOURCES_OUT, JSON.stringify(output, null, 2) + '\n', 'utf8');
226
+ if (selection.corpus) {
227
+ for (const book of selection.books) {
228
+ console.log(
229
+ `[book:${book.id}] build-bib: ${sources.length} ` +
230
+ `source${sources.length === 1 ? '' : 's'} -> ` +
231
+ SOURCES_OUT.replace(PROJECT_ROOT + '/', ''),
232
+ );
233
+ }
234
+ console.log(
235
+ `[book:corpus] build-bib: ${sources.length * selection.books.length} namespaced ` +
236
+ `source${sources.length * selection.books.length === 1 ? '' : 's'} across ` +
237
+ `${selection.books.length} book${selection.books.length === 1 ? '' : 's'} -> ` +
238
+ SOURCES_OUT.replace(PROJECT_ROOT + '/', ''),
239
+ );
240
+ } else {
241
+ console.log(
242
+ `build-bib: ${sources.length} source${sources.length === 1 ? '' : 's'} -> ` +
243
+ `${SOURCES_OUT.replace(PROJECT_ROOT + '/', '')}`,
244
+ );
245
+ }
174
246
  }
175
247
 
176
248
  async function main() {
177
- await buildReferences();
178
- await buildSources();
249
+ const toolingConfig = await loadResolvedBookConfig(PROJECT_ROOT);
250
+ if (toolingConfig.corpus) DIAGNOSTIC_SCOPE = 'corpus';
251
+ const selection = resolveBookSelection(toolingConfig, process.argv.slice(2), 'build-bib');
252
+ await buildReferences(selection);
253
+ await buildSources(selection);
179
254
  }
180
255
 
181
256
  main().catch((err) => {
182
- console.error(`build-bib: failed`);
183
- console.error(err);
257
+ const message = String(err?.message ?? err);
258
+ const prefix = DIAGNOSTIC_SCOPE ? `[book:${DIAGNOSTIC_SCOPE}] ` : '';
259
+ console.error(
260
+ message.startsWith('[book:') ? message : `${prefix}build-bib: failed: ${message}`,
261
+ );
184
262
  process.exit(1);
185
263
  });
@@ -24,6 +24,13 @@
24
24
  import { writeFile, mkdir, readFile } from 'node:fs/promises';
25
25
  import { resolve, dirname, basename } from 'node:path';
26
26
  import { walkMdx, readChaptersBase } from './walk-mdx.mjs';
27
+ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
28
+ import {
29
+ assertLegacyBookMatches,
30
+ frontmatterSlug,
31
+ mergeCorpusArtifact,
32
+ resolveBookSelection,
33
+ } from './corpus-tooling.mjs';
27
34
 
28
35
  const USAGE = `Usage: book-scaffold build-exercises
29
36
 
@@ -36,6 +43,7 @@ Env:
36
43
  BOOK_EXERCISES_OUT Override output path (default: src/data/exercises.json).
37
44
 
38
45
  Options:
46
+ --book <id> In corpus mode, rebuild only one registered book.
39
47
  --help, -h Print this message and exit (non-mutating).
40
48
  `;
41
49
 
@@ -45,8 +53,8 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
45
53
  }
46
54
 
47
55
  const CWD = process.cwd();
48
- const CHAPTERS_DIR = await readChaptersBase(CWD);
49
56
  const OUTPUT_PATH = process.env.BOOK_EXERCISES_OUT ?? 'src/data/exercises.json';
57
+ let DIAGNOSTIC_SCOPE = null;
50
58
 
51
59
  /**
52
60
  * Extract <Exercise id="..."> tags and their body content from MDX.
@@ -85,39 +93,108 @@ function extractExercises(source) {
85
93
  }
86
94
 
87
95
  async function main() {
88
- const byChapter = {};
89
- let totalExercises = 0;
90
- let chaptersWithExercises = 0;
96
+ const toolingConfig = await loadResolvedBookConfig(CWD);
97
+ if (toolingConfig.corpus) DIAGNOSTIC_SCOPE = 'corpus';
98
+ const selection = resolveBookSelection(
99
+ toolingConfig,
100
+ process.argv.slice(2),
101
+ 'build-exercises',
102
+ );
103
+ DIAGNOSTIC_SCOPE = selection.corpus ? 'corpus' : null;
104
+ const chaptersRoot = await readChaptersBase(CWD, { corpus: selection.corpus });
105
+ const runs = selection.corpus
106
+ ? selection.books.map((book) => ({ book, dir: resolve(chaptersRoot, book.id) }))
107
+ : [{ book: null, dir: chaptersRoot }];
108
+ const values = new Map();
109
+ let corpusExercises = 0;
110
+ let corpusChapters = 0;
111
+
112
+ for (const run of runs) {
113
+ const byChapter = {};
114
+ let totalExercises = 0;
115
+ let chaptersWithExercises = 0;
91
116
 
92
- for await (const rel of walkMdx(CHAPTERS_DIR)) {
93
- const chapterPath = resolve(CHAPTERS_DIR, rel);
94
- const chapterSlug = basename(rel).replace(/\.mdx?$/, '');
95
- let source;
96
- try {
97
- source = await readFile(chapterPath, 'utf8');
98
- } catch {
99
- continue;
117
+ for await (const rel of walkMdx(run.dir)) {
118
+ const chapterPath = resolve(run.dir, rel);
119
+ let source;
120
+ try {
121
+ source = await readFile(chapterPath, 'utf8');
122
+ } catch {
123
+ continue;
124
+ }
125
+ if (run.book) {
126
+ assertLegacyBookMatches(
127
+ source,
128
+ run.book,
129
+ `[book:${run.book.id}] ${chapterPath.replace(`${CWD}/`, '')}`,
130
+ );
131
+ }
132
+ const fileLabel = run.book
133
+ ? `[book:${run.book.id}] ${chapterPath.replace(`${CWD}/`, '')}`
134
+ : chapterPath.replace(`${CWD}/`, '');
135
+ const chapterSlug = run.book
136
+ ? frontmatterSlug(source, fileLabel) ?? rel.replace(/\.mdx?$/, '')
137
+ : basename(rel).replace(/\.mdx?$/, '');
138
+ const exercises = extractExercises(source);
139
+ if (exercises.length > 0) {
140
+ byChapter[chapterSlug] = exercises;
141
+ totalExercises += exercises.length;
142
+ chaptersWithExercises += 1;
143
+ }
100
144
  }
101
- const exercises = extractExercises(source);
102
- if (exercises.length > 0) {
103
- byChapter[chapterSlug] = exercises;
104
- totalExercises += exercises.length;
105
- chaptersWithExercises += 1;
145
+
146
+ if (run.book) {
147
+ values.set(run.book.id, byChapter);
148
+ corpusExercises += totalExercises;
149
+ corpusChapters += chaptersWithExercises;
150
+ process.stdout.write(
151
+ `[book:${run.book.id}] build-exercises: ${totalExercises} ` +
152
+ `exercise${totalExercises === 1 ? '' : 's'} across ${chaptersWithExercises} ` +
153
+ `chapter${chaptersWithExercises === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
154
+ );
155
+ } else {
156
+ values.set('', byChapter);
157
+ corpusExercises = totalExercises;
158
+ corpusChapters = chaptersWithExercises;
106
159
  }
107
160
  }
108
161
 
109
162
  const outPath = resolve(CWD, OUTPUT_PATH);
163
+ const output = selection.corpus
164
+ ? await mergeCorpusArtifact({
165
+ path: outPath,
166
+ corpus: selection.corpus,
167
+ requestedBook: selection.requestedBook,
168
+ values,
169
+ emptyValue: () => ({}),
170
+ artifact: OUTPUT_PATH,
171
+ validateValue: (value) =>
172
+ value !== null && typeof value === 'object' && !Array.isArray(value),
173
+ })
174
+ : values.get('');
110
175
  await mkdir(dirname(outPath), { recursive: true });
111
- await writeFile(outPath, JSON.stringify(byChapter, null, 2) + '\n');
112
- process.stdout.write(
113
- `build-exercises: ${totalExercises} exercise${totalExercises === 1 ? '' : 's'} across ` +
114
- `${chaptersWithExercises} chapter${chaptersWithExercises === 1 ? '' : 's'} ${OUTPUT_PATH}\n`,
115
- );
176
+ await writeFile(outPath, JSON.stringify(output, null, 2) + '\n');
177
+ if (selection.corpus) {
178
+ process.stdout.write(
179
+ `[book:corpus] build-exercises: ${corpusExercises} ` +
180
+ `exercise${corpusExercises === 1 ? '' : 's'} across ${corpusChapters} ` +
181
+ `chapter${corpusChapters === 1 ? '' : 's'} and ${selection.books.length} ` +
182
+ `book${selection.books.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
183
+ );
184
+ } else {
185
+ process.stdout.write(
186
+ `build-exercises: ${corpusExercises} exercise${corpusExercises === 1 ? '' : 's'} across ` +
187
+ `${corpusChapters} chapter${corpusChapters === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
188
+ );
189
+ }
116
190
  }
117
191
 
118
192
  main().catch((err) => {
119
- console.error('build-exercises: failed');
120
- console.error(err.message ?? err);
193
+ const message = String(err?.message ?? err);
194
+ const prefix = DIAGNOSTIC_SCOPE ? `[book:${DIAGNOSTIC_SCOPE}] ` : '';
195
+ console.error(
196
+ message.startsWith('[book:') ? message : `${prefix}build-exercises: failed: ${message}`,
197
+ );
121
198
  process.exit(1);
122
199
  });
123
200