@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
@@ -25,21 +25,22 @@
25
25
  *
26
26
  * v4.11.0 (closes #84): each generated SVG gets a post-export rewrite
27
27
  * (recolorSvg) that injects role="img" + a CSS-variable theming layer so one
28
- * SVG serves light + dark. Neutral fills/strokes are remapped to
29
- * var(--diagram-ink|paper|grid, <original>) via injected attribute-selector
30
- * rules (the original attribute stays as the fallback); saturated accent colors
31
- * are left untouched. A `%! no-theme` line in the source .tex opts a figure out.
28
+ * SVG serves light + dark. Known Warm–Tol semantic and Okabe–Ito categorical
29
+ * colors are mapped to var(--fig-*|--series-*, <original>); neutral paints map
30
+ * to var(--diagram-ink|paper|grid, <original>). Unknown saturated colors stay
31
+ * authored. A `%! no-theme` line in the source .tex opts a figure out.
32
32
  * <Figure> inlines local SVGs so they track the in-page [data-theme] toggle.
33
33
  *
34
34
  * Idempotent: skips when the target SVG is newer than the source PDF (and the
35
35
  * recolor itself is a no-op on an already-themed SVG, so re-runs after an
36
36
  * upgrade safely theme pre-existing figures).
37
- * Run on `prebuild` so Astro always sees fresh figures.
37
+ * Run explicitly with `npm run build:figures`; optional system tools keep this
38
+ * authoring pipeline out of the generated project's `prebuild` hook.
38
39
  *
39
40
  * Graceful skip: when pdftocairo / pdftoppm aren't on PATH (e.g. Cloudflare
40
41
  * build container), the script warns and exits 0. Committed SVGs/PNGs under
41
- * public/figures/ are served as-is. Local devs with poppler-utils regenerate
42
- * from PDFs on every `npm run dev`.
42
+ * public/figures/ are served as-is. Local authors with poppler-utils regenerate
43
+ * from PDFs when source figures change.
43
44
  */
44
45
  import { readdir, stat, mkdir } from 'node:fs/promises';
45
46
  import { existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
@@ -56,8 +57,10 @@ Figure pipeline. PDF -> SVG via pdftocairo (PNG fallback via pdftoppm at
56
57
  Graceful-skip if pdftocairo / pdftoppm not on PATH.
57
58
 
58
59
  Each SVG is rewritten to be accessible + dark-mode-aware: role="img" plus
59
- var(--diagram-ink|paper|grid, orig) fills (a "%! no-theme" line in the
60
- source .tex opts out). <Figure> inlines local SVGs so they track the theme.
60
+ exact Warm–Tol/Okabe–Ito palette mappings and neutral
61
+ var(--diagram-ink|paper|grid, orig) mappings. Use base color + separate opacity,
62
+ not a pre-blended tint. A "%! no-theme" line in the source .tex opts out.
63
+ <Figure> inlines local SVGs so they track the theme.
61
64
 
62
65
  Env:
63
66
  BOOK_FIGURES_PATH Override figures source (default: figures/).
@@ -202,7 +205,7 @@ const NO_THEME_RE = /^\s*%!\s*no-theme\b/m;
202
205
  /**
203
206
  * v4.11.0 (#84): apply the theming rewrite to a generated SVG in place.
204
207
  * No-op (returns false) if the file is absent or recolorSvg leaves it unchanged
205
- * (already themed / nothing neutral to remap). Idempotent.
208
+ * (already themed / nothing themeable to remap). Idempotent.
206
209
  */
207
210
  function themeIfSvg(svgPath, optOut) {
208
211
  if (!existsSync(svgPath)) return false;
@@ -48,12 +48,18 @@
48
48
  *
49
49
  * Designed to run in <2 s on a medium book.
50
50
  */
51
- import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
52
- import { resolve, relative, join, dirname, sep } from 'node:path';
51
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
52
+ import { resolve, relative, dirname, sep } from 'node:path';
53
53
  import { pathToFileURL } from 'node:url';
54
54
  import { createMarkdownProcessor } from '@astrojs/markdown-remark';
55
- import { readChaptersBase } from './walk-mdx.mjs';
55
+ import { readChaptersBase, walkMdx } from './walk-mdx.mjs';
56
56
  import { loadResolvedBookConfig } from './resolve-book-config.mjs';
57
+ import {
58
+ assertLegacyBookMatches,
59
+ mergeCorpusArtifact,
60
+ parseFrontmatter,
61
+ resolveBookSelection,
62
+ } from './corpus-tooling.mjs';
57
63
  // #126: reuse the ONE kind vocabulary (theorem-label.ts → its own lean tsup
58
64
  // entry) so a <Theorem kind="proposition"> xref reads "Proposition N.M", not a
59
65
  // kind-blind "Theorem N.M" — and so an unknown/absent kind FAILS HERE (same
@@ -76,6 +82,7 @@ Env:
76
82
  BOOK_LABELS_OUT Override output path (default: src/data/labels.json).
77
83
 
78
84
  Options:
85
+ --book <id> In corpus mode, rebuild only one registered book.
79
86
  --help, -h Print this message and exit (non-mutating).
80
87
 
81
88
  Numbering and chapter hrefs are read from evaluated defineBookConfig metadata.
@@ -87,16 +94,8 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
87
94
  process.exit(0);
88
95
  }
89
96
 
90
- // v4.1.1 (closes #63): readChaptersBase honors BOOK_CHAPTERS_DIR env (when set)
91
- // then parses the consumer's content.config.{ts,mjs,js} for a `chapters`
92
- // collection `loader.base` override. Multi-guide consumers use
93
- // `src/content/<guide-slug>/` rather than the Astro 5 default.
94
- const CHAPTERS_DIR_ABS = await readChaptersBase(process.cwd());
95
- // build-labels uses CHAPTERS_DIR as a path relative to cwd elsewhere in the
96
- // script (joined with `walkMdx`). Convert the absolute path back to relative
97
- // for compatibility with the existing call sites.
98
- const CHAPTERS_DIR = relative(process.cwd(), CHAPTERS_DIR_ABS) || 'src/content/chapters';
99
97
  const OUTPUT_PATH = process.env.BOOK_LABELS_OUT ?? 'src/data/labels.json';
98
+ let DIAGNOSTIC_SCOPE = null;
100
99
 
101
100
  /** Component names that participate in cross-referencing. */
102
101
  const LABELABLE_TYPES = [
@@ -118,27 +117,6 @@ const TYPE_DISPLAY = {
118
117
  CaseStudy: 'Case study',
119
118
  };
120
119
 
121
- // ===== Frontmatter parsing =====
122
-
123
- function splitFrontmatter(source) {
124
- // Standard MDX/YAML frontmatter: `---\n…\n---`.
125
- // Remove it before Markdown processing: YAML comments beginning with `#`
126
- // must not become phantom headings.
127
- const m = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
128
- if (!m) return { frontmatter: {}, body: source };
129
- const fm = {};
130
- for (const line of m[1].split(/\r?\n/)) {
131
- const kv = line.match(/^(\w+)\s*:\s*(.+?)\s*$/);
132
- if (!kv) continue;
133
- const [, key, raw] = kv;
134
- // Strip quotes; coerce numeric scalars.
135
- let val = raw.replace(/^["']|["']$/g, '');
136
- if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
137
- fm[key] = val;
138
- }
139
- return { frontmatter: fm, body: source.slice(m[0].length) };
140
- }
141
-
142
120
  function chapterNumberOf(frontmatter) {
143
121
  // Tools profile uses `chapter`; academic uses `week`. Prefer chapter.
144
122
  if (typeof frontmatter.chapter === 'number') return frontmatter.chapter;
@@ -169,194 +147,221 @@ function extractAttr(attrsBlob, name) {
169
147
  return null;
170
148
  }
171
149
 
172
- // ===== Filesystem walk =====
173
-
174
- async function walkChapters(dir) {
175
- const out = [];
176
- let entries;
177
- try {
178
- entries = await readdir(dir, { withFileTypes: true });
179
- } catch (err) {
180
- if (err.code === 'ENOENT') return out;
181
- throw err;
182
- }
183
- for (const e of entries) {
184
- const path = join(dir, e.name);
185
- if (e.isDirectory()) {
186
- out.push(...(await walkChapters(path)));
187
- continue;
188
- }
189
- if (!e.isFile()) continue;
190
- if (!/\.mdx?$/.test(e.name)) continue;
191
- if (e.name.startsWith('_')) continue; // hidden by convention
192
- out.push(path);
193
- }
194
- return out;
195
- }
196
-
197
150
  // ===== Main =====
198
151
 
199
152
  async function main() {
200
153
  const cwd = process.cwd();
201
- const { numberStyle, chapterRoute, bookField } = await loadResolvedBookConfig(cwd);
202
- const chaptersDir = resolve(cwd, CHAPTERS_DIR);
203
- const files = await walkChapters(chaptersDir);
154
+ const toolingConfig = await loadResolvedBookConfig(cwd);
155
+ if (toolingConfig.corpus) DIAGNOSTIC_SCOPE = 'corpus';
156
+ const { numberStyle, chapterRoute, bookField } = toolingConfig;
157
+ const selection = resolveBookSelection(
158
+ toolingConfig,
159
+ process.argv.slice(2),
160
+ 'build-labels',
161
+ );
162
+ DIAGNOSTIC_SCOPE = selection.corpus ? 'corpus' : null;
163
+ const chaptersRoot = await readChaptersBase(cwd, { corpus: selection.corpus });
164
+ const runs = selection.corpus
165
+ ? selection.books.map((book) => ({ book, dir: resolve(chaptersRoot, book.id) }))
166
+ : [{ book: null, dir: chaptersRoot }];
167
+
204
168
  // Syntax highlighting cannot affect heading metadata and is expensive to
205
169
  // initialize. Everything that does affect Astro heading text/IDs (GFM,
206
170
  // smartypants, rehypeHeadingIds/GitHubSlugger) retains Astro's defaults.
207
171
  const headingProcessor = await createMarkdownProcessor({ syntaxHighlight: false });
208
-
209
- const labels = {};
210
172
  const tagRegex = buildTagRegex();
211
- let totalIds = 0;
212
- let chaptersWithIds = 0;
213
-
214
- for (const file of files) {
215
- const source = await readFile(file, 'utf8');
216
- const { frontmatter: fm, body } = splitFrontmatter(source);
217
- const chapterNum = chapterNumberOf(fm);
218
- const contentId = relative(chaptersDir, file)
219
- .split(sep)
220
- .join('/')
221
- .replace(/\.mdx?$/, '');
222
- const entryId = (typeof fm.slug === 'string' && fm.slug.length > 0)
223
- ? fm.slug
224
- : contentId;
225
- const chapterPath = chapterHref(
226
- { id: entryId, data: fm },
227
- chapterRoute,
228
- '/',
229
- bookField,
230
- ).replace(/^\/+|\/+$/g, '');
231
-
232
- const addLabel = (id, value) => {
233
- if (labels[id]) {
234
- // Duplicate id — surface but don't fail; consumer's validator catches
235
- // collisions with full diagnostic context. Component IDs retain their
236
- // historical precedence over an identically named heading anchor.
237
- process.stderr.write(
238
- `build-labels: WARN duplicate id "${id}" (first in ` +
239
- `${labels[id].href.split('#')[0]}, now in ${entryId})\n`,
173
+ const values = new Map();
174
+ const stats = [];
175
+
176
+ for (const run of runs) {
177
+ const files = [];
178
+ for await (const file of walkMdx(run.dir)) files.push(resolve(run.dir, file));
179
+ const labels = {};
180
+ let totalIds = 0;
181
+ let chaptersWithIds = 0;
182
+
183
+ for (const file of files) {
184
+ const source = await readFile(file, 'utf8');
185
+ if (run.book) {
186
+ assertLegacyBookMatches(
187
+ source,
188
+ run.book,
189
+ `[book:${run.book.id}] ${relative(cwd, file)}`,
240
190
  );
241
191
  }
242
- labels[id] = value;
243
- };
244
-
245
- // Per-chapter counters reset for each file.
246
- const counters = {};
247
- let foundInChapter = 0;
248
-
249
- // #147: BookLink fragments normally target prose sections rather than
250
- // labelable components. Astro owns both text extraction and GitHub-style
251
- // slug collision behavior, so consume its heading metadata directly.
252
- const rendered = await headingProcessor.render(body, {
253
- fileURL: pathToFileURL(file),
254
- frontmatter: fm,
255
- });
256
- for (const heading of rendered.metadata.headings) {
257
- if (heading.depth < 2 || heading.depth > 6) continue;
258
- foundInChapter += 1;
259
- totalIds += 1;
260
- const href = `${chapterPath}#${heading.slug}`;
261
- // Heading fragments are only document-local: many chapters legitimately
262
- // contain `#summary`. A path-qualified, opaque key preserves every one
263
- // without colliding with component IDs or another chapter. BookLink
264
- // validation resolves the exact href VALUE; consumers must not derive
265
- // heading semantics from this internal key.
266
- addLabel(`heading:${href}`, {
267
- href,
268
- display: heading.text,
269
- number: null,
270
- });
271
- }
272
-
273
- for (const match of source.matchAll(tagRegex)) {
274
- const [, componentName, attrs] = match;
275
- const id = extractAttr(attrs, 'id');
276
- if (!id) continue;
277
-
278
- foundInChapter += 1;
279
- totalIds += 1;
280
-
281
- // Resolve the display word only when it will actually be used. A `label=`
282
- // override supplies its own display, so we neither compute nor (for
283
- // <Theorem>) kind-validate it — computing would throw on a kindless
284
- // override, the documented `<Theorem id label="…">` form. For <Theorem>
285
- // the word is kind-aware and THROWS on an absent/unknown kind (the #121
286
- // contract, one build step earlier than render). extractAttr returns null
287
- // for an absent attr → normalize to undefined so theoremLabel reports
288
- // "no kind=" rather than the misleading kind="null".
289
- const labelOverride = extractAttr(attrs, 'label');
290
- let word;
291
- let theoremKind;
292
- if (labelOverride == null) {
293
- if (componentName === 'Theorem') {
294
- try {
295
- const resolvedLabel = theoremLabel({
296
- kind: extractAttr(attrs, 'kind') ?? undefined,
297
- type: extractAttr(attrs, 'type') ?? undefined,
298
- });
299
- word = resolvedLabel.fullLabel;
300
- theoremKind = resolvedLabel.kind;
301
- } catch (err) {
192
+ const fileLabel = run.book
193
+ ? `[book:${run.book.id}] ${relative(cwd, file)}`
194
+ : relative(cwd, file);
195
+ const { frontmatter: fm, body } = parseFrontmatter(source, fileLabel);
196
+ const chapterNum = chapterNumberOf(fm);
197
+ const contentId = relative(run.dir, file)
198
+ .split(sep)
199
+ .join('/')
200
+ .replace(/\.mdx?$/, '');
201
+ const localEntryId = (typeof fm.slug === 'string' && fm.slug.length > 0)
202
+ ? fm.slug
203
+ : contentId;
204
+ const entryId = run.book ? `${run.book.id}/${localEntryId}` : localEntryId;
205
+ const chapterPath = chapterHref(
206
+ { id: entryId, data: fm },
207
+ chapterRoute,
208
+ '/',
209
+ bookField,
210
+ ).replace(/^\/+|\/+$/g, '');
211
+
212
+ const addLabel = (id, value) => {
213
+ if (labels[id]) {
214
+ // Component IDs are book-local in corpus mode, so identical ids in
215
+ // another namespace do not collide.
216
+ const prefix = run.book ? `[book:${run.book.id}] ` : '';
217
+ if (run.book) {
302
218
  throw new Error(
303
- `<Theorem id="${id}"> in ${relative(cwd, file)}: ${err.message}`,
219
+ `${prefix}duplicate label id "${id}" (first in ` +
220
+ `${labels[id].href.split('#')[0]}, now in ${entryId}).`,
304
221
  );
305
222
  }
306
- } else {
307
- word = TYPE_DISPLAY[componentName];
223
+ process.stderr.write(
224
+ `${prefix}build-labels: WARN duplicate id "${id}" (first in ` +
225
+ `${labels[id].href.split('#')[0]}, now in ${entryId})\n`,
226
+ );
308
227
  }
228
+ labels[id] = value;
229
+ };
230
+
231
+ // Per-chapter counters reset for each file.
232
+ const counters = {};
233
+ let foundInChapter = 0;
234
+
235
+ // #147: BookLink fragments normally target prose sections rather than
236
+ // labelable components. Astro owns both text extraction and GitHub-style
237
+ // slug collision behavior, so consume its heading metadata directly.
238
+ const rendered = await headingProcessor.render(body, {
239
+ fileURL: pathToFileURL(file),
240
+ frontmatter: fm,
241
+ });
242
+ for (const heading of rendered.metadata.headings) {
243
+ if (heading.depth < 2 || heading.depth > 6) continue;
244
+ foundInChapter += 1;
245
+ totalIds += 1;
246
+ const href = `${chapterPath}#${heading.slug}`;
247
+ addLabel(`heading:${href}`, {
248
+ href,
249
+ display: heading.text,
250
+ number: null,
251
+ });
309
252
  }
310
253
 
311
- // label= is a custom, unnumbered display and therefore does not consume
312
- // either the historical shared sequence or a per-kind sequence. This
313
- // keeps later auto-numbered entries stable when prose-only labels move.
314
- const counterKey =
315
- numberStyle === 'per-kind' && componentName === 'Theorem'
316
- ? `Theorem/${theoremKind}`
317
- : componentName;
318
- if (labelOverride == null) {
319
- counters[counterKey] = (counters[counterKey] ?? 0) + 1;
254
+ for (const match of source.matchAll(tagRegex)) {
255
+ const [, componentName, attrs] = match;
256
+ const id = extractAttr(attrs, 'id');
257
+ if (!id) continue;
258
+
259
+ foundInChapter += 1;
260
+ totalIds += 1;
261
+
262
+ const labelOverride = extractAttr(attrs, 'label');
263
+ let word;
264
+ let theoremKind;
265
+ if (labelOverride == null) {
266
+ if (componentName === 'Theorem') {
267
+ try {
268
+ const resolvedLabel = theoremLabel({
269
+ kind: extractAttr(attrs, 'kind') ?? undefined,
270
+ type: extractAttr(attrs, 'type') ?? undefined,
271
+ });
272
+ word = resolvedLabel.fullLabel;
273
+ theoremKind = resolvedLabel.kind;
274
+ } catch (err) {
275
+ const prefix = run.book ? `[book:${run.book.id}] ` : '';
276
+ throw new Error(
277
+ `${prefix}<Theorem id="${id}"> in ${relative(cwd, file)}: ${err.message}`,
278
+ );
279
+ }
280
+ } else {
281
+ word = TYPE_DISPLAY[componentName];
282
+ }
283
+ }
284
+
285
+ const counterKey =
286
+ numberStyle === 'per-kind' && componentName === 'Theorem'
287
+ ? `Theorem/${theoremKind}`
288
+ : componentName;
289
+ if (labelOverride == null) {
290
+ counters[counterKey] = (counters[counterKey] ?? 0) + 1;
291
+ }
292
+
293
+ const number = labelOverride != null
294
+ ? null
295
+ : chapterNum != null
296
+ ? `${chapterNum}.${counters[counterKey]}`
297
+ : String(counters[counterKey]);
298
+ const display = labelOverride ?? `${word} ${number}`;
299
+
300
+ addLabel(id, {
301
+ href: `${chapterPath}#${id}`,
302
+ display,
303
+ number,
304
+ });
320
305
  }
321
306
 
322
- // The bare counter string the heading reuses: Theorem.astro reads
323
- // `number` by id and renders it, so heading == xref by construction.
324
- // A `label=` override opts out of auto-numbering → number is null.
325
- const number = labelOverride != null
326
- ? null
327
- : chapterNum != null
328
- ? `${chapterNum}.${counters[counterKey]}`
329
- : String(counters[counterKey]);
330
- const display = labelOverride ?? `${word} ${number}`;
331
-
332
- addLabel(id, {
333
- // #142: base-less ref — XRef.astro prefixes BASE_URL at render so one
334
- // labels.json serves any deploy base (root or path-proxied series).
335
- href: `${chapterPath}#${id}`,
336
- display,
337
- number,
338
- });
307
+ if (foundInChapter > 0) chaptersWithIds += 1;
339
308
  }
340
309
 
341
- if (foundInChapter > 0) chaptersWithIds += 1;
310
+ // Emit deterministic output: keys sorted alphabetically within each book.
311
+ const sorted = {};
312
+ for (const key of Object.keys(labels).sort()) sorted[key] = labels[key];
313
+ values.set(run.book?.id ?? '', sorted);
314
+ stats.push({ book: run.book, totalIds, chaptersWithIds });
342
315
  }
343
316
 
344
- // Emit deterministic output: keys sorted alphabetically.
345
- const sorted = {};
346
- for (const k of Object.keys(labels).sort()) sorted[k] = labels[k];
347
-
348
317
  const outputPath = resolve(cwd, OUTPUT_PATH);
318
+ const output = selection.corpus
319
+ ? await mergeCorpusArtifact({
320
+ path: outputPath,
321
+ corpus: selection.corpus,
322
+ requestedBook: selection.requestedBook,
323
+ values,
324
+ emptyValue: () => ({}),
325
+ artifact: OUTPUT_PATH,
326
+ validateValue: (value) =>
327
+ value !== null && typeof value === 'object' && !Array.isArray(value),
328
+ })
329
+ : values.get('');
349
330
  await mkdir(dirname(outputPath), { recursive: true });
350
- await writeFile(outputPath, JSON.stringify(sorted, null, 2) + '\n', 'utf8');
351
-
352
- process.stdout.write(
353
- `build-labels: ${totalIds} id${totalIds === 1 ? '' : 's'} across ` +
354
- `${chaptersWithIds} chapter${chaptersWithIds === 1 ? '' : 's'} → ` +
355
- `${OUTPUT_PATH} (number-style=${numberStyle})\n`,
356
- );
331
+ await writeFile(outputPath, JSON.stringify(output, null, 2) + '\n', 'utf8');
332
+
333
+ if (selection.corpus) {
334
+ for (const stat of stats) {
335
+ process.stdout.write(
336
+ `[book:${stat.book.id}] build-labels: ${stat.totalIds} ` +
337
+ `id${stat.totalIds === 1 ? '' : 's'} across ${stat.chaptersWithIds} ` +
338
+ `chapter${stat.chaptersWithIds === 1 ? '' : 's'} → ${OUTPUT_PATH} ` +
339
+ `(number-style=${numberStyle})\n`,
340
+ );
341
+ }
342
+ const totalIds = stats.reduce((sum, stat) => sum + stat.totalIds, 0);
343
+ const totalChapters = stats.reduce((sum, stat) => sum + stat.chaptersWithIds, 0);
344
+ process.stdout.write(
345
+ `[book:corpus] build-labels: ${totalIds} id${totalIds === 1 ? '' : 's'} across ` +
346
+ `${totalChapters} chapter${totalChapters === 1 ? '' : 's'} and ` +
347
+ `${stats.length} book${stats.length === 1 ? '' : 's'} → ${OUTPUT_PATH} ` +
348
+ `(number-style=${numberStyle})\n`,
349
+ );
350
+ } else {
351
+ const [stat] = stats;
352
+ process.stdout.write(
353
+ `build-labels: ${stat.totalIds} id${stat.totalIds === 1 ? '' : 's'} across ` +
354
+ `${stat.chaptersWithIds} chapter${stat.chaptersWithIds === 1 ? '' : 's'} → ` +
355
+ `${OUTPUT_PATH} (number-style=${numberStyle})\n`,
356
+ );
357
+ }
357
358
  }
358
359
 
359
360
  main().catch((err) => {
360
- process.stderr.write(`build-labels: fatal: ${err?.message ?? err}\n`);
361
+ const message = String(err?.message ?? err);
362
+ const prefix = DIAGNOSTIC_SCOPE ? `[book:${DIAGNOSTIC_SCOPE}] ` : '';
363
+ process.stderr.write(
364
+ message.startsWith('[book:') ? `${message}\n` : `${prefix}build-labels: fatal: ${message}\n`,
365
+ );
361
366
  process.exit(1);
362
367
  });
@@ -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-tips
29
36
 
@@ -36,6 +43,7 @@ Env:
36
43
  BOOK_TIPS_OUT Override output path (default: src/data/tips.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_TIPS_OUT ?? 'src/data/tips.json';
57
+ let DIAGNOSTIC_SCOPE = null;
50
58
 
51
59
  /**
52
60
  * Extract <Tip n="..." title="..."> tags and their body content from MDX.
@@ -104,38 +112,102 @@ function extractTips(source, chapterSlug) {
104
112
  }
105
113
 
106
114
  async function main() {
107
- const allTips = [];
108
- for await (const rel of walkMdx(CHAPTERS_DIR)) {
109
- const chapterPath = resolve(CHAPTERS_DIR, rel);
110
- const chapterSlug = basename(rel).replace(/\.mdx?$/, '');
111
- let source;
112
- try {
113
- source = await readFile(chapterPath, 'utf8');
114
- } catch {
115
- continue;
115
+ const toolingConfig = await loadResolvedBookConfig(CWD);
116
+ if (toolingConfig.corpus) DIAGNOSTIC_SCOPE = 'corpus';
117
+ const selection = resolveBookSelection(toolingConfig, process.argv.slice(2), 'build-tips');
118
+ DIAGNOSTIC_SCOPE = selection.corpus ? 'corpus' : null;
119
+ const chaptersRoot = await readChaptersBase(CWD, { corpus: selection.corpus });
120
+ const runs = selection.corpus
121
+ ? selection.books.map((book) => ({ book, dir: resolve(chaptersRoot, book.id) }))
122
+ : [{ book: null, dir: chaptersRoot }];
123
+ const values = new Map();
124
+ let corpusTotal = 0;
125
+
126
+ for (const run of runs) {
127
+ const allTips = [];
128
+ for await (const rel of walkMdx(run.dir)) {
129
+ const chapterPath = resolve(run.dir, rel);
130
+ let source;
131
+ try {
132
+ source = await readFile(chapterPath, 'utf8');
133
+ } catch {
134
+ continue;
135
+ }
136
+ if (run.book) {
137
+ assertLegacyBookMatches(
138
+ source,
139
+ run.book,
140
+ `[book:${run.book.id}] ${resolve(CWD, chapterPath).replace(`${CWD}/`, '')}`,
141
+ );
142
+ }
143
+ const fileLabel = run.book
144
+ ? `[book:${run.book.id}] ${resolve(CWD, chapterPath).replace(`${CWD}/`, '')}`
145
+ : resolve(CWD, chapterPath).replace(`${CWD}/`, '');
146
+ const chapterSlug = run.book
147
+ ? frontmatterSlug(source, fileLabel) ?? rel.replace(/\.mdx?$/, '')
148
+ : basename(rel).replace(/\.mdx?$/, '');
149
+ allTips.push(...extractTips(source, chapterSlug));
150
+ }
151
+
152
+ // Sort by n; warn on duplicates (don't fail — the index page just shows duplicates).
153
+ allTips.sort((a, b) => a.n - b.n);
154
+ const seenN = new Set();
155
+ for (const tip of allTips) {
156
+ if (seenN.has(tip.n)) {
157
+ const prefix = run.book ? `[book:${run.book.id}] ` : '';
158
+ process.stderr.write(
159
+ `${prefix}build-tips: WARN duplicate Tip n="${tip.n}" (last wins on /tips index)\n`,
160
+ );
161
+ }
162
+ seenN.add(tip.n);
116
163
  }
117
- allTips.push(...extractTips(source, chapterSlug));
118
- }
119
164
 
120
- // Sort by n; warn on duplicates (don't fail — the index page just shows duplicates).
121
- allTips.sort((a, b) => a.n - b.n);
122
- const seenN = new Set();
123
- for (const tip of allTips) {
124
- if (seenN.has(tip.n)) {
125
- process.stderr.write(`build-tips: WARN duplicate Tip n="${tip.n}" (last wins on /tips index)\n`);
165
+ if (run.book) {
166
+ values.set(run.book.id, allTips);
167
+ corpusTotal += allTips.length;
168
+ process.stdout.write(
169
+ `[book:${run.book.id}] build-tips: ${allTips.length} ` +
170
+ `tip${allTips.length === 1 ? '' : 's'} ${OUTPUT_PATH}\n`,
171
+ );
172
+ } else {
173
+ values.set('', allTips);
126
174
  }
127
- seenN.add(tip.n);
128
175
  }
129
176
 
130
177
  const outPath = resolve(CWD, OUTPUT_PATH);
178
+ const output = selection.corpus
179
+ ? await mergeCorpusArtifact({
180
+ path: outPath,
181
+ corpus: selection.corpus,
182
+ requestedBook: selection.requestedBook,
183
+ values,
184
+ emptyValue: () => [],
185
+ artifact: OUTPUT_PATH,
186
+ validateValue: Array.isArray,
187
+ })
188
+ : values.get('');
131
189
  await mkdir(dirname(outPath), { recursive: true });
132
- await writeFile(outPath, JSON.stringify(allTips, null, 2) + '\n');
133
- process.stdout.write(`build-tips: ${allTips.length} tip${allTips.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`);
190
+ await writeFile(outPath, JSON.stringify(output, null, 2) + '\n');
191
+ if (selection.corpus) {
192
+ process.stdout.write(
193
+ `[book:corpus] build-tips: ${corpusTotal} ` +
194
+ `tip${corpusTotal === 1 ? '' : 's'} across ${selection.books.length} ` +
195
+ `book${selection.books.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
196
+ );
197
+ } else {
198
+ const allTips = values.get('');
199
+ process.stdout.write(
200
+ `build-tips: ${allTips.length} tip${allTips.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
201
+ );
202
+ }
134
203
  }
135
204
 
136
205
  main().catch((err) => {
137
- console.error('build-tips: failed');
138
- console.error(err.message ?? err);
206
+ const message = String(err?.message ?? err);
207
+ const prefix = DIAGNOSTIC_SCOPE ? `[book:${DIAGNOSTIC_SCOPE}] ` : '';
208
+ console.error(
209
+ message.startsWith('[book:') ? message : `${prefix}build-tips: failed: ${message}`,
210
+ );
139
211
  process.exit(1);
140
212
  });
141
213