@brandon_m_behring/book-scaffold-astro 4.6.0 → 4.7.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 CHANGED
@@ -58,6 +58,35 @@ sources: [] # array of source-manifest keys
58
58
  ---
59
59
  ```
60
60
 
61
+ ### Research-portfolio profile (`src/schemas.ts:researchPortfolioChapterSchema`)
62
+
63
+ Hybrid of academic + tools provenance with research-paper-style inline sources. Only `title` + `last_verified` are required; all hierarchy and classification fields are optional.
64
+
65
+ ```yaml
66
+ ---
67
+ title: "..." # required
68
+ last_verified: 2026-05-19 # date, required
69
+ # optional — hierarchy (use whichever fits; all may be omitted)
70
+ slug: ch01-introduction # defaults to filename
71
+ chapter: 1
72
+ part: 1 # number OR academic-style string enum
73
+ week: 1
74
+ # optional — status (AUTHORING state) vs freshness (EPISTEMIC type) are ORTHOGONAL
75
+ status: prose_only # 'scaffolded'|'prose_only'|'code_only'|'chapter_only'|'reading_only'|'implemented'|'planned'
76
+ freshness: experimental-result # 'experimental-result'|'literature-survey'|'theoretical'|'reference'
77
+ # optional — provenance + inline sources (T1-T4 tiers)
78
+ volatility: feature-surface # 'stable-principle'|'architectural-pattern'|'feature-surface'
79
+ tags: [prompt-injection, ...] # freeform string array
80
+ sources:
81
+ - tier: T1
82
+ url: https://...
83
+ label: Primary source
84
+ # optional: description, draft, updated, author, published, image (SEO/og:*)
85
+ ---
86
+ ```
87
+
88
+ **`status` vs `freshness` is the #1 author gotcha.** `status` = authoring state (have I written it?). `freshness` = epistemic type (what kind of evidence?). A chapter can be `status: scaffolded` (not written yet) AND `freshness: theoretical` (will be a math argument). See Recipe 13 for the full table.
89
+
61
90
  ## Component reference
62
91
 
63
92
  Two callout families coexist. Authors import what they need.
package/dist/index.mjs CHANGED
@@ -398,8 +398,8 @@ var academicProfile = defineProfile({
398
398
  references: true,
399
399
  search: true,
400
400
  print: true,
401
- chapters: false,
402
- // academic consumers ship their own week-based /chapters listing
401
+ chapters: true,
402
+ // v4.6.1 (#75 follow-up): auto-injected /chapters/[...slug]/ + /chapters/ index. Pre-v4.3.0 academic books shipped their own listing; v4.6.0 (#76 Layer 3c) removed the consumer template assuming auto-injection. Default flipped here to close the gap. Consumers wanting their own listing override via `routes: { chapters: false }` + their own src/pages/chapters/* — see recipe 18.
403
403
  convergence: false,
404
404
  // tools-profile-specific
405
405
  frontmatter: false,
@@ -607,7 +607,8 @@ var minimalProfile = defineProfile({
607
607
  references: true,
608
608
  search: true,
609
609
  print: true,
610
- chapters: false,
610
+ chapters: true,
611
+ // v4.6.1 (#75 follow-up): default-on across all profiles. Consumer override via routes: { chapters: false }.
611
612
  convergence: false,
612
613
  frontmatter: false,
613
614
  // opt-in per book; see #7
@@ -631,8 +632,8 @@ var courseNotesProfile = defineProfile({
631
632
  references: true,
632
633
  search: true,
633
634
  print: true,
634
- chapters: false,
635
- // multi-book consumers route via [book]/[slug] themselves
635
+ chapters: true,
636
+ // v4.6.1 (#75 follow-up): default-on. Multi-book consumers (DLAI-style) override via routes: { chapters: false } + own [book]/[slug] routes — see #15 deferred.
636
637
  convergence: false,
637
638
  frontmatter: false,
638
639
  // opt-in per book; see #7
@@ -659,8 +660,8 @@ var researchPortfolioProfile = defineProfile({
659
660
  references: true,
660
661
  search: true,
661
662
  print: true,
662
- chapters: false,
663
- // portfolio books ship their own landing/index
663
+ chapters: true,
664
+ // v4.6.1 (#75 follow-up): default-on. Portfolios still ship their own /frontmatter/* + landing; /chapters/* renders the underlying chapter list.
664
665
  convergence: false,
665
666
  // tools-profile-specific
666
667
  frontmatter: true,
package/dist/schemas.mjs CHANGED
@@ -282,8 +282,8 @@ var academicProfile = defineProfile({
282
282
  references: true,
283
283
  search: true,
284
284
  print: true,
285
- chapters: false,
286
- // academic consumers ship their own week-based /chapters listing
285
+ chapters: true,
286
+ // v4.6.1 (#75 follow-up): auto-injected /chapters/[...slug]/ + /chapters/ index. Pre-v4.3.0 academic books shipped their own listing; v4.6.0 (#76 Layer 3c) removed the consumer template assuming auto-injection. Default flipped here to close the gap. Consumers wanting their own listing override via `routes: { chapters: false }` + their own src/pages/chapters/* — see recipe 18.
287
287
  convergence: false,
288
288
  // tools-profile-specific
289
289
  frontmatter: false,
@@ -491,7 +491,8 @@ var minimalProfile = defineProfile({
491
491
  references: true,
492
492
  search: true,
493
493
  print: true,
494
- chapters: false,
494
+ chapters: true,
495
+ // v4.6.1 (#75 follow-up): default-on across all profiles. Consumer override via routes: { chapters: false }.
495
496
  convergence: false,
496
497
  frontmatter: false,
497
498
  // opt-in per book; see #7
@@ -515,8 +516,8 @@ var courseNotesProfile = defineProfile({
515
516
  references: true,
516
517
  search: true,
517
518
  print: true,
518
- chapters: false,
519
- // multi-book consumers route via [book]/[slug] themselves
519
+ chapters: true,
520
+ // v4.6.1 (#75 follow-up): default-on. Multi-book consumers (DLAI-style) override via routes: { chapters: false } + own [book]/[slug] routes — see #15 deferred.
520
521
  convergence: false,
521
522
  frontmatter: false,
522
523
  // opt-in per book; see #7
@@ -543,8 +544,8 @@ var researchPortfolioProfile = defineProfile({
543
544
  references: true,
544
545
  search: true,
545
546
  print: true,
546
- chapters: false,
547
- // portfolio books ship their own landing/index
547
+ chapters: true,
548
+ // v4.6.1 (#75 follow-up): default-on. Portfolios still ship their own /frontmatter/* + landing; /chapters/* renders the underlying chapter list.
548
549
  convergence: false,
549
550
  // tools-profile-specific
550
551
  frontmatter: true,
@@ -1,25 +1,33 @@
1
1
  ---
2
+ # required fields — schema will reject the chapter if missing
2
3
  title: "Chapter N — Title goes here"
3
- slug: chN-short-slug
4
- chapter: 1
5
- part: 1
4
+ last_verified: 2026-05-19 # required; YAML date (no quotes)
5
+
6
+ # optional — hierarchy (use whichever fits; all may be omitted)
7
+ slug: chN-short-slug # optional; defaults to filename
8
+ chapter: 1 # optional; tools-style numeric
9
+ part: 1 # optional; number OR academic-style string enum
6
10
  week: 1 # optional; omit if not on a weekly cadence
7
- status: prose_only # 'prose_only' | 'code_only' | 'implemented' | ...
8
- freshness: experimental-result # 'experimental-result' | 'literature-survey' | 'theoretical' | 'reference'
9
- volatility: feature-surface # 'stable-principle' | 'architectural-pattern' | 'feature-surface'
10
- tags:
11
+
12
+ # optional status (AUTHORING state) vs freshness (EPISTEMIC type); see Recipe 13
13
+ status: prose_only # 'scaffolded'|'prose_only'|'code_only'|'chapter_only'|'reading_only'|'implemented'|'planned'
14
+ freshness: experimental-result # 'experimental-result'|'literature-survey'|'theoretical'|'reference'
15
+
16
+ # optional — provenance
17
+ volatility: feature-surface # 'stable-principle'|'architectural-pattern'|'feature-surface'
18
+ tags: # optional; freeform string array
11
19
  - replace-me
12
20
  - with
13
21
  - real-tags
14
- sources:
22
+ sources: # optional; structured inline T1-T4
15
23
  - tier: T1
16
24
  url: https://example.invalid/primary-source
17
25
  label: Primary source (e.g., NVD CVE / arXiv paper / official spec)
18
26
  - tier: T2
19
27
  url: https://example.invalid/secondary
20
28
  label: Secondary corroboration
21
- last_verified: 2026-05-19
22
- draft: true
29
+
30
+ draft: true # optional; defaults to false
23
31
  ---
24
32
 
25
33
  import PreReleaseBanner from '@brandon_m_behring/book-scaffold-astro/components/PreReleaseBanner.astro';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.6.0",
4
+ "version": "4.7.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -47,6 +47,8 @@ Source: Koller & Friedman, *Probabilistic Graphical Models*, 2009 — chapter st
47
47
  | Research synthesis (one paper or theorem per chapter) | Academic | `academic` |
48
48
  | A practitioner field-guide across multiple tools | Tools | `tools` |
49
49
  | A versioned tech survey with convergence tracking | Tools | `tools` |
50
+ | A research-portfolio with mixed evidence types + AI disclosure | Hybrid | `research-portfolio` (see [Recipe 13](13-research-portfolio-getting-started.md)) |
51
+ | A course-notes / study-derived corpus | Hybrid | `course-notes` |
50
52
  | A solo essay collection | either, lean Academic | `minimal` (uses tools schema) |
51
53
 
52
54
  ## Hybrid books
@@ -49,8 +49,25 @@ For pre-commit: add to `.pre-commit-config.yaml`:
49
49
 
50
50
  ## Environment variables
51
51
 
52
- - `BOOK_PROFILE` — `academic` enables Cite-key checking; otherwise skipped.
52
+ - `BOOK_PRESET` (canonical) / `BOOK_PROFILE` (alias) which preset to validate against. `academic` enables Cite-key checking.
53
53
  - `BOOK_REPO_ROOT` — absolute path to the paired code repo for CodeRef checks. Unset → skipped (the scaffold default; minimal/tools books rarely have a paired code repo).
54
+ - `BOOK_CHAPTERS_DIR` — override the chapters directory (default: read from `content.config.ts`, fallback `src/content/chapters`).
55
+
56
+ ## Preset / chaptersBase resolution (v4.7.0+, #75)
57
+
58
+ The validator resolves both `preset` and `chaptersBase` by consulting multiple sources in a documented order. Notable v4.7.0 addition: the v4.5+ canonical form
59
+
60
+ ```ts
61
+ // src/content.config.ts
62
+ export const { collections } = defineBookSchemas({
63
+ preset: 'research-portfolio',
64
+ chaptersBase: './src/content/textbook',
65
+ });
66
+ ```
67
+
68
+ is now read by the CLI (previously it was silently ignored — the CLI defaulted to `profile=minimal` and walked `./src/content/chapters/` while `astro build` applied the correct settings, masking real schema drift).
69
+
70
+ Full precedence chain documented in [`PACKAGE_DESIGN.md §8 — Preset + chaptersBase resolution`](../../PACKAGE_DESIGN.md#preset--chaptersbase-resolution-v470-closes-75).
54
71
 
55
72
  ## Output
56
73
 
@@ -3,7 +3,7 @@
3
3
  The `research-portfolio` preset (v3.5.0+) is for books that combine:
4
4
 
5
5
  - **Academic structure**: week/part/status, KaTeX math, BibTeX citations, Theorem family
6
- - **Tools-style provenance**: volatility class, T1–T4 tier-tagged sources, `last_verified` freshness
6
+ - **Tools-style provenance**: volatility class, T1–T4 tier-tagged sources, required `last_verified` date
7
7
  - **Portfolio-specific affordances**: pre-release banner, AI collaboration disclosure, blocked-by-upstream callouts, structured ethics/policy references
8
8
 
9
9
  If your book is primarily a weekly curriculum, use [`academic`](07-chapter-shapes.md#academic). If primarily AI-CLI comparison content, use [`tools`](07-chapter-shapes.md#tools). If a course-derived study notebook, use [`course-notes`](07-chapter-shapes.md#course-notes). Research portfolios sit at the intersection of all three and get their own preset.
@@ -39,28 +39,66 @@ This scaffolds:
39
39
 
40
40
  ## Chapter frontmatter shape
41
41
 
42
+ Two fields are **required** by the schema; everything else is optional.
43
+
44
+ | Field | Required? | Notes |
45
+ |---|---|---|
46
+ | `title` | **required** | Non-empty string |
47
+ | `last_verified` | **required** | YAML date (`2026-05-19`); used by freshness reports + the v4.6 prevalidate hook |
48
+ | All other fields | optional | See annotations in the template below |
49
+
50
+ ### `status` vs `freshness` — two distinct axes
51
+
52
+ These look similar but mean different things. Authors often confuse them — getting `freshness` wrong fails the schema with `InvalidContentEntryDataError`.
53
+
54
+ | Field | Concept | Enum values | Mental check |
55
+ |---|---|---|---|
56
+ | `status` | **Authoring state** — where am I in writing this chapter? | `scaffolded`, `prose_only`, `code_only`, `chapter_only`, `reading_only`, `implemented`, `planned` | "Have I written it?" |
57
+ | `freshness` | **Epistemic type** — what kind of evidence does this chapter rest on? | `experimental-result`, `literature-survey`, `theoretical`, `reference` | "What kind of knowledge is this?" |
58
+
59
+ A chapter can be `status: scaffolded` (not yet written) AND `freshness: theoretical` (will be a mathematical argument). They're orthogonal.
60
+
61
+ If you want to mark a chapter as "not written yet", use `status: scaffolded` or `status: planned`. `freshness` has no value for that — it describes the chapter's content type, not its progress.
62
+
63
+ ### Template
64
+
42
65
  ```yaml
43
66
  ---
67
+ # required
44
68
  title: "Chapter title"
45
- slug: ch01-introduction # optional; defaults to filename
46
- chapter: 1 # tools-style numeric
47
- part: 1 # either number OR academic-style string enum
48
- week: 1 # optional; only if you use weekly cadence
49
- status: prose_only # academic 7-state (optional)
50
- freshness: experimental-result # 'experimental-result' | 'literature-survey' | 'theoretical' | 'reference'
51
- volatility: feature-surface # tools-style: 'stable-principle' | 'architectural-pattern' | 'feature-surface'
52
- tags: # freeform string array (NOT the tools_compared enum)
69
+ last_verified: 2026-05-19 # YAML date (no quotes); becomes a JS Date
70
+
71
+ # optional hierarchy (use whichever fits; all three may be omitted)
72
+ slug: ch01-introduction # defaults to filename
73
+ chapter: 1 # tools-style numeric
74
+ part: 1 # either number OR academic-style string enum
75
+ week: 1 # only if you use weekly cadence
76
+
77
+ # optional — authoring state + epistemic type
78
+ status: prose_only # 'scaffolded'|'prose_only'|'code_only'|'chapter_only'|'reading_only'|'implemented'|'planned'
79
+ freshness: experimental-result # 'experimental-result'|'literature-survey'|'theoretical'|'reference'
80
+
81
+ # optional — provenance
82
+ volatility: feature-surface # 'stable-principle'|'architectural-pattern'|'feature-surface'
83
+ tags: # freeform string array (NOT the tools_compared enum)
53
84
  - prompt-injection
54
85
  - red-team
55
86
  - CVE-2025-32711
56
- sources:
87
+ sources: # structured inline; tier ∈ {T1, T2, T3, T4}
57
88
  - tier: T1
58
89
  url: https://nvd.nist.gov/vuln/detail/CVE-2025-32711
59
90
  label: NVD CVE-2025-32711 (primary advisory)
60
91
  - tier: T2
61
92
  url: https://arxiv.org/abs/2406.00799
62
93
  label: TaskTracker (Wallace et al. 2024)
63
- last_verified: 2026-05-19
94
+
95
+ # optional — SEO / OpenGraph (v4.6+)
96
+ description: "..." # used by Base.astro meta tags
97
+ author: "Brandon Behring"
98
+ published: 2026-05-01
99
+ updated: 2026-05-19
100
+ image: "/og/ch01.png"
101
+
64
102
  draft: false
65
103
  ---
66
104
  ```
@@ -0,0 +1,175 @@
1
+ # Recipe 20 — Anki deck export (consumer-side pattern)
2
+
3
+ **Profile**: any (most useful for `course-notes` and `research-portfolio`).
4
+
5
+ **TL;DR**: The scaffold does **not** ship an `<AnkiCard>` component or an `extract-cards` CLI (see [PACKAGE_DESIGN.md §15a](../../PACKAGE_DESIGN.md#15a-deferred-scope-post-v4x) for why). This recipe shows how a consumer can roll their own — a small `<AnkiCard>` component plus a `scripts/extract-anki.mjs` extractor that walks the chapters collection and emits an Anki deck. ~120 lines of consumer-side code; no scaffold changes required.
6
+
7
+ If your book is the **third** independent consumer to want this, please open an issue at [book-scaffold-astro](https://github.com/brandon-behring/book-scaffold-astro/issues) — that's the signal we need to consider promoting this to scaffold-level surface.
8
+
9
+ ## When to use this pattern
10
+
11
+ Use it if your book:
12
+
13
+ - Has discrete reviewable facts that benefit from spaced repetition (course notes, foundational reference material).
14
+ - Already authors chapter content in MDX and wants flashcards as a *byproduct*, not a parallel deck file.
15
+ - Wants `<AnkiCard>` to render inline as a "study widget" on the chapter page AND export to `.apkg` for offline review.
16
+
17
+ Do **not** use it if your book is essay-style prose, narrative arguments, or working through proofs — the per-card "front/back" shape fights that content.
18
+
19
+ ## Step 1 — Add the component
20
+
21
+ Create `src/components/AnkiCard.astro`:
22
+
23
+ ```astro
24
+ ---
25
+ export interface Props {
26
+ id?: string; // Stable Anki note GUID (recommended)
27
+ front: string; // Card front (HTML / Markdown allowed in slot)
28
+ back?: string; // Optional one-line back; otherwise use slot
29
+ tags?: string[]; // Anki tags (in addition to chapter slug)
30
+ }
31
+ const { id, front, back, tags = [] } = Astro.props;
32
+ ---
33
+
34
+ <aside class="anki-card" data-id={id} data-front={front} data-tags={tags.join(',')}>
35
+ <header><strong>Q.</strong> {front}</header>
36
+ {back ? <p><strong>A.</strong> {back}</p> : <slot />}
37
+ </aside>
38
+
39
+ <style>
40
+ .anki-card {
41
+ border-left: 4px solid var(--color-accent, #6366f1);
42
+ padding: 0.75rem 1rem;
43
+ margin: 1rem 0;
44
+ background: var(--color-surface-2, #fafafa);
45
+ border-radius: 4px;
46
+ }
47
+ .anki-card header { margin-bottom: 0.5rem; }
48
+ </style>
49
+ ```
50
+
51
+ Import in chapters:
52
+
53
+ ```mdx
54
+ import AnkiCard from '../components/AnkiCard.astro';
55
+
56
+ <AnkiCard id="ch01-q01" front="What does the central limit theorem state?">
57
+ The distribution of sample means approaches a normal distribution as $n \to \infty$,
58
+ regardless of the underlying population distribution (provided finite variance).
59
+ </AnkiCard>
60
+ ```
61
+
62
+ ## Step 2 — Add the extractor
63
+
64
+ Create `scripts/extract-anki.mjs` at the project root:
65
+
66
+ ```js
67
+ #!/usr/bin/env node
68
+ /**
69
+ * scripts/extract-anki.mjs — walk chapters, find <AnkiCard> instances, emit JSON.
70
+ *
71
+ * Pairs with src/components/AnkiCard.astro. For .apkg generation, pipe the
72
+ * JSON through a separate tool (e.g., genanki Python lib) — keeping that
73
+ * outside this script avoids a runtime dependency on an Anki package builder.
74
+ */
75
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
76
+ import { glob } from 'node:fs/promises';
77
+ import { resolve, dirname } from 'node:path';
78
+
79
+ const CHAPTERS_GLOB = 'src/content/chapters/**/*.{md,mdx}';
80
+ const OUT_PATH = 'dist-anki/cards.json';
81
+
82
+ // Match <AnkiCard ...> ... </AnkiCard> OR self-closing <AnkiCard ... />.
83
+ // Captures attribute block + optional slot body.
84
+ const ANKI_RE = /<AnkiCard\s+([^>]*?)(?:\/>|>([\s\S]*?)<\/AnkiCard>)/g;
85
+ const ATTR_RE = /(\w+)\s*=\s*(?:"([^"]*)"|\{([^}]*)\}|'([^']*)')/g;
86
+
87
+ function parseAttrs(attrStr) {
88
+ const out = {};
89
+ let m;
90
+ while ((m = ATTR_RE.exec(attrStr)) !== null) {
91
+ out[m[1]] = m[2] ?? m[3] ?? m[4];
92
+ }
93
+ return out;
94
+ }
95
+
96
+ async function main() {
97
+ const cards = [];
98
+ for await (const file of glob(CHAPTERS_GLOB)) {
99
+ const src = await readFile(file, 'utf8');
100
+ const slug = file.replace(/^.*\/chapters\//, '').replace(/\.mdx?$/, '');
101
+ let m;
102
+ while ((m = ANKI_RE.exec(src)) !== null) {
103
+ const attrs = parseAttrs(m[1]);
104
+ cards.push({
105
+ guid: attrs.id ?? `${slug}-${cards.length}`,
106
+ chapter: slug,
107
+ front: attrs.front,
108
+ back: attrs.back ?? (m[2] ?? '').trim(),
109
+ tags: (attrs.tags ?? '').split(',').filter(Boolean).concat([slug]),
110
+ });
111
+ }
112
+ }
113
+
114
+ await mkdir(dirname(OUT_PATH), { recursive: true });
115
+ await writeFile(OUT_PATH, JSON.stringify(cards, null, 2) + '\n');
116
+ console.log(`extract-anki: ${cards.length} cards → ${OUT_PATH}`);
117
+ }
118
+
119
+ main().catch((e) => { console.error(e); process.exit(1); });
120
+ ```
121
+
122
+ Wire it into `package.json`:
123
+
124
+ ```json
125
+ {
126
+ "scripts": {
127
+ "build:anki": "node scripts/extract-anki.mjs"
128
+ }
129
+ }
130
+ ```
131
+
132
+ ## Step 3 — Generate the `.apkg`
133
+
134
+ The JSON output is intentionally tool-neutral. Pick whichever Anki builder fits your stack:
135
+
136
+ - **Python `genanki`** (most common): wrap the JSON read in a 20-line Python script that emits `.apkg` via `genanki.Deck` + `genanki.Note`. Stable note GUIDs from the `guid` field keep your review history when the source updates.
137
+ - **Node `anki-apkg-export`** (npm): direct from Node if you want to stay in one runtime.
138
+ - **Plain CSV import**: Anki's CSV importer reads the JSON directly enough that you can convert with `jq -r '.[] | [.guid, .front, .back, (.tags | join(" "))] | @tsv'`.
139
+
140
+ The scaffold deliberately does **not** opine on the choice — the JSON is the contract.
141
+
142
+ ## Step 4 — Per-book grouping (if needed)
143
+
144
+ The example above emits one deck per book. If you have a multi-book corpus (per the DLAI Study Notes pattern), gate emission on a `book` discriminator in chapter frontmatter:
145
+
146
+ ```js
147
+ const byBook = new Map();
148
+ // ... inside the loop:
149
+ const fm = parseFrontmatter(src); // bring your own YAML parser
150
+ const book = fm.book ?? 'main';
151
+ if (!byBook.has(book)) byBook.set(book, []);
152
+ byBook.get(book).push({ ... });
153
+ ```
154
+
155
+ Then write one file per book key. Multi-book corpus routing is itself out of scope at v4.x ([deferred, see #15](../../PACKAGE_DESIGN.md#15a-deferred-scope-post-v4x)) — if you need it, the same consumer-side pattern applies.
156
+
157
+ ## Common gotchas
158
+
159
+ - **Stable GUIDs**: if you omit `id`, the script falls back to `${slug}-${index}`. Adding/removing cards above an existing card will shift indices and break review history. **Always set explicit `id` props** for cards you want to survive content edits.
160
+ - **Markdown in slots**: Anki accepts HTML; MDX renders the slot content to HTML before this extractor sees it, so most formatting carries through. KaTeX math is a special case — the extractor sees raw `$...$` LaTeX; either pre-render with KaTeX server-side before extraction, or use Anki's MathJax integration.
161
+ - **Pagefind**: `<AnkiCard>` instances are indexed by Pagefront like any prose. If you don't want flashcard fronts in search results, wrap in `<aside data-pagefind-ignore>`.
162
+ - **Visual regression**: the component adds a styled block to every chapter that has cards. If you maintain visual baselines, regenerate them after first adoption.
163
+
164
+ ## Why this isn't in the scaffold
165
+
166
+ Per [PACKAGE_DESIGN.md §15a](../../PACKAGE_DESIGN.md#15a-deferred-scope-post-v4x): single consumer signal so far (DLAI), runtime dep concerns (`.apkg` is a SQLite-backed zip — needs an external builder), and the design space for per-book grouping is entangled with multi-book corpus routing (also deferred). When 2-3 consumers independently want this, it gets promoted.
167
+
168
+ ## Canonical files
169
+
170
+ - This recipe (consumer pattern)
171
+ - [PACKAGE_DESIGN.md §15a](../../PACKAGE_DESIGN.md#15a-deferred-scope-post-v4x) — deferral rationale
172
+
173
+ ## Reference implementation
174
+
175
+ - [`dlai-study-notes`](https://github.com/brandon-behring/dlai-study-notes) — the DLAI Study Notes pilot that prototyped this pattern (originator of issue #16).
package/recipes/README.md CHANGED
@@ -20,6 +20,12 @@ Terse pointers into canonical code for the most common book-authoring workflows.
20
20
  | 12 | [Where to file issues](12-where-to-file-issues.md) | any | Consumer-pilot issue template, label conventions |
21
21
  | 13 | [Research-portfolio getting started](13-research-portfolio-getting-started.md) | research-portfolio | When to use the preset, frontmatter shape, the 4 new components |
22
22
  | 14 | [Port a LaTeX book](14-port-latex-book.md) | typically academic | Operational playbook for porting an existing LaTeX manuscript — bib sharing, inline-upstream-PR loop, common pitfalls |
23
+ | 15 | [Defining styles](15-defining-styles.md) | any | The `defineStyle` API (v4.0+): compose styles per-key, override CSS, share between books |
24
+ | 16 | [TikZ figures](16-tikz-figures.md) | typically academic | `build-figures` TikZ standalone → SVG pipeline |
25
+ | 17 | [Draft chapter workflow](17-draft-chapter-workflow.md) | any | `draft: true` filtering, in-flight chapters, prerequisite gating |
26
+ | 18 | [Chapter route ownership](18-chapter-route-ownership.md) | any | When to override the auto-injected `/chapters/[...slug]/` route |
27
+ | 19 | [Prevalidate hook](19-prevalidate-hook.md) | any | Wire `prevalidate` to run `build:bib` + `build:labels` before `validate` |
28
+ | 20 | [Anki deck export (consumer-side)](20-anki-export.md) | any (esp. course-notes, research-portfolio) | Roll-your-own `<AnkiCard>` + extractor; scaffold deliberately doesn't ship this |
23
29
 
24
30
  ## How to read recipes
25
31
 
@@ -28,7 +28,7 @@
28
28
  import { readFile, access } from 'node:fs/promises';
29
29
  import { existsSync, readFileSync } from 'node:fs';
30
30
  import { resolve, dirname, join } from 'node:path';
31
- import { walkMdx, readChaptersBase } from './walk-mdx.mjs';
31
+ import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
32
32
 
33
33
  /**
34
34
  * Best-effort .env reader. Mirrors `readEnvFile` in src/types.ts; kept inline
@@ -105,17 +105,26 @@ const DATA_DIR = resolve(ROOT, 'src/data');
105
105
 
106
106
  // Preset resolution (matches resolvePreset in src/types.ts):
107
107
  // --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
108
- // .env BOOK_PRESET > .env BOOK_PROFILE > 'minimal'.
108
+ // .env BOOK_PRESET > .env BOOK_PROFILE >
109
+ // defineBookSchemas({ preset }) in content.config.ts >
110
+ // defineBookSchemas({ profile }) in content.config.ts (alias) >
111
+ // 'minimal'.
109
112
  // .env fallback closes #20 — without it, consumers who set BOOK_PROFILE in
110
113
  // .env (the documented convenience in SKILL.md + create-book defaults) saw
111
114
  // the CLI silently default to minimal, hiding academic-profile errors.
115
+ // content.config.ts fallback closes #75 — without it, consumers using the
116
+ // canonical v4.5+ defineBookSchemas({ preset, chaptersBase }) form had the
117
+ // CLI silently default to minimal, hiding research-portfolio (and any
118
+ // non-env-set) profile errors while astro build applied the correct settings.
112
119
  const dotenv = readEnvFile(resolve(ROOT, '.env'));
120
+ const schemaConfig = await readBookSchemaConfig(ROOT);
113
121
  const PRESET =
114
122
  presetFromFlag ??
115
123
  process.env.BOOK_PRESET ??
116
124
  process.env.BOOK_PROFILE ??
117
125
  dotenv.BOOK_PRESET ??
118
126
  dotenv.BOOK_PROFILE ??
127
+ schemaConfig.preset ??
119
128
  'minimal';
120
129
  // Alias kept for downstream message text only; the resolution above is canonical.
121
130
  const PROFILE = PRESET;
@@ -34,6 +34,79 @@ export async function* walkMdx(dir, baseDir = dir) {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * Read the consumer's `content.config.ts` (or `.mjs` / `.js`) and extract
39
+ * `defineBookSchemas({ preset?, profile?, chaptersBase? })` options.
40
+ *
41
+ * v4.7.0 (closes #75): consumers using the v4.5+ canonical form
42
+ *
43
+ * export const { collections } = defineBookSchemas({
44
+ * preset: 'research-portfolio',
45
+ * chaptersBase: './src/content/textbook',
46
+ * });
47
+ *
48
+ * previously had BOTH options ignored by the CLI scripts. `validate` and
49
+ * `build-labels` resolved preset only from env vars and walked the default
50
+ * chapters dir, silently checking the wrong directory under the wrong
51
+ * profile while astro build applied the correct settings — a divergence
52
+ * masking real schema drift.
53
+ *
54
+ * Strategy: regex-parse the source file (avoid runtime import; the file
55
+ * imports from `astro:content` / `astro/loaders` which don't resolve in
56
+ * plain Node). Captures the entire `defineBookSchemas({ ... })` options
57
+ * object, then field-by-field regex within that scope for `preset:`,
58
+ * `profile:` (alias), and `chaptersBase:`.
59
+ *
60
+ * Returns `{ preset, chaptersBase }` — both nullable. Returns `null` for
61
+ * either field when:
62
+ * - content.config.{ts,mjs,js} doesn't exist
63
+ * - no `defineBookSchemas(...)` call found
64
+ * - the field is absent or uses a dynamic form (variable, template literal)
65
+ *
66
+ * `preset` and `profile` are aliases (canonical name flipped in v3.7+);
67
+ * `preset` wins when both are present.
68
+ */
69
+ export async function readBookSchemaConfig(projectRoot) {
70
+ const result = { preset: null, chaptersBase: null };
71
+ for (const ext of ['ts', 'mjs', 'js']) {
72
+ const configPath = join(projectRoot, `src/content.config.${ext}`);
73
+ if (!existsSync(configPath)) continue;
74
+ let source;
75
+ try {
76
+ source = await readFile(configPath, 'utf8');
77
+ } catch {
78
+ return result;
79
+ }
80
+ // Match `defineBookSchemas({ ... })` — capture the options object body.
81
+ // Non-greedy `[\s\S]*?` matches the smallest balanced-enough scope; for
82
+ // typical configs the options object is small (≤200 chars) and any
83
+ // nested braces (uncommon in this API) would terminate the match early.
84
+ // Acceptable trade-off: simple regex over a real parser.
85
+ const callMatch = source.match(
86
+ /\bdefineBookSchemas\s*\(\s*\{([\s\S]*?)\}\s*\)/,
87
+ );
88
+ if (callMatch) {
89
+ const optsBody = callMatch[1];
90
+ // preset is canonical (v3.7+); profile is backward-compat alias.
91
+ const presetMatch =
92
+ optsBody.match(/\bpreset\s*:\s*'([^']+)'/) ||
93
+ optsBody.match(/\bpreset\s*:\s*"([^"]+)"/);
94
+ const profileMatch =
95
+ optsBody.match(/\bprofile\s*:\s*'([^']+)'/) ||
96
+ optsBody.match(/\bprofile\s*:\s*"([^"]+)"/);
97
+ result.preset = presetMatch?.[1] ?? profileMatch?.[1] ?? null;
98
+ const chaptersBaseMatch =
99
+ optsBody.match(/\bchaptersBase\s*:\s*'([^']+)'/) ||
100
+ optsBody.match(/\bchaptersBase\s*:\s*"([^"]+)"/);
101
+ result.chaptersBase = chaptersBaseMatch?.[1] ?? null;
102
+ }
103
+ // First existing file wins (priority: .ts > .mjs > .js).
104
+ return result;
105
+ }
106
+ // No content.config.{ts,mjs,js} at all.
107
+ return result;
108
+ }
109
+
37
110
  /**
38
111
  * Read the consumer's `content.config.ts` (or `.mjs` / `.js`) and extract
39
112
  * the `loader.base` path for the `chapters` content collection.
@@ -46,6 +119,9 @@ export async function* walkMdx(dir, baseDir = dir) {
46
119
  * consumer's config file and returns the actual base path so both scripts
47
120
  * discover the consumer's chapter files.
48
121
  *
122
+ * v4.7.0 (closes #75): when the raw Astro form isn't present, also consult
123
+ * `readBookSchemaConfig()` for `defineBookSchemas({ chaptersBase })`.
124
+ *
49
125
  * Strategy: regex-parse the source file (avoid runtime import; the file
50
126
  * imports from `astro:content` / `astro/loaders` which don't resolve in
51
127
  * plain Node). Matches both single- and double-quoted string literals;
@@ -55,6 +131,7 @@ export async function* walkMdx(dir, baseDir = dir) {
55
131
  * `${projectRoot}/src/content/chapters` when:
56
132
  * - content.config.{ts,mjs,js} doesn't exist
57
133
  * - the file exists but no `chapters` collection or `loader.base` found
134
+ * AND no `defineBookSchemas({ chaptersBase: ... })` form found
58
135
  * - the matched base path uses dynamic forms (variables, template literals)
59
136
  * instead of a string literal
60
137
  *
@@ -91,6 +168,12 @@ export async function readChaptersBase(projectRoot) {
91
168
  if (captured) {
92
169
  return resolve(projectRoot, captured);
93
170
  }
171
+ // v4.7.0 (closes #75): no raw Astro form match — try the v4.5+ form
172
+ // `defineBookSchemas({ chaptersBase: '...' })`.
173
+ const schemaConfig = await readBookSchemaConfig(projectRoot);
174
+ if (schemaConfig.chaptersBase) {
175
+ return resolve(projectRoot, schemaConfig.chaptersBase);
176
+ }
94
177
  // File exists but no override found — assume the consumer uses the
95
178
  // scaffold's defineBookSchemas() default.
96
179
  return DEFAULT_BASE;
@@ -20,7 +20,7 @@ export const academicProfile = defineProfile({
20
20
  references: true,
21
21
  search: true,
22
22
  print: true,
23
- chapters: false, // academic consumers ship their own week-based /chapters listing
23
+ chapters: true, // v4.6.1 (#75 follow-up): auto-injected /chapters/[...slug]/ + /chapters/ index. Pre-v4.3.0 academic books shipped their own listing; v4.6.0 (#76 Layer 3c) removed the consumer template assuming auto-injection. Default flipped here to close the gap. Consumers wanting their own listing override via `routes: { chapters: false }` + their own src/pages/chapters/* — see recipe 18.
24
24
  convergence: false, // tools-profile-specific
25
25
  frontmatter: false, // opt-in per book; see #7
26
26
  tips: false, // v4.3.0 #70: opt-in per book; requires build-tips
@@ -22,7 +22,7 @@ export const courseNotesProfile = defineProfile({
22
22
  references: true,
23
23
  search: true,
24
24
  print: true,
25
- chapters: false, // multi-book consumers route via [book]/[slug] themselves
25
+ chapters: true, // v4.6.1 (#75 follow-up): default-on. Multi-book consumers (DLAI-style) override via routes: { chapters: false } + own [book]/[slug] routes — see #15 deferred.
26
26
  convergence: false,
27
27
  frontmatter: false, // opt-in per book; see #7
28
28
  tips: false, // v4.3.0 #70: opt-in per book
@@ -17,7 +17,7 @@ export const minimalProfile = defineProfile({
17
17
  references: true,
18
18
  search: true,
19
19
  print: true,
20
- chapters: false,
20
+ chapters: true, // v4.6.1 (#75 follow-up): default-on across all profiles. Consumer override via routes: { chapters: false }.
21
21
  convergence: false,
22
22
  frontmatter: false, // opt-in per book; see #7
23
23
  tips: false, // v4.3.0 #70: opt-in per book
@@ -33,7 +33,7 @@ export const researchPortfolioProfile = defineProfile({
33
33
  references: true,
34
34
  search: true,
35
35
  print: true,
36
- chapters: false, // portfolio books ship their own landing/index
36
+ chapters: true, // v4.6.1 (#75 follow-up): default-on. Portfolios still ship their own /frontmatter/* + landing; /chapters/* renders the underlying chapter list.
37
37
  convergence: false, // tools-profile-specific
38
38
  frontmatter: true, // portfolios universally need title/disclosure/banner pages
39
39
  tips: false, // v4.3.0 #70: opt-in per book