@brandon_m_behring/book-scaffold-astro 4.30.0 → 5.0.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 +40 -3
- package/MIGRATION-v4-to-v5.md +183 -0
- package/README.md +30 -1
- package/bin/book-scaffold.mjs +1 -1
- package/components/AssessmentTest.astro +26 -5
- package/components/BookLink.astro +6 -3
- package/components/ChapterNav.astro +1 -1
- package/components/Cite.astro +37 -4
- package/components/ExerciseSolutions.astro +24 -4
- package/components/Figure.astro +7 -6
- package/components/Flashcards.tsx +19 -11
- package/components/NavContent.astro +60 -11
- package/components/ObjectiveMap.astro +14 -2
- package/components/PartReview.astro +32 -4
- package/components/PatternTimeline.astro +6 -2
- package/components/Rationale.astro +20 -3
- package/components/Sidebar.astro +11 -3
- package/components/SourceArchive.astro +33 -3
- package/components/Term.astro +18 -1
- package/components/Theorem.astro +15 -5
- package/components/TipsCard.astro +40 -3
- package/components/XRef.astro +14 -2
- package/dist/components/ExamRunner.d.ts +1 -1
- package/dist/components/Flashcards.d.ts +6 -1
- package/dist/components/Flashcards.mjs +14 -11
- package/dist/{exam-manifest-X9IrX1G3.d.ts → exam-manifest-DttY7kyZ.d.ts} +1 -1
- package/dist/index.d.ts +76 -8
- package/dist/index.mjs +517 -43
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +311 -32
- package/dist/{types-DgSlAew3.d.ts → types-D1QZgKMO.d.ts} +73 -21
- package/layouts/Base.astro +38 -9
- package/layouts/Chapter.astro +10 -2
- package/package.json +6 -2
- package/pages/answers.astro +25 -6
- package/pages/book.astro +75 -0
- package/pages/chapters/[...slug].astro +39 -7
- package/pages/chapters-book.astro +17 -0
- package/pages/chapters.astro +87 -9
- package/pages/convergence.astro +40 -10
- package/pages/corpus-apparatus/answers.astro +15 -0
- package/pages/corpus-apparatus/convergence.astro +15 -0
- package/pages/corpus-apparatus/exercises.astro +15 -0
- package/pages/corpus-apparatus/flashcards.astro +15 -0
- package/pages/corpus-apparatus/glossary.astro +15 -0
- package/pages/corpus-apparatus/practice-exam.astro +15 -0
- package/pages/corpus-apparatus/print.astro +15 -0
- package/pages/corpus-apparatus/references.astro +15 -0
- package/pages/corpus-apparatus/tips.astro +15 -0
- package/pages/exercises.astro +18 -5
- package/pages/flashcards.astro +26 -5
- package/pages/glossary.astro +20 -3
- package/pages/index.astro +54 -1
- package/pages/practice-exam.astro +8 -2
- package/pages/print.astro +7 -2
- package/pages/references.astro +26 -6
- package/pages/search.astro +65 -4
- package/pages/tips.astro +17 -4
- package/recipes/03-asset-pipelines.md +12 -4
- package/recipes/09-validation.md +1 -1
- package/recipes/15-defining-styles.md +6 -6
- package/recipes/16-tikz-figures.md +13 -2
- package/recipes/20-anki-export.md +15 -8
- package/recipes/21-multi-guide-single-app.md +293 -44
- package/recipes/22-responsive-nav-and-multibook-routing.md +7 -1
- package/recipes/24-figure-authoring-standard.md +241 -0
- package/recipes/README.md +3 -2
- package/scripts/build-bib.mjs +113 -35
- package/scripts/build-exercises.mjs +101 -24
- package/scripts/build-figures.mjs +13 -10
- package/scripts/build-labels.mjs +199 -194
- package/scripts/build-tips.mjs +95 -23
- package/scripts/corpus-tooling.mjs +268 -0
- package/scripts/render-notebooks.mjs +2 -1
- package/scripts/resolve-book-config.mjs +99 -1
- package/scripts/sync-figure-tokens.mjs +44 -0
- package/scripts/validate.mjs +676 -100
- package/scripts/walk-mdx.mjs +16 -4
- package/src/lib/book-link.ts +72 -0
- package/src/lib/chapters.ts +10 -4
- package/src/lib/corpus-collateral.ts +9 -0
- package/src/lib/corpus.ts +458 -0
- package/src/lib/define-style.ts +28 -15
- package/src/lib/exam-manifest.ts +4 -1
- package/src/lib/figure-palette.mjs +162 -0
- package/src/lib/figure.mjs +113 -37
- package/src/lib/patterns.ts +15 -6
- package/src/lib/questions.ts +8 -3
- package/src/types.ts +603 -0
- package/styles/tokens.css +73 -9
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Recipe 24 — Figure authoring standard
|
|
2
|
+
|
|
3
|
+
**Profile**: any profile that publishes diagrams, plots, or TikZ figures.
|
|
4
|
+
|
|
5
|
+
**TL;DR**: Use `--fig-*` when a color means something and `--series-1` through
|
|
6
|
+
`--series-8` when colors only distinguish data series. Author exported figures
|
|
7
|
+
with the canonical hex plus a separate opacity; `build:figures` maps those
|
|
8
|
+
colors to theme-aware CSS variables. Every figure needs a useful caption and
|
|
9
|
+
accessible description, and color must never be the only way to read it.
|
|
10
|
+
|
|
11
|
+
## 1. Choose the right color contract
|
|
12
|
+
|
|
13
|
+
Semantic colors keep the same meaning across a book:
|
|
14
|
+
|
|
15
|
+
| Token | Meaning | Light | Dark | Export hex |
|
|
16
|
+
|---|---|---|---|---|
|
|
17
|
+
| `--fig-blue` | default, lightweight, informational | `#3B6FA0` | `#7297BB` | `#3B6FA0` |
|
|
18
|
+
| `--fig-green` | positive or successful outcome | `#4A7E3F` | `#7DA275` | `#4A7E3F` |
|
|
19
|
+
| `--fig-rose` | caution or problem | `#C06858` | `#D29287` | `#C06858` |
|
|
20
|
+
| `--fig-plum` | authority, control, or heaviest weight | `#8A4E82` | `#AB80A5` | `#8A4E82` |
|
|
21
|
+
| `--fig-gold` | packaging, coordination, or convergence | `#9D7D34` | `#D2B575` | `#C09840` |
|
|
22
|
+
| `--fig-crimson` | failure or severe problem | `#A03838` | `#BB7070` | `#A03838` |
|
|
23
|
+
| `--fig-ink` | labels, essential outlines, axes | `#1A1A19` | `#E8E5DD` | `#1A1A19` or black |
|
|
24
|
+
| `--fig-paper` | figure background | `#FDFCF9` | `#1A1816` | `#FDFCF9` or white |
|
|
25
|
+
| `--fig-grid` | essential gridlines and secondary structure | `#8C8981` | `#746E67` | `#B5B3AA` or a mid-neutral |
|
|
26
|
+
|
|
27
|
+
`--fig-gold` resolves to a slightly darker gold in the light theme so an
|
|
28
|
+
essential stroke clears 3:1 against `--fig-paper`. The export rewrite still
|
|
29
|
+
recognizes the canonical `#C09840` authoring value.
|
|
30
|
+
|
|
31
|
+
Categorical plots use the stable Okabe–Ito order:
|
|
32
|
+
|
|
33
|
+
| Ordinal | Token | Canonical color |
|
|
34
|
+
|---:|---|---|
|
|
35
|
+
| 1 | `--series-1` | orange `#E69F00` |
|
|
36
|
+
| 2 | `--series-2` | sky blue `#56B4E9` |
|
|
37
|
+
| 3 | `--series-3` | bluish green `#009E73` |
|
|
38
|
+
| 4 | `--series-4` | yellow `#F0E442` |
|
|
39
|
+
| 5 | `--series-5` | blue `#0072B2` |
|
|
40
|
+
| 6 | `--series-6` | vermillion `#D55E00` |
|
|
41
|
+
| 7 | `--series-7` | reddish purple `#CC79A7` |
|
|
42
|
+
| 8 | `--series-8` | theme ink (canonical authoring color: black) |
|
|
43
|
+
|
|
44
|
+
Series 1–7 use the same canonical value in light and dark; each already clears
|
|
45
|
+
3:1 against the dark figure paper. Series 8 changes with `--fig-ink`.
|
|
46
|
+
|
|
47
|
+
Assign series in that order and keep the ordinal attached to the same series
|
|
48
|
+
when a chart or theme changes. Never use `--series-*` to mean good, bad,
|
|
49
|
+
warning, or success; those meanings belong to `--fig-*`.
|
|
50
|
+
|
|
51
|
+
After PDF export, canonical series-8 black is indistinguishable from labels and
|
|
52
|
+
axes. The rewrite therefore maps it to structural ink; `--series-8` aliases the
|
|
53
|
+
same `--fig-ink` value, so it still renders correctly in both themes. Use
|
|
54
|
+
`var(--series-8)` directly in a CSS-native SVG if you need to override that
|
|
55
|
+
ordinal independently.
|
|
56
|
+
|
|
57
|
+
## 2. CSS-native SVG: the preferred hand-authored form
|
|
58
|
+
|
|
59
|
+
Use variables directly. Pale fills should be computed from the current figure
|
|
60
|
+
paper, while their solid borders carry the category:
|
|
61
|
+
|
|
62
|
+
```svg
|
|
63
|
+
<svg role="img" aria-labelledby="pipeline-title pipeline-desc"
|
|
64
|
+
viewBox="0 0 420 140" xmlns="http://www.w3.org/2000/svg">
|
|
65
|
+
<title id="pipeline-title">A three-stage publishing pipeline</title>
|
|
66
|
+
<desc id="pipeline-desc">
|
|
67
|
+
Draft flows to review and then publication. Review is the only caution stage.
|
|
68
|
+
</desc>
|
|
69
|
+
<style>
|
|
70
|
+
.label { fill: var(--fig-ink); font: 16px sans-serif; }
|
|
71
|
+
.edge { fill: none; stroke: var(--fig-ink); stroke-width: 2; }
|
|
72
|
+
.draft {
|
|
73
|
+
fill: color-mix(in srgb, var(--fig-blue) 14%, var(--fig-paper));
|
|
74
|
+
stroke: var(--fig-blue);
|
|
75
|
+
}
|
|
76
|
+
.review {
|
|
77
|
+
fill: color-mix(in srgb, var(--fig-rose) 14%, var(--fig-paper));
|
|
78
|
+
stroke: var(--fig-rose);
|
|
79
|
+
}
|
|
80
|
+
.published {
|
|
81
|
+
fill: color-mix(in srgb, var(--fig-green) 14%, var(--fig-paper));
|
|
82
|
+
stroke: var(--fig-green);
|
|
83
|
+
}
|
|
84
|
+
.node { stroke-width: 3; }
|
|
85
|
+
</style>
|
|
86
|
+
<!-- geometry omitted -->
|
|
87
|
+
</svg>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
When this is a local SVG rendered by `<Figure>`, the host page supplies the
|
|
91
|
+
variables and its light/dark toggle changes the figure immediately. An SVG
|
|
92
|
+
loaded from another origin cannot inherit host variables.
|
|
93
|
+
|
|
94
|
+
For a categorical chart, use `stroke: var(--series-1)`, then `--series-2`, and
|
|
95
|
+
so on. Give every line a second channel too: different dashes, markers, or a
|
|
96
|
+
direct text label.
|
|
97
|
+
|
|
98
|
+
## 3. TikZ and matplotlib exports
|
|
99
|
+
|
|
100
|
+
PDF cannot carry CSS variables. `build:figures` therefore recognizes the exact
|
|
101
|
+
Warm–Tol and Okabe–Ito authoring colors in the generated SVG and maps them back
|
|
102
|
+
to variables. Unknown saturated colors stay exactly as authored.
|
|
103
|
+
|
|
104
|
+
The important rule is **base color plus separate opacity**. Do not use a
|
|
105
|
+
pre-blended low-chroma color such as `figblue!13`: after PDF export it is only
|
|
106
|
+
an anonymous near-white RGB and can be mistaken for figure paper.
|
|
107
|
+
|
|
108
|
+
TikZ:
|
|
109
|
+
|
|
110
|
+
```latex
|
|
111
|
+
\definecolor{figblue}{HTML}{3B6FA0}
|
|
112
|
+
\definecolor{figrose}{HTML}{C06858}
|
|
113
|
+
\definecolor{seriesone}{HTML}{E69F00}
|
|
114
|
+
|
|
115
|
+
\node[
|
|
116
|
+
draw=figblue,
|
|
117
|
+
fill=figblue,
|
|
118
|
+
fill opacity=.14,
|
|
119
|
+
text opacity=1,
|
|
120
|
+
line width=1.2pt,
|
|
121
|
+
] (draft) {Draft};
|
|
122
|
+
\node[draw=figrose, fill=figrose, fill opacity=.14, text opacity=1] (review) {Review};
|
|
123
|
+
\draw[seriesone, very thick, dashed] plot coordinates {(0,0) (1,2) (2,3)};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Matplotlib:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import matplotlib.pyplot as plt
|
|
130
|
+
|
|
131
|
+
FIG = {
|
|
132
|
+
"blue": "#3B6FA0",
|
|
133
|
+
"rose": "#C06858",
|
|
134
|
+
"green": "#4A7E3F",
|
|
135
|
+
}
|
|
136
|
+
SERIES = [
|
|
137
|
+
"#E69F00", "#56B4E9", "#009E73", "#F0E442",
|
|
138
|
+
"#0072B2", "#D55E00", "#CC79A7", "#000000",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
fig, ax = plt.subplots()
|
|
142
|
+
# Keep the canvas transparent: the host's --fig-paper must show through after
|
|
143
|
+
# PDF → SVG conversion instead of baking a light-only rectangle into the plot.
|
|
144
|
+
fig.patch.set_alpha(0)
|
|
145
|
+
ax.set_facecolor("none")
|
|
146
|
+
ax.fill_between(x, low, high, color=FIG["blue"], alpha=.14)
|
|
147
|
+
ax.plot(x, mean, color=FIG["blue"], linewidth=2)
|
|
148
|
+
ax.plot(x, baseline, color=SERIES[0], linestyle="--", marker="o", label="Baseline")
|
|
149
|
+
fig.savefig(
|
|
150
|
+
"figures/results/summary.pdf",
|
|
151
|
+
format="pdf",
|
|
152
|
+
transparent=True,
|
|
153
|
+
bbox_inches="tight",
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Then run `npm run build:figures` to preserve the vector PDF→SVG path. Export a
|
|
158
|
+
raster (`.png`) only when the source is true pixel data, such as a photograph
|
|
159
|
+
or instrument raster. Lines, text, markers, and diagram geometry should remain
|
|
160
|
+
vector PDF/SVG. Re-run the command after upgrading the scaffold so
|
|
161
|
+
already-generated SVGs receive new mappings; the rewrite is idempotent.
|
|
162
|
+
|
|
163
|
+
## 4. Accessible name and description
|
|
164
|
+
|
|
165
|
+
Use `<Figure>` as the accessibility boundary:
|
|
166
|
+
|
|
167
|
+
```mdx
|
|
168
|
+
<Figure
|
|
169
|
+
src="/figures/results/summary.svg"
|
|
170
|
+
id="fig-results-summary"
|
|
171
|
+
caption="Review catches most defects before publication."
|
|
172
|
+
alt="Three-stage publishing pipeline"
|
|
173
|
+
desc="Draft flows right to review, then publication. The review node is marked as a caution stage, and the publication node is marked as a successful outcome."
|
|
174
|
+
/>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `caption` states the point the reader should take from the figure.
|
|
178
|
+
- `alt` is a short accessible name, not a duplicate of every visible label.
|
|
179
|
+
- `desc` gives reading order, relationships, trend, and any conclusion carried
|
|
180
|
+
by position, shape, line style, or color.
|
|
181
|
+
- For a local pipeline SVG, `<Figure>` replaces stale `<title>`/`<desc>` nodes
|
|
182
|
+
with these props and wires `aria-labelledby` automatically.
|
|
183
|
+
- Raster and remote images remain `<img>` elements, so give them complete alt
|
|
184
|
+
text and put any longer explanation in adjacent prose.
|
|
185
|
+
|
|
186
|
+
Do not write “blue means success” as the only description. Name the category
|
|
187
|
+
and the redundant cue: “the successful outcome is the green node with a check
|
|
188
|
+
mark.”
|
|
189
|
+
|
|
190
|
+
## 5. Contrast and color-vision rules
|
|
191
|
+
|
|
192
|
+
Okabe–Ito is color-vision-deficiency-friendly; that does not make every hue a
|
|
193
|
+
high-contrast thin line on light paper. In particular, orange, sky blue,
|
|
194
|
+
yellow, and reddish purple need redundant encoding.
|
|
195
|
+
|
|
196
|
+
- Never encode a distinction by color alone. Add marker shape, dash pattern,
|
|
197
|
+
direct labels, texture, or position.
|
|
198
|
+
- Use `--fig-ink` for axes, text, arrowheads, and essential outlines.
|
|
199
|
+
- Give pale semantic areas a solid `--fig-*` border. The semantic strokes and
|
|
200
|
+
`--fig-grid` clear WCAG's 3:1 non-text contrast threshold in both themes.
|
|
201
|
+
- Outline small categorical marks with `--fig-ink`; use thicker/dashed lines
|
|
202
|
+
and markers for categorical series that do not clear 3:1 by color alone.
|
|
203
|
+
- Do not reorder `--series-*` in dark mode. `--series-8` follows theme ink so
|
|
204
|
+
the neutral series remains visible rather than staying literal black.
|
|
205
|
+
|
|
206
|
+
## 6. Dual-theme release gate
|
|
207
|
+
|
|
208
|
+
Before publishing a new figure:
|
|
209
|
+
|
|
210
|
+
1. Run `npm run build:figures` and confirm the SVG contains one
|
|
211
|
+
`data-diagram-map` style block.
|
|
212
|
+
2. Render it through `<Figure>` with explicit light and dark page themes.
|
|
213
|
+
3. Open the standalone SVG with both OS color schemes; its embedded defaults
|
|
214
|
+
should also switch.
|
|
215
|
+
4. Check labels, axes, arrowheads, boundaries, and all series at the smallest
|
|
216
|
+
published size.
|
|
217
|
+
5. Inspect a grayscale or common CVD simulation and verify that markers,
|
|
218
|
+
dashes, labels, or shapes preserve every distinction.
|
|
219
|
+
6. Confirm the caption and screen-reader description communicate the takeaway
|
|
220
|
+
without relying on color names.
|
|
221
|
+
|
|
222
|
+
If automatic recoloring is genuinely wrong for a special TikZ figure, add a
|
|
223
|
+
`%! no-theme` line to its `.tex` source and supply a fully authored light/dark
|
|
224
|
+
solution yourself. That escape hatch opts out of both palette and neutral
|
|
225
|
+
mapping.
|
|
226
|
+
|
|
227
|
+
## Canonical files
|
|
228
|
+
|
|
229
|
+
- `src/lib/figure-palette.mjs` — sole palette manifest + generated CSS/theme renderers
|
|
230
|
+
- `styles/tokens.css` — public `--fig-*`, `--series-*`, and compatibility tokens
|
|
231
|
+
- `src/lib/figure.mjs` — exact palette recognition and SVG theme injection
|
|
232
|
+
- `scripts/sync-figure-tokens.mjs` — checks or refreshes the generated token block
|
|
233
|
+
- `scripts/build-figures.mjs` — PDF/TikZ conversion pipeline
|
|
234
|
+
- `components/Figure.astro` — inlining plus accessible `<title>`/`<desc>`
|
|
235
|
+
- `tests/figure-palette.test.mjs` — token, mapping, theme, and contrast contract
|
|
236
|
+
|
|
237
|
+
## See also
|
|
238
|
+
|
|
239
|
+
- [Recipe 03 — Asset pipelines](03-asset-pipelines.md)
|
|
240
|
+
- [Recipe 04 — Component library](04-component-library.md)
|
|
241
|
+
- [Recipe 16 — TikZ figures](16-tikz-figures.md)
|
package/recipes/README.md
CHANGED
|
@@ -26,9 +26,10 @@ Terse pointers into canonical code for the most common book-authoring workflows.
|
|
|
26
26
|
| 18 | [Chapter route ownership](18-chapter-route-ownership.md) | any | When to override the auto-injected `/chapters/[...slug]/` route |
|
|
27
27
|
| 19 | [Prevalidate hook](19-prevalidate-hook.md) | any | Wire `prevalidate` to run `build:bib` + `build:labels` before `validate` |
|
|
28
28
|
| 20 | [Anki deck export (consumer-side)](20-anki-export.md) | any (esp. course-notes, research-portfolio) | Roll-your-own `<AnkiCard>` + extractor; scaffold deliberately doesn't ship this |
|
|
29
|
-
| 21 | [Multiple
|
|
30
|
-
| 22 | [Responsive navigation and
|
|
29
|
+
| 21 | [Multiple books in one app](21-multi-guide-single-app.md) | any | First-class `defineBookCorpus`: routes, scoped data/search, `--book`, and v4 migration |
|
|
30
|
+
| 22 | [Responsive navigation and custom routing](22-responsive-nav-and-multibook-routing.md) | any | Mobile/desktop nav and the v4-compatible consumer-owned route-token API |
|
|
31
31
|
| 23 | [Interactive demo substrate](23-interactive-demo-substrate.md) | any | Opt-in Preact shell, slider, stat cards, theme colors, a11y, and reduced motion |
|
|
32
|
+
| 24 | [Figure authoring standard](24-figure-authoring-standard.md) | any | Warm–Tol semantics, Okabe–Ito series, dual-theme exports, contrast, and accessible descriptions |
|
|
32
33
|
|
|
33
34
|
## How to read recipes
|
|
34
35
|
|
package/scripts/build-bib.mjs
CHANGED
|
@@ -40,6 +40,8 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
|
40
40
|
import { dirname, resolve } from 'node:path';
|
|
41
41
|
import { fileURLToPath } from 'node:url';
|
|
42
42
|
import { readEnvFile } from './read-env.mjs';
|
|
43
|
+
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
44
|
+
import { mergeCorpusArtifact, resolveBookSelection } from './corpus-tooling.mjs';
|
|
43
45
|
|
|
44
46
|
// --help / -h: non-mutating (closes #14).
|
|
45
47
|
const USAGE = `Usage: book-scaffold build-bib
|
|
@@ -54,6 +56,7 @@ Env:
|
|
|
54
56
|
.env; default: ./bibliography.bib).
|
|
55
57
|
|
|
56
58
|
Options:
|
|
59
|
+
--book <id> In corpus mode, rebuild only one registered book.
|
|
57
60
|
--help, -h Print this message and exit (non-mutating).
|
|
58
61
|
`;
|
|
59
62
|
|
|
@@ -77,8 +80,13 @@ const BIB_PATH = configuredBibPath
|
|
|
77
80
|
const OUT_PATH = resolve(PROJECT_ROOT, 'src/data/references.json');
|
|
78
81
|
const SOURCES_PATH = resolve(PROJECT_ROOT, 'sources/manifest.yaml');
|
|
79
82
|
const SOURCES_OUT = resolve(PROJECT_ROOT, 'src/data/sources.json');
|
|
83
|
+
let DIAGNOSTIC_SCOPE = null;
|
|
80
84
|
|
|
81
|
-
|
|
85
|
+
function isRecord(value) {
|
|
86
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function buildReferences(selection) {
|
|
82
90
|
// Graceful skip when the .bib file is absent (minimal/tools profile, or
|
|
83
91
|
// an academic book that hasn't authored citations yet). Emits an empty
|
|
84
92
|
// references.json so consumers can still `import refs from '...'`.
|
|
@@ -87,15 +95,10 @@ async function buildReferences() {
|
|
|
87
95
|
bibText = await readFile(BIB_PATH, 'utf8');
|
|
88
96
|
} catch (err) {
|
|
89
97
|
if (err.code === 'ENOENT') {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
);
|
|
94
|
-
await mkdir(dirname(OUT_PATH), { recursive: true });
|
|
95
|
-
await writeFile(OUT_PATH, '{}\n', 'utf8');
|
|
96
|
-
return;
|
|
98
|
+
bibText = null;
|
|
99
|
+
} else {
|
|
100
|
+
throw err;
|
|
97
101
|
}
|
|
98
|
-
throw err;
|
|
99
102
|
}
|
|
100
103
|
// v4.0.0 (closes #54): strip `%`-comment lines before passing to citation-js.
|
|
101
104
|
// The plugin-bibtex lexer doesn't honor BibTeX's %-line-comment semantics —
|
|
@@ -107,13 +110,14 @@ async function buildReferences() {
|
|
|
107
110
|
// BibTeX's real comment grammar is "% at the start of a line (after optional
|
|
108
111
|
// whitespace) to end of line". Mid-line `%` (e.g., `note = {50% confidence}`)
|
|
109
112
|
// is NOT a comment and is preserved.
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
const data = bibText === null
|
|
114
|
+
? []
|
|
115
|
+
: new Cite(
|
|
116
|
+
bibText
|
|
117
|
+
.split(/\r?\n/)
|
|
118
|
+
.map((line) => (line.trimStart().startsWith('%') ? '' : line))
|
|
119
|
+
.join('\n'),
|
|
120
|
+
).data;
|
|
117
121
|
|
|
118
122
|
// Detect duplicates the way biber would (citation-js silently
|
|
119
123
|
// overwrites the earlier entry, which is the opposite of what we want).
|
|
@@ -124,19 +128,53 @@ async function buildReferences() {
|
|
|
124
128
|
seen.add(entry.id);
|
|
125
129
|
}
|
|
126
130
|
if (dupes.length > 0) {
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
const prefix = selection.corpus ? '[book:corpus] ' : '';
|
|
132
|
+
console.error(`${prefix}build-bib: ${dupes.length} duplicate bibkeys:`);
|
|
133
|
+
for (const id of dupes) console.error(`${prefix}build-bib: - ${id}`);
|
|
129
134
|
process.exit(1);
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
const byKey = Object.fromEntries(data.map((entry) => [entry.id, entry]));
|
|
133
138
|
|
|
139
|
+
if (selection.corpus) {
|
|
140
|
+
DIAGNOSTIC_SCOPE = 'corpus';
|
|
141
|
+
}
|
|
142
|
+
const output = selection.corpus
|
|
143
|
+
? await mergeCorpusArtifact({
|
|
144
|
+
path: OUT_PATH,
|
|
145
|
+
corpus: selection.corpus,
|
|
146
|
+
requestedBook: selection.requestedBook,
|
|
147
|
+
values: new Map(selection.books.map((book) => [book.id, byKey])),
|
|
148
|
+
emptyValue: () => ({}),
|
|
149
|
+
artifact: 'src/data/references.json',
|
|
150
|
+
validateValue: isRecord,
|
|
151
|
+
})
|
|
152
|
+
: byKey;
|
|
134
153
|
await mkdir(dirname(OUT_PATH), { recursive: true });
|
|
135
|
-
await writeFile(OUT_PATH, JSON.stringify(
|
|
154
|
+
await writeFile(OUT_PATH, JSON.stringify(output, null, 2) + '\n', 'utf8');
|
|
136
155
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
if (selection.corpus) {
|
|
157
|
+
for (const book of selection.books) {
|
|
158
|
+
console.log(
|
|
159
|
+
`[book:${book.id}] build-bib: ${data.length} entries -> ` +
|
|
160
|
+
OUT_PATH.replace(PROJECT_ROOT + '/', ''),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
console.log(
|
|
164
|
+
`[book:corpus] build-bib: ${data.length * selection.books.length} namespaced entries ` +
|
|
165
|
+
`across ${selection.books.length} book${selection.books.length === 1 ? '' : 's'} -> ` +
|
|
166
|
+
OUT_PATH.replace(PROJECT_ROOT + '/', ''),
|
|
167
|
+
);
|
|
168
|
+
} else if (bibText === null) {
|
|
169
|
+
console.log(
|
|
170
|
+
`build-bib: ${BIB_PATH.replace(PROJECT_ROOT + '/', '')} not found — ` +
|
|
171
|
+
`emitting empty references.json (no citations to process).`,
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
console.log(
|
|
175
|
+
`build-bib: ${data.length} entries -> ${OUT_PATH.replace(PROJECT_ROOT + '/', '')}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
140
178
|
}
|
|
141
179
|
|
|
142
180
|
// v4.10.0 (closes #85): tools-profile books keep their sources in
|
|
@@ -147,17 +185,24 @@ async function buildReferences() {
|
|
|
147
185
|
// references.json. Absent manifest -> no file written (academic/minimal books
|
|
148
186
|
// degrade to empty, exactly like a missing .bib). YAML is lazy-imported so the
|
|
149
187
|
// --help / no-manifest paths stay dependency-free.
|
|
150
|
-
async function buildSources() {
|
|
188
|
+
async function buildSources(selection) {
|
|
151
189
|
let yamlText;
|
|
152
190
|
try {
|
|
153
191
|
yamlText = await readFile(SOURCES_PATH, 'utf8');
|
|
154
192
|
} catch (err) {
|
|
155
|
-
if (err.code === 'ENOENT')
|
|
156
|
-
|
|
193
|
+
if (err.code === 'ENOENT') {
|
|
194
|
+
// A corpus artifact must be a complete, deterministic envelope. Clear
|
|
195
|
+
// stale source data when the shared manifest disappears; legacy
|
|
196
|
+
// single-book mode retains its historical no-file/no-write behavior.
|
|
197
|
+
if (!selection.corpus) return;
|
|
198
|
+
yamlText = null;
|
|
199
|
+
} else {
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
157
202
|
}
|
|
158
203
|
|
|
159
204
|
const { parse } = await import('yaml');
|
|
160
|
-
const parsed = parse(yamlText);
|
|
205
|
+
const parsed = yamlText === null ? null : parse(yamlText);
|
|
161
206
|
// The manifest is a YAML array of source objects. Keep only well-formed
|
|
162
207
|
// entries (a string `id` is the citation key + the /references anchor target).
|
|
163
208
|
// A blank or comments-only manifest parses to null/undefined/[].
|
|
@@ -165,21 +210,54 @@ async function buildSources() {
|
|
|
165
210
|
? parsed.filter((s) => s && typeof s.id === 'string')
|
|
166
211
|
: [];
|
|
167
212
|
|
|
213
|
+
const output = selection.corpus
|
|
214
|
+
? await mergeCorpusArtifact({
|
|
215
|
+
path: SOURCES_OUT,
|
|
216
|
+
corpus: selection.corpus,
|
|
217
|
+
requestedBook: selection.requestedBook,
|
|
218
|
+
values: new Map(selection.books.map((book) => [book.id, sources])),
|
|
219
|
+
emptyValue: () => [],
|
|
220
|
+
artifact: 'src/data/sources.json',
|
|
221
|
+
validateValue: Array.isArray,
|
|
222
|
+
})
|
|
223
|
+
: sources;
|
|
168
224
|
await mkdir(dirname(SOURCES_OUT), { recursive: true });
|
|
169
|
-
await writeFile(SOURCES_OUT, JSON.stringify(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
225
|
+
await writeFile(SOURCES_OUT, JSON.stringify(output, null, 2) + '\n', 'utf8');
|
|
226
|
+
if (selection.corpus) {
|
|
227
|
+
for (const book of selection.books) {
|
|
228
|
+
console.log(
|
|
229
|
+
`[book:${book.id}] build-bib: ${sources.length} ` +
|
|
230
|
+
`source${sources.length === 1 ? '' : 's'} -> ` +
|
|
231
|
+
SOURCES_OUT.replace(PROJECT_ROOT + '/', ''),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
console.log(
|
|
235
|
+
`[book:corpus] build-bib: ${sources.length * selection.books.length} namespaced ` +
|
|
236
|
+
`source${sources.length * selection.books.length === 1 ? '' : 's'} across ` +
|
|
237
|
+
`${selection.books.length} book${selection.books.length === 1 ? '' : 's'} -> ` +
|
|
238
|
+
SOURCES_OUT.replace(PROJECT_ROOT + '/', ''),
|
|
239
|
+
);
|
|
240
|
+
} else {
|
|
241
|
+
console.log(
|
|
242
|
+
`build-bib: ${sources.length} source${sources.length === 1 ? '' : 's'} -> ` +
|
|
243
|
+
`${SOURCES_OUT.replace(PROJECT_ROOT + '/', '')}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
174
246
|
}
|
|
175
247
|
|
|
176
248
|
async function main() {
|
|
177
|
-
await
|
|
178
|
-
|
|
249
|
+
const toolingConfig = await loadResolvedBookConfig(PROJECT_ROOT);
|
|
250
|
+
if (toolingConfig.corpus) DIAGNOSTIC_SCOPE = 'corpus';
|
|
251
|
+
const selection = resolveBookSelection(toolingConfig, process.argv.slice(2), 'build-bib');
|
|
252
|
+
await buildReferences(selection);
|
|
253
|
+
await buildSources(selection);
|
|
179
254
|
}
|
|
180
255
|
|
|
181
256
|
main().catch((err) => {
|
|
182
|
-
|
|
183
|
-
|
|
257
|
+
const message = String(err?.message ?? err);
|
|
258
|
+
const prefix = DIAGNOSTIC_SCOPE ? `[book:${DIAGNOSTIC_SCOPE}] ` : '';
|
|
259
|
+
console.error(
|
|
260
|
+
message.startsWith('[book:') ? message : `${prefix}build-bib: failed: ${message}`,
|
|
261
|
+
);
|
|
184
262
|
process.exit(1);
|
|
185
263
|
});
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
|
25
25
|
import { resolve, dirname, basename } from 'node:path';
|
|
26
26
|
import { walkMdx, readChaptersBase } from './walk-mdx.mjs';
|
|
27
|
+
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
28
|
+
import {
|
|
29
|
+
assertLegacyBookMatches,
|
|
30
|
+
frontmatterSlug,
|
|
31
|
+
mergeCorpusArtifact,
|
|
32
|
+
resolveBookSelection,
|
|
33
|
+
} from './corpus-tooling.mjs';
|
|
27
34
|
|
|
28
35
|
const USAGE = `Usage: book-scaffold build-exercises
|
|
29
36
|
|
|
@@ -36,6 +43,7 @@ Env:
|
|
|
36
43
|
BOOK_EXERCISES_OUT Override output path (default: src/data/exercises.json).
|
|
37
44
|
|
|
38
45
|
Options:
|
|
46
|
+
--book <id> In corpus mode, rebuild only one registered book.
|
|
39
47
|
--help, -h Print this message and exit (non-mutating).
|
|
40
48
|
`;
|
|
41
49
|
|
|
@@ -45,8 +53,8 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
const CWD = process.cwd();
|
|
48
|
-
const CHAPTERS_DIR = await readChaptersBase(CWD);
|
|
49
56
|
const OUTPUT_PATH = process.env.BOOK_EXERCISES_OUT ?? 'src/data/exercises.json';
|
|
57
|
+
let DIAGNOSTIC_SCOPE = null;
|
|
50
58
|
|
|
51
59
|
/**
|
|
52
60
|
* Extract <Exercise id="..."> tags and their body content from MDX.
|
|
@@ -85,39 +93,108 @@ function extractExercises(source) {
|
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
async function main() {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
const toolingConfig = await loadResolvedBookConfig(CWD);
|
|
97
|
+
if (toolingConfig.corpus) DIAGNOSTIC_SCOPE = 'corpus';
|
|
98
|
+
const selection = resolveBookSelection(
|
|
99
|
+
toolingConfig,
|
|
100
|
+
process.argv.slice(2),
|
|
101
|
+
'build-exercises',
|
|
102
|
+
);
|
|
103
|
+
DIAGNOSTIC_SCOPE = selection.corpus ? 'corpus' : null;
|
|
104
|
+
const chaptersRoot = await readChaptersBase(CWD, { corpus: selection.corpus });
|
|
105
|
+
const runs = selection.corpus
|
|
106
|
+
? selection.books.map((book) => ({ book, dir: resolve(chaptersRoot, book.id) }))
|
|
107
|
+
: [{ book: null, dir: chaptersRoot }];
|
|
108
|
+
const values = new Map();
|
|
109
|
+
let corpusExercises = 0;
|
|
110
|
+
let corpusChapters = 0;
|
|
111
|
+
|
|
112
|
+
for (const run of runs) {
|
|
113
|
+
const byChapter = {};
|
|
114
|
+
let totalExercises = 0;
|
|
115
|
+
let chaptersWithExercises = 0;
|
|
91
116
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
117
|
+
for await (const rel of walkMdx(run.dir)) {
|
|
118
|
+
const chapterPath = resolve(run.dir, rel);
|
|
119
|
+
let source;
|
|
120
|
+
try {
|
|
121
|
+
source = await readFile(chapterPath, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (run.book) {
|
|
126
|
+
assertLegacyBookMatches(
|
|
127
|
+
source,
|
|
128
|
+
run.book,
|
|
129
|
+
`[book:${run.book.id}] ${chapterPath.replace(`${CWD}/`, '')}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const fileLabel = run.book
|
|
133
|
+
? `[book:${run.book.id}] ${chapterPath.replace(`${CWD}/`, '')}`
|
|
134
|
+
: chapterPath.replace(`${CWD}/`, '');
|
|
135
|
+
const chapterSlug = run.book
|
|
136
|
+
? frontmatterSlug(source, fileLabel) ?? rel.replace(/\.mdx?$/, '')
|
|
137
|
+
: basename(rel).replace(/\.mdx?$/, '');
|
|
138
|
+
const exercises = extractExercises(source);
|
|
139
|
+
if (exercises.length > 0) {
|
|
140
|
+
byChapter[chapterSlug] = exercises;
|
|
141
|
+
totalExercises += exercises.length;
|
|
142
|
+
chaptersWithExercises += 1;
|
|
143
|
+
}
|
|
100
144
|
}
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
byChapter
|
|
104
|
-
|
|
105
|
-
|
|
145
|
+
|
|
146
|
+
if (run.book) {
|
|
147
|
+
values.set(run.book.id, byChapter);
|
|
148
|
+
corpusExercises += totalExercises;
|
|
149
|
+
corpusChapters += chaptersWithExercises;
|
|
150
|
+
process.stdout.write(
|
|
151
|
+
`[book:${run.book.id}] build-exercises: ${totalExercises} ` +
|
|
152
|
+
`exercise${totalExercises === 1 ? '' : 's'} across ${chaptersWithExercises} ` +
|
|
153
|
+
`chapter${chaptersWithExercises === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
|
|
154
|
+
);
|
|
155
|
+
} else {
|
|
156
|
+
values.set('', byChapter);
|
|
157
|
+
corpusExercises = totalExercises;
|
|
158
|
+
corpusChapters = chaptersWithExercises;
|
|
106
159
|
}
|
|
107
160
|
}
|
|
108
161
|
|
|
109
162
|
const outPath = resolve(CWD, OUTPUT_PATH);
|
|
163
|
+
const output = selection.corpus
|
|
164
|
+
? await mergeCorpusArtifact({
|
|
165
|
+
path: outPath,
|
|
166
|
+
corpus: selection.corpus,
|
|
167
|
+
requestedBook: selection.requestedBook,
|
|
168
|
+
values,
|
|
169
|
+
emptyValue: () => ({}),
|
|
170
|
+
artifact: OUTPUT_PATH,
|
|
171
|
+
validateValue: (value) =>
|
|
172
|
+
value !== null && typeof value === 'object' && !Array.isArray(value),
|
|
173
|
+
})
|
|
174
|
+
: values.get('');
|
|
110
175
|
await mkdir(dirname(outPath), { recursive: true });
|
|
111
|
-
await writeFile(outPath, JSON.stringify(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
176
|
+
await writeFile(outPath, JSON.stringify(output, null, 2) + '\n');
|
|
177
|
+
if (selection.corpus) {
|
|
178
|
+
process.stdout.write(
|
|
179
|
+
`[book:corpus] build-exercises: ${corpusExercises} ` +
|
|
180
|
+
`exercise${corpusExercises === 1 ? '' : 's'} across ${corpusChapters} ` +
|
|
181
|
+
`chapter${corpusChapters === 1 ? '' : 's'} and ${selection.books.length} ` +
|
|
182
|
+
`book${selection.books.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
process.stdout.write(
|
|
186
|
+
`build-exercises: ${corpusExercises} exercise${corpusExercises === 1 ? '' : 's'} across ` +
|
|
187
|
+
`${corpusChapters} chapter${corpusChapters === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
116
190
|
}
|
|
117
191
|
|
|
118
192
|
main().catch((err) => {
|
|
119
|
-
|
|
120
|
-
|
|
193
|
+
const message = String(err?.message ?? err);
|
|
194
|
+
const prefix = DIAGNOSTIC_SCOPE ? `[book:${DIAGNOSTIC_SCOPE}] ` : '';
|
|
195
|
+
console.error(
|
|
196
|
+
message.startsWith('[book:') ? message : `${prefix}build-exercises: failed: ${message}`,
|
|
197
|
+
);
|
|
121
198
|
process.exit(1);
|
|
122
199
|
});
|
|
123
200
|
|