@brandon_m_behring/book-scaffold-astro 4.28.0 → 4.29.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.
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * build-labels.mjs — emit src/data/labels.json for <XRef> resolution.
4
4
  *
5
- * Walks the consumer's `src/content/chapters/**\/*.mdx`, extracts each
6
- * labelable component invocation (Theorem, Figure, Section, … — see
7
- * `LABELABLE_TYPES` below), and assigns it an entry of the form
5
+ * Walks the consumer's `src/content/chapters/**\/*.mdx`, extracts every h2–h6
6
+ * Markdown heading plus each labelable component invocation (Theorem, Figure,
7
+ * Section, … — see `LABELABLE_TYPES` below), and assigns it an entry of the form
8
8
  * { href, display: "Theorem 4.2", number: "4.2" }
9
9
  * matching the LaTeX `\cref` convention. The map is consumed by XRef.astro
10
10
  * (display + href) AND Theorem.astro (#126: `number`, so a heading auto-
@@ -25,11 +25,15 @@
25
25
  * - tools profile: `chapter` field (number).
26
26
  * - academic profile: `week` field (number).
27
27
  *
28
- * Slug used for the href: the chapter's frontmatter `slug:` if set,
29
- * else filename minus `.mdx`. The href shape mirrors the consumer's pages
30
- * router: `/chapters/<slug>#<id>`. Academic books using `[...slug].astro`
31
- * get the same shape since Astro slugifies filenames identically when no
32
- * frontmatter override is present.
28
+ * Chapter IDs use frontmatter `slug:` when set, else the chapter-relative path
29
+ * minus `.md`/`.mdx` (so nested content IDs preserve their directory). Hrefs
30
+ * are resolved through the evaluated defineBookConfig `chapterRoute` and
31
+ * `bookField`; the default remains `chapters/<id>#<label>`.
32
+ *
33
+ * Heading anchors come from Astro's own Markdown processor, including its
34
+ * smartypants text normalization and GitHubSlugger duplicate suffixes. h1 is
35
+ * intentionally excluded because it is the chapter title, not an in-chapter
36
+ * BookLink target.
33
37
  *
34
38
  * Optional override:
35
39
  * <Theorem id="…" label="Custom display" />
@@ -45,7 +49,9 @@
45
49
  * Designed to run in <2 s on a medium book.
46
50
  */
47
51
  import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
48
- import { resolve, relative, join, basename, dirname } from 'node:path';
52
+ import { resolve, relative, join, dirname, sep } from 'node:path';
53
+ import { pathToFileURL } from 'node:url';
54
+ import { createMarkdownProcessor } from '@astrojs/markdown-remark';
49
55
  import { readChaptersBase } from './walk-mdx.mjs';
50
56
  import { loadResolvedBookConfig } from './resolve-book-config.mjs';
51
57
  // #126: reuse the ONE kind vocabulary (theorem-label.ts → its own lean tsup
@@ -54,13 +60,16 @@ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
54
60
  // throw as the render path, #121) one build step earlier. Requires `dist/`
55
61
  // (run `npm run build` first; the published tarball ships dist + scripts).
56
62
  import { theoremLabel } from '../dist/lib/theorem-label.mjs';
63
+ // #147: reuse the same route-token resolver as Sidebar/ChapterNav rather than
64
+ // maintaining a second, hardcoded `chapters/<slug>` interpretation here.
65
+ import { chapterHref } from '../dist/lib/nav-href.mjs';
57
66
 
58
67
  // --help / -h: non-mutating (closes #14).
59
68
  const USAGE = `Usage: book-scaffold build-labels
60
69
 
61
- Emit src/data/labels.json for <XRef> resolution. Walks chapter MDX files,
62
- extracts labelable components (Theorem, Figure, ...), assigns display strings
63
- like "Theorem 4.2" matching LaTeX \\cref.
70
+ Emit src/data/labels.json for <XRef> and <BookLink> resolution. Walks chapter
71
+ Markdown/MDX files, indexes h2–h6 anchors, and extracts labelable components
72
+ (Theorem, Figure, ...) with display strings like "Theorem 4.2".
64
73
 
65
74
  Env:
66
75
  BOOK_CHAPTERS_DIR Override chapters dir (default: src/content/chapters).
@@ -69,8 +78,8 @@ Env:
69
78
  Options:
70
79
  --help, -h Print this message and exit (non-mutating).
71
80
 
72
- Numbering is read from defineBookConfig / defineStyle numberStyle. It defaults
73
- to shared when no scaffold integration can be resolved.
81
+ Numbering and chapter hrefs are read from evaluated defineBookConfig metadata.
82
+ Defaults are shared numbering and /chapters/:id/ when no integration resolves.
74
83
  `;
75
84
 
76
85
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
@@ -111,12 +120,14 @@ const TYPE_DISPLAY = {
111
120
 
112
121
  // ===== Frontmatter parsing =====
113
122
 
114
- function parseFrontmatter(source) {
123
+ function splitFrontmatter(source) {
115
124
  // Standard MDX/YAML frontmatter: `---\n…\n---`.
116
- const m = source.match(/^---\n([\s\S]*?)\n---/);
117
- if (!m) return {};
125
+ // Remove it before Markdown processing: YAML comments beginning with `#`
126
+ // must not become phantom headings.
127
+ const m = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
128
+ if (!m) return { frontmatter: {}, body: source };
118
129
  const fm = {};
119
- for (const line of m[1].split('\n')) {
130
+ for (const line of m[1].split(/\r?\n/)) {
120
131
  const kv = line.match(/^(\w+)\s*:\s*(.+?)\s*$/);
121
132
  if (!kv) continue;
122
133
  const [, key, raw] = kv;
@@ -125,7 +136,7 @@ function parseFrontmatter(source) {
125
136
  if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
126
137
  fm[key] = val;
127
138
  }
128
- return fm;
139
+ return { frontmatter: fm, body: source.slice(m[0].length) };
129
140
  }
130
141
 
131
142
  function chapterNumberOf(frontmatter) {
@@ -187,9 +198,13 @@ async function walkChapters(dir) {
187
198
 
188
199
  async function main() {
189
200
  const cwd = process.cwd();
190
- const { numberStyle } = await loadResolvedBookConfig(cwd);
201
+ const { numberStyle, chapterRoute, bookField } = await loadResolvedBookConfig(cwd);
191
202
  const chaptersDir = resolve(cwd, CHAPTERS_DIR);
192
203
  const files = await walkChapters(chaptersDir);
204
+ // Syntax highlighting cannot affect heading metadata and is expensive to
205
+ // initialize. Everything that does affect Astro heading text/IDs (GFM,
206
+ // smartypants, rehypeHeadingIds/GitHubSlugger) retains Astro's defaults.
207
+ const headingProcessor = await createMarkdownProcessor({ syntaxHighlight: false });
193
208
 
194
209
  const labels = {};
195
210
  const tagRegex = buildTagRegex();
@@ -198,16 +213,63 @@ async function main() {
198
213
 
199
214
  for (const file of files) {
200
215
  const source = await readFile(file, 'utf8');
201
- const fm = parseFrontmatter(source);
216
+ const { frontmatter: fm, body } = splitFrontmatter(source);
202
217
  const chapterNum = chapterNumberOf(fm);
203
- const slug = (typeof fm.slug === 'string' && fm.slug.length > 0)
218
+ const contentId = relative(chaptersDir, file)
219
+ .split(sep)
220
+ .join('/')
221
+ .replace(/\.mdx?$/, '');
222
+ const entryId = (typeof fm.slug === 'string' && fm.slug.length > 0)
204
223
  ? fm.slug
205
- : basename(file).replace(/\.mdx?$/, '');
224
+ : contentId;
225
+ const chapterPath = chapterHref(
226
+ { id: entryId, data: fm },
227
+ chapterRoute,
228
+ '/',
229
+ bookField,
230
+ ).replace(/^\/+|\/+$/g, '');
231
+
232
+ const addLabel = (id, value) => {
233
+ if (labels[id]) {
234
+ // Duplicate id — surface but don't fail; consumer's validator catches
235
+ // collisions with full diagnostic context. Component IDs retain their
236
+ // historical precedence over an identically named heading anchor.
237
+ process.stderr.write(
238
+ `build-labels: WARN duplicate id "${id}" (first in ` +
239
+ `${labels[id].href.split('#')[0]}, now in ${entryId})\n`,
240
+ );
241
+ }
242
+ labels[id] = value;
243
+ };
206
244
 
207
245
  // Per-chapter counters reset for each file.
208
246
  const counters = {};
209
247
  let foundInChapter = 0;
210
248
 
249
+ // #147: BookLink fragments normally target prose sections rather than
250
+ // labelable components. Astro owns both text extraction and GitHub-style
251
+ // slug collision behavior, so consume its heading metadata directly.
252
+ const rendered = await headingProcessor.render(body, {
253
+ fileURL: pathToFileURL(file),
254
+ frontmatter: fm,
255
+ });
256
+ for (const heading of rendered.metadata.headings) {
257
+ if (heading.depth < 2 || heading.depth > 6) continue;
258
+ foundInChapter += 1;
259
+ totalIds += 1;
260
+ const href = `${chapterPath}#${heading.slug}`;
261
+ // Heading fragments are only document-local: many chapters legitimately
262
+ // contain `#summary`. A path-qualified, opaque key preserves every one
263
+ // without colliding with component IDs or another chapter. BookLink
264
+ // validation resolves the exact href VALUE; consumers must not derive
265
+ // heading semantics from this internal key.
266
+ addLabel(`heading:${href}`, {
267
+ href,
268
+ display: heading.text,
269
+ number: null,
270
+ });
271
+ }
272
+
211
273
  for (const match of source.matchAll(tagRegex)) {
212
274
  const [, componentName, attrs] = match;
213
275
  const id = extractAttr(attrs, 'id');
@@ -267,21 +329,13 @@ async function main() {
267
329
  : String(counters[counterKey]);
268
330
  const display = labelOverride ?? `${word} ${number}`;
269
331
 
270
- if (labels[id]) {
271
- // Duplicate id — surface but don't fail; consumer's validator
272
- // catches collisions with full diagnostic context.
273
- process.stderr.write(
274
- `build-labels: WARN duplicate id "${id}" (first in ` +
275
- `${labels[id].href.split('#')[0]}, now in ${slug})\n`,
276
- );
277
- }
278
- labels[id] = {
332
+ addLabel(id, {
279
333
  // #142: base-less ref — XRef.astro prefixes BASE_URL at render so one
280
334
  // labels.json serves any deploy base (root or path-proxied series).
281
- href: `chapters/${slug}#${id}`,
335
+ href: `${chapterPath}#${id}`,
282
336
  display,
283
337
  number,
284
- };
338
+ });
285
339
  }
286
340
 
287
341
  if (foundInChapter > 0) chaptersWithIds += 1;
@@ -5,6 +5,9 @@ import { loadConfigFromFile } from 'vite';
5
5
  export const DEFAULT_TOOLING_CONFIG = Object.freeze({
6
6
  preset: null,
7
7
  numberStyle: 'shared',
8
+ siblingBooks: Object.freeze({}),
9
+ chapterRoute: '/chapters/:id/',
10
+ bookField: 'book',
8
11
  integrationFound: false,
9
12
  });
10
13
 
@@ -42,6 +45,64 @@ function assertPreset(value, configPath) {
42
45
  }
43
46
  }
44
47
 
48
+ function resolveNonEmptyString(value, fallback, field, configPath) {
49
+ if (value == null) return fallback;
50
+ if (typeof value !== 'string' || value.length === 0) {
51
+ throw new Error(
52
+ `book-scaffold tooling: ${configPath} resolved invalid ${field} ` +
53
+ `${JSON.stringify(value)}; expected a non-empty string.`,
54
+ );
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function resolveSiblingBooks(value, configPath) {
60
+ if (value == null) return {};
61
+ if (typeof value !== 'object' || Array.isArray(value)) {
62
+ throw new Error(
63
+ `book-scaffold tooling: ${configPath} resolved invalid siblingBooks ` +
64
+ `${JSON.stringify(value)}; expected an object registry.`,
65
+ );
66
+ }
67
+
68
+ const resolved = [];
69
+ for (const [book, entry] of Object.entries(value)) {
70
+ if (typeof entry === 'string') {
71
+ if (entry.length === 0) {
72
+ throw new Error(
73
+ `book-scaffold tooling: ${configPath} resolved invalid siblingBooks.${book}; ` +
74
+ 'URL strings must not be empty.',
75
+ );
76
+ }
77
+ resolved.push([book, entry]);
78
+ continue;
79
+ }
80
+
81
+ if (
82
+ entry === null ||
83
+ typeof entry !== 'object' ||
84
+ Array.isArray(entry) ||
85
+ typeof entry.url !== 'string' ||
86
+ entry.url.length === 0 ||
87
+ (entry.labels !== undefined &&
88
+ (typeof entry.labels !== 'string' || entry.labels.length === 0))
89
+ ) {
90
+ throw new Error(
91
+ `book-scaffold tooling: ${configPath} resolved invalid siblingBooks.${book}; ` +
92
+ 'expected a URL string or { url: string, labels?: string }.',
93
+ );
94
+ }
95
+ resolved.push([
96
+ book,
97
+ {
98
+ url: entry.url,
99
+ ...(entry.labels === undefined ? {} : { labels: entry.labels }),
100
+ },
101
+ ]);
102
+ }
103
+ return Object.fromEntries(resolved);
104
+ }
105
+
45
106
  /**
46
107
  * Evaluate the consumer's actual Astro config and read the scaffold
47
108
  * integration's internal resolved metadata. Absence of a config/integration is
@@ -91,6 +152,19 @@ export async function loadResolvedBookConfig(projectRoot = process.cwd()) {
91
152
  return {
92
153
  preset: metadata.preset ?? null,
93
154
  numberStyle,
155
+ siblingBooks: resolveSiblingBooks(metadata.siblingBooks, configPath),
156
+ chapterRoute: resolveNonEmptyString(
157
+ metadata.chapterRoute,
158
+ DEFAULT_TOOLING_CONFIG.chapterRoute,
159
+ 'chapterRoute',
160
+ configPath,
161
+ ),
162
+ bookField: resolveNonEmptyString(
163
+ metadata.bookField,
164
+ DEFAULT_TOOLING_CONFIG.bookField,
165
+ 'bookField',
166
+ configPath,
167
+ ),
94
168
  integrationFound: true,
95
169
  };
96
170
  }
@@ -16,8 +16,9 @@
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, and book= is a
20
- * key in the consumer's siblingBooks registry (best-effort).
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).
@@ -41,6 +42,9 @@ import { existsSync, readFileSync } from 'node:fs';
41
42
  import { resolve, dirname, join } from 'node:path';
42
43
  import { fileURLToPath } from 'node:url';
43
44
  import { spawnSync } from 'node:child_process';
45
+ import { unified } from 'unified';
46
+ import remarkParse from 'remark-parse';
47
+ import remarkMdx from 'remark-mdx';
44
48
  import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
45
49
  import { readEnvFile } from './read-env.mjs';
46
50
  import { loadResolvedBookConfig } from './resolve-book-config.mjs';
@@ -256,6 +260,32 @@ async function loadJson(path) {
256
260
  const refs = await loadJson(join(DATA_DIR, 'references.json'));
257
261
  const labels = await loadJson(join(DATA_DIR, 'labels.json'));
258
262
 
263
+ // #147: eagerly load every labels index explicitly declared by the evaluated
264
+ // siblingBooks registry. Unlike this book's generated labels.json, sibling
265
+ // indexes are vendored inputs: never self-heal or silently replace them. A
266
+ // missing, unreadable, malformed, or non-object index is a configuration error
267
+ // even when no current chapter happens to reference that sibling.
268
+ const siblingLabelIndexes = new Map();
269
+ for (const [book, entry] of Object.entries(TOOLING_CONFIG.siblingBooks)) {
270
+ if (typeof entry === 'string' || entry.labels === undefined) continue;
271
+ const indexPath = resolve(ROOT, entry.labels);
272
+ try {
273
+ const index = JSON.parse(await readFile(indexPath, 'utf8'));
274
+ if (index === null || typeof index !== 'object' || Array.isArray(index)) {
275
+ throw new Error('top-level JSON value must be an object');
276
+ }
277
+ siblingLabelIndexes.set(book, { index, configuredPath: entry.labels });
278
+ } catch (error) {
279
+ const detail = error instanceof Error ? error.message : String(error);
280
+ fail(
281
+ 'astro.config',
282
+ 1,
283
+ `siblingBooks.${book}.labels (${JSON.stringify(entry.labels)}) is missing, unreadable, or invalid: ${detail}. ` +
284
+ 'Vendor a readable sibling labels.json at that path or remove labels to opt out with a warning.',
285
+ );
286
+ }
287
+ }
288
+
259
289
  // ===== Collect chapter files =====
260
290
  // v3.7.1 (closes #52): walkMdx (in ./walk-mdx.mjs) is a recursive readdir
261
291
  // walker that replaces the previous `glob` import from `node:fs/promises`.
@@ -282,9 +312,10 @@ const RE_MD_LINK = /\[(?:[^\]]*)\]\((\/[^)\s#]+)(?:#[^)]*)?\)/g;
282
312
  // #121: a <Theorem> opening tag — capture its attributes to assert a
283
313
  // resolvable kind= (or legacy type=) is present.
284
314
  const RE_THEOREM = /<Theorem\b([^>]*)>/g;
285
- // #96: a <BookLink> opening tag assert book= + to= present, and (best-effort)
286
- // that book= is a registered sibling.
287
- const RE_BOOKLINK = /<BookLink\b([^>]*)>/g;
315
+ // #96/#147: BookLink attributes must be read structurally. An opening-tag
316
+ // regex stops at `>` inside a quoted value or expression and can mistake text
317
+ // inside another prop for a real book=/to= assignment.
318
+ const mdxParser = unified().use(remarkParse).use(remarkMdx);
288
319
 
289
320
  async function fileExists(p) {
290
321
  try {
@@ -322,29 +353,107 @@ function literalTheoremNumber(attrs) {
322
353
  return null;
323
354
  }
324
355
 
325
- // #96: best-effort siblingBooks registry keys from astro.config.mjs, so the
326
- // <BookLink> check can flag an unknown book= earlier than the component's
327
- // build-time throw. null = couldn't determine → membership not checked (the
328
- // component still fails loud at build).
329
- let siblingBookKeys = null;
330
- {
331
- const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
332
- if (existsSync(astroConfigPath)) {
333
- const block = readFileSync(astroConfigPath, 'utf8').match(/siblingBooks\s*:\s*\{([^}]*)\}/);
334
- if (block) {
335
- // Anchor each key to an entry boundary ({ , or start) so the `https:` in
336
- // a URL value isn't mistaken for a key.
337
- siblingBookKeys = new Set(
338
- [...block[1].matchAll(/(?:^|[{,])\s*['"]?([\w-]+)['"]?\s*:/g)].map((x) => x[1]),
339
- );
356
+ /** Yield MDX JSX elements with an exact component name, excluding examples in
357
+ * fenced/inline code and strings in expressions by construction. */
358
+ function* mdxElements(node, name) {
359
+ if (
360
+ (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
361
+ node.name === name
362
+ ) {
363
+ yield node;
364
+ }
365
+ if (!Array.isArray(node.children)) return;
366
+ for (const child of node.children) yield* mdxElements(child, name);
367
+ }
368
+
369
+ function expressionString(value) {
370
+ const program = value?.data?.estree;
371
+ if (program?.body?.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
372
+ return null;
373
+ }
374
+ const expression = program.body[0].expression;
375
+ if (expression.type === 'Literal' && typeof expression.value === 'string') {
376
+ return expression.value;
377
+ }
378
+ if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
379
+ return expression.quasis[0]?.value?.cooked ?? expression.quasis[0]?.value?.raw ?? '';
380
+ }
381
+ return null;
382
+ }
383
+
384
+ /**
385
+ * Evaluate one JSX prop in source order. Later explicit attributes override an
386
+ * earlier spread; a later spread makes the value dynamic, exactly as JSX does.
387
+ * Literal ESTree values are already decoded, so entities and JS escapes cannot
388
+ * disguise the href that the component receives at runtime.
389
+ */
390
+ function mdxStringProp(element, name) {
391
+ let result = { present: false, literal: false, value: null, source: 'absent' };
392
+ for (const attribute of element.attributes) {
393
+ if (attribute.type === 'mdxJsxExpressionAttribute') {
394
+ result = { present: true, literal: false, value: null, source: 'spread' };
395
+ continue;
396
+ }
397
+ if (attribute.type !== 'mdxJsxAttribute' || attribute.name !== name) continue;
398
+ if (typeof attribute.value === 'string') {
399
+ result = { present: true, literal: true, value: attribute.value, source: 'attribute' };
400
+ continue;
401
+ }
402
+ const value = expressionString(attribute.value);
403
+ result = value === null
404
+ ? { present: true, literal: false, value: null, source: 'expression' }
405
+ : { present: true, literal: true, value, source: 'attribute' };
406
+ }
407
+ return result;
408
+ }
409
+
410
+ /**
411
+ * Canonical comparison shape for a sibling labels href. The generated index
412
+ * intentionally omits the deployment base, so only normalize route seams:
413
+ * leading slashes and the optional trailing slash immediately before `#`.
414
+ */
415
+ function normalizeSiblingTarget(value) {
416
+ const hash = value.indexOf('#');
417
+ if (hash < 0 || hash === value.length - 1) return null;
418
+ const fragment = value.slice(hash + 1);
419
+ const path = value.slice(0, hash).trim().replace(/^\/+/, '').replace(/\/+$/, '');
420
+ return { fragment, href: `${path}#${fragment}` };
421
+ }
422
+
423
+ /**
424
+ * Heading keys in labels.json are deliberately opaque and path-qualified so
425
+ * `#summary` can exist in every chapter. Resolve sibling targets from href
426
+ * values, while remaining compatible with historical component-id keys.
427
+ */
428
+ function siblingTargetCandidates(index, fragment) {
429
+ const candidates = [];
430
+ for (const [key, entry] of Object.entries(index)) {
431
+ if (entry === null || typeof entry !== 'object' || typeof entry.href !== 'string') continue;
432
+ const normalized = normalizeSiblingTarget(entry.href);
433
+ if (normalized?.fragment === fragment) {
434
+ candidates.push({ key, href: entry.href, normalized });
340
435
  }
341
436
  }
437
+ return candidates;
342
438
  }
343
439
 
344
440
  // ===== Run all checks on each chapter =====
345
441
  for (const rel of chapterFiles) {
346
442
  const abs = join(CHAPTERS_DIR, rel);
347
443
  const content = await readFile(abs, 'utf8');
444
+ let bookLinks = [];
445
+ if (content.includes('<BookLink')) {
446
+ try {
447
+ bookLinks = [...mdxElements(mdxParser.parse(content), 'BookLink')];
448
+ } catch (error) {
449
+ const line = error?.position?.start?.line ?? error?.line ?? 1;
450
+ fail(
451
+ rel,
452
+ line,
453
+ `Cannot structurally validate <BookLink>: ${error?.reason ?? error?.message ?? error}`,
454
+ );
455
+ }
456
+ }
348
457
 
349
458
  // 1. Cite keys (academic profile only — tools profile uses YAML manifest)
350
459
  if (PROFILE === 'academic') {
@@ -443,21 +552,122 @@ for (const rel of chapterFiles) {
443
552
  }
444
553
  }
445
554
 
446
- // 7. BookLink (#96): structural (book= + to=) + best-effort registry membership.
447
- for (const m of content.matchAll(RE_BOOKLINK)) {
448
- const attrs = m[1];
449
- const bookMatch = attrs.match(/\bbook=["']([^"']+)["']/);
450
- if (!bookMatch || !/\bto=["']/.test(attrs)) {
451
- fail(rel, lineOf(content, m.index), `<BookLink> requires both book="…" and to="…".`);
555
+ // 7. BookLink (#96/#147): structural props + evaluated registry membership,
556
+ // then literal path/fragment validation against a declared vendored index.
557
+ // Dynamic values and URL-only compatibility entries are explicit warnings
558
+ // rather than guessed validations.
559
+ for (const element of bookLinks) {
560
+ const line = element.position?.start?.line ?? 1;
561
+ const bookAttr = mdxStringProp(element, 'book');
562
+ const toAttr = mdxStringProp(element, 'to');
563
+
564
+ if (bookAttr.source === 'spread' || toAttr.source === 'spread') {
565
+ warn(
566
+ rel,
567
+ line,
568
+ '<BookLink> validation skipped: a dynamic prop spread may supply or override book=/to=, so the target cannot be proven statically.',
569
+ );
452
570
  continue;
453
571
  }
454
- if (siblingBookKeys && !siblingBookKeys.has(bookMatch[1])) {
572
+ if (!bookAttr.present || !toAttr.present) {
573
+ fail(rel, line, `<BookLink> requires both book="…" and to="…".`);
574
+ continue;
575
+ }
576
+
577
+ if (!bookAttr.literal) {
578
+ warn(
579
+ rel,
580
+ line,
581
+ '<BookLink> target validation skipped: dynamic book= expression cannot be evaluated statically.',
582
+ );
583
+ continue;
584
+ }
585
+
586
+ const book = bookAttr.value;
587
+ const registered = Object.prototype.hasOwnProperty.call(TOOLING_CONFIG.siblingBooks, book);
588
+ if (!registered) {
455
589
  fail(
456
590
  rel,
457
- lineOf(content, m.index),
458
- `<BookLink book="${bookMatch[1]}"> — not in defineBookConfig siblingBooks (${[...siblingBookKeys].join(', ') || 'none'}). Register it or fix the key.`,
591
+ line,
592
+ `<BookLink book="${book}"> — not in evaluated defineBookConfig siblingBooks (${Object.keys(TOOLING_CONFIG.siblingBooks).join(', ') || 'none'}). Register it or fix the key.`,
593
+ );
594
+ continue;
595
+ }
596
+ const siblingEntry = TOOLING_CONFIG.siblingBooks[book];
597
+
598
+ if (!toAttr.literal) {
599
+ warn(
600
+ rel,
601
+ line,
602
+ `<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
603
+ );
604
+ continue;
605
+ }
606
+
607
+ const target = normalizeSiblingTarget(toAttr.value);
608
+ if (!target) {
609
+ warn(
610
+ rel,
611
+ line,
612
+ `<BookLink book="${book}" to="${toAttr.value}"> has no static #fragment; sibling labels validation skipped.`,
459
613
  );
614
+ continue;
460
615
  }
616
+
617
+ if (typeof siblingEntry === 'string' || siblingEntry.labels === undefined) {
618
+ warn(
619
+ rel,
620
+ line,
621
+ `<BookLink book="${book}" to="${toAttr.value}"> target validation skipped: ` +
622
+ 'the siblingBooks entry is URL-only. Use { url, labels } to enable vendored-label validation.',
623
+ );
624
+ continue;
625
+ }
626
+
627
+ const loaded = siblingLabelIndexes.get(book);
628
+ if (!loaded) continue; // A config-level error was already recorded above.
629
+
630
+ const candidates = siblingTargetCandidates(loaded.index, target.fragment);
631
+ if (candidates.some((candidate) => candidate.normalized.href === target.href)) {
632
+ continue;
633
+ }
634
+
635
+ if (candidates.length === 0) {
636
+ // Preserve the actionable diagnostic for a malformed historical entry
637
+ // whose key is the literal fragment. Opaque heading keys are found by
638
+ // valid href values and never need to be decoded here.
639
+ const legacyEntry = loaded.index[target.fragment];
640
+ if (
641
+ Object.prototype.hasOwnProperty.call(loaded.index, target.fragment) &&
642
+ (legacyEntry === null ||
643
+ typeof legacyEntry !== 'object' ||
644
+ typeof legacyEntry.href !== 'string')
645
+ ) {
646
+ fail(
647
+ rel,
648
+ line,
649
+ `<BookLink book="${book}" to="${toAttr.value}"> — ${loaded.configuredPath} entry ` +
650
+ `"${target.fragment}" has no string href; re-vendor a valid sibling labels.json.`,
651
+ );
652
+ continue;
653
+ }
654
+ fail(
655
+ rel,
656
+ line,
657
+ `<BookLink book="${book}" to="${toAttr.value}"> — fragment "${target.fragment}" is not in ` +
658
+ `${loaded.configuredPath}. Vendor the current sibling labels.json, or fix the target id.`,
659
+ );
660
+ continue;
661
+ }
662
+
663
+ const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
664
+ fail(
665
+ rel,
666
+ line,
667
+ `<BookLink book="${book}" to="${toAttr.value}"> — path/fragment does not match ` +
668
+ `${loaded.configuredPath}, which indexes "${target.fragment}" at ${indexedHrefs}. ` +
669
+ 'Fix to= or refresh the vendored index.',
670
+ );
461
671
  }
462
672
 
463
673
  // <Rationale appendix> in CHAPTER bodies (v4.21.0, #114): same missing-for=