@brandon_m_behring/book-scaffold-astro 4.19.0 → 4.20.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/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.
|
|
4
|
+
"version": "4.20.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Brandon Behring",
|
package/recipes/09-validation.md
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
| `<Figure src="/...">` file exists under `public/` | all | Figure.astro emits a broken-image icon for missing files; build doesn't fail. |
|
|
14
14
|
| `[text](/internal-link)` resolves to known chapter slug or top-level route | all | Astro won't fail on dead internal links. Warning, not error (regex misses dynamic routes). |
|
|
15
15
|
| `<CodeRef path="..." line={N} />` path exists + line in bounds | all, if `BOOK_REPO_ROOT` set | Catches stale line numbers after code refactors in the paired experiments/ repo. |
|
|
16
|
+
| `<Theorem>` has a resolvable `kind=` (or legacy `type=`); an id'd theorem resolves in `labels.json` (#121, #126) | all | An absent kind throws at build with less context; an unindexed id silently renders the heading unnumbered. |
|
|
17
|
+
| `<BookLink book= to=>` both present; `book=` registered in `siblingBooks` (#96) | all | Pre-flights the component's build-time throw across all files at once. |
|
|
18
|
+
| Questions collection: unique `id`s + `domain` in `examDomains` (#112) | all, when `src/content/questions/` exists | Duplicate ids break the appendix/flashcards cross-ref key; an unregistered domain throws one-at-a-time at build. |
|
|
19
|
+
| `los[].anchor` ↔ `{/* anchor: <slug> */}` prose markers agree both ways (#130, v4.20.0) | all, when frontmatter has `los:` | A declared objective whose prose marker is missing/misspelled (or an orphan marker) builds green otherwise — frontmatter↔prose drift only a hand audit would catch. |
|
|
20
|
+
|
|
21
|
+
Validate also emits two **non-blocking shadow-route warnings** (exit code unaffected): a consumer-owned `src/pages/chapters/[...slug].astro` without `routes: { chapters: false }` (v4.6.0, #76), and a consumer-owned `src/pages/index.astro` without `routes: { landing: false }` (v4.20.0, #129 — Astro has announced this collision becomes a hard error). See [recipe 18](./18-chapter-route-ownership.md).
|
|
16
22
|
|
|
17
23
|
## What is NOT checked (already covered elsewhere)
|
|
18
24
|
|
|
@@ -82,6 +82,18 @@ If your file has real customization, prefer State 2: keep the file and add `rout
|
|
|
82
82
|
|
|
83
83
|
---
|
|
84
84
|
|
|
85
|
+
## The landing route (`/`) follows the same rules (v4.20.0, #129)
|
|
86
|
+
|
|
87
|
+
Everything above applies verbatim to a consumer-owned `src/pages/index.astro` vs the scaffold's auto-injected `/` landing page — with one extra wrinkle: **Astro has announced that static-route collisions become a hard error in a future version**, so the State-3 shadow isn't just confusing here, it's a latent build break.
|
|
88
|
+
|
|
89
|
+
- **State 1** — no consumer `src/pages/index.astro`; the scaffold's minimal landing renders from your book config.
|
|
90
|
+
- **State 2** — your custom landing page exists AND `defineBookConfig({ routes: { landing: false } })` is set. No injected route, no collision, no future break.
|
|
91
|
+
- **State 3** — your file exists but `routes.landing` is undefined/true. Your page wins today with a `[router]` collision WARN on every build; it stops building when Astro flips the warning to an error. `book-scaffold validate` warns about this state in v4.20.0+.
|
|
92
|
+
|
|
93
|
+
**Fix**: add `routes: { landing: false }` next to your custom landing page.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
85
97
|
## Why this matters
|
|
86
98
|
|
|
87
99
|
Layer-3 cleanup is part of [issue #76](https://github.com/brandon-behring/book-scaffold-astro/issues/76)'s v4.6 bundle. Related companion: [recipe 19 — prevalidate-hook](./19-prevalidate-hook.md), which fixes another silent-CI gap surfaced during the same first-deploy sessions.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Recipe 21 — Multiple guides in one app (v4.20.0, #132)
|
|
2
|
+
|
|
3
|
+
**Profile**: any (proven on research-portfolio).
|
|
4
|
+
|
|
5
|
+
**TL;DR**: To host more than one guide/book in a single Astro app, keep ONE `chapters` collection rooted at `src/content/` and namespace every entry id by its guide folder via a `generateId` in the glob loader. The scaffold's existing rest-param route (`/chapters/[...slug]/`) then serves `/chapters/<guide>/<slug>/` for every guide with **no scaffold changes**. This is the blessed interim pattern until multibook (#15) ships first-class support.
|
|
6
|
+
|
|
7
|
+
## The pattern
|
|
8
|
+
|
|
9
|
+
Author each guide under its own folder:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
src/content/
|
|
13
|
+
├── evaluation/
|
|
14
|
+
│ ├── 01-why-evaluation.mdx
|
|
15
|
+
│ └── ...
|
|
16
|
+
├── llm-app-engineering/
|
|
17
|
+
│ ├── 01-why-llm-app-engineering.mdx
|
|
18
|
+
│ └── ...
|
|
19
|
+
└── frontmatter/ ← shared, excluded below
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Point the collection at the shared base and namespace ids by guide:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// src/content.config.ts
|
|
26
|
+
import { defineCollection } from 'astro:content';
|
|
27
|
+
import { glob } from 'astro/loaders';
|
|
28
|
+
import { researchPortfolioChapterSchema } from '@brandon_m_behring/book-scaffold-astro/schemas';
|
|
29
|
+
|
|
30
|
+
const chapters = defineCollection({
|
|
31
|
+
loader: glob({
|
|
32
|
+
pattern: ['**/*.{md,mdx}', '!**/_*', '!frontmatter/**'],
|
|
33
|
+
base: './src/content',
|
|
34
|
+
generateId: ({ entry, data }) => {
|
|
35
|
+
const guide = entry.split('/')[0];
|
|
36
|
+
const slug =
|
|
37
|
+
(data && data.slug) || entry.replace(/\.[^.]+$/, '').split('/').pop();
|
|
38
|
+
return `${guide}/${slug}`; // → /chapters/<guide>/<slug>/
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
schema: researchPortfolioChapterSchema,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const collections = { chapters };
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The scaffold's auto-injected `pages/chapters/[...slug].astro` routes on `params: { slug: entry.id }` — a namespaced id rides through unchanged, so both guides render with zero route overrides.
|
|
48
|
+
|
|
49
|
+
## Gotcha 1 — the flat-slug footgun
|
|
50
|
+
|
|
51
|
+
**Without** the `generateId`, Astro's glob loader keys entry ids off the frontmatter `slug` (or filename) and routes FLAT at `/chapters/<slug>/`. That silently requires slugs to be **globally unique across all guides** — two guides each having an `introduction` chapter is a route collision, surfaced as a confusing router warning rather than a clear error. The `generateId` namespacing makes slugs only need uniqueness *within* a guide, which is what authors expect.
|
|
52
|
+
|
|
53
|
+
## Gotcha 2 — `validate` counts everything under the base
|
|
54
|
+
|
|
55
|
+
With `base: './src/content'`, `book-scaffold validate` walks the whole base — shared folders like `frontmatter/` are counted as "chapters" (e.g. `16 chapter(s)` for 13 + 2 real chapters + an authors page). The checks still run correctly per file; only the count is inflated. Excluding non-guide folders from the *loader* pattern does not affect the validator's walk. Guide-aware validation is part of the first-class multibook design (#15).
|
|
56
|
+
|
|
57
|
+
## Gotcha 3 — the `/chapters/` index mixes guides
|
|
58
|
+
|
|
59
|
+
The auto-injected `/chapters/` index lists every entry in the collection — i.e. all guides interleaved. If you want per-guide landing pages, add consumer-owned pages (e.g. `src/pages/evaluation/index.astro`) that `getCollection('chapters', (e) => e.id.startsWith('evaluation/'))`. A grouped-by-guide index is also on the #15 wishlist.
|
|
60
|
+
|
|
61
|
+
## When to expect first-class support
|
|
62
|
+
|
|
63
|
+
This recipe is the interim, zero-scaffold-change path. Issue [#15 (multibook)](https://github.com/brandon-behring/book-scaffold-astro/issues/15) tracks a `books`/`guides` registry that would emit per-guide indexes, a guides landing, and guide-scoped `validate`. It was deferred pending 2–3 independent consumers; `guides-ai-engineering` (#132) is the second — file your use case on #15 to add weight.
|
|
64
|
+
|
|
65
|
+
## Canonical files
|
|
66
|
+
|
|
67
|
+
- `package/pages/chapters/[...slug].astro` — the rest-param route that makes namespaced ids "just work"
|
|
68
|
+
- `package/scripts/walk-mdx.mjs` — `readChaptersBase` (how validate finds the base)
|
|
69
|
+
- `package/recipes/12-where-to-file-issues.md` — multi-book corpus routing is a known scaffold-issue category
|
|
70
|
+
|
|
71
|
+
## Reference implementation
|
|
72
|
+
|
|
73
|
+
`guides-ai-engineering` — two guides (Evaluation + LLM Application Engineering) on `@brandon_m_behring/book-scaffold-astro@4.14.2`; both `dist/chapters/evaluation/why-evaluation/index.html` and `dist/chapters/llm-app-engineering/why-llm-app-engineering/index.html` build from the scaffold's injected route.
|
package/recipes/README.md
CHANGED
|
@@ -26,6 +26,7 @@ Terse pointers into canonical code for the most common book-authoring workflows.
|
|
|
26
26
|
| 18 | [Chapter route ownership](18-chapter-route-ownership.md) | any | When to override the auto-injected `/chapters/[...slug]/` route |
|
|
27
27
|
| 19 | [Prevalidate hook](19-prevalidate-hook.md) | any | Wire `prevalidate` to run `build:bib` + `build:labels` before `validate` |
|
|
28
28
|
| 20 | [Anki deck export (consumer-side)](20-anki-export.md) | any (esp. course-notes, research-portfolio) | Roll-your-own `<AnkiCard>` + extractor; scaffold deliberately doesn't ship this |
|
|
29
|
+
| 21 | [Multiple guides in one app](21-multi-guide-single-app.md) | any | `generateId` namespacing over one collection; flat-slug footgun; interim until multibook (#15) |
|
|
29
30
|
|
|
30
31
|
## How to read recipes
|
|
31
32
|
|
package/scripts/validate.mjs
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* 8. Questions collection (#112) — each question's frontmatter `domain` is a
|
|
21
21
|
* member of the consumer's examDomains registry (best-effort), and question
|
|
22
22
|
* `id`s are unique (the cross-ref key for the appendix / flashcards).
|
|
23
|
+
* 9. Learning-objective anchors (#130) — when a chapter declares frontmatter
|
|
24
|
+
* `los:` entries with `anchor:` slugs, the declared set and the prose's
|
|
25
|
+
* MDX anchor-comment marker set must agree in both directions.
|
|
23
26
|
*
|
|
24
27
|
* Run from the consumer's project root. Closes #8 (was resolving paths
|
|
25
28
|
* from the package's own directory inside node_modules — false negatives
|
|
@@ -177,6 +180,34 @@ const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
|
|
|
177
180
|
}
|
|
178
181
|
}
|
|
179
182
|
|
|
183
|
+
// v4.20.0 (issue #129): the same shadow warning for the landing route. A
|
|
184
|
+
// consumer-owned `src/pages/index.astro` collides with the scaffold's
|
|
185
|
+
// auto-injected `/` (Astro warns today and has announced a hard error in a
|
|
186
|
+
// future major). The escape hatch already exists — `routes: { landing: false }`
|
|
187
|
+
// — this check makes it discoverable before Astro's break lands. Same edge
|
|
188
|
+
// cases + heuristic as the chapters check above.
|
|
189
|
+
{
|
|
190
|
+
const consumerLanding = resolve(ROOT, 'src/pages/index.astro');
|
|
191
|
+
if (existsSync(consumerLanding)) {
|
|
192
|
+
const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
|
|
193
|
+
let landingDisabled = false;
|
|
194
|
+
if (existsSync(astroConfigPath)) {
|
|
195
|
+
const astroConfig = readFileSync(astroConfigPath, 'utf8');
|
|
196
|
+
landingDisabled = /\blanding\s*:\s*false\b/.test(astroConfig);
|
|
197
|
+
}
|
|
198
|
+
if (!landingDisabled) {
|
|
199
|
+
console.warn(
|
|
200
|
+
`\n⚠ Consumer-owned landing page at src/pages/index.astro shadows the\n` +
|
|
201
|
+
` scaffold's auto-injected "/" route. Your page wins today, but Astro\n` +
|
|
202
|
+
` has announced route collisions become a HARD ERROR in a future\n` +
|
|
203
|
+
` version. Set 'routes: { landing: false }' in defineBookConfig to\n` +
|
|
204
|
+
` declare the override and silence the collision.\n` +
|
|
205
|
+
` See: package/recipes/18-chapter-route-ownership.md\n`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
180
211
|
const errors = [];
|
|
181
212
|
const warnings = [];
|
|
182
213
|
const fail = (file, line, msg) => errors.push({ file, line, msg });
|
|
@@ -353,6 +384,57 @@ for (const rel of chapterFiles) {
|
|
|
353
384
|
);
|
|
354
385
|
}
|
|
355
386
|
}
|
|
387
|
+
|
|
388
|
+
// 9. Learning-objective anchor binding (#130). Convention (consumer-defined
|
|
389
|
+
// `los` frontmatter, guides-ai-engineering): each `los[].anchor` slug has
|
|
390
|
+
// a matching MDX comment marker in the prose binding the objective to its
|
|
391
|
+
// section. Both drift directions built + validated green before this
|
|
392
|
+
// check, so both fail loud: a declared anchor with no marker (dangling
|
|
393
|
+
// objective) and a marker with no declaration (orphan). Scoped to
|
|
394
|
+
// chapters that opt into the convention (a `los:` frontmatter key) —
|
|
395
|
+
// `los` is not a scaffold schema field, so this can't false-fire on
|
|
396
|
+
// books that don't use it. Heuristic, like #8: any indented `anchor:`
|
|
397
|
+
// line inside the frontmatter counts as a declaration.
|
|
398
|
+
{
|
|
399
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
400
|
+
const front = fmMatch ? fmMatch[1] : '';
|
|
401
|
+
if (/^los\s*:/m.test(front)) {
|
|
402
|
+
const frontOffset = content.indexOf(front);
|
|
403
|
+
const bodyOffset = fmMatch ? fmMatch[0].length : 0;
|
|
404
|
+
const body = content.slice(bodyOffset);
|
|
405
|
+
// Matches both YAML styles: block items (`- anchor: slug` / `anchor: slug`
|
|
406
|
+
// on its own indented line) and flow/inline maps (`- { text: …, anchor:
|
|
407
|
+
// slug }`), where the key follows a `{` or `,`. The prefix alternation —
|
|
408
|
+
// never a bare `.*` — keeps `my-anchor:` from matching as "anchor:".
|
|
409
|
+
const declared = [
|
|
410
|
+
...front.matchAll(
|
|
411
|
+
/^\s+(?:-\s+|.*[{,]\s*)?anchor\s*:\s*["']?([^"',}\n]+?)["']?\s*(?:[,}].*)?$/gm,
|
|
412
|
+
),
|
|
413
|
+
];
|
|
414
|
+
const markers = [...body.matchAll(/\{\s*\/\*\s*anchor:\s*([^\s*]+)\s*\*\/\s*\}/g)];
|
|
415
|
+
const markerSlugs = new Set(markers.map((m) => m[1]));
|
|
416
|
+
const declaredSlugs = new Set(declared.map((m) => m[1].trim()));
|
|
417
|
+
for (const d of declared) {
|
|
418
|
+
const slug = d[1].trim();
|
|
419
|
+
if (!markerSlugs.has(slug)) {
|
|
420
|
+
fail(
|
|
421
|
+
rel,
|
|
422
|
+
lineOf(content, frontOffset + d.index),
|
|
423
|
+
`los anchor "${slug}" has no matching {/* anchor: ${slug} */} marker in the prose — dangling learning objective.`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const m of markers) {
|
|
428
|
+
if (!declaredSlugs.has(m[1])) {
|
|
429
|
+
fail(
|
|
430
|
+
rel,
|
|
431
|
+
lineOf(content, bodyOffset + m.index),
|
|
432
|
+
`prose anchor marker "${m[1]}" has no matching los[].anchor in the frontmatter — orphan anchor.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
356
438
|
}
|
|
357
439
|
|
|
358
440
|
// ===== 8. Questions collection (#112): domain membership + unique ids =====
|