@brandon_m_behring/book-scaffold-astro 3.0.0-alpha.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.
Files changed (84) hide show
  1. package/CLAUDE.md +179 -0
  2. package/bin/book-scaffold.mjs +61 -0
  3. package/components/CaseStudy.astro +36 -0
  4. package/components/ChapterHeader.astro +61 -0
  5. package/components/ChapterNav.astro +29 -0
  6. package/components/ChapterTOC.astro +33 -0
  7. package/components/Citation.astro +94 -0
  8. package/components/Cite.astro +71 -0
  9. package/components/CodeBlock.astro +115 -0
  10. package/components/CodeRef.astro +49 -0
  11. package/components/ConceptBox.astro +26 -0
  12. package/components/Convergence.astro +41 -0
  13. package/components/CounterBox.astro +15 -0
  14. package/components/Divergence.astro +32 -0
  15. package/components/DynConnect.astro +15 -0
  16. package/components/ExampleBox.astro +15 -0
  17. package/components/Figure.astro +35 -0
  18. package/components/InsightBox.astro +15 -0
  19. package/components/KeyIdea.astro +21 -0
  20. package/components/MarginNote.astro +37 -0
  21. package/components/NoteBox.astro +15 -0
  22. package/components/OpenQuestion.astro +15 -0
  23. package/components/PaperBox.astro +15 -0
  24. package/components/PatternTimeline.astro +133 -0
  25. package/components/Recovery.astro +34 -0
  26. package/components/ResultBox.astro +15 -0
  27. package/components/Sidebar.astro +268 -0
  28. package/components/Sidenote.astro +26 -0
  29. package/components/SkillBox.astro +24 -0
  30. package/components/SourceArchive.astro +285 -0
  31. package/components/StatusBadge.astro +51 -0
  32. package/components/Tag.astro +60 -0
  33. package/components/Theorem.astro +65 -0
  34. package/components/TipBox.astro +15 -0
  35. package/components/ToolFilter.tsx +160 -0
  36. package/components/TryThis.astro +23 -0
  37. package/components/VersionSelector.tsx +85 -0
  38. package/components/WarnBox.astro +15 -0
  39. package/components/WeekRef.astro +51 -0
  40. package/components/XRef.astro +40 -0
  41. package/dist/index.d.ts +135 -0
  42. package/dist/index.mjs +369 -0
  43. package/dist/lib/katex-macros.d.ts +26 -0
  44. package/dist/lib/katex-macros.mjs +98 -0
  45. package/dist/schemas.d.ts +17 -0
  46. package/dist/schemas.mjs +160 -0
  47. package/dist/types-Cz-pwE1N.d.ts +61 -0
  48. package/examples/chapter-template-academic.mdx +100 -0
  49. package/examples/chapter-template-tools.mdx +90 -0
  50. package/layouts/Base.astro +250 -0
  51. package/layouts/Chapter.astro +37 -0
  52. package/package.json +137 -0
  53. package/pages/chapters.astro +371 -0
  54. package/pages/convergence.astro +96 -0
  55. package/pages/print.astro +39 -0
  56. package/pages/references.astro +160 -0
  57. package/pages/search.astro +87 -0
  58. package/pedagogy/kf-chapter-shape.md +96 -0
  59. package/pedagogy/source-tiers.md +121 -0
  60. package/pedagogy/volatility-classes.md +110 -0
  61. package/recipes/00-getting-started.md +77 -0
  62. package/recipes/01-add-math.md +71 -0
  63. package/recipes/02-bibliography-pipeline.md +82 -0
  64. package/recipes/03-asset-pipelines.md +84 -0
  65. package/recipes/04-component-library.md +118 -0
  66. package/recipes/05-deploy-cloudflare.md +74 -0
  67. package/recipes/06-mobile-first-layout.md +73 -0
  68. package/recipes/07-chapter-shapes.md +84 -0
  69. package/recipes/08-decisions-ledger.md +110 -0
  70. package/recipes/09-validation.md +106 -0
  71. package/recipes/10-custom-domain.md +72 -0
  72. package/recipes/README.md +43 -0
  73. package/scripts/build-bib.mjs +99 -0
  74. package/scripts/build-figures.mjs +179 -0
  75. package/scripts/render-notebooks.mjs +223 -0
  76. package/scripts/validate.mjs +179 -0
  77. package/styles/callouts.css +303 -0
  78. package/styles/chapter.css +209 -0
  79. package/styles/convergence.css +349 -0
  80. package/styles/layout.css +156 -0
  81. package/styles/print.css +203 -0
  82. package/styles/tokens.css +194 -0
  83. package/styles/tool-filter.css +135 -0
  84. package/styles/typography.css +147 -0
@@ -0,0 +1,72 @@
1
+ # Recipe 10 — Custom domain on Cloudflare
2
+
3
+ **Profile**: any (deploy is profile-agnostic).
4
+
5
+ **TL;DR**: Free, ~5 minutes in the dashboard. Cloudflare auto-issues a TLS cert. Works for both apex (`my-book.com`) and subdomain (`book.my-site.com`).
6
+
7
+ ## Prerequisites
8
+
9
+ - A working `wrangler.toml` deploy (recipe 05). URL: `https://<book-name>.<account>.workers.dev`.
10
+ - A domain you own. Either on Cloudflare DNS already, or willing to add an NS record at your registrar.
11
+ - ~5 minutes of dashboard time.
12
+
13
+ ## Path 1 — Domain already on Cloudflare DNS
14
+
15
+ This is the smoothest path.
16
+
17
+ 1. **Dashboard → Workers & Pages → your-book-name → Settings → Custom Domains → Add Custom Domain.**
18
+ 2. Type the domain (`my-book.com` for apex, or `book.my-site.com` for subdomain).
19
+ 3. Cloudflare auto-provisions:
20
+ - DNS record (CNAME for subdomain, AAAA + A for apex)
21
+ - TLS cert via Cloudflare's CA
22
+ 4. Wait ~30 seconds for propagation. Visit the domain in a fresh tab.
23
+
24
+ That's the whole flow. The domain becomes the canonical URL; the `.workers.dev` URL still works as a fallback.
25
+
26
+ ## Path 2 — Domain at an external registrar
27
+
28
+ Move DNS to Cloudflare first, then use Path 1. Two sub-steps:
29
+
30
+ 1. **Dashboard → Websites → Add a Site → Free plan.** Enter your domain.
31
+ 2. Cloudflare scans current DNS and shows the records. **Cloudflare then gives you 2 nameservers.**
32
+ 3. **At your registrar**, replace the NS records with Cloudflare's 2 nameservers. Save.
33
+ 4. Wait for propagation (15 min – 24 h, usually <1 hour). Cloudflare emails you when active.
34
+ 5. Once active, follow Path 1.
35
+
36
+ ## Apex vs subdomain
37
+
38
+ - **Apex (`my-book.com`)**: Cloudflare uses CNAME flattening. Apex with Workers + Static Assets is well-supported; no special config needed.
39
+ - **Subdomain (`book.my-site.com`)**: a plain CNAME record. The rest of `my-site.com` keeps whatever DNS it had.
40
+
41
+ ## Removing the `.workers.dev` URL
42
+
43
+ The `<book-name>.<account>.workers.dev` URL keeps working alongside the custom domain. To disable it:
44
+
45
+ **Dashboard → Workers & Pages → your-book-name → Settings → Domains & Routes → workers.dev → toggle off.**
46
+
47
+ Most authors leave it on as a fallback / debugging surface.
48
+
49
+ ## Common gotchas
50
+
51
+ - **"Site not active" after adding the domain**: NS records still propagating. Wait. `dig +short NS my-book.com` should return Cloudflare's nameservers when active.
52
+ - **TLS handshake failures for the first ~60 seconds**: cert issuance takes a moment after DNS resolves. Retry.
53
+ - **Mixed-content warnings**: rare with a static Astro build, but if you embedded any `http://` image URLs they'll fail under TLS. Use `//` or `https://`.
54
+ - **Cache TTL too long**: Cloudflare's default cache for Workers + Static Assets is 4 hours for the HTML and 1 year for fingerprinted assets. After redeploying, the HTML may serve stale for a few minutes. Purge: **Dashboard → Caching → Configuration → Purge Everything**.
55
+
56
+ ## Sub-paths
57
+
58
+ Workers + Static Assets is best for whole-domain sites. If you want a book at `my-site.com/book/` and other content at `my-site.com/`, two options:
59
+
60
+ - Run two separate Workers, one per path, and use Cloudflare's Routes feature in `wrangler.toml`.
61
+ - Use Cloudflare's Path-based-routing in Workers — more setup, see Cloudflare docs.
62
+
63
+ Most authors prefer apex or subdomain.
64
+
65
+ ## Canonical files
66
+
67
+ - `wrangler.toml` — name + assets config (recipe 05)
68
+ - Cloudflare dashboard — where the custom domain is wired up
69
+
70
+ ## Reference
71
+
72
+ post-transformers ships at `post-transformers-guide.brandon-m-behring.workers.dev` (the default Workers subdomain). Custom domain not yet attached; the workflow above is the documented path for when it is.
@@ -0,0 +1,43 @@
1
+ # Recipes
2
+
3
+ Terse pointers into canonical code for the most common book-authoring workflows. Each recipe is 50–100 lines; the canonical implementation lives in the scaffold itself.
4
+
5
+ ## Index
6
+
7
+ | # | Recipe | Profile | What it covers |
8
+ |---|---|---|---|
9
+ | 00 | [Getting started](00-getting-started.md) | any | Pick a profile, bootstrap, customize, deploy |
10
+ | 01 | [Add math (KaTeX)](01-add-math.md) | academic | KaTeX 36-macro library, strict mode, `$x^2$` and `$$\int$$` |
11
+ | 02 | [Bibliography pipeline](02-bibliography-pipeline.md) | academic | BibTeX → `references.json` via citation-js |
12
+ | 03 | [Asset pipelines](03-asset-pipelines.md) | any | Figures (PDF→SVG) + notebooks (ipynb→HTML), graceful-skip |
13
+ | 04 | [Component library](04-component-library.md) | any | Two callout families, Theorem, utility components |
14
+ | 05 | [Deploy to Cloudflare](05-deploy-cloudflare.md) | any | Workers + Static Assets via `wrangler.toml` |
15
+ | 06 | [Mobile-first layout](06-mobile-first-layout.md) | any | Three-tier Tufte width + left sidebar |
16
+ | 07 | [Chapter shapes](07-chapter-shapes.md) | profile-aware | Week-based vs Koller-Friedman skeletons |
17
+ | 08 | [Decisions ledger](08-decisions-ledger.md) | any | 15 design decisions explained |
18
+ | 09 | [Pre-flight validation](09-validation.md) | any | `validate.mjs` catches bibkey/XRef/Figure typos |
19
+ | 10 | [Custom domain](10-custom-domain.md) | any | Cloudflare dashboard, apex vs subdomain |
20
+
21
+ ## How to read recipes
22
+
23
+ Each recipe follows the same shape (per decisions ledger D13 — Q11 locked):
24
+
25
+ 1. **Profile** — when this applies
26
+ 2. **TL;DR** — single-paragraph summary
27
+ 3. **Sections** with concrete commands and file paths
28
+ 4. **Common gotchas** — failure modes the recipe maintainer hit
29
+ 5. **Canonical files** — line refs into the scaffold for full code
30
+ 6. **Reference implementation** — a real book using this pattern
31
+
32
+ Recipes don't repeat what the code says — they explain *why* the code is shaped that way, *when* to deviate, and *where to look*.
33
+
34
+ ## How to add a recipe
35
+
36
+ When you discover a non-obvious pattern worth preserving:
37
+
38
+ 1. Pick a recipe number (next available unused number).
39
+ 2. Copy `recipes/00-getting-started.md` as a starting template.
40
+ 3. Add the recipe to the index above.
41
+ 4. Open a PR or commit directly.
42
+
43
+ Recipes are cheap to write and cheap to maintain. The cost-per-line is low because canonical code lives elsewhere; the recipe is just signal-posts.
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/build-bib.mjs — Bibliography pipeline (academic profile).
4
+ *
5
+ * Reads bibliography.bib at scaffold root (BibTeX), parses via @citation-js,
6
+ * emits src/data/references.json keyed by bibkey. The .bib path is
7
+ * overridable via BOOK_BIB_PATH env var for books that keep their .bib
8
+ * elsewhere (e.g. a shared `guides/shared/references.bib` outside the
9
+ * Astro project — the post_transformers pattern).
10
+ *
11
+ * Run on `prebuild` so every Astro build sees fresh bibliography data.
12
+ * Idempotent: re-running with no .bib change produces a byte-identical
13
+ * output (modulo timestamp, which we omit).
14
+ *
15
+ * Output shape:
16
+ * {
17
+ * "gu2024mamba": {
18
+ * "id": "gu2024mamba",
19
+ * "type": "article-journal",
20
+ * "title": "...",
21
+ * "author": [{ "family": "Gu", "given": "Albert" }, ...],
22
+ * "issued": { "date-parts": [[2024, 1]] },
23
+ * ...
24
+ * },
25
+ * ...
26
+ * }
27
+ *
28
+ * Build fails (non-zero exit) on:
29
+ * - references.bib not readable
30
+ * - duplicate bibkeys (citation-js silently overwrites; we surface)
31
+ * - parse errors on any entry (citation-js continues; we fail)
32
+ */
33
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
34
+ import { dirname, resolve } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { Cite } from '@citation-js/core';
37
+ import '@citation-js/plugin-bibtex';
38
+
39
+ const __dirname = dirname(fileURLToPath(import.meta.url));
40
+ const PROJECT_ROOT = resolve(__dirname, '..');
41
+
42
+ // Default: bibliography.bib at scaffold root.
43
+ // Override via BOOK_BIB_PATH=path/to/your.bib (absolute or relative to cwd).
44
+ const BIB_PATH = process.env.BOOK_BIB_PATH
45
+ ? resolve(process.cwd(), process.env.BOOK_BIB_PATH)
46
+ : resolve(PROJECT_ROOT, 'bibliography.bib');
47
+ const OUT_PATH = resolve(PROJECT_ROOT, 'src/data/references.json');
48
+
49
+ async function main() {
50
+ // Graceful skip when the .bib file is absent (minimal/tools profile, or
51
+ // an academic book that hasn't authored citations yet). Emits an empty
52
+ // references.json so consumers can still `import refs from '...'`.
53
+ let bibText;
54
+ try {
55
+ bibText = await readFile(BIB_PATH, 'utf8');
56
+ } catch (err) {
57
+ if (err.code === 'ENOENT') {
58
+ console.log(
59
+ `build-bib: ${BIB_PATH.replace(PROJECT_ROOT + '/', '')} not found — ` +
60
+ `emitting empty references.json (no citations to process).`,
61
+ );
62
+ await mkdir(dirname(OUT_PATH), { recursive: true });
63
+ await writeFile(OUT_PATH, '{}\n', 'utf8');
64
+ return;
65
+ }
66
+ throw err;
67
+ }
68
+ const cite = new Cite(bibText);
69
+ const data = cite.data;
70
+
71
+ // Detect duplicates the way biber would (citation-js silently
72
+ // overwrites the earlier entry, which is the opposite of what we want).
73
+ const seen = new Set();
74
+ const dupes = [];
75
+ for (const entry of data) {
76
+ if (seen.has(entry.id)) dupes.push(entry.id);
77
+ seen.add(entry.id);
78
+ }
79
+ if (dupes.length > 0) {
80
+ console.error(`build-bib: ${dupes.length} duplicate bibkeys:`);
81
+ for (const id of dupes) console.error(` - ${id}`);
82
+ process.exit(1);
83
+ }
84
+
85
+ const byKey = Object.fromEntries(data.map((entry) => [entry.id, entry]));
86
+
87
+ await mkdir(dirname(OUT_PATH), { recursive: true });
88
+ await writeFile(OUT_PATH, JSON.stringify(byKey, null, 2) + '\n', 'utf8');
89
+
90
+ console.log(
91
+ `build-bib: ${data.length} entries -> ${OUT_PATH.replace(PROJECT_ROOT + '/', '')}`,
92
+ );
93
+ }
94
+
95
+ main().catch((err) => {
96
+ console.error(`build-bib: failed`);
97
+ console.error(err);
98
+ process.exit(1);
99
+ });
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/build-figures.mjs — Figure pipeline.
4
+ *
5
+ * Walks the figures source tree and converts every PDF to SVG via
6
+ * pdftocairo, emitting under public/figures/. SVG preserves zoom/quality
7
+ * and stays small for matplotlib-style plots.
8
+ *
9
+ * Default source: figures/ at scaffold root. Override via BOOK_FIGURES_PATH
10
+ * env var (absolute path or path relative to scaffold root) — useful for
11
+ * books that share figures with a LaTeX sibling at e.g. ../shared/figures/.
12
+ *
13
+ * Subdirectory structure is mirrored to public/figures/ (e.g. figures/foo/x.pdf
14
+ * → public/figures/foo/x.svg). PDFs at the top level become public/figures/x.svg.
15
+ *
16
+ * Falls back to pdftoppm (PNG @ 200 DPI) if pdftocairo produces an
17
+ * unreasonably small (likely malformed) SVG.
18
+ *
19
+ * Idempotent: skips when the target SVG is newer than the source PDF.
20
+ * Run on `prebuild` so Astro always sees fresh figures.
21
+ *
22
+ * Graceful skip: when pdftocairo / pdftoppm aren't on PATH (e.g. Cloudflare
23
+ * build container), the script warns and exits 0. Committed SVGs/PNGs under
24
+ * public/figures/ are served as-is. Local devs with poppler-utils regenerate
25
+ * from PDFs on every `npm run dev`.
26
+ */
27
+ import { readdir, stat, mkdir } from 'node:fs/promises';
28
+ import { existsSync, statSync } from 'node:fs';
29
+ import { dirname, resolve, basename } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { spawnSync } from 'node:child_process';
32
+
33
+ const __dirname = dirname(fileURLToPath(import.meta.url));
34
+ const PROJECT_ROOT = resolve(__dirname, '..');
35
+
36
+ // Default: figures/ at scaffold root.
37
+ // Override via BOOK_FIGURES_PATH=path/to/figures (absolute or relative to
38
+ // scaffold root) — used by post_transformers to point at guides/figures.
39
+ const FIGURES_SRC = process.env.BOOK_FIGURES_PATH
40
+ ? resolve(PROJECT_ROOT, process.env.BOOK_FIGURES_PATH)
41
+ : resolve(PROJECT_ROOT, 'figures');
42
+ const FIGURES_DST = resolve(PROJECT_ROOT, 'public/figures');
43
+
44
+ // Threshold below which we treat a generated SVG as suspect and
45
+ // re-render as PNG via pdftoppm. Tuned empirically: anything under
46
+ // 200 bytes is almost certainly a stub or error output.
47
+ const MIN_SVG_BYTES = 200;
48
+
49
+ function check(cmd) {
50
+ const r = spawnSync('which', [cmd], { stdio: 'pipe' });
51
+ return r.status === 0;
52
+ }
53
+
54
+ function bail(cmd) {
55
+ console.warn(
56
+ `build-figures: '${cmd}' not on $PATH — skipping regeneration. ` +
57
+ `Committed SVGs under public/figures/ will be served as-is. ` +
58
+ `(Install poppler-utils locally to regenerate from PDFs.)`,
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Recursively collect PDFs under FIGURES_SRC. Returns an array of
64
+ * { relPath: 'subdir/file.pdf' | 'file.pdf' } objects so the output
65
+ * mirrors the input directory structure.
66
+ */
67
+ async function listPdfsRecursive(root, prefix = '') {
68
+ if (!existsSync(root)) return [];
69
+ const entries = await readdir(root, { withFileTypes: true });
70
+ const out = [];
71
+ for (const entry of entries) {
72
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
73
+ if (entry.isDirectory()) {
74
+ out.push(...(await listPdfsRecursive(resolve(root, entry.name), relPath)));
75
+ } else if (entry.isFile() && entry.name.endsWith('.pdf')) {
76
+ out.push({ relPath });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ function isUpToDate(srcPath, dstPath) {
83
+ if (!existsSync(dstPath)) return false;
84
+ const srcMtime = statSync(srcPath).mtimeMs;
85
+ const dstMtime = statSync(dstPath).mtimeMs;
86
+ return dstMtime >= srcMtime;
87
+ }
88
+
89
+ function convertToSvg(srcPath, dstPath) {
90
+ // pdftocairo wants the destination *without* the .svg extension when
91
+ // -svg is specified — it appends the extension itself. Strip it.
92
+ const dstStem = dstPath.replace(/\.svg$/, '');
93
+ const r = spawnSync('pdftocairo', ['-svg', srcPath, `${dstStem}.svg`], {
94
+ stdio: 'pipe',
95
+ });
96
+ if (r.status !== 0) {
97
+ const stderr = (r.stderr ?? Buffer.from('')).toString().trim();
98
+ throw new Error(
99
+ `pdftocairo failed for ${srcPath}: ${stderr || `exit code ${r.status}`}`,
100
+ );
101
+ }
102
+ // Sanity-check the output size.
103
+ const size = statSync(dstPath).size;
104
+ return size >= MIN_SVG_BYTES;
105
+ }
106
+
107
+ function convertToPng(srcPath, pngStem) {
108
+ // pdftoppm: -r 200 (DPI), -png, single page (first only).
109
+ const r = spawnSync(
110
+ 'pdftoppm',
111
+ ['-r', '200', '-png', '-singlefile', srcPath, pngStem],
112
+ { stdio: 'pipe' },
113
+ );
114
+ if (r.status !== 0) {
115
+ const stderr = (r.stderr ?? Buffer.from('')).toString().trim();
116
+ throw new Error(
117
+ `pdftoppm failed for ${srcPath}: ${stderr || `exit code ${r.status}`}`,
118
+ );
119
+ }
120
+ }
121
+
122
+ async function main() {
123
+ // Graceful skip when poppler is unavailable (e.g. Cloudflare build
124
+ // container). The committed SVGs under public/figures/ serve as the
125
+ // CI artifact; local devs with poppler can refresh them.
126
+ if (!check('pdftocairo')) { bail('pdftocairo'); return; }
127
+ if (!check('pdftoppm')) { bail('pdftoppm'); return; }
128
+
129
+ if (!existsSync(FIGURES_SRC)) {
130
+ console.log(
131
+ `build-figures: ${FIGURES_SRC.replace(PROJECT_ROOT + '/', '')} not found — ` +
132
+ `skipping (no figures to process).`,
133
+ );
134
+ return;
135
+ }
136
+
137
+ const pdfs = await listPdfsRecursive(FIGURES_SRC);
138
+ if (pdfs.length === 0) {
139
+ console.log('build-figures: no PDFs found; nothing to do.');
140
+ return;
141
+ }
142
+
143
+ let total = 0;
144
+ let converted = 0;
145
+ let skipped = 0;
146
+ let pngFallback = 0;
147
+
148
+ for (const { relPath } of pdfs) {
149
+ total++;
150
+ const srcPath = resolve(FIGURES_SRC, relPath);
151
+ const stem = relPath.replace(/\.pdf$/, '');
152
+ const svgPath = resolve(FIGURES_DST, `${stem}.svg`);
153
+ const pngPath = resolve(FIGURES_DST, `${stem}.png`);
154
+
155
+ if (isUpToDate(srcPath, svgPath) || isUpToDate(srcPath, pngPath)) {
156
+ skipped++;
157
+ continue;
158
+ }
159
+
160
+ await mkdir(dirname(svgPath), { recursive: true });
161
+ const svgOK = convertToSvg(srcPath, svgPath);
162
+ if (!svgOK) {
163
+ convertToPng(srcPath, svgPath.replace(/\.svg$/, ''));
164
+ pngFallback++;
165
+ }
166
+ converted++;
167
+ }
168
+
169
+ console.log(
170
+ `build-figures: ${total} total, ${converted} converted ` +
171
+ `(${pngFallback} png fallback), ${skipped} cached`,
172
+ );
173
+ }
174
+
175
+ main().catch((err) => {
176
+ console.error('build-figures: failed');
177
+ console.error(err.message ?? err);
178
+ process.exit(1);
179
+ });
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/render-notebooks.mjs — Notebook rendering pipeline.
4
+ *
5
+ * Walks the notebooks source tree and renders each .ipynb to standalone
6
+ * HTML under public/notebooks/. Chapter pages link to /notebooks/<stem>.html
7
+ * as "View executable companion."
8
+ *
9
+ * Default source: notebooks/ at scaffold root. Override via
10
+ * BOOK_NOTEBOOKS_PATH env var (absolute or relative to scaffold root) —
11
+ * used by post_transformers to point at guides/notebooks/.
12
+ *
13
+ * Per the post_transformers convention (and recommended for academic books),
14
+ * source notebooks are output-free — so the rendered HTML shows code +
15
+ * markdown only, no embedded matplotlib output. The chapter prose is
16
+ * canonical; the notebook is a code companion.
17
+ *
18
+ * Idempotent: skips when the target HTML is newer than the source .ipynb.
19
+ * Run on `prebuild` so Astro always sees fresh notebook HTML.
20
+ *
21
+ * Style scoping: nbconvert's `basic` template emits HTML without nbviewer
22
+ * chrome, but with embedded <style> tags. To prevent CSS bleed, each
23
+ * rendered notebook body is wrapped in <div class="notebook-frame">.
24
+ *
25
+ * Graceful skip: when `uv` is not on PATH (e.g. Cloudflare build container),
26
+ * the script warns and exits 0. Committed HTML under public/notebooks/
27
+ * serves as the CI artifact; local devs with uv regenerate.
28
+ *
29
+ * Prerequisites (local): `uv` on PATH with `jupyter nbconvert` installed
30
+ * in the active environment (`uv pip install jupyter` or via a `pyproject.toml`).
31
+ */
32
+ import { readdir, mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
33
+ import { existsSync, statSync } from 'node:fs';
34
+ import { dirname, resolve, basename } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { spawnSync } from 'node:child_process';
37
+
38
+ const __dirname = dirname(fileURLToPath(import.meta.url));
39
+ const PROJECT_ROOT = resolve(__dirname, '..');
40
+
41
+ // Default: notebooks/ at scaffold root.
42
+ // Override via BOOK_NOTEBOOKS_PATH (absolute or relative to scaffold root)
43
+ // — used by post_transformers to point at guides/notebooks/.
44
+ const NOTEBOOKS_SRC = process.env.BOOK_NOTEBOOKS_PATH
45
+ ? resolve(PROJECT_ROOT, process.env.BOOK_NOTEBOOKS_PATH)
46
+ : resolve(PROJECT_ROOT, 'notebooks');
47
+ const NOTEBOOKS_DST = resolve(PROJECT_ROOT, 'public/notebooks');
48
+
49
+ // uv runs from this working directory so its venv discovery climbs to
50
+ // find pyproject.toml / .venv. Defaults to PROJECT_ROOT but post_transformers
51
+ // needs it set to the wider repo root.
52
+ const UV_CWD = process.env.BOOK_UV_CWD
53
+ ? resolve(PROJECT_ROOT, process.env.BOOK_UV_CWD)
54
+ : PROJECT_ROOT;
55
+
56
+ // Skip threshold: source notebooks tagged as "stub" are tiny (<1.5 KB)
57
+ // and not worth rendering until they grow real content. Configurable
58
+ // via env if you want to render every notebook regardless of size.
59
+ const STUB_BYTES = parseInt(process.env.NOTEBOOK_STUB_BYTES ?? '1500', 10);
60
+
61
+ function check(cmd) {
62
+ const r = spawnSync('which', [cmd], { stdio: 'pipe' });
63
+ return r.status === 0;
64
+ }
65
+
66
+ function bail(cmd) {
67
+ console.warn(
68
+ `render-notebooks: '${cmd}' not on $PATH — skipping regeneration. ` +
69
+ `Committed HTML under public/notebooks/ will be served as-is. ` +
70
+ `(Install uv locally to refresh from .ipynb sources.)`,
71
+ );
72
+ }
73
+
74
+ async function listNotebooks() {
75
+ if (!existsSync(NOTEBOOKS_SRC)) {
76
+ console.error(`render-notebooks: ${NOTEBOOKS_SRC} does not exist; skipping.`);
77
+ return [];
78
+ }
79
+ const entries = await readdir(NOTEBOOKS_SRC, { withFileTypes: true });
80
+ return entries
81
+ .filter((e) => e.isFile() && e.name.endsWith('.ipynb'))
82
+ .map((e) => e.name)
83
+ .sort();
84
+ }
85
+
86
+ function isUpToDate(srcPath, dstPath) {
87
+ if (!existsSync(dstPath)) return false;
88
+ const srcMtime = statSync(srcPath).mtimeMs;
89
+ const dstMtime = statSync(dstPath).mtimeMs;
90
+ return dstMtime >= srcMtime;
91
+ }
92
+
93
+ function isStub(srcPath) {
94
+ const size = statSync(srcPath).size;
95
+ return size < STUB_BYTES;
96
+ }
97
+
98
+ function nbconvertToHtml(srcPath, outDir) {
99
+ // Use --template=basic to get minimal HTML without nbviewer chrome.
100
+ // --output specifies just the stem; nbconvert appends .html.
101
+ const stem = basename(srcPath, '.ipynb');
102
+ const r = spawnSync(
103
+ 'uv',
104
+ [
105
+ 'run',
106
+ 'jupyter',
107
+ 'nbconvert',
108
+ '--to',
109
+ 'html',
110
+ '--template',
111
+ 'basic',
112
+ '--output',
113
+ stem,
114
+ '--output-dir',
115
+ outDir,
116
+ srcPath,
117
+ ],
118
+ {
119
+ stdio: 'pipe',
120
+ cwd: UV_CWD,
121
+ },
122
+ );
123
+ if (r.status !== 0) {
124
+ const stderr = (r.stderr ?? Buffer.from('')).toString().trim();
125
+ throw new Error(
126
+ `nbconvert failed for ${srcPath}: ${stderr || `exit code ${r.status}`}`,
127
+ );
128
+ }
129
+ return resolve(outDir, `${stem}.html`);
130
+ }
131
+
132
+ async function wrapInFrame(htmlPath, sourceName) {
133
+ // Read nbconvert output, wrap body in .notebook-frame so its CSS
134
+ // doesn't bleed into site styles, and emit a minimal HTML doc.
135
+ // We don't use Astro's Base layout here — these pages are static
136
+ // standalone artifacts, not Astro-managed routes.
137
+ const original = await readFile(htmlPath, 'utf8');
138
+ const wrapped = `<!doctype html>
139
+ <html lang="en">
140
+ <head>
141
+ <meta charset="utf-8" />
142
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
143
+ <title>${sourceName} — executable companion</title>
144
+ <style>
145
+ body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; }
146
+ .notebook-frame { font-size: 0.95rem; line-height: 1.55; }
147
+ .notebook-frame pre { background: #f5f5f4; padding: 0.5rem 0.8rem; border-radius: 4px; overflow-x: auto; }
148
+ .notebook-frame code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
149
+ .notebook-frame h1, .notebook-frame h2, .notebook-frame h3 { line-height: 1.2; }
150
+ .notebook-frame img { max-width: 100%; height: auto; }
151
+ .notebook-frame table { border-collapse: collapse; }
152
+ .notebook-frame table td, .notebook-frame table th { padding: 0.3em 0.6em; border: 1px solid #ddd; }
153
+ .companion-header { padding: 0.5rem 0; border-bottom: 1px solid #ddd; margin-bottom: 1.5rem; font-size: 0.85rem; color: #666; }
154
+ .companion-header a { color: #3B6FA0; }
155
+ </style>
156
+ </head>
157
+ <body>
158
+ <div class="companion-header">
159
+ Executable companion — <code>${sourceName}</code>.
160
+ </div>
161
+ <div class="notebook-frame">
162
+ ${original}
163
+ </div>
164
+ </body>
165
+ </html>
166
+ `;
167
+ await writeFile(htmlPath, wrapped, 'utf8');
168
+ }
169
+
170
+ async function main() {
171
+ // Graceful skip when uv is unavailable (e.g. Cloudflare build
172
+ // container). Committed HTML under public/notebooks/ serves as the
173
+ // CI artifact; local devs with uv can refresh.
174
+ if (!check('uv')) { bail('uv'); return; }
175
+
176
+ const notebooks = await listNotebooks();
177
+ if (notebooks.length === 0) {
178
+ console.log('render-notebooks: no notebooks found; nothing to do.');
179
+ return;
180
+ }
181
+
182
+ await mkdir(NOTEBOOKS_DST, { recursive: true });
183
+
184
+ let total = 0;
185
+ let rendered = 0;
186
+ let skipped = 0;
187
+ let stubsSkipped = 0;
188
+
189
+ for (const nb of notebooks) {
190
+ total++;
191
+ const srcPath = resolve(NOTEBOOKS_SRC, nb);
192
+ const stem = basename(nb, '.ipynb');
193
+ const dstPath = resolve(NOTEBOOKS_DST, `${stem}.html`);
194
+
195
+ // Don't bother rendering 1-cell stubs.
196
+ if (isStub(srcPath)) {
197
+ stubsSkipped++;
198
+ // Clean up a stale rendered version if one exists.
199
+ if (existsSync(dstPath)) await unlink(dstPath);
200
+ continue;
201
+ }
202
+
203
+ if (isUpToDate(srcPath, dstPath)) {
204
+ skipped++;
205
+ continue;
206
+ }
207
+
208
+ nbconvertToHtml(srcPath, NOTEBOOKS_DST);
209
+ await wrapInFrame(dstPath, nb);
210
+ rendered++;
211
+ }
212
+
213
+ console.log(
214
+ `render-notebooks: ${total} total, ${rendered} rendered, ` +
215
+ `${skipped} cached, ${stubsSkipped} stubs skipped`,
216
+ );
217
+ }
218
+
219
+ main().catch((err) => {
220
+ console.error('render-notebooks: failed');
221
+ console.error(err.message ?? err);
222
+ process.exit(1);
223
+ });