@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
package/scripts/validate.mjs
CHANGED
|
@@ -51,6 +51,13 @@ import remarkMdx from 'remark-mdx';
|
|
|
51
51
|
import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
|
|
52
52
|
import { readEnvFile } from './read-env.mjs';
|
|
53
53
|
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
54
|
+
import {
|
|
55
|
+
assertCorpusEnvelope,
|
|
56
|
+
frontmatterSlug,
|
|
57
|
+
legacyFrontmatterBook,
|
|
58
|
+
parseFrontmatter,
|
|
59
|
+
resolveBookSelection,
|
|
60
|
+
} from './corpus-tooling.mjs';
|
|
54
61
|
import {
|
|
55
62
|
findAuthoredTargets,
|
|
56
63
|
normalizeAstroBase,
|
|
@@ -67,7 +74,8 @@ internal authored links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
|
|
|
67
74
|
|
|
68
75
|
Options:
|
|
69
76
|
--preset <name> academic | tools | minimal | course-notes | research-portfolio
|
|
70
|
-
|
|
77
|
+
Explicit override when no scaffold integration is resolved.
|
|
78
|
+
--book <id> In corpus mode, validate only one registered book.
|
|
71
79
|
--help, -h Print this message and exit (non-mutating).
|
|
72
80
|
|
|
73
81
|
Env:
|
|
@@ -76,6 +84,7 @@ Env:
|
|
|
76
84
|
BOOK_REPO_ROOT Absolute path to a sibling code repo for CodeRef checks.
|
|
77
85
|
|
|
78
86
|
Exit code = total failure count, capped at 255 so failures never wrap to success.
|
|
87
|
+
No preset is inferred: configure a Style/corpus, content schema preset, flag, or env.
|
|
79
88
|
`;
|
|
80
89
|
|
|
81
90
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
@@ -93,11 +102,6 @@ const presetFromFlag = presetFlagIdx >= 0 ? argv[presetFlagIdx + 1] : undefined;
|
|
|
93
102
|
// Resolves issue #8 — three reference consumers reported "0 chapter(s) checked"
|
|
94
103
|
// because ROOT was the package directory inside node_modules.
|
|
95
104
|
const ROOT = process.cwd();
|
|
96
|
-
// v4.1.1 (closes #63): read the consumer's content.config.{ts,mjs,js} to
|
|
97
|
-
// honor `loader.base` overrides (multi-guide pattern uses
|
|
98
|
-
// `src/content/<guide-slug>/` instead of the Astro 5 default).
|
|
99
|
-
// Falls back to `src/content/chapters` when no override / no config file.
|
|
100
|
-
const CHAPTERS_DIR = await readChaptersBase(ROOT);
|
|
101
105
|
const PUBLIC_DIR = resolve(ROOT, 'public');
|
|
102
106
|
const DATA_DIR = resolve(ROOT, 'src/data');
|
|
103
107
|
|
|
@@ -108,13 +112,32 @@ try {
|
|
|
108
112
|
process.stderr.write(`validate: fatal: ${error?.message ?? error}\n`);
|
|
109
113
|
process.exit(1);
|
|
110
114
|
}
|
|
115
|
+
let BOOK_SELECTION;
|
|
116
|
+
try {
|
|
117
|
+
BOOK_SELECTION = resolveBookSelection(TOOLING_CONFIG, argv, 'validate');
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const prefix = TOOLING_CONFIG.corpus ? '[book:corpus] ' : '';
|
|
120
|
+
process.stderr.write(`${prefix}validate: fatal: ${error?.message ?? error}\n`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const CORPUS_DIAGNOSTIC_PREFIX = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
|
|
124
|
+
// Corpus content uses one root with a registered first segment per book;
|
|
125
|
+
// single-book consumers retain the historical chapters directory.
|
|
126
|
+
let CHAPTERS_DIR;
|
|
127
|
+
try {
|
|
128
|
+
CHAPTERS_DIR = await readChaptersBase(ROOT, { corpus: BOOK_SELECTION.corpus });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`${CORPUS_DIAGNOSTIC_PREFIX}validate: fatal: ${error?.message ?? error}\n`,
|
|
132
|
+
);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
111
135
|
|
|
112
136
|
// Preset resolution:
|
|
113
137
|
// composed Astro-config preset > --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
|
|
114
138
|
// .env BOOK_PRESET > .env BOOK_PROFILE >
|
|
115
139
|
// defineBookSchemas({ preset }) in content.config.ts >
|
|
116
|
-
// defineBookSchemas({ profile }) in content.config.ts (alias)
|
|
117
|
-
// warned v4 compatibility fallback 'minimal'.
|
|
140
|
+
// defineBookSchemas({ profile }) in content.config.ts (alias).
|
|
118
141
|
// .env fallback closes #20 — without it, consumers who set BOOK_PROFILE in
|
|
119
142
|
// .env (the documented convenience in SKILL.md + create-book defaults) saw
|
|
120
143
|
// the CLI silently default to minimal, hiding academic-profile errors.
|
|
@@ -134,27 +157,99 @@ const PRESET_CANDIDATE =
|
|
|
134
157
|
schemaConfig.preset;
|
|
135
158
|
const PRESETS = ['academic', 'tools', 'minimal', 'course-notes', 'research-portfolio'];
|
|
136
159
|
if (presetFlagIdx >= 0 && !presetFromFlag) {
|
|
137
|
-
process.stderr.write(
|
|
160
|
+
process.stderr.write(`${CORPUS_DIAGNOSTIC_PREFIX}validate: --preset requires a value.\n`);
|
|
138
161
|
process.exit(2);
|
|
139
162
|
}
|
|
140
163
|
if (PRESET_CANDIDATE && !PRESETS.includes(PRESET_CANDIDATE)) {
|
|
141
164
|
process.stderr.write(
|
|
142
|
-
|
|
165
|
+
`${CORPUS_DIAGNOSTIC_PREFIX}validate: preset must be one of ${PRESETS.join(' | ')} ` +
|
|
143
166
|
`(got ${JSON.stringify(PRESET_CANDIDATE)}).\n`,
|
|
144
167
|
);
|
|
145
168
|
process.exit(1);
|
|
146
169
|
}
|
|
147
|
-
const PRESET = PRESET_CANDIDATE ?? 'minimal';
|
|
148
170
|
if (!PRESET_CANDIDATE) {
|
|
149
171
|
process.stderr.write(
|
|
150
|
-
|
|
151
|
-
'
|
|
172
|
+
`${CORPUS_DIAGNOSTIC_PREFIX}validate: no book preset was resolved. Add a built-in Style to ` +
|
|
173
|
+
'`defineBookConfig({ styles: [...] })` and pass the same preset to ' +
|
|
174
|
+
'`defineBookSchemas({ preset: "..." })`, pass one `defineBookCorpus` manifest ' +
|
|
175
|
+
'to both entrypoints, use --preset, or set BOOK_PRESET in the environment or .env. ' +
|
|
176
|
+
`Valid presets: ${PRESETS.join(' | ')}. See ` +
|
|
177
|
+
'https://github.com/brandon-behring/book-scaffold-astro/blob/main/' +
|
|
178
|
+
'package/MIGRATION-v4-to-v5.md.\n',
|
|
152
179
|
);
|
|
180
|
+
process.exit(1);
|
|
153
181
|
}
|
|
182
|
+
const PRESET = PRESET_CANDIDATE;
|
|
154
183
|
// Alias kept for downstream message text only; the resolution above is canonical.
|
|
155
184
|
const PROFILE = PRESET;
|
|
156
185
|
const MATH_ENABLED = PROFILE === 'academic' || PROFILE === 'research-portfolio';
|
|
157
186
|
const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
|
|
187
|
+
const corpusDependencyParser = unified().use(remarkParse).use(remarkMdx);
|
|
188
|
+
if (MATH_ENABLED) corpusDependencyParser.use(remarkMath);
|
|
189
|
+
|
|
190
|
+
let selectedCorpusDependencies = null;
|
|
191
|
+
async function resolveSelectedCorpusDependencies() {
|
|
192
|
+
if (selectedCorpusDependencies) return selectedCorpusDependencies;
|
|
193
|
+
|
|
194
|
+
const routeBooks = new Set(BOOK_SELECTION.books.map((book) => book.id));
|
|
195
|
+
const labelBooks = new Set(BOOK_SELECTION.books.map((book) => book.id));
|
|
196
|
+
if (!BOOK_SELECTION.corpus || BOOK_SELECTION.requestedBook === null) {
|
|
197
|
+
selectedCorpusDependencies = { routeBooks, labelBooks };
|
|
198
|
+
return selectedCorpusDependencies;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const selectedBook = BOOK_SELECTION.books[0];
|
|
202
|
+
const selectedDir = join(CHAPTERS_DIR, selectedBook.id);
|
|
203
|
+
const corpusIds = new Set(BOOK_SELECTION.corpus.books.map((book) => book.id));
|
|
204
|
+
const configuredBase = normalizeAstroBase(TOOLING_CONFIG.base);
|
|
205
|
+
for await (const file of walkMdx(selectedDir)) {
|
|
206
|
+
const content = await readFile(join(selectedDir, file), 'utf8');
|
|
207
|
+
|
|
208
|
+
// Root-absolute Markdown chapter links need only the referenced book's
|
|
209
|
+
// slug index. Structural collection avoids examples in code/comments.
|
|
210
|
+
try {
|
|
211
|
+
const targets = findAuthoredTargets(content, {
|
|
212
|
+
format: authoredFormat(file),
|
|
213
|
+
math: MATH_ENABLED,
|
|
214
|
+
});
|
|
215
|
+
for (const target of targets) {
|
|
216
|
+
if (target.kind !== 'Markdown link destination') continue;
|
|
217
|
+
const pathname = rootTargetPathname(target.target);
|
|
218
|
+
if (pathname === null) continue;
|
|
219
|
+
const localPath =
|
|
220
|
+
configuredBase !== '/' &&
|
|
221
|
+
(pathname === configuredBase || pathname.startsWith(`${configuredBase}/`))
|
|
222
|
+
? pathname.slice(configuredBase.length) || '/'
|
|
223
|
+
: pathname;
|
|
224
|
+
const match = localPath.match(/^\/chapters\/([^/]+)(?:\/|$)/);
|
|
225
|
+
if (match && corpusIds.has(match[1])) routeBooks.add(match[1]);
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
// The main validation pass reports the parse failure with source context.
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!content.includes('<BookLink')) continue;
|
|
232
|
+
try {
|
|
233
|
+
for (const element of mdxElements(corpusDependencyParser.parse(content), 'BookLink')) {
|
|
234
|
+
const bookAttr = mdxStringProp(element, 'book');
|
|
235
|
+
const toAttr = mdxStringProp(element, 'to');
|
|
236
|
+
if (!bookAttr.literal || !toAttr.literal || !corpusIds.has(bookAttr.value)) continue;
|
|
237
|
+
const target = normalizeCorpusTarget(
|
|
238
|
+
BOOK_SELECTION.corpus.books.find((book) => book.id === bookAttr.value),
|
|
239
|
+
toAttr.value,
|
|
240
|
+
);
|
|
241
|
+
if (target.error || !target.chapterSlug) continue;
|
|
242
|
+
routeBooks.add(bookAttr.value);
|
|
243
|
+
if (target.fragment !== null) labelBooks.add(bookAttr.value);
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// The main validation pass reports the parse failure with source context.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
selectedCorpusDependencies = { routeBooks, labelBooks };
|
|
251
|
+
return selectedCorpusDependencies;
|
|
252
|
+
}
|
|
158
253
|
|
|
159
254
|
// v4.6.0 (issue #76 Layer 3b): chapter-route shadow warning. Detect a
|
|
160
255
|
// consumer-owned `src/pages/chapters/[...slug].astro` that shadows the
|
|
@@ -185,7 +280,10 @@ const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
|
|
|
185
280
|
}
|
|
186
281
|
if (!chaptersDisabled) {
|
|
187
282
|
console.warn(
|
|
188
|
-
|
|
283
|
+
(BOOK_SELECTION.corpus
|
|
284
|
+
? '[book:corpus] validate: WARN consumer-owned chapter route at '
|
|
285
|
+
: '\n⚠ Consumer-owned chapter route at ') +
|
|
286
|
+
`src/pages/chapters/[...slug].astro\n` +
|
|
189
287
|
` shadows the scaffold v4.3.0+ auto-injected route. Either:\n` +
|
|
190
288
|
` • Delete the consumer file to defer to the scaffold (recommended), OR\n` +
|
|
191
289
|
` • Set 'routes: { chapters: false }' in defineBookConfig to keep\n` +
|
|
@@ -213,7 +311,10 @@ const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
|
|
|
213
311
|
}
|
|
214
312
|
if (!landingDisabled) {
|
|
215
313
|
console.warn(
|
|
216
|
-
|
|
314
|
+
(BOOK_SELECTION.corpus
|
|
315
|
+
? '[book:corpus] validate: WARN consumer-owned landing page at '
|
|
316
|
+
: '\n⚠ Consumer-owned landing page at ') +
|
|
317
|
+
`src/pages/index.astro shadows the\n` +
|
|
217
318
|
` scaffold's auto-injected "/" route. Your page wins today, but Astro\n` +
|
|
218
319
|
` has announced route collisions become a HARD ERROR in a future\n` +
|
|
219
320
|
` version. Set 'routes: { landing: false }' in defineBookConfig to\n` +
|
|
@@ -226,18 +327,36 @@ const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
|
|
|
226
327
|
|
|
227
328
|
const errors = [];
|
|
228
329
|
const warnings = [];
|
|
229
|
-
|
|
230
|
-
const
|
|
330
|
+
let ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
|
|
331
|
+
const fail = (file, line, msg, book = ACTIVE_BOOK_ID) => errors.push({ file, line, msg, book });
|
|
332
|
+
const warn = (file, line, msg, book = ACTIVE_BOOK_ID) => warnings.push({ file, line, msg, book });
|
|
333
|
+
const invalidFrontmatterFiles = new Set();
|
|
334
|
+
function recordFrontmatterFailure(error, file, book) {
|
|
335
|
+
invalidFrontmatterFiles.add(file);
|
|
336
|
+
fail(
|
|
337
|
+
file,
|
|
338
|
+
error?.frontmatterLine ?? 1,
|
|
339
|
+
error?.frontmatterDetail ?? `Invalid YAML frontmatter: ${error?.message ?? error}`,
|
|
340
|
+
book,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
231
343
|
|
|
232
344
|
// ===== Self-heal missing generated artifacts (#186) =====
|
|
233
345
|
// These files are intentionally gitignored. Direct `book-scaffold validate`
|
|
234
346
|
// bypasses npm's prevalidate lifecycle, so rebuild each missing artifact before
|
|
235
|
-
// loading it.
|
|
236
|
-
// are
|
|
347
|
+
// loading it. In selected corpus runs, placeholder-empty requested/dependency
|
|
348
|
+
// namespaces are also materialized to keep results independent of run order.
|
|
349
|
+
// Child diagnostics and failures are propagated verbatim instead of becoming
|
|
350
|
+
// downstream unknown-id noise.
|
|
237
351
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
238
|
-
function regenerate(scriptName, artifact) {
|
|
239
|
-
|
|
240
|
-
|
|
352
|
+
function regenerate(scriptName, artifact, book = null, reason = null) {
|
|
353
|
+
const prefix = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
|
|
354
|
+
process.stdout.write(
|
|
355
|
+
`${prefix}validate: ${reason ?? `${artifact} is missing — regenerating via ${scriptName} (#186)`}\n`,
|
|
356
|
+
);
|
|
357
|
+
const childArgs = [join(scriptDir, scriptName)];
|
|
358
|
+
if (book) childArgs.push('--book', book);
|
|
359
|
+
const result = spawnSync(process.execPath, childArgs, {
|
|
241
360
|
cwd: ROOT,
|
|
242
361
|
env: process.env,
|
|
243
362
|
encoding: 'utf8',
|
|
@@ -247,17 +366,112 @@ function regenerate(scriptName, artifact) {
|
|
|
247
366
|
if (result.error || result.status !== 0) {
|
|
248
367
|
const detail = result.error ? `: ${result.error.message}` : '';
|
|
249
368
|
process.stderr.write(
|
|
250
|
-
|
|
369
|
+
`${prefix}validate: ${scriptName} failed (exit ${result.status ?? 1})${detail} — cannot self-heal.\n`,
|
|
251
370
|
);
|
|
252
371
|
process.exit(result.status ?? 1);
|
|
253
372
|
}
|
|
254
373
|
}
|
|
255
374
|
|
|
256
|
-
|
|
257
|
-
|
|
375
|
+
async function readableCorpusArtifactBooks(path, artifact) {
|
|
376
|
+
try {
|
|
377
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
378
|
+
return assertCorpusEnvelope(
|
|
379
|
+
value,
|
|
380
|
+
BOOK_SELECTION.corpus,
|
|
381
|
+
artifact,
|
|
382
|
+
isRecord,
|
|
383
|
+
).books;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isEmptyRecord(value) {
|
|
390
|
+
return isRecord(value) && Object.keys(value).length === 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const labelsPath = join(DATA_DIR, 'labels.json');
|
|
394
|
+
const materializedLabels = new Set();
|
|
395
|
+
let materializedAllLabels = false;
|
|
396
|
+
if (!existsSync(labelsPath)) {
|
|
397
|
+
regenerate(
|
|
398
|
+
'build-labels.mjs',
|
|
399
|
+
'src/data/labels.json',
|
|
400
|
+
BOOK_SELECTION.requestedBook,
|
|
401
|
+
);
|
|
402
|
+
if (BOOK_SELECTION.requestedBook) materializedLabels.add(BOOK_SELECTION.requestedBook);
|
|
403
|
+
else if (BOOK_SELECTION.corpus) materializedAllLabels = true;
|
|
404
|
+
}
|
|
405
|
+
if (BOOK_SELECTION.requestedBook) {
|
|
406
|
+
const dependencies = await resolveSelectedCorpusDependencies();
|
|
407
|
+
const books = await readableCorpusArtifactBooks(labelsPath, 'src/data/labels.json');
|
|
408
|
+
if (books) {
|
|
409
|
+
for (const book of dependencies.labelBooks) {
|
|
410
|
+
if (materializedLabels.has(book) || !isEmptyRecord(books[book])) continue;
|
|
411
|
+
regenerate(
|
|
412
|
+
'build-labels.mjs',
|
|
413
|
+
'src/data/labels.json',
|
|
414
|
+
book,
|
|
415
|
+
`${book === BOOK_SELECTION.requestedBook
|
|
416
|
+
? `selected book ${book}`
|
|
417
|
+
: `local BookLink from ${BOOK_SELECTION.requestedBook} requires ${book}`} ` +
|
|
418
|
+
'has an empty labels namespace — materializing it (#186)',
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} else if (BOOK_SELECTION.corpus && !materializedAllLabels) {
|
|
423
|
+
const books = await readableCorpusArtifactBooks(labelsPath, 'src/data/labels.json');
|
|
424
|
+
if (books && BOOK_SELECTION.corpus.books.some((book) => isEmptyRecord(books[book.id]))) {
|
|
425
|
+
regenerate(
|
|
426
|
+
'build-labels.mjs',
|
|
427
|
+
'src/data/labels.json',
|
|
428
|
+
null,
|
|
429
|
+
'corpus labels contain placeholder-empty namespaces — rebuilding all books (#186)',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const referencesPath = join(DATA_DIR, 'references.json');
|
|
435
|
+
let materializedReferences = false;
|
|
436
|
+
let materializedAllReferences = false;
|
|
437
|
+
if (!existsSync(referencesPath)) {
|
|
438
|
+
regenerate(
|
|
439
|
+
'build-bib.mjs',
|
|
440
|
+
'src/data/references.json',
|
|
441
|
+
BOOK_SELECTION.requestedBook,
|
|
442
|
+
);
|
|
443
|
+
materializedReferences = BOOK_SELECTION.requestedBook !== null;
|
|
444
|
+
materializedAllReferences = Boolean(BOOK_SELECTION.corpus && !BOOK_SELECTION.requestedBook);
|
|
258
445
|
}
|
|
259
|
-
if (
|
|
260
|
-
|
|
446
|
+
if (BOOK_SELECTION.requestedBook) {
|
|
447
|
+
if (!materializedReferences) {
|
|
448
|
+
const books = await readableCorpusArtifactBooks(
|
|
449
|
+
referencesPath,
|
|
450
|
+
'src/data/references.json',
|
|
451
|
+
);
|
|
452
|
+
if (books && isEmptyRecord(books[BOOK_SELECTION.requestedBook])) {
|
|
453
|
+
regenerate(
|
|
454
|
+
'build-bib.mjs',
|
|
455
|
+
'src/data/references.json',
|
|
456
|
+
BOOK_SELECTION.requestedBook,
|
|
457
|
+
`selected book ${BOOK_SELECTION.requestedBook} has an empty references namespace — ` +
|
|
458
|
+
'materializing it (#186)',
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} else if (BOOK_SELECTION.corpus && !materializedAllReferences) {
|
|
463
|
+
const books = await readableCorpusArtifactBooks(
|
|
464
|
+
referencesPath,
|
|
465
|
+
'src/data/references.json',
|
|
466
|
+
);
|
|
467
|
+
if (books && BOOK_SELECTION.corpus.books.some((book) => isEmptyRecord(books[book.id]))) {
|
|
468
|
+
regenerate(
|
|
469
|
+
'build-bib.mjs',
|
|
470
|
+
'src/data/references.json',
|
|
471
|
+
null,
|
|
472
|
+
'corpus references contain placeholder-empty namespaces — rebuilding all books (#186)',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
261
475
|
}
|
|
262
476
|
|
|
263
477
|
// ===== Load reference data (graceful when missing) =====
|
|
@@ -268,8 +482,28 @@ async function loadJson(path) {
|
|
|
268
482
|
return {};
|
|
269
483
|
}
|
|
270
484
|
}
|
|
271
|
-
|
|
272
|
-
|
|
485
|
+
function isRecord(value) {
|
|
486
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function loadGeneratedArtifact(name) {
|
|
490
|
+
const path = join(DATA_DIR, name);
|
|
491
|
+
if (!BOOK_SELECTION.corpus) return loadJson(path);
|
|
492
|
+
try {
|
|
493
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
494
|
+
return assertCorpusEnvelope(parsed, BOOK_SELECTION.corpus, `src/data/${name}`, isRecord);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
process.stderr.write(
|
|
497
|
+
`[book:corpus] validate: fatal: ${error?.message ?? error}. ` +
|
|
498
|
+
`Run book-scaffold ${name === 'labels.json' ? 'build-labels' : 'build-bib'} ` +
|
|
499
|
+
'to regenerate the corpus envelope.\n',
|
|
500
|
+
);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const refsArtifact = await loadGeneratedArtifact('references.json');
|
|
506
|
+
const labelsArtifact = await loadGeneratedArtifact('labels.json');
|
|
273
507
|
|
|
274
508
|
// #147: eagerly load every labels index explicitly declared by the evaluated
|
|
275
509
|
// siblingBooks registry. Unlike this book's generated labels.json, sibling
|
|
@@ -304,15 +538,88 @@ for (const [book, entry] of Object.entries(TOOLING_CONFIG.siblingBooks)) {
|
|
|
304
538
|
// Node 20 — `npm run validate` crashed on every consumer's prebuild hook.
|
|
305
539
|
// Walker uses readdir + path only; works on Node 18+.
|
|
306
540
|
const chapterFiles = [];
|
|
307
|
-
|
|
308
|
-
|
|
541
|
+
const chapterBookByFile = new Map();
|
|
542
|
+
const chapterCountsByBook = new Map();
|
|
543
|
+
if (BOOK_SELECTION.corpus) {
|
|
544
|
+
for (const book of BOOK_SELECTION.books) {
|
|
545
|
+
let count = 0;
|
|
546
|
+
for await (const file of walkMdx(join(CHAPTERS_DIR, book.id))) {
|
|
547
|
+
if (file.split('/').pop().startsWith('_')) continue;
|
|
548
|
+
const scoped = `${book.id}/${file}`;
|
|
549
|
+
chapterFiles.push(scoped);
|
|
550
|
+
chapterBookByFile.set(scoped, book.id);
|
|
551
|
+
count += 1;
|
|
552
|
+
}
|
|
553
|
+
chapterCountsByBook.set(book.id, count);
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
for await (const file of walkMdx(CHAPTERS_DIR)) {
|
|
557
|
+
if (!file.split('/').pop().startsWith('_')) chapterFiles.push(file);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function bookArtifacts(rel) {
|
|
562
|
+
const book = chapterBookByFile.get(rel) ?? null;
|
|
563
|
+
if (!book) return { book: null, refs: refsArtifact, labels: labelsArtifact };
|
|
564
|
+
return {
|
|
565
|
+
book,
|
|
566
|
+
refs: refsArtifact.books[book],
|
|
567
|
+
labels: labelsArtifact.books[book],
|
|
568
|
+
};
|
|
309
569
|
}
|
|
310
570
|
|
|
311
571
|
// ===== Build slug set from chapter filenames (for internal-link check) =====
|
|
312
|
-
const validSlugs = new Set(
|
|
313
|
-
|
|
314
|
-
|
|
572
|
+
const validSlugs = new Set(
|
|
573
|
+
BOOK_SELECTION.corpus
|
|
574
|
+
? []
|
|
575
|
+
: chapterFiles.map((file) => file.replace(/\.(md|mdx)$/, '')),
|
|
576
|
+
);
|
|
577
|
+
if (BOOK_SELECTION.corpus) {
|
|
578
|
+
// Full validation indexes every book. A selected run indexes its own book
|
|
579
|
+
// plus literal chapter-link dependencies only, so unrelated malformed
|
|
580
|
+
// content cannot make `validate --book <id>` cache-dependent.
|
|
581
|
+
const dependencies = await resolveSelectedCorpusDependencies();
|
|
582
|
+
for (const book of BOOK_SELECTION.corpus.books.filter((candidate) =>
|
|
583
|
+
dependencies.routeBooks.has(candidate.id))) {
|
|
584
|
+
for await (const file of walkMdx(join(CHAPTERS_DIR, book.id))) {
|
|
585
|
+
if (!file.split('/').pop().startsWith('_')) {
|
|
586
|
+
const fileLabel = `[book:${book.id}] ${book.id}/${file}`;
|
|
587
|
+
const source = await readFile(join(CHAPTERS_DIR, book.id, file), 'utf8');
|
|
588
|
+
try {
|
|
589
|
+
const localId = frontmatterSlug(source, fileLabel) ?? file.replace(/\.(md|mdx)$/, '');
|
|
590
|
+
validSlugs.add(`${book.id}/${localId}`);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
recordFrontmatterFailure(error, `${book.id}/${file}`, book.id);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const validTopLevelRoutes = new Set(
|
|
599
|
+
BOOK_SELECTION.corpus
|
|
600
|
+
? ['/', '/chapters/', '/search/']
|
|
601
|
+
: ['/', '/chapters/', '/references/', '/search/', '/print/', '/convergence/'],
|
|
602
|
+
);
|
|
603
|
+
const KNOWN_CORPUS_APPARATUS_ROUTES = new Set([
|
|
604
|
+
'references',
|
|
605
|
+
'print',
|
|
606
|
+
'convergence',
|
|
607
|
+
'tips',
|
|
608
|
+
'exercises',
|
|
609
|
+
'glossary',
|
|
610
|
+
'practice-exam',
|
|
611
|
+
'flashcards',
|
|
612
|
+
'answers',
|
|
315
613
|
]);
|
|
614
|
+
if (BOOK_SELECTION.corpus) {
|
|
615
|
+
for (const book of BOOK_SELECTION.corpus.books) {
|
|
616
|
+
validTopLevelRoutes.add(`/${book.id}/`);
|
|
617
|
+
validTopLevelRoutes.add(`/chapters/${book.id}/`);
|
|
618
|
+
for (const route of book.apparatus ?? TOOLING_CONFIG.apparatusRoutes) {
|
|
619
|
+
validTopLevelRoutes.add(`/${book.id}/${route}/`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
316
623
|
|
|
317
624
|
// ===== Pattern helpers for component-specific checks =====
|
|
318
625
|
const RE_CITE = /<Cite[^>]+key=["']([^"']+)["']/g;
|
|
@@ -504,10 +811,80 @@ function siblingTargetCandidates(index, fragment) {
|
|
|
504
811
|
return candidates;
|
|
505
812
|
}
|
|
506
813
|
|
|
814
|
+
function normalizeCorpusTarget(book, to) {
|
|
815
|
+
if (typeof to !== 'string' || to.trim().length === 0) {
|
|
816
|
+
return { error: 'target must be non-empty' };
|
|
817
|
+
}
|
|
818
|
+
if (to !== to.trim()) return { error: 'surrounding whitespace is not allowed' };
|
|
819
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith('/') || to.includes('\\')) {
|
|
820
|
+
return { error: 'absolute URLs and paths are not allowed' };
|
|
821
|
+
}
|
|
822
|
+
if (/[\u0000-\u001f\u007f]/.test(to)) return { error: 'control characters are not allowed' };
|
|
823
|
+
|
|
824
|
+
const query = to.indexOf('?');
|
|
825
|
+
const hash = to.indexOf('#');
|
|
826
|
+
const suffixIndex = [query, hash]
|
|
827
|
+
.filter((index) => index >= 0)
|
|
828
|
+
.reduce((minimum, index) => Math.min(minimum, index), to.length);
|
|
829
|
+
const path = to.slice(0, suffixIndex).replace(/\/+$/, '');
|
|
830
|
+
if (!path) return { error: 'query-only and fragment-only targets are not allowed' };
|
|
831
|
+
const segments = path.split('/');
|
|
832
|
+
if (segments.some((segment) => segment.length === 0)) {
|
|
833
|
+
return { error: 'empty path segments are not allowed' };
|
|
834
|
+
}
|
|
835
|
+
for (const segment of segments) {
|
|
836
|
+
let decoded;
|
|
837
|
+
try {
|
|
838
|
+
decoded = decodeURIComponent(segment);
|
|
839
|
+
} catch {
|
|
840
|
+
return { error: 'malformed percent encoding' };
|
|
841
|
+
}
|
|
842
|
+
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) {
|
|
843
|
+
return { error: 'path traversal is not allowed' };
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const localPath = segments.join('/');
|
|
848
|
+
const canonicalPath = localPath === 'chapters'
|
|
849
|
+
? `chapters/${book.id}`
|
|
850
|
+
: localPath.startsWith('chapters/')
|
|
851
|
+
? `chapters/${book.id}/${localPath.slice('chapters/'.length)}`
|
|
852
|
+
: `${book.id}/${localPath}`;
|
|
853
|
+
const fragment = hash >= 0 ? to.slice(hash + 1) : null;
|
|
854
|
+
return {
|
|
855
|
+
error: null,
|
|
856
|
+
localPath,
|
|
857
|
+
canonicalPath,
|
|
858
|
+
fragment,
|
|
859
|
+
href: fragment ? `${canonicalPath}#${fragment}` : canonicalPath,
|
|
860
|
+
chapterSlug: localPath.startsWith('chapters/')
|
|
861
|
+
? `${book.id}/${localPath.slice('chapters/'.length)}`
|
|
862
|
+
: null,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
507
866
|
// ===== Run all checks on each chapter =====
|
|
508
867
|
for (const rel of chapterFiles) {
|
|
509
868
|
const abs = join(CHAPTERS_DIR, rel);
|
|
510
869
|
const content = await readFile(abs, 'utf8');
|
|
870
|
+
const artifacts = bookArtifacts(rel);
|
|
871
|
+
const { refs, labels } = artifacts;
|
|
872
|
+
ACTIVE_BOOK_ID = artifacts.book;
|
|
873
|
+
if (artifacts.book && !invalidFrontmatterFiles.has(rel)) {
|
|
874
|
+
try {
|
|
875
|
+
const legacy = legacyFrontmatterBook(content, `[book:${artifacts.book}] ${rel}`);
|
|
876
|
+
if (legacy && legacy.value !== artifacts.book) {
|
|
877
|
+
fail(
|
|
878
|
+
rel,
|
|
879
|
+
legacy.line,
|
|
880
|
+
`frontmatter book ${JSON.stringify(legacy.value)} does not match ` +
|
|
881
|
+
`path-derived corpus book ${JSON.stringify(artifacts.book)}.`,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
} catch (error) {
|
|
885
|
+
recordFrontmatterFailure(error, rel, artifacts.book);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
511
888
|
const authoredTargets = collectAuthoredTargets(rel, content);
|
|
512
889
|
|
|
513
890
|
// 10. Root-absolute authored targets under a non-root Astro base (#190).
|
|
@@ -653,12 +1030,108 @@ for (const rel of chapterFiles) {
|
|
|
653
1030
|
}
|
|
654
1031
|
|
|
655
1032
|
const book = bookAttr.value;
|
|
1033
|
+
const corpusBook = BOOK_SELECTION.corpus?.books.find((candidate) => candidate.id === book);
|
|
1034
|
+
if (corpusBook) {
|
|
1035
|
+
if (!toAttr.literal) {
|
|
1036
|
+
warn(
|
|
1037
|
+
rel,
|
|
1038
|
+
line,
|
|
1039
|
+
`<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
|
|
1040
|
+
);
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const target = normalizeCorpusTarget(corpusBook, toAttr.value);
|
|
1045
|
+
if (target.error) {
|
|
1046
|
+
fail(
|
|
1047
|
+
rel,
|
|
1048
|
+
line,
|
|
1049
|
+
`<BookLink book="${book}" to="${toAttr.value}"> has invalid local corpus target: ` +
|
|
1050
|
+
`${target.error}.`,
|
|
1051
|
+
);
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (target.chapterSlug && !validSlugs.has(target.chapterSlug)) {
|
|
1056
|
+
fail(
|
|
1057
|
+
rel,
|
|
1058
|
+
line,
|
|
1059
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — unknown local corpus target ` +
|
|
1060
|
+
`"${target.canonicalPath}".`,
|
|
1061
|
+
);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Exact apparatus roots are known statically and may be checked against
|
|
1066
|
+
// the target book's route set. Nested/custom non-chapter paths remain a
|
|
1067
|
+
// deliberate runtime escape hatch (for example about/team or a custom
|
|
1068
|
+
// glossary detail route), so validate must not reject them merely
|
|
1069
|
+
// because they are absent from the scaffold apparatus registry.
|
|
1070
|
+
if (
|
|
1071
|
+
!target.chapterSlug &&
|
|
1072
|
+
KNOWN_CORPUS_APPARATUS_ROUTES.has(target.localPath) &&
|
|
1073
|
+
!(corpusBook.apparatus ?? TOOLING_CONFIG.apparatusRoutes).includes(target.localPath)
|
|
1074
|
+
) {
|
|
1075
|
+
fail(
|
|
1076
|
+
rel,
|
|
1077
|
+
line,
|
|
1078
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — local corpus apparatus route ` +
|
|
1079
|
+
`"${target.localPath}" is not enabled for book "${book}".`,
|
|
1080
|
+
);
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (target.fragment !== null && target.chapterSlug) {
|
|
1085
|
+
if (target.fragment.length === 0) {
|
|
1086
|
+
fail(
|
|
1087
|
+
rel,
|
|
1088
|
+
line,
|
|
1089
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — local fragment is empty.`,
|
|
1090
|
+
);
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
const localIndex = labelsArtifact.books[book];
|
|
1094
|
+
const candidates = siblingTargetCandidates(localIndex, target.fragment);
|
|
1095
|
+
if (!candidates.some((candidate) => candidate.normalized.href === target.href)) {
|
|
1096
|
+
const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
|
|
1097
|
+
fail(
|
|
1098
|
+
rel,
|
|
1099
|
+
line,
|
|
1100
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — local fragment ` +
|
|
1101
|
+
`"${target.fragment}" does not resolve in labels.json book namespace "${book}"` +
|
|
1102
|
+
(indexedHrefs ? ` (indexed at ${indexedHrefs})` : '') +
|
|
1103
|
+
'.',
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
} else if (target.fragment !== null) {
|
|
1107
|
+
// labels.json indexes chapter prose/components only. Runtime accepts
|
|
1108
|
+
// fragments on arbitrary book-local routes, so a non-chapter fragment
|
|
1109
|
+
// cannot be proven (or disproven) from this artifact.
|
|
1110
|
+
warn(
|
|
1111
|
+
rel,
|
|
1112
|
+
line,
|
|
1113
|
+
`<BookLink book="${book}" to="${toAttr.value}"> non-chapter fragment ` +
|
|
1114
|
+
'validation skipped; the target route owns its anchors.',
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
656
1120
|
const registered = Object.prototype.hasOwnProperty.call(TOOLING_CONFIG.siblingBooks, book);
|
|
657
1121
|
if (!registered) {
|
|
1122
|
+
const known = [
|
|
1123
|
+
...(BOOK_SELECTION.corpus?.books.map((candidate) => candidate.id) ?? []),
|
|
1124
|
+
...Object.keys(TOOLING_CONFIG.siblingBooks),
|
|
1125
|
+
];
|
|
658
1126
|
fail(
|
|
659
1127
|
rel,
|
|
660
1128
|
line,
|
|
661
|
-
|
|
1129
|
+
BOOK_SELECTION.corpus
|
|
1130
|
+
? `<BookLink book="${book}"> — not a registered local corpus or sibling book ` +
|
|
1131
|
+
`(${known.join(', ') || 'none'}). Register it or fix the key.`
|
|
1132
|
+
: `<BookLink book="${book}"> — not in evaluated defineBookConfig siblingBooks ` +
|
|
1133
|
+
`(${Object.keys(TOOLING_CONFIG.siblingBooks).join(', ') || 'none'}). ` +
|
|
1134
|
+
'Register it or fix the key.',
|
|
662
1135
|
);
|
|
663
1136
|
continue;
|
|
664
1137
|
}
|
|
@@ -805,6 +1278,7 @@ for (const rel of chapterFiles) {
|
|
|
805
1278
|
}
|
|
806
1279
|
}
|
|
807
1280
|
}
|
|
1281
|
+
ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
|
|
808
1282
|
|
|
809
1283
|
// ===== 8. Questions collection (#112): domain membership + unique ids =====
|
|
810
1284
|
//
|
|
@@ -817,6 +1291,33 @@ for (const rel of chapterFiles) {
|
|
|
817
1291
|
// examDomains can't be read from astro.config.mjs, membership is left to the
|
|
818
1292
|
// build-time throw.
|
|
819
1293
|
let questionsChecked = 0;
|
|
1294
|
+
const questionsCheckedByBook = new Map();
|
|
1295
|
+
|
|
1296
|
+
if (BOOK_SELECTION.corpus) {
|
|
1297
|
+
const manifestIds = new Set(BOOK_SELECTION.corpus.books.map((book) => book.id));
|
|
1298
|
+
for (const [collection, label] of [
|
|
1299
|
+
['questions', 'Question'],
|
|
1300
|
+
['glossary', 'Glossary term'],
|
|
1301
|
+
]) {
|
|
1302
|
+
const collectionDir = resolve(ROOT, 'src/content', collection);
|
|
1303
|
+
if (!existsSync(collectionDir)) continue;
|
|
1304
|
+
// Collection ownership is path-derived. Scan the complete non-hidden root
|
|
1305
|
+
// even for `--book` so direct files and unknown first segments cannot
|
|
1306
|
+
// silently disappear from every per-book run.
|
|
1307
|
+
for await (const file of walkMdx(collectionDir)) {
|
|
1308
|
+
const owner = file.split('/')[0] ?? '';
|
|
1309
|
+
if (manifestIds.has(owner)) continue;
|
|
1310
|
+
fail(
|
|
1311
|
+
`${collection}/${file}`,
|
|
1312
|
+
1,
|
|
1313
|
+
`${label} content must be nested under a registered corpus book id ` +
|
|
1314
|
+
`(${[...manifestIds].join(' | ')}); found first segment ${JSON.stringify(owner)}.`,
|
|
1315
|
+
'corpus',
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
820
1321
|
{
|
|
821
1322
|
const QUESTIONS_DIR = resolve(ROOT, 'src/content/questions');
|
|
822
1323
|
if (existsSync(QUESTIONS_DIR)) {
|
|
@@ -830,85 +1331,160 @@ let questionsChecked = 0;
|
|
|
830
1331
|
}
|
|
831
1332
|
}
|
|
832
1333
|
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const content = await readFile(abs, 'utf8');
|
|
841
|
-
const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
842
|
-
const front = fm ? fm[1] : '';
|
|
843
|
-
const qrel = `questions/${rel}`;
|
|
1334
|
+
const runs = BOOK_SELECTION.corpus
|
|
1335
|
+
? BOOK_SELECTION.books.map((book) => ({
|
|
1336
|
+
book,
|
|
1337
|
+
dir: join(QUESTIONS_DIR, book.id),
|
|
1338
|
+
prefix: `questions/${book.id}`,
|
|
1339
|
+
}))
|
|
1340
|
+
: [{ book: null, dir: QUESTIONS_DIR, prefix: 'questions' }];
|
|
844
1341
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1342
|
+
for (const run of runs) {
|
|
1343
|
+
ACTIVE_BOOK_ID = run.book?.id ?? null;
|
|
1344
|
+
const seenIds = new Map(); // ids are book-local in corpus mode
|
|
1345
|
+
const questionFiles = [];
|
|
1346
|
+
for await (const file of walkMdx(run.dir)) {
|
|
1347
|
+
if (!file.split('/').pop().startsWith('_')) questionFiles.push(file);
|
|
1348
|
+
}
|
|
1349
|
+
for (const rel of questionFiles) {
|
|
1350
|
+
const abs = join(run.dir, rel);
|
|
1351
|
+
const content = await readFile(abs, 'utf8');
|
|
1352
|
+
const qrel = `${run.prefix}/${rel}`;
|
|
1353
|
+
let parsedFrontmatter;
|
|
1354
|
+
try {
|
|
1355
|
+
parsedFrontmatter = parseFrontmatter(
|
|
1356
|
+
content,
|
|
1357
|
+
run.book ? `[book:${run.book.id}] ${qrel}` : qrel,
|
|
858
1358
|
);
|
|
859
|
-
}
|
|
860
|
-
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
recordFrontmatterFailure(error, qrel, run.book?.id ?? null);
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
const frontmatter = parsedFrontmatter.frontmatter;
|
|
1364
|
+
if (run.book) {
|
|
1365
|
+
if (
|
|
1366
|
+
Object.prototype.hasOwnProperty.call(frontmatter, 'book') &&
|
|
1367
|
+
frontmatter.book !== run.book.id
|
|
1368
|
+
) {
|
|
1369
|
+
fail(
|
|
1370
|
+
qrel,
|
|
1371
|
+
parsedFrontmatter.lines.book ?? 2,
|
|
1372
|
+
`frontmatter book ${JSON.stringify(frontmatter.book)} does not match ` +
|
|
1373
|
+
`path-derived corpus book ${JSON.stringify(run.book.id)}.`,
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
861
1376
|
}
|
|
862
|
-
}
|
|
863
1377
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1378
|
+
// Questions do not run the legacy link-resolution advisory, so keep the
|
|
1379
|
+
// #190 root-base contract fully inert by parsing only for a non-root base.
|
|
1380
|
+
const authoredTargets = ASTRO_BASE === '/' ? [] : collectAuthoredTargets(qrel, content);
|
|
1381
|
+
validateAuthoredTargets(qrel, content, authoredTargets);
|
|
1382
|
+
|
|
1383
|
+
const normalizedQuestionId = typeof frontmatter.id === 'string'
|
|
1384
|
+
? frontmatter.id.trim()
|
|
1385
|
+
: '';
|
|
1386
|
+
const questionId = normalizedQuestionId.length > 0 ? normalizedQuestionId : null;
|
|
1387
|
+
if (questionId) {
|
|
1388
|
+
const id = questionId;
|
|
1389
|
+
if (seenIds.has(id)) {
|
|
1390
|
+
fail(
|
|
1391
|
+
qrel,
|
|
1392
|
+
parsedFrontmatter.lines.id ?? 1,
|
|
1393
|
+
`Duplicate question id "${id}" — also declared in ${seenIds.get(id)}. Question ids must be unique (cross-ref key for the appendix / flashcards).`,
|
|
1394
|
+
);
|
|
1395
|
+
} else {
|
|
1396
|
+
seenIds.set(id, qrel);
|
|
1397
|
+
}
|
|
872
1398
|
}
|
|
873
|
-
}
|
|
874
1399
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1400
|
+
if (examDomains) {
|
|
1401
|
+
const normalizedDomain = typeof frontmatter.domain === 'string'
|
|
1402
|
+
? frontmatter.domain.trim()
|
|
1403
|
+
: '';
|
|
1404
|
+
const domain = normalizedDomain.length > 0 ? normalizedDomain : null;
|
|
1405
|
+
if (domain && !examDomains.has(domain)) {
|
|
1406
|
+
fail(
|
|
1407
|
+
qrel,
|
|
1408
|
+
parsedFrontmatter.lines.domain ?? 1,
|
|
1409
|
+
`Question domain "${domain}" not in defineBookConfig examDomains (${[...examDomains].join(', ') || 'none'}). Register it or fix the value.`,
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// v4.21.0 (#114): <Rationale appendix> needs for= (the /answers#answer-<id>
|
|
1415
|
+
// anchor target) — the component throws at build; flag it here, earlier.
|
|
1416
|
+
for (const m of content.matchAll(/<Rationale\b([^>]*)>/g)) {
|
|
1417
|
+
const attrs = m[1];
|
|
1418
|
+
if (!/(^|\s)appendix(\s|=|$)/.test(attrs)) continue;
|
|
1419
|
+
const forMatch = attrs.match(/\bfor\s*=\s*["']([^"']+)["']/);
|
|
1420
|
+
if (!forMatch) {
|
|
1421
|
+
fail(
|
|
1422
|
+
qrel,
|
|
1423
|
+
lineOf(content, m.index),
|
|
1424
|
+
`<Rationale appendix> without for="<question-id>" — no appendix anchor target; throws at build.`,
|
|
1425
|
+
);
|
|
1426
|
+
} else if (questionId && forMatch[1] !== questionId) {
|
|
1427
|
+
fail(
|
|
1428
|
+
qrel,
|
|
1429
|
+
lineOf(content, m.index),
|
|
1430
|
+
`<Rationale appendix for="${forMatch[1]}"> does not match this question's id "${questionId}" — the /answers anchor would land on the wrong (or no) answer.`,
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
899
1433
|
}
|
|
900
1434
|
}
|
|
1435
|
+
questionsChecked += questionFiles.length;
|
|
1436
|
+
if (run.book) questionsCheckedByBook.set(run.book.id, questionFiles.length);
|
|
901
1437
|
}
|
|
902
|
-
|
|
1438
|
+
ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
|
|
903
1439
|
}
|
|
904
1440
|
}
|
|
905
1441
|
|
|
906
1442
|
// ===== Report =====
|
|
907
|
-
const format = ({ file, line, msg }) =>
|
|
1443
|
+
const format = ({ file, line, msg, book }) =>
|
|
1444
|
+
`${book ? `[book:${book}] ` : ' '}${file}:${line} ${msg}`;
|
|
908
1445
|
if (warnings.length > 0) {
|
|
909
|
-
|
|
910
|
-
|
|
1446
|
+
const prefix = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
|
|
1447
|
+
console.warn(`${prefix}validate: ${warnings.length} warning(s):`);
|
|
1448
|
+
warnings.forEach((warning) => console.warn(format(warning)));
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
if (BOOK_SELECTION.corpus) {
|
|
1452
|
+
for (const book of BOOK_SELECTION.books) {
|
|
1453
|
+
const count = chapterCountsByBook.get(book.id) ?? 0;
|
|
1454
|
+
const questionCount = questionsCheckedByBook.get(book.id) ?? 0;
|
|
1455
|
+
const questionNote = questionCount > 0 ? ` + ${questionCount} question(s)` : '';
|
|
1456
|
+
const bookErrors = errors.filter((error) => error.book === book.id).length;
|
|
1457
|
+
const bookWarnings = warnings.filter((warning) => warning.book === book.id).length;
|
|
1458
|
+
const result = bookErrors === 0 ? '✓' : '✗';
|
|
1459
|
+
const detail = bookErrors === 0
|
|
1460
|
+
? 'no errors'
|
|
1461
|
+
: `${bookErrors} error${bookErrors === 1 ? '' : 's'}`;
|
|
1462
|
+
console.log(
|
|
1463
|
+
`[book:${book.id}] validate: ${result} ${count} chapter(s)${questionNote} checked; ${detail}, ` +
|
|
1464
|
+
`${bookWarnings} warning${bookWarnings === 1 ? '' : 's'} ` +
|
|
1465
|
+
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}).`,
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
|
|
1470
|
+
const aggregateResult = errors.length === 0 ? '✓' : '✗';
|
|
1471
|
+
const aggregateDetail = errors.length === 0
|
|
1472
|
+
? 'no errors'
|
|
1473
|
+
: `${errors.length} error${errors.length === 1 ? '' : 's'}`;
|
|
1474
|
+
const aggregateMessage =
|
|
1475
|
+
`[book:corpus] validate: ${aggregateResult} ${chapterFiles.length} chapter(s)${qNote} ` +
|
|
1476
|
+
`across ${BOOK_SELECTION.books.length} book${BOOK_SELECTION.books.length === 1 ? '' : 's'}; ` +
|
|
1477
|
+
`${aggregateDetail}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'} ` +
|
|
1478
|
+
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle})`;
|
|
1479
|
+
if (errors.length === 0) {
|
|
1480
|
+
console.log(`${aggregateMessage}.`);
|
|
1481
|
+
process.exit(0);
|
|
1482
|
+
}
|
|
1483
|
+
console.error(`${aggregateMessage}:`);
|
|
1484
|
+
errors.forEach((error) => console.error(format(error)));
|
|
1485
|
+
process.exit(Math.min(errors.length, 255));
|
|
911
1486
|
}
|
|
1487
|
+
|
|
912
1488
|
if (errors.length === 0) {
|
|
913
1489
|
const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
|
|
914
1490
|
console.log(
|
|
@@ -921,5 +1497,5 @@ console.error(
|
|
|
921
1497
|
`validate: ✗ ${errors.length} error(s) in ${chapterFiles.length} chapter(s) ` +
|
|
922
1498
|
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):`,
|
|
923
1499
|
);
|
|
924
|
-
errors.forEach((
|
|
1500
|
+
errors.forEach((error) => console.error(format(error)));
|
|
925
1501
|
process.exit(Math.min(errors.length, 255));
|