@brandon_m_behring/book-scaffold-astro 4.2.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.
@@ -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,57 @@
1
+ ---
2
+ /**
3
+ * Auto-injected /tips route (v4.3.0, closes #70).
4
+ *
5
+ * Gated on `routes.tips: true`. Reads `src/data/tips.json` (emitted by
6
+ * `book-scaffold build-tips`); renders an ordered list with anchor
7
+ * permalinks (`/tips#tip-N`).
8
+ *
9
+ * Graceful skip: if tips.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. 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.';
28
+ ---
29
+ <Base title="Tips" description="Numbered tips from this book, drawn from <Tip> instances in chapters.">
30
+ <article class="prose">
31
+ <h1>Tips</h1>
32
+ {loadError && (
33
+ <p class="tips-empty">{loadError}</p>
34
+ )}
35
+ {!loadError && tips.length === 0 && (
36
+ <p class="tips-empty">No tips defined yet. Add <code>&lt;Tip n="1" title="..."&gt;...&lt;/Tip&gt;</code> to a chapter and rebuild.</p>
37
+ )}
38
+ {tips.length > 0 && (
39
+ <ol class="tips-index">
40
+ {tips.map((tip) => (
41
+ <li id={`tip-${tip.n}`} class="tips-index-item">
42
+ <h2 class="tips-index-title">
43
+ <span class="tips-index-number">Tip {tip.n}.</span>
44
+ {tip.title}
45
+ </h2>
46
+ {tip.preview && (
47
+ <p class="tips-index-preview">{tip.preview}</p>
48
+ )}
49
+ <p class="tips-index-meta">
50
+ From <a href={`/chapters/${tip.chapter}/`}>{tip.chapter}</a>
51
+ </p>
52
+ </li>
53
+ ))}
54
+ </ol>
55
+ )}
56
+ </article>
57
+ </Base>
@@ -0,0 +1,88 @@
1
+ # Recipe 17 — Draft chapter workflow (v4.3.0+)
2
+
3
+ The scaffold ships a `draft: boolean` field on every chapter schema (default `false`). Chapters with `draft: true` are filtered out by the canonical chapter-list and per-chapter routes — they exist in `src/content/chapters/` but don't render. Closes [#68](https://github.com/brandon-behring/2-scaffold-astro/issues/68).
4
+
5
+ ## TL;DR
6
+
7
+ ```yaml
8
+ ---
9
+ title: "My in-progress chapter"
10
+ week: 4
11
+ part: foundations
12
+ status: scaffolded
13
+ draft: true # ← Chapter is invisible while draft: true. Flip to false to publish.
14
+ ---
15
+ ```
16
+
17
+ ## The filter
18
+
19
+ Both the scaffold-injected `/chapters/` index AND the auto-injected per-chapter route `/chapters/[...slug]/` (new in v4.3.0; see [#69](https://github.com/brandon-behring/2-scaffold-astro/issues/69)) filter via:
20
+
21
+ ```ts
22
+ await getCollection('chapters', (entry) => !entry.data.draft);
23
+ ```
24
+
25
+ So a `draft: true` chapter:
26
+ - Does **not** appear in `/chapters/`
27
+ - Does **not** get a `/chapters/<slug>/` route
28
+ - Does **not** appear in nav / TOC
29
+ - DOES exist in the source tree (still validated by `book-scaffold validate`; still scanned by `book-scaffold build-labels`)
30
+
31
+ This is by design: keeps in-progress work under version control without polluting the production build.
32
+
33
+ ## Schema definition
34
+
35
+ All 5 built-in chapter schemas define `draft`:
36
+
37
+ ```ts
38
+ // package/src/schemas.ts (academic + tools + minimal + course-notes + research-portfolio)
39
+ draft: z.boolean().default(false),
40
+ ```
41
+
42
+ The default is `false` — chapters publish by default. Authors flip to `true` to hide a work-in-progress.
43
+
44
+ ## When to use draft
45
+
46
+ - **Active drafting** — writing a chapter over multiple sessions; don't want intermediate states deployed.
47
+ - **Outline-stage chapters** — frontmatter exists but body is just a TODO list.
48
+ - **Migration in progress** — porting from another source format (LaTeX, Docs, etc.); keep the partial port in-tree but invisible.
49
+
50
+ When you're ready to publish, edit the frontmatter to `draft: false` (or delete the line — default is false).
51
+
52
+ ## When NOT to use draft
53
+
54
+ - **Deleting a chapter** — if you no longer want it at all, delete the file. `draft: true` is for chapters that ARE coming back to live.
55
+ - **Reordering** — `draft` doesn't affect chapter ordering. To rearrange chapters, edit the `week:` / `part:` / `chapter:` frontmatter fields (the field varies by preset).
56
+ - **Section-level hiding** — `draft` is whole-chapter only. To hide a section within a published chapter, comment it out in MDX or use a build-time conditional.
57
+
58
+ ## Previewing draft chapters during development
59
+
60
+ The default canonical filter excludes drafts in BOTH `npm run dev` and `npm run build`. To preview a draft locally without publishing it:
61
+
62
+ **Option A** (transient): temporarily flip the chapter to `draft: false`, run `npm run dev`, then flip back before committing.
63
+
64
+ **Option B** (recommended): scope the filter to honor an env var in your `src/pages/chapters/[...slug].astro` (only relevant if you've ejected from the scaffold's auto-injected route):
65
+
66
+ ```ts
67
+ const includeDrafts = import.meta.env.BOOK_INCLUDE_DRAFTS === '1';
68
+ const chapters = await getCollection('chapters', (entry) =>
69
+ includeDrafts || !entry.data.draft
70
+ );
71
+ ```
72
+
73
+ Then run `BOOK_INCLUDE_DRAFTS=1 npm run dev` for the draft-inclusive preview. This is NOT shipped in the scaffold's auto-route (would muddy the production behavior); add it to a consumer-owned override file if you need it.
74
+
75
+ ## Common gotchas
76
+
77
+ - **Silent zero-chapters build** — if EVERY chapter has `draft: true` (or no chapters are published yet), `/chapters/` renders an empty list and no `/chapters/<slug>/` routes generate. Build succeeds with no warnings. Diagnosis: check `npx book-scaffold validate` output — it reports the total `chapter(s) checked` regardless of draft status, so you'll see "5 chapter(s) checked" even when no chapters render.
78
+ - **Author's intent vs filter behavior** — early Phase-1 chapter authoring sessions sometimes catch this: chapter is fully written, frontmatter looks right, but it doesn't render. First check: `grep '^draft:' src/content/chapters/*.mdx` to find drafts. Time-to-diagnosis tax averages ~20 min per consumer per session until they remember the filter exists. This recipe is the discoverability fix.
79
+
80
+ ## See also
81
+
82
+ - `recipes/01-create-book.md` — full scaffold getting-started flow
83
+ - `recipes/09-validation.md` — `book-scaffold validate` semantics (which DON'T honor the draft filter)
84
+ - `PACKAGE_DESIGN.md §5` — `defineBookSchemas` API (where the draft field lives)
85
+
86
+ ## Feedback
87
+
88
+ If the filter behavior surprises you in a new way or you want a different draft-preview UX (e.g., a `BOOK_INCLUDE_DRAFTS` env in the scaffold's auto-route by default), file an issue at https://github.com/brandon-behring/book-scaffold-astro/issues with the `consumer:<workspace>` label.
@@ -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 };
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/build-tips.mjs — emit src/data/tips.json from <Tip> instances
4
+ * in chapter MDX (v4.3.0, closes #70).
5
+ *
6
+ * Scans `src/content/chapters/**\/*.mdx` (honoring loader.base via
7
+ * readChaptersBase from walk-mdx.mjs — same path-resolution as build-labels +
8
+ * validate). Extracts `<Tip n="N" title="T">body</Tip>` occurrences via regex
9
+ * (same approach as build-labels.mjs LABELABLE_TYPES extraction). Emits an
10
+ * array sorted by n.
11
+ *
12
+ * Output shape:
13
+ * [
14
+ * { "n": 1, "title": "...", "chapter": "ch-slug", "preview": "first 80 chars" },
15
+ * ...
16
+ * ]
17
+ *
18
+ * Graceful no-op: if no <Tip> instances exist, writes [] (doesn't fail
19
+ * builds for consumers who don't use the feature).
20
+ *
21
+ * Run on `prebuild` via the consumer's package.json. Doesn't depend on
22
+ * Astro virtual modules — pure regex + Node fs.
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-tips
29
+
30
+ Scan chapter MDX for <Tip n="N" title="T">body</Tip> occurrences; emit
31
+ src/data/tips.json sorted by n. Used by the /tips auto-route + <TipsCard>
32
+ component when routes.tips: true.
33
+
34
+ Env:
35
+ BOOK_CHAPTERS_DIR Override chapters dir (default: src/content/chapters).
36
+ BOOK_TIPS_OUT Override output path (default: src/data/tips.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_TIPS_OUT ?? 'src/data/tips.json';
50
+
51
+ /**
52
+ * Extract <Tip n="..." title="..."> tags and their body content from MDX.
53
+ *
54
+ * Regex captures:
55
+ * - n: the tip number (string; coerced to int when writing JSON)
56
+ * - title: the tip title (string)
57
+ * - body: text between opening + closing tags
58
+ *
59
+ * Limitations (deliberate, documented):
60
+ * - Doesn't handle nested <Tip> tags (no real use case)
61
+ * - String attributes must use single OR double quotes (not template literals)
62
+ * - title may not contain a literal quote of the same type used as delimiter
63
+ * (escaping isn't supported)
64
+ * - body is captured raw; first 80 chars (ignoring leading whitespace) used as preview
65
+ */
66
+ function extractTips(source, chapterSlug) {
67
+ const tips = [];
68
+ // Two-branch alternation (single OR double quotes), no backreference —
69
+ // same portability pattern as readChaptersBase regex (v4.1.2 lesson).
70
+ const re = new RegExp(
71
+ [
72
+ // double-quoted attrs
73
+ `<Tip\\s+n="([^"]+)"\\s+title="([^"]+)"\\s*>([\\s\\S]*?)</Tip>`,
74
+ // single-quoted attrs
75
+ `<Tip\\s+n='([^']+)'\\s+title='([^']+)'\\s*>([\\s\\S]*?)</Tip>`,
76
+ // mixed (n double, title single) — rare but support it
77
+ `<Tip\\s+n="([^"]+)"\\s+title='([^']+)'\\s*>([\\s\\S]*?)</Tip>`,
78
+ // mixed (n single, title double)
79
+ `<Tip\\s+n='([^']+)'\\s+title="([^"]+)"\\s*>([\\s\\S]*?)</Tip>`,
80
+ ].join('|'),
81
+ 'g',
82
+ );
83
+ for (const match of source.matchAll(re)) {
84
+ // One of the 4 alternation branches matched; locate captures.
85
+ const [, n1, t1, b1, n2, t2, b2, n3, t3, b3, n4, t4, b4] = match;
86
+ const n = n1 || n2 || n3 || n4;
87
+ const title = t1 || t2 || t3 || t4;
88
+ const body = b1 || b2 || b3 || b4 || '';
89
+ if (!n || !title) continue;
90
+ const nNum = Number.parseInt(n, 10);
91
+ if (!Number.isFinite(nNum)) continue;
92
+ const preview = body
93
+ .trim()
94
+ .replace(/\s+/g, ' ')
95
+ .slice(0, 80);
96
+ tips.push({
97
+ n: nNum,
98
+ title,
99
+ chapter: chapterSlug,
100
+ preview,
101
+ });
102
+ }
103
+ return tips;
104
+ }
105
+
106
+ async function main() {
107
+ const allTips = [];
108
+ for await (const rel of walkMdx(CHAPTERS_DIR)) {
109
+ const chapterPath = resolve(CHAPTERS_DIR, rel);
110
+ const chapterSlug = basename(rel).replace(/\.mdx?$/, '');
111
+ let source;
112
+ try {
113
+ source = await readFile(chapterPath, 'utf8');
114
+ } catch {
115
+ continue;
116
+ }
117
+ allTips.push(...extractTips(source, chapterSlug));
118
+ }
119
+
120
+ // Sort by n; warn on duplicates (don't fail — the index page just shows duplicates).
121
+ allTips.sort((a, b) => a.n - b.n);
122
+ const seenN = new Set();
123
+ for (const tip of allTips) {
124
+ if (seenN.has(tip.n)) {
125
+ process.stderr.write(`build-tips: WARN duplicate Tip n="${tip.n}" (last wins on /tips index)\n`);
126
+ }
127
+ seenN.add(tip.n);
128
+ }
129
+
130
+ const outPath = resolve(CWD, OUTPUT_PATH);
131
+ await mkdir(dirname(outPath), { recursive: true });
132
+ await writeFile(outPath, JSON.stringify(allTips, null, 2) + '\n');
133
+ process.stdout.write(`build-tips: ${allTips.length} tip${allTips.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`);
134
+ }
135
+
136
+ main().catch((err) => {
137
+ console.error('build-tips: failed');
138
+ console.error(err.message ?? err);
139
+ process.exit(1);
140
+ });
141
+
142
+ // Export for tests.
143
+ export { extractTips };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * src/lib/define-tips.ts — `defineTips()` API for cross-volume tip registry
3
+ * (v4.3.0, closes #70).
4
+ *
5
+ * Pragmatic Programmer-style numbered tips can be distributed across multiple
6
+ * volumes (e.g., Handbook tips 1-25, Architect's Reference 26-40, Field-Guide
7
+ * 41-50). Authors write `<Tip n="14" ...>` with explicit numbers; defineTips()
8
+ * lets per-volume books offset their displayed numbers + label without
9
+ * renumbering source tags.
10
+ *
11
+ * Branded type follows the same convention as `defineStyle` (v4.0.0 D6):
12
+ * type-only `unique symbol` brand, closed shape, readonly fields, no public
13
+ * index signature. Consumer-side metadata goes in scoped `extra` if needed.
14
+ */
15
+
16
+ // ===== Branded nominal type =====
17
+
18
+ declare const TipsConfigBrand: unique symbol;
19
+
20
+ export interface TipsConfig {
21
+ /** Type-only brand for nominal typing. Set automatically by defineTips. */
22
+ readonly [TipsConfigBrand]: true;
23
+ /** Internal version marker; auto-set to 1 by defineTips. */
24
+ readonly __tipsConfigVersion: 1;
25
+ /** Display offset added to each `<Tip n="N">` for cross-volume coordination.
26
+ * Example: Vol B with volumeOffset=25 renders `<Tip n="1">` as "Tip 26". */
27
+ readonly volumeOffset?: number;
28
+ /** Optional label shown alongside tip numbers in the /tips index + TipsCard.
29
+ * Example: "Vol B" → "Vol B Tip 26". */
30
+ readonly volumeLabel?: string;
31
+ /** Scoped consumer-side metadata (matches defineStyle pattern). */
32
+ readonly extra?: Readonly<Record<string, unknown>>;
33
+ }
34
+
35
+ /** Input type for defineTips — omits the auto-set internal fields. */
36
+ export type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVersion'>;
37
+
38
+ /**
39
+ * Identity helper that creates a typed, branded TipsConfig.
40
+ * Zero runtime overhead beyond an object spread + version marker.
41
+ *
42
+ * Usage:
43
+ *
44
+ * import { defineTips } from '@brandon_m_behring/book-scaffold-astro';
45
+ *
46
+ * export const tipsConfig = defineTips({
47
+ * volumeOffset: 25,
48
+ * volumeLabel: 'Vol B',
49
+ * });
50
+ *
51
+ * Consumed by `<Tip>` and `<TipsCard>` components + the auto-injected
52
+ * `/tips` route to compute display numbers from `<Tip n="N">` source tags.
53
+ */
54
+ export function defineTips(opts: TipsConfigInput): TipsConfig {
55
+ return { __tipsConfigVersion: 1, ...opts } as TipsConfig;
56
+ }
@@ -49,6 +49,22 @@ export interface RouteToggles {
49
49
  * If enabled without defining the collection, Astro errors clearly at build.
50
50
  */
51
51
  frontmatter: boolean;
52
+ /**
53
+ * v4.3.0 (closes #70): auto-inject `/tips` route listing all numbered
54
+ * `<Tip>` instances from chapter MDX. Requires running
55
+ * `book-scaffold build-tips` (via prebuild) which emits src/data/tips.json.
56
+ * Default `false` per profile — opt in via
57
+ * defineBookConfig({ routes: { tips: true } }).
58
+ */
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;
52
68
  }
53
69
 
54
70
  /** Profile definition — declarative shape for one book profile. */
@@ -23,6 +23,8 @@ export const academicProfile = defineProfile({
23
23
  chapters: false, // academic consumers ship their own week-based /chapters listing
24
24
  convergence: false, // tools-profile-specific
25
25
  frontmatter: false, // opt-in per book; see #7
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
26
28
  },
27
29
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
28
30
  katex: true,
@@ -25,6 +25,8 @@ export const courseNotesProfile = defineProfile({
25
25
  chapters: false, // multi-book consumers route via [book]/[slug] themselves
26
26
  convergence: false,
27
27
  frontmatter: false, // opt-in per book; see #7
28
+ tips: false, // v4.3.0 #70: opt-in per book
29
+ exercises: false, // v4.4.0: opt-in per book
28
30
  },
29
31
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
30
32
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
@@ -20,6 +20,8 @@ export const minimalProfile = defineProfile({
20
20
  chapters: false,
21
21
  convergence: false,
22
22
  frontmatter: false, // opt-in per book; see #7
23
+ tips: false, // v4.3.0 #70: opt-in per book
24
+ exercises: false, // v4.4.0: opt-in per book
23
25
  },
24
26
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
25
27
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
@@ -36,6 +36,8 @@ export const researchPortfolioProfile = defineProfile({
36
36
  chapters: false, // portfolio books ship their own landing/index
37
37
  convergence: false, // tools-profile-specific
38
38
  frontmatter: true, // portfolios universally need title/disclosure/banner pages
39
+ tips: false, // v4.3.0 #70: opt-in per book
40
+ exercises: false, // v4.4.0: opt-in per book
39
41
  },
40
42
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
41
43
  katex: true, // math is common in research content
@@ -20,6 +20,8 @@ export const toolsProfile = defineProfile({
20
20
  chapters: true, // tools profile ships a flat chapter index
21
21
  convergence: true, // tools profile ships convergence dashboard
22
22
  frontmatter: false, // opt-in per book; see #7
23
+ tips: false, // v4.3.0 #70: opt-in per book
24
+ exercises: false, // v4.4.0: opt-in per book
23
25
  },
24
26
  styles: [
25
27
  'tokens.css', 'layout.css', 'callouts.css', 'chapter.css',