@brandon_m_behring/book-scaffold-astro 4.14.2 → 4.15.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
@@ -96,7 +96,7 @@ Two callout families coexist. Authors import what they need.
96
96
 
97
97
  **Tools family** (`src/components/callouts/`, 8 components): `SkillBox`, `CaseStudy`, `ConceptBox`, `KeyIdea`, `TryThis`, `Recovery`, `Convergence`, `Divergence`.
98
98
 
99
- **Academic family** (`src/components/callouts/`, 10 components): `NoteBox`, `ExampleBox`, `DynConnect`, `InsightBox`, `WarnBox`, `CounterBox`, `TipBox`, `OpenQuestion`, `PaperBox`, `ResultBox`. Plus `Theorem` (unified for theorem/proposition/lemma/corollary/definition/example/exercise/remark/proof).
99
+ **Academic family** (`src/components/callouts/`, 10 components): `NoteBox`, `ExampleBox`, `DynConnect`, `InsightBox`, `WarnBox`, `CounterBox`, `TipBox`, `OpenQuestion`, `PaperBox`, `ResultBox`. Plus `Theorem` (unified for theorem/proposition/lemma/corollary/definition/example/exercise/remark/proof). **Props (v4.14.3, #121):** `kind=` is canonical; `type=` is accepted as a legacy alias (likewise `title=`/`label=` alias `name=`). An absent or unknown kind **throws at build** (via `src/lib/theorem-label`) rather than rendering an empty label; `book-scaffold validate` flags a `<Theorem>` with neither `kind=` nor `type=` even earlier.
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
 
@@ -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
  ---
@@ -4,15 +4,17 @@
4
4
  *
5
5
  * Maps LaTeX `\begin{theorem}`, `\begin{proposition}`, `\begin{lemma}`,
6
6
  * `\begin{corollary}`, `\begin{definition}`, `\begin{example}`,
7
- * `\begin{exercise}`, `\begin{remark}` to a single component with a
8
- * `kind` prop.
7
+ * `\begin{exercise}`, `\begin{remark}`, `\begin{proof}` to a single component.
8
+ *
9
+ * Props (#121): `kind` is canonical; `type=` is an accepted **legacy alias**
10
+ * (ssm-foundations ch1–11 + the LaTeX-env mental model pass `type=`). `name`
11
+ * is canonical with `title=`/`label=` accepted. An absent or unknown kind
12
+ * THROWS at build (see `src/lib/theorem-label`) — it never renders an empty
13
+ * label. Pass `n` to number the environment.
9
14
  *
10
15
  * Auto-numbering note: the LaTeX preamble shares a counter between
11
- * theorem/proposition/lemma/corollary (`\newtheorem{lemma}[theorem]`)
12
- * and gives each of definition/example/exercise/remark its own counter.
13
- * In MDX we accept that as a contract: per-chapter counters are
14
- * implemented in Phase 2.6 (label scanner) — for now, the author
15
- * passes `n` explicitly when numbering matters.
16
+ * theorem/proposition/lemma/corollary and gives definition/example/exercise/
17
+ * remark their own; in MDX the author passes `n` explicitly when it matters.
16
18
  *
17
19
  * Usage:
18
20
  * <Theorem kind="theorem" n="4.2" name="Stable continuous-time eigenvalues" id="thm-w4-stability">
@@ -23,41 +25,14 @@
23
25
  * The HiPPO-LegS state matrix …
24
26
  * </Theorem>
25
27
  */
26
- type Kind =
27
- | 'theorem'
28
- | 'proposition'
29
- | 'lemma'
30
- | 'corollary'
31
- | 'definition'
32
- | 'example'
33
- | 'exercise'
34
- | 'remark'
35
- | 'proof';
28
+ import { theoremLabel, type TheoremLabelProps } from '../src/lib/theorem-label';
36
29
 
37
- interface Props {
38
- kind: Kind;
39
- n?: string;
40
- name?: string;
30
+ interface Props extends TheoremLabelProps {
41
31
  id?: string;
42
32
  }
43
33
 
44
- const { kind, n, name, id } = Astro.props;
45
-
46
- const KIND_LABEL: Record<Kind, string> = {
47
- theorem: 'Theorem',
48
- proposition: 'Proposition',
49
- lemma: 'Lemma',
50
- corollary: 'Corollary',
51
- definition: 'Definition',
52
- example: 'Example',
53
- exercise: 'Exercise',
54
- remark: 'Remark',
55
- proof: 'Proof',
56
- };
57
-
58
- const label = KIND_LABEL[kind];
59
- const numbered = n ? `${label} ${n}` : label;
60
- const fullLabel = name ? `${numbered} (${name})` : numbered;
34
+ const { id } = Astro.props;
35
+ const { kind, fullLabel } = theoremLabel(Astro.props);
61
36
  ---
62
37
  <div class="theorem" data-kind={kind} id={id}>
63
38
  <span class="theorem-label">{fullLabel}.</span>
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-CjOseOSG.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-CjOseOSG.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  /**
@@ -204,6 +204,122 @@ declare function academicPartOrdinal(part: string): number;
204
204
  */
205
205
  declare function academicPartHeading(part: string): string;
206
206
 
207
+ /**
208
+ * theorem-label — resolve a `<Theorem>` component's label from its props,
209
+ * failing loud instead of rendering a silent empty label (#121).
210
+ *
211
+ * Vocabulary: `kind` is canonical; `type` is an accepted **legacy alias** (many
212
+ * consumer books — ssm-foundations ch1–11 — and the LaTeX `\begin{<env>}`
213
+ * mental model pass `type=`). Likewise `name` is canonical with `title`/`label`
214
+ * accepted as aliases. Aliases keep existing content valid; they are synonyms,
215
+ * not deprecations, so they neither warn nor throw.
216
+ *
217
+ * Failure mode: an absent or unrecognized kind THROWS a build-failing,
218
+ * actionable error rather than computing `KIND_LABEL[undefined]` → a bare "."
219
+ * (the v4.8–4.14 silent defect, live across 32+ ssm-foundations theorems). A
220
+ * typo'd kind — silent before — now stops the build with the offending value.
221
+ *
222
+ * Extracted from the `Theorem.astro` frontmatter so the contract is unit-tested
223
+ * in the pure `node --test` suite (see tests/theorem-label.test.mjs) — the same
224
+ * single-source-of-truth move as `academic-parts.ts` (#95).
225
+ */
226
+ declare const THEOREM_KINDS: readonly ["theorem", "proposition", "lemma", "corollary", "definition", "example", "exercise", "remark", "proof"];
227
+ type TheoremKind = (typeof THEOREM_KINDS)[number];
228
+ declare const KIND_LABEL: Record<TheoremKind, string>;
229
+ interface TheoremLabelProps {
230
+ /** Canonical environment selector. */
231
+ kind?: string;
232
+ /** Legacy alias for `kind` (LaTeX-env mental model; ssm-foundations ch1–11). */
233
+ type?: string;
234
+ /** Explicit number, e.g. "4.2"; omit for an unnumbered environment. */
235
+ n?: string;
236
+ /** Canonical display name shown in parentheses. */
237
+ name?: string;
238
+ /** Legacy aliases for `name`. */
239
+ title?: string;
240
+ label?: string;
241
+ }
242
+ interface ResolvedTheoremLabel {
243
+ /** The validated, canonical kind (for `data-kind`). */
244
+ kind: TheoremKind;
245
+ /** The composed "Kind N (Name)" label (never empty). */
246
+ fullLabel: string;
247
+ }
248
+ /**
249
+ * Resolve a `<Theorem>`'s `kind` (canonical or legacy `type=`) and compose its
250
+ * full label. Throws — never returns an empty label — when the kind is absent
251
+ * or not one of {@link THEOREM_KINDS}.
252
+ */
253
+ declare function theoremLabel(props: TheoremLabelProps): ResolvedTheoremLabel;
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
+
207
323
  /**
208
324
  * src/styles/built-in.ts — toolkit-shipped Styles, one per BookPreset (v4.0.0).
209
325
  *
@@ -305,4 +421,4 @@ type TipsConfigInput = Omit<TipsConfig, typeof TipsConfigBrand | '__tipsConfigVe
305
421
  */
306
422
  declare function defineTips(opts: TipsConfigInput): TipsConfig;
307
423
 
308
- export { ACADEMIC_PART_NAMES, BRANDON_PORTFOLIO_DEFAULT, BUILTIN_STYLES, BookConfigOptions, BookScaffoldIntegrationOptions, ChaptersRenderer, type Freshness, type FreshnessStatus, Style, 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, toolsChaptersRenderer, toolsStyle, volatilityLevels };
424
+ 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, 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,10 @@ 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
972
1032
  } = opts;
973
1033
  const def = PROFILES[profile];
974
1034
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -1009,6 +1069,8 @@ function bookScaffoldIntegration(opts) {
1009
1069
  }
1010
1070
  const consumerRoot = fileURLToPath(config.root);
1011
1071
  const resolvedMdxPath = resolveMdxComponentsPath(consumerRoot, mdxComponentsModule);
1072
+ const resolvedGithubRepo = resolveBookGithubRepo(githubRepo, consumerRoot);
1073
+ const resolvedGithubBranch = githubBranch ?? DEFAULT_GITHUB_BRANCH;
1012
1074
  const presetLiteral = JSON.stringify(profile);
1013
1075
  const enabledRouteNames = Object.entries(enabledRoutes).filter(([, on]) => on).map(([name]) => name);
1014
1076
  updateConfig({
@@ -1024,7 +1086,9 @@ function bookScaffoldIntegration(opts) {
1024
1086
  seo: {
1025
1087
  ogImage: seo?.ogImage ?? null,
1026
1088
  twitterHandle: seo?.twitterHandle ?? null
1027
- }
1089
+ },
1090
+ githubRepo: resolvedGithubRepo,
1091
+ githubBranch: resolvedGithubBranch
1028
1092
  })
1029
1093
  ],
1030
1094
  define: {
@@ -1157,7 +1221,10 @@ async function defineBookConfig(opts) {
1157
1221
  // Base.astro + Chapter.astro. `seo.sitemap` is NOT passed through —
1158
1222
  // it's consumed below at config-time by the @astrojs/sitemap call.
1159
1223
  author: opts.author,
1160
- seo: opts.seo ? { ogImage: opts.seo.ogImage, twitterHandle: opts.seo.twitterHandle } : void 0
1224
+ seo: opts.seo ? { ogImage: opts.seo.ogImage, twitterHandle: opts.seo.twitterHandle } : void 0,
1225
+ // v4.15.0 (#109): repo/branch override; integration auto-detects when undefined.
1226
+ githubRepo: opts.githubRepo,
1227
+ githubBranch: opts.githubBranch
1161
1228
  }),
1162
1229
  ...mergedExtraIntegrations
1163
1230
  ];
@@ -1199,6 +1266,9 @@ async function defineBookConfig(opts) {
1199
1266
  // v4.6.0: strip new book-level SEO opts (author + seo block).
1200
1267
  author: _author,
1201
1268
  seo: _seo,
1269
+ // v4.15.0: strip repo opts so they don't leak into AstroUserConfig.
1270
+ githubRepo: _githubRepo,
1271
+ githubBranch: _githubBranch,
1202
1272
  ...rest
1203
1273
  } = opts;
1204
1274
  void _styles;
@@ -1215,6 +1285,8 @@ async function defineBookConfig(opts) {
1215
1285
  void _portfolio;
1216
1286
  void _author;
1217
1287
  void _seo;
1288
+ void _githubRepo;
1289
+ void _githubBranch;
1218
1290
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1219
1291
  const restVite = rest.vite ?? {};
1220
1292
  const restSsr = restVite.ssr ?? {};
@@ -1253,6 +1325,58 @@ function chapterSortKey(data) {
1253
1325
  return partOrdinal * 1e3 + within;
1254
1326
  }
1255
1327
 
1328
+ // src/lib/theorem-label.ts
1329
+ var THEOREM_KINDS = [
1330
+ "theorem",
1331
+ "proposition",
1332
+ "lemma",
1333
+ "corollary",
1334
+ "definition",
1335
+ "example",
1336
+ "exercise",
1337
+ "remark",
1338
+ "proof"
1339
+ ];
1340
+ var KIND_LABEL = {
1341
+ theorem: "Theorem",
1342
+ proposition: "Proposition",
1343
+ lemma: "Lemma",
1344
+ corollary: "Corollary",
1345
+ definition: "Definition",
1346
+ example: "Example",
1347
+ exercise: "Exercise",
1348
+ remark: "Remark",
1349
+ proof: "Proof"
1350
+ };
1351
+ function isTheoremKind(value) {
1352
+ return value !== void 0 && THEOREM_KINDS.includes(value);
1353
+ }
1354
+ function theoremLabel(props) {
1355
+ const raw = props.kind ?? props.type;
1356
+ if (!isTheoremKind(raw)) {
1357
+ const detail = raw === void 0 ? "no kind= (or legacy type=) prop was passed" : `kind="${raw}" is not one of ${THEOREM_KINDS.join(", ")}`;
1358
+ const hint = props.type !== void 0 && props.kind === void 0 ? " (you passed type=; kind= is canonical, type= is accepted)" : "";
1359
+ throw new Error(
1360
+ `<Theorem>: cannot resolve a label \u2014 ${detail}. Pass a valid kind, e.g. kind="theorem"${hint}.`
1361
+ );
1362
+ }
1363
+ const name = props.name ?? props.title ?? props.label;
1364
+ const numbered = props.n ? `${KIND_LABEL[raw]} ${props.n}` : KIND_LABEL[raw];
1365
+ const fullLabel = name ? `${numbered} (${name})` : numbered;
1366
+ return { kind: raw, fullLabel };
1367
+ }
1368
+
1369
+ // src/lib/assert-prop.ts
1370
+ function assertEnumProp(value, allowed, ctx) {
1371
+ if (typeof value === "string" && allowed.includes(value)) {
1372
+ return value;
1373
+ }
1374
+ const got = value === void 0 ? "nothing" : JSON.stringify(value);
1375
+ throw new Error(
1376
+ `<${ctx.component}>: ${ctx.prop}=${got} is not one of ${allowed.join(", ")}.`
1377
+ );
1378
+ }
1379
+
1256
1380
  // src/styles/built-in.ts
1257
1381
  var academicStyle = defineStyle({
1258
1382
  name: "academic",
@@ -1299,6 +1423,9 @@ export {
1299
1423
  BRANDON_PORTFOLIO_DEFAULT,
1300
1424
  BUILTIN_STYLES,
1301
1425
  BookConfigError,
1426
+ DEFAULT_GITHUB_BRANCH,
1427
+ KIND_LABEL,
1428
+ THEOREM_KINDS,
1302
1429
  UNKNOWN_PART_ORDINAL,
1303
1430
  academicChapterSchema,
1304
1431
  academicChaptersRenderer,
@@ -1307,7 +1434,9 @@ export {
1307
1434
  academicPartOrdinal,
1308
1435
  academicParts,
1309
1436
  academicStyle,
1437
+ assertEnumProp,
1310
1438
  bookScaffoldIntegration,
1439
+ buildGithubUrl,
1311
1440
  changeKinds,
1312
1441
  changelogSchema,
1313
1442
  chapterSortKey,
@@ -1327,17 +1456,21 @@ export {
1327
1456
  minimalChapterSchema,
1328
1457
  minimalStyle,
1329
1458
  normalizeFrontmatterConfig,
1459
+ originUrlFromGitConfig,
1460
+ parseRepoSlug,
1330
1461
  patternCategories,
1331
1462
  patternsSchema,
1332
1463
  provenanceObject,
1333
1464
  provenanceSchema,
1334
1465
  researchPortfolioChapterSchema,
1335
1466
  researchPortfolioStyle,
1467
+ resolveGithubRepo,
1336
1468
  resolvePreset,
1337
1469
  resolveProfile,
1338
1470
  sourceTiers,
1339
1471
  sourceTiersResearch,
1340
1472
  sourcesSchema,
1473
+ theoremLabel,
1341
1474
  toolSlugs,
1342
1475
  toolsChapterSchema,
1343
1476
  toolsChaptersRenderer,
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-CjOseOSG.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.2",
4
+ "version": "4.15.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -105,6 +105,23 @@ Not in v2.0:
105
105
 
106
106
  Until v3.0 triggers, accept that bug fixes don't auto-flow to existing books. Both books are currently low-churn.
107
107
 
108
+ ## Visual-suite migration → gallery + Playwright (v5.x infra, 2026-06)
109
+
110
+ ### D18 — Replace run.sh with a component gallery + Playwright
111
+ **Decision**: A standalone `gallery/` app renders every component × state × theme; Playwright (`channel:'chrome'`, full-page, pixelmatch) drives it (`playwright.config.ts`) **and** the 6 profile fixtures' 24 routes (`playwright.fixtures.config.ts`). `run.sh` + its 96 baselines + `visual-regression.yml` + `update-baselines.yml` are retired; `visual-pw.yml` is the sole visual gate.
112
+ **Reasoning**: run.sh's `--window-size=Wx2000` cap left below-fold content untested (root of #82); full-page `toHaveScreenshot` removes the fold + adds interaction (expand `<details>`, dark-mode toggle, canvas recolor). `channel:'chrome'` uses system Chrome — dissolves the "chromium-headless-shell won't build on every distro" objection that drove run.sh.
113
+ **Deviate when**: Never reintroduce viewport-capped screenshots. New components → gallery pages; new routes → fixture-suite entries.
114
+
115
+ ### D19 — Baselines are CI-generated (glyph pages especially)
116
+ **Decision**: Commit baselines from the `visual-pw` `update` dispatch (ubuntu + system chrome). ASCII/common-glyph pages are byte-identical CI==local, but math/Unicode-glyph pages (λ, ℂ, Λ) render with platform fallback fonts and MUST be CI-generated.
117
+ **Reasoning**: Cross-OS font rendering diverges; pixelmatch `maxDiffPixels` absorbs AA, not glyph substitution.
118
+ **Deviate when**: Never hand-commit a glyph-page baseline from a dev box.
119
+
120
+ ### D20 — Full route parity before retiring run.sh (Q3)
121
+ **Decision**: The fixture suite covers run.sh's 24 routes (2 widths: mobile 768 + desktop 1280; run.sh's 1440/1920 were redundant) before deletion. Porting caught a stale run.sh route (research-portfolio `/chapters/example/` was a silently-screenshotted 404 — real slug `ch01-fixture`) + the hardcoded-`GITHUB_REPO` CodeRef/CodeBlock wart (#109).
122
+ **Reasoning**: #82 itself was a coverage gap — don't trade coverage for speed.
123
+ **Deviate when**: Never delete a visual gate without equivalent-or-better coverage in the replacement.
124
+
108
125
  ## How to use this ledger
109
126
 
110
127
  When a future change in the scaffold contradicts a decision above, update this ledger first. Don't change behavior silently — the ledger is the durable record of "why this is shaped like it is."
@@ -13,6 +13,8 @@
13
13
  * 4. Internal markdown links [text](/foo) — target resolves.
14
14
  * 5. <CodeRef path="..." line={N} /> — when BOOK_REPO_ROOT set,
15
15
  * path exists + line in bounds.
16
+ * 6. <Theorem> — has a resolvable kind= (or legacy type=); else it would
17
+ * render an empty label and throw at build (#121).
16
18
  *
17
19
  * Run from the consumer's project root. Closes #8 (was resolving paths
18
20
  * from the package's own directory inside node_modules — false negatives
@@ -209,6 +211,9 @@ const RE_XREF = /<XRef[^>]+id=["']([^"']+)["']/g;
209
211
  const RE_FIGURE = /<Figure[^>]+src=["']([^"']+)["']/g;
210
212
  const RE_CODEREF = /<CodeRef[^>]+path=["']([^"']+)["'](?:[^>]*line=\{(\d+)\})?(?:[^>]*lineEnd=\{(\d+)\})?/g;
211
213
  const RE_MD_LINK = /\[(?:[^\]]*)\]\((\/[^)\s#]+)(?:#[^)]*)?\)/g;
214
+ // #121: a <Theorem> opening tag — capture its attributes to assert a
215
+ // resolvable kind= (or legacy type=) is present.
216
+ const RE_THEOREM = /<Theorem\b([^>]*)>/g;
212
217
 
213
218
  async function fileExists(p) {
214
219
  try {
@@ -278,6 +283,19 @@ for (const rel of chapterFiles) {
278
283
  }
279
284
  }
280
285
  }
286
+
287
+ // 6. Theorem requires a resolvable kind (#121) — kind= canonical, type=
288
+ // legacy alias. Catches the silent-empty-label / build-throw case at the
289
+ // earliest gate. (Value typos are caught at build by theoremLabel's throw.)
290
+ for (const m of content.matchAll(RE_THEOREM)) {
291
+ if (!/\b(?:kind|type)\s*=/.test(m[1])) {
292
+ fail(
293
+ rel,
294
+ lineOf(content, m.index),
295
+ `<Theorem> has no kind= (or legacy type=) — renders an empty label / throws at build. Add e.g. kind="theorem".`,
296
+ );
297
+ }
298
+ }
281
299
  }
282
300
 
283
301
  // ===== 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
+ }
@@ -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) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * theorem-label — resolve a `<Theorem>` component's label from its props,
3
+ * failing loud instead of rendering a silent empty label (#121).
4
+ *
5
+ * Vocabulary: `kind` is canonical; `type` is an accepted **legacy alias** (many
6
+ * consumer books — ssm-foundations ch1–11 — and the LaTeX `\begin{<env>}`
7
+ * mental model pass `type=`). Likewise `name` is canonical with `title`/`label`
8
+ * accepted as aliases. Aliases keep existing content valid; they are synonyms,
9
+ * not deprecations, so they neither warn nor throw.
10
+ *
11
+ * Failure mode: an absent or unrecognized kind THROWS a build-failing,
12
+ * actionable error rather than computing `KIND_LABEL[undefined]` → a bare "."
13
+ * (the v4.8–4.14 silent defect, live across 32+ ssm-foundations theorems). A
14
+ * typo'd kind — silent before — now stops the build with the offending value.
15
+ *
16
+ * Extracted from the `Theorem.astro` frontmatter so the contract is unit-tested
17
+ * in the pure `node --test` suite (see tests/theorem-label.test.mjs) — the same
18
+ * single-source-of-truth move as `academic-parts.ts` (#95).
19
+ */
20
+
21
+ export const THEOREM_KINDS = [
22
+ 'theorem',
23
+ 'proposition',
24
+ 'lemma',
25
+ 'corollary',
26
+ 'definition',
27
+ 'example',
28
+ 'exercise',
29
+ 'remark',
30
+ 'proof',
31
+ ] as const;
32
+
33
+ export type TheoremKind = (typeof THEOREM_KINDS)[number];
34
+
35
+ export const KIND_LABEL: Record<TheoremKind, string> = {
36
+ theorem: 'Theorem',
37
+ proposition: 'Proposition',
38
+ lemma: 'Lemma',
39
+ corollary: 'Corollary',
40
+ definition: 'Definition',
41
+ example: 'Example',
42
+ exercise: 'Exercise',
43
+ remark: 'Remark',
44
+ proof: 'Proof',
45
+ };
46
+
47
+ export interface TheoremLabelProps {
48
+ /** Canonical environment selector. */
49
+ kind?: string;
50
+ /** Legacy alias for `kind` (LaTeX-env mental model; ssm-foundations ch1–11). */
51
+ type?: string;
52
+ /** Explicit number, e.g. "4.2"; omit for an unnumbered environment. */
53
+ n?: string;
54
+ /** Canonical display name shown in parentheses. */
55
+ name?: string;
56
+ /** Legacy aliases for `name`. */
57
+ title?: string;
58
+ label?: string;
59
+ }
60
+
61
+ export interface ResolvedTheoremLabel {
62
+ /** The validated, canonical kind (for `data-kind`). */
63
+ kind: TheoremKind;
64
+ /** The composed "Kind N (Name)" label (never empty). */
65
+ fullLabel: string;
66
+ }
67
+
68
+ function isTheoremKind(value: string | undefined): value is TheoremKind {
69
+ return value !== undefined && (THEOREM_KINDS as readonly string[]).includes(value);
70
+ }
71
+
72
+ /**
73
+ * Resolve a `<Theorem>`'s `kind` (canonical or legacy `type=`) and compose its
74
+ * full label. Throws — never returns an empty label — when the kind is absent
75
+ * or not one of {@link THEOREM_KINDS}.
76
+ */
77
+ export function theoremLabel(props: TheoremLabelProps): ResolvedTheoremLabel {
78
+ const raw = props.kind ?? props.type;
79
+ if (!isTheoremKind(raw)) {
80
+ const detail =
81
+ raw === undefined
82
+ ? 'no kind= (or legacy type=) prop was passed'
83
+ : `kind="${raw}" is not one of ${THEOREM_KINDS.join(', ')}`;
84
+ const hint =
85
+ props.type !== undefined && props.kind === undefined
86
+ ? ' (you passed type=; kind= is canonical, type= is accepted)'
87
+ : '';
88
+ throw new Error(
89
+ `<Theorem>: cannot resolve a label — ${detail}. ` +
90
+ `Pass a valid kind, e.g. kind="theorem"${hint}.`,
91
+ );
92
+ }
93
+
94
+ const name = props.name ?? props.title ?? props.label;
95
+ const numbered = props.n ? `${KIND_LABEL[raw]} ${props.n}` : KIND_LABEL[raw];
96
+ const fullLabel = name ? `${numbered} (${name})` : numbered;
97
+ return { kind: raw, fullLabel };
98
+ }