@brandon_m_behring/book-scaffold-astro 3.1.0 → 3.3.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.
@@ -0,0 +1,130 @@
1
+ # LaTeX → MDX component mapping
2
+
3
+ A consumer-facing reference for converting LaTeX book sources into MDX that consumes `@brandon_m_behring/book-scaffold-astro` components.
4
+
5
+ The scaffold ships **38 components**. Without this map, a `.tex → .mdx` conversion ends up rediscovering them by grep — and frequently rebuilding duplicates. This doc is the canonical "if your LaTeX source has `\begin{<env>}`, here's the component" reference.
6
+
7
+ > Pair with [PACKAGE_DESIGN.md](./PACKAGE_DESIGN.md) §17 for the broader migration story and the `defineMdxComponents` helper for consumer-shipped extensions.
8
+
9
+ ## Components shipped by the scaffold
10
+
11
+ | LaTeX construct | MDX component | Import path | Signature | Notes |
12
+ |---|---|---|---|---|
13
+ | `\begin{tcolorbox}[narrativebox]` | `SkillBox` | `…/components/SkillBox.astro` | `title: string` | Recipe / how-to box |
14
+ | `\begin{tcolorbox}[conceptbox]` | `ConceptBox` | `…/components/ConceptBox.astro` | `term: string` | Single-term definition |
15
+ | `\begin{tcolorbox}[insightbox]` | `InsightBox` | `…/components/InsightBox.astro` | `title?: string` | Non-obvious observation |
16
+ | `\begin{keyconcept}` | `KeyIdea` | `…/components/KeyIdea.astro` | — | Crystallized takeaway |
17
+ | `\begin{warnbox}` / `\warningmargin{}` | `WarnBox` | `…/components/WarnBox.astro` | `title?: string` | Caveats / failure modes |
18
+ | `\begin{notebox}` | `NoteBox` | `…/components/NoteBox.astro` | `title?: string` | Chapter overviews |
19
+ | `\begin{paperbox}` | `PaperBox` | `…/components/PaperBox.astro` | `title?: string` | Paper restatement |
20
+ | `\begin{counterbox}` | `CounterBox` | `…/components/CounterBox.astro` | `title?: string` | Counter-evidence |
21
+ | `\begin{examplebox}` | `ExampleBox` | `…/components/ExampleBox.astro` | `title?: string` | Extended walkthrough |
22
+ | `\begin{openquestion}` | `OpenQuestion` | `…/components/OpenQuestion.astro` | `title?: string` | Research questions |
23
+ | `\begin{trythis}` | `TryThis` | `…/components/TryThis.astro` | `title?: string` | Practice exercise |
24
+ | `\begin{tipbox}` | `TipBox` | `…/components/TipBox.astro` | `title?: string` | Pro tips / shortcuts |
25
+ | `\begin{dynconnect}` | `DynConnect` | `…/components/DynConnect.astro` | `title?: string` | Cross-domain connection |
26
+ | `\begin{theorem}` / `\begin{proposition}` / `\begin{lemma}` / `\begin{corollary}` / `\begin{definition}` / `\begin{remark}` / `\begin{proof}` | `Theorem` | `…/components/Theorem.astro` | `kind, n?, name?, id?` | amsthm family — single component dispatches via `kind` prop |
27
+ | `\marginnote{}` | `MarginNote` | `…/components/MarginNote.astro` | — | Side commentary |
28
+ | `\sidenote{}` | `Sidenote` | `…/components/Sidenote.astro` | — | Auto-numbered marginalia (Tufte) |
29
+ | `\includegraphics + \caption` | `Figure` | `…/components/Figure.astro` | `src, caption?, id?` | XRef-registered |
30
+ | `\cite{}` / `\parencite{}` | `Citation` | `…/components/Citation.astro` | `src, as?` | Resolves `sources` collection |
31
+ | `\cite{}` (inline) | `Cite` | `…/components/Cite.astro` | `key` | Inline citation key |
32
+ | `\xref{}` / `\cref{}` | `XRef` | `…/components/XRef.astro` | `id` | Cross-reference resolver |
33
+ | `\code{path:N}` / inline file refs | `CodeRef` | `…/components/CodeRef.astro` | `path, line?, lineEnd?` | GitHub-linked source ref |
34
+ | (custom Shiki blocks) | `CodeBlock` | `…/components/CodeBlock.astro` | `lang, title?` | Wrapped fenced code |
35
+ | `\recovery{}` | `Recovery` | `…/components/Recovery.astro` | `pattern, symptom?` | Anti-pattern escape |
36
+ | `\casestudy{}` | `CaseStudy` | `…/components/CaseStudy.astro` | `date, title?` | Dated anecdote |
37
+ | `\weekref{}` (academic) | `WeekRef` | `…/components/WeekRef.astro` | `week` | Cross-chapter week ref |
38
+
39
+ Component subset table for tools-profile-specific UI (volatility dashboards, convergence timelines):
40
+
41
+ | Construct | Component | Use case |
42
+ |---|---|---|
43
+ | Volatility badge | `Tag` | `volatility` enum chip in chapter meta |
44
+ | Tool comparison | `ToolFilter` (island) | Interactive comparison gate |
45
+ | Version selector | `VersionSelector` (island) | Switch between tool versions |
46
+ | Convergence event | `Convergence` | "All tools converged here" timeline marker |
47
+ | Divergence event | `Divergence` | "Tool X went its own way" annotation |
48
+ | Pattern timeline | `PatternTimeline` | Multi-event convergence dashboard |
49
+ | Status badge | `StatusBadge` | 7→3-state translation (academic) |
50
+ | Source archive | `SourceArchive` | Tier-tagged source listing |
51
+
52
+ ## What is NOT shipped (extension candidates)
53
+
54
+ The scaffold deliberately doesn't ship these. Add to your consumer via the [`defineMdxComponents`](#consumer-side-extensions-definemdxcomponents) helper described below; surface as a tracked issue if the gap recurs across pilots.
55
+
56
+ - `\begin{problem}` / `\begin{solution}` — interview-prep problem cards
57
+ - `\begin{vignette}` — multi-step scenario walkthroughs
58
+ - `\begin{decisiontree}` — branching decision logic
59
+ - `\begin{interviewcontext}` — interview-tied learning-outcome callouts
60
+ - `<AnkiCard>` — flashcard widget
61
+ - `<Term>` — glossary term reference with tooltip
62
+ - `<RedFlag>` — escalated warning beyond `WarnBox`
63
+ - `<NarrativeBox>` (with extended props) — if a consumer needs richer narrative annotations beyond `SkillBox`
64
+
65
+ ## Consumer-side extensions: `defineMdxComponents`
66
+
67
+ When your book uses custom MDX components, create `src/mdx-components.ts` (or `.js` / `.mjs`) at your project root. The scaffold auto-detects it and threads the components through all auto-injected routes (`/print`, future `/pdf`, `/epub`).
68
+
69
+ ```ts
70
+ // consumer's src/mdx-components.ts
71
+ import { defineMdxComponents } from '@brandon_m_behring/book-scaffold-astro';
72
+ import AnkiCard from './components/AnkiCard.astro';
73
+ import NarrativeBox from './components/NarrativeBox.astro';
74
+ import Term from './components/Term.astro';
75
+
76
+ export default defineMdxComponents({
77
+ AnkiCard,
78
+ NarrativeBox,
79
+ Term,
80
+ });
81
+ ```
82
+
83
+ The `defineMdxComponents<T>()` helper is a TypeScript identity function — it returns the value unchanged, but preserves the exact key→component type mapping for IntelliSense. Same pattern as Vite/Astro `defineConfig`, Zod `z.object`, Drizzle `pgTable`.
84
+
85
+ To use a non-default path, pass it explicitly:
86
+
87
+ ```ts
88
+ // astro.config.mjs
89
+ export default defineBookConfig({
90
+ site: '...',
91
+ mdxComponentsModule: './src/my-custom-components.ts',
92
+ });
93
+ ```
94
+
95
+ ## Disabling auto-injected routes
96
+
97
+ The scaffold auto-injects per-profile defaults (see [PACKAGE_DESIGN.md](./PACKAGE_DESIGN.md) §6). Multi-book consumers (one Astro app, many books under `[book]/[chapter]`) typically want the flat `/chapters` route off:
98
+
99
+ ```ts
100
+ // astro.config.mjs
101
+ import { defineBookConfig } from '@brandon_m_behring/book-scaffold-astro';
102
+
103
+ export default defineBookConfig({
104
+ site: '...',
105
+ profile: 'course-notes',
106
+ routes: {
107
+ chapters: false, // override the profile default
108
+ convergence: false,
109
+ },
110
+ });
111
+ ```
112
+
113
+ The shape is fixed (`references | search | print | chapters | convergence`) and TypeScript catches typos like `convergance: false`.
114
+
115
+ ## Common conversion mistakes
116
+
117
+ Errors observed during the DLAI pilot (closed in v3.3.0 issue #5):
118
+
119
+ 1. **Built `NarrativeBox` from scratch** → should have used `SkillBox`. Same vertical box semantic; `SkillBox` already has the `title` prop.
120
+ 2. **Built `ConceptBox` (block container)** → conflicts with scaffold's `ConceptBox` (term-definition signature). Either rename the consumer one (`ConceptBlock`) or use scaffold's signature.
121
+ 3. **Built `KeyConcept`** → should have used `KeyIdea`. Same crystallized-takeaway role; the scaffold's name comes from the Tufte-style margin-emphasis convention.
122
+ 4. **Built `RedFlag`** → should have used `WarnBox`. Add a higher-severity variant via consumer-side `defineMdxComponents` if needed instead of duplicating WarnBox's semantic.
123
+ 5. **Built `Sidenote` with category prop** → conflicts with scaffold's auto-numbered `Sidenote`. The scaffold uses CSS counters; consumer-side category metadata can wrap (e.g., `<TypedSidenote category="recovery"><Sidenote>...</Sidenote></TypedSidenote>`).
124
+ 6. **Built duplicate `Citation` and `Figure`** → scaffold's versions are XRef-registered. Use them; extend behavior via wrapper components if richer attribution is needed.
125
+
126
+ ## See also
127
+
128
+ - [PACKAGE_DESIGN.md](./PACKAGE_DESIGN.md) — full API contract + Phase A planning decisions
129
+ - [README.md](./README.md) — toolkit overview + getting started
130
+ - [CHANGELOG.md](../CHANGELOG.md) — release notes (issue #5 closed in v3.3.0)
@@ -9,9 +9,15 @@
9
9
  * part, status, companion artifacts) appears in its place.
10
10
  *
11
11
  * v3.1.0 — academic flavor: Roman-numeral part labels, StatusBadge
12
- * component, and an optional companion-artifacts block matching v2.0's
13
- * hand-rolled academic ChapterHeader (verbatim restore for content density
14
- * parity at narrow viewports).
12
+ * component, and an optional companion-artifacts block.
13
+ *
14
+ * v3.2.0 companions refactored from sibling <aside> to inline <span>
15
+ * elements inside the existing .chapter-meta flex row. v3.1.0 shipped
16
+ * <aside class="chapter-companions"> with no CSS; UA-default <ul> block
17
+ * layout added ~100px height at <=1280px, producing a uniform vertical
18
+ * pixel shift on all academic chapters. Inline rendering eliminates the
19
+ * extra block by construction. The data-companion attribute on each
20
+ * inline span preserves introspection.
15
21
  */
16
22
  import type { CollectionEntry } from 'astro:content';
17
23
  import { getFreshness, freshnessLabel } from '../src/lib/freshness';
@@ -92,13 +98,14 @@ const volatility = typeof d.volatility === 'string' ? d.volatility : null;
92
98
  // generic basename strip so any book whose render-notebooks output lands
93
99
  // under public/notebooks/ gets a correct deep link (v2.0 hardcoded a
94
100
  // post_transformers-specific prefix; v3.1.0 generalizes).
95
- const hasCompanions =
96
- hasAcademicMeta &&
97
- (typeof d.code_path === 'string' ||
98
- typeof d.tests_path === 'string' ||
99
- typeof d.notebook_path === 'string');
101
+ //
102
+ // v3.2.0: each companion renders as an inline <span class="chapter-companion">
103
+ // inside .chapter-meta no sibling <aside>, no <ul>, no "Companion artifacts:"
104
+ // label. Zero added vertical height vs v2.0.
105
+ const codePath = hasAcademicMeta && typeof d.code_path === 'string' ? d.code_path : null;
106
+ const testsPath = hasAcademicMeta && typeof d.tests_path === 'string' ? d.tests_path : null;
100
107
  const notebookHtmlPath =
101
- typeof d.notebook_path === 'string'
108
+ hasAcademicMeta && typeof d.notebook_path === 'string'
102
109
  ? `/notebooks/${(d.notebook_path as string)
103
110
  .replace(/^.*\//, '')
104
111
  .replace(/\.ipynb$/, '')}.html`
@@ -126,33 +133,25 @@ const notebookHtmlPath =
126
133
  </span>
127
134
  )}
128
135
  {updated && <span>Updated {formatDate(updated)}</span>}
136
+ {codePath && (
137
+ <span class="chapter-companion" data-companion="code">
138
+ <CodeRef path={codePath} />
139
+ </span>
140
+ )}
141
+ {testsPath && (
142
+ <span class="chapter-companion" data-companion="tests">
143
+ <CodeRef path={testsPath} />
144
+ </span>
145
+ )}
146
+ {notebookHtmlPath && (
147
+ <span class="chapter-companion" data-companion="notebook">
148
+ <a href={notebookHtmlPath}>Notebook</a>
149
+ </span>
150
+ )}
129
151
  </div>
130
152
  <h1>{title}</h1>
131
153
  {description && <p class="chapter-description">{description}</p>}
132
154
 
133
- {hasCompanions && (
134
- <aside class="chapter-companions">
135
- <strong>Companion artifacts:</strong>
136
- <ul>
137
- {typeof d.code_path === 'string' && (
138
- <li>
139
- Implementation: <CodeRef path={d.code_path as string} />
140
- </li>
141
- )}
142
- {typeof d.tests_path === 'string' && (
143
- <li>
144
- Tests: <CodeRef path={d.tests_path as string} />
145
- </li>
146
- )}
147
- {notebookHtmlPath && (
148
- <li>
149
- Notebook: <a href={notebookHtmlPath}>{notebookHtmlPath}</a>
150
- </li>
151
- )}
152
- </ul>
153
- </aside>
154
- )}
155
-
156
155
  {hasToolsMeta && volatility && (
157
156
  <div class="chapter-badge-row">
158
157
  <span class="chapter-badge-row-label">Volatility:</span>
package/dist/index.d.ts CHANGED
@@ -1,135 +1,82 @@
1
1
  import { AstroUserConfig, AstroIntegration } from 'astro';
2
- import { b as BookConfigOptions, d as BookScaffoldIntegrationOptions } from './types-BoCXCvBy.js';
3
- export { B as BOOK_PROFILES, a as BookConfigError, c as BookProfile, e as BookSchemasOptions, r as resolveProfile } from './types-BoCXCvBy.js';
4
- import { z } from 'astro/zod';
2
+ import { b as BookConfigOptions, d as BookScaffoldIntegrationOptions, v as volatilityLevels } from './types-s8NxCLU2.js';
3
+ export { A as AcademicChapter, B as BOOK_PROFILES, a as BookConfigError, c as BookProfile, e as BookSchemasOptions, C as ChapterFor, f as CourseNotesChapter, M as MinimalChapter, P as ProfileDefinition, R as RouteToggles, T as ToolsChapter, g as academicChapterSchema, h as academicParts, i as changeKinds, j as changelogSchema, k as chapterStatus, l as courseNotesChapterSchema, m as defineProfile, n as minimalChapterSchema, p as patternCategories, o as patternsSchema, r as resolveProfile, s as sourceTiers, q as sourcesSchema, t as toolSlugs, u as toolsChapterSchema } from './types-s8NxCLU2.js';
4
+ import 'astro/zod';
5
5
 
6
6
  declare function defineBookConfig(opts: BookConfigOptions): Promise<AstroUserConfig>;
7
7
 
8
8
  declare function bookScaffoldIntegration(opts: BookScaffoldIntegrationOptions): AstroIntegration;
9
9
 
10
10
  /**
11
- * Zod schemas + enum constants for book content collections.
11
+ * Identity helper consumers wrap their mdx-components map for TS inference.
12
+ * Same pattern as Vite/Astro defineConfig: a generic identity function that
13
+ * preserves the exact shape (vs widening to Record<string, …>).
12
14
  *
13
- * Imports `z` from `astro/zod` (a real module re-export) so schemas can
14
- * be constructed at package-load time outside an Astro runtime context.
15
- * `defineBookSchemas` in index.ts wraps these into Astro `defineCollection`
16
- * calls at the consumer's content-config load time.
15
+ * export default defineMdxComponents({ AnkiCard, NarrativeBox });
17
16
  *
18
- * Schemas ported verbatim from v2.0 src/content.config.ts. See
19
- * PACKAGE_DESIGN.md §5 for the public reproduction.
17
+ * The generic constraint Record<string, unknown> is intentionally loose:
18
+ * consumer components are `.astro` files whose runtime type
19
+ * (AstroComponentFactory) lives in `astro/runtime/server/index.js` — not a
20
+ * public Astro export, and importing from internals creates fragility. The
21
+ * looser constraint lets the helper compile cleanly across Astro versions;
22
+ * IntelliSense still surfaces exact keys.
20
23
  */
24
+ declare function defineMdxComponents<T extends Record<string, unknown>>(components: T): T;
21
25
 
22
- declare const toolSlugs: readonly ["claude-code", "gemini-cli", "codex-cli", "cross-tool"];
23
- declare const volatilityLevels: readonly ["stable-principle", "architectural-pattern", "feature-surface"];
24
- declare const sourceTiers: readonly ["T1-official", "T2-release-notes", "T3-practitioner", "T4-conjecture"];
25
- declare const changeKinds: readonly ["added", "removed", "changed", "deprecated"];
26
- declare const patternCategories: readonly ["safety", "scale", "context", "interaction", "extension", "other"];
27
- declare const academicParts: readonly ["foundations", "ssm-core", "beyond-ssm", "integration", "synthesis"];
28
- declare const chapterStatus: readonly ["implemented", "chapter_only", "reading_only", "prose_only", "code_only", "scaffolded", "planned"];
29
- declare const academicChapterSchema: z.ZodObject<{
30
- week: z.ZodNumber;
31
- part: z.ZodEnum<{
32
- foundations: "foundations";
33
- "ssm-core": "ssm-core";
34
- "beyond-ssm": "beyond-ssm";
35
- integration: "integration";
36
- synthesis: "synthesis";
37
- }>;
38
- title: z.ZodString;
39
- status: z.ZodEnum<{
40
- implemented: "implemented";
41
- chapter_only: "chapter_only";
42
- reading_only: "reading_only";
43
- prose_only: "prose_only";
44
- code_only: "code_only";
45
- scaffolded: "scaffolded";
46
- planned: "planned";
47
- }>;
48
- roadmap_lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
49
- code_path: z.ZodOptional<z.ZodString>;
50
- tests_path: z.ZodOptional<z.ZodString>;
51
- notebook_path: z.ZodOptional<z.ZodString>;
52
- description: z.ZodOptional<z.ZodString>;
53
- draft: z.ZodDefault<z.ZodBoolean>;
54
- }, z.core.$strip>;
55
- declare const toolsChapterSchema: z.ZodObject<{
56
- title: z.ZodString;
57
- part: z.ZodNumber;
58
- chapter: z.ZodNumber;
59
- volatility: z.ZodEnum<{
60
- "stable-principle": "stable-principle";
61
- "architectural-pattern": "architectural-pattern";
62
- "feature-surface": "feature-surface";
63
- }>;
64
- tools_compared: z.ZodArray<z.ZodEnum<{
65
- "claude-code": "claude-code";
66
- "gemini-cli": "gemini-cli";
67
- "codex-cli": "codex-cli";
68
- "cross-tool": "cross-tool";
69
- }>>;
70
- last_verified: z.ZodDate;
71
- sources: z.ZodDefault<z.ZodArray<z.ZodString>>;
72
- description: z.ZodOptional<z.ZodString>;
73
- draft: z.ZodDefault<z.ZodBoolean>;
74
- updated: z.ZodOptional<z.ZodDate>;
75
- }, z.core.$strip>;
76
- declare const sourcesSchema: z.ZodObject<{
77
- url: z.ZodString;
78
- title: z.ZodString;
79
- author: z.ZodOptional<z.ZodString>;
80
- publish_date: z.ZodOptional<z.ZodDate>;
81
- captured_at: z.ZodDate;
82
- content_hash: z.ZodOptional<z.ZodString>;
83
- tier: z.ZodEnum<{
84
- "T1-official": "T1-official";
85
- "T2-release-notes": "T2-release-notes";
86
- "T3-practitioner": "T3-practitioner";
87
- "T4-conjecture": "T4-conjecture";
88
- }>;
89
- tool: z.ZodEnum<{
90
- "claude-code": "claude-code";
91
- "gemini-cli": "gemini-cli";
92
- "codex-cli": "codex-cli";
93
- "cross-tool": "cross-tool";
94
- }>;
95
- perma_cc: z.ZodOptional<z.ZodNullable<z.ZodString>>;
96
- local_cache: z.ZodOptional<z.ZodNullable<z.ZodString>>;
97
- }, z.core.$strip>;
98
- declare const changelogSchema: z.ZodObject<{
99
- tool: z.ZodEnum<{
100
- "claude-code": "claude-code";
101
- "gemini-cli": "gemini-cli";
102
- "codex-cli": "codex-cli";
103
- "cross-tool": "cross-tool";
104
- }>;
105
- versions: z.ZodDefault<z.ZodArray<z.ZodObject<{
106
- version: z.ZodString;
107
- date: z.ZodDate;
108
- changes: z.ZodDefault<z.ZodArray<z.ZodObject<{
109
- pattern: z.ZodString;
110
- kind: z.ZodEnum<{
111
- added: "added";
112
- removed: "removed";
113
- changed: "changed";
114
- deprecated: "deprecated";
115
- }>;
116
- note: z.ZodString;
117
- source_key: z.ZodOptional<z.ZodString>;
118
- }, z.core.$strip>>>;
119
- }, z.core.$strip>>>;
120
- }, z.core.$strip>;
121
- declare const patternsSchema: z.ZodObject<{
122
- name: z.ZodString;
123
- description: z.ZodOptional<z.ZodString>;
124
- category: z.ZodOptional<z.ZodEnum<{
125
- safety: "safety";
126
- scale: "scale";
127
- context: "context";
128
- interaction: "interaction";
129
- extension: "extension";
130
- other: "other";
131
- }>>;
132
- convergence_date: z.ZodOptional<z.ZodNullable<z.ZodDate>>;
133
- }, z.core.$strip>;
26
+ /**
27
+ * src/lib/freshness.ts volatility-aware staleness computation.
28
+ *
29
+ * Each chapter carries a `last_verified` date and a `volatility` class.
30
+ * This module maps those to a freshness status the reader can trust.
31
+ *
32
+ * Thresholds chosen to align with Ch 15's source-tier audit cadences:
33
+ * stable-principle → 365 days (principles drift annually at most)
34
+ * architectural-pattern → 180 days (between annual and quarterly; "on
35
+ * major release" isn't derivable from
36
+ * frontmatter)
37
+ * feature-surface → 90 days (quarterly)
38
+ *
39
+ * Status bands (fraction of threshold):
40
+ * fresh (<75%) — green dot, unobtrusive
41
+ * verify-soon (75-100%) — yellow, mild warning
42
+ * stale (>100%) — amber/red, "verify before trusting"
43
+ *
44
+ * Example assertions (verified by visual inspection during Stage 3.1):
45
+ * getFreshness(today, 'feature-surface').status === 'fresh'
46
+ * getFreshness(today-70d, 'feature-surface').status === 'verify-soon'
47
+ * getFreshness(today-100d, 'feature-surface').status === 'stale'
48
+ * getFreshness(today-200d, 'stable-principle').status === 'fresh'
49
+ * getFreshness(today-300d, 'stable-principle').status === 'verify-soon'
50
+ */
51
+
52
+ type VolatilityLevel = (typeof volatilityLevels)[number];
53
+ type FreshnessStatus = 'fresh' | 'verify-soon' | 'stale';
54
+ interface Freshness {
55
+ status: FreshnessStatus;
56
+ daysOld: number;
57
+ thresholdDays: number;
58
+ /** Days until stale; negative when already stale. */
59
+ daysUntil: number;
60
+ }
61
+ /**
62
+ * Compute freshness for a chapter given its last_verified date + volatility.
63
+ *
64
+ * Pure function; caller supplies `now` only in tests. Production callers omit.
65
+ *
66
+ * v3.3.0 (closes issue #1): tolerant of `lastVerified === undefined`. Returns
67
+ * `null` instead of crashing when the chapter schema omits the field (e.g.,
68
+ * academic profile chapters that don't track verification dates, or consumer
69
+ * schemas that don't declare last_verified).
70
+ *
71
+ * Callers compose with optional chaining:
72
+ * const status = getFreshness(d.last_verified, d.volatility)?.status;
73
+ */
74
+ declare function getFreshness(lastVerified: Date | undefined, volatility: VolatilityLevel, now?: Date): Freshness | null;
75
+ /** Human-readable label for each status; used for ARIA + tooltips.
76
+ *
77
+ * v3.3.0: accepts `null` (the new return shape of getFreshness for undefined
78
+ * inputs). Returns a sentinel "unknown" label so callers can render a neutral
79
+ * affordance without a separate branch. */
80
+ declare function freshnessLabel(f: Freshness | null): string;
134
81
 
135
- export { BookConfigOptions, BookScaffoldIntegrationOptions, academicChapterSchema, academicParts, bookScaffoldIntegration, changeKinds, changelogSchema, chapterStatus, defineBookConfig, patternCategories, patternsSchema, sourceTiers, sourcesSchema, toolSlugs, toolsChapterSchema, volatilityLevels };
82
+ export { BookConfigOptions, BookScaffoldIntegrationOptions, type Freshness, type FreshnessStatus, type VolatilityLevel, bookScaffoldIntegration, defineBookConfig, defineMdxComponents, freshnessLabel, getFreshness, volatilityLevels };
package/dist/index.mjs CHANGED
@@ -118,9 +118,223 @@ var init_katex_macros = __esm({
118
118
  import mdx from "@astrojs/mdx";
119
119
  import preact from "@astrojs/preact";
120
120
 
121
+ // src/profile-kit.ts
122
+ function defineProfile(p) {
123
+ return p;
124
+ }
125
+
126
+ // src/schemas.ts
127
+ import { z } from "astro/zod";
128
+ var toolSlugs = [
129
+ "claude-code",
130
+ "gemini-cli",
131
+ "codex-cli",
132
+ "cross-tool"
133
+ ];
134
+ var volatilityLevels = [
135
+ "stable-principle",
136
+ "architectural-pattern",
137
+ "feature-surface"
138
+ ];
139
+ var sourceTiers = [
140
+ "T1-official",
141
+ "T2-release-notes",
142
+ "T3-practitioner",
143
+ "T4-conjecture"
144
+ ];
145
+ var changeKinds = ["added", "removed", "changed", "deprecated"];
146
+ var patternCategories = [
147
+ "safety",
148
+ "scale",
149
+ "context",
150
+ "interaction",
151
+ "extension",
152
+ "other"
153
+ ];
154
+ var academicParts = [
155
+ "foundations",
156
+ "ssm-core",
157
+ "beyond-ssm",
158
+ "integration",
159
+ "synthesis"
160
+ ];
161
+ var chapterStatus = [
162
+ "implemented",
163
+ "chapter_only",
164
+ "reading_only",
165
+ "prose_only",
166
+ "code_only",
167
+ "scaffolded",
168
+ "planned"
169
+ ];
170
+ var academicChapterSchema = z.object({
171
+ week: z.number().int().min(1).max(99),
172
+ part: z.enum(academicParts),
173
+ title: z.string().min(1),
174
+ status: z.enum(chapterStatus),
175
+ roadmap_lines: z.tuple([z.number().int(), z.number().int()]).optional(),
176
+ code_path: z.string().optional(),
177
+ tests_path: z.string().optional(),
178
+ notebook_path: z.string().optional(),
179
+ description: z.string().optional(),
180
+ draft: z.boolean().default(false)
181
+ });
182
+ var toolsChapterSchema = z.object({
183
+ title: z.string().min(1),
184
+ part: z.number().int().min(0).max(10),
185
+ chapter: z.number().int().min(0).max(99),
186
+ volatility: z.enum(volatilityLevels),
187
+ tools_compared: z.array(z.enum(toolSlugs)).min(1),
188
+ last_verified: z.date(),
189
+ sources: z.array(z.string()).default([]),
190
+ description: z.string().optional(),
191
+ draft: z.boolean().default(false),
192
+ updated: z.date().optional()
193
+ });
194
+ var minimalChapterSchema = toolsChapterSchema;
195
+ var courseNotesChapterSchema = z.object({
196
+ // Identity
197
+ title: z.string().min(1),
198
+ chapter: z.number().int().min(0).max(99),
199
+ part: z.number().int().min(0).max(20).default(1),
200
+ description: z.string().optional(),
201
+ // Source attribution
202
+ course: z.string().optional(),
203
+ instructor: z.string().optional(),
204
+ source_url: z.string().url().optional(),
205
+ // Pedagogy
206
+ learning_outcomes: z.array(
207
+ z.object({
208
+ id: z.string(),
209
+ verb: z.string(),
210
+ text: z.string()
211
+ })
212
+ ).default([]),
213
+ tags: z.array(z.string()).default([]),
214
+ // Provenance + status (shared shape with tools profile)
215
+ last_verified: z.date(),
216
+ volatility: z.enum(volatilityLevels).default("architectural-pattern"),
217
+ sources: z.array(z.string()).default([]),
218
+ draft: z.boolean().default(false)
219
+ });
220
+ var sourcesSchema = z.object({
221
+ url: z.string().url(),
222
+ title: z.string().min(1),
223
+ author: z.string().optional(),
224
+ publish_date: z.date().optional(),
225
+ captured_at: z.date(),
226
+ content_hash: z.string().regex(/^sha256:[a-f0-9]+$/).optional(),
227
+ tier: z.enum(sourceTiers),
228
+ tool: z.enum(toolSlugs),
229
+ perma_cc: z.string().url().nullable().optional(),
230
+ local_cache: z.string().nullable().optional()
231
+ });
232
+ var changelogSchema = z.object({
233
+ tool: z.enum(toolSlugs),
234
+ versions: z.array(
235
+ z.object({
236
+ version: z.string().min(1),
237
+ date: z.date(),
238
+ changes: z.array(
239
+ z.object({
240
+ pattern: z.string(),
241
+ kind: z.enum(changeKinds),
242
+ note: z.string().min(1),
243
+ source_key: z.string().optional()
244
+ })
245
+ ).default([])
246
+ })
247
+ ).default([])
248
+ });
249
+ var patternsSchema = z.object({
250
+ name: z.string().min(1),
251
+ description: z.string().optional(),
252
+ category: z.enum(patternCategories).optional(),
253
+ convergence_date: z.date().nullable().optional()
254
+ });
255
+
256
+ // src/profiles/academic.ts
257
+ var academicProfile = defineProfile({
258
+ name: "academic",
259
+ schema: academicChapterSchema,
260
+ routes: {
261
+ references: true,
262
+ search: true,
263
+ print: true,
264
+ chapters: false,
265
+ // academic consumers ship their own week-based /chapters listing
266
+ convergence: false
267
+ // tools-profile-specific
268
+ },
269
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
270
+ katex: true
271
+ });
272
+
273
+ // src/profiles/tools.ts
274
+ var toolsProfile = defineProfile({
275
+ name: "tools",
276
+ schema: toolsChapterSchema,
277
+ routes: {
278
+ references: true,
279
+ search: true,
280
+ print: true,
281
+ chapters: true,
282
+ // tools profile ships a flat chapter index
283
+ convergence: true
284
+ // tools profile ships convergence dashboard
285
+ },
286
+ styles: [
287
+ "tokens.css",
288
+ "layout.css",
289
+ "callouts.css",
290
+ "chapter.css",
291
+ "typography.css",
292
+ "print.css",
293
+ "convergence.css",
294
+ "tool-filter.css"
295
+ ]
296
+ });
297
+
298
+ // src/profiles/minimal.ts
299
+ var minimalProfile = defineProfile({
300
+ name: "minimal",
301
+ schema: minimalChapterSchema,
302
+ routes: {
303
+ references: true,
304
+ search: true,
305
+ print: true,
306
+ chapters: false,
307
+ convergence: false
308
+ },
309
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"]
310
+ });
311
+
312
+ // src/profiles/course-notes.ts
313
+ var courseNotesProfile = defineProfile({
314
+ name: "course-notes",
315
+ schema: courseNotesChapterSchema,
316
+ routes: {
317
+ references: true,
318
+ search: true,
319
+ print: true,
320
+ chapters: false,
321
+ // multi-book consumers route via [book]/[slug] themselves
322
+ convergence: false
323
+ },
324
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"]
325
+ });
326
+
327
+ // src/profiles/index.ts
328
+ var PROFILES = {
329
+ academic: academicProfile,
330
+ tools: toolsProfile,
331
+ minimal: minimalProfile,
332
+ "course-notes": courseNotesProfile
333
+ };
334
+ var BOOK_PROFILES = Object.keys(PROFILES);
335
+
121
336
  // src/types.ts
122
337
  import { existsSync, readFileSync } from "fs";
123
- var BOOK_PROFILES = ["academic", "tools", "minimal"];
124
338
  var BookConfigError = class extends Error {
125
339
  constructor(message) {
126
340
  super(message);
@@ -171,48 +385,92 @@ function resolveProfile(explicit) {
171
385
 
172
386
  // src/integration.ts
173
387
  import { fileURLToPath } from "url";
174
- var PACKAGE_NAME = "@brandon_m_behring/book-scaffold-astro";
175
- var ALWAYS_ON_STYLES = [
176
- "tokens.css",
177
- "layout.css",
178
- "callouts.css",
179
- "chapter.css",
180
- "typography.css",
181
- "print.css"
182
- ];
183
- var TOOLS_ONLY_STYLES = ["convergence.css", "tool-filter.css"];
184
- var DEFAULT_ROUTES_ALL = [
185
- { pattern: "/references", file: "references.astro" },
186
- { pattern: "/search", file: "search.astro" },
187
- { pattern: "/print", file: "print.astro" }
188
- ];
189
- var DEFAULT_ROUTES_TOOLS = [
190
- { pattern: "/chapters", file: "chapters.astro" },
191
- { pattern: "/convergence", file: "convergence.astro" }
388
+
389
+ // src/mdx-components-resolver.ts
390
+ import { existsSync as existsSync2 } from "fs";
391
+ import { resolve } from "path";
392
+ var CANDIDATE_FILES = [
393
+ "src/mdx-components.ts",
394
+ "src/mdx-components.js",
395
+ "src/mdx-components.mjs"
192
396
  ];
397
+ var VIRTUAL_ID = "virtual:book-scaffold/mdx-components";
398
+ var RESOLVED_ID = "\0" + VIRTUAL_ID;
399
+ function resolveMdxComponentsPath(consumerRoot, explicit) {
400
+ if (explicit) {
401
+ const p = resolve(consumerRoot, explicit);
402
+ return existsSync2(p) ? p : null;
403
+ }
404
+ for (const candidate of CANDIDATE_FILES) {
405
+ const p = resolve(consumerRoot, candidate);
406
+ if (existsSync2(p)) return p;
407
+ }
408
+ return null;
409
+ }
410
+ function makeMdxComponentsVitePlugin(consumerPath) {
411
+ return {
412
+ name: "book-scaffold:mdx-components",
413
+ enforce: "pre",
414
+ resolveId(id) {
415
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
416
+ return null;
417
+ },
418
+ load(id) {
419
+ if (id !== RESOLVED_ID) return null;
420
+ if (consumerPath === null) {
421
+ return "export default {};";
422
+ }
423
+ return `export { default } from ${JSON.stringify(consumerPath)};`;
424
+ }
425
+ };
426
+ }
427
+ function defineMdxComponents(components) {
428
+ return components;
429
+ }
430
+
431
+ // src/integration.ts
432
+ var PACKAGE_NAME = "@brandon_m_behring/book-scaffold-astro";
433
+ var ROUTE_REGISTRY = {
434
+ references: { pattern: "/references", file: "references.astro" },
435
+ search: { pattern: "/search", file: "search.astro" },
436
+ print: { pattern: "/print", file: "print.astro" },
437
+ chapters: { pattern: "/chapters", file: "chapters.astro" },
438
+ convergence: { pattern: "/convergence", file: "convergence.astro" }
439
+ };
193
440
  function resolvePage(file) {
194
441
  return fileURLToPath(new URL(`../pages/${file}`, import.meta.url));
195
442
  }
196
443
  function bookScaffoldIntegration(opts) {
197
- const { profile, extraStyles = [] } = opts;
444
+ const { profile, routes: userOverrides = {}, extraStyles = [], mdxComponentsModule } = opts;
445
+ const def = PROFILES[profile];
446
+ const enabledRoutes = { ...def.routes, ...userOverrides };
198
447
  return {
199
448
  name: "book-scaffold-astro",
200
449
  hooks: {
201
- "astro:config:setup": ({ injectScript, injectRoute }) => {
202
- const styles = profile === "tools" ? [...ALWAYS_ON_STYLES, ...TOOLS_ONLY_STYLES, ...extraStyles] : [...ALWAYS_ON_STYLES, ...extraStyles];
450
+ "astro:config:setup": ({ injectScript, injectRoute, updateConfig, config }) => {
451
+ const styles = [...def.styles, ...extraStyles];
203
452
  for (const sheet of styles) {
204
453
  injectScript("page-ssr", `import '${PACKAGE_NAME}/styles/${sheet}';`);
205
454
  }
206
- if (profile === "academic") {
455
+ if (def.katex) {
207
456
  injectScript("page-ssr", "import 'katex/dist/katex.min.css';");
208
457
  }
209
- const routes = profile === "tools" ? [...DEFAULT_ROUTES_ALL, ...DEFAULT_ROUTES_TOOLS] : [...DEFAULT_ROUTES_ALL];
210
- for (const route of routes) {
458
+ for (const [name, on] of Object.entries(enabledRoutes)) {
459
+ if (!on) continue;
460
+ const route = ROUTE_REGISTRY[name];
461
+ if (!route) continue;
211
462
  injectRoute({
212
463
  pattern: route.pattern,
213
464
  entrypoint: resolvePage(route.file)
214
465
  });
215
466
  }
467
+ const consumerRoot = fileURLToPath(config.root);
468
+ const resolvedMdxPath = resolveMdxComponentsPath(consumerRoot, mdxComponentsModule);
469
+ updateConfig({
470
+ vite: {
471
+ plugins: [makeMdxComponentsVitePlugin(resolvedMdxPath)]
472
+ }
473
+ });
216
474
  }
217
475
  }
218
476
  };
@@ -249,7 +507,14 @@ async function defineBookConfig(opts) {
249
507
  const integrations = [
250
508
  mdx(),
251
509
  preact(),
252
- bookScaffoldIntegration({ profile, extraStyles: opts.extraStyles }),
510
+ bookScaffoldIntegration({
511
+ profile,
512
+ routes: opts.routes,
513
+ // v3.3.0 — per-route override (issue #3)
514
+ mdxComponentsModule: opts.mdxComponentsModule,
515
+ // v3.3.0 — explicit mdx-components path (issue #2)
516
+ extraStyles: opts.extraStyles
517
+ }),
253
518
  ...opts.extraIntegrations ?? []
254
519
  ];
255
520
  const userMarkdown = opts.markdown ?? {};
@@ -267,12 +532,18 @@ async function defineBookConfig(opts) {
267
532
  };
268
533
  const {
269
534
  profile: _profile,
535
+ routes: _routes,
536
+ // v3.3.0
537
+ mdxComponentsModule: _mdxComponentsModule,
538
+ // v3.3.0
270
539
  extraIntegrations: _extraIntegrations,
271
540
  extraStyles: _extraStyles,
272
541
  markdown: _markdown,
273
542
  ...rest
274
543
  } = opts;
275
544
  void _profile;
545
+ void _routes;
546
+ void _mdxComponentsModule;
276
547
  void _extraIntegrations;
277
548
  void _extraStyles;
278
549
  void _markdown;
@@ -293,109 +564,39 @@ async function defineBookConfig(opts) {
293
564
  return config;
294
565
  }
295
566
 
296
- // src/schemas.ts
297
- import { z } from "astro/zod";
298
- var toolSlugs = [
299
- "claude-code",
300
- "gemini-cli",
301
- "codex-cli",
302
- "cross-tool"
303
- ];
304
- var volatilityLevels = [
305
- "stable-principle",
306
- "architectural-pattern",
307
- "feature-surface"
308
- ];
309
- var sourceTiers = [
310
- "T1-official",
311
- "T2-release-notes",
312
- "T3-practitioner",
313
- "T4-conjecture"
314
- ];
315
- var changeKinds = ["added", "removed", "changed", "deprecated"];
316
- var patternCategories = [
317
- "safety",
318
- "scale",
319
- "context",
320
- "interaction",
321
- "extension",
322
- "other"
323
- ];
324
- var academicParts = [
325
- "foundations",
326
- "ssm-core",
327
- "beyond-ssm",
328
- "integration",
329
- "synthesis"
330
- ];
331
- var chapterStatus = [
332
- "implemented",
333
- "chapter_only",
334
- "reading_only",
335
- "prose_only",
336
- "code_only",
337
- "scaffolded",
338
- "planned"
339
- ];
340
- var academicChapterSchema = z.object({
341
- week: z.number().int().min(1).max(99),
342
- part: z.enum(academicParts),
343
- title: z.string().min(1),
344
- status: z.enum(chapterStatus),
345
- roadmap_lines: z.tuple([z.number().int(), z.number().int()]).optional(),
346
- code_path: z.string().optional(),
347
- tests_path: z.string().optional(),
348
- notebook_path: z.string().optional(),
349
- description: z.string().optional(),
350
- draft: z.boolean().default(false)
351
- });
352
- var toolsChapterSchema = z.object({
353
- title: z.string().min(1),
354
- part: z.number().int().min(0).max(10),
355
- chapter: z.number().int().min(0).max(99),
356
- volatility: z.enum(volatilityLevels),
357
- tools_compared: z.array(z.enum(toolSlugs)).min(1),
358
- last_verified: z.date(),
359
- sources: z.array(z.string()).default([]),
360
- description: z.string().optional(),
361
- draft: z.boolean().default(false),
362
- updated: z.date().optional()
363
- });
364
- var sourcesSchema = z.object({
365
- url: z.string().url(),
366
- title: z.string().min(1),
367
- author: z.string().optional(),
368
- publish_date: z.date().optional(),
369
- captured_at: z.date(),
370
- content_hash: z.string().regex(/^sha256:[a-f0-9]+$/).optional(),
371
- tier: z.enum(sourceTiers),
372
- tool: z.enum(toolSlugs),
373
- perma_cc: z.string().url().nullable().optional(),
374
- local_cache: z.string().nullable().optional()
375
- });
376
- var changelogSchema = z.object({
377
- tool: z.enum(toolSlugs),
378
- versions: z.array(
379
- z.object({
380
- version: z.string().min(1),
381
- date: z.date(),
382
- changes: z.array(
383
- z.object({
384
- pattern: z.string(),
385
- kind: z.enum(changeKinds),
386
- note: z.string().min(1),
387
- source_key: z.string().optional()
388
- })
389
- ).default([])
390
- })
391
- ).default([])
392
- });
393
- var patternsSchema = z.object({
394
- name: z.string().min(1),
395
- description: z.string().optional(),
396
- category: z.enum(patternCategories).optional(),
397
- convergence_date: z.date().nullable().optional()
398
- });
567
+ // src/lib/freshness.ts
568
+ var THRESHOLDS = {
569
+ "stable-principle": 365,
570
+ "architectural-pattern": 180,
571
+ "feature-surface": 90
572
+ };
573
+ var MS_PER_DAY = 1e3 * 60 * 60 * 24;
574
+ function getFreshness(lastVerified, volatility, now = /* @__PURE__ */ new Date()) {
575
+ if (!(lastVerified instanceof Date)) return null;
576
+ const thresholdDays = THRESHOLDS[volatility];
577
+ const daysOld = Math.floor((now.getTime() - lastVerified.getTime()) / MS_PER_DAY);
578
+ const daysUntil = thresholdDays - daysOld;
579
+ let status;
580
+ if (daysOld < thresholdDays * 0.75) {
581
+ status = "fresh";
582
+ } else if (daysOld < thresholdDays) {
583
+ status = "verify-soon";
584
+ } else {
585
+ status = "stale";
586
+ }
587
+ return { status, daysOld, thresholdDays, daysUntil };
588
+ }
589
+ function freshnessLabel(f) {
590
+ if (f === null) return "Verification status unknown";
591
+ switch (f.status) {
592
+ case "fresh":
593
+ return `Fresh (${f.daysOld}d old; verify within ${f.daysUntil}d)`;
594
+ case "verify-soon":
595
+ return `Verify soon (${f.daysOld}d old; ${f.daysUntil}d until stale)`;
596
+ case "stale":
597
+ return `Stale (${f.daysOld}d old; ${Math.abs(f.daysUntil)}d past threshold)`;
598
+ }
599
+ }
399
600
  export {
400
601
  BOOK_PROFILES,
401
602
  BookConfigError,
@@ -405,7 +606,13 @@ export {
405
606
  changeKinds,
406
607
  changelogSchema,
407
608
  chapterStatus,
609
+ courseNotesChapterSchema,
408
610
  defineBookConfig,
611
+ defineMdxComponents,
612
+ defineProfile,
613
+ freshnessLabel,
614
+ getFreshness,
615
+ minimalChapterSchema,
409
616
  patternCategories,
410
617
  patternsSchema,
411
618
  resolveProfile,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { e as BookSchemasOptions } from './types-BoCXCvBy.js';
1
+ import { e as BookSchemasOptions } from './types-s8NxCLU2.js';
2
2
  import 'astro';
3
+ import 'astro/zod';
3
4
 
4
5
  /**
5
6
  * Returns the package's default content collections. Closed shape per Q5;
package/dist/schemas.mjs CHANGED
@@ -3,55 +3,9 @@ import { existsSync as existsSync2 } from "fs";
3
3
  import { defineCollection } from "astro:content";
4
4
  import { glob, file } from "astro/loaders";
5
5
 
6
- // src/types.ts
7
- import { existsSync, readFileSync } from "fs";
8
- var BOOK_PROFILES = ["academic", "tools", "minimal"];
9
- var BookConfigError = class extends Error {
10
- constructor(message) {
11
- super(message);
12
- this.name = "BookConfigError";
13
- }
14
- };
15
- function readEnvFile(path = ".env") {
16
- try {
17
- if (!existsSync(path)) return {};
18
- const out = {};
19
- for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
20
- const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
21
- if (!m) continue;
22
- let val = m[2] ?? "";
23
- if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
24
- val = val.slice(1, -1);
25
- }
26
- out[m[1]] = val;
27
- }
28
- return out;
29
- } catch {
30
- return {};
31
- }
32
- }
33
- function resolveProfile(explicit) {
34
- let candidate = explicit ?? process.env.BOOK_PROFILE;
35
- let source = "default";
36
- if (explicit) source = "param";
37
- else if (process.env.BOOK_PROFILE) source = "env";
38
- if (!candidate) {
39
- const fromFile = readEnvFile().BOOK_PROFILE;
40
- if (fromFile) {
41
- candidate = fromFile;
42
- source = "dotenv";
43
- }
44
- }
45
- candidate = candidate ?? "minimal";
46
- if (!BOOK_PROFILES.includes(candidate)) {
47
- throw new BookConfigError(
48
- `profile must be one of ${BOOK_PROFILES.join(" | ")} (got ${JSON.stringify(candidate)})`
49
- );
50
- }
51
- if (source === "default") {
52
- console.warn("book-scaffold-astro: BOOK_PROFILE not set; falling back to 'minimal'.");
53
- }
54
- return candidate;
6
+ // src/profile-kit.ts
7
+ function defineProfile(p) {
8
+ return p;
55
9
  }
56
10
 
57
11
  // src/schemas.ts
@@ -122,6 +76,32 @@ var toolsChapterSchema = z.object({
122
76
  draft: z.boolean().default(false),
123
77
  updated: z.date().optional()
124
78
  });
79
+ var minimalChapterSchema = toolsChapterSchema;
80
+ var courseNotesChapterSchema = z.object({
81
+ // Identity
82
+ title: z.string().min(1),
83
+ chapter: z.number().int().min(0).max(99),
84
+ part: z.number().int().min(0).max(20).default(1),
85
+ description: z.string().optional(),
86
+ // Source attribution
87
+ course: z.string().optional(),
88
+ instructor: z.string().optional(),
89
+ source_url: z.string().url().optional(),
90
+ // Pedagogy
91
+ learning_outcomes: z.array(
92
+ z.object({
93
+ id: z.string(),
94
+ verb: z.string(),
95
+ text: z.string()
96
+ })
97
+ ).default([]),
98
+ tags: z.array(z.string()).default([]),
99
+ // Provenance + status (shared shape with tools profile)
100
+ last_verified: z.date(),
101
+ volatility: z.enum(volatilityLevels).default("architectural-pattern"),
102
+ sources: z.array(z.string()).default([]),
103
+ draft: z.boolean().default(false)
104
+ });
125
105
  var sourcesSchema = z.object({
126
106
  url: z.string().url(),
127
107
  title: z.string().min(1),
@@ -158,17 +138,148 @@ var patternsSchema = z.object({
158
138
  convergence_date: z.date().nullable().optional()
159
139
  });
160
140
 
141
+ // src/profiles/academic.ts
142
+ var academicProfile = defineProfile({
143
+ name: "academic",
144
+ schema: academicChapterSchema,
145
+ routes: {
146
+ references: true,
147
+ search: true,
148
+ print: true,
149
+ chapters: false,
150
+ // academic consumers ship their own week-based /chapters listing
151
+ convergence: false
152
+ // tools-profile-specific
153
+ },
154
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
155
+ katex: true
156
+ });
157
+
158
+ // src/profiles/tools.ts
159
+ var toolsProfile = defineProfile({
160
+ name: "tools",
161
+ schema: toolsChapterSchema,
162
+ routes: {
163
+ references: true,
164
+ search: true,
165
+ print: true,
166
+ chapters: true,
167
+ // tools profile ships a flat chapter index
168
+ convergence: true
169
+ // tools profile ships convergence dashboard
170
+ },
171
+ styles: [
172
+ "tokens.css",
173
+ "layout.css",
174
+ "callouts.css",
175
+ "chapter.css",
176
+ "typography.css",
177
+ "print.css",
178
+ "convergence.css",
179
+ "tool-filter.css"
180
+ ]
181
+ });
182
+
183
+ // src/profiles/minimal.ts
184
+ var minimalProfile = defineProfile({
185
+ name: "minimal",
186
+ schema: minimalChapterSchema,
187
+ routes: {
188
+ references: true,
189
+ search: true,
190
+ print: true,
191
+ chapters: false,
192
+ convergence: false
193
+ },
194
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"]
195
+ });
196
+
197
+ // src/profiles/course-notes.ts
198
+ var courseNotesProfile = defineProfile({
199
+ name: "course-notes",
200
+ schema: courseNotesChapterSchema,
201
+ routes: {
202
+ references: true,
203
+ search: true,
204
+ print: true,
205
+ chapters: false,
206
+ // multi-book consumers route via [book]/[slug] themselves
207
+ convergence: false
208
+ },
209
+ styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"]
210
+ });
211
+
212
+ // src/profiles/index.ts
213
+ var PROFILES = {
214
+ academic: academicProfile,
215
+ tools: toolsProfile,
216
+ minimal: minimalProfile,
217
+ "course-notes": courseNotesProfile
218
+ };
219
+ var BOOK_PROFILES = Object.keys(PROFILES);
220
+
221
+ // src/types.ts
222
+ import { existsSync, readFileSync } from "fs";
223
+ var BookConfigError = class extends Error {
224
+ constructor(message) {
225
+ super(message);
226
+ this.name = "BookConfigError";
227
+ }
228
+ };
229
+ function readEnvFile(path = ".env") {
230
+ try {
231
+ if (!existsSync(path)) return {};
232
+ const out = {};
233
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
234
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
235
+ if (!m) continue;
236
+ let val = m[2] ?? "";
237
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
238
+ val = val.slice(1, -1);
239
+ }
240
+ out[m[1]] = val;
241
+ }
242
+ return out;
243
+ } catch {
244
+ return {};
245
+ }
246
+ }
247
+ function resolveProfile(explicit) {
248
+ let candidate = explicit ?? process.env.BOOK_PROFILE;
249
+ let source = "default";
250
+ if (explicit) source = "param";
251
+ else if (process.env.BOOK_PROFILE) source = "env";
252
+ if (!candidate) {
253
+ const fromFile = readEnvFile().BOOK_PROFILE;
254
+ if (fromFile) {
255
+ candidate = fromFile;
256
+ source = "dotenv";
257
+ }
258
+ }
259
+ candidate = candidate ?? "minimal";
260
+ if (!BOOK_PROFILES.includes(candidate)) {
261
+ throw new BookConfigError(
262
+ `profile must be one of ${BOOK_PROFILES.join(" | ")} (got ${JSON.stringify(candidate)})`
263
+ );
264
+ }
265
+ if (source === "default") {
266
+ console.warn("book-scaffold-astro: BOOK_PROFILE not set; falling back to 'minimal'.");
267
+ }
268
+ return candidate;
269
+ }
270
+
161
271
  // src/schemas-entry.ts
162
272
  function defineBookSchemas(opts = {}) {
163
273
  const profile = resolveProfile(opts.profile);
164
274
  const chaptersBase = opts.chaptersBase ?? "./src/content/chapters";
275
+ const schemaForProfile = profile === "academic" ? academicChapterSchema : profile === "course-notes" ? courseNotesChapterSchema : profile === "minimal" ? minimalChapterSchema : toolsChapterSchema;
165
276
  const chapters = defineCollection({
166
277
  loader: glob({
167
278
  // Exclude underscore-prefixed files (standard "hidden" convention).
168
279
  pattern: ["**/*.{md,mdx}", "!**/_*"],
169
280
  base: chaptersBase
170
281
  }),
171
- schema: profile === "academic" ? academicChapterSchema : toolsChapterSchema
282
+ schema: schemaForProfile
172
283
  });
173
284
  const collections = {
174
285
  chapters
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": "3.1.0",
4
+ "version": "3.3.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -113,7 +113,8 @@
113
113
  "pedagogy",
114
114
  "examples",
115
115
  "CLAUDE.md",
116
- "README.md"
116
+ "README.md",
117
+ "LATEX_TO_MDX_MAPPING.md"
117
118
  ],
118
119
  "scripts": {
119
120
  "build": "tsup && rm -f dist/types-*.d.ts",
package/pages/print.astro CHANGED
@@ -7,6 +7,13 @@
7
7
  * body, and wraps in a <section.chapter-print> so print.css can force
8
8
  * page breaks between chapters.
9
9
  *
10
+ * v3.3.0 (closes #2): renders chapters with the consumer's MDX-components
11
+ * map. Consumer creates src/mdx-components.{ts,js,mjs} that default-exports
12
+ * a defineMdxComponents({...}) call; this route imports the map via the
13
+ * virtual:book-scaffold/mdx-components module exposed by the toolkit's
14
+ * Vite plugin. If the consumer has no mdx-components file, the virtual
15
+ * module exports {} so the import is harmless.
16
+ *
10
17
  * Build pipeline:
11
18
  * npm run build → Astro emits dist/print/index.html
12
19
  * npm run pdf → pagedjs-cli fetches dist/print/ via preview
@@ -17,6 +24,7 @@ import Base from '../layouts/Base.astro';
17
24
  import { render } from 'astro:content';
18
25
  import { getAllChapters } from '../src/lib/chapters';
19
26
  import ChapterHeader from '../components/ChapterHeader.astro';
27
+ import mdxComponents from 'virtual:book-scaffold/mdx-components';
20
28
 
21
29
  const chapters = await getAllChapters();
22
30
  const rendered = await Promise.all(
@@ -32,7 +40,7 @@ const rendered = await Promise.all(
32
40
  {rendered.map(({ entry, Content }) => (
33
41
  <section class="chapter-print">
34
42
  <ChapterHeader data={entry.data} />
35
- <Content />
43
+ <Content components={mdxComponents} />
36
44
  </section>
37
45
  ))}
38
46
  </main>
@@ -49,12 +49,22 @@ const MS_PER_DAY = 1000 * 60 * 60 * 24;
49
49
  * Compute freshness for a chapter given its last_verified date + volatility.
50
50
  *
51
51
  * Pure function; caller supplies `now` only in tests. Production callers omit.
52
+ *
53
+ * v3.3.0 (closes issue #1): tolerant of `lastVerified === undefined`. Returns
54
+ * `null` instead of crashing when the chapter schema omits the field (e.g.,
55
+ * academic profile chapters that don't track verification dates, or consumer
56
+ * schemas that don't declare last_verified).
57
+ *
58
+ * Callers compose with optional chaining:
59
+ * const status = getFreshness(d.last_verified, d.volatility)?.status;
52
60
  */
53
61
  export function getFreshness(
54
- lastVerified: Date,
62
+ lastVerified: Date | undefined,
55
63
  volatility: VolatilityLevel,
56
64
  now: Date = new Date(),
57
- ): Freshness {
65
+ ): Freshness | null {
66
+ if (!(lastVerified instanceof Date)) return null;
67
+
58
68
  const thresholdDays = THRESHOLDS[volatility];
59
69
  const daysOld = Math.floor((now.getTime() - lastVerified.getTime()) / MS_PER_DAY);
60
70
  const daysUntil = thresholdDays - daysOld;
@@ -71,8 +81,13 @@ export function getFreshness(
71
81
  return { status, daysOld, thresholdDays, daysUntil };
72
82
  }
73
83
 
74
- /** Human-readable label for each status; used for ARIA + tooltips. */
75
- export function freshnessLabel(f: Freshness): string {
84
+ /** Human-readable label for each status; used for ARIA + tooltips.
85
+ *
86
+ * v3.3.0: accepts `null` (the new return shape of getFreshness for undefined
87
+ * inputs). Returns a sentinel "unknown" label so callers can render a neutral
88
+ * affordance without a separate branch. */
89
+ export function freshnessLabel(f: Freshness | null): string {
90
+ if (f === null) return 'Verification status unknown';
76
91
  switch (f.status) {
77
92
  case 'fresh':
78
93
  return `Fresh (${f.daysOld}d old; verify within ${f.daysUntil}d)`;
@@ -38,6 +38,29 @@
38
38
  font-size: 0.75em;
39
39
  }
40
40
 
41
+ /* Inline companion-artifact chips inside .chapter-meta.
42
+ * v3.2.0: structural inline rendering. Previous v3.1.0 emitted a sibling
43
+ * <aside class="chapter-companions"><ul>...</ul></aside> with no CSS
44
+ * coverage; UA-default block layout added ~100px height at <=1280px,
45
+ * producing a uniform vertical pixel shift on academic chapter pages
46
+ * vs the v2.0 baseline. The inline span here adds zero block height by
47
+ * construction; styling matches the surrounding .chapter-meta spans. */
48
+ .chapter-companion {
49
+ font-family: var(--font-code);
50
+ font-size: var(--text-sm);
51
+ color: var(--color-text-muted);
52
+ }
53
+ .chapter-companion a,
54
+ .chapter-companion code {
55
+ color: inherit;
56
+ text-decoration: none;
57
+ border-bottom: 1px dotted var(--color-border);
58
+ }
59
+ .chapter-companion a:hover {
60
+ color: var(--color-link);
61
+ border-bottom-style: solid;
62
+ }
63
+
41
64
  /* Volatility + tool badges: reuse .tool-badge from callouts.css */
42
65
  .volatility-badge {
43
66
  display: inline-block;