@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 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
 
@@ -199,6 +201,7 @@ the full precedence and customization contract.
199
201
  - Literal `<Theorem n>` values that disagree with `labels.json` (dynamic expressions and `label=` overrides are skipped)
200
202
  - Missing `<Figure src>` files under `public/`
201
203
  - Internal markdown links that don't resolve
204
+ - Authored root-absolute Markdown/HTML/JSX `href` or `src` targets that escape a configured non-root Astro `base` (#190). Structurally parsed and decoded literal targets fail with file/line context; root-base books, external/protocol-relative URLs, fragments, dynamic JSX expressions, and already base-prefixed targets are unaffected. `rel="external"` does not change URL resolution or opt out. Validation never rewrites prose.
202
205
  - Study-guide questions (v4.17.0, #112) — a question whose frontmatter `domain` isn't in `examDomains`, and duplicate question `id`s
203
206
 
204
207
  Missing `src/data/labels.json` or `references.json` self-heals before checks by running the corresponding package build script; an existing artifact is never rewritten implicitly.
@@ -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.30.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -190,18 +190,21 @@
190
190
  },
191
191
  "rehype-katex": {
192
192
  "optional": true
193
- },
194
- "remark-math": {
195
- "optional": true
196
193
  }
197
194
  },
198
195
  "dependencies": {
196
+ "@astrojs/markdown-remark": "^7.1.2",
199
197
  "@astrojs/sitemap": "^3.6.1",
200
198
  "@citation-js/core": "^0.7.21",
201
199
  "@citation-js/plugin-bibtex": "^0.7.21",
202
200
  "@fontsource-variable/roboto": "^5.2.10",
203
201
  "@fontsource-variable/source-code-pro": "^5.2.7",
204
202
  "pagefind": "^1.5.2",
203
+ "parse5": "^7.3.0",
204
+ "remark-math": "^6.0.0",
205
+ "remark-mdx": "^3.1.1",
206
+ "remark-parse": "^11.0.0",
207
+ "unified": "^11.0.5",
205
208
  "vite": "^7.3.2",
206
209
  "yaml": "^2.9.0"
207
210
  },
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Profile**: any (Cite checks skip under non-academic).
4
4
 
5
- **TL;DR**: `npm run validate` runs `scripts/validate.mjs` against all chapter MDX files. It first regenerates missing `labels.json` and `references.json`, then catches typo'd bibkeys / XRef ids / Figure paths / internal links that `astro build` would either miss or surface with poor context. Auto-runs as `prebuild`; recommend wiring into pre-commit too.
5
+ **TL;DR**: `npm run validate` runs `scripts/validate.mjs` against chapter MD/MDX files (plus its question-collection checks). It first regenerates missing `labels.json` and `references.json`, then catches typo'd bibkeys / XRef ids / Figure paths / internal links that `astro build` would either miss or surface with poor context. Auto-runs as `prebuild`; recommend wiring into pre-commit too.
6
6
 
7
7
  ## What gets checked
8
8
 
@@ -14,12 +14,59 @@
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
+ | Literal authored Markdown/HTML/JSX `href`/`src` stays inside a non-root Astro `base` (#190) | all | A browser resolves `/chapters/...` from the host root, bypassing Astro's deployment base and producing a convincing 404. This is a build-blocking error, not a rewrite. |
20
21
 
21
22
  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
23
 
24
+ ### Cross-book labels indexes (#147)
25
+
26
+ The original URL-string registry remains compatible. Use the descriptor form to opt a sibling into literal path/fragment validation:
27
+
28
+ ```ts
29
+ export default await defineBookConfig({
30
+ styles: [minimalStyle],
31
+ site: 'https://docs.example/current/',
32
+ siblingBooks: {
33
+ legacy: 'https://legacy.example/',
34
+ design: {
35
+ // Deployment bases may be path-proxied; BookLink preserves this prefix.
36
+ url: 'https://hub.example/library/design/',
37
+ // Relative paths resolve from this consumer project's root.
38
+ labels: './vendor/design-labels.json',
39
+ },
40
+ },
41
+ });
42
+ ```
43
+
44
+ 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.
45
+
46
+ 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`.
47
+
48
+ 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.
49
+
50
+ 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.
51
+
52
+ ## Authored links under a non-root base (#190)
53
+
54
+ When evaluated `astro.config.*` sets a non-root base such as `base: '/library/books/'`, a root-absolute authored URL escapes that deployment mount:
55
+
56
+ ```mdx
57
+ <!-- Errors: both resolve from the host root, outside /library/books/. -->
58
+ [Chapter one](/chapters/one/)
59
+ <img src="/figures/overview.svg" alt="Overview" />
60
+
61
+ <!-- Valid: explicitly remains inside the configured base. -->
62
+ [Chapter one](/library/books/chapters/one/)
63
+ <a href={`${import.meta.env.BASE_URL}references/`}>References</a>
64
+ ```
65
+
66
+ The validator structurally parses inline Markdown links and images, Markdown reference definitions, quoted or unquoted HTML `href`/`src`, and quoted or statically braced JSX string attributes. HTML entities and JavaScript string escapes are decoded before containment is checked. It reports the authored file and line, the evaluated target, the evaluated base, and a browser-normalized base-prefixed replacement. It also scans question MD/MDX files already visited by validation.
67
+
68
+ The check is deliberately validation-only: it does not mutate or rewrite content, and there is no opt-out. Under the root base (`/`) it is inert. Protocol URLs (`https:`, `mailto:`, `data:`, and others), protocol-relative URLs (`//cdn…`), fragments, relative targets, targets already inside the base, and dynamic JSX expressions are excluded. `rel="external"` describes a relationship but does not change where a browser navigates, so it does not exempt a host-root URL. Frontmatter, comments, fenced/inline/indented code examples, and `<pre>`/`<code>` examples are excluded by their parsed Markdown/MDX structure so documentation snippets do not become false diagnostics.
69
+
23
70
  ## Missing generated artifacts
24
71
 
25
72
  `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.
@@ -68,7 +115,7 @@ For pre-commit: add to `.pre-commit-config.yaml`:
68
115
 
69
116
  ## Preset / chaptersBase resolution (v4.7.0+, #75)
70
117
 
71
- The validator evaluates `astro.config.*` through Vite first. A resolved scaffold integration supplies the composed preset and `numberStyle`, so CLI tooling sees the same Style chain as the Astro build. Without such an integration, the legacy preset chain remains: `--preset` → process `BOOK_PRESET`/`BOOK_PROFILE` → root `.env` → the literal value in `defineBookSchemas` → a warned `minimal` v4 compatibility fallback. Every selected value is checked against the five-preset enum; config-evaluation failures stop validation instead of silently using defaults.
118
+ The validator evaluates `astro.config.*` through Vite first. A resolved scaffold integration supplies the composed preset and `numberStyle`, so CLI tooling sees the same Style chain as the Astro build. The evaluated Astro `base` is read from that same config regardless of whether the scaffold integration is present; omission defaults to `/`. Without such an integration, the legacy preset chain remains: `--preset` → process `BOOK_PRESET`/`BOOK_PROFILE` → root `.env` → the literal value in `defineBookSchemas` → a warned `minimal` v4 compatibility fallback. Every selected value is checked against the five-preset enum; config-evaluation failures stop validation instead of silently using defaults.
72
119
 
73
120
  `chaptersBase` resolution still consults `BOOK_CHAPTERS_DIR`, content configuration, then `src/content/chapters`. The v4.5+ canonical form is:
74
121
 
@@ -101,7 +148,7 @@ validate: ✗ 17 error(s) in 6 chapter(s) (profile=academic, number-style=shared
101
148
  ...
102
149
  ```
103
150
 
104
- Warnings (currently: internal-link unresolved) are printed to stderr but don't affect exit code.
151
+ Warnings (internal-link uncertainty plus explicit BookLink skip cases) are printed to stderr but don't affect exit code.
105
152
 
106
153
  ## Extending the validator
107
154
 
@@ -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