@brandon_m_behring/book-scaffold-astro 4.9.0 → 4.11.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 CHANGED
@@ -166,9 +166,16 @@ See `recipes/09-validation.md` to extend.
166
166
 
167
167
  ### Add a figure
168
168
 
169
- 1. Drop PDF in `figures/<topic>/<name>.pdf` (or set `BOOK_FIGURES_PATH`).
169
+ 1. Drop a PDF in `figures/<topic>/<name>.pdf`, or a TikZ standalone `.tex` (auto-compiled), or set `BOOK_FIGURES_PATH`.
170
170
  2. `npm run build:figures` produces `public/figures/<topic>/<name>.svg`.
171
- 3. Reference: `<Figure src="/figures/<topic>/<name>.svg" caption="..." id="..." />`.
171
+ 3. Reference: `<Figure src="/figures/<topic>/<name>.svg" caption="..." alt="..." id="..." />`.
172
+
173
+ **Accessibility + dark mode (v4.11.0, #84).** `build:figures` rewrites every generated SVG so one file serves both themes: it adds `role="img"` and remaps the *neutral* fills/strokes to `var(--diagram-ink|paper|grid, <original>)` (saturated accent colors are left as authored). `<Figure>` **inlines** a local `.svg` (vs `<img>`), so the page's `--diagram-*` tokens cascade in and the figure tracks the in-page dark-mode toggle; `caption`/`alt`/`desc` become the SVG's `<title>`/`<desc>`. Notes:
174
+
175
+ - `alt` is the short accessible name (defaults to `caption`); `desc` is an optional longer description.
176
+ - Non-SVG (`.png` fallback), remote, or unreadable `src` keep the `<img>` render.
177
+ - Opt a figure out of theming with a `%! no-theme` line in its source `.tex`.
178
+ - After upgrading, re-run `npm run build:figures` to theme pre-existing figures (the rewrite is idempotent).
172
179
 
173
180
  ### Add a new component
174
181
 
@@ -27,8 +27,9 @@ const HELP = `Usage: book-scaffold <sub-command> [args...]
27
27
  Sub-commands:
28
28
  validate Pre-flight content validator (XRef ids, Cite keys, Figure srcs).
29
29
  build-labels Emit src/data/labels.json for cross-references (Phase C).
30
- build-bib BibTeX -> CSL JSON for the <Cite> component.
30
+ build-bib BibTeX -> references.json (+ sources/manifest.yaml -> sources.json).
31
31
  build-figures PDF -> SVG via pdftocairo / pdftoppm fallback (+ TikZ in v4.2.0).
32
+ Each SVG gets role="img" + dark-mode var(--diagram-*) fills (v4.11.0).
32
33
  build-tips Scan chapters for <Tip> instances; emit src/data/tips.json (v4.3.0).
33
34
  build-exercises Scan chapters for <Exercise> instances; emit src/data/exercises.json (v4.4.0).
34
35
  render-notebooks ipynb -> HTML via Jupyter nbconvert.
@@ -4,13 +4,22 @@
4
4
  * `\label{...}` pattern with a single component.
5
5
  *
6
6
  * Source assets are emitted under public/figures/weekNN/ by
7
- * scripts/build-figures.sh (Phase 2.4); src paths are absolute from
8
- * site root.
7
+ * scripts/build-figures.mjs; src paths are absolute from site root.
8
+ *
9
+ * v4.11.0 (#84): a local pipeline `.svg` is **inlined** (read from public/ +
10
+ * `set:html`) rather than referenced via `<img>`. Inlining puts the SVG in the
11
+ * host DOM, so the page's tokens.css `--diagram-*` cascade in — the figure
12
+ * tracks the in-page dark-mode toggle (an `<img>`-loaded SVG is CSS-isolated
13
+ * and could only follow the OS preference). Inlining also lets a11y
14
+ * `<title>`/`<desc>` come from this component's props (assembleSvg). Non-SVG
15
+ * (the pdftoppm `.png` fallback), remote, or unreadable `src` gracefully keep
16
+ * the `<img>` render — a figure must never crash a build.
9
17
  *
10
18
  * Usage:
11
19
  * <Figure
12
20
  * src="/figures/week04/ex2_hippo_eigenvalues.svg"
13
21
  * caption="HiPPO-LegS eigenvalue structure for N = 16 and N = 64."
22
+ * alt="Two overlaid eigenvalue spectra forming nested arcs."
14
23
  * width="100%"
15
24
  * id="w4-fig-hippo-eigenvalues"
16
25
  * />
@@ -18,18 +27,41 @@
18
27
  * The id prop registers the figure with the cross-reference label map
19
28
  * (consumed by `<XRef>`).
20
29
  */
30
+ import { readFileSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ import { shouldInline, assembleSvg } from '../src/lib/figure.mjs';
33
+
21
34
  interface Props {
22
35
  src: string;
23
36
  caption?: string;
24
37
  width?: string;
25
38
  id?: string;
26
39
  alt?: string;
40
+ /** Long-form description → SVG <desc> (alt stays the short accessible name). */
41
+ desc?: string;
27
42
  }
28
43
 
29
- const { src, caption, width = '100%', id, alt } = Astro.props;
44
+ const { src, caption, width = '100%', id, alt, desc } = Astro.props;
30
45
  const altText = alt ?? caption ?? '';
46
+
47
+ // Inline a local pipeline SVG so host CSS variables theme it + a11y nodes come
48
+ // from props. Any read failure falls back to <img> below (never throws).
49
+ let inlineSvg: string | null = null;
50
+ if (shouldInline(src)) {
51
+ try {
52
+ const raw = readFileSync(join(process.cwd(), 'public', src), 'utf8');
53
+ const idBase = id ?? `fig-${src.replace(/^\/+/, '').replace(/\.[^.]+$/, '')}`;
54
+ inlineSvg = assembleSvg(raw, { caption, alt, desc, width, idBase });
55
+ } catch {
56
+ inlineSvg = null;
57
+ }
58
+ }
31
59
  ---
32
60
  <figure class="figure" id={id}>
33
- <img src={src} alt={altText} style={`width: ${width}; max-width: 100%;`} />
61
+ {inlineSvg ? (
62
+ <Fragment set:html={inlineSvg} />
63
+ ) : (
64
+ <img src={src} alt={altText} style={`width: ${width}; max-width: 100%;`} />
65
+ )}
34
66
  {caption && <figcaption>{caption}</figcaption>}
35
67
  </figure>
@@ -3,14 +3,14 @@
3
3
  * SourceArchive — renders every entry in sources/manifest.yaml grouped
4
4
  * by tier in descending authority (T1 → T4).
5
5
  *
6
- * Used from Appendix D (`d-source-archive-index.mdx`) to replace the
7
- * static hand-maintained listing with an auto-generated view that
8
- * always reflects the manifest.
6
+ * Used by the auto-injected /references page (v4.10.0, #85) and by
7
+ * author-placed source-archive appendices, replacing a static hand-
8
+ * maintained listing with an auto-generated view that always reflects
9
+ * the manifest.
9
10
  *
10
11
  * Empty-tier behavior: renders an honest placeholder ("No sources at
11
- * this tier yet.") rather than hiding the section. This matches the
12
- * pedagogy of Appendix D the archive is intentionally sparse in the
13
- * early book, and the gap is visible by design.
12
+ * this tier yet.") rather than hiding the section, so the tier taxonomy
13
+ * stays visible even before a tier has entries.
14
14
  */
15
15
  import { sourceTiers } from '@brandon_m_behring/book-scaffold-astro';
16
16
  import {
@@ -53,7 +53,7 @@ function year(d: Date | undefined): string | null {
53
53
 
54
54
  {empty ? (
55
55
  <p class="source-archive-empty">
56
- No sources at this tier yet. Appendix D is intentionally sparse in the early book; this slot fills as chapters leave draft.
56
+ No sources at this tier yet.
57
57
  </p>
58
58
  ) : (
59
59
  <ol
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.9.0",
4
+ "version": "4.11.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -142,15 +142,21 @@
142
142
  "test": "node --test tests/*.test.mjs"
143
143
  },
144
144
  "peerDependencies": {
145
- "astro": "^6.1.7",
146
145
  "@astrojs/mdx": "^5.0.3",
147
146
  "@astrojs/preact": "^5.1.1",
147
+ "astro": "^6.1.7",
148
148
  "preact": "^10.29.1"
149
149
  },
150
150
  "peerDependenciesMeta": {
151
- "katex": { "optional": true },
152
- "rehype-katex": { "optional": true },
153
- "remark-math": { "optional": true }
151
+ "katex": {
152
+ "optional": true
153
+ },
154
+ "rehype-katex": {
155
+ "optional": true
156
+ },
157
+ "remark-math": {
158
+ "optional": true
159
+ }
154
160
  },
155
161
  "dependencies": {
156
162
  "@astrojs/sitemap": "^3.6.1",
@@ -158,7 +164,8 @@
158
164
  "@citation-js/plugin-bibtex": "^0.7.21",
159
165
  "@fontsource-variable/roboto": "^5.2.10",
160
166
  "@fontsource-variable/source-code-pro": "^5.2.7",
161
- "pagefind": "^1.5.2"
167
+ "pagefind": "^1.5.2",
168
+ "yaml": "^2.9.0"
162
169
  },
163
170
  "devDependencies": {
164
171
  "@types/node": "^22.10.0",
@@ -1,16 +1,22 @@
1
1
  ---
2
2
  /**
3
- * /references — the book's bibliography page.
3
+ * /references — the book's bibliography + cited-sources page.
4
4
  *
5
- * Renders every entry in src/data/references.json (produced by
6
- * scripts/build-bib.mjs from guides/shared/references.bib). Each
7
- * entry gets an anchor ID matching its bibkey, so `<Cite key="gu2024mamba" />`
8
- * elsewhere on the site links to /references#gu2024mamba.
5
+ * Auto-injected on every profile (integration route table). Reads two
6
+ * independent inputs, each via a defensive `import.meta.glob` (missing file →
7
+ * empty, never a crash same pattern <XRef> uses for labels.json):
9
8
  *
10
- * Sorted alphabetically by first-author surname, then by year.
11
- * arXiv-style notes are surfaced as direct links when present.
9
+ * - src/data/references.json BibTeX entries (academic), from build-bib.
10
+ * Each entry's anchor matches its bibkey, so `<Cite key="…" />` links to
11
+ * /references#<bibkey>.
12
+ * - src/data/sources.json — sources/manifest.yaml entries (tools profile),
13
+ * also from build-bib (v4.10.0, #85). Its presence is a profile-safe GATE
14
+ * for the <SourceArchive> render: a falsy `&&` never instantiates the
15
+ * component, so academic/minimal books (which have no `sources` content
16
+ * collection) never call getCollection('sources').
12
17
  */
13
18
  import Base from '../layouts/Base.astro';
19
+ import SourceArchive from '../components/SourceArchive.astro';
14
20
 
15
21
  type CslAuthor = { family?: string; given?: string; literal?: string };
16
22
  type CslEntry = {
@@ -29,8 +35,7 @@ type CslEntry = {
29
35
  note?: string;
30
36
  };
31
37
 
32
- // Resolve references.json from consumer's project root. Missing file -> empty
33
- // list (page renders an "empty bibliography" notice rather than crashing).
38
+ // --- Bibliography: BibTeX → references.json (academic profile). ---
34
39
  const refsModules = import.meta.glob<{ default: Record<string, CslEntry> }>(
35
40
  '/src/data/references.json',
36
41
  { eager: true },
@@ -39,6 +44,18 @@ const refsModule = refsModules['/src/data/references.json'];
39
44
  const map = (refsModule?.default ?? {}) as Record<string, CslEntry>;
40
45
  const entries = Object.values(map);
41
46
 
47
+ // --- Cited sources: sources/manifest.yaml → sources.json (tools profile).
48
+ // Presence-only gate; the actual render reads the live `sources` collection
49
+ // via <SourceArchive>, which only runs when this gate is true (so it is never
50
+ // reached on profiles without a `sources` collection). ---
51
+ const srcModules = import.meta.glob<{ default: unknown[] }>(
52
+ '/src/data/sources.json',
53
+ { eager: true },
54
+ );
55
+ const sourcesData = (srcModules['/src/data/sources.json']?.default ?? []) as unknown[];
56
+ const hasSources = Array.isArray(sourcesData) && sourcesData.length > 0;
57
+ const hasBib = entries.length > 0;
58
+
42
59
  const surname = (a: CslAuthor): string =>
43
60
  (a.family ?? a.literal ?? '').toLowerCase();
44
61
 
@@ -73,61 +90,95 @@ function arxivUrl(note?: string): string | null {
73
90
  const m = note.match(/arXiv:\s*(\S+)/i);
74
91
  return m ? `https://arxiv.org/abs/${m[1]}` : null;
75
92
  }
93
+
94
+ const lede =
95
+ hasBib && hasSources
96
+ ? 'Every work cited in this book — the bibliography below, then the external sources grouped by tier.'
97
+ : hasBib
98
+ ? 'Every paper, book, and software citation in this book, sorted alphabetically by first-author surname. Click an entry’s anchor to share a deep link, or follow the arXiv / DOI / URL for the source.'
99
+ : hasSources
100
+ ? 'Every external source cited in this book, grouped by tier in descending authority.'
101
+ : 'This book has no references yet.';
76
102
  ---
77
- <Base
78
- title="References — Post-Transformers"
79
- description="Bibliography for the post_transformers guide, generated from guides/shared/references.bib."
80
- >
103
+ <Base title="References" description="Bibliography and cited sources for this book.">
81
104
  <article class="prose">
82
105
  <header>
83
106
  <h1>References</h1>
84
- <p class="lede">
85
- Every paper, book, and software citation in this guide, sorted
86
- alphabetically by first-author surname. Click an entry's
87
- anchor to share a deep link, or follow the arXiv / DOI / URL
88
- for the source.
89
- </p>
90
- <p>
91
- <small>
92
- {entries.length} entries. Generated from
93
- <code>guides/shared/references.bib</code> at build time via
94
- <code>scripts/build-bib.mjs</code>.
95
- </small>
96
- </p>
107
+ <p class="lede">{lede}</p>
97
108
  </header>
98
109
 
99
- <ol class="references-list">
100
- {entries.map((e) => {
101
- const y = year(e);
102
- const arxiv = arxivUrl(e.note);
103
- const primaryUrl = arxiv ?? e.URL ?? (e.DOI ? `https://doi.org/${e.DOI}` : null);
104
- return (
105
- <li id={e.id} class="reference-entry">
106
- <span class="reference-key" aria-label="bibkey">[{e.id}]</span>
107
- <span class="reference-text">
108
- {formatAuthors(e.author)}
109
- {y > 0 && <> ({y})</>}.
110
- {' '}
111
- <em>{e.title}</em>.
112
- {e['container-title'] && <> {e['container-title']}.</>}
113
- {e.publisher && !e['container-title'] && <> {e.publisher}.</>}
114
- {e.volume && <> Vol. {e.volume}{e.issue && <>, no. {e.issue}</>}.</>}
115
- {e.page && <> pp. {e.page}.</>}
116
- {primaryUrl && (
117
- <>
110
+ {!hasBib && !hasSources && (
111
+ <p class="references-empty">
112
+ No references yet. Add a <code>bibliography.bib</code> (academic) or
113
+ populate <code>sources/manifest.yaml</code> (tools), then run
114
+ <code>npm run build:bib</code>.
115
+ </p>
116
+ )}
117
+
118
+ {hasBib && (
119
+ <section class="references-bibliography">
120
+ {hasSources && <h2>Bibliography</h2>}
121
+ <ol class="references-list">
122
+ {entries.map((e) => {
123
+ const y = year(e);
124
+ const arxiv = arxivUrl(e.note);
125
+ const primaryUrl = arxiv ?? e.URL ?? (e.DOI ? `https://doi.org/${e.DOI}` : null);
126
+ return (
127
+ <li id={e.id} class="reference-entry">
128
+ <span class="reference-key" aria-label="bibkey">[{e.id}]</span>
129
+ <span class="reference-text">
130
+ {formatAuthors(e.author)}
131
+ {y > 0 && <> ({y})</>}.
118
132
  {' '}
119
- <a href={primaryUrl} rel="external noopener">link</a>.
120
- </>
121
- )}
122
- </span>
123
- </li>
124
- );
125
- })}
126
- </ol>
133
+ <em>{e.title}</em>.
134
+ {e['container-title'] && <> {e['container-title']}.</>}
135
+ {e.publisher && !e['container-title'] && <> {e.publisher}.</>}
136
+ {e.volume && <> Vol. {e.volume}{e.issue && <>, no. {e.issue}</>}.</>}
137
+ {e.page && <> pp. {e.page}.</>}
138
+ {primaryUrl && (
139
+ <>
140
+ {' '}
141
+ <a href={primaryUrl} rel="external noopener">link</a>.
142
+ </>
143
+ )}
144
+ </span>
145
+ </li>
146
+ );
147
+ })}
148
+ </ol>
149
+ </section>
150
+ )}
151
+
152
+ {hasSources && (
153
+ <section class="references-sources">
154
+ <h2>Cited sources</h2>
155
+ <p class="references-sources-lede">
156
+ External sources cited inline via <code>&lt;Citation&gt;</code>, grouped
157
+ by tier in descending authority.
158
+ </p>
159
+ <SourceArchive />
160
+ </section>
161
+ )}
127
162
  </article>
128
163
  </Base>
129
164
 
130
165
  <style>
166
+ .references-empty {
167
+ color: var(--color-text-muted);
168
+ font-style: italic;
169
+ padding: var(--space-3);
170
+ background: var(--color-bg-subtle);
171
+ border-radius: var(--radius-sm);
172
+ text-indent: 0;
173
+ }
174
+ .references-sources {
175
+ margin-top: var(--space-6);
176
+ }
177
+ .references-sources-lede {
178
+ color: var(--color-text-muted);
179
+ font-size: var(--text-sm);
180
+ text-indent: 0;
181
+ }
131
182
  .references-list {
132
183
  list-style: none;
133
184
  padding: 0;
@@ -64,7 +64,7 @@ Supported `type` values: `theorem`, `proposition`, `lemma`, `corollary`, `defini
64
64
  |---|---|---|
65
65
  | `Cite` | Inline citation linked to `/references` | `<Cite key="gu2024mamba" page="3" />` |
66
66
  | `XRef` | Cross-reference to a labeled element | `<XRef id="thm:zoh-stability" />` |
67
- | `Figure` | Image + caption + id | `<Figure src="/figures/week04/eigenvalues.svg" caption="…" id="fig-eig" />` |
67
+ | `Figure` | Image/SVG + caption + id; local SVGs inline for a11y + dark mode (`alt`, `desc`) | `<Figure src="/figures/week04/eigenvalues.svg" caption="…" alt="…" id="fig-eig" />` |
68
68
  | `MarginNote` | Right-margin annotation (Tufte-style) | `<MarginNote>side comment</MarginNote>` |
69
69
  | `Sidenote` | Auto-numbered marginalia | `<Sidenote>numbered note</Sidenote>` |
70
70
  | `WeekRef` | Jump-link to a week chapter | `<WeekRef week={4} />` |
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * scripts/build-bib.mjs — Bibliography pipeline (academic profile).
3
+ * scripts/build-bib.mjs — Bibliography + source-manifest pipeline.
4
4
  *
5
5
  * Reads bibliography.bib at scaffold root (BibTeX), parses via @citation-js,
6
6
  * emits src/data/references.json keyed by bibkey. The .bib path is
@@ -8,6 +8,12 @@
8
8
  * elsewhere (e.g. a shared `guides/shared/references.bib` outside the
9
9
  * Astro project — the post_transformers pattern).
10
10
  *
11
+ * v4.10.0 (#85): ALSO reads sources/manifest.yaml (tools-profile sources,
12
+ * cited inline via <Citation src>) and emits src/data/sources.json so the
13
+ * auto-injected /references page can surface them. The two steps are
14
+ * independent — a tools book with a manifest and no .bib still gets a
15
+ * populated /references.
16
+ *
11
17
  * Run on `prebuild` so every Astro build sees fresh bibliography data.
12
18
  * Idempotent: re-running with no .bib change produces a byte-identical
13
19
  * output (modulo timestamp, which we omit).
@@ -37,8 +43,10 @@ import { fileURLToPath } from 'node:url';
37
43
  // --help / -h: non-mutating (closes #14).
38
44
  const USAGE = `Usage: book-scaffold build-bib
39
45
 
40
- Bibliography pipeline (academic profile). Reads bibliography.bib (or
41
- BOOK_BIB_PATH if set), parses via @citation-js, emits src/data/references.json.
46
+ Bibliography + source-manifest pipeline. Reads bibliography.bib (or
47
+ BOOK_BIB_PATH if set) -> src/data/references.json (BibTeX via @citation-js),
48
+ AND sources/manifest.yaml -> src/data/sources.json (tools-profile sources for
49
+ the /references page). Either input may be absent.
42
50
 
43
51
  Env:
44
52
  BOOK_BIB_PATH Override path to .bib file (default: ./bibliography.bib).
@@ -63,8 +71,10 @@ const BIB_PATH = process.env.BOOK_BIB_PATH
63
71
  ? resolve(process.cwd(), process.env.BOOK_BIB_PATH)
64
72
  : resolve(PROJECT_ROOT, 'bibliography.bib');
65
73
  const OUT_PATH = resolve(PROJECT_ROOT, 'src/data/references.json');
74
+ const SOURCES_PATH = resolve(PROJECT_ROOT, 'sources/manifest.yaml');
75
+ const SOURCES_OUT = resolve(PROJECT_ROOT, 'src/data/sources.json');
66
76
 
67
- async function main() {
77
+ async function buildReferences() {
68
78
  // Graceful skip when the .bib file is absent (minimal/tools profile, or
69
79
  // an academic book that hasn't authored citations yet). Emits an empty
70
80
  // references.json so consumers can still `import refs from '...'`.
@@ -125,6 +135,45 @@ async function main() {
125
135
  );
126
136
  }
127
137
 
138
+ // v4.10.0 (closes #85): tools-profile books keep their sources in
139
+ // sources/manifest.yaml (cited inline via <Citation src="id" />); the BibTeX
140
+ // path above never sees them, so the auto-injected /references page rendered
141
+ // blank. Emit those sources to src/data/sources.json so references.astro can
142
+ // surface them via the same defensive import.meta.glob it uses for
143
+ // references.json. Absent manifest -> no file written (academic/minimal books
144
+ // degrade to empty, exactly like a missing .bib). YAML is lazy-imported so the
145
+ // --help / no-manifest paths stay dependency-free.
146
+ async function buildSources() {
147
+ let yamlText;
148
+ try {
149
+ yamlText = await readFile(SOURCES_PATH, 'utf8');
150
+ } catch (err) {
151
+ if (err.code === 'ENOENT') return; // no manifest — nothing to emit
152
+ throw err;
153
+ }
154
+
155
+ const { parse } = await import('yaml');
156
+ const parsed = parse(yamlText);
157
+ // The manifest is a YAML array of source objects. Keep only well-formed
158
+ // entries (a string `id` is the citation key + the /references anchor target).
159
+ // A blank or comments-only manifest parses to null/undefined/[].
160
+ const sources = Array.isArray(parsed)
161
+ ? parsed.filter((s) => s && typeof s.id === 'string')
162
+ : [];
163
+
164
+ await mkdir(dirname(SOURCES_OUT), { recursive: true });
165
+ await writeFile(SOURCES_OUT, JSON.stringify(sources, null, 2) + '\n', 'utf8');
166
+ console.log(
167
+ `build-bib: ${sources.length} source${sources.length === 1 ? '' : 's'} -> ` +
168
+ `${SOURCES_OUT.replace(PROJECT_ROOT + '/', '')}`,
169
+ );
170
+ }
171
+
172
+ async function main() {
173
+ await buildReferences();
174
+ await buildSources();
175
+ }
176
+
128
177
  main().catch((err) => {
129
178
  console.error(`build-bib: failed`);
130
179
  console.error(err);
@@ -23,7 +23,17 @@
23
23
  * Falls back to pdftoppm (PNG @ 200 DPI) if pdftocairo produces an
24
24
  * unreasonably small (likely malformed) SVG.
25
25
  *
26
- * Idempotent: skips when the target SVG is newer than the source PDF.
26
+ * v4.11.0 (closes #84): each generated SVG gets a post-export rewrite
27
+ * (recolorSvg) that injects role="img" + a CSS-variable theming layer so one
28
+ * SVG serves light + dark. Neutral fills/strokes are remapped to
29
+ * var(--diagram-ink|paper|grid, <original>) via injected attribute-selector
30
+ * rules (the original attribute stays as the fallback); saturated accent colors
31
+ * are left untouched. A `%! no-theme` line in the source .tex opts a figure out.
32
+ * <Figure> inlines local SVGs so they track the in-page [data-theme] toggle.
33
+ *
34
+ * Idempotent: skips when the target SVG is newer than the source PDF (and the
35
+ * recolor itself is a no-op on an already-themed SVG, so re-runs after an
36
+ * upgrade safely theme pre-existing figures).
27
37
  * Run on `prebuild` so Astro always sees fresh figures.
28
38
  *
29
39
  * Graceful skip: when pdftocairo / pdftoppm aren't on PATH (e.g. Cloudflare
@@ -32,10 +42,11 @@
32
42
  * from PDFs on every `npm run dev`.
33
43
  */
34
44
  import { readdir, stat, mkdir } from 'node:fs/promises';
35
- import { existsSync, statSync } from 'node:fs';
45
+ import { existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
36
46
  import { dirname, resolve, basename } from 'node:path';
37
47
  import { fileURLToPath } from 'node:url';
38
48
  import { spawnSync } from 'node:child_process';
49
+ import { recolorSvg } from '../src/lib/figure.mjs';
39
50
 
40
51
  // --help / -h: non-mutating (closes #14).
41
52
  const USAGE = `Usage: book-scaffold build-figures
@@ -44,6 +55,10 @@ Figure pipeline. PDF -> SVG via pdftocairo (PNG fallback via pdftoppm at
44
55
  200dpi). Walks figures/ (or BOOK_FIGURES_PATH), emits to public/figures/.
45
56
  Graceful-skip if pdftocairo / pdftoppm not on PATH.
46
57
 
58
+ Each SVG is rewritten to be accessible + dark-mode-aware: role="img" plus
59
+ var(--diagram-ink|paper|grid, orig) fills (a "%! no-theme" line in the
60
+ source .tex opts out). <Figure> inlines local SVGs so they track the theme.
61
+
47
62
  Env:
48
63
  BOOK_FIGURES_PATH Override figures source (default: figures/).
49
64
 
@@ -179,6 +194,25 @@ function convertToSvg(srcPath, dstPath) {
179
194
  return size >= MIN_SVG_BYTES;
180
195
  }
181
196
 
197
+ // v4.11.0 (#84): a `%! no-theme` line (anywhere in the source .tex) opts a
198
+ // figure out of the dark-mode/a11y rewrite. Matches `%!` then `no-theme`,
199
+ // tolerant of surrounding whitespace — distinct from BibTeX `%`-line comments.
200
+ const NO_THEME_RE = /^\s*%!\s*no-theme\b/m;
201
+
202
+ /**
203
+ * v4.11.0 (#84): apply the theming rewrite to a generated SVG in place.
204
+ * No-op (returns false) if the file is absent or recolorSvg leaves it unchanged
205
+ * (already themed / nothing neutral to remap). Idempotent.
206
+ */
207
+ function themeIfSvg(svgPath, optOut) {
208
+ if (!existsSync(svgPath)) return false;
209
+ const original = readFileSync(svgPath, 'utf8');
210
+ const themed = recolorSvg(original, { optOut });
211
+ if (themed === original) return false;
212
+ writeFileSync(svgPath, themed, 'utf8');
213
+ return true;
214
+ }
215
+
182
216
  function convertToPng(srcPath, pngStem) {
183
217
  // pdftoppm: -r 200 (DPI), -png, single page (first only).
184
218
  const r = spawnSync(
@@ -251,6 +285,7 @@ async function main() {
251
285
  let converted = 0;
252
286
  let skipped = 0;
253
287
  let pngFallback = 0;
288
+ let themed = 0;
254
289
 
255
290
  for (const { relPath } of pdfs) {
256
291
  total++;
@@ -259,7 +294,20 @@ async function main() {
259
294
  const svgPath = resolve(FIGURES_DST, `${stem}.svg`);
260
295
  const pngPath = resolve(FIGURES_DST, `${stem}.png`);
261
296
 
262
- if (isUpToDate(srcPath, svgPath) || isUpToDate(srcPath, pngPath)) {
297
+ // Opt-out via `%! no-theme` in the source .tex (TikZ figures only).
298
+ const texSibling = resolve(FIGURES_SRC, `${stem}.tex`);
299
+ const optOut =
300
+ existsSync(texSibling) && NO_THEME_RE.test(readFileSync(texSibling, 'utf8'));
301
+
302
+ // Cached SVG: still run the (idempotent) rewrite so an upgrade themes
303
+ // pre-existing figures without forcing a source touch. PNG fallbacks are
304
+ // raster — nothing to theme.
305
+ if (isUpToDate(srcPath, svgPath)) {
306
+ if (themeIfSvg(svgPath, optOut)) themed++;
307
+ skipped++;
308
+ continue;
309
+ }
310
+ if (isUpToDate(srcPath, pngPath)) {
263
311
  skipped++;
264
312
  continue;
265
313
  }
@@ -269,14 +317,17 @@ async function main() {
269
317
  if (!svgOK) {
270
318
  convertToPng(srcPath, svgPath.replace(/\.svg$/, ''));
271
319
  pngFallback++;
320
+ } else if (themeIfSvg(svgPath, optOut)) {
321
+ themed++;
272
322
  }
273
323
  converted++;
274
324
  }
275
325
 
276
326
  const tikzNote = tikzCompiled > 0 ? `, ${tikzCompiled} tikz→pdf` : '';
327
+ const themedNote = themed > 0 ? `, ${themed} themed` : '';
277
328
  console.log(
278
329
  `build-figures: ${total} total, ${converted} converted ` +
279
- `(${pngFallback} png fallback), ${skipped} cached${tikzNote}`,
330
+ `(${pngFallback} png fallback), ${skipped} cached${themedNote}${tikzNote}`,
280
331
  );
281
332
  }
282
333
 
@@ -0,0 +1,245 @@
1
+ /**
2
+ * src/lib/figure.mjs — pure SVG transforms for the figure pipeline (v4.11.0, #84).
3
+ *
4
+ * Two concerns, one module so the `data-diagram-*` sentinels stay DRY:
5
+ *
6
+ * • BUILD side — `recolorSvg()` rewrites a pdftocairo SVG to be theme-aware
7
+ * (called by scripts/build-figures.mjs after each PDF→SVG conversion).
8
+ * • RENDER side — `shouldInline()` + `assembleSvg()` let Figure.astro inline a
9
+ * local pipeline SVG with a11y <title>/<desc> from the <Figure> props.
10
+ *
11
+ * Why a stylesheet, not presentation attributes: `var()` inside a presentation
12
+ * attribute (`fill="var(--x, …)"`) has unreliable browser support, whereas
13
+ * `var()` in a real <style> rule is universal. So `recolorSvg` injects an
14
+ * attribute-selector rule per distinct neutral color and never mutates a
15
+ * drawing element — the untouched `fill=""`/`stroke=""` attribute is the
16
+ * automatic fallback where `var()` is unsupported.
17
+ *
18
+ * Why inline (vs <img>): an SVG loaded via <img> is CSS-isolated, so a host
19
+ * page's `var(--diagram-*)` cannot reach it — it could only follow the OS
20
+ * prefers-color-scheme via the embedded <style data-diagram-theme>. Inlining
21
+ * puts the SVG in the host DOM, so the host's tokens.css (which tracks the
22
+ * in-page [data-theme] toggle) themes it. `assembleSvg` therefore STRIPS the
23
+ * embedded theme block so the host is the sole source of --diagram-*.
24
+ *
25
+ * Pure string ops only — no `node:` imports — so the module bundles for any
26
+ * consumer (Figure.astro imports it; fs lives in the .astro/.mjs callers).
27
+ */
28
+
29
+ // Standalone self-theming defaults: used only when the SVG is NOT inlined
30
+ // (direct file open / <img>). Hex literals because host CSS vars don't exist
31
+ // there. Mirrors styles/tokens.css light + dark neutral values.
32
+ export const DIAGRAM_THEME_CSS =
33
+ ':root{--diagram-ink:#1A1A19;--diagram-paper:#FDFCF9;--diagram-grid:#B5B3AA}' +
34
+ '@media (prefers-color-scheme:dark){:root{' +
35
+ '--diagram-ink:#E8E5DD;--diagram-paper:#1A1816;--diagram-grid:#3A3632}}';
36
+
37
+ const DIAGRAM_VAR = {
38
+ ink: '--diagram-ink',
39
+ paper: '--diagram-paper',
40
+ grid: '--diagram-grid',
41
+ };
42
+
43
+ /**
44
+ * Parse a solid SVG paint into {r,g,b} in [0,1], or null when it is not a
45
+ * concrete color we should remap (none / url(...) / currentColor / inherit).
46
+ * Handles pdftocairo's `rgb(R%, G%, B%)` plus `rgb(r,g,b)` and #hex for safety.
47
+ */
48
+ export function parseColor(value) {
49
+ if (typeof value !== 'string') return null;
50
+ const v = value.trim();
51
+ let m;
52
+ if ((m = v.match(/^rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/i))) {
53
+ return { r: +m[1] / 100, g: +m[2] / 100, b: +m[3] / 100 };
54
+ }
55
+ if ((m = v.match(/^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/i))) {
56
+ return { r: +m[1] / 255, g: +m[2] / 255, b: +m[3] / 255 };
57
+ }
58
+ if ((m = v.match(/^#([0-9a-f]{3})$/i))) {
59
+ const [a, b, c] = m[1];
60
+ return { r: parseInt(a + a, 16) / 255, g: parseInt(b + b, 16) / 255, b: parseInt(c + c, 16) / 255 };
61
+ }
62
+ if ((m = v.match(/^#([0-9a-f]{6})$/i))) {
63
+ return {
64
+ r: parseInt(m[1].slice(0, 2), 16) / 255,
65
+ g: parseInt(m[1].slice(2, 4), 16) / 255,
66
+ b: parseInt(m[1].slice(4, 6), 16) / 255,
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Classify a color as 'ink' | 'paper' | 'grid', or null to leave it untouched.
74
+ * Saturated colors (chroma over threshold) are intentional accents and keep
75
+ * their hue across themes; only near-neutral colors are remapped, split by
76
+ * relative luminance into dark ink / light paper / mid gridlines.
77
+ */
78
+ export function classifyColor(value) {
79
+ const c = parseColor(value);
80
+ if (!c) return null;
81
+ const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
82
+ if (chroma > 0.12) return null; // saturated accent — preserve as authored
83
+ const lum = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
84
+ if (lum < 0.3) return 'ink';
85
+ if (lum > 0.9) return 'paper';
86
+ return 'grid';
87
+ }
88
+
89
+ // Escape a value for use inside a CSS [attr="…"] selector string.
90
+ function cssAttrEscape(s) {
91
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
92
+ }
93
+
94
+ /**
95
+ * Rewrite a pdftocairo SVG to be theme-aware + carry role="img". Pure and
96
+ * idempotent (a second pass is a no-op). `opts.optOut` short-circuits, returning
97
+ * the input unchanged (the `%! no-theme` authoring escape hatch).
98
+ *
99
+ * Injects, right after the opening <svg> tag:
100
+ * <style data-diagram-theme> — standalone var defaults + @media(dark).
101
+ * <style data-diagram-map> — `[fill="C"]{fill:var(--diagram-X, C)}` … per
102
+ * distinct neutral color C. Elements are NOT
103
+ * modified; the attribute stays as fallback.
104
+ */
105
+ export function recolorSvg(svg, { optOut = false } = {}) {
106
+ if (typeof svg !== 'string') return svg;
107
+ if (optOut || svg.includes('data-diagram-map')) return svg;
108
+
109
+ const openMatch = svg.match(/<svg\b[^>]*>/i);
110
+ if (!openMatch) return svg;
111
+
112
+ // Distinct concrete colors used as fill="" / stroke="" attributes.
113
+ const found = new Map(); // original string → class
114
+ const attrRe = /\b(?:fill|stroke)="([^"]+)"/g;
115
+ let am;
116
+ while ((am = attrRe.exec(svg)) !== null) {
117
+ if (found.has(am[1])) continue;
118
+ const cls = classifyColor(am[1]);
119
+ if (cls) found.set(am[1], cls);
120
+ }
121
+
122
+ let openTag = openMatch[0];
123
+ if (!/\srole=/i.test(openTag)) openTag = openTag.replace(/<svg\b/i, '<svg role="img"');
124
+
125
+ // Nothing neutral to remap — still surface role="img" for a11y, no <style>.
126
+ if (found.size === 0) {
127
+ return openTag === openMatch[0]
128
+ ? svg
129
+ : svg.slice(0, openMatch.index) + openTag + svg.slice(openMatch.index + openMatch[0].length);
130
+ }
131
+
132
+ let mapCss = '';
133
+ for (const [orig, cls] of found) {
134
+ const v = DIAGRAM_VAR[cls];
135
+ const sel = cssAttrEscape(orig);
136
+ mapCss +=
137
+ `[fill="${sel}"]{fill:var(${v}, ${orig})}` +
138
+ `[stroke="${sel}"]{stroke:var(${v}, ${orig})}`;
139
+ }
140
+
141
+ const styleBlocks =
142
+ `<style data-diagram-theme>${DIAGRAM_THEME_CSS}</style>` +
143
+ `<style data-diagram-map>${mapCss}</style>`;
144
+
145
+ return (
146
+ svg.slice(0, openMatch.index) +
147
+ openTag +
148
+ styleBlocks +
149
+ svg.slice(openMatch.index + openMatch[0].length)
150
+ );
151
+ }
152
+
153
+ // ── Render side ────────────────────────────────────────────────────────────
154
+
155
+ /** Should <Figure> inline `src` (local pipeline .svg) vs render <img>? */
156
+ export function shouldInline(src) {
157
+ return (
158
+ typeof src === 'string' &&
159
+ src.startsWith('/') &&
160
+ !src.startsWith('//') && // protocol-relative = remote host
161
+ /\.svg$/i.test(src)
162
+ );
163
+ }
164
+
165
+ const THEME_BLOCK_RE = /<style\b[^>]*\bdata-diagram-theme\b[^>]*>[\s\S]*?<\/style>/gi;
166
+
167
+ /** Remove the standalone self-theming block so the host tokens.css is the sole
168
+ * source of --diagram-* once the SVG is inlined into the page. */
169
+ export function stripThemeBlock(svg) {
170
+ return typeof svg === 'string' ? svg.replace(THEME_BLOCK_RE, '') : svg;
171
+ }
172
+
173
+ function escapeXml(s) {
174
+ return String(s)
175
+ .replace(/&/g, '&amp;')
176
+ .replace(/</g, '&lt;')
177
+ .replace(/>/g, '&gt;')
178
+ .replace(/"/g, '&quot;');
179
+ }
180
+
181
+ function ensureSvgAttr(openTag, name, value) {
182
+ const re = new RegExp(`\\s${name}=`, 'i');
183
+ if (re.test(openTag)) return openTag;
184
+ return openTag.replace(/<svg\b/i, `<svg ${name}="${value}"`);
185
+ }
186
+
187
+ function setSvgAttr(openTag, name, value) {
188
+ const re = new RegExp(`\\s${name}="[^"]*"`, 'i');
189
+ if (re.test(openTag)) return openTag.replace(re, ` ${name}="${value}"`);
190
+ return openTag.replace(/<svg\b/i, `<svg ${name}="${value}"`);
191
+ }
192
+
193
+ function mergeSvgStyle(openTag, css) {
194
+ const re = /\sstyle="([^"]*)"/i;
195
+ const m = openTag.match(re);
196
+ if (m) {
197
+ const existing = m[1].trim().replace(/;\s*$/, '');
198
+ return openTag.replace(re, ` style="${existing ? existing + ';' : ''}${css}"`);
199
+ }
200
+ return openTag.replace(/<svg\b/i, `<svg style="${css}"`);
201
+ }
202
+
203
+ /**
204
+ * Prepare a raw pipeline SVG for inline embedding in <Figure>:
205
+ * - strip the standalone <style data-diagram-theme> (host tokens.css themes it);
206
+ * - replace any pre-existing <title>/<desc> with ones from the call-site props
207
+ * (caption → <title>, desc ?? alt → <desc>) and wire aria-labelledby;
208
+ * - ensure role="img" and a responsive width on the root <svg>.
209
+ * Pure string transform; output is a trusted local build artifact (set:html).
210
+ */
211
+ export function assembleSvg(raw, opts = {}) {
212
+ const { caption, alt, desc, width = '100%', idBase = 'figure' } = opts;
213
+ if (typeof raw !== 'string') return '';
214
+
215
+ let svg = stripThemeBlock(raw);
216
+ const openMatch = svg.match(/<svg\b[^>]*>/i);
217
+ if (!openMatch) return svg;
218
+
219
+ let openTag = openMatch[0];
220
+ let body = svg
221
+ .slice(openMatch.index + openTag.length)
222
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '')
223
+ .replace(/<desc\b[^>]*>[\s\S]*?<\/desc>/gi, '');
224
+
225
+ const titleText = caption ?? alt ?? '';
226
+ const descText = desc ?? (alt && alt !== titleText ? alt : '');
227
+ const id = String(idBase).replace(/[^a-zA-Z0-9_-]/g, '-');
228
+
229
+ const a11y = [];
230
+ const labelledby = [];
231
+ if (titleText) {
232
+ a11y.push(`<title id="${id}-title">${escapeXml(titleText)}</title>`);
233
+ labelledby.push(`${id}-title`);
234
+ }
235
+ if (descText) {
236
+ a11y.push(`<desc id="${id}-desc">${escapeXml(descText)}</desc>`);
237
+ labelledby.push(`${id}-desc`);
238
+ }
239
+
240
+ openTag = ensureSvgAttr(openTag, 'role', 'img');
241
+ if (labelledby.length) openTag = setSvgAttr(openTag, 'aria-labelledby', labelledby.join(' '));
242
+ openTag = mergeSvgStyle(openTag, `width:${width};max-width:100%;height:auto`);
243
+
244
+ return `${svg.slice(0, openMatch.index)}${openTag}${a11y.join('')}${body}`;
245
+ }
package/styles/tokens.css CHANGED
@@ -51,6 +51,15 @@
51
51
  --callout-worked: var(--warm-plum);
52
52
  --callout-learn: var(--warm-gold);
53
53
 
54
+ /* Diagram semantic roles (v4.11.0, #84): theme-aware TikZ→SVG figures.
55
+ * build-figures remaps an SVG's neutral fills/strokes to these via
56
+ * var(--diagram-*, <original>); <Figure> inlines the SVG so this cascade
57
+ * reaches it. They point at existing roles, so they auto-flip in dark mode
58
+ * (no dark-block edits) — ink↔text, paper↔page bg, grid↔border. */
59
+ --diagram-ink: var(--color-text);
60
+ --diagram-paper: var(--color-bg);
61
+ --diagram-grid: var(--color-border);
62
+
54
63
  /* ===== Typography scale ===== */
55
64
  --font-body: 'Roboto Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
56
65
  --font-code: 'Source Code Pro Variable', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;