@brandon_m_behring/book-scaffold-astro 4.3.0 → 4.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.
@@ -18,6 +18,7 @@ const handlers = {
18
18
  'build-bib': '../scripts/build-bib.mjs',
19
19
  'build-figures': '../scripts/build-figures.mjs',
20
20
  'build-tips': '../scripts/build-tips.mjs',
21
+ 'build-exercises': '../scripts/build-exercises.mjs',
21
22
  'render-notebooks': '../scripts/render-notebooks.mjs',
22
23
  };
23
24
 
@@ -29,6 +30,7 @@ Sub-commands:
29
30
  build-bib BibTeX -> CSL JSON for the <Cite> component.
30
31
  build-figures PDF -> SVG via pdftocairo / pdftoppm fallback (+ TikZ in v4.2.0).
31
32
  build-tips Scan chapters for <Tip> instances; emit src/data/tips.json (v4.3.0).
33
+ build-exercises Scan chapters for <Exercise> instances; emit src/data/exercises.json (v4.4.0).
32
34
  render-notebooks ipynb -> HTML via Jupyter nbconvert.
33
35
 
34
36
  --help, -h This message.
@@ -1,22 +1,97 @@
1
1
  ---
2
2
  /**
3
- * ExerciseSolutions — chapter-end wrapper that provides the section
4
- * heading + container for <Solution> elements (v4.3.0, closes #71).
3
+ * ExerciseSolutions — chapter-end wrapper (v4.3.0 #71 + v4.4.0 auto mode).
5
4
  *
6
- * Author places this at chapter end and fills it with <Solution for="...">
7
- * elements. The wrapper provides:
8
- * - <h2>Exercise solutions</h2> heading
9
- * - Container styling (separates the section visually from preceding prose)
10
- * - Anchor (`#exercise-solutions`) so the section is linkable
5
+ * Two modes:
11
6
  *
12
- * v4.3.0 design: manual <Solution> placement (no auto-collection of
13
- * <Exercise> content). Auto-collection deferred to v4.4.0+ if real
14
- * demand surfaces — would require MDX AST traversal at build time.
7
+ * 1. Manual (default; v4.3.0): author places <Solution for="..."> elements
8
+ * inside the wrapper. Full control; works with no build script.
9
+ *
10
+ * <ExerciseSolutions>
11
+ * <Solution for="ex-1">Solution text.</Solution>
12
+ * </ExerciseSolutions>
13
+ *
14
+ * 2. Auto (v4.4.0): reads src/data/exercises.json (emitted by
15
+ * `book-scaffold build-exercises`), scopes by the current chapter
16
+ * (derived from Astro.url.pathname), and renders an auto-generated
17
+ * list of exercise problem statements with placeholder solution
18
+ * slots. Author fills solutions by hand-editing the rendered HTML
19
+ * OR by switching back to manual mode.
20
+ *
21
+ * <ExerciseSolutions auto />
22
+ *
23
+ * Auto mode requires:
24
+ * - `routes.chapters: true` (or a custom `/chapters/<slug>/` route pattern)
25
+ * - `book-scaffold build-exercises` run before astro build (typically wired
26
+ * into the `prebuild` script in package.json)
27
+ *
28
+ * Graceful skip: when exercises.json is missing OR no exercises found for
29
+ * the current chapter, auto mode renders an empty section with a hint.
15
30
  *
16
31
  * Family: book-genre (cross-profile).
17
32
  */
33
+ interface Props {
34
+ /** v4.4.0: if true, auto-collect exercises from src/data/exercises.json
35
+ * (scoped to the current chapter via Astro.url.pathname). Default false
36
+ * uses the manual-slot mode from v4.3.0. */
37
+ auto?: boolean;
38
+ }
39
+ const { auto = false } = Astro.props;
40
+
41
+ // Derive current chapter slug from URL when in auto mode. Astro.url.pathname
42
+ // for `/chapters/<slug>/` returns `/chapters/<slug>/`; strip prefix+suffix.
43
+ let chapterSlug = '';
44
+ let exercises: Array<{ id: string; problem: string }> = [];
45
+ let autoError: string | null = null;
46
+
47
+ if (auto) {
48
+ const path = Astro.url.pathname;
49
+ const m = path.match(/^\/chapters\/([^/]+)\//);
50
+ chapterSlug = m ? m[1] : '';
51
+ if (!chapterSlug) {
52
+ autoError = `<ExerciseSolutions auto /> requires a /chapters/<slug>/ URL; got ${path}.`;
53
+ } else {
54
+ // v4.4.0 fix: project-root-relative import via Vite's import.meta.glob
55
+ // (the previous `../../../src/data/exercises.json` failed in node_modules contexts).
56
+ const exModules = import.meta.glob<{ default: Record<string, Array<{ id: string; problem: string }>> }>(
57
+ '/src/data/exercises.json',
58
+ { eager: true },
59
+ );
60
+ const exEntry = exModules['/src/data/exercises.json'];
61
+ if (!exEntry) {
62
+ autoError = `src/data/exercises.json not found — run \`npx book-scaffold build-exercises\` first.`;
63
+ } else {
64
+ exercises = exEntry.default[chapterSlug] ?? [];
65
+ }
66
+ }
67
+ }
18
68
  ---
19
69
  <section class="exercise-solutions" id="exercise-solutions">
20
70
  <h2 class="exercise-solutions-heading">Exercise solutions</h2>
21
- <div class="exercise-solutions-body"><slot /></div>
71
+ {!auto && (
72
+ <div class="exercise-solutions-body"><slot /></div>
73
+ )}
74
+ {auto && autoError && (
75
+ <p class="exercise-solutions-hint">{autoError}</p>
76
+ )}
77
+ {auto && !autoError && exercises.length === 0 && (
78
+ <p class="exercise-solutions-hint">
79
+ No <code>&lt;Exercise&gt;</code> instances found in chapter <code>{chapterSlug}</code>.
80
+ Run <code>npx book-scaffold build-exercises</code> after adding exercises.
81
+ </p>
82
+ )}
83
+ {auto && !autoError && exercises.length > 0 && (
84
+ <div class="exercise-solutions-body">
85
+ {exercises.map((ex) => (
86
+ <div class="solution" id={`solution-${ex.id}`}>
87
+ <div class="solution-header">
88
+ <strong class="solution-label">Exercise {ex.id}</strong>
89
+ <a href={`#exercise-${ex.id}`} class="solution-backlink">↑ Exercise</a>
90
+ </div>
91
+ <blockquote class="solution-problem">{ex.problem}</blockquote>
92
+ <p class="solution-placeholder"><em>Add your solution here.</em></p>
93
+ </div>
94
+ ))}
95
+ </div>
96
+ )}
22
97
  </section>
@@ -13,13 +13,13 @@
13
13
  * empty container with a note (doesn't fail the build). Consumers who
14
14
  * don't use the tips feature can include the component without prep.
15
15
  */
16
- let tips: Array<{ n: number; title: string; chapter: string; preview: string }> = [];
17
- try {
18
- const mod = await import('../../../src/data/tips.json', { with: { type: 'json' } });
19
- tips = (mod.default ?? []) as typeof tips;
20
- } catch {
21
- // tips.json not generated yet — render empty card with hint.
22
- }
16
+ // v4.4.0 fix: project-root-relative import via Vite's import.meta.glob
17
+ // (the previous `../../../src/data/tips.json` failed in node_modules contexts).
18
+ const tipsModules = import.meta.glob<{ default: Array<{ n: number; title: string; chapter: string; preview: string }> }>(
19
+ '/src/data/tips.json',
20
+ { eager: true },
21
+ );
22
+ const tips = tipsModules['/src/data/tips.json']?.default ?? [];
23
23
  ---
24
24
  <aside class="tips-card" role="contentinfo">
25
25
  <h2 class="tips-card-title">Tips</h2>
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Q as volatilityLevels, h as ChaptersRenderer, n as Style } from './types-B8Js3qF0.js';
3
- export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ResearchPortfolioChapter, m as RouteToggles, S as StatusBadge, o as StyleInput, T as ToolsChapter, V as VolatilityBadge, p as academicChapterSchema, q as academicParts, r as changeKinds, s as changelogSchema, t as chapterStatus, u as composeStyles, v as courseNotesChapterSchema, w as defineProfile, x as defineStyle, y as minimalChapterSchema, z as normalizeFrontmatterConfig, D as patternCategories, E as patternsSchema, G as researchPortfolioChapterSchema, H as resolvePreset, I as resolveProfile, J as sourceTiers, K as sourceTiersResearch, L as sourcesSchema, N as toolSlugs, O as toolsChapterSchema } from './types-B8Js3qF0.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Q as volatilityLevels, h as ChaptersRenderer, n as Style } from './types-BhlCranN.js';
3
+ export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ResearchPortfolioChapter, m as RouteToggles, S as StatusBadge, o as StyleInput, T as ToolsChapter, V as VolatilityBadge, p as academicChapterSchema, q as academicParts, r as changeKinds, s as changelogSchema, t as chapterStatus, u as composeStyles, v as courseNotesChapterSchema, w as defineProfile, x as defineStyle, y as minimalChapterSchema, z as normalizeFrontmatterConfig, D as patternCategories, E as patternsSchema, G as researchPortfolioChapterSchema, H as resolvePreset, I as resolveProfile, J as sourceTiers, K as sourceTiersResearch, L as sourcesSchema, N as toolSlugs, O as toolsChapterSchema } from './types-BhlCranN.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  declare function defineBookConfig(opts: BookConfigOptions): Promise<AstroUserConfig>;
package/dist/index.mjs CHANGED
@@ -378,8 +378,10 @@ var academicProfile = defineProfile({
378
378
  // tools-profile-specific
379
379
  frontmatter: false,
380
380
  // opt-in per book; see #7
381
- tips: false
381
+ tips: false,
382
382
  // v4.3.0 #70: opt-in per book; requires build-tips
383
+ exercises: false
384
+ // v4.4.0: opt-in per book; requires build-exercises
383
385
  },
384
386
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
385
387
  katex: true,
@@ -494,8 +496,10 @@ var toolsProfile = defineProfile({
494
496
  // tools profile ships convergence dashboard
495
497
  frontmatter: false,
496
498
  // opt-in per book; see #7
497
- tips: false
499
+ tips: false,
498
500
  // v4.3.0 #70: opt-in per book
501
+ exercises: false
502
+ // v4.4.0: opt-in per book
499
503
  },
500
504
  styles: [
501
505
  "tokens.css",
@@ -574,8 +578,10 @@ var minimalProfile = defineProfile({
574
578
  convergence: false,
575
579
  frontmatter: false,
576
580
  // opt-in per book; see #7
577
- tips: false
581
+ tips: false,
578
582
  // v4.3.0 #70: opt-in per book
583
+ exercises: false
584
+ // v4.4.0: opt-in per book
579
585
  },
580
586
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
581
587
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
@@ -595,8 +601,10 @@ var courseNotesProfile = defineProfile({
595
601
  convergence: false,
596
602
  frontmatter: false,
597
603
  // opt-in per book; see #7
598
- tips: false
604
+ tips: false,
599
605
  // v4.3.0 #70: opt-in per book
606
+ exercises: false
607
+ // v4.4.0: opt-in per book
600
608
  },
601
609
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
602
610
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
@@ -617,8 +625,10 @@ var researchPortfolioProfile = defineProfile({
617
625
  // tools-profile-specific
618
626
  frontmatter: true,
619
627
  // portfolios universally need title/disclosure/banner pages
620
- tips: false
628
+ tips: false,
621
629
  // v4.3.0 #70: opt-in per book
630
+ exercises: false
631
+ // v4.4.0: opt-in per book
622
632
  },
623
633
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
624
634
  katex: true,
@@ -820,6 +830,9 @@ var ROUTE_REGISTRY = {
820
830
  // v4.3.0 (#70): cross-volume numbered-tips index. Opt-in via
821
831
  // routes.tips: true; pairs with build-tips script + <Tip> component.
822
832
  tips: { pattern: "/tips", file: "tips.astro" },
833
+ // v4.4.0: exercises index by chapter. Opt-in via routes.exercises: true;
834
+ // pairs with build-exercises script + <ExerciseSolutions auto /> mode.
835
+ exercises: { pattern: "/exercises", file: "exercises.astro" },
823
836
  // v3.4.0 (#7): consumer-collection-backed frontmatter route. Opt-in via
824
837
  // routes: { frontmatter: true } AND content.config.ts defining the
825
838
  // collection (use frontmatterCollection() helper from /schemas subpath).
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-B8Js3qF0.js';
2
+ import { g as BookSchemasOptions } from './types-BhlCranN.js';
3
3
  import 'astro';
4
4
  import 'astro/zod';
5
5
 
package/dist/schemas.mjs CHANGED
@@ -263,8 +263,10 @@ var academicProfile = defineProfile({
263
263
  // tools-profile-specific
264
264
  frontmatter: false,
265
265
  // opt-in per book; see #7
266
- tips: false
266
+ tips: false,
267
267
  // v4.3.0 #70: opt-in per book; requires build-tips
268
+ exercises: false
269
+ // v4.4.0: opt-in per book; requires build-exercises
268
270
  },
269
271
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
270
272
  katex: true,
@@ -379,8 +381,10 @@ var toolsProfile = defineProfile({
379
381
  // tools profile ships convergence dashboard
380
382
  frontmatter: false,
381
383
  // opt-in per book; see #7
382
- tips: false
384
+ tips: false,
383
385
  // v4.3.0 #70: opt-in per book
386
+ exercises: false
387
+ // v4.4.0: opt-in per book
384
388
  },
385
389
  styles: [
386
390
  "tokens.css",
@@ -459,8 +463,10 @@ var minimalProfile = defineProfile({
459
463
  convergence: false,
460
464
  frontmatter: false,
461
465
  // opt-in per book; see #7
462
- tips: false
466
+ tips: false,
463
467
  // v4.3.0 #70: opt-in per book
468
+ exercises: false
469
+ // v4.4.0: opt-in per book
464
470
  },
465
471
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
466
472
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
@@ -480,8 +486,10 @@ var courseNotesProfile = defineProfile({
480
486
  convergence: false,
481
487
  frontmatter: false,
482
488
  // opt-in per book; see #7
483
- tips: false
489
+ tips: false,
484
490
  // v4.3.0 #70: opt-in per book
491
+ exercises: false
492
+ // v4.4.0: opt-in per book
485
493
  },
486
494
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
487
495
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
@@ -502,8 +510,10 @@ var researchPortfolioProfile = defineProfile({
502
510
  // tools-profile-specific
503
511
  frontmatter: true,
504
512
  // portfolios universally need title/disclosure/banner pages
505
- tips: false
513
+ tips: false,
506
514
  // v4.3.0 #70: opt-in per book
515
+ exercises: false
516
+ // v4.4.0: opt-in per book
507
517
  },
508
518
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
509
519
  katex: true,
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.3.0",
4
+ "version": "4.4.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -0,0 +1,65 @@
1
+ ---
2
+ /**
3
+ * Auto-injected /exercises route (v4.4.0).
4
+ *
5
+ * Gated on `routes.exercises: true`. Reads `src/data/exercises.json`
6
+ * (emitted by `book-scaffold build-exercises`); renders ordered list
7
+ * grouped by chapter with deep links into the chapter routes.
8
+ *
9
+ * Graceful skip: if exercises.json doesn't exist, renders an instructions
10
+ * page (doesn't fail the build).
11
+ */
12
+ import Base from '../layouts/Base.astro';
13
+
14
+ // v4.4.0 fix: use Vite's import.meta.glob with a project-root-relative
15
+ // path (same lesson as tips.astro). `/src/data/...` is consumer-project-
16
+ // rooted in Vite's resolver and works across all consumer build contexts.
17
+ const exModules = import.meta.glob<{ default: Record<string, Array<{ id: string; problem: string }>> }>(
18
+ '/src/data/exercises.json',
19
+ { eager: true },
20
+ );
21
+ const exEntry = exModules['/src/data/exercises.json'];
22
+ const byChapter = exEntry?.default ?? {};
23
+ const loadError = exEntry
24
+ ? null
25
+ : 'src/data/exercises.json not found — run `npx book-scaffold build-exercises` to generate.';
26
+
27
+ const chapters = Object.keys(byChapter).sort();
28
+ const total = chapters.reduce((sum, c) => sum + byChapter[c].length, 0);
29
+ ---
30
+ <Base title="Exercises" description="All exercises in this book, grouped by chapter with deep links.">
31
+ <article class="prose">
32
+ <h1>Exercises</h1>
33
+ {loadError && (
34
+ <p class="exercises-empty">{loadError}</p>
35
+ )}
36
+ {!loadError && total === 0 && (
37
+ <p class="exercises-empty">
38
+ No exercises defined yet. Add <code>&lt;Exercise id="..."&gt;...&lt;/Exercise&gt;</code> to a chapter
39
+ and run <code>npx book-scaffold build-exercises</code>.
40
+ </p>
41
+ )}
42
+ {total > 0 && (
43
+ <p class="exercises-summary">
44
+ {total} exercise{total === 1 ? '' : 's'} across {chapters.length} chapter{chapters.length === 1 ? '' : 's'}.
45
+ </p>
46
+ )}
47
+ {chapters.map((chapter) => (
48
+ <section class="exercises-chapter" id={`chapter-${chapter}`}>
49
+ <h2 class="exercises-chapter-title">
50
+ Chapter: <a href={`/chapters/${chapter}/`}>{chapter}</a>
51
+ </h2>
52
+ <ol class="exercises-list">
53
+ {byChapter[chapter].map((ex) => (
54
+ <li class="exercises-item">
55
+ <a href={`/chapters/${chapter}/#exercise-${ex.id}`} class="exercises-link">
56
+ <strong class="exercises-id">Exercise {ex.id}</strong>
57
+ <span class="exercises-preview">{ex.problem.slice(0, 120)}{ex.problem.length > 120 ? '…' : ''}</span>
58
+ </a>
59
+ </li>
60
+ ))}
61
+ </ol>
62
+ </section>
63
+ ))}
64
+ </article>
65
+ </Base>
package/pages/tips.astro CHANGED
@@ -11,14 +11,20 @@
11
11
  */
12
12
  import Base from '../layouts/Base.astro';
13
13
 
14
- let tips: Array<{ n: number; title: string; chapter: string; preview: string }> = [];
15
- let loadError: string | null = null;
16
- try {
17
- const mod = await import('../../../src/data/tips.json', { with: { type: 'json' } });
18
- tips = (mod.default ?? []) as typeof tips;
19
- } catch {
20
- loadError = 'src/data/tips.json not found — run `npx book-scaffold build-tips` to generate.';
21
- }
14
+ // v4.4.0 fix: use Vite's import.meta.glob with a project-root-relative
15
+ // path. The previous `../../../src/data/tips.json` resolution failed when
16
+ // the auto-injected route was processed from node_modules (or any context
17
+ // other than a workspace symlink). `/src/data/...` is consumer-project-rooted
18
+ // in Vite's resolver and works across all consumer build contexts.
19
+ const tipsModules = import.meta.glob<{ default: Array<{ n: number; title: string; chapter: string; preview: string }> }>(
20
+ '/src/data/tips.json',
21
+ { eager: true },
22
+ );
23
+ const tipsEntry = tipsModules['/src/data/tips.json'];
24
+ const tips = tipsEntry?.default ?? [];
25
+ const loadError = tipsEntry
26
+ ? null
27
+ : 'src/data/tips.json not found — run `npx book-scaffold build-tips` to generate.';
22
28
  ---
23
29
  <Base title="Tips" description="Numbered tips from this book, drawn from <Tip> instances in chapters.">
24
30
  <article class="prose">
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/build-exercises.mjs — emit src/data/exercises.json from <Exercise>
4
+ * instances in chapter MDX (v4.4.0, closes v4.3.0 backlog).
5
+ *
6
+ * Sister script to build-tips.mjs (v4.3.0). Scans chapters honoring
7
+ * loader.base override (via readChaptersBase from walk-mdx.mjs). Extracts
8
+ * <Exercise id="X">body</Exercise> via 4-branch regex (same quote-style
9
+ * portability lesson as build-tips). Emits a chapter-keyed object so the
10
+ * /exercises route + <ExerciseSolutions auto> can scope by chapter.
11
+ *
12
+ * Output shape:
13
+ * {
14
+ * "ch1-foundations": [
15
+ * { "id": "ex-1", "problem": "Given..." },
16
+ * { "id": "ex-2", "problem": "Refactor..." }
17
+ * ],
18
+ * ...
19
+ * }
20
+ *
21
+ * Graceful no-op: if no <Exercise> instances exist, writes {} (doesn't
22
+ * fail builds for consumers who don't use the feature).
23
+ */
24
+ import { writeFile, mkdir, readFile } from 'node:fs/promises';
25
+ import { resolve, dirname, basename } from 'node:path';
26
+ import { walkMdx, readChaptersBase } from './walk-mdx.mjs';
27
+
28
+ const USAGE = `Usage: book-scaffold build-exercises
29
+
30
+ Scan chapter MDX for <Exercise id="X">body</Exercise> occurrences; emit
31
+ src/data/exercises.json keyed by chapter slug. Used by the /exercises
32
+ auto-route + <ExerciseSolutions auto> mode when routes.exercises: true.
33
+
34
+ Env:
35
+ BOOK_CHAPTERS_DIR Override chapters dir (default: src/content/chapters).
36
+ BOOK_EXERCISES_OUT Override output path (default: src/data/exercises.json).
37
+
38
+ Options:
39
+ --help, -h Print this message and exit (non-mutating).
40
+ `;
41
+
42
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
43
+ process.stdout.write(USAGE);
44
+ process.exit(0);
45
+ }
46
+
47
+ const CWD = process.cwd();
48
+ const CHAPTERS_DIR = await readChaptersBase(CWD);
49
+ const OUTPUT_PATH = process.env.BOOK_EXERCISES_OUT ?? 'src/data/exercises.json';
50
+
51
+ /**
52
+ * Extract <Exercise id="..."> tags and their body content from MDX.
53
+ *
54
+ * Returns array of { id, problem }. Problem is the full body content
55
+ * (trimmed + whitespace-normalized); the /exercises route + ExerciseSolutions
56
+ * auto truncate for display. Full content kept so consumers using
57
+ * `<ExerciseSolutions auto />` see the entire problem statement.
58
+ *
59
+ * Limitations (same as build-tips):
60
+ * - Doesn't handle nested <Exercise> tags
61
+ * - String attributes must use single OR double quotes (not template literals)
62
+ * - id may not contain a literal quote of the same type used as delimiter
63
+ */
64
+ function extractExercises(source) {
65
+ const exercises = [];
66
+ // 4-branch alternation (single/single, double/double, mixed) — no
67
+ // backreference for cross-runtime regex portability (v4.1.2 lesson).
68
+ // Since Exercise has only 1 attribute (id), we just need 2 branches.
69
+ const re = new RegExp(
70
+ [
71
+ `<Exercise\\s+id="([^"]+)"\\s*>([\\s\\S]*?)</Exercise>`,
72
+ `<Exercise\\s+id='([^']+)'\\s*>([\\s\\S]*?)</Exercise>`,
73
+ ].join('|'),
74
+ 'g',
75
+ );
76
+ for (const match of source.matchAll(re)) {
77
+ const [, id1, body1, id2, body2] = match;
78
+ const id = id1 || id2;
79
+ const body = body1 || body2 || '';
80
+ if (!id) continue;
81
+ const problem = body.trim().replace(/\s+/g, ' ');
82
+ exercises.push({ id, problem });
83
+ }
84
+ return exercises;
85
+ }
86
+
87
+ async function main() {
88
+ const byChapter = {};
89
+ let totalExercises = 0;
90
+ let chaptersWithExercises = 0;
91
+
92
+ for await (const rel of walkMdx(CHAPTERS_DIR)) {
93
+ const chapterPath = resolve(CHAPTERS_DIR, rel);
94
+ const chapterSlug = basename(rel).replace(/\.mdx?$/, '');
95
+ let source;
96
+ try {
97
+ source = await readFile(chapterPath, 'utf8');
98
+ } catch {
99
+ continue;
100
+ }
101
+ const exercises = extractExercises(source);
102
+ if (exercises.length > 0) {
103
+ byChapter[chapterSlug] = exercises;
104
+ totalExercises += exercises.length;
105
+ chaptersWithExercises += 1;
106
+ }
107
+ }
108
+
109
+ const outPath = resolve(CWD, OUTPUT_PATH);
110
+ await mkdir(dirname(outPath), { recursive: true });
111
+ await writeFile(outPath, JSON.stringify(byChapter, null, 2) + '\n');
112
+ process.stdout.write(
113
+ `build-exercises: ${totalExercises} exercise${totalExercises === 1 ? '' : 's'} across ` +
114
+ `${chaptersWithExercises} chapter${chaptersWithExercises === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
115
+ );
116
+ }
117
+
118
+ main().catch((err) => {
119
+ console.error('build-exercises: failed');
120
+ console.error(err.message ?? err);
121
+ process.exit(1);
122
+ });
123
+
124
+ // Export for tests.
125
+ export { extractExercises };
@@ -57,6 +57,14 @@ export interface RouteToggles {
57
57
  * defineBookConfig({ routes: { tips: true } }).
58
58
  */
59
59
  tips: boolean;
60
+ /**
61
+ * v4.4.0: auto-inject `/exercises` route listing all `<Exercise id="...">`
62
+ * instances from chapter MDX, grouped by chapter with deep links into
63
+ * the chapter routes. Requires running `book-scaffold build-exercises`
64
+ * (via prebuild) which emits src/data/exercises.json. Default `false` per
65
+ * profile — opt in via defineBookConfig({ routes: { exercises: true } }).
66
+ */
67
+ exercises: boolean;
60
68
  }
61
69
 
62
70
  /** Profile definition — declarative shape for one book profile. */
@@ -24,6 +24,7 @@ export const academicProfile = defineProfile({
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
27
+ exercises: false, // v4.4.0: opt-in per book; requires build-exercises
27
28
  },
28
29
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
29
30
  katex: true,
@@ -26,6 +26,7 @@ export const courseNotesProfile = defineProfile({
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
29
+ exercises: false, // v4.4.0: opt-in per book
29
30
  },
30
31
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
31
32
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
@@ -21,6 +21,7 @@ export const minimalProfile = defineProfile({
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
24
+ exercises: false, // v4.4.0: opt-in per book
24
25
  },
25
26
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
26
27
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
@@ -37,6 +37,7 @@ export const researchPortfolioProfile = defineProfile({
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
40
+ exercises: false, // v4.4.0: opt-in per book
40
41
  },
41
42
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
42
43
  katex: true, // math is common in research content
@@ -21,6 +21,7 @@ export const toolsProfile = defineProfile({
21
21
  convergence: true, // tools profile ships convergence dashboard
22
22
  frontmatter: false, // opt-in per book; see #7
23
23
  tips: false, // v4.3.0 #70: opt-in per book
24
+ exercises: false, // v4.4.0: opt-in per book
24
25
  },
25
26
  styles: [
26
27
  'tokens.css', 'layout.css', 'callouts.css', 'chapter.css',