@brandon_m_behring/book-scaffold-astro 5.0.0 → 5.1.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 +27 -3
- package/README.md +34 -0
- package/bin/book-scaffold.mjs +4 -0
- package/dist/index.mjs +6 -2
- package/package.json +2 -1
- package/recipes/25-qa-readiness.md +197 -0
- package/recipes/README.md +1 -0
- package/scripts/init-qa.mjs +209 -0
- package/scripts/qa-core.mjs +1511 -0
- package/scripts/qa.mjs +293 -0
- package/scripts/resolve-book-config.mjs +39 -0
- package/scripts/validate-core.mjs +1675 -0
- package/scripts/validate.mjs +18 -1492
- package/scripts/validation-artifacts.mjs +23 -0
|
@@ -0,0 +1,1675 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validate-core.mjs — callable pre-flight validation for book content.
|
|
3
|
+
*
|
|
4
|
+
* Catches authoring errors that astro build either misses or surfaces
|
|
5
|
+
* with insufficient context. Designed to run in <2 s on a medium-sized
|
|
6
|
+
* book so it's pre-commit-hook friendly.
|
|
7
|
+
*
|
|
8
|
+
* Checks performed (per Q14 in the v2.0 plan):
|
|
9
|
+
* 1. <Cite key="..." /> — key exists in src/data/references.json.
|
|
10
|
+
* 2. <XRef id="..." /> — id exists in src/data/labels.json.
|
|
11
|
+
* 3. <Figure src="/path/..." /> — file exists under public/.
|
|
12
|
+
* 4. Internal markdown links [text](/foo) — target resolves.
|
|
13
|
+
* 5. <CodeRef path="..." line={N} /> — when BOOK_REPO_ROOT set,
|
|
14
|
+
* path exists + line in bounds.
|
|
15
|
+
* 6. <Theorem> — has a resolvable kind= (or legacy type=); else it would
|
|
16
|
+
* render an empty label and throw at build (#121). An id'd theorem must
|
|
17
|
+
* resolve in labels.json, and a literal n= must agree with that index.
|
|
18
|
+
* 7. <BookLink book="…" to="…"> (#96/#147) — both props present, book= is
|
|
19
|
+
* registered, and literal fragment targets resolve in the sibling's
|
|
20
|
+
* declared vendored labels index. Dynamic and URL-only entries warn/skip.
|
|
21
|
+
* 8. Questions collection (#112) — each question's frontmatter `domain` is a
|
|
22
|
+
* member of the consumer's examDomains registry (best-effort), and question
|
|
23
|
+
* `id`s are unique (the cross-ref key for the appendix / flashcards).
|
|
24
|
+
* 9. Learning-objective anchors (#130) — when a chapter declares frontmatter
|
|
25
|
+
* `los:` entries with `anchor:` slugs, the declared set and the prose's
|
|
26
|
+
* MDX anchor-comment marker set must agree in both directions.
|
|
27
|
+
* 10. Authored root-absolute href/src targets (#190) — under a non-root Astro
|
|
28
|
+
* base, literal Markdown, HTML, and JSX targets must stay inside that base.
|
|
29
|
+
*
|
|
30
|
+
* Run from the consumer's project root. Closes #8 (was resolving paths
|
|
31
|
+
* from the package's own directory inside node_modules — false negatives
|
|
32
|
+
* across all reference consumers).
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* book-scaffold validate
|
|
36
|
+
* book-scaffold validate --preset academic
|
|
37
|
+
* BOOK_REPO_ROOT=/abs/path npx book-scaffold validate
|
|
38
|
+
*
|
|
39
|
+
* Exit code = total failure count capped at 255 (0 = pass, >=1 = errors).
|
|
40
|
+
*/
|
|
41
|
+
import { readFile, access } from 'node:fs/promises';
|
|
42
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
43
|
+
import { resolve, dirname, extname, join } from 'node:path';
|
|
44
|
+
import { unified } from 'unified';
|
|
45
|
+
import remarkParse from 'remark-parse';
|
|
46
|
+
import remarkMath from 'remark-math';
|
|
47
|
+
import remarkMdx from 'remark-mdx';
|
|
48
|
+
import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
|
|
49
|
+
import { readEnvFile } from './read-env.mjs';
|
|
50
|
+
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
51
|
+
import {
|
|
52
|
+
assertCorpusEnvelope,
|
|
53
|
+
frontmatterSlug,
|
|
54
|
+
legacyFrontmatterBook,
|
|
55
|
+
parseFrontmatter,
|
|
56
|
+
resolveBookSelection,
|
|
57
|
+
} from './corpus-tooling.mjs';
|
|
58
|
+
import {
|
|
59
|
+
findAuthoredTargets,
|
|
60
|
+
normalizeAstroBase,
|
|
61
|
+
rootTargetEscapesBase,
|
|
62
|
+
rootTargetPathname,
|
|
63
|
+
suggestBaseContainedTarget,
|
|
64
|
+
} from './authored-links.mjs';
|
|
65
|
+
|
|
66
|
+
// --help / -h: non-mutating (closes #14).
|
|
67
|
+
export const VALIDATE_USAGE = `Usage: book-scaffold validate [--preset <name>]
|
|
68
|
+
|
|
69
|
+
Pre-flight content validator. Checks Cite keys, XRef ids, Figure srcs,
|
|
70
|
+
internal authored links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
--preset <name> academic | tools | minimal | course-notes | research-portfolio
|
|
74
|
+
Explicit override when no scaffold integration is resolved.
|
|
75
|
+
--book <id> In corpus mode, validate only one registered book.
|
|
76
|
+
--help, -h Print this message and exit (non-mutating).
|
|
77
|
+
|
|
78
|
+
Env:
|
|
79
|
+
BOOK_PRESET Preset name (preferred over BOOK_PROFILE).
|
|
80
|
+
BOOK_PROFILE Backward-compat alias for BOOK_PRESET.
|
|
81
|
+
BOOK_REPO_ROOT Absolute path to a sibling code repo for CodeRef checks.
|
|
82
|
+
|
|
83
|
+
Exit code = total failure count, capped at 255 so failures never wrap to success.
|
|
84
|
+
No preset is inferred: configure a Style/corpus, content schema preset, flag, or env.
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
class ValidationFatal extends Error {
|
|
88
|
+
constructor(kind, message, exitCode) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = 'ValidationFatal';
|
|
91
|
+
this.kind = kind;
|
|
92
|
+
this.exitCode = exitCode;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run the content-contract validator without mutating process globals.
|
|
98
|
+
*
|
|
99
|
+
* `regenerateArtifact` is deliberately injected: the core never spawns a
|
|
100
|
+
* command. The legacy CLI supplies a spawn-backed callback, while other
|
|
101
|
+
* callers may provide an in-process implementation or leave it absent and
|
|
102
|
+
* receive a structured artifact fatal.
|
|
103
|
+
*/
|
|
104
|
+
export async function runValidation(options = {}) {
|
|
105
|
+
const argv = [...(options.argv ?? [])];
|
|
106
|
+
const ROOT = resolve(options.root ?? process.cwd());
|
|
107
|
+
const ENV = options.env ?? process.env;
|
|
108
|
+
const stdout = [];
|
|
109
|
+
const stderr = [];
|
|
110
|
+
const context = {
|
|
111
|
+
root: ROOT,
|
|
112
|
+
toolingConfig: null,
|
|
113
|
+
selection: null,
|
|
114
|
+
preset: null,
|
|
115
|
+
errors: [],
|
|
116
|
+
warnings: [],
|
|
117
|
+
notices: [],
|
|
118
|
+
chapterFiles: [],
|
|
119
|
+
chapterCountsByBook: new Map(),
|
|
120
|
+
questionsChecked: 0,
|
|
121
|
+
questionsCheckedByBook: new Map(),
|
|
122
|
+
regenerationRequests: [],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const writeOutput = (stream, value) => {
|
|
126
|
+
const text = String(value);
|
|
127
|
+
(stream === 'stdout' ? stdout : stderr).push(text);
|
|
128
|
+
options.onOutput?.({ stream, text });
|
|
129
|
+
};
|
|
130
|
+
const writeStdout = (value) => writeOutput('stdout', value);
|
|
131
|
+
const writeStderr = (value) => writeOutput('stderr', value);
|
|
132
|
+
const abort = (kind, message, exitCode, rendered) => {
|
|
133
|
+
if (rendered) writeStderr(rendered);
|
|
134
|
+
throw new ValidationFatal(kind, message, exitCode);
|
|
135
|
+
};
|
|
136
|
+
const finish = (exitCode, fatal = null) => {
|
|
137
|
+
const selection = context.selection;
|
|
138
|
+
const selected = selection?.corpus
|
|
139
|
+
? selection.books.map((book) => book.id)
|
|
140
|
+
: [];
|
|
141
|
+
const diagnostic = (severity, item, code) => ({
|
|
142
|
+
severity,
|
|
143
|
+
code,
|
|
144
|
+
message: item.msg,
|
|
145
|
+
book: item.book,
|
|
146
|
+
...(item.file ? { file: item.file } : {}),
|
|
147
|
+
...(Number.isInteger(item.line) ? { line: item.line } : {}),
|
|
148
|
+
});
|
|
149
|
+
const diagnostics = [
|
|
150
|
+
...context.errors.map((item) => diagnostic('error', item, 'validation_error')),
|
|
151
|
+
...context.warnings.map((item) => diagnostic('warning', item, 'validation_warning')),
|
|
152
|
+
...context.notices,
|
|
153
|
+
];
|
|
154
|
+
const bookResults = selection
|
|
155
|
+
? selection.corpus
|
|
156
|
+
? selection.books.map((book) => ({
|
|
157
|
+
book: book.id,
|
|
158
|
+
chapters: context.chapterCountsByBook.get(book.id) ?? 0,
|
|
159
|
+
questions: context.questionsCheckedByBook.get(book.id) ?? 0,
|
|
160
|
+
errors: context.errors.filter((item) => item.book === book.id),
|
|
161
|
+
warnings: context.warnings.filter((item) => item.book === book.id),
|
|
162
|
+
}))
|
|
163
|
+
: [{
|
|
164
|
+
book: null,
|
|
165
|
+
chapters: context.chapterFiles.length,
|
|
166
|
+
questions: context.questionsChecked,
|
|
167
|
+
errors: [...context.errors],
|
|
168
|
+
warnings: [...context.warnings],
|
|
169
|
+
}]
|
|
170
|
+
: [];
|
|
171
|
+
return {
|
|
172
|
+
status: fatal ? 'fatal' : context.errors.length > 0 ? 'invalid' : 'valid',
|
|
173
|
+
exitCode,
|
|
174
|
+
fatal,
|
|
175
|
+
preset: context.preset,
|
|
176
|
+
numberStyle: context.toolingConfig?.numberStyle ?? null,
|
|
177
|
+
toolingConfig: context.toolingConfig,
|
|
178
|
+
scope: selection
|
|
179
|
+
? {
|
|
180
|
+
kind: selection.corpus ? 'corpus' : 'single',
|
|
181
|
+
requestedBook: selection.requestedBook,
|
|
182
|
+
selected,
|
|
183
|
+
}
|
|
184
|
+
: null,
|
|
185
|
+
errors: [...context.errors],
|
|
186
|
+
warnings: [...context.warnings],
|
|
187
|
+
notices: [...context.notices],
|
|
188
|
+
diagnostics,
|
|
189
|
+
bookResults,
|
|
190
|
+
counts: {
|
|
191
|
+
chapters: context.chapterFiles.length,
|
|
192
|
+
questions: context.questionsChecked,
|
|
193
|
+
},
|
|
194
|
+
regenerationRequests: [...context.regenerationRequests],
|
|
195
|
+
output: {
|
|
196
|
+
stdout: stdout.join(''),
|
|
197
|
+
stderr: stderr.join(''),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
|
|
204
|
+
// --preset <name> CLI flag (closes #9 — single source of truth across
|
|
205
|
+
// defineBookConfig + validate).
|
|
206
|
+
const presetFlagIdx = argv.findIndex((a) => a === '--preset');
|
|
207
|
+
const presetFromFlag = presetFlagIdx >= 0 ? argv[presetFlagIdx + 1] : undefined;
|
|
208
|
+
|
|
209
|
+
// v3.4.0: ROOT is the consumer's CWD, not the package's own dir.
|
|
210
|
+
// Resolves issue #8 — three reference consumers reported "0 chapter(s) checked"
|
|
211
|
+
// because ROOT was the package directory inside node_modules.
|
|
212
|
+
const PUBLIC_DIR = resolve(ROOT, 'public');
|
|
213
|
+
const DATA_DIR = resolve(ROOT, 'src/data');
|
|
214
|
+
|
|
215
|
+
let TOOLING_CONFIG;
|
|
216
|
+
try {
|
|
217
|
+
TOOLING_CONFIG = await loadResolvedBookConfig(ROOT);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const message = String(error?.message ?? error);
|
|
220
|
+
abort('config', message, 1, `validate: fatal: ${message}\n`);
|
|
221
|
+
}
|
|
222
|
+
context.toolingConfig = TOOLING_CONFIG;
|
|
223
|
+
let BOOK_SELECTION;
|
|
224
|
+
try {
|
|
225
|
+
BOOK_SELECTION = resolveBookSelection(TOOLING_CONFIG, argv, 'validate');
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const prefix = TOOLING_CONFIG.corpus ? '[book:corpus] ' : '';
|
|
228
|
+
const message = String(error?.message ?? error);
|
|
229
|
+
abort('invocation', message, 1, `${prefix}validate: fatal: ${message}\n`);
|
|
230
|
+
}
|
|
231
|
+
context.selection = BOOK_SELECTION;
|
|
232
|
+
const CORPUS_DIAGNOSTIC_PREFIX = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
|
|
233
|
+
// Corpus content uses one root with a registered first segment per book;
|
|
234
|
+
// single-book consumers retain the historical chapters directory.
|
|
235
|
+
let CHAPTERS_DIR;
|
|
236
|
+
try {
|
|
237
|
+
CHAPTERS_DIR = await readChaptersBase(ROOT, { corpus: BOOK_SELECTION.corpus });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const message = String(error?.message ?? error);
|
|
240
|
+
abort(
|
|
241
|
+
'config',
|
|
242
|
+
message,
|
|
243
|
+
1,
|
|
244
|
+
`${CORPUS_DIAGNOSTIC_PREFIX}validate: fatal: ${message}\n`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Preset resolution:
|
|
249
|
+
// composed Astro-config preset > --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
|
|
250
|
+
// .env BOOK_PRESET > .env BOOK_PROFILE >
|
|
251
|
+
// defineBookSchemas({ preset }) in content.config.ts >
|
|
252
|
+
// defineBookSchemas({ profile }) in content.config.ts (alias).
|
|
253
|
+
// .env fallback closes #20 — without it, consumers who set BOOK_PROFILE in
|
|
254
|
+
// .env (the documented convenience in SKILL.md + create-book defaults) saw
|
|
255
|
+
// the CLI silently default to minimal, hiding academic-profile errors.
|
|
256
|
+
// content.config.ts fallback closes #75 — without it, consumers using the
|
|
257
|
+
// canonical v4.5+ defineBookSchemas({ preset, chaptersBase }) form had the
|
|
258
|
+
// CLI silently default to minimal, hiding research-portfolio (and any
|
|
259
|
+
// non-env-set) profile errors while astro build applied the correct settings.
|
|
260
|
+
const dotenv = readEnvFile(ROOT);
|
|
261
|
+
const schemaConfig = await readBookSchemaConfig(ROOT);
|
|
262
|
+
const PRESET_CANDIDATE =
|
|
263
|
+
TOOLING_CONFIG.preset ??
|
|
264
|
+
presetFromFlag ??
|
|
265
|
+
ENV.BOOK_PRESET ??
|
|
266
|
+
ENV.BOOK_PROFILE ??
|
|
267
|
+
dotenv.BOOK_PRESET ??
|
|
268
|
+
dotenv.BOOK_PROFILE ??
|
|
269
|
+
schemaConfig.preset;
|
|
270
|
+
const PRESETS = ['academic', 'tools', 'minimal', 'course-notes', 'research-portfolio'];
|
|
271
|
+
if (presetFlagIdx >= 0 && !presetFromFlag) {
|
|
272
|
+
abort(
|
|
273
|
+
'invocation',
|
|
274
|
+
'--preset requires a value.',
|
|
275
|
+
2,
|
|
276
|
+
`${CORPUS_DIAGNOSTIC_PREFIX}validate: --preset requires a value.\n`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (PRESET_CANDIDATE && !PRESETS.includes(PRESET_CANDIDATE)) {
|
|
280
|
+
const message =
|
|
281
|
+
`preset must be one of ${PRESETS.join(' | ')} ` +
|
|
282
|
+
`(got ${JSON.stringify(PRESET_CANDIDATE)}).`;
|
|
283
|
+
abort(
|
|
284
|
+
presetFlagIdx >= 0 ? 'invocation' : 'config',
|
|
285
|
+
message,
|
|
286
|
+
1,
|
|
287
|
+
`${CORPUS_DIAGNOSTIC_PREFIX}validate: ${message}\n`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (!PRESET_CANDIDATE) {
|
|
291
|
+
const message =
|
|
292
|
+
'no book preset was resolved. Add a built-in Style to ' +
|
|
293
|
+
'`defineBookConfig({ styles: [...] })` and pass the same preset to ' +
|
|
294
|
+
'`defineBookSchemas({ preset: "..." })`, pass one `defineBookCorpus` manifest ' +
|
|
295
|
+
'to both entrypoints, use --preset, or set BOOK_PRESET in the environment or .env. ' +
|
|
296
|
+
`Valid presets: ${PRESETS.join(' | ')}. See ` +
|
|
297
|
+
'https://github.com/brandon-behring/book-scaffold-astro/blob/main/' +
|
|
298
|
+
'package/MIGRATION-v4-to-v5.md.';
|
|
299
|
+
abort(
|
|
300
|
+
'config',
|
|
301
|
+
message,
|
|
302
|
+
1,
|
|
303
|
+
`${CORPUS_DIAGNOSTIC_PREFIX}validate: ${message}\n`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const PRESET = PRESET_CANDIDATE;
|
|
307
|
+
context.preset = PRESET;
|
|
308
|
+
// Alias kept for downstream message text only; the resolution above is canonical.
|
|
309
|
+
const PROFILE = PRESET;
|
|
310
|
+
const MATH_ENABLED = PROFILE === 'academic' || PROFILE === 'research-portfolio';
|
|
311
|
+
const REPO_ROOT = ENV.BOOK_REPO_ROOT ?? null;
|
|
312
|
+
const corpusDependencyParser = unified().use(remarkParse).use(remarkMdx);
|
|
313
|
+
if (MATH_ENABLED) corpusDependencyParser.use(remarkMath);
|
|
314
|
+
|
|
315
|
+
let selectedCorpusDependencies = null;
|
|
316
|
+
async function resolveSelectedCorpusDependencies() {
|
|
317
|
+
if (selectedCorpusDependencies) return selectedCorpusDependencies;
|
|
318
|
+
|
|
319
|
+
const routeBooks = new Set(BOOK_SELECTION.books.map((book) => book.id));
|
|
320
|
+
const labelBooks = new Set(BOOK_SELECTION.books.map((book) => book.id));
|
|
321
|
+
if (!BOOK_SELECTION.corpus || BOOK_SELECTION.requestedBook === null) {
|
|
322
|
+
selectedCorpusDependencies = { routeBooks, labelBooks };
|
|
323
|
+
return selectedCorpusDependencies;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const selectedBook = BOOK_SELECTION.books[0];
|
|
327
|
+
const selectedDir = join(CHAPTERS_DIR, selectedBook.id);
|
|
328
|
+
const corpusIds = new Set(BOOK_SELECTION.corpus.books.map((book) => book.id));
|
|
329
|
+
const configuredBase = normalizeAstroBase(TOOLING_CONFIG.base);
|
|
330
|
+
for await (const file of walkMdx(selectedDir)) {
|
|
331
|
+
const content = await readFile(join(selectedDir, file), 'utf8');
|
|
332
|
+
|
|
333
|
+
// Root-absolute Markdown chapter links need only the referenced book's
|
|
334
|
+
// slug index. Structural collection avoids examples in code/comments.
|
|
335
|
+
try {
|
|
336
|
+
const targets = findAuthoredTargets(content, {
|
|
337
|
+
format: authoredFormat(file),
|
|
338
|
+
math: MATH_ENABLED,
|
|
339
|
+
});
|
|
340
|
+
for (const target of targets) {
|
|
341
|
+
if (target.kind !== 'Markdown link destination') continue;
|
|
342
|
+
const pathname = rootTargetPathname(target.target);
|
|
343
|
+
if (pathname === null) continue;
|
|
344
|
+
const localPath =
|
|
345
|
+
configuredBase !== '/' &&
|
|
346
|
+
(pathname === configuredBase || pathname.startsWith(`${configuredBase}/`))
|
|
347
|
+
? pathname.slice(configuredBase.length) || '/'
|
|
348
|
+
: pathname;
|
|
349
|
+
const match = localPath.match(/^\/chapters\/([^/]+)(?:\/|$)/);
|
|
350
|
+
if (match && corpusIds.has(match[1])) routeBooks.add(match[1]);
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
// The main validation pass reports the parse failure with source context.
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!content.includes('<BookLink')) continue;
|
|
357
|
+
try {
|
|
358
|
+
for (const element of mdxElements(corpusDependencyParser.parse(content), 'BookLink')) {
|
|
359
|
+
const bookAttr = mdxStringProp(element, 'book');
|
|
360
|
+
const toAttr = mdxStringProp(element, 'to');
|
|
361
|
+
if (!bookAttr.literal || !toAttr.literal || !corpusIds.has(bookAttr.value)) continue;
|
|
362
|
+
const target = normalizeCorpusTarget(
|
|
363
|
+
BOOK_SELECTION.corpus.books.find((book) => book.id === bookAttr.value),
|
|
364
|
+
toAttr.value,
|
|
365
|
+
);
|
|
366
|
+
if (target.error || !target.chapterSlug) continue;
|
|
367
|
+
routeBooks.add(bookAttr.value);
|
|
368
|
+
if (target.fragment !== null) labelBooks.add(bookAttr.value);
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
// The main validation pass reports the parse failure with source context.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
selectedCorpusDependencies = { routeBooks, labelBooks };
|
|
376
|
+
return selectedCorpusDependencies;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// v4.6.0 (issue #76 Layer 3b): chapter-route shadow warning. Detect a
|
|
380
|
+
// consumer-owned `src/pages/chapters/[...slug].astro` that shadows the
|
|
381
|
+
// scaffold v4.3.0+ auto-injected route. Non-blocking — emits to stderr,
|
|
382
|
+
// validate continues normally. Suppressed when the consumer explicitly
|
|
383
|
+
// disabled the scaffold's chapter route (intentional override).
|
|
384
|
+
//
|
|
385
|
+
// Edge cases per issue #76:
|
|
386
|
+
// file + routes.chapters undefined/true → WARN
|
|
387
|
+
// file + routes.chapters: false → silent (intentional override)
|
|
388
|
+
// no file (any routes config) → silent
|
|
389
|
+
//
|
|
390
|
+
// Heuristic for "routes.chapters: false": regex-grep astro.config.mjs for
|
|
391
|
+
// the literal `chapters: false`. Light-touch detection that matches the
|
|
392
|
+
// issue's warning-not-error intent; consumers wanting a stricter detector
|
|
393
|
+
// can run `astro check` separately.
|
|
394
|
+
{
|
|
395
|
+
const consumerChapterRoute = resolve(ROOT, 'src/pages/chapters/[...slug].astro');
|
|
396
|
+
if (existsSync(consumerChapterRoute)) {
|
|
397
|
+
const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
|
|
398
|
+
let chaptersDisabled = false;
|
|
399
|
+
if (existsSync(astroConfigPath)) {
|
|
400
|
+
const astroConfig = readFileSync(astroConfigPath, 'utf8');
|
|
401
|
+
// Match `chapters: false` (with optional whitespace) inside a routes
|
|
402
|
+
// object. Slight false-positive risk on commented-out code; acceptable
|
|
403
|
+
// for a non-blocking warning.
|
|
404
|
+
chaptersDisabled = /\bchapters\s*:\s*false\b/.test(astroConfig);
|
|
405
|
+
}
|
|
406
|
+
if (!chaptersDisabled) {
|
|
407
|
+
const message =
|
|
408
|
+
(BOOK_SELECTION.corpus
|
|
409
|
+
? '[book:corpus] validate: WARN consumer-owned chapter route at '
|
|
410
|
+
: '\n⚠ Consumer-owned chapter route at ') +
|
|
411
|
+
`src/pages/chapters/[...slug].astro\n` +
|
|
412
|
+
` shadows the scaffold v4.3.0+ auto-injected route. Either:\n` +
|
|
413
|
+
` • Delete the consumer file to defer to the scaffold (recommended), OR\n` +
|
|
414
|
+
` • Set 'routes: { chapters: false }' in defineBookConfig to keep\n` +
|
|
415
|
+
` your override (intentional).\n` +
|
|
416
|
+
` See: package/recipes/18-chapter-route-ownership.md\n`;
|
|
417
|
+
writeStderr(`${message}\n`);
|
|
418
|
+
context.notices.push({
|
|
419
|
+
severity: 'warning',
|
|
420
|
+
code: 'consumer_chapter_route_shadow',
|
|
421
|
+
message: 'Consumer-owned chapter route shadows the scaffold route.',
|
|
422
|
+
book: BOOK_SELECTION.corpus ? 'corpus' : null,
|
|
423
|
+
file: 'src/pages/chapters/[...slug].astro',
|
|
424
|
+
line: 1,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// v4.20.0 (issue #129): the same shadow warning for the landing route. A
|
|
431
|
+
// consumer-owned `src/pages/index.astro` collides with the scaffold's
|
|
432
|
+
// auto-injected `/` (Astro warns today and has announced a hard error in a
|
|
433
|
+
// future major). The escape hatch already exists — `routes: { landing: false }`
|
|
434
|
+
// — this check makes it discoverable before Astro's break lands. Same edge
|
|
435
|
+
// cases + heuristic as the chapters check above.
|
|
436
|
+
{
|
|
437
|
+
const consumerLanding = resolve(ROOT, 'src/pages/index.astro');
|
|
438
|
+
if (existsSync(consumerLanding)) {
|
|
439
|
+
const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
|
|
440
|
+
let landingDisabled = false;
|
|
441
|
+
if (existsSync(astroConfigPath)) {
|
|
442
|
+
const astroConfig = readFileSync(astroConfigPath, 'utf8');
|
|
443
|
+
landingDisabled = /\blanding\s*:\s*false\b/.test(astroConfig);
|
|
444
|
+
}
|
|
445
|
+
if (!landingDisabled) {
|
|
446
|
+
const message =
|
|
447
|
+
(BOOK_SELECTION.corpus
|
|
448
|
+
? '[book:corpus] validate: WARN consumer-owned landing page at '
|
|
449
|
+
: '\n⚠ Consumer-owned landing page at ') +
|
|
450
|
+
`src/pages/index.astro shadows the\n` +
|
|
451
|
+
` scaffold's auto-injected "/" route. Your page wins today, but Astro\n` +
|
|
452
|
+
` has announced route collisions become a HARD ERROR in a future\n` +
|
|
453
|
+
` version. Set 'routes: { landing: false }' in defineBookConfig to\n` +
|
|
454
|
+
` declare the override and silence the collision.\n` +
|
|
455
|
+
` See: package/recipes/18-chapter-route-ownership.md\n`;
|
|
456
|
+
writeStderr(`${message}\n`);
|
|
457
|
+
context.notices.push({
|
|
458
|
+
severity: 'warning',
|
|
459
|
+
code: 'consumer_landing_route_shadow',
|
|
460
|
+
message: 'Consumer-owned landing page shadows the scaffold route.',
|
|
461
|
+
book: BOOK_SELECTION.corpus ? 'corpus' : null,
|
|
462
|
+
file: 'src/pages/index.astro',
|
|
463
|
+
line: 1,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const errors = context.errors;
|
|
470
|
+
const warnings = context.warnings;
|
|
471
|
+
let ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
|
|
472
|
+
const fail = (file, line, msg, book = ACTIVE_BOOK_ID) => errors.push({ file, line, msg, book });
|
|
473
|
+
const warn = (file, line, msg, book = ACTIVE_BOOK_ID) => warnings.push({ file, line, msg, book });
|
|
474
|
+
const invalidFrontmatterFiles = new Set();
|
|
475
|
+
function recordFrontmatterFailure(error, file, book) {
|
|
476
|
+
invalidFrontmatterFiles.add(file);
|
|
477
|
+
fail(
|
|
478
|
+
file,
|
|
479
|
+
error?.frontmatterLine ?? 1,
|
|
480
|
+
error?.frontmatterDetail ?? `Invalid YAML frontmatter: ${error?.message ?? error}`,
|
|
481
|
+
book,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ===== Self-heal missing generated artifacts (#186) =====
|
|
486
|
+
// These files are intentionally gitignored. Direct `book-scaffold validate`
|
|
487
|
+
// bypasses npm's prevalidate lifecycle, so rebuild each missing artifact before
|
|
488
|
+
// loading it. In selected corpus runs, placeholder-empty requested/dependency
|
|
489
|
+
// namespaces are also materialized to keep results independent of run order.
|
|
490
|
+
// Child diagnostics and failures are propagated verbatim instead of becoming
|
|
491
|
+
// downstream unknown-id noise.
|
|
492
|
+
async function regenerate(scriptName, artifact, book = null, reason = null) {
|
|
493
|
+
const prefix = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
|
|
494
|
+
const progress =
|
|
495
|
+
`${prefix}validate: ${reason ?? `${artifact} is missing — regenerating via ${scriptName} (#186)`}\n`;
|
|
496
|
+
writeStdout(progress);
|
|
497
|
+
const request = { scriptName, artifact, book, reason, root: ROOT, env: ENV };
|
|
498
|
+
context.regenerationRequests.push({ scriptName, artifact, book, reason });
|
|
499
|
+
await options.onProgress?.({ type: 'artifact-regeneration', ...request });
|
|
500
|
+
if (typeof options.regenerateArtifact !== 'function') {
|
|
501
|
+
const message = `${artifact} requires regeneration via ${scriptName}.`;
|
|
502
|
+
abort(
|
|
503
|
+
'artifact',
|
|
504
|
+
message,
|
|
505
|
+
1,
|
|
506
|
+
`${prefix}validate: fatal: ${message} Supply regenerateArtifact or run the producer first.\n`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
let result;
|
|
510
|
+
try {
|
|
511
|
+
result = (await options.regenerateArtifact(request)) ?? { status: 0 };
|
|
512
|
+
} catch (error) {
|
|
513
|
+
result = { status: 1, error };
|
|
514
|
+
}
|
|
515
|
+
if (result?.stdout) writeStdout(result.stdout);
|
|
516
|
+
if (result?.stderr) writeStderr(result.stderr);
|
|
517
|
+
const status = result.status === undefined ? 0 : result.status;
|
|
518
|
+
if (result.error || status !== 0) {
|
|
519
|
+
const detail = result.error ? `: ${result.error.message}` : '';
|
|
520
|
+
const exitCode = status ?? 1;
|
|
521
|
+
const message = `${scriptName} failed (exit ${exitCode})${detail} — cannot self-heal.`;
|
|
522
|
+
abort(
|
|
523
|
+
'artifact',
|
|
524
|
+
message,
|
|
525
|
+
exitCode,
|
|
526
|
+
`${prefix}validate: ${message}\n`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function readableCorpusArtifactBooks(path, artifact) {
|
|
532
|
+
try {
|
|
533
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
534
|
+
return assertCorpusEnvelope(
|
|
535
|
+
value,
|
|
536
|
+
BOOK_SELECTION.corpus,
|
|
537
|
+
artifact,
|
|
538
|
+
isRecord,
|
|
539
|
+
).books;
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function isEmptyRecord(value) {
|
|
546
|
+
return isRecord(value) && Object.keys(value).length === 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const labelsPath = join(DATA_DIR, 'labels.json');
|
|
550
|
+
const materializedLabels = new Set();
|
|
551
|
+
let materializedAllLabels = false;
|
|
552
|
+
if (!existsSync(labelsPath)) {
|
|
553
|
+
await regenerate(
|
|
554
|
+
'build-labels.mjs',
|
|
555
|
+
'src/data/labels.json',
|
|
556
|
+
BOOK_SELECTION.requestedBook,
|
|
557
|
+
);
|
|
558
|
+
if (BOOK_SELECTION.requestedBook) materializedLabels.add(BOOK_SELECTION.requestedBook);
|
|
559
|
+
else if (BOOK_SELECTION.corpus) materializedAllLabels = true;
|
|
560
|
+
}
|
|
561
|
+
if (BOOK_SELECTION.requestedBook) {
|
|
562
|
+
const dependencies = await resolveSelectedCorpusDependencies();
|
|
563
|
+
const books = await readableCorpusArtifactBooks(labelsPath, 'src/data/labels.json');
|
|
564
|
+
if (books) {
|
|
565
|
+
for (const book of dependencies.labelBooks) {
|
|
566
|
+
if (materializedLabels.has(book) || !isEmptyRecord(books[book])) continue;
|
|
567
|
+
await regenerate(
|
|
568
|
+
'build-labels.mjs',
|
|
569
|
+
'src/data/labels.json',
|
|
570
|
+
book,
|
|
571
|
+
`${book === BOOK_SELECTION.requestedBook
|
|
572
|
+
? `selected book ${book}`
|
|
573
|
+
: `local BookLink from ${BOOK_SELECTION.requestedBook} requires ${book}`} ` +
|
|
574
|
+
'has an empty labels namespace — materializing it (#186)',
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} else if (BOOK_SELECTION.corpus && !materializedAllLabels) {
|
|
579
|
+
const books = await readableCorpusArtifactBooks(labelsPath, 'src/data/labels.json');
|
|
580
|
+
if (books && BOOK_SELECTION.corpus.books.some((book) => isEmptyRecord(books[book.id]))) {
|
|
581
|
+
await regenerate(
|
|
582
|
+
'build-labels.mjs',
|
|
583
|
+
'src/data/labels.json',
|
|
584
|
+
null,
|
|
585
|
+
'corpus labels contain placeholder-empty namespaces — rebuilding all books (#186)',
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const referencesPath = join(DATA_DIR, 'references.json');
|
|
591
|
+
let materializedReferences = false;
|
|
592
|
+
let materializedAllReferences = false;
|
|
593
|
+
if (!existsSync(referencesPath)) {
|
|
594
|
+
await regenerate(
|
|
595
|
+
'build-bib.mjs',
|
|
596
|
+
'src/data/references.json',
|
|
597
|
+
BOOK_SELECTION.requestedBook,
|
|
598
|
+
);
|
|
599
|
+
materializedReferences = BOOK_SELECTION.requestedBook !== null;
|
|
600
|
+
materializedAllReferences = Boolean(BOOK_SELECTION.corpus && !BOOK_SELECTION.requestedBook);
|
|
601
|
+
}
|
|
602
|
+
if (BOOK_SELECTION.requestedBook) {
|
|
603
|
+
if (!materializedReferences) {
|
|
604
|
+
const books = await readableCorpusArtifactBooks(
|
|
605
|
+
referencesPath,
|
|
606
|
+
'src/data/references.json',
|
|
607
|
+
);
|
|
608
|
+
if (books && isEmptyRecord(books[BOOK_SELECTION.requestedBook])) {
|
|
609
|
+
await regenerate(
|
|
610
|
+
'build-bib.mjs',
|
|
611
|
+
'src/data/references.json',
|
|
612
|
+
BOOK_SELECTION.requestedBook,
|
|
613
|
+
`selected book ${BOOK_SELECTION.requestedBook} has an empty references namespace — ` +
|
|
614
|
+
'materializing it (#186)',
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} else if (BOOK_SELECTION.corpus && !materializedAllReferences) {
|
|
619
|
+
const books = await readableCorpusArtifactBooks(
|
|
620
|
+
referencesPath,
|
|
621
|
+
'src/data/references.json',
|
|
622
|
+
);
|
|
623
|
+
if (books && BOOK_SELECTION.corpus.books.some((book) => isEmptyRecord(books[book.id]))) {
|
|
624
|
+
await regenerate(
|
|
625
|
+
'build-bib.mjs',
|
|
626
|
+
'src/data/references.json',
|
|
627
|
+
null,
|
|
628
|
+
'corpus references contain placeholder-empty namespaces — rebuilding all books (#186)',
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ===== Load reference data (graceful when missing) =====
|
|
634
|
+
async function loadJson(path) {
|
|
635
|
+
try {
|
|
636
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
637
|
+
} catch {
|
|
638
|
+
return {};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function isRecord(value) {
|
|
642
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function loadGeneratedArtifact(name) {
|
|
646
|
+
const path = join(DATA_DIR, name);
|
|
647
|
+
if (!BOOK_SELECTION.corpus) return loadJson(path);
|
|
648
|
+
try {
|
|
649
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
650
|
+
return assertCorpusEnvelope(parsed, BOOK_SELECTION.corpus, `src/data/${name}`, isRecord);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const message =
|
|
653
|
+
`${error?.message ?? error}. ` +
|
|
654
|
+
`Run book-scaffold ${name === 'labels.json' ? 'build-labels' : 'build-bib'} ` +
|
|
655
|
+
'to regenerate the corpus envelope.';
|
|
656
|
+
abort(
|
|
657
|
+
'artifact',
|
|
658
|
+
message,
|
|
659
|
+
1,
|
|
660
|
+
`[book:corpus] validate: fatal: ${message}\n`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const refsArtifact = await loadGeneratedArtifact('references.json');
|
|
666
|
+
const labelsArtifact = await loadGeneratedArtifact('labels.json');
|
|
667
|
+
|
|
668
|
+
// #147: eagerly load every labels index explicitly declared by the evaluated
|
|
669
|
+
// siblingBooks registry. Unlike this book's generated labels.json, sibling
|
|
670
|
+
// indexes are vendored inputs: never self-heal or silently replace them. A
|
|
671
|
+
// missing, unreadable, malformed, or non-object index is a configuration error
|
|
672
|
+
// even when no current chapter happens to reference that sibling.
|
|
673
|
+
const siblingLabelIndexes = new Map();
|
|
674
|
+
for (const [book, entry] of Object.entries(TOOLING_CONFIG.siblingBooks)) {
|
|
675
|
+
if (typeof entry === 'string' || entry.labels === undefined) continue;
|
|
676
|
+
const indexPath = resolve(ROOT, entry.labels);
|
|
677
|
+
try {
|
|
678
|
+
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
|
679
|
+
if (index === null || typeof index !== 'object' || Array.isArray(index)) {
|
|
680
|
+
throw new Error('top-level JSON value must be an object');
|
|
681
|
+
}
|
|
682
|
+
siblingLabelIndexes.set(book, { index, configuredPath: entry.labels });
|
|
683
|
+
} catch (error) {
|
|
684
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
685
|
+
fail(
|
|
686
|
+
'astro.config',
|
|
687
|
+
1,
|
|
688
|
+
`siblingBooks.${book}.labels (${JSON.stringify(entry.labels)}) is missing, unreadable, or invalid: ${detail}. ` +
|
|
689
|
+
'Vendor a readable sibling labels.json at that path or remove labels to opt out with a warning.',
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ===== Collect chapter files =====
|
|
695
|
+
// v3.7.1 (closes #52): walkMdx (in ./walk-mdx.mjs) is a recursive readdir
|
|
696
|
+
// walker that replaces the previous `glob` import from `node:fs/promises`.
|
|
697
|
+
// The `glob` API was added in Node 22 but consumer CI templates ship
|
|
698
|
+
// Node 20 — `npm run validate` crashed on every consumer's prebuild hook.
|
|
699
|
+
// Walker uses readdir + path only; works on Node 18+.
|
|
700
|
+
const chapterFiles = context.chapterFiles;
|
|
701
|
+
const chapterBookByFile = new Map();
|
|
702
|
+
const chapterCountsByBook = context.chapterCountsByBook;
|
|
703
|
+
if (BOOK_SELECTION.corpus) {
|
|
704
|
+
for (const book of BOOK_SELECTION.books) {
|
|
705
|
+
let count = 0;
|
|
706
|
+
for await (const file of walkMdx(join(CHAPTERS_DIR, book.id))) {
|
|
707
|
+
if (file.split('/').pop().startsWith('_')) continue;
|
|
708
|
+
const scoped = `${book.id}/${file}`;
|
|
709
|
+
chapterFiles.push(scoped);
|
|
710
|
+
chapterBookByFile.set(scoped, book.id);
|
|
711
|
+
count += 1;
|
|
712
|
+
}
|
|
713
|
+
chapterCountsByBook.set(book.id, count);
|
|
714
|
+
}
|
|
715
|
+
} else {
|
|
716
|
+
for await (const file of walkMdx(CHAPTERS_DIR)) {
|
|
717
|
+
if (!file.split('/').pop().startsWith('_')) chapterFiles.push(file);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function bookArtifacts(rel) {
|
|
722
|
+
const book = chapterBookByFile.get(rel) ?? null;
|
|
723
|
+
if (!book) return { book: null, refs: refsArtifact, labels: labelsArtifact };
|
|
724
|
+
return {
|
|
725
|
+
book,
|
|
726
|
+
refs: refsArtifact.books[book],
|
|
727
|
+
labels: labelsArtifact.books[book],
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ===== Build slug set from chapter filenames (for internal-link check) =====
|
|
732
|
+
const validSlugs = new Set(
|
|
733
|
+
BOOK_SELECTION.corpus
|
|
734
|
+
? []
|
|
735
|
+
: chapterFiles.map((file) => file.replace(/\.(md|mdx)$/, '')),
|
|
736
|
+
);
|
|
737
|
+
if (BOOK_SELECTION.corpus) {
|
|
738
|
+
// Full validation indexes every book. A selected run indexes its own book
|
|
739
|
+
// plus literal chapter-link dependencies only, so unrelated malformed
|
|
740
|
+
// content cannot make `validate --book <id>` cache-dependent.
|
|
741
|
+
const dependencies = await resolveSelectedCorpusDependencies();
|
|
742
|
+
for (const book of BOOK_SELECTION.corpus.books.filter((candidate) =>
|
|
743
|
+
dependencies.routeBooks.has(candidate.id))) {
|
|
744
|
+
for await (const file of walkMdx(join(CHAPTERS_DIR, book.id))) {
|
|
745
|
+
if (!file.split('/').pop().startsWith('_')) {
|
|
746
|
+
const fileLabel = `[book:${book.id}] ${book.id}/${file}`;
|
|
747
|
+
const source = await readFile(join(CHAPTERS_DIR, book.id, file), 'utf8');
|
|
748
|
+
try {
|
|
749
|
+
const localId = frontmatterSlug(source, fileLabel) ?? file.replace(/\.(md|mdx)$/, '');
|
|
750
|
+
validSlugs.add(`${book.id}/${localId}`);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
recordFrontmatterFailure(error, `${book.id}/${file}`, book.id);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const validTopLevelRoutes = new Set(
|
|
759
|
+
BOOK_SELECTION.corpus
|
|
760
|
+
? ['/', '/chapters/', '/search/']
|
|
761
|
+
: ['/', '/chapters/', '/references/', '/search/', '/print/', '/convergence/'],
|
|
762
|
+
);
|
|
763
|
+
const KNOWN_CORPUS_APPARATUS_ROUTES = new Set([
|
|
764
|
+
'references',
|
|
765
|
+
'print',
|
|
766
|
+
'convergence',
|
|
767
|
+
'tips',
|
|
768
|
+
'exercises',
|
|
769
|
+
'glossary',
|
|
770
|
+
'practice-exam',
|
|
771
|
+
'flashcards',
|
|
772
|
+
'answers',
|
|
773
|
+
]);
|
|
774
|
+
if (BOOK_SELECTION.corpus) {
|
|
775
|
+
for (const book of BOOK_SELECTION.corpus.books) {
|
|
776
|
+
validTopLevelRoutes.add(`/${book.id}/`);
|
|
777
|
+
validTopLevelRoutes.add(`/chapters/${book.id}/`);
|
|
778
|
+
for (const route of book.apparatus ?? TOOLING_CONFIG.apparatusRoutes) {
|
|
779
|
+
validTopLevelRoutes.add(`/${book.id}/${route}/`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ===== Pattern helpers for component-specific checks =====
|
|
785
|
+
const RE_CITE = /<Cite[^>]+key=["']([^"']+)["']/g;
|
|
786
|
+
const RE_XREF = /<XRef[^>]+id=["']([^"']+)["']/g;
|
|
787
|
+
const RE_FIGURE = /<Figure[^>]+src=["']([^"']+)["']/g;
|
|
788
|
+
const RE_CODEREF = /<CodeRef[^>]+path=["']([^"']+)["'](?:[^>]*line=\{(\d+)\})?(?:[^>]*lineEnd=\{(\d+)\})?/g;
|
|
789
|
+
// #121: a <Theorem> opening tag — capture its attributes to assert a
|
|
790
|
+
// resolvable kind= (or legacy type=) is present.
|
|
791
|
+
const RE_THEOREM = /<Theorem\b([^>]*)>/g;
|
|
792
|
+
// #96/#147: BookLink attributes must be read structurally. An opening-tag
|
|
793
|
+
// regex stops at `>` inside a quoted value or expression and can mistake text
|
|
794
|
+
// inside another prop for a real book=/to= assignment.
|
|
795
|
+
const mdxParser = unified().use(remarkParse).use(remarkMdx);
|
|
796
|
+
if (MATH_ENABLED) mdxParser.use(remarkMath);
|
|
797
|
+
|
|
798
|
+
async function fileExists(p) {
|
|
799
|
+
try {
|
|
800
|
+
await access(p);
|
|
801
|
+
return true;
|
|
802
|
+
} catch {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function lineOf(content, idx) {
|
|
808
|
+
return content.slice(0, idx).split('\n').length;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const ASTRO_BASE = normalizeAstroBase(TOOLING_CONFIG.base);
|
|
812
|
+
|
|
813
|
+
function authoredFormat(file) {
|
|
814
|
+
return extname(file).toLowerCase() === '.md' ? 'md' : 'mdx';
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function collectAuthoredTargets(file, content) {
|
|
818
|
+
try {
|
|
819
|
+
return findAuthoredTargets(content, {
|
|
820
|
+
format: authoredFormat(file),
|
|
821
|
+
math: MATH_ENABLED,
|
|
822
|
+
});
|
|
823
|
+
} catch (error) {
|
|
824
|
+
const line = error?.line ?? error?.place?.start?.line ?? error?.position?.start?.line ?? 1;
|
|
825
|
+
const detail = String(error?.reason ?? error?.message ?? error).split('\n')[0];
|
|
826
|
+
fail(file, line, `Could not parse authored links as ${authoredFormat(file).toUpperCase()}: ${detail}`);
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function validateAuthoredTargets(file, content, targets) {
|
|
832
|
+
for (const violation of targets) {
|
|
833
|
+
if (!rootTargetEscapesBase(violation.target, ASTRO_BASE)) continue;
|
|
834
|
+
const suggested = suggestBaseContainedTarget(violation.target, ASTRO_BASE);
|
|
835
|
+
fail(
|
|
836
|
+
file,
|
|
837
|
+
lineOf(content, violation.index),
|
|
838
|
+
`Authored ${violation.kind} ${JSON.stringify(violation.target)} escapes configured Astro base ` +
|
|
839
|
+
`${JSON.stringify(ASTRO_BASE)}. Use ${JSON.stringify(suggested)}, ` +
|
|
840
|
+
'import.meta.env.BASE_URL in JSX, or a base-aware component. ' +
|
|
841
|
+
'Validation does not rewrite authored URLs (#190).',
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function validateInternalMarkdownLinks(file, content, targets) {
|
|
847
|
+
for (const authored of targets) {
|
|
848
|
+
if (authored.kind !== 'Markdown link destination') continue;
|
|
849
|
+
const pathname = rootTargetPathname(authored.target);
|
|
850
|
+
if (pathname === null) continue;
|
|
851
|
+
const baseRelativeTarget =
|
|
852
|
+
ASTRO_BASE !== '/' && (pathname === ASTRO_BASE || pathname.startsWith(`${ASTRO_BASE}/`))
|
|
853
|
+
? pathname.slice(ASTRO_BASE.length) || '/'
|
|
854
|
+
: pathname;
|
|
855
|
+
const target = baseRelativeTarget.replace(/\/$/, '') || '/';
|
|
856
|
+
if (validTopLevelRoutes.has(`${target}/`) || validTopLevelRoutes.has(target)) continue;
|
|
857
|
+
const chMatch = target.match(/^\/chapters\/(.+)$/);
|
|
858
|
+
if (chMatch && validSlugs.has(chMatch[1])) continue;
|
|
859
|
+
warn(
|
|
860
|
+
file,
|
|
861
|
+
lineOf(content, authored.index),
|
|
862
|
+
`Internal link ${JSON.stringify(authored.target)} — target may not resolve (check spelling or route)`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Return a statically knowable n= value. Quoted strings and braced
|
|
869
|
+
* string/numeric literals are safe to compare; identifiers, calls,
|
|
870
|
+
* interpolation, and all other expressions are deliberately skipped.
|
|
871
|
+
*/
|
|
872
|
+
function literalTheoremNumber(attrs) {
|
|
873
|
+
const quoted = attrs.match(/\bn\s*=\s*(["'])(.*?)\1/);
|
|
874
|
+
if (quoted) return quoted[2];
|
|
875
|
+
|
|
876
|
+
const braced = attrs.match(/\bn\s*=\s*\{\s*([^}]*?)\s*\}/);
|
|
877
|
+
if (!braced) return null;
|
|
878
|
+
const expression = braced[1].trim();
|
|
879
|
+
const stringLiteral = expression.match(/^(["'`])([\s\S]*)\1$/);
|
|
880
|
+
if (stringLiteral) {
|
|
881
|
+
if (stringLiteral[1] === '`' && stringLiteral[2].includes('${')) return null;
|
|
882
|
+
return stringLiteral[2];
|
|
883
|
+
}
|
|
884
|
+
if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(expression)) {
|
|
885
|
+
return String(Number(expression));
|
|
886
|
+
}
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Yield MDX JSX elements with an exact component name, excluding examples in
|
|
891
|
+
* fenced/inline code and strings in expressions by construction. */
|
|
892
|
+
function* mdxElements(node, name) {
|
|
893
|
+
if (
|
|
894
|
+
(node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
|
|
895
|
+
node.name === name
|
|
896
|
+
) {
|
|
897
|
+
yield node;
|
|
898
|
+
}
|
|
899
|
+
if (!Array.isArray(node.children)) return;
|
|
900
|
+
for (const child of node.children) yield* mdxElements(child, name);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function expressionString(value) {
|
|
904
|
+
const program = value?.data?.estree;
|
|
905
|
+
if (program?.body?.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
const expression = program.body[0].expression;
|
|
909
|
+
if (expression.type === 'Literal' && typeof expression.value === 'string') {
|
|
910
|
+
return expression.value;
|
|
911
|
+
}
|
|
912
|
+
if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
|
|
913
|
+
return expression.quasis[0]?.value?.cooked ?? expression.quasis[0]?.value?.raw ?? '';
|
|
914
|
+
}
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Evaluate one JSX prop in source order. Later explicit attributes override an
|
|
920
|
+
* earlier spread; a later spread makes the value dynamic, exactly as JSX does.
|
|
921
|
+
* Literal ESTree values are already decoded, so entities and JS escapes cannot
|
|
922
|
+
* disguise the href that the component receives at runtime.
|
|
923
|
+
*/
|
|
924
|
+
function mdxStringProp(element, name) {
|
|
925
|
+
let result = { present: false, literal: false, value: null, source: 'absent' };
|
|
926
|
+
for (const attribute of element.attributes) {
|
|
927
|
+
if (attribute.type === 'mdxJsxExpressionAttribute') {
|
|
928
|
+
result = { present: true, literal: false, value: null, source: 'spread' };
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
if (attribute.type !== 'mdxJsxAttribute' || attribute.name !== name) continue;
|
|
932
|
+
if (typeof attribute.value === 'string') {
|
|
933
|
+
result = { present: true, literal: true, value: attribute.value, source: 'attribute' };
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const value = expressionString(attribute.value);
|
|
937
|
+
result = value === null
|
|
938
|
+
? { present: true, literal: false, value: null, source: 'expression' }
|
|
939
|
+
: { present: true, literal: true, value, source: 'attribute' };
|
|
940
|
+
}
|
|
941
|
+
return result;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Canonical comparison shape for a sibling labels href. The generated index
|
|
946
|
+
* intentionally omits the deployment base, so only normalize route seams:
|
|
947
|
+
* leading slashes and the optional trailing slash immediately before `#`.
|
|
948
|
+
*/
|
|
949
|
+
function normalizeSiblingTarget(value) {
|
|
950
|
+
const hash = value.indexOf('#');
|
|
951
|
+
if (hash < 0 || hash === value.length - 1) return null;
|
|
952
|
+
const fragment = value.slice(hash + 1);
|
|
953
|
+
const path = value.slice(0, hash).trim().replace(/^\/+/, '').replace(/\/+$/, '');
|
|
954
|
+
return { fragment, href: `${path}#${fragment}` };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Heading keys in labels.json are deliberately opaque and path-qualified so
|
|
959
|
+
* `#summary` can exist in every chapter. Resolve sibling targets from href
|
|
960
|
+
* values, while remaining compatible with historical component-id keys.
|
|
961
|
+
*/
|
|
962
|
+
function siblingTargetCandidates(index, fragment) {
|
|
963
|
+
const candidates = [];
|
|
964
|
+
for (const [key, entry] of Object.entries(index)) {
|
|
965
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.href !== 'string') continue;
|
|
966
|
+
const normalized = normalizeSiblingTarget(entry.href);
|
|
967
|
+
if (normalized?.fragment === fragment) {
|
|
968
|
+
candidates.push({ key, href: entry.href, normalized });
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return candidates;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function normalizeCorpusTarget(book, to) {
|
|
975
|
+
if (typeof to !== 'string' || to.trim().length === 0) {
|
|
976
|
+
return { error: 'target must be non-empty' };
|
|
977
|
+
}
|
|
978
|
+
if (to !== to.trim()) return { error: 'surrounding whitespace is not allowed' };
|
|
979
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith('/') || to.includes('\\')) {
|
|
980
|
+
return { error: 'absolute URLs and paths are not allowed' };
|
|
981
|
+
}
|
|
982
|
+
if (/[\u0000-\u001f\u007f]/.test(to)) return { error: 'control characters are not allowed' };
|
|
983
|
+
|
|
984
|
+
const query = to.indexOf('?');
|
|
985
|
+
const hash = to.indexOf('#');
|
|
986
|
+
const suffixIndex = [query, hash]
|
|
987
|
+
.filter((index) => index >= 0)
|
|
988
|
+
.reduce((minimum, index) => Math.min(minimum, index), to.length);
|
|
989
|
+
const path = to.slice(0, suffixIndex).replace(/\/+$/, '');
|
|
990
|
+
if (!path) return { error: 'query-only and fragment-only targets are not allowed' };
|
|
991
|
+
const segments = path.split('/');
|
|
992
|
+
if (segments.some((segment) => segment.length === 0)) {
|
|
993
|
+
return { error: 'empty path segments are not allowed' };
|
|
994
|
+
}
|
|
995
|
+
for (const segment of segments) {
|
|
996
|
+
let decoded;
|
|
997
|
+
try {
|
|
998
|
+
decoded = decodeURIComponent(segment);
|
|
999
|
+
} catch {
|
|
1000
|
+
return { error: 'malformed percent encoding' };
|
|
1001
|
+
}
|
|
1002
|
+
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) {
|
|
1003
|
+
return { error: 'path traversal is not allowed' };
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const localPath = segments.join('/');
|
|
1008
|
+
const canonicalPath = localPath === 'chapters'
|
|
1009
|
+
? `chapters/${book.id}`
|
|
1010
|
+
: localPath.startsWith('chapters/')
|
|
1011
|
+
? `chapters/${book.id}/${localPath.slice('chapters/'.length)}`
|
|
1012
|
+
: `${book.id}/${localPath}`;
|
|
1013
|
+
const fragment = hash >= 0 ? to.slice(hash + 1) : null;
|
|
1014
|
+
return {
|
|
1015
|
+
error: null,
|
|
1016
|
+
localPath,
|
|
1017
|
+
canonicalPath,
|
|
1018
|
+
fragment,
|
|
1019
|
+
href: fragment ? `${canonicalPath}#${fragment}` : canonicalPath,
|
|
1020
|
+
chapterSlug: localPath.startsWith('chapters/')
|
|
1021
|
+
? `${book.id}/${localPath.slice('chapters/'.length)}`
|
|
1022
|
+
: null,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ===== Run all checks on each chapter =====
|
|
1027
|
+
for (const rel of chapterFiles) {
|
|
1028
|
+
const abs = join(CHAPTERS_DIR, rel);
|
|
1029
|
+
const content = await readFile(abs, 'utf8');
|
|
1030
|
+
const artifacts = bookArtifacts(rel);
|
|
1031
|
+
const { refs, labels } = artifacts;
|
|
1032
|
+
ACTIVE_BOOK_ID = artifacts.book;
|
|
1033
|
+
if (artifacts.book && !invalidFrontmatterFiles.has(rel)) {
|
|
1034
|
+
try {
|
|
1035
|
+
const legacy = legacyFrontmatterBook(content, `[book:${artifacts.book}] ${rel}`);
|
|
1036
|
+
if (legacy && legacy.value !== artifacts.book) {
|
|
1037
|
+
fail(
|
|
1038
|
+
rel,
|
|
1039
|
+
legacy.line,
|
|
1040
|
+
`frontmatter book ${JSON.stringify(legacy.value)} does not match ` +
|
|
1041
|
+
`path-derived corpus book ${JSON.stringify(artifacts.book)}.`,
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
recordFrontmatterFailure(error, rel, artifacts.book);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const authoredTargets = collectAuthoredTargets(rel, content);
|
|
1049
|
+
|
|
1050
|
+
// 10. Root-absolute authored targets under a non-root Astro base (#190).
|
|
1051
|
+
// Structural parsing covers Markdown link/image destinations, HTML
|
|
1052
|
+
// href/src, and decoded static JSX strings while excluding code examples.
|
|
1053
|
+
validateAuthoredTargets(rel, content, authoredTargets);
|
|
1054
|
+
|
|
1055
|
+
let bookLinks = [];
|
|
1056
|
+
if (content.includes('<BookLink')) {
|
|
1057
|
+
try {
|
|
1058
|
+
bookLinks = [...mdxElements(mdxParser.parse(content), 'BookLink')];
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
const line = error?.position?.start?.line ?? error?.line ?? 1;
|
|
1061
|
+
fail(
|
|
1062
|
+
rel,
|
|
1063
|
+
line,
|
|
1064
|
+
`Cannot structurally validate <BookLink>: ${error?.reason ?? error?.message ?? error}`,
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// 1. Cite keys (academic profile only — tools profile uses YAML manifest)
|
|
1070
|
+
if (PROFILE === 'academic') {
|
|
1071
|
+
for (const m of content.matchAll(RE_CITE)) {
|
|
1072
|
+
if (!refs[m[1]]) fail(rel, lineOf(content, m.index), `Unknown bibkey "${m[1]}" — not in references.json`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// 2. XRef ids
|
|
1077
|
+
for (const m of content.matchAll(RE_XREF)) {
|
|
1078
|
+
if (!labels[m[1]]) fail(rel, lineOf(content, m.index), `Unknown XRef id "${m[1]}" — not in labels.json`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// 3. Figure src exists in public/
|
|
1082
|
+
for (const m of content.matchAll(RE_FIGURE)) {
|
|
1083
|
+
const src = m[1];
|
|
1084
|
+
if (src.startsWith('http')) continue; // external image
|
|
1085
|
+
const path = src.startsWith('/') ? join(PUBLIC_DIR, src) : join(dirname(abs), src);
|
|
1086
|
+
if (!(await fileExists(path))) {
|
|
1087
|
+
fail(rel, lineOf(content, m.index), `Figure src "${src}" not found at ${path}`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// 4. Internal Markdown links resolve. Reuse the structural authored-target
|
|
1092
|
+
// traversal so code examples and comments cannot create false warnings.
|
|
1093
|
+
validateInternalMarkdownLinks(rel, content, authoredTargets);
|
|
1094
|
+
|
|
1095
|
+
// 5. CodeRef path + line bounds (only when BOOK_REPO_ROOT set)
|
|
1096
|
+
if (REPO_ROOT) {
|
|
1097
|
+
for (const m of content.matchAll(RE_CODEREF)) {
|
|
1098
|
+
const [, path, lineStart, lineEnd] = m;
|
|
1099
|
+
const abs2 = resolve(REPO_ROOT, path);
|
|
1100
|
+
if (!(await fileExists(abs2))) {
|
|
1101
|
+
fail(rel, lineOf(content, m.index), `CodeRef path "${path}" not found at ${abs2}`);
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
if (lineStart) {
|
|
1105
|
+
const fileLineCount = (await readFile(abs2, 'utf8')).split('\n').length;
|
|
1106
|
+
const lo = Number(lineStart);
|
|
1107
|
+
const hi = lineEnd ? Number(lineEnd) : lo;
|
|
1108
|
+
if (lo > fileLineCount || hi > fileLineCount) {
|
|
1109
|
+
fail(rel, lineOf(content, m.index), `CodeRef line ${lo}-${hi} exceeds file length (${fileLineCount}) in "${path}"`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// 6. Theorem requires a resolvable kind (#121) — kind= canonical, type=
|
|
1116
|
+
// legacy alias. Catches the silent-empty-label / build-throw case at the
|
|
1117
|
+
// earliest gate. (Value typos are caught at build by theoremLabel's throw.)
|
|
1118
|
+
// Plus (#126): an id'd <Theorem> without a label= override auto-numbers
|
|
1119
|
+
// from labels.json — an id absent from the index silently renders the
|
|
1120
|
+
// heading UNNUMBERED (no [?id] placeholder, unlike <XRef>). Fail loud to
|
|
1121
|
+
// restore symmetry with check #2. (A label= override opts out → number:null.)
|
|
1122
|
+
for (const m of content.matchAll(RE_THEOREM)) {
|
|
1123
|
+
const attrs = m[1];
|
|
1124
|
+
if (!/\b(?:kind|type)\s*=/.test(attrs)) {
|
|
1125
|
+
fail(
|
|
1126
|
+
rel,
|
|
1127
|
+
lineOf(content, m.index),
|
|
1128
|
+
`<Theorem> has no kind= (or legacy type=) — renders an empty label / throws at build. Add e.g. kind="theorem".`,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
const thmId = attrs.match(/\bid=["']([^"']+)["']/);
|
|
1132
|
+
const hasLabelOverride = /\blabel\s*=/.test(attrs);
|
|
1133
|
+
if (thmId && !hasLabelOverride && !labels[thmId[1]]) {
|
|
1134
|
+
fail(
|
|
1135
|
+
rel,
|
|
1136
|
+
lineOf(content, m.index),
|
|
1137
|
+
`<Theorem id="${thmId[1]}"> — not in labels.json; heading silently renders unnumbered. Run build:labels, or fix the id.`,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
// #176: compare only literal n= values. Dynamic expressions cannot be
|
|
1141
|
+
// evaluated reliably by this intentionally regex-based validator, and a
|
|
1142
|
+
// label= override explicitly opts out of labels.json auto-numbering.
|
|
1143
|
+
const explicitNumber = hasLabelOverride ? null : literalTheoremNumber(attrs);
|
|
1144
|
+
const indexedNumber = thmId ? labels[thmId[1]]?.number : null;
|
|
1145
|
+
if (
|
|
1146
|
+
thmId &&
|
|
1147
|
+
explicitNumber !== null &&
|
|
1148
|
+
indexedNumber != null &&
|
|
1149
|
+
explicitNumber !== String(indexedNumber)
|
|
1150
|
+
) {
|
|
1151
|
+
fail(
|
|
1152
|
+
rel,
|
|
1153
|
+
lineOf(content, m.index),
|
|
1154
|
+
`<Theorem id="${thmId[1]}" n="${explicitNumber}"> — labels.json numbers it ` +
|
|
1155
|
+
`${indexedNumber}, and the rendered heading + every XRef use the index. ` +
|
|
1156
|
+
'Drop the stale n= (auto-numbering wins) or re-run build:labels.',
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// 7. BookLink (#96/#147): structural props + evaluated registry membership,
|
|
1162
|
+
// then literal path/fragment validation against a declared vendored index.
|
|
1163
|
+
// Dynamic values and URL-only compatibility entries are explicit warnings
|
|
1164
|
+
// rather than guessed validations.
|
|
1165
|
+
for (const element of bookLinks) {
|
|
1166
|
+
const line = element.position?.start?.line ?? 1;
|
|
1167
|
+
const bookAttr = mdxStringProp(element, 'book');
|
|
1168
|
+
const toAttr = mdxStringProp(element, 'to');
|
|
1169
|
+
|
|
1170
|
+
if (bookAttr.source === 'spread' || toAttr.source === 'spread') {
|
|
1171
|
+
warn(
|
|
1172
|
+
rel,
|
|
1173
|
+
line,
|
|
1174
|
+
'<BookLink> validation skipped: a dynamic prop spread may supply or override book=/to=, so the target cannot be proven statically.',
|
|
1175
|
+
);
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
if (!bookAttr.present || !toAttr.present) {
|
|
1179
|
+
fail(rel, line, `<BookLink> requires both book="…" and to="…".`);
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (!bookAttr.literal) {
|
|
1184
|
+
warn(
|
|
1185
|
+
rel,
|
|
1186
|
+
line,
|
|
1187
|
+
'<BookLink> target validation skipped: dynamic book= expression cannot be evaluated statically.',
|
|
1188
|
+
);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const book = bookAttr.value;
|
|
1193
|
+
const corpusBook = BOOK_SELECTION.corpus?.books.find((candidate) => candidate.id === book);
|
|
1194
|
+
if (corpusBook) {
|
|
1195
|
+
if (!toAttr.literal) {
|
|
1196
|
+
warn(
|
|
1197
|
+
rel,
|
|
1198
|
+
line,
|
|
1199
|
+
`<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
|
|
1200
|
+
);
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const target = normalizeCorpusTarget(corpusBook, toAttr.value);
|
|
1205
|
+
if (target.error) {
|
|
1206
|
+
fail(
|
|
1207
|
+
rel,
|
|
1208
|
+
line,
|
|
1209
|
+
`<BookLink book="${book}" to="${toAttr.value}"> has invalid local corpus target: ` +
|
|
1210
|
+
`${target.error}.`,
|
|
1211
|
+
);
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (target.chapterSlug && !validSlugs.has(target.chapterSlug)) {
|
|
1216
|
+
fail(
|
|
1217
|
+
rel,
|
|
1218
|
+
line,
|
|
1219
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — unknown local corpus target ` +
|
|
1220
|
+
`"${target.canonicalPath}".`,
|
|
1221
|
+
);
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Exact apparatus roots are known statically and may be checked against
|
|
1226
|
+
// the target book's route set. Nested/custom non-chapter paths remain a
|
|
1227
|
+
// deliberate runtime escape hatch (for example about/team or a custom
|
|
1228
|
+
// glossary detail route), so validate must not reject them merely
|
|
1229
|
+
// because they are absent from the scaffold apparatus registry.
|
|
1230
|
+
if (
|
|
1231
|
+
!target.chapterSlug &&
|
|
1232
|
+
KNOWN_CORPUS_APPARATUS_ROUTES.has(target.localPath) &&
|
|
1233
|
+
!(corpusBook.apparatus ?? TOOLING_CONFIG.apparatusRoutes).includes(target.localPath)
|
|
1234
|
+
) {
|
|
1235
|
+
fail(
|
|
1236
|
+
rel,
|
|
1237
|
+
line,
|
|
1238
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — local corpus apparatus route ` +
|
|
1239
|
+
`"${target.localPath}" is not enabled for book "${book}".`,
|
|
1240
|
+
);
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (target.fragment !== null && target.chapterSlug) {
|
|
1245
|
+
if (target.fragment.length === 0) {
|
|
1246
|
+
fail(
|
|
1247
|
+
rel,
|
|
1248
|
+
line,
|
|
1249
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — local fragment is empty.`,
|
|
1250
|
+
);
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
const localIndex = labelsArtifact.books[book];
|
|
1254
|
+
const candidates = siblingTargetCandidates(localIndex, target.fragment);
|
|
1255
|
+
if (!candidates.some((candidate) => candidate.normalized.href === target.href)) {
|
|
1256
|
+
const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
|
|
1257
|
+
fail(
|
|
1258
|
+
rel,
|
|
1259
|
+
line,
|
|
1260
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — local fragment ` +
|
|
1261
|
+
`"${target.fragment}" does not resolve in labels.json book namespace "${book}"` +
|
|
1262
|
+
(indexedHrefs ? ` (indexed at ${indexedHrefs})` : '') +
|
|
1263
|
+
'.',
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
} else if (target.fragment !== null) {
|
|
1267
|
+
// labels.json indexes chapter prose/components only. Runtime accepts
|
|
1268
|
+
// fragments on arbitrary book-local routes, so a non-chapter fragment
|
|
1269
|
+
// cannot be proven (or disproven) from this artifact.
|
|
1270
|
+
warn(
|
|
1271
|
+
rel,
|
|
1272
|
+
line,
|
|
1273
|
+
`<BookLink book="${book}" to="${toAttr.value}"> non-chapter fragment ` +
|
|
1274
|
+
'validation skipped; the target route owns its anchors.',
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const registered = Object.prototype.hasOwnProperty.call(TOOLING_CONFIG.siblingBooks, book);
|
|
1281
|
+
if (!registered) {
|
|
1282
|
+
const known = [
|
|
1283
|
+
...(BOOK_SELECTION.corpus?.books.map((candidate) => candidate.id) ?? []),
|
|
1284
|
+
...Object.keys(TOOLING_CONFIG.siblingBooks),
|
|
1285
|
+
];
|
|
1286
|
+
fail(
|
|
1287
|
+
rel,
|
|
1288
|
+
line,
|
|
1289
|
+
BOOK_SELECTION.corpus
|
|
1290
|
+
? `<BookLink book="${book}"> — not a registered local corpus or sibling book ` +
|
|
1291
|
+
`(${known.join(', ') || 'none'}). Register it or fix the key.`
|
|
1292
|
+
: `<BookLink book="${book}"> — not in evaluated defineBookConfig siblingBooks ` +
|
|
1293
|
+
`(${Object.keys(TOOLING_CONFIG.siblingBooks).join(', ') || 'none'}). ` +
|
|
1294
|
+
'Register it or fix the key.',
|
|
1295
|
+
);
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
const siblingEntry = TOOLING_CONFIG.siblingBooks[book];
|
|
1299
|
+
|
|
1300
|
+
if (!toAttr.literal) {
|
|
1301
|
+
warn(
|
|
1302
|
+
rel,
|
|
1303
|
+
line,
|
|
1304
|
+
`<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
|
|
1305
|
+
);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const target = normalizeSiblingTarget(toAttr.value);
|
|
1310
|
+
if (!target) {
|
|
1311
|
+
warn(
|
|
1312
|
+
rel,
|
|
1313
|
+
line,
|
|
1314
|
+
`<BookLink book="${book}" to="${toAttr.value}"> has no static #fragment; sibling labels validation skipped.`,
|
|
1315
|
+
);
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
if (typeof siblingEntry === 'string' || siblingEntry.labels === undefined) {
|
|
1320
|
+
warn(
|
|
1321
|
+
rel,
|
|
1322
|
+
line,
|
|
1323
|
+
`<BookLink book="${book}" to="${toAttr.value}"> target validation skipped: ` +
|
|
1324
|
+
'the siblingBooks entry is URL-only. Use { url, labels } to enable vendored-label validation.',
|
|
1325
|
+
);
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const loaded = siblingLabelIndexes.get(book);
|
|
1330
|
+
if (!loaded) continue; // A config-level error was already recorded above.
|
|
1331
|
+
|
|
1332
|
+
const candidates = siblingTargetCandidates(loaded.index, target.fragment);
|
|
1333
|
+
if (candidates.some((candidate) => candidate.normalized.href === target.href)) {
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (candidates.length === 0) {
|
|
1338
|
+
// Preserve the actionable diagnostic for a malformed historical entry
|
|
1339
|
+
// whose key is the literal fragment. Opaque heading keys are found by
|
|
1340
|
+
// valid href values and never need to be decoded here.
|
|
1341
|
+
const legacyEntry = loaded.index[target.fragment];
|
|
1342
|
+
if (
|
|
1343
|
+
Object.prototype.hasOwnProperty.call(loaded.index, target.fragment) &&
|
|
1344
|
+
(legacyEntry === null ||
|
|
1345
|
+
typeof legacyEntry !== 'object' ||
|
|
1346
|
+
typeof legacyEntry.href !== 'string')
|
|
1347
|
+
) {
|
|
1348
|
+
fail(
|
|
1349
|
+
rel,
|
|
1350
|
+
line,
|
|
1351
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — ${loaded.configuredPath} entry ` +
|
|
1352
|
+
`"${target.fragment}" has no string href; re-vendor a valid sibling labels.json.`,
|
|
1353
|
+
);
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
fail(
|
|
1357
|
+
rel,
|
|
1358
|
+
line,
|
|
1359
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — fragment "${target.fragment}" is not in ` +
|
|
1360
|
+
`${loaded.configuredPath}. Vendor the current sibling labels.json, or fix the target id.`,
|
|
1361
|
+
);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
|
|
1366
|
+
fail(
|
|
1367
|
+
rel,
|
|
1368
|
+
line,
|
|
1369
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — path/fragment does not match ` +
|
|
1370
|
+
`${loaded.configuredPath}, which indexes "${target.fragment}" at ${indexedHrefs}. ` +
|
|
1371
|
+
'Fix to= or refresh the vendored index.',
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// <Rationale appendix> in CHAPTER bodies (v4.21.0, #114): same missing-for=
|
|
1376
|
+
// pre-flight as the questions scan below (the for===id rule is
|
|
1377
|
+
// question-file-scoped and doesn't apply here). The component still throws
|
|
1378
|
+
// at build either way; this just catches it at the earliest gate.
|
|
1379
|
+
for (const m of content.matchAll(/<Rationale\b([^>]*)>/g)) {
|
|
1380
|
+
const attrs = m[1];
|
|
1381
|
+
if (/(^|\s)appendix(\s|=|$)/.test(attrs) && !/\bfor\s*=/.test(attrs)) {
|
|
1382
|
+
fail(
|
|
1383
|
+
rel,
|
|
1384
|
+
lineOf(content, m.index),
|
|
1385
|
+
`<Rationale appendix> without for="<question-id>" — no appendix anchor target; throws at build.`,
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// 9. Learning-objective anchor binding (#130). Convention (consumer-defined
|
|
1391
|
+
// `los` frontmatter, guides-ai-engineering): each `los[].anchor` slug has
|
|
1392
|
+
// a matching MDX comment marker in the prose binding the objective to its
|
|
1393
|
+
// section. Both drift directions built + validated green before this
|
|
1394
|
+
// check, so both fail loud: a declared anchor with no marker (dangling
|
|
1395
|
+
// objective) and a marker with no declaration (orphan). Scoped to
|
|
1396
|
+
// chapters that opt into the convention (a `los:` frontmatter key) —
|
|
1397
|
+
// `los` is not a scaffold schema field, so this can't false-fire on
|
|
1398
|
+
// books that don't use it. Heuristic, like #8: any indented `anchor:`
|
|
1399
|
+
// line inside the frontmatter counts as a declaration.
|
|
1400
|
+
{
|
|
1401
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1402
|
+
const front = fmMatch ? fmMatch[1] : '';
|
|
1403
|
+
if (/^los\s*:/m.test(front)) {
|
|
1404
|
+
const frontOffset = content.indexOf(front);
|
|
1405
|
+
const bodyOffset = fmMatch ? fmMatch[0].length : 0;
|
|
1406
|
+
const body = content.slice(bodyOffset);
|
|
1407
|
+
// Matches both YAML styles: block items (`- anchor: slug` / `anchor: slug`
|
|
1408
|
+
// on its own indented line) and flow/inline maps (`- { text: …, anchor:
|
|
1409
|
+
// slug }`), where the key follows a `{` or `,`. The prefix alternation —
|
|
1410
|
+
// never a bare `.*` — keeps `my-anchor:` from matching as "anchor:".
|
|
1411
|
+
const declared = [
|
|
1412
|
+
...front.matchAll(
|
|
1413
|
+
/^\s+(?:-\s+|.*[{,]\s*)?anchor\s*:\s*["']?([^"',}\n]+?)["']?\s*(?:[,}].*)?$/gm,
|
|
1414
|
+
),
|
|
1415
|
+
];
|
|
1416
|
+
const markers = [...body.matchAll(/\{\s*\/\*\s*anchor:\s*([^\s*]+)\s*\*\/\s*\}/g)];
|
|
1417
|
+
const markerSlugs = new Set(markers.map((m) => m[1]));
|
|
1418
|
+
const declaredSlugs = new Set(declared.map((m) => m[1].trim()));
|
|
1419
|
+
for (const d of declared) {
|
|
1420
|
+
const slug = d[1].trim();
|
|
1421
|
+
if (!markerSlugs.has(slug)) {
|
|
1422
|
+
fail(
|
|
1423
|
+
rel,
|
|
1424
|
+
lineOf(content, frontOffset + d.index),
|
|
1425
|
+
`los anchor "${slug}" has no matching {/* anchor: ${slug} */} marker in the prose — dangling learning objective.`,
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
for (const m of markers) {
|
|
1430
|
+
if (!declaredSlugs.has(m[1])) {
|
|
1431
|
+
fail(
|
|
1432
|
+
rel,
|
|
1433
|
+
lineOf(content, bodyOffset + m.index),
|
|
1434
|
+
`prose anchor marker "${m[1]}" has no matching los[].anchor in the frontmatter — orphan anchor.`,
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
|
|
1442
|
+
|
|
1443
|
+
// ===== 8. Questions collection (#112): domain membership + unique ids =====
|
|
1444
|
+
//
|
|
1445
|
+
// The study-guide `questions` collection (src/content/questions/**) is scanned
|
|
1446
|
+
// separately from chapters: a question's frontmatter `domain` must be in the
|
|
1447
|
+
// consumer's examDomains registry — an unregistered domain throws at build via
|
|
1448
|
+
// assertKnownDomain (route layer); we flag it here, earlier, the same way #7
|
|
1449
|
+
// pre-flights BookLink. Question `id`s must also be unique (they're the stable
|
|
1450
|
+
// cross-ref key the appendix/flashcards resolve against). Best-effort: when
|
|
1451
|
+
// examDomains can't be read from astro.config.mjs, membership is left to the
|
|
1452
|
+
// build-time throw.
|
|
1453
|
+
let questionsChecked = 0;
|
|
1454
|
+
const questionsCheckedByBook = context.questionsCheckedByBook;
|
|
1455
|
+
|
|
1456
|
+
if (BOOK_SELECTION.corpus) {
|
|
1457
|
+
const manifestIds = new Set(BOOK_SELECTION.corpus.books.map((book) => book.id));
|
|
1458
|
+
for (const [collection, label] of [
|
|
1459
|
+
['questions', 'Question'],
|
|
1460
|
+
['glossary', 'Glossary term'],
|
|
1461
|
+
]) {
|
|
1462
|
+
const collectionDir = resolve(ROOT, 'src/content', collection);
|
|
1463
|
+
if (!existsSync(collectionDir)) continue;
|
|
1464
|
+
// Collection ownership is path-derived. Scan the complete non-hidden root
|
|
1465
|
+
// even for `--book` so direct files and unknown first segments cannot
|
|
1466
|
+
// silently disappear from every per-book run.
|
|
1467
|
+
for await (const file of walkMdx(collectionDir)) {
|
|
1468
|
+
const owner = file.split('/')[0] ?? '';
|
|
1469
|
+
if (manifestIds.has(owner)) continue;
|
|
1470
|
+
fail(
|
|
1471
|
+
`${collection}/${file}`,
|
|
1472
|
+
1,
|
|
1473
|
+
`${label} content must be nested under a registered corpus book id ` +
|
|
1474
|
+
`(${[...manifestIds].join(' | ')}); found first segment ${JSON.stringify(owner)}.`,
|
|
1475
|
+
'corpus',
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
{
|
|
1482
|
+
const QUESTIONS_DIR = resolve(ROOT, 'src/content/questions');
|
|
1483
|
+
if (existsSync(QUESTIONS_DIR)) {
|
|
1484
|
+
// examDomains registry from astro.config.mjs (best-effort, mirrors siblingBooks).
|
|
1485
|
+
let examDomains = null;
|
|
1486
|
+
const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
|
|
1487
|
+
if (existsSync(astroConfigPath)) {
|
|
1488
|
+
const block = readFileSync(astroConfigPath, 'utf8').match(/examDomains\s*:\s*\[([^\]]*)\]/);
|
|
1489
|
+
if (block) {
|
|
1490
|
+
examDomains = new Set([...block[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]));
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const runs = BOOK_SELECTION.corpus
|
|
1495
|
+
? BOOK_SELECTION.books.map((book) => ({
|
|
1496
|
+
book,
|
|
1497
|
+
dir: join(QUESTIONS_DIR, book.id),
|
|
1498
|
+
prefix: `questions/${book.id}`,
|
|
1499
|
+
}))
|
|
1500
|
+
: [{ book: null, dir: QUESTIONS_DIR, prefix: 'questions' }];
|
|
1501
|
+
|
|
1502
|
+
for (const run of runs) {
|
|
1503
|
+
ACTIVE_BOOK_ID = run.book?.id ?? null;
|
|
1504
|
+
const seenIds = new Map(); // ids are book-local in corpus mode
|
|
1505
|
+
const questionFiles = [];
|
|
1506
|
+
for await (const file of walkMdx(run.dir)) {
|
|
1507
|
+
if (!file.split('/').pop().startsWith('_')) questionFiles.push(file);
|
|
1508
|
+
}
|
|
1509
|
+
for (const rel of questionFiles) {
|
|
1510
|
+
const abs = join(run.dir, rel);
|
|
1511
|
+
const content = await readFile(abs, 'utf8');
|
|
1512
|
+
const qrel = `${run.prefix}/${rel}`;
|
|
1513
|
+
let parsedFrontmatter;
|
|
1514
|
+
try {
|
|
1515
|
+
parsedFrontmatter = parseFrontmatter(
|
|
1516
|
+
content,
|
|
1517
|
+
run.book ? `[book:${run.book.id}] ${qrel}` : qrel,
|
|
1518
|
+
);
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
recordFrontmatterFailure(error, qrel, run.book?.id ?? null);
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
const frontmatter = parsedFrontmatter.frontmatter;
|
|
1524
|
+
if (run.book) {
|
|
1525
|
+
if (
|
|
1526
|
+
Object.prototype.hasOwnProperty.call(frontmatter, 'book') &&
|
|
1527
|
+
frontmatter.book !== run.book.id
|
|
1528
|
+
) {
|
|
1529
|
+
fail(
|
|
1530
|
+
qrel,
|
|
1531
|
+
parsedFrontmatter.lines.book ?? 2,
|
|
1532
|
+
`frontmatter book ${JSON.stringify(frontmatter.book)} does not match ` +
|
|
1533
|
+
`path-derived corpus book ${JSON.stringify(run.book.id)}.`,
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Questions do not run the legacy link-resolution advisory, so keep the
|
|
1539
|
+
// #190 root-base contract fully inert by parsing only for a non-root base.
|
|
1540
|
+
const authoredTargets = ASTRO_BASE === '/' ? [] : collectAuthoredTargets(qrel, content);
|
|
1541
|
+
validateAuthoredTargets(qrel, content, authoredTargets);
|
|
1542
|
+
|
|
1543
|
+
const normalizedQuestionId = typeof frontmatter.id === 'string'
|
|
1544
|
+
? frontmatter.id.trim()
|
|
1545
|
+
: '';
|
|
1546
|
+
const questionId = normalizedQuestionId.length > 0 ? normalizedQuestionId : null;
|
|
1547
|
+
if (questionId) {
|
|
1548
|
+
const id = questionId;
|
|
1549
|
+
if (seenIds.has(id)) {
|
|
1550
|
+
fail(
|
|
1551
|
+
qrel,
|
|
1552
|
+
parsedFrontmatter.lines.id ?? 1,
|
|
1553
|
+
`Duplicate question id "${id}" — also declared in ${seenIds.get(id)}. Question ids must be unique (cross-ref key for the appendix / flashcards).`,
|
|
1554
|
+
);
|
|
1555
|
+
} else {
|
|
1556
|
+
seenIds.set(id, qrel);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
if (examDomains) {
|
|
1561
|
+
const normalizedDomain = typeof frontmatter.domain === 'string'
|
|
1562
|
+
? frontmatter.domain.trim()
|
|
1563
|
+
: '';
|
|
1564
|
+
const domain = normalizedDomain.length > 0 ? normalizedDomain : null;
|
|
1565
|
+
if (domain && !examDomains.has(domain)) {
|
|
1566
|
+
fail(
|
|
1567
|
+
qrel,
|
|
1568
|
+
parsedFrontmatter.lines.domain ?? 1,
|
|
1569
|
+
`Question domain "${domain}" not in defineBookConfig examDomains (${[...examDomains].join(', ') || 'none'}). Register it or fix the value.`,
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// v4.21.0 (#114): <Rationale appendix> needs for= (the /answers#answer-<id>
|
|
1575
|
+
// anchor target) — the component throws at build; flag it here, earlier.
|
|
1576
|
+
for (const m of content.matchAll(/<Rationale\b([^>]*)>/g)) {
|
|
1577
|
+
const attrs = m[1];
|
|
1578
|
+
if (!/(^|\s)appendix(\s|=|$)/.test(attrs)) continue;
|
|
1579
|
+
const forMatch = attrs.match(/\bfor\s*=\s*["']([^"']+)["']/);
|
|
1580
|
+
if (!forMatch) {
|
|
1581
|
+
fail(
|
|
1582
|
+
qrel,
|
|
1583
|
+
lineOf(content, m.index),
|
|
1584
|
+
`<Rationale appendix> without for="<question-id>" — no appendix anchor target; throws at build.`,
|
|
1585
|
+
);
|
|
1586
|
+
} else if (questionId && forMatch[1] !== questionId) {
|
|
1587
|
+
fail(
|
|
1588
|
+
qrel,
|
|
1589
|
+
lineOf(content, m.index),
|
|
1590
|
+
`<Rationale appendix for="${forMatch[1]}"> does not match this question's id "${questionId}" — the /answers anchor would land on the wrong (or no) answer.`,
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
questionsChecked += questionFiles.length;
|
|
1596
|
+
if (run.book) questionsCheckedByBook.set(run.book.id, questionFiles.length);
|
|
1597
|
+
}
|
|
1598
|
+
ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// ===== Report =====
|
|
1603
|
+
context.questionsChecked = questionsChecked;
|
|
1604
|
+
const format = ({ file, line, msg, book }) =>
|
|
1605
|
+
`${book ? `[book:${book}] ` : ' '}${file}:${line} ${msg}`;
|
|
1606
|
+
if (warnings.length > 0) {
|
|
1607
|
+
const prefix = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
|
|
1608
|
+
writeStderr(`${prefix}validate: ${warnings.length} warning(s):\n`);
|
|
1609
|
+
warnings.forEach((warning) => writeStderr(`${format(warning)}\n`));
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
if (BOOK_SELECTION.corpus) {
|
|
1613
|
+
for (const book of BOOK_SELECTION.books) {
|
|
1614
|
+
const count = chapterCountsByBook.get(book.id) ?? 0;
|
|
1615
|
+
const questionCount = questionsCheckedByBook.get(book.id) ?? 0;
|
|
1616
|
+
const questionNote = questionCount > 0 ? ` + ${questionCount} question(s)` : '';
|
|
1617
|
+
const bookErrors = errors.filter((error) => error.book === book.id).length;
|
|
1618
|
+
const bookWarnings = warnings.filter((warning) => warning.book === book.id).length;
|
|
1619
|
+
const result = bookErrors === 0 ? '✓' : '✗';
|
|
1620
|
+
const detail = bookErrors === 0
|
|
1621
|
+
? 'no errors'
|
|
1622
|
+
: `${bookErrors} error${bookErrors === 1 ? '' : 's'}`;
|
|
1623
|
+
writeStdout(
|
|
1624
|
+
`[book:${book.id}] validate: ${result} ${count} chapter(s)${questionNote} checked; ${detail}, ` +
|
|
1625
|
+
`${bookWarnings} warning${bookWarnings === 1 ? '' : 's'} ` +
|
|
1626
|
+
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}).\n`,
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
|
|
1631
|
+
const aggregateResult = errors.length === 0 ? '✓' : '✗';
|
|
1632
|
+
const aggregateDetail = errors.length === 0
|
|
1633
|
+
? 'no errors'
|
|
1634
|
+
: `${errors.length} error${errors.length === 1 ? '' : 's'}`;
|
|
1635
|
+
const aggregateMessage =
|
|
1636
|
+
`[book:corpus] validate: ${aggregateResult} ${chapterFiles.length} chapter(s)${qNote} ` +
|
|
1637
|
+
`across ${BOOK_SELECTION.books.length} book${BOOK_SELECTION.books.length === 1 ? '' : 's'}; ` +
|
|
1638
|
+
`${aggregateDetail}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'} ` +
|
|
1639
|
+
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle})`;
|
|
1640
|
+
if (errors.length === 0) {
|
|
1641
|
+
writeStdout(`${aggregateMessage}.\n`);
|
|
1642
|
+
return finish(0);
|
|
1643
|
+
}
|
|
1644
|
+
writeStderr(`${aggregateMessage}:\n`);
|
|
1645
|
+
errors.forEach((error) => writeStderr(`${format(error)}\n`));
|
|
1646
|
+
return finish(Math.min(errors.length, 255));
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
if (errors.length === 0) {
|
|
1650
|
+
const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
|
|
1651
|
+
writeStdout(
|
|
1652
|
+
`validate: ✓ ${chapterFiles.length} chapter(s)${qNote} checked ` +
|
|
1653
|
+
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}); no errors.\n`,
|
|
1654
|
+
);
|
|
1655
|
+
return finish(0);
|
|
1656
|
+
}
|
|
1657
|
+
writeStderr(
|
|
1658
|
+
`validate: ✗ ${errors.length} error(s) in ${chapterFiles.length} chapter(s) ` +
|
|
1659
|
+
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):\n`,
|
|
1660
|
+
);
|
|
1661
|
+
errors.forEach((error) => writeStderr(`${format(error)}\n`));
|
|
1662
|
+
return finish(Math.min(errors.length, 255));
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
if (error instanceof ValidationFatal) {
|
|
1665
|
+
return finish(error.exitCode, {
|
|
1666
|
+
kind: error.kind,
|
|
1667
|
+
message: error.message,
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
const message = String(error?.message ?? error);
|
|
1671
|
+
const prefix = context.selection?.corpus ? '[book:corpus] ' : '';
|
|
1672
|
+
writeStderr(`${prefix}validate: fatal: ${message}\n`);
|
|
1673
|
+
return finish(1, { kind: 'internal', message });
|
|
1674
|
+
}
|
|
1675
|
+
}
|