@brandon_m_behring/book-scaffold-astro 4.31.0 → 5.1.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 (86) hide show
  1. package/CLAUDE.md +61 -2
  2. package/MIGRATION-v4-to-v5.md +183 -0
  3. package/README.md +64 -1
  4. package/bin/book-scaffold.mjs +4 -0
  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/Flashcards.tsx +19 -11
  11. package/components/NavContent.astro +60 -11
  12. package/components/ObjectiveMap.astro +14 -2
  13. package/components/PartReview.astro +32 -4
  14. package/components/PatternTimeline.astro +6 -2
  15. package/components/Rationale.astro +20 -3
  16. package/components/Sidebar.astro +11 -3
  17. package/components/SourceArchive.astro +33 -3
  18. package/components/Term.astro +18 -1
  19. package/components/Theorem.astro +15 -5
  20. package/components/TipsCard.astro +40 -3
  21. package/components/XRef.astro +14 -2
  22. package/dist/components/ExamRunner.d.ts +1 -1
  23. package/dist/components/Flashcards.d.ts +6 -1
  24. package/dist/components/Flashcards.mjs +14 -11
  25. package/dist/{exam-manifest-X9IrX1G3.d.ts → exam-manifest-DttY7kyZ.d.ts} +1 -1
  26. package/dist/index.d.ts +76 -8
  27. package/dist/index.mjs +522 -44
  28. package/dist/schemas.d.ts +1 -1
  29. package/dist/schemas.mjs +311 -32
  30. package/dist/{types-DgSlAew3.d.ts → types-D1QZgKMO.d.ts} +73 -21
  31. package/layouts/Base.astro +38 -9
  32. package/layouts/Chapter.astro +10 -2
  33. package/package.json +5 -2
  34. package/pages/answers.astro +25 -6
  35. package/pages/book.astro +75 -0
  36. package/pages/chapters/[...slug].astro +39 -7
  37. package/pages/chapters-book.astro +17 -0
  38. package/pages/chapters.astro +87 -9
  39. package/pages/convergence.astro +40 -10
  40. package/pages/corpus-apparatus/answers.astro +15 -0
  41. package/pages/corpus-apparatus/convergence.astro +15 -0
  42. package/pages/corpus-apparatus/exercises.astro +15 -0
  43. package/pages/corpus-apparatus/flashcards.astro +15 -0
  44. package/pages/corpus-apparatus/glossary.astro +15 -0
  45. package/pages/corpus-apparatus/practice-exam.astro +15 -0
  46. package/pages/corpus-apparatus/print.astro +15 -0
  47. package/pages/corpus-apparatus/references.astro +15 -0
  48. package/pages/corpus-apparatus/tips.astro +15 -0
  49. package/pages/exercises.astro +18 -5
  50. package/pages/flashcards.astro +26 -5
  51. package/pages/glossary.astro +20 -3
  52. package/pages/index.astro +54 -1
  53. package/pages/practice-exam.astro +8 -2
  54. package/pages/print.astro +7 -2
  55. package/pages/references.astro +26 -6
  56. package/pages/search.astro +65 -4
  57. package/pages/tips.astro +17 -4
  58. package/recipes/09-validation.md +1 -1
  59. package/recipes/15-defining-styles.md +6 -6
  60. package/recipes/20-anki-export.md +15 -8
  61. package/recipes/21-multi-guide-single-app.md +293 -44
  62. package/recipes/22-responsive-nav-and-multibook-routing.md +7 -1
  63. package/recipes/25-qa-readiness.md +197 -0
  64. package/recipes/README.md +3 -2
  65. package/scripts/build-bib.mjs +113 -35
  66. package/scripts/build-exercises.mjs +101 -24
  67. package/scripts/build-labels.mjs +199 -194
  68. package/scripts/build-tips.mjs +95 -23
  69. package/scripts/corpus-tooling.mjs +268 -0
  70. package/scripts/init-qa.mjs +209 -0
  71. package/scripts/qa-core.mjs +1511 -0
  72. package/scripts/qa.mjs +293 -0
  73. package/scripts/resolve-book-config.mjs +138 -1
  74. package/scripts/validate-core.mjs +1675 -0
  75. package/scripts/validate.mjs +18 -916
  76. package/scripts/validation-artifacts.mjs +23 -0
  77. package/scripts/walk-mdx.mjs +16 -4
  78. package/src/lib/book-link.ts +72 -0
  79. package/src/lib/chapters.ts +10 -4
  80. package/src/lib/corpus-collateral.ts +9 -0
  81. package/src/lib/corpus.ts +458 -0
  82. package/src/lib/define-style.ts +28 -15
  83. package/src/lib/exam-manifest.ts +4 -1
  84. package/src/lib/patterns.ts +15 -6
  85. package/src/lib/questions.ts +8 -3
  86. package/src/types.ts +603 -0
package/CLAUDE.md CHANGED
@@ -31,9 +31,46 @@ the consuming book knows which versions are actually deployed. Import it from
31
31
  navigation belongs. It renders nothing for an omitted or empty manifest;
32
32
  `Base.astro` never invents or auto-mounts version links.
33
33
 
34
+ ## Corpus mode (v5, opt-in)
35
+
36
+ A corpus is one Astro application and one homogeneous preset/Style chain with
37
+ an ordered `defineBookCorpus` manifest. The same branded manifest must be passed
38
+ to `defineBookConfig({ corpus })` and `defineBookSchemas({ corpus })`; do not
39
+ copy or reconstruct it between entrypoints.
40
+
41
+ Corpus chapters live at `src/content/<book>/<local-path>.{md,mdx}`. The
42
+ registered first path segment is the book identity and generates an entry id
43
+ `<book>/<local-id>` plus URL `/chapters/<book>/<local-id>/`. Do not add required
44
+ `book:` frontmatter or hand-write a `generateId`; a legacy `book:` value may
45
+ remain only when it matches the path. Questions and glossary entries use
46
+ `src/content/{questions,glossary}/<book>/...` and the same namespacing rule.
47
+ Consequently, `questions`, `glossary`, and `frontmatter` are reserved book ids:
48
+ those directories belong to scaffold content collections, not chapter owners.
49
+
50
+ Keep every lookup book-scoped: navigation/previous-next, labels, references,
51
+ tips, exercises, questions, and glossary entries must select the current
52
+ manifest book. Corpus JSON is `{ schemaVersion: 1, books: { [id]: payload } }`;
53
+ single-book JSON remains flat. The content-derived `build-labels`, `build-bib`,
54
+ `build-tips`, `build-exercises`, and `validate` commands accept `--book <id>`
55
+ in corpus mode. `qa` defaults to all manifest books, accepts either one exact
56
+ `--book <id>` or `--all`, and rejects `--book` in single-book mode. Figures and
57
+ notebooks remain application-wide.
58
+
59
+ Convergence collateral lives at `changelog/<book>/patterns.yaml` and
60
+ `changelog/<book>/tools/*.yaml`; root-level v4 collateral is single-book only.
61
+ Flashcard persistence is automatically namespaced by deployment base and book.
62
+
63
+ Canonical corpus routes are `/`, `/chapters/`, `/<book>/`,
64
+ `/chapters/<book>/`, `/chapters/<book>/<slug>/`, `/search/` (optionally
65
+ `?book=<id>`), and `/<book>/<apparatus>/`, all under Astro `base`. Corpus mode
66
+ owns `chapterRoute`, `bookField`, `apparatusRoute`, and `apparatusRoutes`. An
67
+ omitted manifest `apparatus` inherits enabled app routes; `[]` disables all for
68
+ that book. Search uses one Pagefind index with a `book` filter. See Recipe 21
69
+ before changing corpus content, routing, or data scripts.
70
+
34
71
  ## Frontmatter schemas
35
72
 
36
- **Universal field (v4.9.0):** every profile accepts an optional `slug:` string that overrides the URL. A file `99-appendix.mdx` with `slug: appendix` is served at `/chapters/appendix/` Astro's glob loader maps frontmatter `slug` `entry.id`, and cross-references (`<XRef>`, via `build-labels`) resolve to the same path. Omit it and the URL falls back to the filename. Use it to keep numbered filenames for ordering while publishing clean URLs.
73
+ **Universal field (v4.9.0):** every profile accepts an optional `slug:` string that overrides the URL. In single-book mode, a file `99-appendix.mdx` with `slug: appendix` is served at `/chapters/appendix/`; in corpus mode, `evaluation/99-appendix.mdx` is served at `/chapters/evaluation/appendix/`. The loader and `build-labels` resolve the same id. Omit `slug` to use the nested filename path.
37
74
 
38
75
  ### Academic profile (`src/content.config.ts:academicChapterSchema`)
39
76
 
@@ -108,7 +145,7 @@ Two callout families coexist. Authors import what they need.
108
145
 
109
146
  **Pedagogy family** (v4.1.0+, any profile, 4 components): `Pitfall` (rose; "common mistake" — distinct from `WarnBox`'s preemptive warning), `WorkedExample` (plum; collapsible `<details>` block with `#worked-example-{id}` anchor for deep links), `YouWillLearn` (gold; chapter-opener with optional `prerequisites` prop), `Diagnostic` (v4.19.0, #110; teal; pre-reading "Do I Know This Already?" DIKTA self-check — a slotted question list + a skip/skim/read routing rubric via `skimTo`, plus an optional collapsible answer key via `slot="answers"`). Slot bullets/code freely; render at any preset.
110
147
 
111
- **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginFigure`, `MarginNote`, `Sidenote`, `EvidenceTag`, `Newthought`, `Epigraph`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; #147 adds `{ url, labels }` entries so `validate` checks literal fragment targets against a vendored sibling `labels.json`, while legacy URL strings warn/skip target validation; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
148
+ **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginFigure`, `MarginNote`, `Sidenote`, `EvidenceTag`, `Newthought`, `Epigraph`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (cross-book link — a manifest-owned `book` resolves locally in corpus mode; otherwise it resolves through `defineBookConfig({ siblingBooks })`; #147 `{ url, labels }` entries let `validate` check external literal fragments against a vendored `labels.json`; `<XRef>` remains current-book only), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
112
149
 
113
150
  **Cross-book heading indexes (#147):** `book-scaffold build-labels` indexes h2–h6 with Astro's heading collector (inline formatting/smartypants plus GitHub duplicate slugs), alongside the historical component IDs. Emitted hrefs are base-less and resolve the evaluated `chapterRoute` / `bookField`; nested content IDs keep their directory. Heading keys are opaque and path-qualified so the same fragment can exist in multiple chapters; `validate` matches exact normalized href values, while component keys stay backward-compatible for XRef. This makes a generated sibling `labels.json` usable for literal `<BookLink ... to="...#anchor">` validation without assuming `/chapters/`. h1 stays excluded as the chapter title.
114
151
 
@@ -166,6 +203,7 @@ npm install # once after clone
166
203
  npm run dev # localhost:4321
167
204
  npm run build # astro build + pagefind index → dist/
168
205
  npm run validate # pre-flight check (recipe 09)
206
+ npm exec -- book-scaffold qa # content-readiness verdict (recipe 25)
169
207
  npm run build:bib # rebuild references.json after .bib edit
170
208
  npm run pdf # render dist-pdf/book.pdf via Paged.js
171
209
  ```
@@ -192,6 +230,27 @@ A consumer `public/_headers` wins unchanged. Use
192
230
  or `securityHeaders: false` when another layer owns all headers. Recipe 05 has
193
231
  the full precedence and customization contract.
194
232
 
233
+ ## QA and content readiness
234
+
235
+ Run `npm exec -- book-scaffold qa` after substantive content changes. It calls
236
+ the same content-contract library as `validate`, then reports stable chapter,
237
+ link, learning-objective, component, and JSON-fixture facts. Human output is
238
+ the default. For CI use `npm --offline exec -- book-scaffold qa --format json`;
239
+ JSON stdout contains only the schema-v1 result, while progress/fatal details go
240
+ to stderr. Exit `0` means no blocking failure, `1` means a red selected/shared
241
+ result, and `2` means the invocation or execution failed.
242
+
243
+ In a corpus, omission checks every manifest book in order; `--book <id>` checks
244
+ one and `--all` states the default explicitly. Schema v1 always includes a
245
+ top-level `shared: { verdict, checks, diagnostics }` aggregate for corpus-wide
246
+ fixture data. Its exact single-book value is
247
+ `{ "verdict": "not_applicable", "checks": {}, "diagnostics": [] }`.
248
+
249
+ Run `npm exec -- book-scaffold init-qa` only when a portfolio QA engine needs
250
+ `guide_qa.yaml`. It emits one deterministic offline command per manifest book,
251
+ or one implicit `book` entry without `--book`. It preserves an existing file;
252
+ `--force` replaces that whole file and no other path. See Recipe 25.
253
+
195
254
  ## Validation
196
255
 
197
256
  `npm run validate` (also runs in prebuild) catches:
@@ -0,0 +1,183 @@
1
+ # Migrating from v4.x to v5.0.0
2
+
3
+ **Audience**: existing users of `@brandon_m_behring/book-scaffold-astro@^4`.
4
+
5
+ v5 makes two previously deprecated configuration changes and adds an opt-in
6
+ book-corpus mode. Existing single-book routes and generated JSON stay unchanged;
7
+ corpus mode is the only feature that changes their shape.
8
+
9
+ ## Required changes for every application
10
+
11
+ ### 1. Make the preset explicit
12
+
13
+ v4 could fall back to `minimal` when no preset was configured. v5 fails instead,
14
+ because a missing `.env` in CI must not silently select the wrong schema and
15
+ rendering behavior.
16
+
17
+ For `defineBookConfig`, include a built-in Style or a custom Style with a
18
+ `preset`:
19
+
20
+ ```ts
21
+ import {
22
+ defineBookConfig,
23
+ academicStyle,
24
+ } from '@brandon_m_behring/book-scaffold-astro';
25
+
26
+ export default await defineBookConfig({
27
+ styles: [academicStyle],
28
+ site: 'https://book.example/',
29
+ });
30
+ ```
31
+
32
+ Give `defineBookSchemas` the matching preset directly:
33
+
34
+ ```ts
35
+ import { defineBookSchemas } from '@brandon_m_behring/book-scaffold-astro/schemas';
36
+
37
+ export const { collections } = defineBookSchemas({ preset: 'academic' });
38
+ ```
39
+
40
+ `BOOK_PRESET` in the process environment or project `.env` remains a supported
41
+ shared source for a preset-less Style/schema call. `BOOK_PROFILE` and the
42
+ `profile` schema option remain backward-compatible aliases. Prefer the canonical
43
+ names in new configuration:
44
+
45
+ ```dotenv
46
+ BOOK_PRESET=academic
47
+ ```
48
+
49
+ The resolution order remains explicit argument, `BOOK_PRESET`, `BOOK_PROFILE`,
50
+ then the same two keys in `.env`. There is no final default.
51
+
52
+ ### 2. Delete `deploy` from book and Style configuration
53
+
54
+ Remove `deploy` from both locations if present:
55
+
56
+ ```diff
57
+ export const brandStyle = defineStyle({
58
+ name: 'brand',
59
+ - deploy: 'workers',
60
+ });
61
+
62
+ export default await defineBookConfig({
63
+ styles: [brandStyle, academicStyle],
64
+ - deploy: 'workers',
65
+ site: 'https://book.example/',
66
+ });
67
+ ```
68
+
69
+ The v4 field was inert: it never changed an existing deployment and never chose
70
+ the file emitted by `create-book`. v5 removes it from the types and built-in
71
+ Styles; JavaScript configurations and v4 Style objects that still carry it fail
72
+ with a migration error.
73
+
74
+ Configure the real deployment surface instead:
75
+
76
+ - edit `wrangler.toml` for Cloudflare Workers or Pages;
77
+ - edit the deployment platform's project settings or workflow; and
78
+ - keep `site` in `defineBookConfig` as the public canonical origin.
79
+
80
+ `create-book --preset` still chooses an initial `wrangler.toml` shape for a new
81
+ project. That scaffold-time choice is separate from book configuration.
82
+
83
+ ## Corpus opt-in
84
+
85
+ Corpus mode is optional. A Recipe 21 multi-guide application migrates in six
86
+ steps:
87
+
88
+ 1. Extract the ordered book metadata into one `defineBookCorpus` manifest.
89
+ 2. Pass that same manifest to `defineBookConfig` and `defineBookSchemas`.
90
+ 3. Remove the hand-written chapter collection and its custom `generateId`.
91
+ 4. Keep book folders under `src/content/`, or set one explicit `chaptersBase`
92
+ when the shared base lives elsewhere.
93
+ 5. Compare consumer-owned per-book index pages with the injected pages, then
94
+ remove the duplicates.
95
+ 6. Run every content-derived command (`build-labels`, `build-bib`,
96
+ `build-tips`, `build-exercises`, and `validate`); update scripts that read
97
+ generated corpus JSON to select a book from the versioned envelope. Figure
98
+ and notebook conversion remain application-wide.
99
+
100
+ A minimal shared manifest looks like this:
101
+
102
+ ```ts
103
+ // src/book-corpus.ts
104
+ import { defineBookCorpus } from '@brandon_m_behring/book-scaffold-astro';
105
+
106
+ export const corpus = defineBookCorpus({
107
+ preset: 'tools',
108
+ books: [
109
+ { id: 'evaluation', title: 'Evaluation Engineering' },
110
+ { id: 'llm-apps', title: 'LLM Application Engineering' },
111
+ ],
112
+ });
113
+ ```
114
+
115
+ ```ts
116
+ // astro.config.mjs
117
+ import { defineBookConfig } from '@brandon_m_behring/book-scaffold-astro';
118
+ import { corpus } from './src/book-corpus.js';
119
+
120
+ export default await defineBookConfig({
121
+ corpus,
122
+ site: 'https://guides.example/',
123
+ });
124
+ ```
125
+
126
+ ```ts
127
+ // src/content.config.ts
128
+ import { defineBookSchemas } from '@brandon_m_behring/book-scaffold-astro/schemas';
129
+ import { corpus } from './book-corpus.js';
130
+
131
+ export const { collections } = defineBookSchemas({ corpus });
132
+ ```
133
+
134
+ The manifest's preset is an explicit preset source. If a composed Style also
135
+ sets a preset, it must match the manifest.
136
+
137
+ Book ids may not be `questions`, `glossary`, or `frontmatter`; those names own
138
+ shared scaffold collection roots beneath `src/content/`, rather than chapter
139
+ directories. `defineBookCorpus` rejects such collisions before content sync.
140
+
141
+ Existing Recipe 21 chapter URLs remain
142
+ `/chapters/<book>/<slug>/`; no redirect is needed. In corpus mode, generated
143
+ labels, references, tips, and exercise data use the versioned envelope:
144
+
145
+ ```json
146
+ {
147
+ "schemaVersion": 1,
148
+ "books": {
149
+ "evaluation": {},
150
+ "llm-apps": {}
151
+ }
152
+ }
153
+ ```
154
+
155
+ Consumers must select the current book before looking up an entry.
156
+
157
+ If the corpus exposes convergence dashboards, move the v4 root collateral into
158
+ one directory per manifest id:
159
+
160
+ ```text
161
+ changelog/<book>/patterns.yaml
162
+ changelog/<book>/tools/*.yaml
163
+ ```
164
+
165
+ Corpus routes never reuse a root-level pattern/timeline collection across
166
+ books. Flashcard progress is automatically isolated under per-book storage
167
+ keys; no authoring change is required.
168
+
169
+ ## Single-book compatibility
170
+
171
+ A single-book application does not need `defineBookCorpus`. Its
172
+ `/chapters/<slug>/` and `/chapters/` routes, flat apparatus routes, schemas, and
173
+ generated JSON retain their v4 shapes. Only the explicit-preset requirement and
174
+ the removed `deploy` field apply.
175
+
176
+ ## Upgrade checklist
177
+
178
+ 1. Remove every `deploy` key from book configs and shared Styles.
179
+ 2. Confirm both Astro config and content schemas resolve the intended preset.
180
+ 3. Run `npm run validate` and `npm run build`; the generated prevalidation hook
181
+ refreshes the applicable bibliography and label artifacts. Run optional
182
+ tips/exercises build scripts when the application enables those surfaces.
183
+ 4. For a corpus, inspect every book's routes and update generated-JSON readers.
package/README.md CHANGED
@@ -18,7 +18,70 @@ Available presets are `academic`, `tools`, `minimal`, `course-notes`, and
18
18
  [repository README](https://github.com/brandon-behring/book-scaffold-astro)
19
19
  for the current release overview, and
20
20
  [PACKAGE_DESIGN.md](https://github.com/brandon-behring/book-scaffold-astro/blob/main/PACKAGE_DESIGN.md)
21
- for the complete API contract.
21
+ for the complete API contract. Upgrading an existing v4 book? Follow
22
+ [MIGRATION-v4-to-v5.md](./MIGRATION-v4-to-v5.md).
23
+
24
+ v5 requires an explicit preset. A built-in/custom Style, schema option,
25
+ `BOOK_PRESET`, or corpus manifest may supply it; an absent preset fails instead
26
+ of silently selecting `minimal`. The deprecated v4 `deploy` book/Style field is
27
+ removed—configure the real provider surface and Astro `site`/`base` instead.
28
+
29
+ ## Multi-book corpora
30
+
31
+ Several books can share one app, deployment, preset/Style chain, and Pagefind
32
+ index through one branded manifest:
33
+
34
+ ```ts
35
+ import { defineBookCorpus } from '@brandon_m_behring/book-scaffold-astro';
36
+
37
+ export const corpus = defineBookCorpus({
38
+ preset: 'research-portfolio',
39
+ books: [
40
+ { id: 'evaluation', title: 'Evaluation Engineering' },
41
+ { id: 'llm-apps', title: 'LLM Application Engineering' },
42
+ ],
43
+ });
44
+ ```
45
+
46
+ Pass that same `corpus` to `defineBookConfig` and `defineBookSchemas`.
47
+ Namespaced chapters, apparatus routes, navigation, generated JSON, diagnostics,
48
+ local `<BookLink>` targets, and Pagefind results remain book-scoped. See
49
+ [Recipe 21](./recipes/21-multi-guide-single-app.md) for content layout, route
50
+ ownership, `--book` tooling, shared sources, and migration.
51
+
52
+ ## Content readiness and CLI
53
+
54
+ `book-scaffold qa` combines the existing validation contract with stable
55
+ chapter, link, learning-objective, component, and JSON-fixture metrics. Human
56
+ output is the default; schema-v1 JSON is deterministic and keeps stdout clean
57
+ for CI:
58
+
59
+ ```bash
60
+ npm exec -- book-scaffold qa
61
+ npm --offline exec -- book-scaffold qa --format json
62
+ npm exec -- book-scaffold init-qa
63
+ ```
64
+
65
+ In corpus mode `qa` checks every manifest book by default, while `--book <id>`
66
+ selects one exact id. `init-qa` creates the portfolio-engine interoperability
67
+ file `guide_qa.yaml`; its generated checks use `npm --offline exec` and an
68
+ existing file is preserved unless `--force` is explicit. See
69
+ [Recipe 25](./recipes/25-qa-readiness.md) for verdicts, exit codes, the
70
+ top-level `shared` aggregate, and CI wiring.
71
+
72
+ The installed `book-scaffold` dispatcher owns these commands:
73
+
74
+ | Command | Purpose |
75
+ |---|---|
76
+ | `validate` | Pre-flight authored content, references, figures, and links |
77
+ | `qa` | Emit a human or schema-v1 readiness verdict |
78
+ | `init-qa` | Generate deterministic `guide_qa.yaml` interoperability config |
79
+ | `build-labels` | Build cross-reference and heading indexes |
80
+ | `build-bib` | Build bibliography and source-manifest data |
81
+ | `build-tips` | Build the tips index |
82
+ | `build-exercises` | Build the exercises index |
83
+ | `build-figures` | Convert and theme application-wide figure assets |
84
+ | `render-notebooks` | Render application-wide notebook companions |
22
85
 
23
86
  Astro builds emit a Cloudflare-compatible `dist/_headers` with audited
24
87
  security defaults. A consumer-owned `public/_headers` wins unchanged;
@@ -14,6 +14,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
 
15
15
  const handlers = {
16
16
  validate: '../scripts/validate.mjs',
17
+ qa: '../scripts/qa.mjs',
18
+ 'init-qa': '../scripts/init-qa.mjs',
17
19
  'build-labels': '../scripts/build-labels.mjs',
18
20
  'build-bib': '../scripts/build-bib.mjs',
19
21
  'build-figures': '../scripts/build-figures.mjs',
@@ -26,6 +28,8 @@ const HELP = `Usage: book-scaffold <sub-command> [args...]
26
28
 
27
29
  Sub-commands:
28
30
  validate Pre-flight content validator (XRef ids, Cite keys, Figure srcs).
31
+ qa Content-health and readiness report (human or schema-v1 JSON).
32
+ init-qa Generate deterministic guide_qa.yaml interoperability config.
29
33
  build-labels Emit src/data/labels.json for cross-references (Phase C).
30
34
  build-bib BibTeX -> references.json (+ sources/manifest.yaml -> sources.json).
31
35
  build-figures PDF -> SVG via pdftocairo / pdftoppm fallback (+ TikZ in v4.2.0).
@@ -32,6 +32,7 @@ import { assertKnownDomain } from '../src/lib/exam-domains';
32
32
  import { buildExamManifest, deriveDomainRouting } from '../src/lib/exam-manifest';
33
33
  import '../styles/exam-runner.css';
34
34
  import { normalizeBase } from '../src/lib/nav-href';
35
+ import { corpusBookHasApparatusRoute, corpusBookIdFromPath } from '../src/lib/corpus';
35
36
 
36
37
  interface Props {
37
38
  /** Heading above the test. Defaults to "Assessment test". */
@@ -65,9 +66,17 @@ const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}',
65
66
  import: 'default',
66
67
  eager: true,
67
68
  });
68
- const hasQuestions = Object.keys(questionModules).length > 0;
69
+ const currentBook = bookConfig.corpus
70
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
71
+ : null;
72
+ if (bookConfig.corpus && !currentBook) {
73
+ throw new Error('<AssessmentTest> requires a canonical corpus book route.');
74
+ }
75
+ const hasQuestions = currentBook
76
+ ? Object.keys(questionModules).some((path) => path.includes(`/questions/${currentBook}/`))
77
+ : Object.keys(questionModules).length > 0;
69
78
 
70
- const entries = hasQuestions ? await getAllQuestions() : [];
79
+ const entries = hasQuestions ? await getAllQuestions(currentBook ?? undefined) : [];
71
80
 
72
81
  // Fail loud: an unregistered/typo'd domain must throw at build, not silently
73
82
  // skew the assessment's domain spread.
@@ -85,10 +94,22 @@ const scoreable = entries.filter(
85
94
  // deriveDomainRouting takes baseUrl as a param (not import.meta.env) because it
86
95
  // ships pre-compiled in dist/ where Vite's env replacement does not reach.
87
96
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
97
+ const bookRoutePrefix = currentBook ? `${currentBook}/` : '';
88
98
  const manifest = buildExamManifest(entries);
89
- const domainRouting = deriveDomainRouting(entries, baseUrl);
90
- const practiceExamHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
91
- ? `${baseUrl}practice-exam`
99
+ const domainRouting = deriveDomainRouting(entries, baseUrl, currentBook ?? undefined);
100
+ const hasPracticeExam = bookConfig.corpus
101
+ ? Boolean(
102
+ currentBook &&
103
+ corpusBookHasApparatusRoute(
104
+ bookConfig.corpus,
105
+ currentBook,
106
+ 'practice-exam',
107
+ bookConfig.apparatusRoutes,
108
+ ),
109
+ )
110
+ : (bookConfig.enabledRoutes ?? []).includes('practiceExam');
111
+ const practiceExamHref = hasPracticeExam
112
+ ? `${baseUrl}${bookRoutePrefix}practice-exam`
92
113
  : null;
93
114
 
94
115
  const rendered = await Promise.all(
@@ -20,7 +20,7 @@
20
20
  * <BookLink book="design" to="chapters/patterns/#layered">the layered pattern</BookLink>.
21
21
  */
22
22
  import bookConfig from 'virtual:book-scaffold/book-config';
23
- import { resolveBookHref } from '../src/lib/book-link';
23
+ import { resolveBookHref, resolveCorpusBookHref } from '../src/lib/book-link';
24
24
 
25
25
  interface Props {
26
26
  book: string;
@@ -28,6 +28,9 @@ interface Props {
28
28
  }
29
29
 
30
30
  const { book, to } = Astro.props;
31
- const href = resolveBookHref(bookConfig.siblingBooks, book, to);
31
+ const localBook = bookConfig.corpus?.books.some((candidate) => candidate.id === book) ?? false;
32
+ const href = localBook
33
+ ? resolveCorpusBookHref(book, to, import.meta.env.BASE_URL)
34
+ : resolveBookHref(bookConfig.siblingBooks, book, to);
32
35
  ---
33
- <a href={href} rel="external noopener" class="book-link"><slot /></a>
36
+ <a href={href} rel={localBook ? undefined : 'external noopener'} class="book-link"><slot /></a>
@@ -20,7 +20,7 @@ const chapterRoute = (bookConfig as { chapterRoute?: string }).chapterRoute ?? '
20
20
  const bookField = (bookConfig as { bookField?: string }).bookField ?? 'book';
21
21
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
22
22
 
23
- const { prev, next } = await getNeighbors(currentId, { bookField });
23
+ const { prev, next } = await getNeighbors(currentId, { bookField, corpus: bookConfig.corpus });
24
24
  // Resolve hrefs in frontmatter (casts here, never in the JSX expression container).
25
25
  const prevHref = prev
26
26
  ? chapterHref({ id: prev.id, data: prev.data as Record<string, unknown> }, chapterRoute, baseUrl, bookField)
@@ -1,5 +1,11 @@
1
1
  ---
2
- import { normalizeBase } from '../src/lib/nav-href';
2
+ import { apparatusHref, normalizeBase } from '../src/lib/nav-href';
3
+ import bookConfig from 'virtual:book-scaffold/book-config';
4
+ import {
5
+ corpusBookHasApparatusRoute,
6
+ corpusBookIdFromPath,
7
+ selectBookArtifact,
8
+ } from '../src/lib/corpus';
3
9
  /**
4
10
  * Cite — renders a `\cite{bibkey}` LaTeX citation as a clickable
5
11
  * inline reference to the bibliography page.
@@ -33,12 +39,36 @@ type CslEntry = {
33
39
 
34
40
  // Resolve references.json from consumer's project root. Missing file -> empty
35
41
  // map -> any <Cite> throws below (strict per D3 — bibkeys must resolve at build).
36
- const refsModules = import.meta.glob<{ default: Record<string, CslEntry> }>(
42
+ const refsModules = import.meta.glob<{ default: unknown }>(
37
43
  '/src/data/references.json',
38
44
  { eager: true },
39
45
  );
40
46
  const refsModule = refsModules['/src/data/references.json'];
41
- const map = (refsModule?.default ?? {}) as Record<string, CslEntry>;
47
+ const currentBook = bookConfig.corpus
48
+ ? corpusBookIdFromPath(bookConfig.corpus, Astro.url.pathname, import.meta.env.BASE_URL)
49
+ : null;
50
+ if (
51
+ bookConfig.corpus &&
52
+ (!currentBook ||
53
+ !corpusBookHasApparatusRoute(
54
+ bookConfig.corpus,
55
+ currentBook,
56
+ 'references',
57
+ bookConfig.apparatusRoutes,
58
+ ))
59
+ ) {
60
+ throw new Error(
61
+ `<Cite key="${Astro.props.key}"> requires the current corpus book's references route.`,
62
+ );
63
+ }
64
+ const map = refsModule
65
+ ? selectBookArtifact<Record<string, CslEntry>>(
66
+ refsModule.default,
67
+ bookConfig.corpus,
68
+ currentBook,
69
+ 'src/data/references.json',
70
+ )
71
+ : {};
42
72
 
43
73
  interface Props {
44
74
  key: string;
@@ -47,6 +77,9 @@ interface Props {
47
77
 
48
78
  const { key, page } = Astro.props;
49
79
  const baseUrl = normalizeBase(import.meta.env.BASE_URL);
80
+ const referencesHref = bookConfig.corpus
81
+ ? apparatusHref('references', currentBook, '/:book/:route/', baseUrl)
82
+ : `${baseUrl}references`;
50
83
  const entry = map[key];
51
84
  if (!entry) {
52
85
  throw new Error(
@@ -68,6 +101,6 @@ else authorText = `${surname(authors[0])} et al.`;
68
101
  const year = entry.issued?.['date-parts']?.[0]?.[0] ?? 'n.d.';
69
102
  const yearText = page ? `${year}, p. ${page}` : `${year}`;
70
103
  ---
71
- <a href={`${baseUrl}references#${key}`} class="cite" title={entry.title ?? key}>
104
+ <a href={`${referencesHref}#${key}`} class="cite" title={entry.title ?? key}>
72
105
  {authorText} ({yearText})
73
106
  </a>
@@ -30,6 +30,10 @@
30
30
  *
31
31
  * Family: book-genre (cross-profile).
32
32
  */
33
+ import bookConfig from 'virtual:book-scaffold/book-config';
34
+ import { normalizeBase } from '../src/lib/nav-href';
35
+ import { corpusBookIdFromPath, selectBookArtifact } from '../src/lib/corpus';
36
+
33
37
  interface Props {
34
38
  /** v4.4.0: if true, auto-collect exercises from src/data/exercises.json
35
39
  * (scoped to the current chapter via Astro.url.pathname). Default false
@@ -46,14 +50,24 @@ let autoError: string | null = null;
46
50
 
47
51
  if (auto) {
48
52
  const path = Astro.url.pathname;
49
- const m = path.match(/^\/chapters\/([^/]+)\//);
50
- chapterSlug = m ? m[1] : '';
53
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
54
+ const relativePath = path.startsWith(baseUrl)
55
+ ? path.slice(baseUrl.length)
56
+ : path.replace(/^\/+/, '');
57
+ const currentBook = bookConfig.corpus
58
+ ? corpusBookIdFromPath(bookConfig.corpus, path, baseUrl)
59
+ : null;
60
+ const corpusMatch = currentBook
61
+ ? relativePath.match(new RegExp(`^chapters/${currentBook}/(.+?)/?$`))
62
+ : null;
63
+ const singleMatch = relativePath.match(/^chapters\/([^/]+)\//);
64
+ chapterSlug = corpusMatch ? corpusMatch[1] : singleMatch ? singleMatch[1] : '';
51
65
  if (!chapterSlug) {
52
66
  autoError = `<ExerciseSolutions auto /> requires a /chapters/<slug>/ URL; got ${path}.`;
53
67
  } else {
54
68
  // v4.4.0 fix: project-root-relative import via Vite's import.meta.glob
55
69
  // (the previous `../../../src/data/exercises.json` failed in node_modules contexts).
56
- const exModules = import.meta.glob<{ default: Record<string, Array<{ id: string; problem: string }>> }>(
70
+ const exModules = import.meta.glob<{ default: unknown }>(
57
71
  '/src/data/exercises.json',
58
72
  { eager: true },
59
73
  );
@@ -61,7 +75,13 @@ if (auto) {
61
75
  if (!exEntry) {
62
76
  autoError = `src/data/exercises.json not found — run \`npx book-scaffold build-exercises\` first.`;
63
77
  } else {
64
- exercises = exEntry.default[chapterSlug] ?? [];
78
+ const byChapter = selectBookArtifact<Record<string, Array<{ id: string; problem: string }>>>(
79
+ exEntry.default,
80
+ bookConfig.corpus,
81
+ currentBook,
82
+ 'src/data/exercises.json',
83
+ );
84
+ exercises = byChapter[chapterSlug] ?? [];
65
85
  }
66
86
  }
67
87
  }
@@ -25,18 +25,23 @@ import { useEffect, useRef, useState } from 'preact/hooks';
25
25
  import { shuffle } from '../src/lib/exam-engine';
26
26
  import type { FlashcardRef } from '../src/lib/flashcards';
27
27
 
28
- const STORAGE_KEY = 'book:flashcards:known';
28
+ const DEFAULT_STORAGE_KEY = 'book:flashcards:known';
29
29
 
30
30
  interface Props {
31
31
  /** Deck manifest (buildFlashcardDeck output) — card ids + fronts only. */
32
32
  deck: FlashcardRef[];
33
+ /**
34
+ * Persistence namespace. Corpus routes pass a base- and book-specific key
35
+ * so repeated local glossary ids never share or erase another book's state.
36
+ */
37
+ storageKey?: string;
33
38
  }
34
39
 
35
40
  type Phase = 'idle' | 'deck';
36
41
 
37
- function readKnown(): Set<string> {
42
+ function readKnown(storageKey: string): Set<string> {
38
43
  try {
39
- const raw = localStorage.getItem(STORAGE_KEY);
44
+ const raw = localStorage.getItem(storageKey);
40
45
  if (!raw) return new Set();
41
46
  const parsed = JSON.parse(raw);
42
47
  if (!Array.isArray(parsed)) return new Set();
@@ -46,15 +51,18 @@ function readKnown(): Set<string> {
46
51
  }
47
52
  }
48
53
 
49
- function writeKnown(known: Set<string>): void {
54
+ function writeKnown(storageKey: string, known: Set<string>): void {
50
55
  try {
51
- localStorage.setItem(STORAGE_KEY, JSON.stringify([...known]));
56
+ localStorage.setItem(storageKey, JSON.stringify([...known]));
52
57
  } catch {
53
58
  /* localStorage unavailable — keep in-memory state only */
54
59
  }
55
60
  }
56
61
 
57
- export default function Flashcards({ deck }: Props) {
62
+ export default function Flashcards({ deck, storageKey = DEFAULT_STORAGE_KEY }: Props) {
63
+ if (typeof storageKey !== 'string' || storageKey.trim().length === 0) {
64
+ throw new Error('Flashcards: storageKey must be a non-empty string.');
65
+ }
58
66
  const [phase, setPhase] = useState<Phase>('idle');
59
67
  const [order, setOrder] = useState<string[]>([]);
60
68
  const [pos, setPos] = useState(0);
@@ -67,12 +75,12 @@ export default function Flashcards({ deck }: Props) {
67
75
  // Intersect the stored bucket with the CURRENT deck: a term deleted from
68
76
  // the glossary would otherwise inflate "marked known" forever (even past
69
77
  // deck.length) — evict stale ids on mount and persist the cleaned set.
70
- const stored = readKnown();
78
+ const stored = readKnown(storageKey);
71
79
  const deckIds = new Set(deck.map((c) => c.id));
72
80
  const cleaned = new Set([...stored].filter((id) => deckIds.has(id)));
73
- if (cleaned.size !== stored.size) writeKnown(cleaned);
81
+ if (cleaned.size !== stored.size) writeKnown(storageKey, cleaned);
74
82
  setKnown(cleaned);
75
- }, []);
83
+ }, [deck, storageKey]);
76
84
 
77
85
  function requireRoot(): HTMLElement {
78
86
  const r = ref.current?.closest<HTMLElement>('[data-flashcards-root]');
@@ -138,7 +146,7 @@ export default function Flashcards({ deck }: Props) {
138
146
  if (knewIt) next.add(id);
139
147
  else next.delete(id);
140
148
  setKnown(next);
141
- writeKnown(next);
149
+ writeKnown(storageKey, next);
142
150
  if (pos < order.length - 1) goTo(pos + 1);
143
151
  else end();
144
152
  }
@@ -159,7 +167,7 @@ export default function Flashcards({ deck }: Props) {
159
167
  function resetKnown(): void {
160
168
  const next = new Set<string>();
161
169
  setKnown(next);
162
- writeKnown(next);
170
+ writeKnown(storageKey, next);
163
171
  }
164
172
 
165
173
  if (deck.length === 0) {