@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.
package/CLAUDE.md CHANGED
@@ -108,7 +108,9 @@ Two callout families coexist. Authors import what they need.
108
108
 
109
109
  **Pedagogy family** (v4.1.0+, any profile, 4 components): `Pitfall` (rose; "common mistake" — distinct from `WarnBox`'s preemptive warning), `WorkedExample` (plum; collapsible `<details>` block with `#worked-example-{id}` anchor for deep links), `YouWillLearn` (gold; chapter-opener with optional `prerequisites` prop), `Diagnostic` (v4.19.0, #110; teal; pre-reading "Do I Know This Already?" DIKTA self-check — a slotted question list + a skip/skim/read routing rubric via `skimTo`, plus an optional collapsible answer key via `slot="answers"`). Slot bullets/code freely; render at any preset.
110
110
 
111
- **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginFigure`, `MarginNote`, `Sidenote`, `EvidenceTag`, `Newthought`, `Epigraph`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
111
+ **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginFigure`, `MarginNote`, `Sidenote`, `EvidenceTag`, `Newthought`, `Epigraph`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; #147 adds `{ url, labels }` entries so `validate` checks literal fragment targets against a vendored sibling `labels.json`, while legacy URL strings warn/skip target validation; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
112
+
113
+ **Cross-book heading indexes (#147):** `book-scaffold build-labels` indexes h2–h6 with Astro's heading collector (inline formatting/smartypants plus GitHub duplicate slugs), alongside the historical component IDs. Emitted hrefs are base-less and resolve the evaluated `chapterRoute` / `bookField`; nested content IDs keep their directory. Heading keys are opaque and path-qualified so the same fragment can exist in multiple chapters; `validate` matches exact normalized href values, while component keys stay backward-compatible for XRef. This makes a generated sibling `labels.json` usable for literal `<BookLink ... to="...#anchor">` validation without assuming `/chapters/`. h1 stays excluded as the chapter title.
112
114
 
113
115
  **Interactive demo substrate (#143; opt-in):** `DemoFrame`, `Slider`, `StatCards`, and `useThemeColors` are named exports from `@brandon_m_behring/book-scaffold-astro/demo`. Import `@brandon_m_behring/book-scaffold-astro/styles/demo.css` on the page that mounts the consumer-owned Preact island; it is never included by a profile and nothing auto-mounts. The substrate owns figure/label/metric semantics, focus/reduced-motion styling, SVG token helpers, and theme-token resolution. Consumers own all data, kernels, charts, and domain interaction policy. See Recipe 23.
114
116
 
@@ -11,8 +11,9 @@
11
11
  * build (fail-loud, see `src/lib/book-link`), never a dead cross-origin link.
12
12
  *
13
13
  * `to` is a path within the sibling book (its router shape), e.g.
14
- * `chapters/<slug>/#<id>`. Phase 1 builds the href; Phase 2 (deferred) will
15
- * validate `to` against a vendored sibling `labels.json`.
14
+ * `chapters/<slug>/#<id>`. A `{ url, labels }` registry descriptor lets
15
+ * `book-scaffold validate` check literal path/fragment targets against a
16
+ * vendored sibling `labels.json`; URL strings remain runtime-compatible.
16
17
  *
17
18
  * Usage:
18
19
  * For design depth see
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { AstroUserConfig, AstroIntegration, MarkdownHeading } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, o as Style } from './types-CZrkqzpC.js';
3
- export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, N as NUMBER_STYLES, j as NumberStyle, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ReleaseStatusConfig, m as RouteToggles, S as SecurityHeadersConfig, n as StatusBadge, p as StyleInput, V as VolatilityBadge, q as composeStyles, r as defineProfile, s as defineStyle, t as normalizeFrontmatterConfig, u as resolvePreset, v as resolveProfile } from './types-CZrkqzpC.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, h as ChaptersRenderer, p as SiblingBooks, r as Style } from './types-DgSlAew3.js';
3
+ export { B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, F as FreshnessAffordance, i as FrontmatterRouteConfig, N as NUMBER_STYLES, j as NumberStyle, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, R as ReleaseStatusConfig, m as RouteToggles, S as SecurityHeadersConfig, n as SiblingBookDescriptor, o as SiblingBookEntry, q as StatusBadge, s as StyleInput, V as VolatilityBadge, t as composeStyles, u as defineProfile, v as defineStyle, w as normalizeFrontmatterConfig, x as resolvePreset, y as resolveProfile } from './types-DgSlAew3.js';
4
4
  import { E as volatilityLevels, c as academicParts, Q as Question } from './schemas-CKipJ5Ie.js';
5
5
  export { A as AcademicChapter, B as BloomLevel, C as CourseNotesChapter, G as GlossaryTerm, M as MinimalChapter, P as Provenance, a as QuestionType, R as ResearchPortfolioChapter, T as ToolsChapter, b as academicChapterSchema, d as bloomLevels, e as changeKinds, f as changelogSchema, g as chapterStatus, h as citationBackstops, i as courseNotesChapterSchema, j as glossarySchema, l as layoutModes, m as minimalChapterSchema, p as patternCategories, k as patternsSchema, n as provenanceObject, o as provenanceSchema, q as questionDifficulties, r as questionSchema, s as questionTypes, t as refineQuestion, u as refinedQuestionSchema, v as researchPortfolioChapterSchema, w as sourceTiers, x as sourceTiersResearch, y as sourcesSchema, z as toolSlugs, D as toolsChapterSchema } from './schemas-CKipJ5Ie.js';
6
6
  export { KIND_LABEL, ResolvedTheoremLabel, THEOREM_KINDS, TheoremKind, TheoremLabelProps, resolveTheoremNumber, theoremLabel } from './lib/theorem-label.js';
7
7
  export { ssmMacros } from './lib/katex-macros.js';
8
+ export { ChapterLike, apparatusHref, baseNoSlash, bookOf, chapterHref, isCurrentChapter, normalizeBase, slugOf } from './lib/nav-href.js';
8
9
  export { D as DomainScore, E as ExamBlueprint, a as ExamQuestion, b as ExamResult, R as RoutingChapter, c as buildExamManifest, d as deriveDomainRouting, s as sampleExam, e as scoreExam, f as shuffle, g as spreadBlueprint } from './exam-manifest-X9IrX1G3.js';
9
10
  export { F as FlashcardRef, b as buildFlashcardDeck } from './flashcards-okekZcl8.js';
10
11
  import 'astro/zod';
@@ -303,75 +304,12 @@ declare function assertEnumProp<T extends string>(value: unknown, allowed: reado
303
304
  * sibling redeploys or extracts to its own repo. An unknown `book` THROWS
304
305
  * rather than emitting a dead cross-origin link (fail-loud, like #109).
305
306
  *
306
- * Phase 1 (this): registry-backed href + fail-loud on unknown book. Phase 2
307
- * (deferred): validate the `to` id against a vendored sibling `labels.json`.
307
+ * #147 extends each registry entry from a URL string to the backward-compatible
308
+ * `{ url, labels? }` descriptor. Runtime href resolution uses `url`; the
309
+ * validator uses `labels` to check literal sibling path/fragment targets.
308
310
  */
309
- declare function resolveBookHref(siblingBooks: Record<string, string> | null | undefined, book: string, to: string): string;
310
311
 
311
- /**
312
- * src/lib/nav-href.ts — pure route-href resolver (#80 multi-book navigation).
313
- *
314
- * No `astro:content` import (mirrors chapter-sort.ts) so tsup can include it in
315
- * the DTS bundle without dragging Astro virtual modules into the build graph.
316
- * The nav components (Sidebar, ChapterNav, NavContent, …) call these helpers
317
- * instead of hardcoding the single-book `/chapters/<id>/` URL shape — so ONE set
318
- * of components serves both a single-book site (the default pattern) and a
319
- * multi-book consumer whose chapters render at `/<book>/<slug>/`.
320
- *
321
- * Patterns are base-relative TOKEN STRINGS (a resolver *function* could not
322
- * survive the book-config virtual module's `JSON.stringify`, so the config
323
- * surface is a declarative string resolved here):
324
- * :id → entry.id verbatim, slashes preserved e.g. 'kg/01-intro'
325
- * :book → the entry's book name (see `bookField`), or '' e.g. 'kg'
326
- * :slug → entry.id with a leading '<book>/' stripped e.g. '01-intro'
327
- * BASE_URL is applied here, so patterns are written base-relative (lead '/').
328
- *
329
- * Defaults reproduce the single-book behavior BYTE-FOR-BYTE:
330
- * chapterRoute = '/chapters/:id/' → `${base}chapters/${id}/`
331
- * bookField = 'book' (academic/tools schemas have no `book` → bookOf
332
- * returns null → "show all chapters", today's nav)
333
- * apparatusRoute = '/:route/' → `${base}<route>/`
334
- */
335
- /** Minimal shape the resolver needs from a chapter collection entry. */
336
- interface ChapterLike {
337
- id: string;
338
- data: Record<string, unknown>;
339
- }
340
- /**
341
- * Normalize a base URL to exactly one trailing slash (`''`/`undefined` → `'/'`).
342
- *
343
- * v4.27.0 (#182): promoted from this module's private helper to THE shared
344
- * normalizer — 18 `.astro` files previously inlined the same normalization in
345
- * three regex idioms. Astro does not guarantee a trailing slash on `base`
346
- * (`'/foo'` is a documented form), so every `import.meta.env.BASE_URL` read
347
- * must pass through here before href composition: `${normalizeBase(...)}chapters/`.
348
- * Takes the base as a PARAMETER because src/lib ships pre-compiled in dist/,
349
- * where Vite's import.meta.env replacement cannot reach (see exam-manifest.ts).
350
- */
351
- declare function normalizeBase(baseUrl: string | undefined): string;
352
- /**
353
- * The composing variant (#182): strip ALL trailing slashes (`'/'` → `''`,
354
- * `'/foo/'` → `'/foo'`) for `${baseNoSlash(...)}/answers`-style templates
355
- * where the literal supplies the slash (Rationale.astro's route detection).
356
- */
357
- declare function baseNoSlash(baseUrl: string | undefined): string;
358
- /**
359
- * The entry's book name from `data[bookField]`, or `null` when absent/blank.
360
- * Single-book schemas (academic/tools/minimal) have no such field → `null`,
361
- * which callers read as "this is the only book — show every chapter".
362
- */
363
- declare function bookOf(entry: ChapterLike, bookField?: string): string | null;
364
- /** `entry.id` with a leading `'<book>/'` stripped (multi-book) or unchanged. */
365
- declare function slugOf(entry: ChapterLike, bookField?: string): string;
366
- /** Resolve a chapter entry to a base-prefixed href via the `chapterRoute` pattern. */
367
- declare function chapterHref(entry: ChapterLike, pattern?: string, baseUrl?: string, bookField?: string): string;
368
- /**
369
- * Resolve a per-book apparatus route (glossary / practice-exam / flashcards /
370
- * answers) to a base-prefixed href via the `apparatusRoute` pattern.
371
- */
372
- declare function apparatusHref(route: string, book: string | null, pattern?: string, baseUrl?: string): string;
373
- /** Whether `entry` is the page at `currentPath` (trailing-slash tolerant). */
374
- declare function isCurrentChapter(entry: ChapterLike, currentPath: string, pattern?: string, baseUrl?: string, bookField?: string): boolean;
312
+ declare function resolveBookHref(siblingBooks: SiblingBooks | null | undefined, book: string, to: string): string;
375
313
 
376
314
  /**
377
315
  * exam-domains — validate a question's `domain` against the consumer's closed
@@ -629,4 +567,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
629
567
  */
630
568
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
631
569
 
632
- export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, type ChapterLike, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, type PartReviewGroup, type PartReviewSelection, Question, type ReviewChapter, type ReviewExercise, Style, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VisibleHeading, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, apparatusHref, assertEnumProp, assertKnownDomain, baseNoSlash, bookOf, bookScaffoldIntegration, buildGithubUrl, chapterHref, chapterLabel, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, deriveObjectiveMap, distinctChaptersSorted, fallbackChaptersRenderer, freshnessLabel, getFreshness, groupByChapter, groupByDomain, isCurrentChapter, minimalStyle, normalizeBase, originUrlFromGitConfig, parseRepoSlug, pickActive, researchPortfolioChaptersRenderer, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, selectPartExercises, slugOf, sortQuestions, tocHeadings, toolsChaptersRenderer, toolsStyle, volatilityLevels };
570
+ export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, type PartReviewGroup, type PartReviewSelection, Question, type ReviewChapter, type ReviewExercise, SiblingBooks, Style, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VisibleHeading, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, assertEnumProp, assertKnownDomain, bookScaffoldIntegration, buildGithubUrl, chapterLabel, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, deriveObjectiveMap, distinctChaptersSorted, fallbackChaptersRenderer, freshnessLabel, getFreshness, groupByChapter, groupByDomain, minimalStyle, originUrlFromGitConfig, parseRepoSlug, pickActive, researchPortfolioChaptersRenderer, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, selectPartExercises, sortQuestions, tocHeadings, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -1379,7 +1379,13 @@ function bookScaffoldIntegration(opts) {
1379
1379
  }
1380
1380
  };
1381
1381
  Object.defineProperty(integration, "__bookScaffoldResolvedConfig", {
1382
- value: Object.freeze({ preset: profile, numberStyle }),
1382
+ value: Object.freeze({
1383
+ preset: profile,
1384
+ numberStyle,
1385
+ siblingBooks: siblingBooks ?? {},
1386
+ chapterRoute: chapterRoute ?? "/chapters/:id/",
1387
+ bookField: bookField ?? "book"
1388
+ }),
1383
1389
  enumerable: false,
1384
1390
  configurable: false,
1385
1391
  writable: false
@@ -1528,7 +1534,7 @@ async function defineBookConfig(opts) {
1528
1534
  // v4.15.0 (#109): repo/branch override; integration auto-detects when undefined.
1529
1535
  githubRepo: opts.githubRepo,
1530
1536
  githubBranch: opts.githubBranch,
1531
- // v4.16.0 (#96): cross-book link registry.
1537
+ // v4.16.0 (#96), extended in #147: cross-book link registry.
1532
1538
  siblingBooks: opts.siblingBooks,
1533
1539
  // v4.17.0 (#112): per-book exam-domain taxonomy for the questions collection.
1534
1540
  examDomains: opts.examDomains,
@@ -1721,14 +1727,25 @@ function assertEnumProp(value, allowed, ctx) {
1721
1727
  init_katex_macros();
1722
1728
 
1723
1729
  // src/lib/book-link.ts
1730
+ function entryUrl(entry) {
1731
+ if (typeof entry === "string") return entry;
1732
+ return entry?.url;
1733
+ }
1724
1734
  function resolveBookHref(siblingBooks, book, to) {
1725
- const base = siblingBooks?.[book];
1726
- if (!base) {
1735
+ const registered = siblingBooks !== null && siblingBooks !== void 0 && Object.prototype.hasOwnProperty.call(siblingBooks, book);
1736
+ if (!registered) {
1727
1737
  const known = siblingBooks ? Object.keys(siblingBooks) : [];
1728
1738
  throw new Error(
1729
1739
  `<BookLink book="${book}">: unknown sibling book. Register it in defineBookConfig({ siblingBooks: { "${book}": "https://\u2026" } })` + (known.length ? ` (known: ${known.join(", ")})` : "") + "."
1730
1740
  );
1731
1741
  }
1742
+ const entry = siblingBooks[book];
1743
+ const base = entryUrl(entry);
1744
+ if (typeof base !== "string" || base.length === 0) {
1745
+ throw new Error(
1746
+ `<BookLink book="${book}">: invalid siblingBooks entry. Expected a URL string or { url: "https://\u2026", labels?: "./path/to/labels.json" }.`
1747
+ );
1748
+ }
1732
1749
  return `${base.replace(/\/+$/, "")}/${to.replace(/^\/+/, "")}`;
1733
1750
  }
1734
1751
 
@@ -0,0 +1,66 @@
1
+ /**
2
+ * src/lib/nav-href.ts — pure route-href resolver (#80 multi-book navigation).
3
+ *
4
+ * No `astro:content` import (mirrors chapter-sort.ts) so tsup can include it in
5
+ * the DTS bundle without dragging Astro virtual modules into the build graph.
6
+ * The nav components (Sidebar, ChapterNav, NavContent, …) call these helpers
7
+ * instead of hardcoding the single-book `/chapters/<id>/` URL shape — so ONE set
8
+ * of components serves both a single-book site (the default pattern) and a
9
+ * multi-book consumer whose chapters render at `/<book>/<slug>/`.
10
+ *
11
+ * Patterns are base-relative TOKEN STRINGS (a resolver *function* could not
12
+ * survive the book-config virtual module's `JSON.stringify`, so the config
13
+ * surface is a declarative string resolved here):
14
+ * :id → entry.id verbatim, slashes preserved e.g. 'kg/01-intro'
15
+ * :book → the entry's book name (see `bookField`), or '' e.g. 'kg'
16
+ * :slug → entry.id with a leading '<book>/' stripped e.g. '01-intro'
17
+ * BASE_URL is applied here, so patterns are written base-relative (lead '/').
18
+ *
19
+ * Defaults reproduce the single-book behavior BYTE-FOR-BYTE:
20
+ * chapterRoute = '/chapters/:id/' → `${base}chapters/${id}/`
21
+ * bookField = 'book' (academic/tools schemas have no `book` → bookOf
22
+ * returns null → "show all chapters", today's nav)
23
+ * apparatusRoute = '/:route/' → `${base}<route>/`
24
+ */
25
+ /** Minimal shape the resolver needs from a chapter collection entry. */
26
+ interface ChapterLike {
27
+ id: string;
28
+ data: Record<string, unknown>;
29
+ }
30
+ /**
31
+ * Normalize a base URL to exactly one trailing slash (`''`/`undefined` → `'/'`).
32
+ *
33
+ * v4.27.0 (#182): promoted from this module's private helper to THE shared
34
+ * normalizer — 18 `.astro` files previously inlined the same normalization in
35
+ * three regex idioms. Astro does not guarantee a trailing slash on `base`
36
+ * (`'/foo'` is a documented form), so every `import.meta.env.BASE_URL` read
37
+ * must pass through here before href composition: `${normalizeBase(...)}chapters/`.
38
+ * Takes the base as a PARAMETER because src/lib ships pre-compiled in dist/,
39
+ * where Vite's import.meta.env replacement cannot reach (see exam-manifest.ts).
40
+ */
41
+ declare function normalizeBase(baseUrl: string | undefined): string;
42
+ /**
43
+ * The composing variant (#182): strip ALL trailing slashes (`'/'` → `''`,
44
+ * `'/foo/'` → `'/foo'`) for `${baseNoSlash(...)}/answers`-style templates
45
+ * where the literal supplies the slash (Rationale.astro's route detection).
46
+ */
47
+ declare function baseNoSlash(baseUrl: string | undefined): string;
48
+ /**
49
+ * The entry's book name from `data[bookField]`, or `null` when absent/blank.
50
+ * Single-book schemas (academic/tools/minimal) have no such field → `null`,
51
+ * which callers read as "this is the only book — show every chapter".
52
+ */
53
+ declare function bookOf(entry: ChapterLike, bookField?: string): string | null;
54
+ /** `entry.id` with a leading `'<book>/'` stripped (multi-book) or unchanged. */
55
+ declare function slugOf(entry: ChapterLike, bookField?: string): string;
56
+ /** Resolve a chapter entry to a base-prefixed href via the `chapterRoute` pattern. */
57
+ declare function chapterHref(entry: ChapterLike, pattern?: string, baseUrl?: string, bookField?: string): string;
58
+ /**
59
+ * Resolve a per-book apparatus route (glossary / practice-exam / flashcards /
60
+ * answers) to a base-prefixed href via the `apparatusRoute` pattern.
61
+ */
62
+ declare function apparatusHref(route: string, book: string | null, pattern?: string, baseUrl?: string): string;
63
+ /** Whether `entry` is the page at `currentPath` (trailing-slash tolerant). */
64
+ declare function isCurrentChapter(entry: ChapterLike, currentPath: string, pattern?: string, baseUrl?: string, bookField?: string): boolean;
65
+
66
+ export { type ChapterLike, apparatusHref, baseNoSlash, bookOf, chapterHref, isCurrentChapter, normalizeBase, slugOf };
@@ -0,0 +1,44 @@
1
+ // src/lib/nav-href.ts
2
+ function normalizeBase(baseUrl) {
3
+ return (baseUrl || "/").replace(/\/*$/, "/");
4
+ }
5
+ function baseNoSlash(baseUrl) {
6
+ return (baseUrl || "/").replace(/\/+$/, "");
7
+ }
8
+ var normBase = normalizeBase;
9
+ function fillTokens(pattern, tokens) {
10
+ return pattern.replace(/:(book|slug|route|id)\b/g, (_m, k) => tokens[k] ?? "");
11
+ }
12
+ function bookOf(entry, bookField = "book") {
13
+ const v = entry.data[bookField];
14
+ return typeof v === "string" && v.length > 0 ? v : null;
15
+ }
16
+ function slugOf(entry, bookField = "book") {
17
+ const book = bookOf(entry, bookField);
18
+ return book && entry.id.startsWith(`${book}/`) ? entry.id.slice(book.length + 1) : entry.id;
19
+ }
20
+ function chapterHref(entry, pattern = "/chapters/:id/", baseUrl = "/", bookField = "book") {
21
+ const path = fillTokens(pattern, {
22
+ id: entry.id,
23
+ book: bookOf(entry, bookField) ?? "",
24
+ slug: slugOf(entry, bookField)
25
+ }).replace(/\/{2,}/g, "/").replace(/^\//, "");
26
+ return normBase(baseUrl) + path;
27
+ }
28
+ function apparatusHref(route, book, pattern = "/:route/", baseUrl = "/") {
29
+ const path = fillTokens(pattern, { route, book: book ?? "" }).replace(/\/{2,}/g, "/").replace(/^\//, "");
30
+ return normBase(baseUrl) + path;
31
+ }
32
+ function isCurrentChapter(entry, currentPath, pattern = "/chapters/:id/", baseUrl = "/", bookField = "book") {
33
+ const href = chapterHref(entry, pattern, baseUrl, bookField);
34
+ return currentPath === href || currentPath === href.replace(/\/$/, "");
35
+ }
36
+ export {
37
+ apparatusHref,
38
+ baseNoSlash,
39
+ bookOf,
40
+ chapterHref,
41
+ isCurrentChapter,
42
+ normalizeBase,
43
+ slugOf
44
+ };
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-CZrkqzpC.js';
2
+ import { g as BookSchemasOptions } from './types-DgSlAew3.js';
3
3
  import 'astro';
4
4
  import './schemas-CKipJ5Ie.js';
5
5
  import 'astro/zod';
@@ -487,6 +487,22 @@ declare const BOOK_PRESETS: readonly ("academic" | "tools" | "minimal" | "course
487
487
  /** Theorem-family numbering strategy used by build-labels and validate. */
488
488
  type NumberStyle = 'shared' | 'per-kind';
489
489
  declare const NUMBER_STYLES: readonly ["shared", "per-kind"];
490
+ /**
491
+ * A sibling book with an optional vendored label index for cross-book
492
+ * `<BookLink>` validation (#147).
493
+ *
494
+ * `url` is the deployed book base, including any path prefix. `labels` is a
495
+ * path to that sibling's vendored `labels.json`, resolved from the consumer
496
+ * project root by `book-scaffold validate`.
497
+ */
498
+ interface SiblingBookDescriptor {
499
+ url: string;
500
+ labels?: string;
501
+ }
502
+ /** Backward-compatible sibling-book registry entry. */
503
+ type SiblingBookEntry = string | SiblingBookDescriptor;
504
+ /** Registry consumed by `<BookLink>` and the pre-flight validator. */
505
+ type SiblingBooks = Record<string, SiblingBookEntry>;
490
506
  /**
491
507
  * v4.27.0 (#188): build-time security-header customization.
492
508
  *
@@ -738,12 +754,20 @@ interface BookConfigOptions {
738
754
  /** v4.15.0 (#109): branch for CodeRef/CodeBlock links. Defaults to `main`. */
739
755
  githubBranch?: string;
740
756
  /**
741
- * v4.16.0 (#96): registry of sibling-book base URLs for cross-book
742
- * `<BookLink book="…" to="…" />`. Maps a book key (used as `book=`) to its
743
- * deployed origin the single place to update when a sibling redeploys.
744
- * @example siblingBooks: { design: 'https://design.example' }
757
+ * v4.16.0 (#96), extended in #147: registry for cross-book
758
+ * `<BookLink book="…" to="…" />`. A string value remains supported. The
759
+ * descriptor form adds a vendored sibling labels index so literal fragment
760
+ * targets can be checked by `book-scaffold validate`.
761
+ *
762
+ * `url` may include a deployment path prefix. Relative `labels` paths are
763
+ * resolved from the consumer project root.
764
+ * @example
765
+ * siblingBooks: {
766
+ * legacy: 'https://legacy.example',
767
+ * design: { url: 'https://hub.example/books/design/', labels: './vendor/design-labels.json' },
768
+ * }
745
769
  */
746
- siblingBooks?: Record<string, string>;
770
+ siblingBooks?: SiblingBooks;
747
771
  /**
748
772
  * v4.17.0 (Tier 3, #112): closed exam-domain taxonomy for the study-guide
749
773
  * `questions` collection. A question whose `domain` is not in this list
@@ -861,8 +885,8 @@ interface BookScaffoldIntegrationOptions {
861
885
  */
862
886
  githubRepo?: string;
863
887
  githubBranch?: string;
864
- /** v4.16.0 (#96): sibling-book base-URL registry for <BookLink>. */
865
- siblingBooks?: Record<string, string>;
888
+ /** v4.16.0 (#96), extended in #147: sibling-book registry for <BookLink>. */
889
+ siblingBooks?: SiblingBooks;
866
890
  /** v4.17.0 (#112): closed exam-domain taxonomy for the questions collection. */
867
891
  examDomains?: readonly string[];
868
892
  /** v4.26.0 (#80): chapter-route token pattern, propagated via the book-config
@@ -887,4 +911,4 @@ declare function resolvePreset(explicitPreset?: BookPreset, explicitProfile?: Bo
887
911
  */
888
912
  declare function resolveProfile(explicit?: BookProfile): BookProfile;
889
913
 
890
- export { BOOK_PRESETS as B, type ChapterFor as C, type FreshnessAffordance as F, NUMBER_STYLES as N, type PartKey as P, type ReleaseStatusConfig as R, type SecurityHeadersConfig as S, type VolatilityBadge as V, BOOK_PROFILES as a, BookConfigError as b, type BookConfigOptions as c, type BookPreset as d, type BookProfile as e, type BookScaffoldIntegrationOptions as f, type BookSchemasOptions as g, type ChaptersRenderer as h, type FrontmatterRouteConfig as i, type NumberStyle as j, type PartialRouteToggles as k, type ProfileDefinition as l, type RouteToggles as m, type StatusBadge as n, type Style as o, type StyleInput as p, composeStyles as q, defineProfile as r, defineStyle as s, normalizeFrontmatterConfig as t, resolvePreset as u, resolveProfile as v };
914
+ export { BOOK_PRESETS as B, type ChapterFor as C, type FreshnessAffordance as F, NUMBER_STYLES as N, type PartKey as P, type ReleaseStatusConfig as R, type SecurityHeadersConfig as S, type VolatilityBadge as V, BOOK_PROFILES as a, BookConfigError as b, type BookConfigOptions as c, type BookPreset as d, type BookProfile as e, type BookScaffoldIntegrationOptions as f, type BookSchemasOptions as g, type ChaptersRenderer as h, type FrontmatterRouteConfig as i, type NumberStyle as j, type PartialRouteToggles as k, type ProfileDefinition as l, type RouteToggles as m, type SiblingBookDescriptor as n, type SiblingBookEntry as o, type SiblingBooks as p, type StatusBadge as q, type Style as r, type StyleInput as s, composeStyles as t, defineProfile as u, defineStyle as v, normalizeFrontmatterConfig as w, resolvePreset as x, resolveProfile as y };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books with five typed presets, Tufte typography, citations, search, PDF, and Cloudflare deployment.",
4
- "version": "4.28.0",
4
+ "version": "4.29.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -196,12 +196,16 @@
196
196
  }
197
197
  },
198
198
  "dependencies": {
199
+ "@astrojs/markdown-remark": "^7.1.2",
199
200
  "@astrojs/sitemap": "^3.6.1",
200
201
  "@citation-js/core": "^0.7.21",
201
202
  "@citation-js/plugin-bibtex": "^0.7.21",
202
203
  "@fontsource-variable/roboto": "^5.2.10",
203
204
  "@fontsource-variable/source-code-pro": "^5.2.7",
204
205
  "pagefind": "^1.5.2",
206
+ "remark-mdx": "^3.1.1",
207
+ "remark-parse": "^11.0.0",
208
+ "unified": "^11.0.5",
205
209
  "vite": "^7.3.2",
206
210
  "yaml": "^2.9.0"
207
211
  },
@@ -14,12 +14,40 @@
14
14
  | `[text](/internal-link)` resolves to known chapter slug or top-level route | all | Astro won't fail on dead internal links. Warning, not error (regex misses dynamic routes). |
15
15
  | `<CodeRef path="..." line={N} />` path exists + line in bounds | all, if `BOOK_REPO_ROOT` set | Catches stale line numbers after code refactors in the paired experiments/ repo. |
16
16
  | `<Theorem>` has a resolvable `kind=` (or legacy `type=`); an id'd theorem resolves in `labels.json`; a literal `n=` agrees with the index (#121, #126, #176) | all | An absent kind throws at build with less context; an unindexed id silently renders the heading unnumbered; a stale literal `n=` contradicts the heading/XRefs. Dynamic expressions and `label=` overrides are skipped. |
17
- | `<BookLink book= to=>` both present; `book=` registered in `siblingBooks` (#96) | all | Pre-flights the component's build-time throw across all files at once. |
17
+ | `<BookLink book= to=>` both present; `book=` registered; literal fragment target resolves in a declared sibling labels index (#96, #147) | all | Pre-flights the component's build-time throw and dead cross-book anchors across all files at once. |
18
18
  | Questions collection: unique `id`s + `domain` in `examDomains` (#112); `<Rationale appendix>` carries `for=` (#114, v4.21.0) | all, when `src/content/questions/` exists | Duplicate ids break the appendix/flashcards cross-ref key; an unregistered domain throws one-at-a-time at build; an appendix rationale without its anchor target throws at build. |
19
19
  | `los[].anchor` ↔ `{/* anchor: <slug> */}` prose markers agree both ways (#130, v4.20.0) | all, when frontmatter has `los:` | A declared objective whose prose marker is missing/misspelled (or an orphan marker) builds green otherwise — frontmatter↔prose drift only a hand audit would catch. |
20
20
 
21
21
  Validate also emits two **non-blocking shadow-route warnings** (exit code unaffected): a consumer-owned `src/pages/chapters/[...slug].astro` without `routes: { chapters: false }` (v4.6.0, #76), and a consumer-owned `src/pages/index.astro` without `routes: { landing: false }` (v4.20.0, #129 — Astro has announced this collision becomes a hard error). See [recipe 18](./18-chapter-route-ownership.md).
22
22
 
23
+ ### Cross-book labels indexes (#147)
24
+
25
+ The original URL-string registry remains compatible. Use the descriptor form to opt a sibling into literal path/fragment validation:
26
+
27
+ ```ts
28
+ export default await defineBookConfig({
29
+ styles: [minimalStyle],
30
+ site: 'https://docs.example/current/',
31
+ siblingBooks: {
32
+ legacy: 'https://legacy.example/',
33
+ design: {
34
+ // Deployment bases may be path-proxied; BookLink preserves this prefix.
35
+ url: 'https://hub.example/library/design/',
36
+ // Relative paths resolve from this consumer project's root.
37
+ labels: './vendor/design-labels.json',
38
+ },
39
+ },
40
+ });
41
+ ```
42
+
43
+ For a literal `<BookLink book="design" to="/chapters/patterns/#layered">`, validation looks up `layered` and verifies that its indexed `href` matches the authored sibling path. Leading route slashes and the optional slash immediately before `#` are normalized; the sibling deployment base is deliberately not part of the vendored index.
44
+
45
+ Generate the sibling artifact in that sibling's project with `book-scaffold build-labels`, then vendor or sync the resulting `src/data/labels.json`. The builder indexes Markdown/MDX h2–h6 headings in addition to component IDs. Heading text and anchors come from Astro's Markdown processor, so inline formatting, smartypants, and GitHub-style duplicate suffixes (`repeat`, `repeat-1`, …) match the rendered page. Its base-less hrefs also use the sibling's evaluated `chapterRoute` and `bookField`; a root-routed book configured with `chapterRoute: '/:id/'` therefore emits `chapter-id#anchor`, not `chapters/chapter-id#anchor`.
46
+
47
+ Heading-entry keys are intentionally opaque and path-qualified: `#summary` can legitimately occur in every chapter. Validation matches the normalized `href` values, so every repeated fragment remains addressable. Component-ID keys keep their historical shape for `<XRef>`; do not use a heading's internal JSON key as an XRef ID or derive it in consumer code.
48
+
49
+ A declared `labels` file that is missing, unreadable, malformed, or not a JSON object is an error. An unknown book, unknown fragment, wrong path, or malformed label entry is also an error with the source file and target. Dynamic `book=`/`to=` expressions and prop spreads cannot be evaluated safely, so they emit explicit skip warnings. URL strings and `{ url }` descriptors likewise warn for literal fragment links because no index was declared. Non-fragment route links warn and skip: labels indexes describe anchors, not every sibling route.
50
+
23
51
  ## Missing generated artifacts
24
52
 
25
53
  `src/data/labels.json` and `src/data/references.json` are derived and normally gitignored. v4.27+ `validate` self-heals either missing file unconditionally by invoking the package's own `build-labels` or `build-bib` command before loading data. Existing files are untouched. A failed child command stops validation with the child's original diagnostic and exit status.
@@ -101,7 +129,7 @@ validate: ✗ 17 error(s) in 6 chapter(s) (profile=academic, number-style=shared
101
129
  ...
102
130
  ```
103
131
 
104
- Warnings (currently: internal-link unresolved) are printed to stderr but don't affect exit code.
132
+ Warnings (internal-link uncertainty plus explicit BookLink skip cases) are printed to stderr but don't affect exit code.
105
133
 
106
134
  ## Extending the validator
107
135
 
@@ -83,6 +83,11 @@ import { chapterHref, apparatusHref, bookOf, isCurrentChapter }
83
83
  Pure functions, no `astro:content` — pass a `{ id, data }` and the same
84
84
  `chapterRoute` / `bookField` you set in `defineBookConfig`.
85
85
 
86
+ `book-scaffold build-labels` evaluates those same two fields and routes every
87
+ component and h2–h6 heading entry through `chapterHref`. A root- or multi-book
88
+ site's vendored cross-book index therefore describes its real routes rather
89
+ than silently falling back to `/chapters/<id>/`.
90
+
86
91
  ## Verify
87
92
 
88
93
  A consumer should drive a cross-device audit (Playwright is ideal): for each rich