@brandon_m_behring/book-scaffold-astro 4.1.2 → 4.3.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,102 @@
1
+ # Recipe 16 — TikZ figures (v4.2.0+)
2
+
3
+ `book-scaffold build-figures` (v4.2.0+) auto-compiles TikZ standalone `.tex` sources to PDF via `pdflatex`, then converts the PDF to SVG via the existing pdf2svg pipeline. Closes [#17](https://github.com/brandon-behring/book-scaffold-astro/issues/17).
4
+
5
+ ## TL;DR
6
+
7
+ Drop `figures/<topic>/diagram.tex` (standalone TikZ source). Run `npm run build:figures` (or it's wired into `prebuild`). Get `public/figures/<topic>/diagram.svg` ready to reference in MDX as `<Figure src="/figures/<topic>/diagram.svg" />`.
8
+
9
+ ## The TikZ source
10
+
11
+ Use the `standalone` document class; configure for SVG via `tikz` option. Recommended:
12
+
13
+ ```latex
14
+ \documentclass[tikz,border=2mm]{standalone}
15
+ \usepackage{tikz}
16
+ \usetikzlibrary{positioning, shapes, arrows} % whichever libraries you need
17
+ \begin{document}
18
+ \begin{tikzpicture}
19
+ \node (a) at (0,0) {Hello};
20
+ \node (b) at (3,0) {World};
21
+ \draw[->] (a) -- (b);
22
+ \end{tikzpicture}
23
+ \end{document}
24
+ ```
25
+
26
+ The `border=2mm` adds a small margin around the figure so it doesn't crop right at the edge.
27
+
28
+ ## Discovery rule
29
+
30
+ `build-figures` walks `figures/` (or `BOOK_FIGURES_PATH`) for both `.pdf` and `.tex` files. For each `.tex` source:
31
+
32
+ - If no sibling `.pdf` exists → compile.
33
+ - If `.pdf` exists but `.tex` is newer → recompile.
34
+ - If `.pdf` is newer than (or equal in mtime to) `.tex` → skip (use the existing PDF).
35
+
36
+ This means consumers who ship pre-compiled `.pdf` figures alongside their `.tex` sources don't pay the compilation cost on every build.
37
+
38
+ ## Working directory
39
+
40
+ `pdflatex` runs in `figures/<topic>/` (the directory containing the source). This makes TikZ `\input{}` relative paths work correctly and keeps intermediate files (`.aux`, `.log`) alongside the source for easy debugging.
41
+
42
+ ## Gitignore intermediate files
43
+
44
+ Add to your project's `.gitignore`:
45
+
46
+ ```gitignore
47
+ figures/**/*.aux
48
+ figures/**/*.log
49
+ figures/**/*.fdb_latexmk
50
+ figures/**/*.fls
51
+ figures/**/*.synctex.gz
52
+ ```
53
+
54
+ The intermediate `.pdf` files generated from `.tex` SHOULD be committed — they let consumers without TeX Live still see your figures (the SVGs still get generated from the committed PDFs).
55
+
56
+ ## Required: TeX Live install
57
+
58
+ `pdflatex` is a system dependency (not an npm package). Install:
59
+
60
+ - **macOS**: `brew install --cask mactex` (full) or `brew install --cask basictex` (minimal — add packages via `tlmgr`)
61
+ - **Ubuntu/Debian**: `sudo apt-get install texlive-base texlive-pictures` (minimal for TikZ)
62
+ - **Other**: https://www.tug.org/texlive/
63
+
64
+ If `pdflatex` is missing but `.tex` files are present, `build-figures` prints a clear ERROR with the install link and continues processing any `.pdf`-only topics. Doesn't crash the build.
65
+
66
+ ## CI workflow note
67
+
68
+ If your CI builds rely on regenerating SVGs from `.tex` sources (rather than just serving committed SVGs), add TeX Live to your CI workflow:
69
+
70
+ ```yaml
71
+ - name: Install TeX Live for TikZ figures
72
+ run: sudo apt-get install -y texlive-base texlive-pictures
73
+ ```
74
+
75
+ This adds ~200 MB to the runner — only do it if your figures actually change between commits. The recommended pattern is to commit both `.tex` AND the generated `.pdf`/`.svg`, treating regeneration as a local-dev concern.
76
+
77
+ ## Debugging compilation failures
78
+
79
+ When pdflatex fails on a `.tex` source, `build-figures` prints the stderr (and falls back to stdout) and continues with the remaining figures. Don't fail the whole build for one broken figure.
80
+
81
+ To debug interactively:
82
+
83
+ ```bash
84
+ cd figures/<topic>/
85
+ pdflatex diagram.tex
86
+ # read diagram.log for the full error trace
87
+ ```
88
+
89
+ Common failures:
90
+ - **Missing package**: `! LaTeX Error: File 'tikz-cd.sty' not found.` → install with `tlmgr install tikz-cd` (macOS/Linux Tex Live) or `sudo apt-get install texlive-tikz-cd` (Debian).
91
+ - **Syntax error**: `! Undefined control sequence.` → check the `.log` file for the line number.
92
+ - **Compilation hang**: should auto-resolve via `-halt-on-error -interaction=nonstopmode` flags the scaffold uses.
93
+
94
+ ## Feedback loop
95
+
96
+ If you hit friction with the TikZ pipeline (a TikZ feature that doesn't compile, an obscure error message, a workflow pattern that doesn't fit), file an issue at https://github.com/brandon-behring/book-scaffold-astro/issues with the `consumer:<your-workspace>` label. v4.x is the iteration window.
97
+
98
+ ## See also
99
+
100
+ - `recipes/06-figures.md` — overall figure pipeline + matplotlib/svg sources
101
+ - `PACKAGE_DESIGN.md §7` — peer dependencies (lists `pdflatex` as optional system dep)
102
+ - `PACKAGE_DESIGN.md §8` — `book-scaffold` CLI reference (build-figures subcommand)
@@ -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.
@@ -6,6 +6,13 @@
6
6
  * pdftocairo, emitting under public/figures/. SVG preserves zoom/quality
7
7
  * and stays small for matplotlib-style plots.
8
8
  *
9
+ * v4.2.0 (closes #17): TikZ standalone `.tex` sources are auto-compiled
10
+ * to `.pdf` via pdflatex before the existing PDF→SVG pass runs. Discovery
11
+ * rule: if `figures/<topic>/<name>.tex` exists AND no sibling `.pdf`
12
+ * (or `.tex` is newer than `.pdf`), pdflatex runs. Graceful skip when
13
+ * pdflatex is not on PATH (only emits ERROR if .tex sources exist).
14
+ * See recipes/16-tikz-figures.md for the workflow + install pointers.
15
+ *
9
16
  * Default source: figures/ at scaffold root. Override via BOOK_FIGURES_PATH
10
17
  * env var (absolute path or path relative to scaffold root) — useful for
11
18
  * books that share figures with a LaTeX sibling at e.g. ../shared/figures/.
@@ -98,6 +105,55 @@ async function listPdfsRecursive(root, prefix = '') {
98
105
  return out;
99
106
  }
100
107
 
108
+ /**
109
+ * v4.2.0 (#17): recursively collect .tex sources under FIGURES_SRC.
110
+ * Same shape as listPdfsRecursive but for `.tex` extension. TikZ
111
+ * standalone files at any subdirectory depth are picked up.
112
+ */
113
+ async function listTexRecursive(root, prefix = '') {
114
+ if (!existsSync(root)) return [];
115
+ const entries = await readdir(root, { withFileTypes: true });
116
+ const out = [];
117
+ for (const entry of entries) {
118
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
119
+ if (entry.isDirectory()) {
120
+ out.push(...(await listTexRecursive(resolve(root, entry.name), relPath)));
121
+ } else if (entry.isFile() && entry.name.endsWith('.tex')) {
122
+ out.push({ relPath });
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * v4.2.0 (#17): compile a TikZ standalone .tex to .pdf via pdflatex.
130
+ * Run in the source directory so TikZ \input{} relative paths resolve
131
+ * + intermediate .aux/.log files land alongside the source.
132
+ * Returns true on success; throws Error with stderr on failure.
133
+ */
134
+ function compileTikz(srcPath) {
135
+ const srcDir = dirname(srcPath);
136
+ const srcName = basename(srcPath);
137
+ const r = spawnSync(
138
+ 'pdflatex',
139
+ [
140
+ '-halt-on-error',
141
+ '-interaction=nonstopmode',
142
+ '-output-directory=.',
143
+ srcName,
144
+ ],
145
+ { cwd: srcDir, stdio: 'pipe' },
146
+ );
147
+ if (r.status !== 0) {
148
+ const stderr = (r.stderr ?? Buffer.from('')).toString().trim();
149
+ const stdout = (r.stdout ?? Buffer.from('')).toString().trim();
150
+ throw new Error(
151
+ `pdflatex failed for ${srcPath}: ${stderr || stdout || `exit code ${r.status}`}`,
152
+ );
153
+ }
154
+ return true;
155
+ }
156
+
101
157
  function isUpToDate(srcPath, dstPath) {
102
158
  if (!existsSync(dstPath)) return false;
103
159
  const srcMtime = statSync(srcPath).mtimeMs;
@@ -153,6 +209,38 @@ async function main() {
153
209
  return;
154
210
  }
155
211
 
212
+ // v4.2.0 (#17): stage 1 — compile any TikZ standalone .tex sources to .pdf
213
+ // BEFORE the PDF→SVG loop. Topic-walk discovers .tex files; compiles those
214
+ // where no sibling .pdf exists OR .tex is newer than .pdf.
215
+ const texSources = await listTexRecursive(FIGURES_SRC);
216
+ let tikzCompiled = 0;
217
+ if (texSources.length > 0) {
218
+ if (!check('pdflatex')) {
219
+ console.error(
220
+ `build-figures: pdflatex not on $PATH but ${texSources.length} ` +
221
+ `.tex source(s) detected. Install TeX Live to enable TikZ ` +
222
+ `compilation (see https://www.tug.org/texlive/). Skipping ` +
223
+ `.tex sources; pre-compiled .pdf siblings (if any) will still ` +
224
+ `be converted to SVG.`,
225
+ );
226
+ } else {
227
+ for (const { relPath } of texSources) {
228
+ const srcPath = resolve(FIGURES_SRC, relPath);
229
+ const pdfPath = srcPath.replace(/\.tex$/, '.pdf');
230
+ if (existsSync(pdfPath) && statSync(pdfPath).mtimeMs >= statSync(srcPath).mtimeMs) {
231
+ continue; // pdf is up-to-date
232
+ }
233
+ try {
234
+ compileTikz(srcPath);
235
+ tikzCompiled++;
236
+ } catch (err) {
237
+ console.error(`build-figures: ${err.message ?? err}`);
238
+ // Continue — don't fail the whole build on one broken .tex file.
239
+ }
240
+ }
241
+ }
242
+ }
243
+
156
244
  const pdfs = await listPdfsRecursive(FIGURES_SRC);
157
245
  if (pdfs.length === 0) {
158
246
  console.log('build-figures: no PDFs found; nothing to do.');
@@ -185,9 +273,10 @@ async function main() {
185
273
  converted++;
186
274
  }
187
275
 
276
+ const tikzNote = tikzCompiled > 0 ? `, ${tikzCompiled} tikz→pdf` : '';
188
277
  console.log(
189
278
  `build-figures: ${total} total, ${converted} converted ` +
190
- `(${pngFallback} png fallback), ${skipped} cached`,
279
+ `(${pngFallback} png fallback), ${skipped} cached${tikzNote}`,
191
280
  );
192
281
  }
193
282
 
@@ -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,14 @@ 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;
52
60
  }
53
61
 
54
62
  /** Profile definition — declarative shape for one book profile. */
@@ -23,6 +23,7 @@ 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
26
27
  },
27
28
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
28
29
  katex: true,
@@ -25,6 +25,7 @@ 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
28
29
  },
29
30
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
30
31
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
@@ -20,6 +20,7 @@ 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
23
24
  },
24
25
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
25
26
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
@@ -36,6 +36,7 @@ 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
39
40
  },
40
41
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
41
42
  katex: true, // math is common in research content
@@ -20,6 +20,7 @@ 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
23
24
  },
24
25
  styles: [
25
26
  'tokens.css', 'layout.css', 'callouts.css', 'chapter.css',