@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 +1 -1
- package/components/CodeBlock.astro +20 -7
- package/components/CodeRef.astro +15 -2
- package/components/PocLayout.astro +10 -2
- package/components/Practice.astro +11 -2
- package/components/StatusBadge.astro +18 -9
- package/components/Theorem.astro +13 -38
- package/dist/index.d.ts +119 -3
- package/dist/index.mjs +136 -3
- package/dist/schemas.d.ts +1 -1
- package/package.json +1 -1
- package/recipes/08-decisions-ledger.md +17 -0
- package/scripts/validate.mjs +18 -0
- package/src/lib/assert-prop.ts +30 -0
- package/src/lib/repo-url.ts +82 -17
- package/src/lib/theorem-label.ts +98 -0
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 (
|
|
8
|
-
*
|
|
9
|
-
* to Astro's `<Code>`
|
|
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
|
-
//
|
|
41
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
---
|
package/components/CodeRef.astro
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* CodeRef — inline reference to a specific file (and optional line range)
|
|
4
|
-
* in the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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/components/Theorem.astro
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
|
12
|
-
*
|
|
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
|
|
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 {
|
|
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-
|
|
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-
|
|
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
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.
|
|
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."
|
package/scripts/validate.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/repo-url.ts
CHANGED
|
@@ -1,27 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* repo-url — resolve and build GitHub source links for CodeRef / CodeBlock.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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('
|
|
19
|
-
* buildGithubUrl('
|
|
20
|
-
* buildGithubUrl('
|
|
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(
|
|
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 =
|
|
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
|
+
}
|