@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.
- package/bin/book-scaffold.mjs +3 -1
- package/components/Exercise.astro +24 -0
- package/components/ExerciseSolutions.astro +22 -0
- package/components/Practice.astro +30 -0
- package/components/Solution.astro +27 -0
- package/components/Tip.astro +28 -0
- package/components/TipsCard.astro +44 -0
- package/dist/index.d.ts +52 -3
- package/dist/index.mjs +35 -5
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +15 -5
- package/package.json +7 -1
- package/pages/chapters/[...slug].astro +40 -0
- package/pages/chapters.astro +1 -1
- package/pages/tips.astro +51 -0
- package/recipes/16-tikz-figures.md +102 -0
- package/recipes/17-draft-chapter-workflow.md +88 -0
- package/scripts/build-figures.mjs +90 -1
- package/scripts/build-tips.mjs +143 -0
- package/src/lib/define-tips.ts +56 -0
- package/src/profile-kit.ts +8 -0
- package/src/profiles/academic.ts +1 -0
- package/src/profiles/course-notes.ts +1 -0
- package/src/profiles/minimal.ts +1 -0
- package/src/profiles/research-portfolio.ts +1 -0
- package/src/profiles/tools.ts +1 -0
- package/styles/callouts.css +145 -0
|
@@ -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
|
+
}
|
package/src/profile-kit.ts
CHANGED
|
@@ -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. */
|
package/src/profiles/academic.ts
CHANGED
|
@@ -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
|
package/src/profiles/minimal.ts
CHANGED
|
@@ -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
|
package/src/profiles/tools.ts
CHANGED
|
@@ -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',
|