@brandon_m_behring/book-scaffold-astro 4.26.2 → 4.27.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 (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CLAUDE.md +35 -10
  3. package/LATEX_TO_MDX_MAPPING.md +1 -1
  4. package/LICENSE +27 -0
  5. package/LICENSE-CONTENT +19 -0
  6. package/README.md +33 -0
  7. package/components/AssessmentTest.astro +2 -1
  8. package/components/ChapterNav.astro +2 -2
  9. package/components/Cite.astro +2 -1
  10. package/components/NavContent.astro +2 -2
  11. package/components/PartReview.astro +2 -1
  12. package/components/Rationale.astro +3 -2
  13. package/components/Sidebar.astro +2 -1
  14. package/components/Term.astro +2 -1
  15. package/components/TipsCard.astro +2 -1
  16. package/components/VersionSelector.tsx +39 -36
  17. package/components/WeekRef.astro +2 -1
  18. package/components/XRef.astro +2 -1
  19. package/dist/components/VersionSelector.d.ts +16 -2
  20. package/dist/components/VersionSelector.mjs +18 -17
  21. package/dist/index.d.ts +24 -6
  22. package/dist/index.mjs +96 -8
  23. package/dist/schemas.d.ts +1 -1
  24. package/dist/schemas.mjs +8 -1
  25. package/dist/{types-Hue-uSeQ.d.ts → types-CZrkqzpC.d.ts} +84 -48
  26. package/layouts/Base.astro +23 -21
  27. package/package.json +7 -4
  28. package/pages/answers.astro +2 -1
  29. package/pages/chapters.astro +2 -1
  30. package/pages/exercises.astro +2 -1
  31. package/pages/index.astro +4 -3
  32. package/pages/search.astro +2 -1
  33. package/pages/tips.astro +2 -1
  34. package/recipes/01-add-math.md +40 -28
  35. package/recipes/04-component-library.md +14 -4
  36. package/recipes/05-deploy-cloudflare.md +44 -0
  37. package/recipes/06-mobile-first-layout.md +25 -2
  38. package/recipes/09-validation.md +18 -9
  39. package/recipes/15-defining-styles.md +7 -3
  40. package/recipes/19-prevalidate-hook.md +29 -83
  41. package/scripts/build-bib.mjs +7 -3
  42. package/scripts/build-labels.mjs +33 -16
  43. package/scripts/read-env.mjs +25 -0
  44. package/scripts/resolve-book-config.mjs +96 -0
  45. package/scripts/validate.mjs +125 -84
  46. package/src/lib/define-style.ts +25 -8
  47. package/src/lib/nav-href.ts +24 -2
  48. package/styles/layout.css +14 -8
  49. package/styles/tokens.css +8 -10
@@ -1,113 +1,59 @@
1
- # Recipe 19 — `prevalidate` npm hook (v4.6.0+)
1
+ # Recipe 19 — generated data before validation (v4.27+)
2
2
 
3
- `book-scaffold validate` checks `<Cite key="...">` against `src/data/references.json` (academic profile) and `<XRef id="...">` against `src/data/labels.json`. Both JSON files are **derived artifacts** regenerated from `bibliography.bib` + chapter MDX by `book-scaffold build-bib` + `book-scaffold build-labels`. Both are gitignored.
3
+ `book-scaffold validate` checks citations and cross-references against two derived, normally gitignored files:
4
4
 
5
- When `npm run validate` runs **standalone** (e.g., a reusable deploy workflow runs only the validate command, without the full `npm run build` chain), the prereq scripts don't fire and the validator chokes on apparently-missing keys.
5
+ - `src/data/references.json`, emitted by `book-scaffold build-bib`
6
+ - `src/data/labels.json`, emitted by `book-scaffold build-labels`
6
7
 
7
- The `prevalidate` npm lifecycle hook is the canonical fix: it auto-runs the prereqs whenever `npm run validate` is invoked, regardless of how validate is called.
8
-
9
- ---
10
-
11
- ## TL;DR
12
-
13
- ```json
14
- {
15
- "scripts": {
16
- "prevalidate": "npm run build:bib && npm run build:labels --if-present",
17
- "validate": "book-scaffold validate"
18
- }
19
- }
20
- ```
21
-
22
- `npm run validate` → automatically runs `prevalidate` first (build:bib + build:labels) → then runs `validate`. CI and local behave identically; no separate `ci:validate` wrapper script needed.
23
-
24
- ---
25
-
26
- ## Why the workaround was needed
27
-
28
- `brandon-behring/deploy-workflows@v1` (the reusable Cloudflare Workers deploy) runs `npm run <validate-command>` between `npm ci` and `npm run build`. Without the `prevalidate` hook OR an explicit wrapper script, the validate step ran against missing artifacts.
29
-
30
- The Phase 1c first-deploy of `ssm-foundations` (2026-05-26) hit this — validate emitted 25+ "Unknown bibkey" errors that pointed at chapter content instead of the missing `references.json` artifact. The deploy-time fix shipped as a `ci:validate` wrapper:
8
+ Every profile can use theorem/Figure IDs and XRefs, so every generated book now carries the same npm lifecycle:
31
9
 
32
10
  ```json
33
11
  {
34
12
  "scripts": {
35
- "ci:validate": "npm run build:bib && npm run build:labels --if-present && npm run validate"
13
+ "prevalidate": "npm run build:bib --if-present && npm run build:labels --if-present",
14
+ "validate": "book-scaffold validate",
15
+ "prebuild": "npm run validate --if-present"
36
16
  }
37
17
  }
38
18
  ```
39
19
 
40
- …with `validate-command: ci:validate` in `.github/workflows/deploy.yml`. This worked but introduced consumer-side ceremony for what is structurally a scaffold-level convention.
41
-
42
- The cleaner long-term fix is the `prevalidate` npm-lifecycle hook (this recipe). v4.6.0's `create-book` automatically scaffolds it for academic + research-portfolio profiles.
43
-
44
- ---
20
+ `npm run validate` runs `prevalidate` automatically. `npm run build` runs `prebuild`, which delegates to that same path. Academic, tools, minimal, course-notes, and research-portfolio scaffolds use this identical contract.
45
21
 
46
- ## Migration: from `ci:validate` to `prevalidate`
22
+ ## Direct CLI calls self-heal too
47
23
 
48
- Existing consumers (DML, ssm, dlai when it ships) that adopted the `ci:validate` wrapper during 2026-05-26 deploys can migrate when they bump to scaffold `^4.6.0`. Three-file mechanical change per consumer:
24
+ An npm hook cannot protect `npx book-scaffold validate` or a direct bin invocation. Starting in v4.27, the validator itself regenerates each missing artifact before loading it:
49
25
 
50
- ### 1. `package.json` rename `ci:validate` → `prevalidate`
26
+ 1. Missing `labels.json` invokes `build-labels`.
27
+ 2. Missing `references.json` invokes `build-bib`, even when no default `bibliography.bib` exists; that valid no-bibliography case emits `{}`.
28
+ 3. Existing files are not rewritten.
29
+ 4. A child failure stops validation and preserves the original child diagnostic and non-zero status.
51
30
 
52
- ```diff
53
- {
54
- "scripts": {
55
- - "ci:validate": "npm run build:bib && npm run build:labels --if-present && npm run validate",
56
- + "prevalidate": "npm run build:bib && npm run build:labels --if-present",
57
- "validate": "book-scaffold validate"
58
- }
59
- }
60
- ```
31
+ This makes fresh checkouts deterministic without hiding stale existing data. After changing theorem IDs, kinds, slugs, bibliography entries, or `numberStyle`, explicitly rerun the corresponding build command.
61
32
 
62
- Note the renamed script no longer needs to call `validate` itself — npm's lifecycle invokes it automatically after `prevalidate` completes.
33
+ ## Bibliography path precedence
63
34
 
64
- ### 2. `.github/workflows/deploy.yml` revert `validate-command`
35
+ Books with a shared or non-root bibliography can set:
65
36
 
66
- ```diff
67
- jobs:
68
- deploy:
69
- uses: brandon-behring/deploy-workflows/.github/workflows/deploy-astro-worker.yml@v2
70
- secrets: inherit
71
- with:
72
- working-directory: web
73
- - validate-command: ci:validate
74
- + validate-command: validate
75
- enable-pr-previews: true
37
+ ```dotenv
38
+ BOOK_BIB_PATH=../shared/references.bib
76
39
  ```
77
40
 
78
- The reusable workflow's `validate-command` now points at the native `validate` script; the `prevalidate` hook handles its own prereqs transparently.
41
+ `build-bib` resolves the process environment first, then the project-root `.env`, then `./bibliography.bib`. Relative paths are resolved from the book root. This same precedence applies when validation invokes `build-bib` during self-healing.
79
42
 
80
- ### 3. Simplify `prebuild` (optional)
43
+ ## Migrating older consumers
81
44
 
82
- If the consumer's `prebuild` still has the long chain:
45
+ Replace custom `ci:validate` wrappers and profile-conditional hooks with the uniform scripts above. A reusable deploy workflow can call the normal `validate` script; no wrapper needs to repeat the artifact chain.
83
46
 
84
47
  ```diff
85
48
  {
86
49
  "scripts": {
87
- - "prebuild": "npm run build:bib --if-present && npm run build:labels --if-present && npm run validate --if-present",
88
- + "prebuild": "npm run validate --if-present",
89
- "build": "astro build && pagefind --site dist"
50
+ - "ci:validate": "npm run build:bib && npm run build:labels && npm run validate",
51
+ + "prevalidate": "npm run build:bib --if-present && npm run build:labels --if-present",
52
+ "validate": "book-scaffold validate",
53
+ - "prebuild": "npm run build:bib && npm run build:labels && npm run validate"
54
+ + "prebuild": "npm run validate --if-present"
90
55
  }
91
56
  }
92
57
  ```
93
58
 
94
- Now `npm run build` triggers `prebuild` (which runs `validate`) triggers `prevalidate` (which runs the prereqs) validate runs cleanly. Single source of truth for the prereq chain.
95
-
96
- ---
97
-
98
- ## When `prevalidate` is NOT needed
99
-
100
- Profiles that don't run cite-key or XRef validation don't need `prevalidate`. Specifically:
101
-
102
- - `tools`, `minimal`: no `<Cite>` resolution against `references.json`.
103
- - `course-notes`: depends on whether the consumer uses `<Cite>` (some do for source-tier attribution).
104
-
105
- For these profiles, `book-scaffold validate` operates on chapter structure + XRef IDs only; `prevalidate` would be a no-op. The v4.6.0 create-book template adds `prevalidate` ONLY for academic + research-portfolio.
106
-
107
- ---
108
-
109
- ## Why this matters
110
-
111
- Recipe 19 is part of [issue #76](https://github.com/brandon-behring/book-scaffold-astro/issues/76)'s v4.6 bundle. Companion: [recipe 18 — chapter-route ownership](./18-chapter-route-ownership.md), which fixes another silent-CI surface from the same Phase 1c first-deploys.
112
-
113
- The `prevalidate` convention also closes [issue #77](https://github.com/brandon-behring/book-scaffold-astro/issues/77) — the v4.6 validator now emits a single re-framed error pointing at this recipe when `references.json` is missing, replacing the noisy 25-symptom output.
59
+ The historical profile split is intentionally gone: non-academic profiles still need labels, and an empty bibliography build is a supported no-op that creates the importable empty artifact.
@@ -39,6 +39,7 @@
39
39
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
40
40
  import { dirname, resolve } from 'node:path';
41
41
  import { fileURLToPath } from 'node:url';
42
+ import { readEnvFile } from './read-env.mjs';
42
43
 
43
44
  // --help / -h: non-mutating (closes #14).
44
45
  const USAGE = `Usage: book-scaffold build-bib
@@ -49,7 +50,8 @@ AND sources/manifest.yaml -> src/data/sources.json (tools-profile sources for
49
50
  the /references page). Either input may be absent.
50
51
 
51
52
  Env:
52
- BOOK_BIB_PATH Override path to .bib file (default: ./bibliography.bib).
53
+ BOOK_BIB_PATH Override path to .bib file (process env wins, then root
54
+ .env; default: ./bibliography.bib).
53
55
 
54
56
  Options:
55
57
  --help, -h Print this message and exit (non-mutating).
@@ -64,11 +66,13 @@ import '@citation-js/plugin-bibtex';
64
66
 
65
67
  const __dirname = dirname(fileURLToPath(import.meta.url));
66
68
  const PROJECT_ROOT = process.cwd();
69
+ const dotenv = readEnvFile(PROJECT_ROOT);
67
70
 
68
71
  // Default: bibliography.bib at scaffold root.
69
72
  // Override via BOOK_BIB_PATH=path/to/your.bib (absolute or relative to cwd).
70
- const BIB_PATH = process.env.BOOK_BIB_PATH
71
- ? resolve(process.cwd(), process.env.BOOK_BIB_PATH)
73
+ const configuredBibPath = process.env.BOOK_BIB_PATH ?? dotenv.BOOK_BIB_PATH;
74
+ const BIB_PATH = configuredBibPath
75
+ ? resolve(PROJECT_ROOT, configuredBibPath)
72
76
  : resolve(PROJECT_ROOT, 'bibliography.bib');
73
77
  const OUT_PATH = resolve(PROJECT_ROOT, 'src/data/references.json');
74
78
  const SOURCES_PATH = resolve(PROJECT_ROOT, 'sources/manifest.yaml');
@@ -12,14 +12,15 @@
12
12
  *
13
13
  * Kind-aware (#126): a `<Theorem kind="proposition">` resolves its display
14
14
  * WORD through the shared theorem-label vocabulary (`Proposition 8.1`), not a
15
- * kind-blind `Theorem 8.1`. The theorem family shares one counter (keyed by
16
- * the JSX component, as amsthm shares its counter), so numbers are unchanged.
15
+ * kind-blind `Theorem 8.1`. v4.27.0 (#175) lets defineBookConfig / defineStyle
16
+ * select shared (the historical default) or independent per-kind counters.
17
17
  *
18
18
  * The resulting map is consumed by XRef.astro via
19
19
  * `import.meta.glob('/src/data/labels.json', { eager: true })`.
20
20
  *
21
- * Per-chapter, per-type counter: each chapter resets the counter, so two
22
- * chapters can both have `Theorem 1` without colliding. The chapter
21
+ * Per-chapter counters: each chapter resets the sequence, so two chapters can
22
+ * both have `Theorem 1` without colliding. Labelable component families always
23
+ * have independent counters; theorem kinds share or split per numberStyle. The chapter
23
24
  * number comes from frontmatter:
24
25
  * - tools profile: `chapter` field (number).
25
26
  * - academic profile: `week` field (number).
@@ -46,6 +47,7 @@
46
47
  import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
47
48
  import { resolve, relative, join, basename, dirname } from 'node:path';
48
49
  import { readChaptersBase } from './walk-mdx.mjs';
50
+ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
49
51
  // #126: reuse the ONE kind vocabulary (theorem-label.ts → its own lean tsup
50
52
  // entry) so a <Theorem kind="proposition"> xref reads "Proposition N.M", not a
51
53
  // kind-blind "Theorem N.M" — and so an unknown/absent kind FAILS HERE (same
@@ -66,6 +68,9 @@ Env:
66
68
 
67
69
  Options:
68
70
  --help, -h Print this message and exit (non-mutating).
71
+
72
+ Numbering is read from defineBookConfig / defineStyle numberStyle. It defaults
73
+ to shared when no scaffold integration can be resolved.
69
74
  `;
70
75
 
71
76
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
@@ -182,6 +187,7 @@ async function walkChapters(dir) {
182
187
 
183
188
  async function main() {
184
189
  const cwd = process.cwd();
190
+ const { numberStyle } = await loadResolvedBookConfig(cwd);
185
191
  const chaptersDir = resolve(cwd, CHAPTERS_DIR);
186
192
  const files = await walkChapters(chaptersDir);
187
193
 
@@ -207,10 +213,6 @@ async function main() {
207
213
  const id = extractAttr(attrs, 'id');
208
214
  if (!id) continue;
209
215
 
210
- // One shared counter per component (keyed by the JSX name, NOT the
211
- // amsthm kind) — so theorem/proposition/lemma share a sequence exactly
212
- // as they do under amsthm, and existing numbers never shift (#126).
213
- counters[componentName] = (counters[componentName] ?? 0) + 1;
214
216
  foundInChapter += 1;
215
217
  totalIds += 1;
216
218
 
@@ -224,13 +226,16 @@ async function main() {
224
226
  // "no kind=" rather than the misleading kind="null".
225
227
  const labelOverride = extractAttr(attrs, 'label');
226
228
  let word;
229
+ let theoremKind;
227
230
  if (labelOverride == null) {
228
231
  if (componentName === 'Theorem') {
229
232
  try {
230
- word = theoremLabel({
233
+ const resolvedLabel = theoremLabel({
231
234
  kind: extractAttr(attrs, 'kind') ?? undefined,
232
235
  type: extractAttr(attrs, 'type') ?? undefined,
233
- }).fullLabel;
236
+ });
237
+ word = resolvedLabel.fullLabel;
238
+ theoremKind = resolvedLabel.kind;
234
239
  } catch (err) {
235
240
  throw new Error(
236
241
  `<Theorem id="${id}"> in ${relative(cwd, file)}: ${err.message}`,
@@ -241,13 +246,25 @@ async function main() {
241
246
  }
242
247
  }
243
248
 
249
+ // label= is a custom, unnumbered display and therefore does not consume
250
+ // either the historical shared sequence or a per-kind sequence. This
251
+ // keeps later auto-numbered entries stable when prose-only labels move.
252
+ const counterKey =
253
+ numberStyle === 'per-kind' && componentName === 'Theorem'
254
+ ? `Theorem/${theoremKind}`
255
+ : componentName;
256
+ if (labelOverride == null) {
257
+ counters[counterKey] = (counters[counterKey] ?? 0) + 1;
258
+ }
259
+
244
260
  // The bare counter string the heading reuses: Theorem.astro reads
245
261
  // `number` by id and renders it, so heading == xref by construction.
246
262
  // A `label=` override opts out of auto-numbering → number is null.
247
- const number =
248
- chapterNum != null
249
- ? `${chapterNum}.${counters[componentName]}`
250
- : String(counters[componentName]);
263
+ const number = labelOverride != null
264
+ ? null
265
+ : chapterNum != null
266
+ ? `${chapterNum}.${counters[counterKey]}`
267
+ : String(counters[counterKey]);
251
268
  const display = labelOverride ?? `${word} ${number}`;
252
269
 
253
270
  if (labels[id]) {
@@ -263,7 +280,7 @@ async function main() {
263
280
  // labels.json serves any deploy base (root or path-proxied series).
264
281
  href: `chapters/${slug}#${id}`,
265
282
  display,
266
- number: labelOverride ? null : number,
283
+ number,
267
284
  };
268
285
  }
269
286
 
@@ -281,7 +298,7 @@ async function main() {
281
298
  process.stdout.write(
282
299
  `build-labels: ${totalIds} id${totalIds === 1 ? '' : 's'} across ` +
283
300
  `${chaptersWithIds} chapter${chaptersWithIds === 1 ? '' : 's'} → ` +
284
- `${OUTPUT_PATH}\n`,
301
+ `${OUTPUT_PATH} (number-style=${numberStyle})\n`,
285
302
  );
286
303
  }
287
304
 
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ /** Read a small dotenv-style file without mutating process.env. */
5
+ export function readEnvFile(projectRoot = process.cwd()) {
6
+ try {
7
+ const out = {};
8
+ const source = readFileSync(resolve(projectRoot, '.env'), 'utf8');
9
+ for (const line of source.split(/\r?\n/)) {
10
+ const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
11
+ if (!match) continue;
12
+ let value = match[2] ?? '';
13
+ if (
14
+ (value.startsWith('"') && value.endsWith('"')) ||
15
+ (value.startsWith("'") && value.endsWith("'"))
16
+ ) {
17
+ value = value.slice(1, -1);
18
+ }
19
+ out[match[1]] = value;
20
+ }
21
+ return out;
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
@@ -0,0 +1,96 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { loadConfigFromFile } from 'vite';
4
+
5
+ export const DEFAULT_TOOLING_CONFIG = Object.freeze({
6
+ preset: null,
7
+ numberStyle: 'shared',
8
+ integrationFound: false,
9
+ });
10
+
11
+ const ASTRO_CONFIG_NAMES = [
12
+ 'astro.config.mjs',
13
+ 'astro.config.ts',
14
+ 'astro.config.js',
15
+ 'astro.config.cjs',
16
+ ];
17
+ const PRESETS = ['academic', 'tools', 'minimal', 'course-notes', 'research-portfolio'];
18
+
19
+ function findAstroConfig(projectRoot) {
20
+ for (const name of ASTRO_CONFIG_NAMES) {
21
+ const path = resolve(projectRoot, name);
22
+ if (existsSync(path)) return path;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function assertNumberStyle(value, configPath) {
28
+ if (value !== 'shared' && value !== 'per-kind') {
29
+ throw new Error(
30
+ `book-scaffold tooling: ${configPath} resolved invalid numberStyle ` +
31
+ `${JSON.stringify(value)}; expected shared | per-kind.`,
32
+ );
33
+ }
34
+ }
35
+
36
+ function assertPreset(value, configPath) {
37
+ if (value != null && !PRESETS.includes(value)) {
38
+ throw new Error(
39
+ `book-scaffold tooling: ${configPath} resolved invalid preset ` +
40
+ `${JSON.stringify(value)}; expected ${PRESETS.join(' | ')}.`,
41
+ );
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Evaluate the consumer's actual Astro config and read the scaffold
47
+ * integration's internal resolved metadata. Absence of a config/integration is
48
+ * a supported legacy shape and preserves shared numbering. Evaluation errors
49
+ * are deliberately not swallowed: tooling must not silently use wrong config.
50
+ */
51
+ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
52
+ const configPath = findAstroConfig(projectRoot);
53
+ if (!configPath) return { ...DEFAULT_TOOLING_CONFIG };
54
+
55
+ let loaded;
56
+ try {
57
+ loaded = await loadConfigFromFile(
58
+ { command: 'build', mode: 'production', isSsrBuild: true, isPreview: false },
59
+ configPath,
60
+ projectRoot,
61
+ 'silent',
62
+ );
63
+ } catch (error) {
64
+ const detail = error instanceof Error ? error.message : String(error);
65
+ throw new Error(`book-scaffold tooling: failed to evaluate ${configPath}: ${detail}`, {
66
+ cause: error,
67
+ });
68
+ }
69
+
70
+ if (!loaded) {
71
+ throw new Error(`book-scaffold tooling: Vite did not return config for ${configPath}.`);
72
+ }
73
+
74
+ const integrations = Array.isArray(loaded.config?.integrations)
75
+ ? loaded.config.integrations.flat(Infinity)
76
+ : [];
77
+ const integration = integrations.find((candidate) => candidate?.name === 'book-scaffold-astro');
78
+ if (!integration) return { ...DEFAULT_TOOLING_CONFIG };
79
+
80
+ const metadata = integration.__bookScaffoldResolvedConfig;
81
+ if (!metadata) {
82
+ // A config can contain an older scaffold integration with no metadata.
83
+ // Preserve the historical numbering default rather than treating upgrade
84
+ // sequencing as a config error.
85
+ return { ...DEFAULT_TOOLING_CONFIG, integrationFound: true };
86
+ }
87
+
88
+ const numberStyle = metadata.numberStyle ?? 'shared';
89
+ assertNumberStyle(numberStyle, configPath);
90
+ assertPreset(metadata.preset, configPath);
91
+ return {
92
+ preset: metadata.preset ?? null,
93
+ numberStyle,
94
+ integrationFound: true,
95
+ };
96
+ }