@brandon_m_behring/book-scaffold-astro 4.28.0 → 4.30.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 +4 -1
- package/components/BookLink.astro +3 -2
- package/dist/index.d.ts +8 -70
- package/dist/index.mjs +21 -4
- package/dist/lib/nav-href.d.ts +66 -0
- package/dist/lib/nav-href.mjs +44 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/{types-CZrkqzpC.d.ts → types-DgSlAew3.d.ts} +32 -8
- package/package.json +7 -4
- package/recipes/09-validation.md +51 -4
- package/recipes/22-responsive-nav-and-multibook-routing.md +5 -0
- package/scripts/authored-links.mjs +328 -0
- package/scripts/build-labels.mjs +88 -34
- package/scripts/resolve-book-config.mjs +90 -2
- package/scripts/validate.mjs +328 -44
- package/scripts/walk-mdx.mjs +122 -8
- package/src/lib/book-link.ts +25 -5
package/scripts/validate.mjs
CHANGED
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
* 6. <Theorem> — has a resolvable kind= (or legacy type=); else it would
|
|
17
17
|
* render an empty label and throw at build (#121). An id'd theorem must
|
|
18
18
|
* resolve in labels.json, and a literal n= must agree with that index.
|
|
19
|
-
* 7. <BookLink book="…" to="…"> (#96) — both props present,
|
|
20
|
-
*
|
|
19
|
+
* 7. <BookLink book="…" to="…"> (#96/#147) — both props present, book= is
|
|
20
|
+
* registered, and literal fragment targets resolve in the sibling's
|
|
21
|
+
* declared vendored labels index. Dynamic and URL-only entries warn/skip.
|
|
21
22
|
* 8. Questions collection (#112) — each question's frontmatter `domain` is a
|
|
22
23
|
* member of the consumer's examDomains registry (best-effort), and question
|
|
23
24
|
* `id`s are unique (the cross-ref key for the appendix / flashcards).
|
|
24
25
|
* 9. Learning-objective anchors (#130) — when a chapter declares frontmatter
|
|
25
26
|
* `los:` entries with `anchor:` slugs, the declared set and the prose's
|
|
26
27
|
* MDX anchor-comment marker set must agree in both directions.
|
|
28
|
+
* 10. Authored root-absolute href/src targets (#190) — under a non-root Astro
|
|
29
|
+
* base, literal Markdown, HTML, and JSX targets must stay inside that base.
|
|
27
30
|
*
|
|
28
31
|
* Run from the consumer's project root. Closes #8 (was resolving paths
|
|
29
32
|
* from the package's own directory inside node_modules — false negatives
|
|
@@ -34,22 +37,33 @@
|
|
|
34
37
|
* book-scaffold validate --preset academic
|
|
35
38
|
* BOOK_REPO_ROOT=/abs/path npx book-scaffold validate
|
|
36
39
|
*
|
|
37
|
-
* Exit code = total failure count (0 = pass, >=1 = errors).
|
|
40
|
+
* Exit code = total failure count capped at 255 (0 = pass, >=1 = errors).
|
|
38
41
|
*/
|
|
39
42
|
import { readFile, access } from 'node:fs/promises';
|
|
40
43
|
import { existsSync, readFileSync } from 'node:fs';
|
|
41
|
-
import { resolve, dirname, join } from 'node:path';
|
|
44
|
+
import { resolve, dirname, extname, join } from 'node:path';
|
|
42
45
|
import { fileURLToPath } from 'node:url';
|
|
43
46
|
import { spawnSync } from 'node:child_process';
|
|
47
|
+
import { unified } from 'unified';
|
|
48
|
+
import remarkParse from 'remark-parse';
|
|
49
|
+
import remarkMath from 'remark-math';
|
|
50
|
+
import remarkMdx from 'remark-mdx';
|
|
44
51
|
import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
|
|
45
52
|
import { readEnvFile } from './read-env.mjs';
|
|
46
53
|
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
54
|
+
import {
|
|
55
|
+
findAuthoredTargets,
|
|
56
|
+
normalizeAstroBase,
|
|
57
|
+
rootTargetEscapesBase,
|
|
58
|
+
rootTargetPathname,
|
|
59
|
+
suggestBaseContainedTarget,
|
|
60
|
+
} from './authored-links.mjs';
|
|
47
61
|
|
|
48
62
|
// --help / -h: non-mutating (closes #14).
|
|
49
63
|
const USAGE = `Usage: book-scaffold validate [--preset <name>]
|
|
50
64
|
|
|
51
65
|
Pre-flight content validator. Checks Cite keys, XRef ids, Figure srcs,
|
|
52
|
-
internal
|
|
66
|
+
internal authored links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
|
|
53
67
|
|
|
54
68
|
Options:
|
|
55
69
|
--preset <name> academic | tools | minimal | course-notes | research-portfolio
|
|
@@ -61,7 +75,7 @@ Env:
|
|
|
61
75
|
BOOK_PROFILE Backward-compat alias for BOOK_PRESET.
|
|
62
76
|
BOOK_REPO_ROOT Absolute path to a sibling code repo for CodeRef checks.
|
|
63
77
|
|
|
64
|
-
Exit code = total failure count.
|
|
78
|
+
Exit code = total failure count, capped at 255 so failures never wrap to success.
|
|
65
79
|
`;
|
|
66
80
|
|
|
67
81
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
@@ -139,6 +153,7 @@ if (!PRESET_CANDIDATE) {
|
|
|
139
153
|
}
|
|
140
154
|
// Alias kept for downstream message text only; the resolution above is canonical.
|
|
141
155
|
const PROFILE = PRESET;
|
|
156
|
+
const MATH_ENABLED = PROFILE === 'academic' || PROFILE === 'research-portfolio';
|
|
142
157
|
const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
|
|
143
158
|
|
|
144
159
|
// v4.6.0 (issue #76 Layer 3b): chapter-route shadow warning. Detect a
|
|
@@ -256,6 +271,32 @@ async function loadJson(path) {
|
|
|
256
271
|
const refs = await loadJson(join(DATA_DIR, 'references.json'));
|
|
257
272
|
const labels = await loadJson(join(DATA_DIR, 'labels.json'));
|
|
258
273
|
|
|
274
|
+
// #147: eagerly load every labels index explicitly declared by the evaluated
|
|
275
|
+
// siblingBooks registry. Unlike this book's generated labels.json, sibling
|
|
276
|
+
// indexes are vendored inputs: never self-heal or silently replace them. A
|
|
277
|
+
// missing, unreadable, malformed, or non-object index is a configuration error
|
|
278
|
+
// even when no current chapter happens to reference that sibling.
|
|
279
|
+
const siblingLabelIndexes = new Map();
|
|
280
|
+
for (const [book, entry] of Object.entries(TOOLING_CONFIG.siblingBooks)) {
|
|
281
|
+
if (typeof entry === 'string' || entry.labels === undefined) continue;
|
|
282
|
+
const indexPath = resolve(ROOT, entry.labels);
|
|
283
|
+
try {
|
|
284
|
+
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
|
285
|
+
if (index === null || typeof index !== 'object' || Array.isArray(index)) {
|
|
286
|
+
throw new Error('top-level JSON value must be an object');
|
|
287
|
+
}
|
|
288
|
+
siblingLabelIndexes.set(book, { index, configuredPath: entry.labels });
|
|
289
|
+
} catch (error) {
|
|
290
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
291
|
+
fail(
|
|
292
|
+
'astro.config',
|
|
293
|
+
1,
|
|
294
|
+
`siblingBooks.${book}.labels (${JSON.stringify(entry.labels)}) is missing, unreadable, or invalid: ${detail}. ` +
|
|
295
|
+
'Vendor a readable sibling labels.json at that path or remove labels to opt out with a warning.',
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
259
300
|
// ===== Collect chapter files =====
|
|
260
301
|
// v3.7.1 (closes #52): walkMdx (in ./walk-mdx.mjs) is a recursive readdir
|
|
261
302
|
// walker that replaces the previous `glob` import from `node:fs/promises`.
|
|
@@ -273,18 +314,19 @@ const validTopLevelRoutes = new Set([
|
|
|
273
314
|
'/', '/chapters/', '/references/', '/search/', '/print/', '/convergence/',
|
|
274
315
|
]);
|
|
275
316
|
|
|
276
|
-
// ===== Pattern helpers
|
|
317
|
+
// ===== Pattern helpers for component-specific checks =====
|
|
277
318
|
const RE_CITE = /<Cite[^>]+key=["']([^"']+)["']/g;
|
|
278
319
|
const RE_XREF = /<XRef[^>]+id=["']([^"']+)["']/g;
|
|
279
320
|
const RE_FIGURE = /<Figure[^>]+src=["']([^"']+)["']/g;
|
|
280
321
|
const RE_CODEREF = /<CodeRef[^>]+path=["']([^"']+)["'](?:[^>]*line=\{(\d+)\})?(?:[^>]*lineEnd=\{(\d+)\})?/g;
|
|
281
|
-
const RE_MD_LINK = /\[(?:[^\]]*)\]\((\/[^)\s#]+)(?:#[^)]*)?\)/g;
|
|
282
322
|
// #121: a <Theorem> opening tag — capture its attributes to assert a
|
|
283
323
|
// resolvable kind= (or legacy type=) is present.
|
|
284
324
|
const RE_THEOREM = /<Theorem\b([^>]*)>/g;
|
|
285
|
-
// #96:
|
|
286
|
-
//
|
|
287
|
-
|
|
325
|
+
// #96/#147: BookLink attributes must be read structurally. An opening-tag
|
|
326
|
+
// regex stops at `>` inside a quoted value or expression and can mistake text
|
|
327
|
+
// inside another prop for a real book=/to= assignment.
|
|
328
|
+
const mdxParser = unified().use(remarkParse).use(remarkMdx);
|
|
329
|
+
if (MATH_ENABLED) mdxParser.use(remarkMath);
|
|
288
330
|
|
|
289
331
|
async function fileExists(p) {
|
|
290
332
|
try {
|
|
@@ -299,6 +341,62 @@ function lineOf(content, idx) {
|
|
|
299
341
|
return content.slice(0, idx).split('\n').length;
|
|
300
342
|
}
|
|
301
343
|
|
|
344
|
+
const ASTRO_BASE = normalizeAstroBase(TOOLING_CONFIG.base);
|
|
345
|
+
|
|
346
|
+
function authoredFormat(file) {
|
|
347
|
+
return extname(file).toLowerCase() === '.md' ? 'md' : 'mdx';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function collectAuthoredTargets(file, content) {
|
|
351
|
+
try {
|
|
352
|
+
return findAuthoredTargets(content, {
|
|
353
|
+
format: authoredFormat(file),
|
|
354
|
+
math: MATH_ENABLED,
|
|
355
|
+
});
|
|
356
|
+
} catch (error) {
|
|
357
|
+
const line = error?.line ?? error?.place?.start?.line ?? error?.position?.start?.line ?? 1;
|
|
358
|
+
const detail = String(error?.reason ?? error?.message ?? error).split('\n')[0];
|
|
359
|
+
fail(file, line, `Could not parse authored links as ${authoredFormat(file).toUpperCase()}: ${detail}`);
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateAuthoredTargets(file, content, targets) {
|
|
365
|
+
for (const violation of targets) {
|
|
366
|
+
if (!rootTargetEscapesBase(violation.target, ASTRO_BASE)) continue;
|
|
367
|
+
const suggested = suggestBaseContainedTarget(violation.target, ASTRO_BASE);
|
|
368
|
+
fail(
|
|
369
|
+
file,
|
|
370
|
+
lineOf(content, violation.index),
|
|
371
|
+
`Authored ${violation.kind} ${JSON.stringify(violation.target)} escapes configured Astro base ` +
|
|
372
|
+
`${JSON.stringify(ASTRO_BASE)}. Use ${JSON.stringify(suggested)}, ` +
|
|
373
|
+
'import.meta.env.BASE_URL in JSX, or a base-aware component. ' +
|
|
374
|
+
'Validation does not rewrite authored URLs (#190).',
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function validateInternalMarkdownLinks(file, content, targets) {
|
|
380
|
+
for (const authored of targets) {
|
|
381
|
+
if (authored.kind !== 'Markdown link destination') continue;
|
|
382
|
+
const pathname = rootTargetPathname(authored.target);
|
|
383
|
+
if (pathname === null) continue;
|
|
384
|
+
const baseRelativeTarget =
|
|
385
|
+
ASTRO_BASE !== '/' && (pathname === ASTRO_BASE || pathname.startsWith(`${ASTRO_BASE}/`))
|
|
386
|
+
? pathname.slice(ASTRO_BASE.length) || '/'
|
|
387
|
+
: pathname;
|
|
388
|
+
const target = baseRelativeTarget.replace(/\/$/, '') || '/';
|
|
389
|
+
if (validTopLevelRoutes.has(`${target}/`) || validTopLevelRoutes.has(target)) continue;
|
|
390
|
+
const chMatch = target.match(/^\/chapters\/(.+)$/);
|
|
391
|
+
if (chMatch && validSlugs.has(chMatch[1])) continue;
|
|
392
|
+
warn(
|
|
393
|
+
file,
|
|
394
|
+
lineOf(content, authored.index),
|
|
395
|
+
`Internal link ${JSON.stringify(authored.target)} — target may not resolve (check spelling or route)`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
302
400
|
/**
|
|
303
401
|
* Return a statically knowable n= value. Quoted strings and braced
|
|
304
402
|
* string/numeric literals are safe to compare; identifiers, calls,
|
|
@@ -322,29 +420,114 @@ function literalTheoremNumber(attrs) {
|
|
|
322
420
|
return null;
|
|
323
421
|
}
|
|
324
422
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
423
|
+
/** Yield MDX JSX elements with an exact component name, excluding examples in
|
|
424
|
+
* fenced/inline code and strings in expressions by construction. */
|
|
425
|
+
function* mdxElements(node, name) {
|
|
426
|
+
if (
|
|
427
|
+
(node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
|
|
428
|
+
node.name === name
|
|
429
|
+
) {
|
|
430
|
+
yield node;
|
|
431
|
+
}
|
|
432
|
+
if (!Array.isArray(node.children)) return;
|
|
433
|
+
for (const child of node.children) yield* mdxElements(child, name);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function expressionString(value) {
|
|
437
|
+
const program = value?.data?.estree;
|
|
438
|
+
if (program?.body?.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
const expression = program.body[0].expression;
|
|
442
|
+
if (expression.type === 'Literal' && typeof expression.value === 'string') {
|
|
443
|
+
return expression.value;
|
|
444
|
+
}
|
|
445
|
+
if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
|
|
446
|
+
return expression.quasis[0]?.value?.cooked ?? expression.quasis[0]?.value?.raw ?? '';
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Evaluate one JSX prop in source order. Later explicit attributes override an
|
|
453
|
+
* earlier spread; a later spread makes the value dynamic, exactly as JSX does.
|
|
454
|
+
* Literal ESTree values are already decoded, so entities and JS escapes cannot
|
|
455
|
+
* disguise the href that the component receives at runtime.
|
|
456
|
+
*/
|
|
457
|
+
function mdxStringProp(element, name) {
|
|
458
|
+
let result = { present: false, literal: false, value: null, source: 'absent' };
|
|
459
|
+
for (const attribute of element.attributes) {
|
|
460
|
+
if (attribute.type === 'mdxJsxExpressionAttribute') {
|
|
461
|
+
result = { present: true, literal: false, value: null, source: 'spread' };
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (attribute.type !== 'mdxJsxAttribute' || attribute.name !== name) continue;
|
|
465
|
+
if (typeof attribute.value === 'string') {
|
|
466
|
+
result = { present: true, literal: true, value: attribute.value, source: 'attribute' };
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const value = expressionString(attribute.value);
|
|
470
|
+
result = value === null
|
|
471
|
+
? { present: true, literal: false, value: null, source: 'expression' }
|
|
472
|
+
: { present: true, literal: true, value, source: 'attribute' };
|
|
473
|
+
}
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Canonical comparison shape for a sibling labels href. The generated index
|
|
479
|
+
* intentionally omits the deployment base, so only normalize route seams:
|
|
480
|
+
* leading slashes and the optional trailing slash immediately before `#`.
|
|
481
|
+
*/
|
|
482
|
+
function normalizeSiblingTarget(value) {
|
|
483
|
+
const hash = value.indexOf('#');
|
|
484
|
+
if (hash < 0 || hash === value.length - 1) return null;
|
|
485
|
+
const fragment = value.slice(hash + 1);
|
|
486
|
+
const path = value.slice(0, hash).trim().replace(/^\/+/, '').replace(/\/+$/, '');
|
|
487
|
+
return { fragment, href: `${path}#${fragment}` };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Heading keys in labels.json are deliberately opaque and path-qualified so
|
|
492
|
+
* `#summary` can exist in every chapter. Resolve sibling targets from href
|
|
493
|
+
* values, while remaining compatible with historical component-id keys.
|
|
494
|
+
*/
|
|
495
|
+
function siblingTargetCandidates(index, fragment) {
|
|
496
|
+
const candidates = [];
|
|
497
|
+
for (const [key, entry] of Object.entries(index)) {
|
|
498
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.href !== 'string') continue;
|
|
499
|
+
const normalized = normalizeSiblingTarget(entry.href);
|
|
500
|
+
if (normalized?.fragment === fragment) {
|
|
501
|
+
candidates.push({ key, href: entry.href, normalized });
|
|
340
502
|
}
|
|
341
503
|
}
|
|
504
|
+
return candidates;
|
|
342
505
|
}
|
|
343
506
|
|
|
344
507
|
// ===== Run all checks on each chapter =====
|
|
345
508
|
for (const rel of chapterFiles) {
|
|
346
509
|
const abs = join(CHAPTERS_DIR, rel);
|
|
347
510
|
const content = await readFile(abs, 'utf8');
|
|
511
|
+
const authoredTargets = collectAuthoredTargets(rel, content);
|
|
512
|
+
|
|
513
|
+
// 10. Root-absolute authored targets under a non-root Astro base (#190).
|
|
514
|
+
// Structural parsing covers Markdown link/image destinations, HTML
|
|
515
|
+
// href/src, and decoded static JSX strings while excluding code examples.
|
|
516
|
+
validateAuthoredTargets(rel, content, authoredTargets);
|
|
517
|
+
|
|
518
|
+
let bookLinks = [];
|
|
519
|
+
if (content.includes('<BookLink')) {
|
|
520
|
+
try {
|
|
521
|
+
bookLinks = [...mdxElements(mdxParser.parse(content), 'BookLink')];
|
|
522
|
+
} catch (error) {
|
|
523
|
+
const line = error?.position?.start?.line ?? error?.line ?? 1;
|
|
524
|
+
fail(
|
|
525
|
+
rel,
|
|
526
|
+
line,
|
|
527
|
+
`Cannot structurally validate <BookLink>: ${error?.reason ?? error?.message ?? error}`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
348
531
|
|
|
349
532
|
// 1. Cite keys (academic profile only — tools profile uses YAML manifest)
|
|
350
533
|
if (PROFILE === 'academic') {
|
|
@@ -368,14 +551,9 @@ for (const rel of chapterFiles) {
|
|
|
368
551
|
}
|
|
369
552
|
}
|
|
370
553
|
|
|
371
|
-
// 4. Internal
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (validTopLevelRoutes.has(target + '/') || validTopLevelRoutes.has(target)) continue;
|
|
375
|
-
const chMatch = target.match(/^\/chapters\/(.+)$/);
|
|
376
|
-
if (chMatch && validSlugs.has(chMatch[1])) continue;
|
|
377
|
-
warn(rel, lineOf(content, m.index), `Internal link "${m[1]}" — target may not resolve (check spelling or route)`);
|
|
378
|
-
}
|
|
554
|
+
// 4. Internal Markdown links resolve. Reuse the structural authored-target
|
|
555
|
+
// traversal so code examples and comments cannot create false warnings.
|
|
556
|
+
validateInternalMarkdownLinks(rel, content, authoredTargets);
|
|
379
557
|
|
|
380
558
|
// 5. CodeRef path + line bounds (only when BOOK_REPO_ROOT set)
|
|
381
559
|
if (REPO_ROOT) {
|
|
@@ -443,21 +621,122 @@ for (const rel of chapterFiles) {
|
|
|
443
621
|
}
|
|
444
622
|
}
|
|
445
623
|
|
|
446
|
-
// 7. BookLink (#96): structural
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
624
|
+
// 7. BookLink (#96/#147): structural props + evaluated registry membership,
|
|
625
|
+
// then literal path/fragment validation against a declared vendored index.
|
|
626
|
+
// Dynamic values and URL-only compatibility entries are explicit warnings
|
|
627
|
+
// rather than guessed validations.
|
|
628
|
+
for (const element of bookLinks) {
|
|
629
|
+
const line = element.position?.start?.line ?? 1;
|
|
630
|
+
const bookAttr = mdxStringProp(element, 'book');
|
|
631
|
+
const toAttr = mdxStringProp(element, 'to');
|
|
632
|
+
|
|
633
|
+
if (bookAttr.source === 'spread' || toAttr.source === 'spread') {
|
|
634
|
+
warn(
|
|
635
|
+
rel,
|
|
636
|
+
line,
|
|
637
|
+
'<BookLink> validation skipped: a dynamic prop spread may supply or override book=/to=, so the target cannot be proven statically.',
|
|
638
|
+
);
|
|
452
639
|
continue;
|
|
453
640
|
}
|
|
454
|
-
if (
|
|
641
|
+
if (!bookAttr.present || !toAttr.present) {
|
|
642
|
+
fail(rel, line, `<BookLink> requires both book="…" and to="…".`);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!bookAttr.literal) {
|
|
647
|
+
warn(
|
|
648
|
+
rel,
|
|
649
|
+
line,
|
|
650
|
+
'<BookLink> target validation skipped: dynamic book= expression cannot be evaluated statically.',
|
|
651
|
+
);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const book = bookAttr.value;
|
|
656
|
+
const registered = Object.prototype.hasOwnProperty.call(TOOLING_CONFIG.siblingBooks, book);
|
|
657
|
+
if (!registered) {
|
|
455
658
|
fail(
|
|
456
659
|
rel,
|
|
457
|
-
|
|
458
|
-
`<BookLink book="${
|
|
660
|
+
line,
|
|
661
|
+
`<BookLink book="${book}"> — not in evaluated defineBookConfig siblingBooks (${Object.keys(TOOLING_CONFIG.siblingBooks).join(', ') || 'none'}). Register it or fix the key.`,
|
|
662
|
+
);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const siblingEntry = TOOLING_CONFIG.siblingBooks[book];
|
|
666
|
+
|
|
667
|
+
if (!toAttr.literal) {
|
|
668
|
+
warn(
|
|
669
|
+
rel,
|
|
670
|
+
line,
|
|
671
|
+
`<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
|
|
672
|
+
);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const target = normalizeSiblingTarget(toAttr.value);
|
|
677
|
+
if (!target) {
|
|
678
|
+
warn(
|
|
679
|
+
rel,
|
|
680
|
+
line,
|
|
681
|
+
`<BookLink book="${book}" to="${toAttr.value}"> has no static #fragment; sibling labels validation skipped.`,
|
|
459
682
|
);
|
|
683
|
+
continue;
|
|
460
684
|
}
|
|
685
|
+
|
|
686
|
+
if (typeof siblingEntry === 'string' || siblingEntry.labels === undefined) {
|
|
687
|
+
warn(
|
|
688
|
+
rel,
|
|
689
|
+
line,
|
|
690
|
+
`<BookLink book="${book}" to="${toAttr.value}"> target validation skipped: ` +
|
|
691
|
+
'the siblingBooks entry is URL-only. Use { url, labels } to enable vendored-label validation.',
|
|
692
|
+
);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const loaded = siblingLabelIndexes.get(book);
|
|
697
|
+
if (!loaded) continue; // A config-level error was already recorded above.
|
|
698
|
+
|
|
699
|
+
const candidates = siblingTargetCandidates(loaded.index, target.fragment);
|
|
700
|
+
if (candidates.some((candidate) => candidate.normalized.href === target.href)) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (candidates.length === 0) {
|
|
705
|
+
// Preserve the actionable diagnostic for a malformed historical entry
|
|
706
|
+
// whose key is the literal fragment. Opaque heading keys are found by
|
|
707
|
+
// valid href values and never need to be decoded here.
|
|
708
|
+
const legacyEntry = loaded.index[target.fragment];
|
|
709
|
+
if (
|
|
710
|
+
Object.prototype.hasOwnProperty.call(loaded.index, target.fragment) &&
|
|
711
|
+
(legacyEntry === null ||
|
|
712
|
+
typeof legacyEntry !== 'object' ||
|
|
713
|
+
typeof legacyEntry.href !== 'string')
|
|
714
|
+
) {
|
|
715
|
+
fail(
|
|
716
|
+
rel,
|
|
717
|
+
line,
|
|
718
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — ${loaded.configuredPath} entry ` +
|
|
719
|
+
`"${target.fragment}" has no string href; re-vendor a valid sibling labels.json.`,
|
|
720
|
+
);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
fail(
|
|
724
|
+
rel,
|
|
725
|
+
line,
|
|
726
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — fragment "${target.fragment}" is not in ` +
|
|
727
|
+
`${loaded.configuredPath}. Vendor the current sibling labels.json, or fix the target id.`,
|
|
728
|
+
);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
|
|
733
|
+
fail(
|
|
734
|
+
rel,
|
|
735
|
+
line,
|
|
736
|
+
`<BookLink book="${book}" to="${toAttr.value}"> — path/fragment does not match ` +
|
|
737
|
+
`${loaded.configuredPath}, which indexes "${target.fragment}" at ${indexedHrefs}. ` +
|
|
738
|
+
'Fix to= or refresh the vendored index.',
|
|
739
|
+
);
|
|
461
740
|
}
|
|
462
741
|
|
|
463
742
|
// <Rationale appendix> in CHAPTER bodies (v4.21.0, #114): same missing-for=
|
|
@@ -563,6 +842,11 @@ let questionsChecked = 0;
|
|
|
563
842
|
const front = fm ? fm[1] : '';
|
|
564
843
|
const qrel = `questions/${rel}`;
|
|
565
844
|
|
|
845
|
+
// Questions do not run the legacy link-resolution advisory, so keep the
|
|
846
|
+
// #190 root-base contract fully inert by parsing only for a non-root base.
|
|
847
|
+
const authoredTargets = ASTRO_BASE === '/' ? [] : collectAuthoredTargets(qrel, content);
|
|
848
|
+
validateAuthoredTargets(qrel, content, authoredTargets);
|
|
849
|
+
|
|
566
850
|
const idMatch = front.match(/^id\s*:\s*["']?([^"'\n]+?)["']?\s*$/m);
|
|
567
851
|
if (idMatch) {
|
|
568
852
|
const id = idMatch[1].trim();
|
|
@@ -638,4 +922,4 @@ console.error(
|
|
|
638
922
|
`(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):`,
|
|
639
923
|
);
|
|
640
924
|
errors.forEach((e) => console.error(format(e)));
|
|
641
|
-
process.exit(errors.length);
|
|
925
|
+
process.exit(Math.min(errors.length, 255));
|
package/scripts/walk-mdx.mjs
CHANGED
|
@@ -16,6 +16,111 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
import { join, relative, resolve } from 'node:path';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Mask JS/TS comments and string/template contents with spaces while
|
|
21
|
+
* preserving offsets and newlines. Collection detection runs against this
|
|
22
|
+
* mask so prose such as "default chapters" and code examples in comments
|
|
23
|
+
* cannot bind to a later, unrelated collection's `base:` property.
|
|
24
|
+
*/
|
|
25
|
+
function codeMask(source) {
|
|
26
|
+
// split('') preserves UTF-16 code-unit offsets used by RegExp#index even
|
|
27
|
+
// when a preceding comment contains an emoji/non-BMP character.
|
|
28
|
+
const out = source.split('');
|
|
29
|
+
let state = 'code';
|
|
30
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
31
|
+
const char = source[i];
|
|
32
|
+
const next = source[i + 1];
|
|
33
|
+
|
|
34
|
+
if (state === 'line-comment') {
|
|
35
|
+
if (char === '\n') state = 'code';
|
|
36
|
+
else out[i] = ' ';
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (state === 'block-comment') {
|
|
40
|
+
if (char === '*' && next === '/') {
|
|
41
|
+
out[i] = ' ';
|
|
42
|
+
out[i + 1] = ' ';
|
|
43
|
+
i += 1;
|
|
44
|
+
state = 'code';
|
|
45
|
+
} else if (char !== '\n') {
|
|
46
|
+
out[i] = ' ';
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (state === 'single' || state === 'double' || state === 'template') {
|
|
51
|
+
const closing = state === 'single' ? "'" : state === 'double' ? '"' : '`';
|
|
52
|
+
if (char === '\\') {
|
|
53
|
+
out[i] = ' ';
|
|
54
|
+
if (i + 1 < source.length) {
|
|
55
|
+
if (source[i + 1] !== '\n') out[i + 1] = ' ';
|
|
56
|
+
i += 1;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
if (char === closing) state = 'code';
|
|
60
|
+
if (char !== '\n') out[i] = ' ';
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (char === '/' && next === '/') {
|
|
66
|
+
out[i] = ' ';
|
|
67
|
+
out[i + 1] = ' ';
|
|
68
|
+
i += 1;
|
|
69
|
+
state = 'line-comment';
|
|
70
|
+
} else if (char === '/' && next === '*') {
|
|
71
|
+
out[i] = ' ';
|
|
72
|
+
out[i + 1] = ' ';
|
|
73
|
+
i += 1;
|
|
74
|
+
state = 'block-comment';
|
|
75
|
+
} else if (char === "'") {
|
|
76
|
+
out[i] = ' ';
|
|
77
|
+
state = 'single';
|
|
78
|
+
} else if (char === '"') {
|
|
79
|
+
out[i] = ' ';
|
|
80
|
+
state = 'double';
|
|
81
|
+
} else if (char === '`') {
|
|
82
|
+
out[i] = ' ';
|
|
83
|
+
state = 'template';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out.join('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Find an actual `chapters = defineCollection(...)` call and return its body
|
|
90
|
+
* plus an offset-preserving code mask. Supports declaration and object-property
|
|
91
|
+
* forms without relying on a distance from any arbitrary `chapters` word. */
|
|
92
|
+
function findChaptersCollectionCall(source) {
|
|
93
|
+
const mask = codeMask(source);
|
|
94
|
+
const starts = [
|
|
95
|
+
/\b(?:const|let|var)\s+chapters\s*=\s*defineCollection\s*\(/g,
|
|
96
|
+
/(?:^|[,{])\s*chapters\s*:\s*defineCollection\s*\(/gm,
|
|
97
|
+
];
|
|
98
|
+
const matches = [];
|
|
99
|
+
for (const pattern of starts) {
|
|
100
|
+
for (const match of mask.matchAll(pattern)) {
|
|
101
|
+
matches.push({ index: match.index, open: match.index + match[0].lastIndexOf('(') });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
matches.sort((a, b) => a.index - b.index);
|
|
105
|
+
|
|
106
|
+
for (const match of matches) {
|
|
107
|
+
let depth = 0;
|
|
108
|
+
for (let i = match.open; i < mask.length; i += 1) {
|
|
109
|
+
if (mask[i] === '(') depth += 1;
|
|
110
|
+
else if (mask[i] === ')') {
|
|
111
|
+
depth -= 1;
|
|
112
|
+
if (depth === 0) {
|
|
113
|
+
return {
|
|
114
|
+
body: source.slice(match.open + 1, i),
|
|
115
|
+
mask: mask.slice(match.open + 1, i),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
19
124
|
export async function* walkMdx(dir, baseDir = dir) {
|
|
20
125
|
let entries;
|
|
21
126
|
try {
|
|
@@ -152,19 +257,28 @@ export async function readChaptersBase(projectRoot) {
|
|
|
152
257
|
} catch {
|
|
153
258
|
return DEFAULT_BASE;
|
|
154
259
|
}
|
|
155
|
-
// Look for
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// those fall back to the default since the value is dynamic).
|
|
260
|
+
// Look for an actual `chapters` collection's `loader.base` string. The
|
|
261
|
+
// older "chapters word, then any base within 500 chars" regex could start
|
|
262
|
+
// in a comment or `...scaffold.collections` and steal the following
|
|
263
|
+
// supplements collection's base (#147 handbook dogfood).
|
|
160
264
|
//
|
|
161
265
|
// Forms matched:
|
|
162
266
|
// - `const chapters = defineCollection({ loader: glob({ base: './foo' }) })`
|
|
163
267
|
// - `export const collections = { chapters: defineCollection({ loader: glob({ base: './foo' }) }) }`
|
|
164
268
|
// - any indentation / line break style
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
269
|
+
const collectionCall = findChaptersCollectionCall(source);
|
|
270
|
+
let captured = null;
|
|
271
|
+
if (collectionCall) {
|
|
272
|
+
const loader = /\bloader\s*:/.exec(collectionCall.mask);
|
|
273
|
+
const base = loader
|
|
274
|
+
? /\bbase\s*:/.exec(collectionCall.mask.slice(loader.index + loader[0].length))
|
|
275
|
+
: null;
|
|
276
|
+
if (base) {
|
|
277
|
+
const valueOffset = loader.index + loader[0].length + base.index + base[0].length;
|
|
278
|
+
const literal = collectionCall.body.slice(valueOffset).match(/^\s*(?:'([^']+)'|"([^"]+)")/);
|
|
279
|
+
captured = literal && (literal[1] || literal[2]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
168
282
|
if (captured) {
|
|
169
283
|
return resolve(projectRoot, captured);
|
|
170
284
|
}
|