@brandon_m_behring/book-scaffold-astro 4.10.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 +9 -2
- package/bin/book-scaffold.mjs +1 -0
- package/components/Figure.astro +36 -4
- package/package.json +1 -1
- package/recipes/04-component-library.md +1 -1
- package/scripts/build-figures.mjs +55 -4
- package/src/lib/figure.mjs +245 -0
- package/styles/tokens.css +9 -0
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
|
|
package/bin/book-scaffold.mjs
CHANGED
|
@@ -29,6 +29,7 @@ Sub-commands:
|
|
|
29
29
|
build-labels Emit src/data/labels.json for cross-references (Phase C).
|
|
30
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.
|
package/components/Figure.astro
CHANGED
|
@@ -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.
|
|
8
|
-
*
|
|
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
|
-
|
|
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>
|
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.
|
|
4
|
+
"version": "4.11.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Brandon Behring",
|
|
@@ -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} />` |
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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, '&')
|
|
176
|
+
.replace(/</g, '<')
|
|
177
|
+
.replace(/>/g, '>')
|
|
178
|
+
.replace(/"/g, '"');
|
|
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;
|