@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.
@@ -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, 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).
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 markdown links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
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 (regex-based; cheap, good enough for MDX) =====
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: 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;
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
- // #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
- );
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 markdown links resolve
372
- for (const m of content.matchAll(RE_MD_LINK)) {
373
- const target = m[1].replace(/\/$/, '');
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 (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="…".`);
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 (siblingBookKeys && !siblingBookKeys.has(bookMatch[1])) {
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
- lineOf(content, m.index),
458
- `<BookLink book="${bookMatch[1]}"> — not in defineBookConfig siblingBooks (${[...siblingBookKeys].join(', ') || 'none'}). Register it or fix the key.`,
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));
@@ -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 a `chapters` collection's `loader.base` string. Permissive
156
- // form: match the `chapters` identifier, then within the next 500
157
- // chars find `base: 'string'` or `base: "string"`. NOT template
158
- // literals (which use backticks and may contain ${} interpolation —
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 re = /\bchapters\b[\s\S]{0,500}?\bbase\s*:\s*'([^']+)'|\bchapters\b[\s\S]{0,500}?\bbase\s*:\s*"([^"]+)"/;
166
- const m = source.match(re);
167
- const captured = m && (m[1] || m[2]);
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
  }