@brandon_m_behring/book-scaffold-astro 3.2.0 → 3.4.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/dist/schemas.mjs CHANGED
@@ -3,55 +3,9 @@ import { existsSync as existsSync2 } from "fs";
3
3
  import { defineCollection } from "astro:content";
4
4
  import { glob, file } from "astro/loaders";
5
5
 
6
- // src/types.ts
7
- import { existsSync, readFileSync } from "fs";
8
- var BOOK_PROFILES = ["academic", "tools", "minimal"];
9
- var BookConfigError = class extends Error {
10
- constructor(message) {
11
- super(message);
12
- this.name = "BookConfigError";
13
- }
14
- };
15
- function readEnvFile(path = ".env") {
16
- try {
17
- if (!existsSync(path)) return {};
18
- const out = {};
19
- for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
20
- const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
21
- if (!m) continue;
22
- let val = m[2] ?? "";
23
- if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
24
- val = val.slice(1, -1);
25
- }
26
- out[m[1]] = val;
27
- }
28
- return out;
29
- } catch {
30
- return {};
31
- }
32
- }
33
- function resolveProfile(explicit) {
34
- let candidate = explicit ?? process.env.BOOK_PROFILE;
35
- let source = "default";
36
- if (explicit) source = "param";
37
- else if (process.env.BOOK_PROFILE) source = "env";
38
- if (!candidate) {
39
- const fromFile = readEnvFile().BOOK_PROFILE;
40
- if (fromFile) {
41
- candidate = fromFile;
42
- source = "dotenv";
43
- }
44
- }
45
- candidate = candidate ?? "minimal";
46
- if (!BOOK_PROFILES.includes(candidate)) {
47
- throw new BookConfigError(
48
- `profile must be one of ${BOOK_PROFILES.join(" | ")} (got ${JSON.stringify(candidate)})`
49
- );
50
- }
51
- if (source === "default") {
52
- console.warn("book-scaffold-astro: BOOK_PROFILE not set; falling back to 'minimal'.");
53
- }
54
- return candidate;
6
+ // src/profile-kit.ts
7
+ function defineProfile(p) {
8
+ return p;
55
9
  }
56
10
 
57
11
  // src/schemas.ts
@@ -122,6 +76,32 @@ var toolsChapterSchema = z.object({
122
76
  draft: z.boolean().default(false),
123
77
  updated: z.date().optional()
124
78
  });
79
+ var minimalChapterSchema = toolsChapterSchema;
80
+ var courseNotesChapterSchema = z.object({
81
+ // Identity
82
+ title: z.string().min(1),
83
+ chapter: z.number().int().min(0).max(99),
84
+ part: z.number().int().min(0).max(20).default(1),
85
+ description: z.string().optional(),
86
+ // Source attribution
87
+ course: z.string().optional(),
88
+ instructor: z.string().optional(),
89
+ source_url: z.string().url().optional(),
90
+ // Pedagogy
91
+ learning_outcomes: z.array(
92
+ z.object({
93
+ id: z.string(),
94
+ verb: z.string(),
95
+ text: z.string()
96
+ })
97
+ ).default([]),
98
+ tags: z.array(z.string()).default([]),
99
+ // Provenance + status (shared shape with tools profile)
100
+ last_verified: z.date(),
101
+ volatility: z.enum(volatilityLevels).default("architectural-pattern"),
102
+ sources: z.array(z.string()).default([]),
103
+ draft: z.boolean().default(false)
104
+ });
125
105
  var sourcesSchema = z.object({
126
106
  url: z.string().url(),
127
107
  title: z.string().min(1),
@@ -158,17 +138,167 @@ var patternsSchema = z.object({
158
138
  convergence_date: z.date().nullable().optional()
159
139
  });
160
140
 
141
+ // src/profiles/academic.ts
142
+ var academicProfile = defineProfile({
143
+ name: "academic",
144
+ schema: academicChapterSchema,
145
+ routes: {
146
+ references: true,
147
+ search: true,
148
+ print: true,
149
+ chapters: false,
150
+ // academic consumers ship their own week-based /chapters listing
151
+ convergence: false,
152
+ // tools-profile-specific
153
+ frontmatter: false
154
+ // opt-in per book; see #7
155
+ },
156
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
157
+ katex: true
158
+ });
159
+
160
+ // src/profiles/tools.ts
161
+ var toolsProfile = defineProfile({
162
+ name: "tools",
163
+ schema: toolsChapterSchema,
164
+ routes: {
165
+ references: true,
166
+ search: true,
167
+ print: true,
168
+ chapters: true,
169
+ // tools profile ships a flat chapter index
170
+ convergence: true,
171
+ // tools profile ships convergence dashboard
172
+ frontmatter: false
173
+ // opt-in per book; see #7
174
+ },
175
+ styles: [
176
+ "tokens.css",
177
+ "layout.css",
178
+ "callouts.css",
179
+ "chapter.css",
180
+ "typography.css",
181
+ "print.css",
182
+ "convergence.css",
183
+ "tool-filter.css"
184
+ ]
185
+ });
186
+
187
+ // src/profiles/minimal.ts
188
+ var minimalProfile = defineProfile({
189
+ name: "minimal",
190
+ schema: minimalChapterSchema,
191
+ routes: {
192
+ references: true,
193
+ search: true,
194
+ print: true,
195
+ chapters: false,
196
+ convergence: false,
197
+ frontmatter: false
198
+ // opt-in per book; see #7
199
+ },
200
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"]
201
+ });
202
+
203
+ // src/profiles/course-notes.ts
204
+ var courseNotesProfile = defineProfile({
205
+ name: "course-notes",
206
+ schema: courseNotesChapterSchema,
207
+ routes: {
208
+ references: true,
209
+ search: true,
210
+ print: true,
211
+ chapters: false,
212
+ // multi-book consumers route via [book]/[slug] themselves
213
+ convergence: false,
214
+ frontmatter: false
215
+ // opt-in per book; see #7
216
+ },
217
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"]
218
+ });
219
+
220
+ // src/profiles/index.ts
221
+ var PROFILES = {
222
+ academic: academicProfile,
223
+ tools: toolsProfile,
224
+ minimal: minimalProfile,
225
+ "course-notes": courseNotesProfile
226
+ };
227
+ var BOOK_PROFILES = Object.keys(PROFILES);
228
+
229
+ // src/types.ts
230
+ import { existsSync, readFileSync } from "fs";
231
+ var BOOK_PRESETS = BOOK_PROFILES;
232
+ var BookConfigError = class extends Error {
233
+ constructor(message) {
234
+ super(message);
235
+ this.name = "BookConfigError";
236
+ }
237
+ };
238
+ function readEnvFile(path = ".env") {
239
+ try {
240
+ if (!existsSync(path)) return {};
241
+ const out = {};
242
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
243
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
244
+ if (!m) continue;
245
+ let val = m[2] ?? "";
246
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
247
+ val = val.slice(1, -1);
248
+ }
249
+ out[m[1]] = val;
250
+ }
251
+ return out;
252
+ } catch {
253
+ return {};
254
+ }
255
+ }
256
+ function resolvePreset(explicitPreset, explicitProfile) {
257
+ let candidate = explicitPreset ?? explicitProfile ?? process.env.BOOK_PRESET ?? process.env.BOOK_PROFILE;
258
+ let source = "default";
259
+ if (explicitPreset || explicitProfile) source = "param";
260
+ else if (process.env.BOOK_PRESET || process.env.BOOK_PROFILE) source = "env";
261
+ if (!candidate) {
262
+ const env = readEnvFile();
263
+ const fromFile = env.BOOK_PRESET ?? env.BOOK_PROFILE;
264
+ if (fromFile) {
265
+ candidate = fromFile;
266
+ source = "dotenv";
267
+ }
268
+ }
269
+ candidate = candidate ?? "minimal";
270
+ if (!BOOK_PRESETS.includes(candidate)) {
271
+ throw new BookConfigError(
272
+ `preset must be one of ${BOOK_PRESETS.join(" | ")} (got ${JSON.stringify(candidate)})`
273
+ );
274
+ }
275
+ if (source === "default") {
276
+ console.warn("book-scaffold-astro: BOOK_PRESET not set; falling back to 'minimal'.");
277
+ }
278
+ return candidate;
279
+ }
280
+
161
281
  // src/schemas-entry.ts
282
+ function frontmatterCollection(schema, base = "./src/content/frontmatter") {
283
+ return defineCollection({
284
+ loader: glob({
285
+ pattern: ["**/*.{md,mdx}", "!**/_*"],
286
+ base
287
+ }),
288
+ schema
289
+ });
290
+ }
162
291
  function defineBookSchemas(opts = {}) {
163
- const profile = resolveProfile(opts.profile);
292
+ const profile = resolvePreset(opts.preset, opts.profile);
164
293
  const chaptersBase = opts.chaptersBase ?? "./src/content/chapters";
294
+ const schemaForProfile = profile === "academic" ? academicChapterSchema : profile === "course-notes" ? courseNotesChapterSchema : profile === "minimal" ? minimalChapterSchema : toolsChapterSchema;
165
295
  const chapters = defineCollection({
166
296
  loader: glob({
167
297
  // Exclude underscore-prefixed files (standard "hidden" convention).
168
298
  pattern: ["**/*.{md,mdx}", "!**/_*"],
169
299
  base: chaptersBase
170
300
  }),
171
- schema: profile === "academic" ? academicChapterSchema : toolsChapterSchema
301
+ schema: schemaForProfile
172
302
  });
173
303
  const collections = {
174
304
  chapters
@@ -194,5 +324,6 @@ function defineBookSchemas(opts = {}) {
194
324
  return { collections };
195
325
  }
196
326
  export {
197
- defineBookSchemas
327
+ defineBookSchemas,
328
+ frontmatterCollection
198
329
  };
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": "3.2.0",
4
+ "version": "3.4.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -113,7 +113,8 @@
113
113
  "pedagogy",
114
114
  "examples",
115
115
  "CLAUDE.md",
116
- "README.md"
116
+ "README.md",
117
+ "LATEX_TO_MDX_MAPPING.md"
117
118
  ],
118
119
  "scripts": {
119
120
  "build": "tsup && rm -f dist/types-*.d.ts",
@@ -0,0 +1,48 @@
1
+ ---
2
+ /**
3
+ * pages/frontmatter/[...slug].astro — auto-injected route for the
4
+ * consumer-defined `frontmatter` content collection. v3.4.0 closes #7.
5
+ *
6
+ * Opt-in: consumer enables via defineBookConfig({ routes: { frontmatter: true } })
7
+ * AND defines the collection in src/content.config.ts via the
8
+ * `frontmatterCollection(schema)` helper. Drops MDX files under
9
+ * src/content/frontmatter/; each renders at /frontmatter/<slug>/.
10
+ *
11
+ * Why a single template route (not consumer-owned): the rendering shape
12
+ * is uniform (Base layout + prose + Content) — every consumer would write
13
+ * the same file. Centralizing it keeps the consumer-side surface to just
14
+ * the schema definition + the routes-toggle flip.
15
+ *
16
+ * mdx-components plumbing (issue #2): the consumer's src/mdx-components.ts
17
+ * components are imported via the virtual module and threaded through
18
+ * <Content components={mdxComponents} />, so custom MDX components render
19
+ * here exactly as they do on /print and chapter routes.
20
+ */
21
+ import { getCollection, render } from 'astro:content';
22
+ import Base from '../../layouts/Base.astro';
23
+ import mdxComponents from 'virtual:book-scaffold/mdx-components';
24
+
25
+ export async function getStaticPaths() {
26
+ const entries = await getCollection('frontmatter');
27
+ return entries.map((entry) => ({
28
+ params: { slug: entry.id },
29
+ props: { entry },
30
+ }));
31
+ }
32
+
33
+ const { entry } = Astro.props;
34
+ const { Content } = await render(entry);
35
+
36
+ // The schema is consumer-defined; we read defensively to avoid crashes
37
+ // if the consumer skipped title/description fields. The frontmatterCollection
38
+ // helper documents the recommended shape.
39
+ const data = entry.data as Record<string, unknown>;
40
+ const title = typeof data.title === 'string' ? data.title : entry.id;
41
+ const description = typeof data.description === 'string' ? data.description : undefined;
42
+ ---
43
+
44
+ <Base title={title} description={description ?? ''}>
45
+ <article class="prose frontmatter-page">
46
+ <Content components={mdxComponents} />
47
+ </article>
48
+ </Base>
package/pages/print.astro CHANGED
@@ -7,6 +7,13 @@
7
7
  * body, and wraps in a <section.chapter-print> so print.css can force
8
8
  * page breaks between chapters.
9
9
  *
10
+ * v3.3.0 (closes #2): renders chapters with the consumer's MDX-components
11
+ * map. Consumer creates src/mdx-components.{ts,js,mjs} that default-exports
12
+ * a defineMdxComponents({...}) call; this route imports the map via the
13
+ * virtual:book-scaffold/mdx-components module exposed by the toolkit's
14
+ * Vite plugin. If the consumer has no mdx-components file, the virtual
15
+ * module exports {} so the import is harmless.
16
+ *
10
17
  * Build pipeline:
11
18
  * npm run build → Astro emits dist/print/index.html
12
19
  * npm run pdf → pagedjs-cli fetches dist/print/ via preview
@@ -17,6 +24,7 @@ import Base from '../layouts/Base.astro';
17
24
  import { render } from 'astro:content';
18
25
  import { getAllChapters } from '../src/lib/chapters';
19
26
  import ChapterHeader from '../components/ChapterHeader.astro';
27
+ import mdxComponents from 'virtual:book-scaffold/mdx-components';
20
28
 
21
29
  const chapters = await getAllChapters();
22
30
  const rendered = await Promise.all(
@@ -32,7 +40,7 @@ const rendered = await Promise.all(
32
40
  {rendered.map(({ entry, Content }) => (
33
41
  <section class="chapter-print">
34
42
  <ChapterHeader data={entry.data} />
35
- <Content />
43
+ <Content components={mdxComponents} />
36
44
  </section>
37
45
  ))}
38
46
  </main>
@@ -0,0 +1,58 @@
1
+ # Recipe 12 — Where to file issues (consumer-driven evolution)
2
+
3
+ This toolkit grows through cross-consumer dogfooding. Each new book project you stand up — academic curriculum, AI-CLI comparison, course notes, research portfolio, or something new — is both content work *and* a structured test of the scaffold's abstraction.
4
+
5
+ ## When to file an issue
6
+
7
+ File against [`brandon-behring/book-scaffold-astro/issues`](https://github.com/brandon-behring/book-scaffold-astro/issues) when:
8
+
9
+ - The scaffold's current schemas don't fit your book's content shape (e.g. course notes needing freeform `tags` instead of the `tools_compared` enum).
10
+ - An auto-injected route conflicts with your book's URL structure (e.g. multi-book corpus that routes via `[book]/[slug]/`).
11
+ - A scaffold-injected route can't render your custom MDX components (e.g. you have `<AnkiCard>` that needs to appear on `/print`).
12
+ - A CLI subcommand crashes or behaves unexpectedly (e.g. `validate` reports zero chapters).
13
+ - A scaffold component you rebuilt has an exact equivalent already shipped (waste signal — file as `docs: missing in LATEX_TO_MDX_MAPPING.md`).
14
+ - An API decision blocks one of your downstream projects.
15
+
16
+ ## Issue shape
17
+
18
+ Mirror the pattern used by issues [#1–#14](https://github.com/brandon-behring/book-scaffold-astro/issues?q=is%3Aissue+sort%3Acreated-desc):
19
+
20
+ ```markdown
21
+ ## Problem
22
+ <observed behavior + repro steps + which consumer surfaced it>
23
+
24
+ ## Evidence
25
+ <command output, file paths, version pin (`npm view @brandon_m_behring/book-scaffold-astro version`)>
26
+
27
+ ## Suggested fix
28
+ <one or more concrete options; trade-offs noted>
29
+
30
+ ## Acceptance criteria
31
+ <bulleted checklist a reviewer can verify>
32
+ ```
33
+
34
+ Label with `bug` / `enhancement` / `documentation`. Reference the consumer repo + line where the friction was hit.
35
+
36
+ ## Why this matters (the loop)
37
+
38
+ Each batch of cross-consumer issues drives a minor toolkit release:
39
+
40
+ - **v3.0–v3.2** absorbed Phase B/C/D feedback from `post_transformers` + `book-template-astro`.
41
+ - **v3.3.0** closed 5 issues surfaced from the DLAI knowledge-graphs-rag pilot (course-notes profile + defineMdxComponents + per-route override + LaTeX migration doc).
42
+ - **v3.4.0** closed 8 more (preset vocabulary + propagation + frontmatter helper + validate root fix + CI hygiene + docs).
43
+ - **v3.5.0** (future) is expected to add the `research-portfolio` preset per issue #6 once cross-repo coordination with `prompt-injection-portfolio` is ready.
44
+
45
+ Profile-by-profile growth is the explicit strategy: the toolkit gets a new profile when a real consumer needs one, not before.
46
+
47
+ ## What NOT to file
48
+
49
+ - Bug reports from external users of a single book — file those against the book's repo, not the scaffold's.
50
+ - Style preferences that already have an escape hatch (e.g. `extraStyles` array, consumer-side `<style>` blocks).
51
+ - Speculative features ("we might one day want X"). Wait for the second consumer to actually need it.
52
+
53
+ ## Where to find prior decisions
54
+
55
+ - [`CHANGELOG.md`](../../CHANGELOG.md) — release-by-release breakdown.
56
+ - [`PACKAGE_DESIGN.md`](../PACKAGE_DESIGN.md) §1 Q1–Q6 — original Phase A locked decisions.
57
+ - [`LATEX_TO_MDX_MAPPING.md`](../LATEX_TO_MDX_MAPPING.md) — 38-component reference.
58
+ - [Closed issues](https://github.com/brandon-behring/book-scaffold-astro/issues?q=is%3Aissue+is%3Aclosed) — many problems already have rejected-alternative discussion attached.
@@ -33,6 +33,24 @@
33
33
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
34
34
  import { dirname, resolve } from 'node:path';
35
35
  import { fileURLToPath } from 'node:url';
36
+
37
+ // --help / -h: non-mutating (closes #14).
38
+ const USAGE = `Usage: book-scaffold build-bib
39
+
40
+ Bibliography pipeline (academic profile). Reads bibliography.bib (or
41
+ BOOK_BIB_PATH if set), parses via @citation-js, emits src/data/references.json.
42
+
43
+ Env:
44
+ BOOK_BIB_PATH Override path to .bib file (default: ./bibliography.bib).
45
+
46
+ Options:
47
+ --help, -h Print this message and exit (non-mutating).
48
+ `;
49
+
50
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
51
+ process.stdout.write(USAGE);
52
+ process.exit(0);
53
+ }
36
54
  import { Cite } from '@citation-js/core';
37
55
  import '@citation-js/plugin-bibtex';
38
56
 
@@ -30,6 +30,25 @@ import { dirname, resolve, basename } from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
31
  import { spawnSync } from 'node:child_process';
32
32
 
33
+ // --help / -h: non-mutating (closes #14).
34
+ const USAGE = `Usage: book-scaffold build-figures
35
+
36
+ Figure pipeline. PDF -> SVG via pdftocairo (PNG fallback via pdftoppm at
37
+ 200dpi). Walks figures/ (or BOOK_FIGURES_PATH), emits to public/figures/.
38
+ Graceful-skip if pdftocairo / pdftoppm not on PATH.
39
+
40
+ Env:
41
+ BOOK_FIGURES_PATH Override figures source (default: figures/).
42
+
43
+ Options:
44
+ --help, -h Print this message and exit (non-mutating).
45
+ `;
46
+
47
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
48
+ process.stdout.write(USAGE);
49
+ process.exit(0);
50
+ }
51
+
33
52
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
53
  const PROJECT_ROOT = process.cwd();
35
54
 
@@ -36,6 +36,26 @@
36
36
  import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
37
37
  import { resolve, join, basename, dirname } from 'node:path';
38
38
 
39
+ // --help / -h: non-mutating (closes #14).
40
+ const USAGE = `Usage: book-scaffold build-labels
41
+
42
+ Emit src/data/labels.json for <XRef> resolution. Walks chapter MDX files,
43
+ extracts labelable components (Theorem, Figure, ...), assigns display strings
44
+ like "Theorem 4.2" matching LaTeX \\cref.
45
+
46
+ Env:
47
+ BOOK_CHAPTERS_DIR Override chapters dir (default: src/content/chapters).
48
+ BOOK_LABELS_OUT Override output path (default: src/data/labels.json).
49
+
50
+ Options:
51
+ --help, -h Print this message and exit (non-mutating).
52
+ `;
53
+
54
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
55
+ process.stdout.write(USAGE);
56
+ process.exit(0);
57
+ }
58
+
39
59
  const CHAPTERS_DIR = process.env.BOOK_CHAPTERS_DIR ?? 'src/content/chapters';
40
60
  const OUTPUT_PATH = process.env.BOOK_LABELS_OUT ?? 'src/data/labels.json';
41
61
 
@@ -35,6 +35,25 @@ import { dirname, resolve, basename } from 'node:path';
35
35
  import { fileURLToPath } from 'node:url';
36
36
  import { spawnSync } from 'node:child_process';
37
37
 
38
+ // --help / -h: non-mutating (closes #14).
39
+ const USAGE = `Usage: book-scaffold render-notebooks
40
+
41
+ Notebook pipeline. .ipynb -> standalone HTML via Jupyter nbconvert (--basic).
42
+ Walks notebooks/ (or BOOK_NOTEBOOKS_PATH), emits to public/notebooks/.
43
+ Graceful-skip if uv not on PATH.
44
+
45
+ Env:
46
+ BOOK_NOTEBOOKS_PATH Override notebooks source (default: notebooks/).
47
+
48
+ Options:
49
+ --help, -h Print this message and exit (non-mutating).
50
+ `;
51
+
52
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
53
+ process.stdout.write(USAGE);
54
+ process.exit(0);
55
+ }
56
+
38
57
  const __dirname = dirname(fileURLToPath(import.meta.url));
39
58
  const PROJECT_ROOT = process.cwd();
40
59
 
@@ -7,56 +7,70 @@
7
7
  * book so it's pre-commit-hook friendly.
8
8
  *
9
9
  * Checks performed (per Q14 in the v2.0 plan):
10
- *
11
10
  * 1. <Cite key="..." /> — key exists in src/data/references.json.
12
- * (Cite.astro already throws on unknown keys at build time; we
13
- * surface ALL bad keys at once instead of failing on the first.)
14
- *
15
- * 2. <XRef id="..." /> — id exists in src/data/labels.json. XRef
16
- * doesn't fail the build for unknown ids; without this check,
17
- * typos ship to readers as "[?label]" placeholders.
18
- *
19
- * 3. <Figure src="/path/..." /> — referenced file exists under
20
- * public/. Figure.astro renders a broken-image icon otherwise.
21
- *
22
- * 4. Internal markdown links [text](/foo) — target resolves to a
23
- * known chapter slug or a known top-level route. External (http*)
24
- * links are not checked (would need network IO).
11
+ * 2. <XRef id="..." /> id exists in src/data/labels.json.
12
+ * 3. <Figure src="/path/..." /> file exists under public/.
13
+ * 4. Internal markdown links [text](/foo) — target resolves.
14
+ * 5. <CodeRef path="..." line={N} /> — when BOOK_REPO_ROOT set,
15
+ * path exists + line in bounds.
25
16
  *
26
- * 5. <CodeRef path="..." line={N} /> when run inside a repo
27
- * whose root is BOOK_REPO_ROOT, the path exists and the line
28
- * number is within file bounds. Skipped when BOOK_REPO_ROOT
29
- * isn't set (the scaffold default; only meaningful for academic
30
- * books that paired with an experiments/ subtree).
31
- *
32
- * What this DOESN'T do (and why):
33
- * - frontmatter Zod validation — already done by astro build's
34
- * content-collection sync.
35
- * - MDX renders — same; astro build will fail.
36
- * - KaTeX strict-mode — covered by rehype-katex when academic
37
- * profile is active; undefined macros become build errors.
17
+ * Run from the consumer's project root. Closes #8 (was resolving paths
18
+ * from the package's own directory inside node_modules false negatives
19
+ * across all reference consumers).
38
20
  *
39
21
  * Usage:
40
- * node scripts/validate.mjs
41
- * BOOK_REPO_ROOT=/abs/path/to/code/repo node scripts/validate.mjs
42
- *
43
- * Exit code = total failure count (0 = pass, ≥1 = errors).
22
+ * book-scaffold validate
23
+ * book-scaffold validate --preset academic
24
+ * BOOK_REPO_ROOT=/abs/path npx book-scaffold validate
44
25
  *
45
- * Wire into:
46
- * - package.json scripts: "validate": "node scripts/validate.mjs"
47
- * - pre-commit hook: .pre-commit-config.yaml
48
- * - CI build pipeline: run before `astro build`
26
+ * Exit code = total failure count (0 = pass, >=1 = errors).
49
27
  */
50
28
  import { readFile, access } from 'node:fs/promises';
51
29
  import { glob } from 'node:fs/promises';
52
30
  import { resolve, dirname, join } from 'node:path';
53
- import { fileURLToPath } from 'node:url';
54
31
 
55
- const ROOT = resolve(fileURLToPath(import.meta.url), '../..');
32
+ // --help / -h: non-mutating (closes #14).
33
+ const USAGE = `Usage: book-scaffold validate [--preset <name>]
34
+
35
+ Pre-flight content validator. Checks Cite keys, XRef ids, Figure srcs,
36
+ internal markdown links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
37
+
38
+ Options:
39
+ --preset <name> academic | tools | minimal | course-notes
40
+ (overrides BOOK_PRESET / BOOK_PROFILE env)
41
+ --help, -h Print this message and exit (non-mutating).
42
+
43
+ Env:
44
+ BOOK_PRESET Preset name (preferred over BOOK_PROFILE).
45
+ BOOK_PROFILE Backward-compat alias for BOOK_PRESET.
46
+ BOOK_REPO_ROOT Absolute path to a sibling code repo for CodeRef checks.
47
+
48
+ Exit code = total failure count.
49
+ `;
50
+
51
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
52
+ process.stdout.write(USAGE);
53
+ process.exit(0);
54
+ }
55
+
56
+ // --preset <name> CLI flag (closes #9 — single source of truth across
57
+ // defineBookConfig + validate).
58
+ const argv = process.argv.slice(2);
59
+ const presetFlagIdx = argv.findIndex((a) => a === '--preset');
60
+ const presetFromFlag = presetFlagIdx >= 0 ? argv[presetFlagIdx + 1] : undefined;
61
+
62
+ // v3.4.0: ROOT is the consumer's CWD, not the package's own dir.
63
+ // Resolves issue #8 — three reference consumers reported "0 chapter(s) checked"
64
+ // because ROOT was the package directory inside node_modules.
65
+ const ROOT = process.cwd();
56
66
  const CHAPTERS_DIR = resolve(ROOT, 'src/content/chapters');
57
67
  const PUBLIC_DIR = resolve(ROOT, 'public');
58
68
  const DATA_DIR = resolve(ROOT, 'src/data');
59
- const PROFILE = process.env.BOOK_PROFILE ?? 'minimal';
69
+
70
+ // Preset resolution: --preset flag > BOOK_PRESET env > BOOK_PROFILE env > 'minimal'.
71
+ const PRESET = presetFromFlag ?? process.env.BOOK_PRESET ?? process.env.BOOK_PROFILE ?? 'minimal';
72
+ // Alias kept for downstream message text only; the resolution above is canonical.
73
+ const PROFILE = PRESET;
60
74
  const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
61
75
 
62
76
  const errors = [];