@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
@@ -0,0 +1,241 @@
1
+ # Recipe 24 — Figure authoring standard
2
+
3
+ **Profile**: any profile that publishes diagrams, plots, or TikZ figures.
4
+
5
+ **TL;DR**: Use `--fig-*` when a color means something and `--series-1` through
6
+ `--series-8` when colors only distinguish data series. Author exported figures
7
+ with the canonical hex plus a separate opacity; `build:figures` maps those
8
+ colors to theme-aware CSS variables. Every figure needs a useful caption and
9
+ accessible description, and color must never be the only way to read it.
10
+
11
+ ## 1. Choose the right color contract
12
+
13
+ Semantic colors keep the same meaning across a book:
14
+
15
+ | Token | Meaning | Light | Dark | Export hex |
16
+ |---|---|---|---|---|
17
+ | `--fig-blue` | default, lightweight, informational | `#3B6FA0` | `#7297BB` | `#3B6FA0` |
18
+ | `--fig-green` | positive or successful outcome | `#4A7E3F` | `#7DA275` | `#4A7E3F` |
19
+ | `--fig-rose` | caution or problem | `#C06858` | `#D29287` | `#C06858` |
20
+ | `--fig-plum` | authority, control, or heaviest weight | `#8A4E82` | `#AB80A5` | `#8A4E82` |
21
+ | `--fig-gold` | packaging, coordination, or convergence | `#9D7D34` | `#D2B575` | `#C09840` |
22
+ | `--fig-crimson` | failure or severe problem | `#A03838` | `#BB7070` | `#A03838` |
23
+ | `--fig-ink` | labels, essential outlines, axes | `#1A1A19` | `#E8E5DD` | `#1A1A19` or black |
24
+ | `--fig-paper` | figure background | `#FDFCF9` | `#1A1816` | `#FDFCF9` or white |
25
+ | `--fig-grid` | essential gridlines and secondary structure | `#8C8981` | `#746E67` | `#B5B3AA` or a mid-neutral |
26
+
27
+ `--fig-gold` resolves to a slightly darker gold in the light theme so an
28
+ essential stroke clears 3:1 against `--fig-paper`. The export rewrite still
29
+ recognizes the canonical `#C09840` authoring value.
30
+
31
+ Categorical plots use the stable Okabe–Ito order:
32
+
33
+ | Ordinal | Token | Canonical color |
34
+ |---:|---|---|
35
+ | 1 | `--series-1` | orange `#E69F00` |
36
+ | 2 | `--series-2` | sky blue `#56B4E9` |
37
+ | 3 | `--series-3` | bluish green `#009E73` |
38
+ | 4 | `--series-4` | yellow `#F0E442` |
39
+ | 5 | `--series-5` | blue `#0072B2` |
40
+ | 6 | `--series-6` | vermillion `#D55E00` |
41
+ | 7 | `--series-7` | reddish purple `#CC79A7` |
42
+ | 8 | `--series-8` | theme ink (canonical authoring color: black) |
43
+
44
+ Series 1–7 use the same canonical value in light and dark; each already clears
45
+ 3:1 against the dark figure paper. Series 8 changes with `--fig-ink`.
46
+
47
+ Assign series in that order and keep the ordinal attached to the same series
48
+ when a chart or theme changes. Never use `--series-*` to mean good, bad,
49
+ warning, or success; those meanings belong to `--fig-*`.
50
+
51
+ After PDF export, canonical series-8 black is indistinguishable from labels and
52
+ axes. The rewrite therefore maps it to structural ink; `--series-8` aliases the
53
+ same `--fig-ink` value, so it still renders correctly in both themes. Use
54
+ `var(--series-8)` directly in a CSS-native SVG if you need to override that
55
+ ordinal independently.
56
+
57
+ ## 2. CSS-native SVG: the preferred hand-authored form
58
+
59
+ Use variables directly. Pale fills should be computed from the current figure
60
+ paper, while their solid borders carry the category:
61
+
62
+ ```svg
63
+ <svg role="img" aria-labelledby="pipeline-title pipeline-desc"
64
+ viewBox="0 0 420 140" xmlns="http://www.w3.org/2000/svg">
65
+ <title id="pipeline-title">A three-stage publishing pipeline</title>
66
+ <desc id="pipeline-desc">
67
+ Draft flows to review and then publication. Review is the only caution stage.
68
+ </desc>
69
+ <style>
70
+ .label { fill: var(--fig-ink); font: 16px sans-serif; }
71
+ .edge { fill: none; stroke: var(--fig-ink); stroke-width: 2; }
72
+ .draft {
73
+ fill: color-mix(in srgb, var(--fig-blue) 14%, var(--fig-paper));
74
+ stroke: var(--fig-blue);
75
+ }
76
+ .review {
77
+ fill: color-mix(in srgb, var(--fig-rose) 14%, var(--fig-paper));
78
+ stroke: var(--fig-rose);
79
+ }
80
+ .published {
81
+ fill: color-mix(in srgb, var(--fig-green) 14%, var(--fig-paper));
82
+ stroke: var(--fig-green);
83
+ }
84
+ .node { stroke-width: 3; }
85
+ </style>
86
+ <!-- geometry omitted -->
87
+ </svg>
88
+ ```
89
+
90
+ When this is a local SVG rendered by `<Figure>`, the host page supplies the
91
+ variables and its light/dark toggle changes the figure immediately. An SVG
92
+ loaded from another origin cannot inherit host variables.
93
+
94
+ For a categorical chart, use `stroke: var(--series-1)`, then `--series-2`, and
95
+ so on. Give every line a second channel too: different dashes, markers, or a
96
+ direct text label.
97
+
98
+ ## 3. TikZ and matplotlib exports
99
+
100
+ PDF cannot carry CSS variables. `build:figures` therefore recognizes the exact
101
+ Warm–Tol and Okabe–Ito authoring colors in the generated SVG and maps them back
102
+ to variables. Unknown saturated colors stay exactly as authored.
103
+
104
+ The important rule is **base color plus separate opacity**. Do not use a
105
+ pre-blended low-chroma color such as `figblue!13`: after PDF export it is only
106
+ an anonymous near-white RGB and can be mistaken for figure paper.
107
+
108
+ TikZ:
109
+
110
+ ```latex
111
+ \definecolor{figblue}{HTML}{3B6FA0}
112
+ \definecolor{figrose}{HTML}{C06858}
113
+ \definecolor{seriesone}{HTML}{E69F00}
114
+
115
+ \node[
116
+ draw=figblue,
117
+ fill=figblue,
118
+ fill opacity=.14,
119
+ text opacity=1,
120
+ line width=1.2pt,
121
+ ] (draft) {Draft};
122
+ \node[draw=figrose, fill=figrose, fill opacity=.14, text opacity=1] (review) {Review};
123
+ \draw[seriesone, very thick, dashed] plot coordinates {(0,0) (1,2) (2,3)};
124
+ ```
125
+
126
+ Matplotlib:
127
+
128
+ ```python
129
+ import matplotlib.pyplot as plt
130
+
131
+ FIG = {
132
+ "blue": "#3B6FA0",
133
+ "rose": "#C06858",
134
+ "green": "#4A7E3F",
135
+ }
136
+ SERIES = [
137
+ "#E69F00", "#56B4E9", "#009E73", "#F0E442",
138
+ "#0072B2", "#D55E00", "#CC79A7", "#000000",
139
+ ]
140
+
141
+ fig, ax = plt.subplots()
142
+ # Keep the canvas transparent: the host's --fig-paper must show through after
143
+ # PDF → SVG conversion instead of baking a light-only rectangle into the plot.
144
+ fig.patch.set_alpha(0)
145
+ ax.set_facecolor("none")
146
+ ax.fill_between(x, low, high, color=FIG["blue"], alpha=.14)
147
+ ax.plot(x, mean, color=FIG["blue"], linewidth=2)
148
+ ax.plot(x, baseline, color=SERIES[0], linestyle="--", marker="o", label="Baseline")
149
+ fig.savefig(
150
+ "figures/results/summary.pdf",
151
+ format="pdf",
152
+ transparent=True,
153
+ bbox_inches="tight",
154
+ )
155
+ ```
156
+
157
+ Then run `npm run build:figures` to preserve the vector PDF→SVG path. Export a
158
+ raster (`.png`) only when the source is true pixel data, such as a photograph
159
+ or instrument raster. Lines, text, markers, and diagram geometry should remain
160
+ vector PDF/SVG. Re-run the command after upgrading the scaffold so
161
+ already-generated SVGs receive new mappings; the rewrite is idempotent.
162
+
163
+ ## 4. Accessible name and description
164
+
165
+ Use `<Figure>` as the accessibility boundary:
166
+
167
+ ```mdx
168
+ <Figure
169
+ src="/figures/results/summary.svg"
170
+ id="fig-results-summary"
171
+ caption="Review catches most defects before publication."
172
+ alt="Three-stage publishing pipeline"
173
+ desc="Draft flows right to review, then publication. The review node is marked as a caution stage, and the publication node is marked as a successful outcome."
174
+ />
175
+ ```
176
+
177
+ - `caption` states the point the reader should take from the figure.
178
+ - `alt` is a short accessible name, not a duplicate of every visible label.
179
+ - `desc` gives reading order, relationships, trend, and any conclusion carried
180
+ by position, shape, line style, or color.
181
+ - For a local pipeline SVG, `<Figure>` replaces stale `<title>`/`<desc>` nodes
182
+ with these props and wires `aria-labelledby` automatically.
183
+ - Raster and remote images remain `<img>` elements, so give them complete alt
184
+ text and put any longer explanation in adjacent prose.
185
+
186
+ Do not write “blue means success” as the only description. Name the category
187
+ and the redundant cue: “the successful outcome is the green node with a check
188
+ mark.”
189
+
190
+ ## 5. Contrast and color-vision rules
191
+
192
+ Okabe–Ito is color-vision-deficiency-friendly; that does not make every hue a
193
+ high-contrast thin line on light paper. In particular, orange, sky blue,
194
+ yellow, and reddish purple need redundant encoding.
195
+
196
+ - Never encode a distinction by color alone. Add marker shape, dash pattern,
197
+ direct labels, texture, or position.
198
+ - Use `--fig-ink` for axes, text, arrowheads, and essential outlines.
199
+ - Give pale semantic areas a solid `--fig-*` border. The semantic strokes and
200
+ `--fig-grid` clear WCAG's 3:1 non-text contrast threshold in both themes.
201
+ - Outline small categorical marks with `--fig-ink`; use thicker/dashed lines
202
+ and markers for categorical series that do not clear 3:1 by color alone.
203
+ - Do not reorder `--series-*` in dark mode. `--series-8` follows theme ink so
204
+ the neutral series remains visible rather than staying literal black.
205
+
206
+ ## 6. Dual-theme release gate
207
+
208
+ Before publishing a new figure:
209
+
210
+ 1. Run `npm run build:figures` and confirm the SVG contains one
211
+ `data-diagram-map` style block.
212
+ 2. Render it through `<Figure>` with explicit light and dark page themes.
213
+ 3. Open the standalone SVG with both OS color schemes; its embedded defaults
214
+ should also switch.
215
+ 4. Check labels, axes, arrowheads, boundaries, and all series at the smallest
216
+ published size.
217
+ 5. Inspect a grayscale or common CVD simulation and verify that markers,
218
+ dashes, labels, or shapes preserve every distinction.
219
+ 6. Confirm the caption and screen-reader description communicate the takeaway
220
+ without relying on color names.
221
+
222
+ If automatic recoloring is genuinely wrong for a special TikZ figure, add a
223
+ `%! no-theme` line to its `.tex` source and supply a fully authored light/dark
224
+ solution yourself. That escape hatch opts out of both palette and neutral
225
+ mapping.
226
+
227
+ ## Canonical files
228
+
229
+ - `src/lib/figure-palette.mjs` — sole palette manifest + generated CSS/theme renderers
230
+ - `styles/tokens.css` — public `--fig-*`, `--series-*`, and compatibility tokens
231
+ - `src/lib/figure.mjs` — exact palette recognition and SVG theme injection
232
+ - `scripts/sync-figure-tokens.mjs` — checks or refreshes the generated token block
233
+ - `scripts/build-figures.mjs` — PDF/TikZ conversion pipeline
234
+ - `components/Figure.astro` — inlining plus accessible `<title>`/`<desc>`
235
+ - `tests/figure-palette.test.mjs` — token, mapping, theme, and contrast contract
236
+
237
+ ## See also
238
+
239
+ - [Recipe 03 — Asset pipelines](03-asset-pipelines.md)
240
+ - [Recipe 04 — Component library](04-component-library.md)
241
+ - [Recipe 16 — TikZ figures](16-tikz-figures.md)
package/recipes/README.md CHANGED
@@ -26,9 +26,10 @@ Terse pointers into canonical code for the most common book-authoring workflows.
26
26
  | 18 | [Chapter route ownership](18-chapter-route-ownership.md) | any | When to override the auto-injected `/chapters/[...slug]/` route |
27
27
  | 19 | [Prevalidate hook](19-prevalidate-hook.md) | any | Wire `prevalidate` to run `build:bib` + `build:labels` before `validate` |
28
28
  | 20 | [Anki deck export (consumer-side)](20-anki-export.md) | any (esp. course-notes, research-portfolio) | Roll-your-own `<AnkiCard>` + extractor; scaffold deliberately doesn't ship this |
29
- | 21 | [Multiple guides in one app](21-multi-guide-single-app.md) | any | `generateId` namespacing over one collection; flat-slug footgun; interim until multibook (#80) |
30
- | 22 | [Responsive navigation and multi-book routing](22-responsive-nav-and-multibook-routing.md) | any | Mobile/desktop nav, base-aware route ownership, and the interim multi-book routing contract |
29
+ | 21 | [Multiple books in one app](21-multi-guide-single-app.md) | any | First-class `defineBookCorpus`: routes, scoped data/search, `--book`, and v4 migration |
30
+ | 22 | [Responsive navigation and custom routing](22-responsive-nav-and-multibook-routing.md) | any | Mobile/desktop nav and the v4-compatible consumer-owned route-token API |
31
31
  | 23 | [Interactive demo substrate](23-interactive-demo-substrate.md) | any | Opt-in Preact shell, slider, stat cards, theme colors, a11y, and reduced motion |
32
+ | 24 | [Figure authoring standard](24-figure-authoring-standard.md) | any | Warm–Tol semantics, Okabe–Ito series, dual-theme exports, contrast, and accessible descriptions |
32
33
 
33
34
  ## How to read recipes
34
35
 
@@ -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