@brandon_m_behring/book-scaffold-astro 4.3.0 → 4.5.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,8 +1,24 @@
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-DLIpEgTm.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-DLIpEgTm.js';
4
4
  import 'astro/zod';
5
5
 
6
+ /**
7
+ * v4.5.0: Default portfolio backlink baked into the scaffold. Rendered in
8
+ * the auto-injected `/` landing footer for every consumer that doesn't
9
+ * explicitly override `portfolio` in defineBookConfig.
10
+ *
11
+ * Single source of truth for the brandon-behring.dev URL across all
12
+ * consumers — update here, bump scaffold version, every consumer inherits
13
+ * on next build. This is the intentional Brandon-specific default
14
+ * discussed in plan §Phase 6-pre. Consumers outside the brandon-behring.dev
15
+ * ecosystem set `portfolio: false` (no link) or pass `{ url, label }` to
16
+ * override.
17
+ */
18
+ declare const BRANDON_PORTFOLIO_DEFAULT: {
19
+ readonly url: "https://brandon-behring.dev";
20
+ readonly label: "brandon-behring.dev";
21
+ };
6
22
  declare function defineBookConfig(opts: BookConfigOptions): Promise<AstroUserConfig>;
7
23
 
8
24
  declare function bookScaffoldIntegration(opts: BookScaffoldIntegrationOptions): AstroIntegration;
@@ -244,4 +260,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
244
260
  */
245
261
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
246
262
 
247
- export { BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, Style, type TipsConfig, type TipsConfigInput, type VolatilityLevel, academicChaptersRenderer, academicStyle, bookScaffoldIntegration, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, researchPortfolioStyle, toolsChaptersRenderer, toolsStyle, volatilityLevels };
263
+ export { BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, Style, type TipsConfig, type TipsConfigInput, type VolatilityLevel, academicChaptersRenderer, academicStyle, bookScaffoldIntegration, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, researchPortfolioStyle, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -378,8 +378,12 @@ 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
385
+ landing: true
386
+ // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
383
387
  },
384
388
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
385
389
  katex: true,
@@ -494,8 +498,12 @@ var toolsProfile = defineProfile({
494
498
  // tools profile ships convergence dashboard
495
499
  frontmatter: false,
496
500
  // opt-in per book; see #7
497
- tips: false
501
+ tips: false,
498
502
  // v4.3.0 #70: opt-in per book
503
+ exercises: false,
504
+ // v4.4.0: opt-in per book
505
+ landing: true
506
+ // v4.5.0: auto-inject minimal root landing
499
507
  },
500
508
  styles: [
501
509
  "tokens.css",
@@ -574,8 +582,12 @@ var minimalProfile = defineProfile({
574
582
  convergence: false,
575
583
  frontmatter: false,
576
584
  // opt-in per book; see #7
577
- tips: false
585
+ tips: false,
578
586
  // v4.3.0 #70: opt-in per book
587
+ exercises: false,
588
+ // v4.4.0: opt-in per book
589
+ landing: true
590
+ // v4.5.0: auto-inject minimal root landing
579
591
  },
580
592
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
581
593
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
@@ -595,8 +607,12 @@ var courseNotesProfile = defineProfile({
595
607
  convergence: false,
596
608
  frontmatter: false,
597
609
  // opt-in per book; see #7
598
- tips: false
610
+ tips: false,
599
611
  // v4.3.0 #70: opt-in per book
612
+ exercises: false,
613
+ // v4.4.0: opt-in per book
614
+ landing: true
615
+ // v4.5.0: auto-inject minimal root landing
600
616
  },
601
617
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
602
618
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
@@ -617,8 +633,12 @@ var researchPortfolioProfile = defineProfile({
617
633
  // tools-profile-specific
618
634
  frontmatter: true,
619
635
  // portfolios universally need title/disclosure/banner pages
620
- tips: false
636
+ tips: false,
621
637
  // v4.3.0 #70: opt-in per book
638
+ exercises: false,
639
+ // v4.4.0: opt-in per book
640
+ landing: true
641
+ // v4.5.0: auto-inject minimal root landing
622
642
  },
623
643
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
624
644
  katex: true,
@@ -820,6 +840,14 @@ var ROUTE_REGISTRY = {
820
840
  // v4.3.0 (#70): cross-volume numbered-tips index. Opt-in via
821
841
  // routes.tips: true; pairs with build-tips script + <Tip> component.
822
842
  tips: { pattern: "/tips", file: "tips.astro" },
843
+ // v4.4.0: exercises index by chapter. Opt-in via routes.exercises: true;
844
+ // pairs with build-exercises script + <ExerciseSolutions auto /> mode.
845
+ exercises: { pattern: "/exercises", file: "exercises.astro" },
846
+ // v4.5.0: minimal root landing page. Reads title/description/portfolio/routes
847
+ // from vite.define-injected import.meta.env vars. Default-on per profile;
848
+ // consumers with their own src/pages/index.astro override (file-system route
849
+ // wins over injectRoute).
850
+ landing: { pattern: "/", file: "index.astro" },
823
851
  // v3.4.0 (#7): consumer-collection-backed frontmatter route. Opt-in via
824
852
  // routes: { frontmatter: true } AND content.config.ts defining the
825
853
  // collection (use frontmatterCollection() helper from /schemas subpath).
@@ -837,7 +865,16 @@ function resolvePage(file) {
837
865
  return fileURLToPath(new URL(`../pages/${file}`, import.meta.url));
838
866
  }
839
867
  function bookScaffoldIntegration(opts) {
840
- const { profile, routes: userOverrides = {}, extraStyles = [], mdxComponentsModule } = opts;
868
+ const {
869
+ profile,
870
+ routes: userOverrides = {},
871
+ extraStyles = [],
872
+ mdxComponentsModule,
873
+ // v4.5.0: landing-page data, propagated via vite.define to /index.astro.
874
+ title,
875
+ description,
876
+ portfolio
877
+ } = opts;
841
878
  const def = PROFILES[profile];
842
879
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
843
880
  const fmEnabled = fmNormalized?.enabled ?? def.routes.frontmatter;
@@ -878,12 +915,20 @@ function bookScaffoldIntegration(opts) {
878
915
  const consumerRoot = fileURLToPath(config.root);
879
916
  const resolvedMdxPath = resolveMdxComponentsPath(consumerRoot, mdxComponentsModule);
880
917
  const presetLiteral = JSON.stringify(profile);
918
+ const enabledRouteNames = Object.entries(enabledRoutes).filter(([, on]) => on).map(([name]) => name);
881
919
  updateConfig({
882
920
  vite: {
883
921
  plugins: [makeMdxComponentsVitePlugin(resolvedMdxPath)],
884
922
  define: {
885
923
  "import.meta.env.BOOK_PRESET": presetLiteral,
886
- "import.meta.env.BOOK_PROFILE": presetLiteral
924
+ "import.meta.env.BOOK_PROFILE": presetLiteral,
925
+ // v4.5.0: landing-page data. JSON.stringify on undefined → 'undefined'
926
+ // (which evaluates to JavaScript undefined at use site); on object →
927
+ // the JSON literal; on false → 'false'.
928
+ "import.meta.env.BOOK_TITLE": JSON.stringify(title ?? null),
929
+ "import.meta.env.BOOK_DESCRIPTION": JSON.stringify(description ?? null),
930
+ "import.meta.env.BOOK_PORTFOLIO": JSON.stringify(portfolio ?? null),
931
+ "import.meta.env.BOOK_ROUTES_ENABLED": JSON.stringify(enabledRouteNames)
887
932
  }
888
933
  }
889
934
  });
@@ -893,6 +938,10 @@ function bookScaffoldIntegration(opts) {
893
938
  }
894
939
 
895
940
  // src/config.ts
941
+ var BRANDON_PORTFOLIO_DEFAULT = {
942
+ url: "https://brandon-behring.dev",
943
+ label: "brandon-behring.dev"
944
+ };
896
945
  function v3MigrationError(opts) {
897
946
  const v3Value = opts.preset ?? opts.profile;
898
947
  const v3FieldUsed = "preset" in opts ? "preset" : "profile";
@@ -973,6 +1022,7 @@ async function defineBookConfig(opts) {
973
1022
  }
974
1023
  ]);
975
1024
  }
1025
+ const resolvedPortfolio = opts.portfolio === false ? false : opts.portfolio ?? BRANDON_PORTFOLIO_DEFAULT;
976
1026
  const integrations = [
977
1027
  mdx(),
978
1028
  preact(),
@@ -980,7 +1030,12 @@ async function defineBookConfig(opts) {
980
1030
  profile,
981
1031
  routes: mergedRoutes,
982
1032
  mdxComponentsModule,
983
- extraStyles: mergedExtraStyles
1033
+ extraStyles: mergedExtraStyles,
1034
+ // v4.5.0: pass landing-page data through to the integration so it can
1035
+ // be exposed to the auto-injected /index.astro via vite.define.
1036
+ title: opts.title,
1037
+ description: opts.description,
1038
+ portfolio: resolvedPortfolio
984
1039
  }),
985
1040
  ...mergedExtraIntegrations
986
1041
  ];
@@ -1015,6 +1070,10 @@ async function defineBookConfig(opts) {
1015
1070
  extraStyles: _extraStyles,
1016
1071
  markdown: _markdown,
1017
1072
  katexMacros: _katexMacros,
1073
+ // v4.5.0: strip new landing-related opts so they don't leak into AstroUserConfig.
1074
+ title: _title,
1075
+ description: _description,
1076
+ portfolio: _portfolio,
1018
1077
  ...rest
1019
1078
  } = opts;
1020
1079
  void _styles;
@@ -1026,6 +1085,9 @@ async function defineBookConfig(opts) {
1026
1085
  void _extraStyles;
1027
1086
  void _markdown;
1028
1087
  void _katexMacros;
1088
+ void _title;
1089
+ void _description;
1090
+ void _portfolio;
1029
1091
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1030
1092
  const config = {
1031
1093
  site,
@@ -1102,6 +1164,7 @@ function defineTips(opts) {
1102
1164
  export {
1103
1165
  BOOK_PRESETS,
1104
1166
  BOOK_PROFILES,
1167
+ BRANDON_PORTFOLIO_DEFAULT,
1105
1168
  BUILTIN_STYLES,
1106
1169
  BookConfigError,
1107
1170
  academicChapterSchema,
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-DLIpEgTm.js';
3
3
  import 'astro';
4
4
  import 'astro/zod';
5
5
 
package/dist/schemas.mjs CHANGED
@@ -263,8 +263,12 @@ 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
270
+ landing: true
271
+ // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
268
272
  },
269
273
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
270
274
  katex: true,
@@ -379,8 +383,12 @@ var toolsProfile = defineProfile({
379
383
  // tools profile ships convergence dashboard
380
384
  frontmatter: false,
381
385
  // opt-in per book; see #7
382
- tips: false
386
+ tips: false,
383
387
  // v4.3.0 #70: opt-in per book
388
+ exercises: false,
389
+ // v4.4.0: opt-in per book
390
+ landing: true
391
+ // v4.5.0: auto-inject minimal root landing
384
392
  },
385
393
  styles: [
386
394
  "tokens.css",
@@ -459,8 +467,12 @@ var minimalProfile = defineProfile({
459
467
  convergence: false,
460
468
  frontmatter: false,
461
469
  // opt-in per book; see #7
462
- tips: false
470
+ tips: false,
463
471
  // v4.3.0 #70: opt-in per book
472
+ exercises: false,
473
+ // v4.4.0: opt-in per book
474
+ landing: true
475
+ // v4.5.0: auto-inject minimal root landing
464
476
  },
465
477
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
466
478
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
@@ -480,8 +492,12 @@ var courseNotesProfile = defineProfile({
480
492
  convergence: false,
481
493
  frontmatter: false,
482
494
  // opt-in per book; see #7
483
- tips: false
495
+ tips: false,
484
496
  // v4.3.0 #70: opt-in per book
497
+ exercises: false,
498
+ // v4.4.0: opt-in per book
499
+ landing: true
500
+ // v4.5.0: auto-inject minimal root landing
485
501
  },
486
502
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
487
503
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
@@ -502,8 +518,12 @@ var researchPortfolioProfile = defineProfile({
502
518
  // tools-profile-specific
503
519
  frontmatter: true,
504
520
  // portfolios universally need title/disclosure/banner pages
505
- tips: false
521
+ tips: false,
506
522
  // v4.3.0 #70: opt-in per book
523
+ exercises: false,
524
+ // v4.4.0: opt-in per book
525
+ landing: true
526
+ // v4.5.0: auto-inject minimal root landing
507
527
  },
508
528
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
509
529
  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.5.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>
@@ -0,0 +1,111 @@
1
+ ---
2
+ /**
3
+ * /index — minimal default landing page (v4.5.0).
4
+ *
5
+ * Auto-injected by bookScaffoldIntegration when routes.landing === true
6
+ * (default for every profile). Consumers with their own src/pages/index.astro
7
+ * override automatically — file-system routes win over injectRoute, no extra
8
+ * config needed.
9
+ *
10
+ * Reads book identity + portfolio + enabled-routes from vite.define-injected
11
+ * env vars (see integration.ts §4.5.0). Renders:
12
+ * - h1 with book title (fallback: 'book-scaffold-astro')
13
+ * - lead paragraph with description (omitted if not set)
14
+ * - "Read" list of links to enabled scaffold routes (filtered to only
15
+ * routes the integration actually injected; respects routes.<x>: false)
16
+ * - portfolio backlink in the footer (omitted if portfolio: false)
17
+ *
18
+ * Override paths:
19
+ * - Disable entirely: defineBookConfig({ routes: { landing: false } })
20
+ * - Compose differently: create src/pages/index.astro in your consumer
21
+ * repo (file wins; this page never renders)
22
+ * - Change portfolio target: defineBookConfig({ portfolio: { url, label } })
23
+ * or defineBookConfig({ portfolio: false })
24
+ */
25
+ import Base from '../layouts/Base.astro';
26
+
27
+ // Vite-injected at build time. JSON.stringify(value ?? null) is the
28
+ // integration's convention; null means "not set", an object/string means real value.
29
+ const title = (import.meta.env.BOOK_TITLE as string | null) ?? 'book-scaffold-astro';
30
+ const description = import.meta.env.BOOK_DESCRIPTION as string | null;
31
+ const portfolio = import.meta.env.BOOK_PORTFOLIO as { url: string; label: string } | false | null;
32
+ const enabledRoutes = (import.meta.env.BOOK_ROUTES_ENABLED as string[] | undefined) ?? [];
33
+
34
+ // Map from internal route name → display label + URL. Only routes that
35
+ // produce a single landing-list entry are listed here (frontmatter is a
36
+ // slug pattern; chaptersSlug is dynamic; landing IS this page).
37
+ const ROUTE_LABELS: Record<string, { label: string; href: string }> = {
38
+ chapters: { label: 'Chapters', href: '/chapters/' },
39
+ search: { label: 'Search', href: '/search/' },
40
+ references: { label: 'References', href: '/references/' },
41
+ print: { label: 'Print view', href: '/print/' },
42
+ convergence: { label: 'Convergence', href: '/convergence/' },
43
+ tips: { label: 'Tips', href: '/tips/' },
44
+ exercises: { label: 'Exercises', href: '/exercises/' },
45
+ };
46
+
47
+ const visibleRoutes = enabledRoutes
48
+ .filter((name) => name in ROUTE_LABELS)
49
+ .map((name) => ROUTE_LABELS[name]!);
50
+ ---
51
+
52
+ <Base title={title} description={description ?? undefined}>
53
+ <article class="prose landing">
54
+ <h1>{title}</h1>
55
+ {description && <p class="lead">{description}</p>}
56
+
57
+ {visibleRoutes.length > 0 && (
58
+ <>
59
+ <h2 class="read-heading">Read</h2>
60
+ <ul class="route-list">
61
+ {visibleRoutes.map((r) => (
62
+ <li><a href={r.href}>{r.label}</a></li>
63
+ ))}
64
+ </ul>
65
+ </>
66
+ )}
67
+
68
+ {portfolio && portfolio !== false && (
69
+ <p class="portfolio-footer">
70
+ <small>
71
+ Part of <a href={portfolio.url}>{portfolio.label}</a>
72
+ </small>
73
+ </p>
74
+ )}
75
+ </article>
76
+ </Base>
77
+
78
+ <style>
79
+ .landing {
80
+ max-width: 60ch;
81
+ margin: 2rem auto;
82
+ }
83
+ .lead {
84
+ font-size: 1.1rem;
85
+ line-height: 1.5;
86
+ margin: 1.25rem 0 2.5rem 0;
87
+ }
88
+ .read-heading {
89
+ font-size: 0.85rem;
90
+ text-transform: uppercase;
91
+ letter-spacing: 0.08em;
92
+ color: var(--color-text-muted, #555);
93
+ margin-top: 2.5rem;
94
+ margin-bottom: 0.75rem;
95
+ }
96
+ .route-list {
97
+ list-style: none;
98
+ padding: 0;
99
+ margin: 0;
100
+ }
101
+ .route-list li {
102
+ padding: 0.4rem 0;
103
+ font-family: var(--font-sans, sans-serif);
104
+ font-size: 1rem;
105
+ }
106
+ .portfolio-footer {
107
+ margin-top: 3rem;
108
+ color: var(--color-text-muted, #555);
109
+ font-family: var(--font-sans, sans-serif);
110
+ }
111
+ </style>
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 };
@@ -41,6 +41,16 @@ export interface RouteToggles {
41
41
  print: boolean;
42
42
  chapters: boolean;
43
43
  convergence: boolean;
44
+ /**
45
+ * v4.5.0: auto-inject a minimal `/` landing page that reads the book's
46
+ * `title`, `description`, and `portfolio` from defineBookConfig and
47
+ * renders a route list (filtered to enabled routes). Defaults to `true`
48
+ * on every profile. Consumers with their own `src/pages/index.astro` keep
49
+ * their custom landing (file-system routes win over `injectRoute`). Set
50
+ * to `false` to suppress the auto-injection entirely without writing
51
+ * a custom landing.
52
+ */
53
+ landing: boolean;
44
54
  /**
45
55
  * v3.4.0 (closes #7): auto-inject `/frontmatter/[slug]/` rendering a
46
56
  * consumer-defined `frontmatter` content collection. Default `false` per
@@ -57,6 +67,14 @@ export interface RouteToggles {
57
67
  * defineBookConfig({ routes: { tips: true } }).
58
68
  */
59
69
  tips: boolean;
70
+ /**
71
+ * v4.4.0: auto-inject `/exercises` route listing all `<Exercise id="...">`
72
+ * instances from chapter MDX, grouped by chapter with deep links into
73
+ * the chapter routes. Requires running `book-scaffold build-exercises`
74
+ * (via prebuild) which emits src/data/exercises.json. Default `false` per
75
+ * profile — opt in via defineBookConfig({ routes: { exercises: true } }).
76
+ */
77
+ exercises: boolean;
60
78
  }
61
79
 
62
80
  /** Profile definition — declarative shape for one book profile. */
@@ -24,6 +24,8 @@ 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
28
+ landing: true, // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
27
29
  },
28
30
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
29
31
  katex: true,
@@ -26,6 +26,8 @@ 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
30
+ landing: true, // v4.5.0: auto-inject minimal root landing
29
31
  },
30
32
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
31
33
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
@@ -21,6 +21,8 @@ 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
25
+ landing: true, // v4.5.0: auto-inject minimal root landing
24
26
  },
25
27
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
26
28
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
@@ -37,6 +37,8 @@ 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
41
+ landing: true, // v4.5.0: auto-inject minimal root landing
40
42
  },
41
43
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
42
44
  katex: true, // math is common in research content
@@ -21,6 +21,8 @@ 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
25
+ landing: true, // v4.5.0: auto-inject minimal root landing
24
26
  },
25
27
  styles: [
26
28
  'tokens.css', 'layout.css', 'callouts.css', 'chapter.css',