@brandon_m_behring/book-scaffold-astro 4.14.3 → 4.16.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
@@ -100,7 +100,7 @@ Two callout families coexist. Authors import what they need.
100
100
 
101
101
  **Pedagogy family** (v4.1.0+, any profile, 3 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). Slot bullets/code freely; render at any preset.
102
102
 
103
- **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginNote`, `Sidenote`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
103
+ **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginNote`, `Sidenote`, `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`).
104
104
 
105
105
  **Provenance** (v4.8.0, any profile, **auto-injected by the chapter route — not author-imported**): per-chapter "How this was made" audit-trail block, rendered from the optional `provenance` frontmatter (`ai_tools`, `prompts_archive`, `decisions_log`, `audit_history`, `citation_backstop`). **Opt-out**: a chapter with no `provenance` shows a fallback ("Audit history not yet recorded"). Distinct from `AICollaborationDisclosure` (book-level, manual model+role disclosure). Repo-relative path fields render as `<code>`; only `http(s)` values link.
106
106
 
@@ -0,0 +1,32 @@
1
+ ---
2
+ /**
3
+ * BookLink — cross-book link to a sibling scaffold book (#96).
4
+ *
5
+ * Each scaffold book is a separate Astro app with its own `labels.json` and
6
+ * deploy origin, so `<XRef>` can't reach a sibling — a cross-book ref resolves
7
+ * against the wrong labels and dies. `<BookLink book="design" to="…">` resolves
8
+ * `book` against the consumer's `siblingBooks` registry
9
+ * (`defineBookConfig({ siblingBooks })`) — the single place to update when a
10
+ * sibling redeploys or extracts to its own repo. An unknown `book` throws at
11
+ * build (fail-loud, see `src/lib/book-link`), never a dead cross-origin link.
12
+ *
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`.
16
+ *
17
+ * Usage:
18
+ * For design depth see
19
+ * <BookLink book="design" to="chapters/patterns/#layered">the layered pattern</BookLink>.
20
+ */
21
+ import bookConfig from 'virtual:book-scaffold/book-config';
22
+ import { resolveBookHref } from '../src/lib/book-link';
23
+
24
+ interface Props {
25
+ book: string;
26
+ to: string;
27
+ }
28
+
29
+ const { book, to } = Astro.props;
30
+ const href = resolveBookHref(bookConfig.siblingBooks, book, to);
31
+ ---
32
+ <a href={href} rel="external noopener" class="book-link"><slot /></a>
@@ -4,9 +4,9 @@
4
4
  * build time, with syntax highlighting and a "View on GitHub" header
5
5
  * link pointing to the exact line range.
6
6
  *
7
- * Reads the file from the repo root (resolved relative to
8
- * guides/web/), slices `lines` ("N-M" or "N"), and hands the snippet
9
- * to Astro's `<Code>` component for Shiki rendering.
7
+ * Reads the file from the code repo root (the consumer's project root by
8
+ * default; set BOOK_REPO_ROOT for a monorepo subdir layout), slices `lines`
9
+ * ("N-M" or "N"), and hands the snippet to Astro's `<Code>` for Shiki.
10
10
  *
11
11
  * Build fails if:
12
12
  * - The file does not exist on disk
@@ -28,6 +28,7 @@ import { Code } from 'astro:components';
28
28
  import { readFileSync, existsSync } from 'node:fs';
29
29
  import { resolve } from 'node:path';
30
30
  import { buildGithubUrl } from '../src/lib/repo-url';
31
+ import bookConfig from 'virtual:book-scaffold/book-config';
31
32
 
32
33
  interface Props {
33
34
  src: string;
@@ -37,11 +38,15 @@ interface Props {
37
38
 
38
39
  const { src, lines, lang } = Astro.props;
39
40
 
40
- // Resolve src relative to repo root. Astro's build runs with cwd =
41
- // guides/web/, so the repo root is two levels up. Using process.cwd()
41
+ // #109: resolve src relative to the code repo root. Defaults to the consumer's
42
+ // project root (process.cwd() during astro build book + code in one repo);
43
+ // set BOOK_REPO_ROOT when the code lives elsewhere (e.g. a monorepo where the
44
+ // book is a subdir, the old guides/web/ → '../..' case). Using an env + cwd
42
45
  // (not import.meta.dirname) survives Astro's bundling — at runtime
43
46
  // import.meta.dirname points to the emitted .mjs chunk, not the source.
44
- const REPO_ROOT = resolve(process.cwd(), '..', '..');
47
+ const REPO_ROOT = process.env.BOOK_REPO_ROOT
48
+ ? resolve(process.env.BOOK_REPO_ROOT)
49
+ : process.cwd();
45
50
  const absPath = resolve(REPO_ROOT, src);
46
51
 
47
52
  if (!existsSync(absPath)) {
@@ -102,7 +107,15 @@ const inferLang = (path: string): string => {
102
107
 
103
108
  const language = (lang ?? inferLang(src)) as Parameters<typeof Code>[0]['lang'];
104
109
 
105
- const githubUrl = buildGithubUrl(src, startLine, endLine);
110
+ // #109: fail loud rather than link the wrong repo (see CodeRef for the rationale).
111
+ if (!bookConfig.githubRepo) {
112
+ throw new Error(
113
+ `<CodeBlock src="${src}">: no GitHub repo resolved. Set githubRepo in ` +
114
+ `defineBookConfig(), or add a "repository" field to package.json (or an ` +
115
+ `origin git remote) so the scaffold can auto-detect owner/repo.`,
116
+ );
117
+ }
118
+ const githubUrl = buildGithubUrl(bookConfig.githubRepo, bookConfig.githubBranch, src, startLine, endLine);
106
119
  const rangeText = startLine === endLine ? `:${startLine}` : `:${startLine}-${endLine}`;
107
120
  const displayPath = `${src}${rangeText}`;
108
121
  ---
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  /**
3
3
  * CodeRef — inline reference to a specific file (and optional line range)
4
- * in the post_transformers repo on GitHub.
4
+ * in the book's GitHub repo (resolved per book — #109: defineBookConfig
5
+ * `githubRepo`, else auto-detected from package.json / git remote).
5
6
  *
6
7
  * Maps the LaTeX `\code{path:N}` and `\path{path}` idioms (which only
7
8
  * styled monospace text) to a real hyperlink that lands the reader on
@@ -26,6 +27,7 @@
26
27
  * synthesized.
27
28
  */
28
29
  import { buildGithubUrl } from '../src/lib/repo-url';
30
+ import bookConfig from 'virtual:book-scaffold/book-config';
29
31
 
30
32
  interface Props {
31
33
  path: string;
@@ -34,7 +36,18 @@ interface Props {
34
36
  }
35
37
 
36
38
  const { path, line, lineEnd } = Astro.props;
37
- const url = buildGithubUrl(path, line, lineEnd);
39
+
40
+ // #109: fail loud rather than link to the wrong repo. `githubRepo` is null
41
+ // only when neither defineBookConfig({ githubRepo }) nor auto-detection
42
+ // (package.json repository / git remote) resolved one.
43
+ if (!bookConfig.githubRepo) {
44
+ throw new Error(
45
+ `<CodeRef path="${path}">: no GitHub repo resolved. Set githubRepo in ` +
46
+ `defineBookConfig(), or add a "repository" field to package.json (or an ` +
47
+ `origin git remote) so the scaffold can auto-detect owner/repo.`,
48
+ );
49
+ }
50
+ const url = buildGithubUrl(bookConfig.githubRepo, bookConfig.githubBranch, path, line, lineEnd);
38
51
  const hasSlot = await Astro.slots.has('default');
39
52
  const basename = path.split('/').pop() ?? path;
40
53
  const defaultText =
@@ -11,12 +11,20 @@
11
11
  * Closed `kind` union: discriminated literal type. To add a 6th kind in
12
12
  * a future release, expand the union + add a CSS block in poc-layouts.css.
13
13
  */
14
- export type PocLayoutKind = 'tutorial' | 'how-to' | 'tldr' | 'part-summary' | 'cheat-sheet';
14
+ import { assertEnumProp } from '../src/lib/assert-prop';
15
+
16
+ const POC_LAYOUT_KINDS = ['tutorial', 'how-to', 'tldr', 'part-summary', 'cheat-sheet'] as const;
17
+ export type PocLayoutKind = (typeof POC_LAYOUT_KINDS)[number];
15
18
 
16
19
  interface Props {
17
20
  kind: PocLayoutKind;
18
21
  }
19
- const { kind } = Astro.props;
22
+ // #109 sweep: fail loud on an out-of-union kind instead of emitting a
23
+ // poc-layout-<bogus> class that matches no CSS rule.
24
+ const kind = assertEnumProp(Astro.props.kind, POC_LAYOUT_KINDS, {
25
+ component: 'PocLayout',
26
+ prop: 'kind',
27
+ });
20
28
  ---
21
29
  <div class={`poc-layout poc-layout-${kind}`}>
22
30
  <slot />
@@ -11,12 +11,21 @@
11
11
  *
12
12
  * Family: book-genre (cross-profile).
13
13
  */
14
- type Difficulty = '1' | '2' | '3' | '4';
14
+ import { assertEnumProp } from '../src/lib/assert-prop';
15
+
16
+ const PRACTICE_DIFFICULTIES = ['1', '2', '3', '4'] as const;
17
+ type Difficulty = (typeof PRACTICE_DIFFICULTIES)[number];
15
18
  interface Props {
16
19
  id: string;
17
20
  difficulty: Difficulty;
18
21
  }
19
- const { id, difficulty } = Astro.props;
22
+ const { id } = Astro.props;
23
+ // #109 sweep: coerce (tolerates difficulty={3} numeric usage), then fail loud
24
+ // on anything outside 1-4 instead of rendering empty / NaN diamond markers.
25
+ const difficulty = assertEnumProp(String(Astro.props.difficulty), PRACTICE_DIFFICULTIES, {
26
+ component: 'Practice',
27
+ prop: 'difficulty',
28
+ });
20
29
  const anchorId = `practice-${id}`;
21
30
  const filled = Number.parseInt(difficulty, 10);
22
31
  const markers = '◆'.repeat(filled) + '◇'.repeat(4 - filled);
@@ -11,14 +11,18 @@
11
11
  * Internal frontmatter retains the 7-state value for STATUS.md alignment;
12
12
  * this component is the public-facing translator.
13
13
  */
14
- type InternalStatus =
15
- | 'implemented'
16
- | 'chapter_only'
17
- | 'reading_only'
18
- | 'prose_only'
19
- | 'code_only'
20
- | 'scaffolded'
21
- | 'planned';
14
+ import { assertEnumProp } from '../src/lib/assert-prop';
15
+
16
+ const INTERNAL_STATUSES = [
17
+ 'implemented',
18
+ 'chapter_only',
19
+ 'reading_only',
20
+ 'prose_only',
21
+ 'code_only',
22
+ 'scaffolded',
23
+ 'planned',
24
+ ] as const;
25
+ type InternalStatus = (typeof INTERNAL_STATUSES)[number];
22
26
 
23
27
  type PublicStatus = 'published' | 'draft' | 'coming-soon';
24
28
 
@@ -42,7 +46,12 @@ interface Props {
42
46
  status: InternalStatus;
43
47
  }
44
48
 
45
- const { status } = Astro.props;
49
+ // #109 sweep: fail loud on an unknown status instead of rendering an empty
50
+ // badge label (PUBLIC_OF[undefined] -> undefined -> blank).
51
+ const status = assertEnumProp(Astro.props.status, INTERNAL_STATUSES, {
52
+ component: 'StatusBadge',
53
+ prop: 'status',
54
+ });
46
55
  const publicStatus = PUBLIC_OF[status];
47
56
  const label = PUBLIC_LABEL[publicStatus];
48
57
  ---
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
2
- import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Y as volatilityLevels, h as ChaptersRenderer, r as academicParts, o as Style } from './types-CULHImU4.js';
3
- export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, m as Provenance, R as ResearchPortfolioChapter, n as RouteToggles, S as StatusBadge, p as StyleInput, T as ToolsChapter, V as VolatilityBadge, q as academicChapterSchema, s as changeKinds, t as changelogSchema, u as chapterStatus, v as citationBackstops, w as composeStyles, x as courseNotesChapterSchema, y as defineProfile, z as defineStyle, D as minimalChapterSchema, E as normalizeFrontmatterConfig, G as patternCategories, H as patternsSchema, I as provenanceObject, J as provenanceSchema, K as researchPortfolioChapterSchema, L as resolvePreset, N as resolveProfile, O as sourceTiers, Q as sourceTiersResearch, U as sourcesSchema, W as toolSlugs, X as toolsChapterSchema } from './types-CULHImU4.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Y as volatilityLevels, h as ChaptersRenderer, r as academicParts, o as Style } from './types-BmBIV5qD.js';
3
+ export { A as AcademicChapter, B as BOOK_PRESETS, a as BOOK_PROFILES, b as BookConfigError, d as BookPreset, e as BookProfile, g as BookSchemasOptions, C as ChapterFor, i as CourseNotesChapter, F as FreshnessAffordance, j as FrontmatterRouteConfig, M as MinimalChapter, P as PartKey, k as PartialRouteToggles, l as ProfileDefinition, m as Provenance, R as ResearchPortfolioChapter, n as RouteToggles, S as StatusBadge, p as StyleInput, T as ToolsChapter, V as VolatilityBadge, q as academicChapterSchema, s as changeKinds, t as changelogSchema, u as chapterStatus, v as citationBackstops, w as composeStyles, x as courseNotesChapterSchema, y as defineProfile, z as defineStyle, D as minimalChapterSchema, E as normalizeFrontmatterConfig, G as patternCategories, H as patternsSchema, I as provenanceObject, J as provenanceSchema, K as researchPortfolioChapterSchema, L as resolvePreset, N as resolveProfile, O as sourceTiers, Q as sourceTiersResearch, U as sourcesSchema, W as toolSlugs, X as toolsChapterSchema } from './types-BmBIV5qD.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  /**
@@ -252,6 +252,91 @@ interface ResolvedTheoremLabel {
252
252
  */
253
253
  declare function theoremLabel(props: TheoremLabelProps): ResolvedTheoremLabel;
254
254
 
255
+ /**
256
+ * repo-url — resolve and build GitHub source links for CodeRef / CodeBlock.
257
+ *
258
+ * The repo is no longer hardcoded (#109). `buildGithubUrl` takes an explicit
259
+ * `repo` + `branch`; the integration resolves them per book — from
260
+ * `defineBookConfig({ githubRepo })` (override), else auto-detected from the
261
+ * consumer's own `package.json` `repository` (or git remote) via
262
+ * `parseRepoSlug`. When nothing resolves, the components throw rather than
263
+ * emit links to the wrong repo (the old silent default was
264
+ * `brandon-behring/post_transformers`).
265
+ */
266
+ /** Default branch when a book configures a repo but no branch. */
267
+ declare const DEFAULT_GITHUB_BRANCH = "main";
268
+ /**
269
+ * Derive an `owner/repo` slug from an npm `repository` field (string or
270
+ * `{ url }`) or a raw git remote URL. Handles https, ssh, the `git+` prefix,
271
+ * a trailing `.git`, and the `github:owner/repo` npm shorthand. Returns null
272
+ * for anything that isn't a recognizable GitHub repo — no silent guess.
273
+ */
274
+ declare function parseRepoSlug(repository: string | {
275
+ url?: string;
276
+ } | null | undefined): string | null;
277
+ /** Extract the `origin` remote URL from the text of a `.git/config` file. */
278
+ declare function originUrlFromGitConfig(gitConfigText: string): string | null;
279
+ /**
280
+ * Resolve a GitHub `owner/repo` by precedence (#109) — the rule that keeps a
281
+ * book from ever silently linking to the wrong repo:
282
+ * 1. explicit `override` (defineBookConfig `githubRepo`)
283
+ * 2. the consumer's `package.json` `repository`
284
+ * 3. the `origin` remote in `.git/config`
285
+ * 4. null — the components then throw rather than guess.
286
+ */
287
+ declare function resolveGithubRepo(sources: {
288
+ override?: string | null;
289
+ packageJsonRepository?: string | {
290
+ url?: string;
291
+ } | null;
292
+ gitConfigText?: string | null;
293
+ }): string | null;
294
+ /**
295
+ * Build a GitHub line-anchor URL for an explicit repo + branch.
296
+ * buildGithubUrl('o/r', 'main', 'a/b.py') -> https://github.com/o/r/blob/main/a/b.py
297
+ * buildGithubUrl('o/r', 'main', 'a/b.py', 42) -> …/a/b.py#L42
298
+ * buildGithubUrl('o/r', 'main', 'a/b.py', 42, 58) -> …/a/b.py#L42-L58
299
+ */
300
+ declare function buildGithubUrl(repo: string, branch: string, path: string, line?: number, lineEnd?: number): string;
301
+
302
+ /**
303
+ * assert-prop — shared fail-loud validator for closed-union component props
304
+ * (#109 / the v4.15.0 parseProps sweep).
305
+ *
306
+ * Astro has no runtime prop validation, so a closed-union prop given an
307
+ * out-of-range value silently produces a broken render — an empty StatusBadge
308
+ * label, a `poc-layout-<bogus>` class that matches no CSS, NaN difficulty
309
+ * markers. This is the same silent-degradation class as the #121 Theorem bug.
310
+ * `assertEnumProp` converts it into a loud, actionable build-time throw.
311
+ */
312
+ /**
313
+ * Return `value` when it's one of `allowed`; otherwise throw an actionable
314
+ * error naming the component, prop, the offending value, and the legal set.
315
+ * Never returns a fallback — a closed union with no valid value is an authoring
316
+ * error that should stop the build, not render something wrong.
317
+ */
318
+ declare function assertEnumProp<T extends string>(value: unknown, allowed: readonly T[], ctx: {
319
+ component: string;
320
+ prop: string;
321
+ }): T;
322
+
323
+ /**
324
+ * book-link — resolve a cross-book `<BookLink>` href from the consumer's
325
+ * sibling-book registry (#96).
326
+ *
327
+ * Each scaffold book is a separate Astro app with its own `labels.json` and
328
+ * deploy origin, so `<XRef>` can't reach a sibling book — a cross-book ref
329
+ * resolves against the wrong labels and dies. `<BookLink book to>` instead
330
+ * resolves `book` against a per-consumer registry of sibling base URLs
331
+ * (`defineBookConfig({ siblingBooks })`) — the single place to update when a
332
+ * sibling redeploys or extracts to its own repo. An unknown `book` THROWS
333
+ * rather than emitting a dead cross-origin link (fail-loud, like #109).
334
+ *
335
+ * Phase 1 (this): registry-backed href + fail-loud on unknown book. Phase 2
336
+ * (deferred): validate the `to` id against a vendored sibling `labels.json`.
337
+ */
338
+ declare function resolveBookHref(siblingBooks: Record<string, string> | null | undefined, book: string, to: string): string;
339
+
255
340
  /**
256
341
  * src/styles/built-in.ts — toolkit-shipped Styles, one per BookPreset (v4.0.0).
257
342
  *
@@ -353,4 +438,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
353
438
  */
354
439
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
355
440
 
356
- export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, KIND_LABEL, type ResolvedTheoremLabel, Style, THEOREM_KINDS, type TheoremKind, type TheoremLabelProps, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, bookScaffoldIntegration, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, researchPortfolioStyle, theoremLabel, toolsChaptersRenderer, toolsStyle, volatilityLevels };
441
+ export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, DEFAULT_GITHUB_BRANCH, type Freshness, type FreshnessStatus, KIND_LABEL, type ResolvedTheoremLabel, Style, THEOREM_KINDS, type TheoremKind, type TheoremLabelProps, type TipsConfig, type TipsConfigInput, UNKNOWN_PART_ORDINAL, type VolatilityLevel, academicChaptersRenderer, academicPartHeading, academicPartName, academicPartOrdinal, academicParts, academicStyle, assertEnumProp, bookScaffoldIntegration, buildGithubUrl, chapterSortKey, courseNotesStyle, defineBookConfig, defineMdxComponents, defineTips, fallbackChaptersRenderer, freshnessLabel, getFreshness, minimalStyle, originUrlFromGitConfig, parseRepoSlug, researchPortfolioStyle, resolveBookHref, resolveGithubRepo, theoremLabel, toolsChaptersRenderer, toolsStyle, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -788,6 +788,8 @@ function resolveProfile(explicit) {
788
788
 
789
789
  // src/integration.ts
790
790
  import { fileURLToPath } from "url";
791
+ import { readFileSync as readFileSync2 } from "fs";
792
+ import { join } from "path";
791
793
 
792
794
  // src/lib/define-style.ts
793
795
  function defineStyle(opts) {
@@ -855,6 +857,44 @@ function normalizeFrontmatterConfig(v) {
855
857
  return v;
856
858
  }
857
859
 
860
+ // src/lib/repo-url.ts
861
+ var DEFAULT_GITHUB_BRANCH = "main";
862
+ function parseRepoSlug(repository) {
863
+ const raw = typeof repository === "string" ? repository : repository && typeof repository === "object" ? repository.url : void 0;
864
+ if (!raw || typeof raw !== "string") return null;
865
+ const s = raw.trim();
866
+ if (s === "") return null;
867
+ const shorthand = s.match(/^github:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/i);
868
+ if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
869
+ const m = s.replace(/^git\+/, "").match(/github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/#?].*)?$/i);
870
+ return m ? `${m[1]}/${m[2]}` : null;
871
+ }
872
+ function originUrlFromGitConfig(gitConfigText) {
873
+ const m = gitConfigText.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
874
+ return m ? m[1] : null;
875
+ }
876
+ function resolveGithubRepo(sources) {
877
+ if (sources.override) return sources.override;
878
+ const fromPkg = parseRepoSlug(sources.packageJsonRepository ?? null);
879
+ if (fromPkg) return fromPkg;
880
+ if (sources.gitConfigText) {
881
+ const fromGit = parseRepoSlug(originUrlFromGitConfig(sources.gitConfigText));
882
+ if (fromGit) return fromGit;
883
+ }
884
+ return null;
885
+ }
886
+ function buildGithubUrl(repo, branch, path, line, lineEnd) {
887
+ const cleanPath = path.replace(/^\/+/, "");
888
+ let url = `https://github.com/${repo}/blob/${branch}/${cleanPath}`;
889
+ if (line !== void 0) {
890
+ url += `#L${line}`;
891
+ if (lineEnd !== void 0 && lineEnd !== line) {
892
+ url += `-L${lineEnd}`;
893
+ }
894
+ }
895
+ return url;
896
+ }
897
+
858
898
  // src/mdx-components-resolver.ts
859
899
  import { existsSync as existsSync2 } from "fs";
860
900
  import { resolve } from "path";
@@ -915,6 +955,23 @@ function makeBookConfigVitePlugin(config) {
915
955
  }
916
956
  };
917
957
  }
958
+ function resolveBookGithubRepo(override, consumerRoot) {
959
+ let packageJsonRepository = null;
960
+ let gitConfigText = null;
961
+ try {
962
+ packageJsonRepository = JSON.parse(readFileSync2(join(consumerRoot, "package.json"), "utf8")).repository ?? null;
963
+ } catch {
964
+ }
965
+ try {
966
+ gitConfigText = readFileSync2(join(consumerRoot, ".git", "config"), "utf8");
967
+ } catch {
968
+ }
969
+ return resolveGithubRepo({
970
+ override,
971
+ packageJsonRepository,
972
+ gitConfigText
973
+ });
974
+ }
918
975
  var PACKAGE_NAME = "@brandon_m_behring/book-scaffold-astro";
919
976
  var ROUTE_REGISTRY = {
920
977
  references: { pattern: "/references", file: "references.astro" },
@@ -968,7 +1025,12 @@ function bookScaffoldIntegration(opts) {
968
1025
  // v4.6.0: book-level author + SEO config, propagated through the
969
1026
  // (renamed) book-config virtual module to Base.astro + Chapter.astro.
970
1027
  author,
971
- seo
1028
+ seo,
1029
+ // v4.15.0 (#109): optional GitHub repo/branch override for CodeRef/CodeBlock.
1030
+ githubRepo,
1031
+ githubBranch,
1032
+ // v4.16.0 (#96): sibling-book registry for cross-book <BookLink>.
1033
+ siblingBooks
972
1034
  } = opts;
973
1035
  const def = PROFILES[profile];
974
1036
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -1009,6 +1071,8 @@ function bookScaffoldIntegration(opts) {
1009
1071
  }
1010
1072
  const consumerRoot = fileURLToPath(config.root);
1011
1073
  const resolvedMdxPath = resolveMdxComponentsPath(consumerRoot, mdxComponentsModule);
1074
+ const resolvedGithubRepo = resolveBookGithubRepo(githubRepo, consumerRoot);
1075
+ const resolvedGithubBranch = githubBranch ?? DEFAULT_GITHUB_BRANCH;
1012
1076
  const presetLiteral = JSON.stringify(profile);
1013
1077
  const enabledRouteNames = Object.entries(enabledRoutes).filter(([, on]) => on).map(([name]) => name);
1014
1078
  updateConfig({
@@ -1024,7 +1088,10 @@ function bookScaffoldIntegration(opts) {
1024
1088
  seo: {
1025
1089
  ogImage: seo?.ogImage ?? null,
1026
1090
  twitterHandle: seo?.twitterHandle ?? null
1027
- }
1091
+ },
1092
+ githubRepo: resolvedGithubRepo,
1093
+ githubBranch: resolvedGithubBranch,
1094
+ siblingBooks: siblingBooks ?? {}
1028
1095
  })
1029
1096
  ],
1030
1097
  define: {
@@ -1157,7 +1224,12 @@ async function defineBookConfig(opts) {
1157
1224
  // Base.astro + Chapter.astro. `seo.sitemap` is NOT passed through —
1158
1225
  // it's consumed below at config-time by the @astrojs/sitemap call.
1159
1226
  author: opts.author,
1160
- seo: opts.seo ? { ogImage: opts.seo.ogImage, twitterHandle: opts.seo.twitterHandle } : void 0
1227
+ seo: opts.seo ? { ogImage: opts.seo.ogImage, twitterHandle: opts.seo.twitterHandle } : void 0,
1228
+ // v4.15.0 (#109): repo/branch override; integration auto-detects when undefined.
1229
+ githubRepo: opts.githubRepo,
1230
+ githubBranch: opts.githubBranch,
1231
+ // v4.16.0 (#96): cross-book link registry.
1232
+ siblingBooks: opts.siblingBooks
1161
1233
  }),
1162
1234
  ...mergedExtraIntegrations
1163
1235
  ];
@@ -1199,6 +1271,11 @@ async function defineBookConfig(opts) {
1199
1271
  // v4.6.0: strip new book-level SEO opts (author + seo block).
1200
1272
  author: _author,
1201
1273
  seo: _seo,
1274
+ // v4.15.0: strip repo opts so they don't leak into AstroUserConfig.
1275
+ githubRepo: _githubRepo,
1276
+ githubBranch: _githubBranch,
1277
+ // v4.16.0: strip cross-book registry.
1278
+ siblingBooks: _siblingBooks,
1202
1279
  ...rest
1203
1280
  } = opts;
1204
1281
  void _styles;
@@ -1215,6 +1292,9 @@ async function defineBookConfig(opts) {
1215
1292
  void _portfolio;
1216
1293
  void _author;
1217
1294
  void _seo;
1295
+ void _githubRepo;
1296
+ void _githubBranch;
1297
+ void _siblingBooks;
1218
1298
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1219
1299
  const restVite = rest.vite ?? {};
1220
1300
  const restSsr = restVite.ssr ?? {};
@@ -1294,6 +1374,29 @@ function theoremLabel(props) {
1294
1374
  return { kind: raw, fullLabel };
1295
1375
  }
1296
1376
 
1377
+ // src/lib/assert-prop.ts
1378
+ function assertEnumProp(value, allowed, ctx) {
1379
+ if (typeof value === "string" && allowed.includes(value)) {
1380
+ return value;
1381
+ }
1382
+ const got = value === void 0 ? "nothing" : JSON.stringify(value);
1383
+ throw new Error(
1384
+ `<${ctx.component}>: ${ctx.prop}=${got} is not one of ${allowed.join(", ")}.`
1385
+ );
1386
+ }
1387
+
1388
+ // src/lib/book-link.ts
1389
+ function resolveBookHref(siblingBooks, book, to) {
1390
+ const base = siblingBooks?.[book];
1391
+ if (!base) {
1392
+ const known = siblingBooks ? Object.keys(siblingBooks) : [];
1393
+ throw new Error(
1394
+ `<BookLink book="${book}">: unknown sibling book. Register it in defineBookConfig({ siblingBooks: { "${book}": "https://\u2026" } })` + (known.length ? ` (known: ${known.join(", ")})` : "") + "."
1395
+ );
1396
+ }
1397
+ return `${base.replace(/\/+$/, "")}/${to.replace(/^\/+/, "")}`;
1398
+ }
1399
+
1297
1400
  // src/styles/built-in.ts
1298
1401
  var academicStyle = defineStyle({
1299
1402
  name: "academic",
@@ -1340,6 +1443,7 @@ export {
1340
1443
  BRANDON_PORTFOLIO_DEFAULT,
1341
1444
  BUILTIN_STYLES,
1342
1445
  BookConfigError,
1446
+ DEFAULT_GITHUB_BRANCH,
1343
1447
  KIND_LABEL,
1344
1448
  THEOREM_KINDS,
1345
1449
  UNKNOWN_PART_ORDINAL,
@@ -1350,7 +1454,9 @@ export {
1350
1454
  academicPartOrdinal,
1351
1455
  academicParts,
1352
1456
  academicStyle,
1457
+ assertEnumProp,
1353
1458
  bookScaffoldIntegration,
1459
+ buildGithubUrl,
1354
1460
  changeKinds,
1355
1461
  changelogSchema,
1356
1462
  chapterSortKey,
@@ -1370,12 +1476,16 @@ export {
1370
1476
  minimalChapterSchema,
1371
1477
  minimalStyle,
1372
1478
  normalizeFrontmatterConfig,
1479
+ originUrlFromGitConfig,
1480
+ parseRepoSlug,
1373
1481
  patternCategories,
1374
1482
  patternsSchema,
1375
1483
  provenanceObject,
1376
1484
  provenanceSchema,
1377
1485
  researchPortfolioChapterSchema,
1378
1486
  researchPortfolioStyle,
1487
+ resolveBookHref,
1488
+ resolveGithubRepo,
1379
1489
  resolvePreset,
1380
1490
  resolveProfile,
1381
1491
  sourceTiers,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-CULHImU4.js';
2
+ import { g as BookSchemasOptions } from './types-BmBIV5qD.js';
3
3
  import 'astro';
4
4
  import 'astro/zod';
5
5
 
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. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.14.3",
4
+ "version": "4.16.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -43,6 +43,7 @@
43
43
  "./package.json": "./package.json",
44
44
  "./components/AICollaborationDisclosure.astro": "./components/AICollaborationDisclosure.astro",
45
45
  "./components/BlockedByCallout.astro": "./components/BlockedByCallout.astro",
46
+ "./components/BookLink.astro": "./components/BookLink.astro",
46
47
  "./components/CaseStudy.astro": "./components/CaseStudy.astro",
47
48
  "./components/ChapterHeader.astro": "./components/ChapterHeader.astro",
48
49
  "./components/ChapterNav.astro": "./components/ChapterNav.astro",
@@ -15,6 +15,8 @@
15
15
  * path exists + line in bounds.
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).
18
+ * 7. <BookLink book="…" to="…"> (#96) — both props present, and book= is a
19
+ * key in the consumer's siblingBooks registry (best-effort).
18
20
  *
19
21
  * Run from the consumer's project root. Closes #8 (was resolving paths
20
22
  * from the package's own directory inside node_modules — false negatives
@@ -214,6 +216,9 @@ const RE_MD_LINK = /\[(?:[^\]]*)\]\((\/[^)\s#]+)(?:#[^)]*)?\)/g;
214
216
  // #121: a <Theorem> opening tag — capture its attributes to assert a
215
217
  // resolvable kind= (or legacy type=) is present.
216
218
  const RE_THEOREM = /<Theorem\b([^>]*)>/g;
219
+ // #96: a <BookLink> opening tag — assert book= + to= present, and (best-effort)
220
+ // that book= is a registered sibling.
221
+ const RE_BOOKLINK = /<BookLink\b([^>]*)>/g;
217
222
 
218
223
  async function fileExists(p) {
219
224
  try {
@@ -228,6 +233,25 @@ function lineOf(content, idx) {
228
233
  return content.slice(0, idx).split('\n').length;
229
234
  }
230
235
 
236
+ // #96: best-effort siblingBooks registry keys from astro.config.mjs, so the
237
+ // <BookLink> check can flag an unknown book= earlier than the component's
238
+ // build-time throw. null = couldn't determine → membership not checked (the
239
+ // component still fails loud at build).
240
+ let siblingBookKeys = null;
241
+ {
242
+ const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
243
+ if (existsSync(astroConfigPath)) {
244
+ const block = readFileSync(astroConfigPath, 'utf8').match(/siblingBooks\s*:\s*\{([^}]*)\}/);
245
+ if (block) {
246
+ // Anchor each key to an entry boundary ({ , or start) so the `https:` in
247
+ // a URL value isn't mistaken for a key.
248
+ siblingBookKeys = new Set(
249
+ [...block[1].matchAll(/(?:^|[{,])\s*['"]?([\w-]+)['"]?\s*:/g)].map((x) => x[1]),
250
+ );
251
+ }
252
+ }
253
+ }
254
+
231
255
  // ===== Run all checks on each chapter =====
232
256
  for (const rel of chapterFiles) {
233
257
  const abs = join(CHAPTERS_DIR, rel);
@@ -296,6 +320,23 @@ for (const rel of chapterFiles) {
296
320
  );
297
321
  }
298
322
  }
323
+
324
+ // 7. BookLink (#96): structural (book= + to=) + best-effort registry membership.
325
+ for (const m of content.matchAll(RE_BOOKLINK)) {
326
+ const attrs = m[1];
327
+ const bookMatch = attrs.match(/\bbook=["']([^"']+)["']/);
328
+ if (!bookMatch || !/\bto=["']/.test(attrs)) {
329
+ fail(rel, lineOf(content, m.index), `<BookLink> requires both book="…" and to="…".`);
330
+ continue;
331
+ }
332
+ if (siblingBookKeys && !siblingBookKeys.has(bookMatch[1])) {
333
+ fail(
334
+ rel,
335
+ lineOf(content, m.index),
336
+ `<BookLink book="${bookMatch[1]}"> — not in defineBookConfig siblingBooks (${[...siblingBookKeys].join(', ') || 'none'}). Register it or fix the key.`,
337
+ );
338
+ }
339
+ }
299
340
  }
300
341
 
301
342
  // ===== v4.6.0 (issue #77): missing-prereq re-framing =====
@@ -0,0 +1,30 @@
1
+ /**
2
+ * assert-prop — shared fail-loud validator for closed-union component props
3
+ * (#109 / the v4.15.0 parseProps sweep).
4
+ *
5
+ * Astro has no runtime prop validation, so a closed-union prop given an
6
+ * out-of-range value silently produces a broken render — an empty StatusBadge
7
+ * label, a `poc-layout-<bogus>` class that matches no CSS, NaN difficulty
8
+ * markers. This is the same silent-degradation class as the #121 Theorem bug.
9
+ * `assertEnumProp` converts it into a loud, actionable build-time throw.
10
+ */
11
+
12
+ /**
13
+ * Return `value` when it's one of `allowed`; otherwise throw an actionable
14
+ * error naming the component, prop, the offending value, and the legal set.
15
+ * Never returns a fallback — a closed union with no valid value is an authoring
16
+ * error that should stop the build, not render something wrong.
17
+ */
18
+ export function assertEnumProp<T extends string>(
19
+ value: unknown,
20
+ allowed: readonly T[],
21
+ ctx: { component: string; prop: string },
22
+ ): T {
23
+ if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) {
24
+ return value as T;
25
+ }
26
+ const got = value === undefined ? 'nothing' : JSON.stringify(value);
27
+ throw new Error(
28
+ `<${ctx.component}>: ${ctx.prop}=${got} is not one of ${allowed.join(', ')}.`,
29
+ );
30
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * book-link — resolve a cross-book `<BookLink>` href from the consumer's
3
+ * sibling-book registry (#96).
4
+ *
5
+ * Each scaffold book is a separate Astro app with its own `labels.json` and
6
+ * deploy origin, so `<XRef>` can't reach a sibling book — a cross-book ref
7
+ * resolves against the wrong labels and dies. `<BookLink book to>` instead
8
+ * resolves `book` against a per-consumer registry of sibling base URLs
9
+ * (`defineBookConfig({ siblingBooks })`) — the single place to update when a
10
+ * sibling redeploys or extracts to its own repo. An unknown `book` THROWS
11
+ * rather than emitting a dead cross-origin link (fail-loud, like #109).
12
+ *
13
+ * Phase 1 (this): registry-backed href + fail-loud on unknown book. Phase 2
14
+ * (deferred): validate the `to` id against a vendored sibling `labels.json`.
15
+ */
16
+ export function resolveBookHref(
17
+ siblingBooks: Record<string, string> | null | undefined,
18
+ book: string,
19
+ to: string,
20
+ ): string {
21
+ const base = siblingBooks?.[book];
22
+ if (!base) {
23
+ const known = siblingBooks ? Object.keys(siblingBooks) : [];
24
+ throw new Error(
25
+ `<BookLink book="${book}">: unknown sibling book. Register it in ` +
26
+ `defineBookConfig({ siblingBooks: { "${book}": "https://…" } })` +
27
+ (known.length ? ` (known: ${known.join(', ')})` : '') +
28
+ '.',
29
+ );
30
+ }
31
+ return `${base.replace(/\/+$/, '')}/${to.replace(/^\/+/, '')}`;
32
+ }
@@ -1,27 +1,92 @@
1
1
  /**
2
- * Repository URL constants used by CodeRef and CodeBlock components.
2
+ * repo-url resolve and build GitHub source links for CodeRef / CodeBlock.
3
3
  *
4
- * Centralized here so renaming the repo or moving branches is a one-file
5
- * change rather than a hunt across components.
6
- *
7
- * If the repo is later mirrored to a different host, update GITHUB_BASE
8
- * accordingly; the components do not assume GitHub-specific anchor syntax
9
- * beyond #L<N>-L<M>, which all major hosts (GitHub, GitLab, Codeberg)
10
- * support.
4
+ * The repo is no longer hardcoded (#109). `buildGithubUrl` takes an explicit
5
+ * `repo` + `branch`; the integration resolves them per book — from
6
+ * `defineBookConfig({ githubRepo })` (override), else auto-detected from the
7
+ * consumer's own `package.json` `repository` (or git remote) via
8
+ * `parseRepoSlug`. When nothing resolves, the components throw rather than
9
+ * emit links to the wrong repo (the old silent default was
10
+ * `brandon-behring/post_transformers`).
11
+ */
12
+
13
+ /** Default branch when a book configures a repo but no branch. */
14
+ export const DEFAULT_GITHUB_BRANCH = 'main';
15
+
16
+ /**
17
+ * Derive an `owner/repo` slug from an npm `repository` field (string or
18
+ * `{ url }`) or a raw git remote URL. Handles https, ssh, the `git+` prefix,
19
+ * a trailing `.git`, and the `github:owner/repo` npm shorthand. Returns null
20
+ * for anything that isn't a recognizable GitHub repo — no silent guess.
11
21
  */
12
- export const GITHUB_REPO = 'brandon-behring/post_transformers';
13
- export const GITHUB_BRANCH = 'main';
14
- export const GITHUB_BASE = `https://github.com/${GITHUB_REPO}/blob/${GITHUB_BRANCH}`;
22
+ export function parseRepoSlug(
23
+ repository: string | { url?: string } | null | undefined,
24
+ ): string | null {
25
+ const raw =
26
+ typeof repository === 'string'
27
+ ? repository
28
+ : repository && typeof repository === 'object'
29
+ ? repository.url
30
+ : undefined;
31
+ if (!raw || typeof raw !== 'string') return null;
32
+ const s = raw.trim();
33
+ if (s === '') return null;
34
+
35
+ // npm shorthand: github:owner/repo
36
+ const shorthand = s.match(/^github:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/i);
37
+ if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
38
+
39
+ // https://github.com/owner/repo(.git), git+https://…, ssh git@github.com:owner/repo(.git)
40
+ const m = s
41
+ .replace(/^git\+/, '')
42
+ .match(/github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/#?].*)?$/i);
43
+ return m ? `${m[1]}/${m[2]}` : null;
44
+ }
45
+
46
+ /** Extract the `origin` remote URL from the text of a `.git/config` file. */
47
+ export function originUrlFromGitConfig(gitConfigText: string): string | null {
48
+ const m = gitConfigText.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
49
+ return m ? m[1]! : null;
50
+ }
51
+
52
+ /**
53
+ * Resolve a GitHub `owner/repo` by precedence (#109) — the rule that keeps a
54
+ * book from ever silently linking to the wrong repo:
55
+ * 1. explicit `override` (defineBookConfig `githubRepo`)
56
+ * 2. the consumer's `package.json` `repository`
57
+ * 3. the `origin` remote in `.git/config`
58
+ * 4. null — the components then throw rather than guess.
59
+ */
60
+ export function resolveGithubRepo(sources: {
61
+ override?: string | null;
62
+ packageJsonRepository?: string | { url?: string } | null;
63
+ gitConfigText?: string | null;
64
+ }): string | null {
65
+ if (sources.override) return sources.override;
66
+ const fromPkg = parseRepoSlug(sources.packageJsonRepository ?? null);
67
+ if (fromPkg) return fromPkg;
68
+ if (sources.gitConfigText) {
69
+ const fromGit = parseRepoSlug(originUrlFromGitConfig(sources.gitConfigText));
70
+ if (fromGit) return fromGit;
71
+ }
72
+ return null;
73
+ }
15
74
 
16
75
  /**
17
- * Build a GitHub line-anchor URL.
18
- * buildGithubUrl('experiments/jax/week04/s4.py', 42) -> .../s4.py#L42
19
- * buildGithubUrl('experiments/jax/week04/s4.py', 42, 58) -> .../s4.py#L42-L58
20
- * buildGithubUrl('experiments/jax/week04/s4.py') -> .../s4.py
76
+ * Build a GitHub line-anchor URL for an explicit repo + branch.
77
+ * buildGithubUrl('o/r', 'main', 'a/b.py') -> https://github.com/o/r/blob/main/a/b.py
78
+ * buildGithubUrl('o/r', 'main', 'a/b.py', 42) -> …/a/b.py#L42
79
+ * buildGithubUrl('o/r', 'main', 'a/b.py', 42, 58) -> …/a/b.py#L42-L58
21
80
  */
22
- export function buildGithubUrl(path: string, line?: number, lineEnd?: number): string {
81
+ export function buildGithubUrl(
82
+ repo: string,
83
+ branch: string,
84
+ path: string,
85
+ line?: number,
86
+ lineEnd?: number,
87
+ ): string {
23
88
  const cleanPath = path.replace(/^\/+/, '');
24
- let url = `${GITHUB_BASE}/${cleanPath}`;
89
+ let url = `https://github.com/${repo}/blob/${branch}/${cleanPath}`;
25
90
  if (line !== undefined) {
26
91
  url += `#L${line}`;
27
92
  if (lineEnd !== undefined && lineEnd !== line) {