@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 +9 -2
- package/bin/book-scaffold.mjs +2 -1
- package/components/Figure.astro +36 -4
- package/components/SourceArchive.astro +7 -7
- package/package.json +13 -6
- package/pages/references.astro +104 -53
- package/recipes/04-component-library.md +1 -1
- package/scripts/build-bib.mjs +53 -4
- 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
|
@@ -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 ->
|
|
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>
|
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
* always reflects
|
|
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
|
|
12
|
-
*
|
|
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.
|
|
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.
|
|
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": {
|
|
152
|
-
|
|
153
|
-
|
|
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",
|
package/pages/references.astro
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
{
|
|
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
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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><Citation></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} />` |
|
package/scripts/build-bib.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* scripts/build-bib.mjs — Bibliography
|
|
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
|
|
41
|
-
BOOK_BIB_PATH if set)
|
|
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
|
|
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
|
-
*
|
|
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;
|