@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.
- package/CLAUDE.md +179 -0
- package/bin/book-scaffold.mjs +61 -0
- package/components/CaseStudy.astro +36 -0
- package/components/ChapterHeader.astro +61 -0
- package/components/ChapterNav.astro +29 -0
- package/components/ChapterTOC.astro +33 -0
- package/components/Citation.astro +94 -0
- package/components/Cite.astro +71 -0
- package/components/CodeBlock.astro +115 -0
- package/components/CodeRef.astro +49 -0
- package/components/ConceptBox.astro +26 -0
- package/components/Convergence.astro +41 -0
- package/components/CounterBox.astro +15 -0
- package/components/Divergence.astro +32 -0
- package/components/DynConnect.astro +15 -0
- package/components/ExampleBox.astro +15 -0
- package/components/Figure.astro +35 -0
- package/components/InsightBox.astro +15 -0
- package/components/KeyIdea.astro +21 -0
- package/components/MarginNote.astro +37 -0
- package/components/NoteBox.astro +15 -0
- package/components/OpenQuestion.astro +15 -0
- package/components/PaperBox.astro +15 -0
- package/components/PatternTimeline.astro +133 -0
- package/components/Recovery.astro +34 -0
- package/components/ResultBox.astro +15 -0
- package/components/Sidebar.astro +268 -0
- package/components/Sidenote.astro +26 -0
- package/components/SkillBox.astro +24 -0
- package/components/SourceArchive.astro +285 -0
- package/components/StatusBadge.astro +51 -0
- package/components/Tag.astro +60 -0
- package/components/Theorem.astro +65 -0
- package/components/TipBox.astro +15 -0
- package/components/ToolFilter.tsx +160 -0
- package/components/TryThis.astro +23 -0
- package/components/VersionSelector.tsx +85 -0
- package/components/WarnBox.astro +15 -0
- package/components/WeekRef.astro +51 -0
- package/components/XRef.astro +40 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.mjs +369 -0
- package/dist/lib/katex-macros.d.ts +26 -0
- package/dist/lib/katex-macros.mjs +98 -0
- package/dist/schemas.d.ts +17 -0
- package/dist/schemas.mjs +160 -0
- package/dist/types-Cz-pwE1N.d.ts +61 -0
- package/examples/chapter-template-academic.mdx +100 -0
- package/examples/chapter-template-tools.mdx +90 -0
- package/layouts/Base.astro +250 -0
- package/layouts/Chapter.astro +37 -0
- package/package.json +137 -0
- package/pages/chapters.astro +371 -0
- package/pages/convergence.astro +96 -0
- package/pages/print.astro +39 -0
- package/pages/references.astro +160 -0
- package/pages/search.astro +87 -0
- package/pedagogy/kf-chapter-shape.md +96 -0
- package/pedagogy/source-tiers.md +121 -0
- package/pedagogy/volatility-classes.md +110 -0
- package/recipes/00-getting-started.md +77 -0
- package/recipes/01-add-math.md +71 -0
- package/recipes/02-bibliography-pipeline.md +82 -0
- package/recipes/03-asset-pipelines.md +84 -0
- package/recipes/04-component-library.md +118 -0
- package/recipes/05-deploy-cloudflare.md +74 -0
- package/recipes/06-mobile-first-layout.md +73 -0
- package/recipes/07-chapter-shapes.md +84 -0
- package/recipes/08-decisions-ledger.md +110 -0
- package/recipes/09-validation.md +106 -0
- package/recipes/10-custom-domain.md +72 -0
- package/recipes/README.md +43 -0
- package/scripts/build-bib.mjs +99 -0
- package/scripts/build-figures.mjs +179 -0
- package/scripts/render-notebooks.mjs +223 -0
- package/scripts/validate.mjs +179 -0
- package/styles/callouts.css +303 -0
- package/styles/chapter.css +209 -0
- package/styles/convergence.css +349 -0
- package/styles/layout.css +156 -0
- package/styles/print.css +203 -0
- package/styles/tokens.css +194 -0
- package/styles/tool-filter.css +135 -0
- 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
|
+
});
|