@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
|
@@ -25,21 +25,22 @@
|
|
|
25
25
|
*
|
|
26
26
|
* v4.11.0 (closes #84): each generated SVG gets a post-export rewrite
|
|
27
27
|
* (recolorSvg) that injects role="img" + a CSS-variable theming layer so one
|
|
28
|
-
* SVG serves light + dark.
|
|
29
|
-
* var(--
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* SVG serves light + dark. Known Warm–Tol semantic and Okabe–Ito categorical
|
|
29
|
+
* colors are mapped to var(--fig-*|--series-*, <original>); neutral paints map
|
|
30
|
+
* to var(--diagram-ink|paper|grid, <original>). Unknown saturated colors stay
|
|
31
|
+
* authored. A `%! no-theme` line in the source .tex opts a figure out.
|
|
32
32
|
* <Figure> inlines local SVGs so they track the in-page [data-theme] toggle.
|
|
33
33
|
*
|
|
34
34
|
* Idempotent: skips when the target SVG is newer than the source PDF (and the
|
|
35
35
|
* recolor itself is a no-op on an already-themed SVG, so re-runs after an
|
|
36
36
|
* upgrade safely theme pre-existing figures).
|
|
37
|
-
* Run
|
|
37
|
+
* Run explicitly with `npm run build:figures`; optional system tools keep this
|
|
38
|
+
* authoring pipeline out of the generated project's `prebuild` hook.
|
|
38
39
|
*
|
|
39
40
|
* Graceful skip: when pdftocairo / pdftoppm aren't on PATH (e.g. Cloudflare
|
|
40
41
|
* build container), the script warns and exits 0. Committed SVGs/PNGs under
|
|
41
|
-
* public/figures/ are served as-is. Local
|
|
42
|
-
* from PDFs
|
|
42
|
+
* public/figures/ are served as-is. Local authors with poppler-utils regenerate
|
|
43
|
+
* from PDFs when source figures change.
|
|
43
44
|
*/
|
|
44
45
|
import { readdir, stat, mkdir } from 'node:fs/promises';
|
|
45
46
|
import { existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -56,8 +57,10 @@ Figure pipeline. PDF -> SVG via pdftocairo (PNG fallback via pdftoppm at
|
|
|
56
57
|
Graceful-skip if pdftocairo / pdftoppm not on PATH.
|
|
57
58
|
|
|
58
59
|
Each SVG is rewritten to be accessible + dark-mode-aware: role="img" plus
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
exact Warm–Tol/Okabe–Ito palette mappings and neutral
|
|
61
|
+
var(--diagram-ink|paper|grid, orig) mappings. Use base color + separate opacity,
|
|
62
|
+
not a pre-blended tint. A "%! no-theme" line in the source .tex opts out.
|
|
63
|
+
<Figure> inlines local SVGs so they track the theme.
|
|
61
64
|
|
|
62
65
|
Env:
|
|
63
66
|
BOOK_FIGURES_PATH Override figures source (default: figures/).
|
|
@@ -202,7 +205,7 @@ const NO_THEME_RE = /^\s*%!\s*no-theme\b/m;
|
|
|
202
205
|
/**
|
|
203
206
|
* v4.11.0 (#84): apply the theming rewrite to a generated SVG in place.
|
|
204
207
|
* No-op (returns false) if the file is absent or recolorSvg leaves it unchanged
|
|
205
|
-
* (already themed / nothing
|
|
208
|
+
* (already themed / nothing themeable to remap). Idempotent.
|
|
206
209
|
*/
|
|
207
210
|
function themeIfSvg(svgPath, optOut) {
|
|
208
211
|
if (!existsSync(svgPath)) return false;
|
package/scripts/build-labels.mjs
CHANGED
|
@@ -48,12 +48,18 @@
|
|
|
48
48
|
*
|
|
49
49
|
* Designed to run in <2 s on a medium book.
|
|
50
50
|
*/
|
|
51
|
-
import { readFile, writeFile, mkdir
|
|
52
|
-
import { resolve, relative,
|
|
51
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
52
|
+
import { resolve, relative, dirname, sep } from 'node:path';
|
|
53
53
|
import { pathToFileURL } from 'node:url';
|
|
54
54
|
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
|
|
55
|
-
import { readChaptersBase } from './walk-mdx.mjs';
|
|
55
|
+
import { readChaptersBase, walkMdx } from './walk-mdx.mjs';
|
|
56
56
|
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
57
|
+
import {
|
|
58
|
+
assertLegacyBookMatches,
|
|
59
|
+
mergeCorpusArtifact,
|
|
60
|
+
parseFrontmatter,
|
|
61
|
+
resolveBookSelection,
|
|
62
|
+
} from './corpus-tooling.mjs';
|
|
57
63
|
// #126: reuse the ONE kind vocabulary (theorem-label.ts → its own lean tsup
|
|
58
64
|
// entry) so a <Theorem kind="proposition"> xref reads "Proposition N.M", not a
|
|
59
65
|
// kind-blind "Theorem N.M" — and so an unknown/absent kind FAILS HERE (same
|
|
@@ -76,6 +82,7 @@ Env:
|
|
|
76
82
|
BOOK_LABELS_OUT Override output path (default: src/data/labels.json).
|
|
77
83
|
|
|
78
84
|
Options:
|
|
85
|
+
--book <id> In corpus mode, rebuild only one registered book.
|
|
79
86
|
--help, -h Print this message and exit (non-mutating).
|
|
80
87
|
|
|
81
88
|
Numbering and chapter hrefs are read from evaluated defineBookConfig metadata.
|
|
@@ -87,16 +94,8 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
87
94
|
process.exit(0);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
// v4.1.1 (closes #63): readChaptersBase honors BOOK_CHAPTERS_DIR env (when set)
|
|
91
|
-
// then parses the consumer's content.config.{ts,mjs,js} for a `chapters`
|
|
92
|
-
// collection `loader.base` override. Multi-guide consumers use
|
|
93
|
-
// `src/content/<guide-slug>/` rather than the Astro 5 default.
|
|
94
|
-
const CHAPTERS_DIR_ABS = await readChaptersBase(process.cwd());
|
|
95
|
-
// build-labels uses CHAPTERS_DIR as a path relative to cwd elsewhere in the
|
|
96
|
-
// script (joined with `walkMdx`). Convert the absolute path back to relative
|
|
97
|
-
// for compatibility with the existing call sites.
|
|
98
|
-
const CHAPTERS_DIR = relative(process.cwd(), CHAPTERS_DIR_ABS) || 'src/content/chapters';
|
|
99
97
|
const OUTPUT_PATH = process.env.BOOK_LABELS_OUT ?? 'src/data/labels.json';
|
|
98
|
+
let DIAGNOSTIC_SCOPE = null;
|
|
100
99
|
|
|
101
100
|
/** Component names that participate in cross-referencing. */
|
|
102
101
|
const LABELABLE_TYPES = [
|
|
@@ -118,27 +117,6 @@ const TYPE_DISPLAY = {
|
|
|
118
117
|
CaseStudy: 'Case study',
|
|
119
118
|
};
|
|
120
119
|
|
|
121
|
-
// ===== Frontmatter parsing =====
|
|
122
|
-
|
|
123
|
-
function splitFrontmatter(source) {
|
|
124
|
-
// Standard MDX/YAML frontmatter: `---\n…\n---`.
|
|
125
|
-
// Remove it before Markdown processing: YAML comments beginning with `#`
|
|
126
|
-
// must not become phantom headings.
|
|
127
|
-
const m = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
128
|
-
if (!m) return { frontmatter: {}, body: source };
|
|
129
|
-
const fm = {};
|
|
130
|
-
for (const line of m[1].split(/\r?\n/)) {
|
|
131
|
-
const kv = line.match(/^(\w+)\s*:\s*(.+?)\s*$/);
|
|
132
|
-
if (!kv) continue;
|
|
133
|
-
const [, key, raw] = kv;
|
|
134
|
-
// Strip quotes; coerce numeric scalars.
|
|
135
|
-
let val = raw.replace(/^["']|["']$/g, '');
|
|
136
|
-
if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
|
|
137
|
-
fm[key] = val;
|
|
138
|
-
}
|
|
139
|
-
return { frontmatter: fm, body: source.slice(m[0].length) };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
120
|
function chapterNumberOf(frontmatter) {
|
|
143
121
|
// Tools profile uses `chapter`; academic uses `week`. Prefer chapter.
|
|
144
122
|
if (typeof frontmatter.chapter === 'number') return frontmatter.chapter;
|
|
@@ -169,194 +147,221 @@ function extractAttr(attrsBlob, name) {
|
|
|
169
147
|
return null;
|
|
170
148
|
}
|
|
171
149
|
|
|
172
|
-
// ===== Filesystem walk =====
|
|
173
|
-
|
|
174
|
-
async function walkChapters(dir) {
|
|
175
|
-
const out = [];
|
|
176
|
-
let entries;
|
|
177
|
-
try {
|
|
178
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
179
|
-
} catch (err) {
|
|
180
|
-
if (err.code === 'ENOENT') return out;
|
|
181
|
-
throw err;
|
|
182
|
-
}
|
|
183
|
-
for (const e of entries) {
|
|
184
|
-
const path = join(dir, e.name);
|
|
185
|
-
if (e.isDirectory()) {
|
|
186
|
-
out.push(...(await walkChapters(path)));
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
if (!e.isFile()) continue;
|
|
190
|
-
if (!/\.mdx?$/.test(e.name)) continue;
|
|
191
|
-
if (e.name.startsWith('_')) continue; // hidden by convention
|
|
192
|
-
out.push(path);
|
|
193
|
-
}
|
|
194
|
-
return out;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
150
|
// ===== Main =====
|
|
198
151
|
|
|
199
152
|
async function main() {
|
|
200
153
|
const cwd = process.cwd();
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
const
|
|
154
|
+
const toolingConfig = await loadResolvedBookConfig(cwd);
|
|
155
|
+
if (toolingConfig.corpus) DIAGNOSTIC_SCOPE = 'corpus';
|
|
156
|
+
const { numberStyle, chapterRoute, bookField } = toolingConfig;
|
|
157
|
+
const selection = resolveBookSelection(
|
|
158
|
+
toolingConfig,
|
|
159
|
+
process.argv.slice(2),
|
|
160
|
+
'build-labels',
|
|
161
|
+
);
|
|
162
|
+
DIAGNOSTIC_SCOPE = selection.corpus ? 'corpus' : null;
|
|
163
|
+
const chaptersRoot = await readChaptersBase(cwd, { corpus: selection.corpus });
|
|
164
|
+
const runs = selection.corpus
|
|
165
|
+
? selection.books.map((book) => ({ book, dir: resolve(chaptersRoot, book.id) }))
|
|
166
|
+
: [{ book: null, dir: chaptersRoot }];
|
|
167
|
+
|
|
204
168
|
// Syntax highlighting cannot affect heading metadata and is expensive to
|
|
205
169
|
// initialize. Everything that does affect Astro heading text/IDs (GFM,
|
|
206
170
|
// smartypants, rehypeHeadingIds/GitHubSlugger) retains Astro's defaults.
|
|
207
171
|
const headingProcessor = await createMarkdownProcessor({ syntaxHighlight: false });
|
|
208
|
-
|
|
209
|
-
const labels = {};
|
|
210
172
|
const tagRegex = buildTagRegex();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
for (const
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
'/',
|
|
229
|
-
bookField,
|
|
230
|
-
).replace(/^\/+|\/+$/g, '');
|
|
231
|
-
|
|
232
|
-
const addLabel = (id, value) => {
|
|
233
|
-
if (labels[id]) {
|
|
234
|
-
// Duplicate id — surface but don't fail; consumer's validator catches
|
|
235
|
-
// collisions with full diagnostic context. Component IDs retain their
|
|
236
|
-
// historical precedence over an identically named heading anchor.
|
|
237
|
-
process.stderr.write(
|
|
238
|
-
`build-labels: WARN duplicate id "${id}" (first in ` +
|
|
239
|
-
`${labels[id].href.split('#')[0]}, now in ${entryId})\n`,
|
|
173
|
+
const values = new Map();
|
|
174
|
+
const stats = [];
|
|
175
|
+
|
|
176
|
+
for (const run of runs) {
|
|
177
|
+
const files = [];
|
|
178
|
+
for await (const file of walkMdx(run.dir)) files.push(resolve(run.dir, file));
|
|
179
|
+
const labels = {};
|
|
180
|
+
let totalIds = 0;
|
|
181
|
+
let chaptersWithIds = 0;
|
|
182
|
+
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
const source = await readFile(file, 'utf8');
|
|
185
|
+
if (run.book) {
|
|
186
|
+
assertLegacyBookMatches(
|
|
187
|
+
source,
|
|
188
|
+
run.book,
|
|
189
|
+
`[book:${run.book.id}] ${relative(cwd, file)}`,
|
|
240
190
|
);
|
|
241
191
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
display: heading.text,
|
|
269
|
-
number: null,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
for (const match of source.matchAll(tagRegex)) {
|
|
274
|
-
const [, componentName, attrs] = match;
|
|
275
|
-
const id = extractAttr(attrs, 'id');
|
|
276
|
-
if (!id) continue;
|
|
277
|
-
|
|
278
|
-
foundInChapter += 1;
|
|
279
|
-
totalIds += 1;
|
|
280
|
-
|
|
281
|
-
// Resolve the display word only when it will actually be used. A `label=`
|
|
282
|
-
// override supplies its own display, so we neither compute nor (for
|
|
283
|
-
// <Theorem>) kind-validate it — computing would throw on a kindless
|
|
284
|
-
// override, the documented `<Theorem id label="…">` form. For <Theorem>
|
|
285
|
-
// the word is kind-aware and THROWS on an absent/unknown kind (the #121
|
|
286
|
-
// contract, one build step earlier than render). extractAttr returns null
|
|
287
|
-
// for an absent attr → normalize to undefined so theoremLabel reports
|
|
288
|
-
// "no kind=" rather than the misleading kind="null".
|
|
289
|
-
const labelOverride = extractAttr(attrs, 'label');
|
|
290
|
-
let word;
|
|
291
|
-
let theoremKind;
|
|
292
|
-
if (labelOverride == null) {
|
|
293
|
-
if (componentName === 'Theorem') {
|
|
294
|
-
try {
|
|
295
|
-
const resolvedLabel = theoremLabel({
|
|
296
|
-
kind: extractAttr(attrs, 'kind') ?? undefined,
|
|
297
|
-
type: extractAttr(attrs, 'type') ?? undefined,
|
|
298
|
-
});
|
|
299
|
-
word = resolvedLabel.fullLabel;
|
|
300
|
-
theoremKind = resolvedLabel.kind;
|
|
301
|
-
} catch (err) {
|
|
192
|
+
const fileLabel = run.book
|
|
193
|
+
? `[book:${run.book.id}] ${relative(cwd, file)}`
|
|
194
|
+
: relative(cwd, file);
|
|
195
|
+
const { frontmatter: fm, body } = parseFrontmatter(source, fileLabel);
|
|
196
|
+
const chapterNum = chapterNumberOf(fm);
|
|
197
|
+
const contentId = relative(run.dir, file)
|
|
198
|
+
.split(sep)
|
|
199
|
+
.join('/')
|
|
200
|
+
.replace(/\.mdx?$/, '');
|
|
201
|
+
const localEntryId = (typeof fm.slug === 'string' && fm.slug.length > 0)
|
|
202
|
+
? fm.slug
|
|
203
|
+
: contentId;
|
|
204
|
+
const entryId = run.book ? `${run.book.id}/${localEntryId}` : localEntryId;
|
|
205
|
+
const chapterPath = chapterHref(
|
|
206
|
+
{ id: entryId, data: fm },
|
|
207
|
+
chapterRoute,
|
|
208
|
+
'/',
|
|
209
|
+
bookField,
|
|
210
|
+
).replace(/^\/+|\/+$/g, '');
|
|
211
|
+
|
|
212
|
+
const addLabel = (id, value) => {
|
|
213
|
+
if (labels[id]) {
|
|
214
|
+
// Component IDs are book-local in corpus mode, so identical ids in
|
|
215
|
+
// another namespace do not collide.
|
|
216
|
+
const prefix = run.book ? `[book:${run.book.id}] ` : '';
|
|
217
|
+
if (run.book) {
|
|
302
218
|
throw new Error(
|
|
303
|
-
|
|
219
|
+
`${prefix}duplicate label id "${id}" (first in ` +
|
|
220
|
+
`${labels[id].href.split('#')[0]}, now in ${entryId}).`,
|
|
304
221
|
);
|
|
305
222
|
}
|
|
306
|
-
|
|
307
|
-
|
|
223
|
+
process.stderr.write(
|
|
224
|
+
`${prefix}build-labels: WARN duplicate id "${id}" (first in ` +
|
|
225
|
+
`${labels[id].href.split('#')[0]}, now in ${entryId})\n`,
|
|
226
|
+
);
|
|
308
227
|
}
|
|
228
|
+
labels[id] = value;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Per-chapter counters reset for each file.
|
|
232
|
+
const counters = {};
|
|
233
|
+
let foundInChapter = 0;
|
|
234
|
+
|
|
235
|
+
// #147: BookLink fragments normally target prose sections rather than
|
|
236
|
+
// labelable components. Astro owns both text extraction and GitHub-style
|
|
237
|
+
// slug collision behavior, so consume its heading metadata directly.
|
|
238
|
+
const rendered = await headingProcessor.render(body, {
|
|
239
|
+
fileURL: pathToFileURL(file),
|
|
240
|
+
frontmatter: fm,
|
|
241
|
+
});
|
|
242
|
+
for (const heading of rendered.metadata.headings) {
|
|
243
|
+
if (heading.depth < 2 || heading.depth > 6) continue;
|
|
244
|
+
foundInChapter += 1;
|
|
245
|
+
totalIds += 1;
|
|
246
|
+
const href = `${chapterPath}#${heading.slug}`;
|
|
247
|
+
addLabel(`heading:${href}`, {
|
|
248
|
+
href,
|
|
249
|
+
display: heading.text,
|
|
250
|
+
number: null,
|
|
251
|
+
});
|
|
309
252
|
}
|
|
310
253
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
254
|
+
for (const match of source.matchAll(tagRegex)) {
|
|
255
|
+
const [, componentName, attrs] = match;
|
|
256
|
+
const id = extractAttr(attrs, 'id');
|
|
257
|
+
if (!id) continue;
|
|
258
|
+
|
|
259
|
+
foundInChapter += 1;
|
|
260
|
+
totalIds += 1;
|
|
261
|
+
|
|
262
|
+
const labelOverride = extractAttr(attrs, 'label');
|
|
263
|
+
let word;
|
|
264
|
+
let theoremKind;
|
|
265
|
+
if (labelOverride == null) {
|
|
266
|
+
if (componentName === 'Theorem') {
|
|
267
|
+
try {
|
|
268
|
+
const resolvedLabel = theoremLabel({
|
|
269
|
+
kind: extractAttr(attrs, 'kind') ?? undefined,
|
|
270
|
+
type: extractAttr(attrs, 'type') ?? undefined,
|
|
271
|
+
});
|
|
272
|
+
word = resolvedLabel.fullLabel;
|
|
273
|
+
theoremKind = resolvedLabel.kind;
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const prefix = run.book ? `[book:${run.book.id}] ` : '';
|
|
276
|
+
throw new Error(
|
|
277
|
+
`${prefix}<Theorem id="${id}"> in ${relative(cwd, file)}: ${err.message}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
word = TYPE_DISPLAY[componentName];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const counterKey =
|
|
286
|
+
numberStyle === 'per-kind' && componentName === 'Theorem'
|
|
287
|
+
? `Theorem/${theoremKind}`
|
|
288
|
+
: componentName;
|
|
289
|
+
if (labelOverride == null) {
|
|
290
|
+
counters[counterKey] = (counters[counterKey] ?? 0) + 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const number = labelOverride != null
|
|
294
|
+
? null
|
|
295
|
+
: chapterNum != null
|
|
296
|
+
? `${chapterNum}.${counters[counterKey]}`
|
|
297
|
+
: String(counters[counterKey]);
|
|
298
|
+
const display = labelOverride ?? `${word} ${number}`;
|
|
299
|
+
|
|
300
|
+
addLabel(id, {
|
|
301
|
+
href: `${chapterPath}#${id}`,
|
|
302
|
+
display,
|
|
303
|
+
number,
|
|
304
|
+
});
|
|
320
305
|
}
|
|
321
306
|
|
|
322
|
-
|
|
323
|
-
// `number` by id and renders it, so heading == xref by construction.
|
|
324
|
-
// A `label=` override opts out of auto-numbering → number is null.
|
|
325
|
-
const number = labelOverride != null
|
|
326
|
-
? null
|
|
327
|
-
: chapterNum != null
|
|
328
|
-
? `${chapterNum}.${counters[counterKey]}`
|
|
329
|
-
: String(counters[counterKey]);
|
|
330
|
-
const display = labelOverride ?? `${word} ${number}`;
|
|
331
|
-
|
|
332
|
-
addLabel(id, {
|
|
333
|
-
// #142: base-less ref — XRef.astro prefixes BASE_URL at render so one
|
|
334
|
-
// labels.json serves any deploy base (root or path-proxied series).
|
|
335
|
-
href: `${chapterPath}#${id}`,
|
|
336
|
-
display,
|
|
337
|
-
number,
|
|
338
|
-
});
|
|
307
|
+
if (foundInChapter > 0) chaptersWithIds += 1;
|
|
339
308
|
}
|
|
340
309
|
|
|
341
|
-
|
|
310
|
+
// Emit deterministic output: keys sorted alphabetically within each book.
|
|
311
|
+
const sorted = {};
|
|
312
|
+
for (const key of Object.keys(labels).sort()) sorted[key] = labels[key];
|
|
313
|
+
values.set(run.book?.id ?? '', sorted);
|
|
314
|
+
stats.push({ book: run.book, totalIds, chaptersWithIds });
|
|
342
315
|
}
|
|
343
316
|
|
|
344
|
-
// Emit deterministic output: keys sorted alphabetically.
|
|
345
|
-
const sorted = {};
|
|
346
|
-
for (const k of Object.keys(labels).sort()) sorted[k] = labels[k];
|
|
347
|
-
|
|
348
317
|
const outputPath = resolve(cwd, OUTPUT_PATH);
|
|
318
|
+
const output = selection.corpus
|
|
319
|
+
? await mergeCorpusArtifact({
|
|
320
|
+
path: outputPath,
|
|
321
|
+
corpus: selection.corpus,
|
|
322
|
+
requestedBook: selection.requestedBook,
|
|
323
|
+
values,
|
|
324
|
+
emptyValue: () => ({}),
|
|
325
|
+
artifact: OUTPUT_PATH,
|
|
326
|
+
validateValue: (value) =>
|
|
327
|
+
value !== null && typeof value === 'object' && !Array.isArray(value),
|
|
328
|
+
})
|
|
329
|
+
: values.get('');
|
|
349
330
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
350
|
-
await writeFile(outputPath, JSON.stringify(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
331
|
+
await writeFile(outputPath, JSON.stringify(output, null, 2) + '\n', 'utf8');
|
|
332
|
+
|
|
333
|
+
if (selection.corpus) {
|
|
334
|
+
for (const stat of stats) {
|
|
335
|
+
process.stdout.write(
|
|
336
|
+
`[book:${stat.book.id}] build-labels: ${stat.totalIds} ` +
|
|
337
|
+
`id${stat.totalIds === 1 ? '' : 's'} across ${stat.chaptersWithIds} ` +
|
|
338
|
+
`chapter${stat.chaptersWithIds === 1 ? '' : 's'} → ${OUTPUT_PATH} ` +
|
|
339
|
+
`(number-style=${numberStyle})\n`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
const totalIds = stats.reduce((sum, stat) => sum + stat.totalIds, 0);
|
|
343
|
+
const totalChapters = stats.reduce((sum, stat) => sum + stat.chaptersWithIds, 0);
|
|
344
|
+
process.stdout.write(
|
|
345
|
+
`[book:corpus] build-labels: ${totalIds} id${totalIds === 1 ? '' : 's'} across ` +
|
|
346
|
+
`${totalChapters} chapter${totalChapters === 1 ? '' : 's'} and ` +
|
|
347
|
+
`${stats.length} book${stats.length === 1 ? '' : 's'} → ${OUTPUT_PATH} ` +
|
|
348
|
+
`(number-style=${numberStyle})\n`,
|
|
349
|
+
);
|
|
350
|
+
} else {
|
|
351
|
+
const [stat] = stats;
|
|
352
|
+
process.stdout.write(
|
|
353
|
+
`build-labels: ${stat.totalIds} id${stat.totalIds === 1 ? '' : 's'} across ` +
|
|
354
|
+
`${stat.chaptersWithIds} chapter${stat.chaptersWithIds === 1 ? '' : 's'} → ` +
|
|
355
|
+
`${OUTPUT_PATH} (number-style=${numberStyle})\n`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
357
358
|
}
|
|
358
359
|
|
|
359
360
|
main().catch((err) => {
|
|
360
|
-
|
|
361
|
+
const message = String(err?.message ?? err);
|
|
362
|
+
const prefix = DIAGNOSTIC_SCOPE ? `[book:${DIAGNOSTIC_SCOPE}] ` : '';
|
|
363
|
+
process.stderr.write(
|
|
364
|
+
message.startsWith('[book:') ? `${message}\n` : `${prefix}build-labels: fatal: ${message}\n`,
|
|
365
|
+
);
|
|
361
366
|
process.exit(1);
|
|
362
367
|
});
|
package/scripts/build-tips.mjs
CHANGED
|
@@ -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-tips
|
|
29
36
|
|
|
@@ -36,6 +43,7 @@ Env:
|
|
|
36
43
|
BOOK_TIPS_OUT Override output path (default: src/data/tips.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_TIPS_OUT ?? 'src/data/tips.json';
|
|
57
|
+
let DIAGNOSTIC_SCOPE = null;
|
|
50
58
|
|
|
51
59
|
/**
|
|
52
60
|
* Extract <Tip n="..." title="..."> tags and their body content from MDX.
|
|
@@ -104,38 +112,102 @@ function extractTips(source, chapterSlug) {
|
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
async function main() {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
const toolingConfig = await loadResolvedBookConfig(CWD);
|
|
116
|
+
if (toolingConfig.corpus) DIAGNOSTIC_SCOPE = 'corpus';
|
|
117
|
+
const selection = resolveBookSelection(toolingConfig, process.argv.slice(2), 'build-tips');
|
|
118
|
+
DIAGNOSTIC_SCOPE = selection.corpus ? 'corpus' : null;
|
|
119
|
+
const chaptersRoot = await readChaptersBase(CWD, { corpus: selection.corpus });
|
|
120
|
+
const runs = selection.corpus
|
|
121
|
+
? selection.books.map((book) => ({ book, dir: resolve(chaptersRoot, book.id) }))
|
|
122
|
+
: [{ book: null, dir: chaptersRoot }];
|
|
123
|
+
const values = new Map();
|
|
124
|
+
let corpusTotal = 0;
|
|
125
|
+
|
|
126
|
+
for (const run of runs) {
|
|
127
|
+
const allTips = [];
|
|
128
|
+
for await (const rel of walkMdx(run.dir)) {
|
|
129
|
+
const chapterPath = resolve(run.dir, rel);
|
|
130
|
+
let source;
|
|
131
|
+
try {
|
|
132
|
+
source = await readFile(chapterPath, 'utf8');
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (run.book) {
|
|
137
|
+
assertLegacyBookMatches(
|
|
138
|
+
source,
|
|
139
|
+
run.book,
|
|
140
|
+
`[book:${run.book.id}] ${resolve(CWD, chapterPath).replace(`${CWD}/`, '')}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const fileLabel = run.book
|
|
144
|
+
? `[book:${run.book.id}] ${resolve(CWD, chapterPath).replace(`${CWD}/`, '')}`
|
|
145
|
+
: resolve(CWD, chapterPath).replace(`${CWD}/`, '');
|
|
146
|
+
const chapterSlug = run.book
|
|
147
|
+
? frontmatterSlug(source, fileLabel) ?? rel.replace(/\.mdx?$/, '')
|
|
148
|
+
: basename(rel).replace(/\.mdx?$/, '');
|
|
149
|
+
allTips.push(...extractTips(source, chapterSlug));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Sort by n; warn on duplicates (don't fail — the index page just shows duplicates).
|
|
153
|
+
allTips.sort((a, b) => a.n - b.n);
|
|
154
|
+
const seenN = new Set();
|
|
155
|
+
for (const tip of allTips) {
|
|
156
|
+
if (seenN.has(tip.n)) {
|
|
157
|
+
const prefix = run.book ? `[book:${run.book.id}] ` : '';
|
|
158
|
+
process.stderr.write(
|
|
159
|
+
`${prefix}build-tips: WARN duplicate Tip n="${tip.n}" (last wins on /tips index)\n`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
seenN.add(tip.n);
|
|
116
163
|
}
|
|
117
|
-
allTips.push(...extractTips(source, chapterSlug));
|
|
118
|
-
}
|
|
119
164
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
165
|
+
if (run.book) {
|
|
166
|
+
values.set(run.book.id, allTips);
|
|
167
|
+
corpusTotal += allTips.length;
|
|
168
|
+
process.stdout.write(
|
|
169
|
+
`[book:${run.book.id}] build-tips: ${allTips.length} ` +
|
|
170
|
+
`tip${allTips.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
values.set('', allTips);
|
|
126
174
|
}
|
|
127
|
-
seenN.add(tip.n);
|
|
128
175
|
}
|
|
129
176
|
|
|
130
177
|
const outPath = resolve(CWD, OUTPUT_PATH);
|
|
178
|
+
const output = selection.corpus
|
|
179
|
+
? await mergeCorpusArtifact({
|
|
180
|
+
path: outPath,
|
|
181
|
+
corpus: selection.corpus,
|
|
182
|
+
requestedBook: selection.requestedBook,
|
|
183
|
+
values,
|
|
184
|
+
emptyValue: () => [],
|
|
185
|
+
artifact: OUTPUT_PATH,
|
|
186
|
+
validateValue: Array.isArray,
|
|
187
|
+
})
|
|
188
|
+
: values.get('');
|
|
131
189
|
await mkdir(dirname(outPath), { recursive: true });
|
|
132
|
-
await writeFile(outPath, JSON.stringify(
|
|
133
|
-
|
|
190
|
+
await writeFile(outPath, JSON.stringify(output, null, 2) + '\n');
|
|
191
|
+
if (selection.corpus) {
|
|
192
|
+
process.stdout.write(
|
|
193
|
+
`[book:corpus] build-tips: ${corpusTotal} ` +
|
|
194
|
+
`tip${corpusTotal === 1 ? '' : 's'} across ${selection.books.length} ` +
|
|
195
|
+
`book${selection.books.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
|
|
196
|
+
);
|
|
197
|
+
} else {
|
|
198
|
+
const allTips = values.get('');
|
|
199
|
+
process.stdout.write(
|
|
200
|
+
`build-tips: ${allTips.length} tip${allTips.length === 1 ? '' : 's'} → ${OUTPUT_PATH}\n`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
134
203
|
}
|
|
135
204
|
|
|
136
205
|
main().catch((err) => {
|
|
137
|
-
|
|
138
|
-
|
|
206
|
+
const message = String(err?.message ?? err);
|
|
207
|
+
const prefix = DIAGNOSTIC_SCOPE ? `[book:${DIAGNOSTIC_SCOPE}] ` : '';
|
|
208
|
+
console.error(
|
|
209
|
+
message.startsWith('[book:') ? message : `${prefix}build-tips: failed: ${message}`,
|
|
210
|
+
);
|
|
139
211
|
process.exit(1);
|
|
140
212
|
});
|
|
141
213
|
|