@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.
- package/CLAUDE.md +61 -2
- package/MIGRATION-v4-to-v5.md +183 -0
- package/README.md +64 -1
- package/bin/book-scaffold.mjs +4 -0
- package/components/AssessmentTest.astro +26 -5
- package/components/BookLink.astro +6 -3
- package/components/ChapterNav.astro +1 -1
- package/components/Cite.astro +37 -4
- package/components/ExerciseSolutions.astro +24 -4
- package/components/Flashcards.tsx +19 -11
- package/components/NavContent.astro +60 -11
- package/components/ObjectiveMap.astro +14 -2
- package/components/PartReview.astro +32 -4
- package/components/PatternTimeline.astro +6 -2
- package/components/Rationale.astro +20 -3
- package/components/Sidebar.astro +11 -3
- package/components/SourceArchive.astro +33 -3
- package/components/Term.astro +18 -1
- package/components/Theorem.astro +15 -5
- package/components/TipsCard.astro +40 -3
- package/components/XRef.astro +14 -2
- package/dist/components/ExamRunner.d.ts +1 -1
- package/dist/components/Flashcards.d.ts +6 -1
- package/dist/components/Flashcards.mjs +14 -11
- package/dist/{exam-manifest-X9IrX1G3.d.ts → exam-manifest-DttY7kyZ.d.ts} +1 -1
- package/dist/index.d.ts +76 -8
- package/dist/index.mjs +522 -44
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +311 -32
- package/dist/{types-DgSlAew3.d.ts → types-D1QZgKMO.d.ts} +73 -21
- package/layouts/Base.astro +38 -9
- package/layouts/Chapter.astro +10 -2
- package/package.json +5 -2
- package/pages/answers.astro +25 -6
- package/pages/book.astro +75 -0
- package/pages/chapters/[...slug].astro +39 -7
- package/pages/chapters-book.astro +17 -0
- package/pages/chapters.astro +87 -9
- package/pages/convergence.astro +40 -10
- package/pages/corpus-apparatus/answers.astro +15 -0
- package/pages/corpus-apparatus/convergence.astro +15 -0
- package/pages/corpus-apparatus/exercises.astro +15 -0
- package/pages/corpus-apparatus/flashcards.astro +15 -0
- package/pages/corpus-apparatus/glossary.astro +15 -0
- package/pages/corpus-apparatus/practice-exam.astro +15 -0
- package/pages/corpus-apparatus/print.astro +15 -0
- package/pages/corpus-apparatus/references.astro +15 -0
- package/pages/corpus-apparatus/tips.astro +15 -0
- package/pages/exercises.astro +18 -5
- package/pages/flashcards.astro +26 -5
- package/pages/glossary.astro +20 -3
- package/pages/index.astro +54 -1
- package/pages/practice-exam.astro +8 -2
- package/pages/print.astro +7 -2
- package/pages/references.astro +26 -6
- package/pages/search.astro +65 -4
- package/pages/tips.astro +17 -4
- package/recipes/09-validation.md +1 -1
- package/recipes/15-defining-styles.md +6 -6
- package/recipes/20-anki-export.md +15 -8
- package/recipes/21-multi-guide-single-app.md +293 -44
- package/recipes/22-responsive-nav-and-multibook-routing.md +7 -1
- package/recipes/25-qa-readiness.md +197 -0
- package/recipes/README.md +3 -2
- package/scripts/build-bib.mjs +113 -35
- package/scripts/build-exercises.mjs +101 -24
- package/scripts/build-labels.mjs +199 -194
- package/scripts/build-tips.mjs +95 -23
- package/scripts/corpus-tooling.mjs +268 -0
- package/scripts/init-qa.mjs +209 -0
- package/scripts/qa-core.mjs +1511 -0
- package/scripts/qa.mjs +293 -0
- package/scripts/resolve-book-config.mjs +138 -1
- package/scripts/validate-core.mjs +1675 -0
- package/scripts/validate.mjs +18 -916
- package/scripts/validation-artifacts.mjs +23 -0
- package/scripts/walk-mdx.mjs +16 -4
- package/src/lib/book-link.ts +72 -0
- package/src/lib/chapters.ts +10 -4
- package/src/lib/corpus-collateral.ts +9 -0
- package/src/lib/corpus.ts +458 -0
- package/src/lib/define-style.ts +28 -15
- package/src/lib/exam-manifest.ts +4 -1
- package/src/lib/patterns.ts +15 -6
- package/src/lib/questions.ts +8 -3
- 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.
|
|
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` (
|
|
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;
|
package/bin/book-scaffold.mjs
CHANGED
|
@@ -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
|
|
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
|
|
91
|
-
?
|
|
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
|
|
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=
|
|
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)
|
package/components/Cite.astro
CHANGED
|
@@ -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:
|
|
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
|
|
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={`${
|
|
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
|
|
50
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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) {
|