@brandon_m_behring/book-scaffold-astro 4.6.1 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -99,6 +99,8 @@ Two callout families coexist. Authors import what they need.
99
99
 
100
100
  **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginNote`, `Sidenote`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
101
101
 
102
+ **Provenance** (v4.8.0, any profile, **auto-injected by the chapter route — not author-imported**): per-chapter "How this was made" audit-trail block, rendered from the optional `provenance` frontmatter (`ai_tools`, `prompts_archive`, `decisions_log`, `audit_history`, `citation_backstop`). **Opt-out**: a chapter with no `provenance` shows a fallback ("Audit history not yet recorded"). Distinct from `AICollaborationDisclosure` (book-level, manual model+role disclosure). Repo-relative path fields render as `<code>`; only `http(s)` values link.
103
+
102
104
  Full reference in `recipes/04-component-library.md`.
103
105
 
104
106
  ## Citation patterns
@@ -0,0 +1,206 @@
1
+ ---
2
+ /**
3
+ * Provenance — per-chapter "How this was made" audit-trail disclosure (v4.8.0).
4
+ *
5
+ * Renders the optional `provenance` chapter frontmatter (see provenanceObject in
6
+ * src/schemas.ts) as a collapsed <details> block, family-styled with the warm-plum
7
+ * disclosure language (cf. AICollaborationDisclosure.astro) and the ▸/▾ summary
8
+ * marker (cf. .chapter-toc in styles/chapter.css). No JavaScript.
9
+ *
10
+ * Auto-injected by the chapter route (pages/chapters/[...slug].astro) on EVERY
11
+ * chapter of EVERY profile — so it is OPT-OUT, not opt-in: a chapter with no
12
+ * `provenance` still renders, showing a fallback ("Audit history not yet
13
+ * recorded — initial draft"). This forces awareness and signals to readers that
14
+ * an audit trail is expected.
15
+ *
16
+ * Scope (deliberate): this block owns the PROCESS audit trail (ai_tools, prompts,
17
+ * decisions, audits, citation backstop). It does NOT render freshness/volatility —
18
+ * ChapterHeader.astro owns that "is this current?" badge. And it is distinct from
19
+ * AICollaborationDisclosure (book-level, manual model+role disclosure).
20
+ *
21
+ * Claim-safety: references are repo-relative paths (e.g. "audits/AUDIT_x.md",
22
+ * "DECISIONS.md#anchor") that do NOT resolve on the built static site, so they
23
+ * render as <code> text. Only genuine http(s) URLs become clickable links — the
24
+ * site's "no dead links" thesis applied to its own chrome.
25
+ */
26
+ import type { Provenance } from '../src/schemas.js';
27
+
28
+ interface Props {
29
+ data: Provenance | null;
30
+ }
31
+ const { data } = Astro.props;
32
+
33
+ const isUrl = (s: string) => /^https?:\/\//i.test(s);
34
+ const fmtDate = (d: Date | string) =>
35
+ (d instanceof Date ? d : new Date(d)).toISOString().slice(0, 10);
36
+
37
+ const aiTools = data?.ai_tools ?? [];
38
+ const audits = data?.audit_history ?? [];
39
+ const backstop = data?.citation_backstop;
40
+ const promptsArchive = data?.prompts_archive;
41
+ const decisionsLog = data?.decisions_log;
42
+
43
+ // Opt-out (D3): the block always renders; "empty" → fallback.
44
+ const hasContent =
45
+ aiTools.length > 0 ||
46
+ audits.length > 0 ||
47
+ Boolean(backstop) ||
48
+ Boolean(promptsArchive) ||
49
+ Boolean(decisionsLog);
50
+
51
+ // Teaser digest shown in the <summary> so the signal reads while collapsed.
52
+ const teaserParts: string[] = [];
53
+ if (audits.length) teaserParts.push(`${audits.length} audit${audits.length > 1 ? 's' : ''}`);
54
+ if (backstop) teaserParts.push(`${backstop}-backed`);
55
+ if (!teaserParts.length && aiTools.length) {
56
+ teaserParts.push(`${aiTools.length} tool${aiTools.length > 1 ? 's' : ''}`);
57
+ }
58
+ const teaser = teaserParts.length ? `How this was made · ${teaserParts.join(' · ')}` : 'How this was made';
59
+ ---
60
+ <aside class="provenance" role="note" aria-labelledby="provenance-h">
61
+ <details class="provenance-details">
62
+ <summary id="provenance-h">{teaser}</summary>
63
+
64
+ {hasContent ? (
65
+ <div class="provenance-body">
66
+ {aiTools.length > 0 && (
67
+ <p class="provenance-row">
68
+ <span class="provenance-label">AI tools</span>
69
+ {aiTools.map((t) => <span class="provenance-chip">{t}</span>)}
70
+ </p>
71
+ )}
72
+
73
+ {(promptsArchive || decisionsLog) && (
74
+ <p class="provenance-row">
75
+ <span class="provenance-label">References</span>
76
+ {promptsArchive && (
77
+ <span class="provenance-ref">prompts: {isUrl(promptsArchive)
78
+ ? <a href={promptsArchive}>{promptsArchive}</a>
79
+ : <code>{promptsArchive}</code>}</span>
80
+ )}
81
+ {decisionsLog && (
82
+ <span class="provenance-ref">decisions: {isUrl(decisionsLog)
83
+ ? <a href={decisionsLog}>{decisionsLog}</a>
84
+ : <code>{decisionsLog}</code>}</span>
85
+ )}
86
+ </p>
87
+ )}
88
+
89
+ {audits.length > 0 && (
90
+ <div class="provenance-row">
91
+ <span class="provenance-label">Audit history</span>
92
+ <ul class="provenance-audits">
93
+ {audits.map((a) => (
94
+ <li>
95
+ <time datetime={fmtDate(a.date)}>{fmtDate(a.date)}</time>
96
+ <span class="provenance-audit-type">{a.type}</span>
97
+ {isUrl(a.file) ? <a href={a.file}>{a.file}</a> : <code>{a.file}</code>}
98
+ </li>
99
+ ))}
100
+ </ul>
101
+ </div>
102
+ )}
103
+
104
+ {backstop && (
105
+ <p class="provenance-row">
106
+ <span class="provenance-label">Citation backstop</span>
107
+ <span class={`provenance-badge backstop-${backstop}`}>{backstop}</span>
108
+ </p>
109
+ )}
110
+ </div>
111
+ ) : (
112
+ <p class="provenance-fallback">
113
+ Audit history not yet recorded — initial draft.
114
+ </p>
115
+ )}
116
+ </details>
117
+ </aside>
118
+
119
+ <style>
120
+ .provenance {
121
+ display: block;
122
+ margin: var(--space-6) 0;
123
+ padding: var(--space-3) var(--space-4);
124
+ border-left: var(--border-bar) solid var(--warm-plum);
125
+ background: var(--warm-plum-tint);
126
+ border-radius: 0 var(--radius-md) var(--radius-md) 0;
127
+ font-size: var(--text-sm);
128
+ line-height: var(--leading-normal);
129
+ max-width: var(--measure-main);
130
+ }
131
+ .provenance-details > summary {
132
+ cursor: pointer;
133
+ font-weight: 500;
134
+ color: var(--warm-plum);
135
+ letter-spacing: 0.02em;
136
+ list-style: none;
137
+ }
138
+ .provenance-details > summary::-webkit-details-marker {
139
+ display: none;
140
+ }
141
+ .provenance-details > summary::before {
142
+ content: '▸ ';
143
+ display: inline-block;
144
+ }
145
+ .provenance-details[open] > summary::before {
146
+ content: '▾ ';
147
+ }
148
+ .provenance-body {
149
+ margin-top: var(--space-3);
150
+ }
151
+ .provenance-row {
152
+ margin: var(--space-2) 0;
153
+ text-indent: 0;
154
+ }
155
+ .provenance-label {
156
+ display: inline-block;
157
+ min-width: 9rem;
158
+ font-size: var(--text-xs);
159
+ text-transform: uppercase;
160
+ letter-spacing: 0.04em;
161
+ color: var(--warm-plum);
162
+ vertical-align: top;
163
+ }
164
+ .provenance-chip,
165
+ .provenance-badge {
166
+ display: inline-block;
167
+ margin: 0 var(--space-1) var(--space-1) 0;
168
+ padding: 0.1em 0.5em;
169
+ border-radius: var(--radius-sm);
170
+ background: color-mix(in srgb, var(--warm-plum) 12%, var(--paper));
171
+ font-size: var(--text-xs);
172
+ }
173
+ .provenance-ref {
174
+ display: inline-block;
175
+ margin-right: var(--space-3);
176
+ }
177
+ .provenance-ref code,
178
+ .provenance-audits code {
179
+ font-family: var(--font-code);
180
+ font-size: 0.95em;
181
+ background: var(--color-code-bg);
182
+ padding: 0.1em 0.4em;
183
+ border-radius: var(--radius-sm);
184
+ }
185
+ .provenance-audits {
186
+ list-style: none;
187
+ margin: var(--space-1) 0 0;
188
+ padding-left: 0;
189
+ }
190
+ .provenance-audits li {
191
+ margin: var(--space-1) 0;
192
+ }
193
+ .provenance-audits time {
194
+ font-family: var(--font-code);
195
+ margin-right: var(--space-2);
196
+ }
197
+ .provenance-audit-type {
198
+ margin-right: var(--space-2);
199
+ font-style: italic;
200
+ }
201
+ .provenance-fallback {
202
+ margin: var(--space-3) 0 0;
203
+ font-style: italic;
204
+ opacity: 0.8;
205
+ }
206
+ </style>
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, Q as volatilityLevels, h as ChaptersRenderer, n as Style } from './types-B2bO9Nga.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, R as ResearchPortfolioChapter, m as RouteToggles, S as StatusBadge, o as StyleInput, T as ToolsChapter, V as VolatilityBadge, p as academicChapterSchema, q as academicParts, r as changeKinds, s as changelogSchema, t as chapterStatus, u as composeStyles, v as courseNotesChapterSchema, w as defineProfile, x as defineStyle, y as minimalChapterSchema, z as normalizeFrontmatterConfig, D as patternCategories, E as patternsSchema, G as researchPortfolioChapterSchema, H as resolvePreset, I as resolveProfile, J as sourceTiers, K as sourceTiersResearch, L as sourcesSchema, N as toolSlugs, O as toolsChapterSchema } from './types-B2bO9Nga.js';
2
+ import { c as BookConfigOptions, f as BookScaffoldIntegrationOptions, Y as volatilityLevels, h as ChaptersRenderer, o as Style } from './types-DTQ2l6B6.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, r as academicParts, 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-DTQ2l6B6.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  /**
package/dist/index.mjs CHANGED
@@ -168,6 +168,18 @@ var chapterStatus = [
168
168
  "scaffolded",
169
169
  "planned"
170
170
  ];
171
+ var citationBackstops = ["research-kb", "manual", "unverified"];
172
+ var provenanceObject = z.object({
173
+ ai_tools: z.array(z.string()).default([]),
174
+ prompts_archive: z.string().optional(),
175
+ decisions_log: z.string().optional(),
176
+ audit_history: z.array(z.object({ date: z.date(), type: z.string(), file: z.string() })).default([]),
177
+ citation_backstop: z.enum(citationBackstops).optional()
178
+ }).strict();
179
+ var provenanceSchema = provenanceObject.refine(
180
+ (p) => p.ai_tools.length > 0 || p.audit_history.length > 0 || Boolean(p.citation_backstop) || Boolean(p.prompts_archive) || Boolean(p.decisions_log),
181
+ { message: "provenance is present but empty \u2014 omit the key, or set at least one field" }
182
+ ).optional();
171
183
  var academicChapterSchema = z.object({
172
184
  week: z.number().int().min(1).max(99),
173
185
  part: z.enum(academicParts),
@@ -185,7 +197,9 @@ var academicChapterSchema = z.object({
185
197
  published: z.date().optional(),
186
198
  updated: z.date().optional(),
187
199
  tags: z.array(z.string()).default([]),
188
- image: z.string().optional()
200
+ image: z.string().optional(),
201
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
202
+ provenance: provenanceSchema
189
203
  });
190
204
  var toolsChapterSchema = z.object({
191
205
  title: z.string().min(1),
@@ -203,7 +217,9 @@ var toolsChapterSchema = z.object({
203
217
  author: z.string().optional(),
204
218
  published: z.date().optional(),
205
219
  tags: z.array(z.string()).default([]),
206
- image: z.string().optional()
220
+ image: z.string().optional(),
221
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
222
+ provenance: provenanceSchema
207
223
  });
208
224
  var minimalChapterSchema = toolsChapterSchema;
209
225
  var sourceTiersResearch = ["T1", "T2", "T3", "T4"];
@@ -237,7 +253,9 @@ var courseNotesChapterSchema = z.object({
237
253
  author: z.string().optional(),
238
254
  published: z.date().optional(),
239
255
  updated: z.date().optional(),
240
- image: z.string().optional()
256
+ image: z.string().optional(),
257
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
258
+ provenance: provenanceSchema
241
259
  });
242
260
  var researchPortfolioChapterSchema = z.object({
243
261
  // Identity
@@ -295,7 +313,9 @@ var researchPortfolioChapterSchema = z.object({
295
313
  // `tags` + `updated` already existed; `author` + `published` + `image` are new.
296
314
  author: z.string().optional(),
297
315
  published: z.date().optional(),
298
- image: z.string().optional()
316
+ image: z.string().optional(),
317
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
318
+ provenance: provenanceSchema
299
319
  });
300
320
  var sourcesSchema = z.object({
301
321
  url: z.string().url(),
@@ -1262,6 +1282,7 @@ export {
1262
1282
  changelogSchema,
1263
1283
  chapterSortKey,
1264
1284
  chapterStatus,
1285
+ citationBackstops,
1265
1286
  composeStyles,
1266
1287
  courseNotesChapterSchema,
1267
1288
  courseNotesStyle,
@@ -1278,6 +1299,8 @@ export {
1278
1299
  normalizeFrontmatterConfig,
1279
1300
  patternCategories,
1280
1301
  patternsSchema,
1302
+ provenanceObject,
1303
+ provenanceSchema,
1281
1304
  researchPortfolioChapterSchema,
1282
1305
  researchPortfolioStyle,
1283
1306
  resolvePreset,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-B2bO9Nga.js';
2
+ import { g as BookSchemasOptions } from './types-DTQ2l6B6.js';
3
3
  import 'astro';
4
4
  import 'astro/zod';
5
5
 
package/dist/schemas.mjs CHANGED
@@ -52,6 +52,18 @@ var chapterStatus = [
52
52
  "scaffolded",
53
53
  "planned"
54
54
  ];
55
+ var citationBackstops = ["research-kb", "manual", "unverified"];
56
+ var provenanceObject = z.object({
57
+ ai_tools: z.array(z.string()).default([]),
58
+ prompts_archive: z.string().optional(),
59
+ decisions_log: z.string().optional(),
60
+ audit_history: z.array(z.object({ date: z.date(), type: z.string(), file: z.string() })).default([]),
61
+ citation_backstop: z.enum(citationBackstops).optional()
62
+ }).strict();
63
+ var provenanceSchema = provenanceObject.refine(
64
+ (p) => p.ai_tools.length > 0 || p.audit_history.length > 0 || Boolean(p.citation_backstop) || Boolean(p.prompts_archive) || Boolean(p.decisions_log),
65
+ { message: "provenance is present but empty \u2014 omit the key, or set at least one field" }
66
+ ).optional();
55
67
  var academicChapterSchema = z.object({
56
68
  week: z.number().int().min(1).max(99),
57
69
  part: z.enum(academicParts),
@@ -69,7 +81,9 @@ var academicChapterSchema = z.object({
69
81
  published: z.date().optional(),
70
82
  updated: z.date().optional(),
71
83
  tags: z.array(z.string()).default([]),
72
- image: z.string().optional()
84
+ image: z.string().optional(),
85
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
86
+ provenance: provenanceSchema
73
87
  });
74
88
  var toolsChapterSchema = z.object({
75
89
  title: z.string().min(1),
@@ -87,7 +101,9 @@ var toolsChapterSchema = z.object({
87
101
  author: z.string().optional(),
88
102
  published: z.date().optional(),
89
103
  tags: z.array(z.string()).default([]),
90
- image: z.string().optional()
104
+ image: z.string().optional(),
105
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
106
+ provenance: provenanceSchema
91
107
  });
92
108
  var minimalChapterSchema = toolsChapterSchema;
93
109
  var sourceTiersResearch = ["T1", "T2", "T3", "T4"];
@@ -121,7 +137,9 @@ var courseNotesChapterSchema = z.object({
121
137
  author: z.string().optional(),
122
138
  published: z.date().optional(),
123
139
  updated: z.date().optional(),
124
- image: z.string().optional()
140
+ image: z.string().optional(),
141
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
142
+ provenance: provenanceSchema
125
143
  });
126
144
  var researchPortfolioChapterSchema = z.object({
127
145
  // Identity
@@ -179,7 +197,9 @@ var researchPortfolioChapterSchema = z.object({
179
197
  // `tags` + `updated` already existed; `author` + `published` + `image` are new.
180
198
  author: z.string().optional(),
181
199
  published: z.date().optional(),
182
- image: z.string().optional()
200
+ image: z.string().optional(),
201
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
202
+ provenance: provenanceSchema
183
203
  });
184
204
  var sourcesSchema = z.object({
185
205
  url: z.string().url(),
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.6.1",
4
+ "version": "4.8.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -72,6 +72,7 @@
72
72
  "./components/PolicyRef.astro": "./components/PolicyRef.astro",
73
73
  "./components/Practice.astro": "./components/Practice.astro",
74
74
  "./components/PreReleaseBanner.astro": "./components/PreReleaseBanner.astro",
75
+ "./components/Provenance.astro": "./components/Provenance.astro",
75
76
  "./components/Recovery.astro": "./components/Recovery.astro",
76
77
  "./components/ResultBox.astro": "./components/ResultBox.astro",
77
78
  "./components/Sidebar.astro": "./components/Sidebar.astro",
@@ -17,8 +17,10 @@
17
17
  * [...slug].astro for the canonical pattern).
18
18
  */
19
19
  import { getCollection, render } from 'astro:content';
20
+ import type { Provenance } from '../../src/schemas.js';
20
21
  import Chapter from '../../layouts/Chapter.astro';
21
22
  import Base from '../../layouts/Base.astro';
23
+ import ProvenanceBlock from '../../components/Provenance.astro';
22
24
 
23
25
  const BOOK_PRESET = import.meta.env.BOOK_PRESET ?? 'minimal';
24
26
  const USE_CHAPTER_LAYOUT = ['academic', 'research-portfolio'].includes(BOOK_PRESET);
@@ -34,7 +36,14 @@ export async function getStaticPaths() {
34
36
  const { entry } = Astro.props;
35
37
  const { Content, headings } = await render(entry);
36
38
  const Layout = USE_CHAPTER_LAYOUT ? Chapter : Base;
39
+
40
+ // v4.8.0: per-chapter provenance audit trail. Rendered here (not in a single
41
+ // layout) so it reaches BOTH layout paths — Chapter.astro (academic/
42
+ // research-portfolio) AND Base.astro (tools/minimal/course-notes). Opt-out:
43
+ // always rendered; absent `provenance` → the component's fallback.
44
+ const provenance = (entry.data as { provenance?: Provenance }).provenance ?? null;
37
45
  ---
38
46
  <Layout entry={entry} headings={headings}>
39
47
  <Content />
48
+ <ProvenanceBlock data={provenance} />
40
49
  </Layout>
@@ -73,6 +73,24 @@ Supported `type` values: `theorem`, `proposition`, `lemma`, `corollary`, `defini
73
73
  | `Tag` | Inline volatility/topic tag | `<Tag>stable-principle</Tag>` |
74
74
  | `StatusBadge` | Render frontmatter `status` value with color | `<StatusBadge status={frontmatter.status} />` |
75
75
  | `ChapterHeader` | Auto-rendered metadata block (week, part, status, companion links) | placed at top of each chapter automatically by Chapter.astro |
76
+ | `Provenance` | Auto-rendered per-chapter audit trail (v4.8.0) | placed at the end of each chapter by the chapter route — not imported |
77
+
78
+ ## Per-chapter provenance (v4.8.0, auto-injected)
79
+
80
+ `Provenance` renders a collapsible "How this was made" block on **every** chapter — you don't import or place it. It reads the optional `provenance` frontmatter and is **opt-out**: a chapter with no `provenance` shows a fallback ("Audit history not yet recorded"). It surfaces *process* (the audit trail); `ChapterHeader` still owns *freshness*. Distinct from `AICollaborationDisclosure` (book-level, manual model+role disclosure).
81
+
82
+ ```yaml
83
+ provenance:
84
+ ai_tools: ['Claude Code (Opus 4.8)', 'research-kb']
85
+ prompts_archive: docs/sessions/2026-05-22--ch07.md # repo-relative path or URL
86
+ decisions_log: DECISIONS.md#ch07-derivation # repo-relative path or URL
87
+ audit_history:
88
+ - { date: 2026-05-15, type: routine, file: audits/AUDIT_2026-05-15.md }
89
+ - { date: 2026-05-22, type: independent, file: audits/AUDIT_2026-05-22.md }
90
+ citation_backstop: research-kb # research-kb | manual | unverified
91
+ ```
92
+
93
+ Repo-relative paths render as `<code>`; only `http(s)` values become links (no dead links). If present, the `provenance` object must be non-empty (omit the key to opt out — unknown keys are rejected). `citation_backstop` is a closed set; `audit_history[].type` is free text.
76
94
 
77
95
  ## Conditional imports
78
96
 
@@ -49,8 +49,25 @@ For pre-commit: add to `.pre-commit-config.yaml`:
49
49
 
50
50
  ## Environment variables
51
51
 
52
- - `BOOK_PROFILE` — `academic` enables Cite-key checking; otherwise skipped.
52
+ - `BOOK_PRESET` (canonical) / `BOOK_PROFILE` (alias) which preset to validate against. `academic` enables Cite-key checking.
53
53
  - `BOOK_REPO_ROOT` — absolute path to the paired code repo for CodeRef checks. Unset → skipped (the scaffold default; minimal/tools books rarely have a paired code repo).
54
+ - `BOOK_CHAPTERS_DIR` — override the chapters directory (default: read from `content.config.ts`, fallback `src/content/chapters`).
55
+
56
+ ## Preset / chaptersBase resolution (v4.7.0+, #75)
57
+
58
+ The validator resolves both `preset` and `chaptersBase` by consulting multiple sources in a documented order. Notable v4.7.0 addition: the v4.5+ canonical form
59
+
60
+ ```ts
61
+ // src/content.config.ts
62
+ export const { collections } = defineBookSchemas({
63
+ preset: 'research-portfolio',
64
+ chaptersBase: './src/content/textbook',
65
+ });
66
+ ```
67
+
68
+ is now read by the CLI (previously it was silently ignored — the CLI defaulted to `profile=minimal` and walked `./src/content/chapters/` while `astro build` applied the correct settings, masking real schema drift).
69
+
70
+ Full precedence chain documented in [`PACKAGE_DESIGN.md §8 — Preset + chaptersBase resolution`](../../PACKAGE_DESIGN.md#preset--chaptersbase-resolution-v470-closes-75).
54
71
 
55
72
  ## Output
56
73
 
@@ -28,7 +28,7 @@
28
28
  import { readFile, access } from 'node:fs/promises';
29
29
  import { existsSync, readFileSync } from 'node:fs';
30
30
  import { resolve, dirname, join } from 'node:path';
31
- import { walkMdx, readChaptersBase } from './walk-mdx.mjs';
31
+ import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
32
32
 
33
33
  /**
34
34
  * Best-effort .env reader. Mirrors `readEnvFile` in src/types.ts; kept inline
@@ -105,17 +105,26 @@ const DATA_DIR = resolve(ROOT, 'src/data');
105
105
 
106
106
  // Preset resolution (matches resolvePreset in src/types.ts):
107
107
  // --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
108
- // .env BOOK_PRESET > .env BOOK_PROFILE > 'minimal'.
108
+ // .env BOOK_PRESET > .env BOOK_PROFILE >
109
+ // defineBookSchemas({ preset }) in content.config.ts >
110
+ // defineBookSchemas({ profile }) in content.config.ts (alias) >
111
+ // 'minimal'.
109
112
  // .env fallback closes #20 — without it, consumers who set BOOK_PROFILE in
110
113
  // .env (the documented convenience in SKILL.md + create-book defaults) saw
111
114
  // the CLI silently default to minimal, hiding academic-profile errors.
115
+ // content.config.ts fallback closes #75 — without it, consumers using the
116
+ // canonical v4.5+ defineBookSchemas({ preset, chaptersBase }) form had the
117
+ // CLI silently default to minimal, hiding research-portfolio (and any
118
+ // non-env-set) profile errors while astro build applied the correct settings.
112
119
  const dotenv = readEnvFile(resolve(ROOT, '.env'));
120
+ const schemaConfig = await readBookSchemaConfig(ROOT);
113
121
  const PRESET =
114
122
  presetFromFlag ??
115
123
  process.env.BOOK_PRESET ??
116
124
  process.env.BOOK_PROFILE ??
117
125
  dotenv.BOOK_PRESET ??
118
126
  dotenv.BOOK_PROFILE ??
127
+ schemaConfig.preset ??
119
128
  'minimal';
120
129
  // Alias kept for downstream message text only; the resolution above is canonical.
121
130
  const PROFILE = PRESET;
@@ -34,6 +34,79 @@ export async function* walkMdx(dir, baseDir = dir) {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * Read the consumer's `content.config.ts` (or `.mjs` / `.js`) and extract
39
+ * `defineBookSchemas({ preset?, profile?, chaptersBase? })` options.
40
+ *
41
+ * v4.7.0 (closes #75): consumers using the v4.5+ canonical form
42
+ *
43
+ * export const { collections } = defineBookSchemas({
44
+ * preset: 'research-portfolio',
45
+ * chaptersBase: './src/content/textbook',
46
+ * });
47
+ *
48
+ * previously had BOTH options ignored by the CLI scripts. `validate` and
49
+ * `build-labels` resolved preset only from env vars and walked the default
50
+ * chapters dir, silently checking the wrong directory under the wrong
51
+ * profile while astro build applied the correct settings — a divergence
52
+ * masking real schema drift.
53
+ *
54
+ * Strategy: regex-parse the source file (avoid runtime import; the file
55
+ * imports from `astro:content` / `astro/loaders` which don't resolve in
56
+ * plain Node). Captures the entire `defineBookSchemas({ ... })` options
57
+ * object, then field-by-field regex within that scope for `preset:`,
58
+ * `profile:` (alias), and `chaptersBase:`.
59
+ *
60
+ * Returns `{ preset, chaptersBase }` — both nullable. Returns `null` for
61
+ * either field when:
62
+ * - content.config.{ts,mjs,js} doesn't exist
63
+ * - no `defineBookSchemas(...)` call found
64
+ * - the field is absent or uses a dynamic form (variable, template literal)
65
+ *
66
+ * `preset` and `profile` are aliases (canonical name flipped in v3.7+);
67
+ * `preset` wins when both are present.
68
+ */
69
+ export async function readBookSchemaConfig(projectRoot) {
70
+ const result = { preset: null, chaptersBase: null };
71
+ for (const ext of ['ts', 'mjs', 'js']) {
72
+ const configPath = join(projectRoot, `src/content.config.${ext}`);
73
+ if (!existsSync(configPath)) continue;
74
+ let source;
75
+ try {
76
+ source = await readFile(configPath, 'utf8');
77
+ } catch {
78
+ return result;
79
+ }
80
+ // Match `defineBookSchemas({ ... })` — capture the options object body.
81
+ // Non-greedy `[\s\S]*?` matches the smallest balanced-enough scope; for
82
+ // typical configs the options object is small (≤200 chars) and any
83
+ // nested braces (uncommon in this API) would terminate the match early.
84
+ // Acceptable trade-off: simple regex over a real parser.
85
+ const callMatch = source.match(
86
+ /\bdefineBookSchemas\s*\(\s*\{([\s\S]*?)\}\s*\)/,
87
+ );
88
+ if (callMatch) {
89
+ const optsBody = callMatch[1];
90
+ // preset is canonical (v3.7+); profile is backward-compat alias.
91
+ const presetMatch =
92
+ optsBody.match(/\bpreset\s*:\s*'([^']+)'/) ||
93
+ optsBody.match(/\bpreset\s*:\s*"([^"]+)"/);
94
+ const profileMatch =
95
+ optsBody.match(/\bprofile\s*:\s*'([^']+)'/) ||
96
+ optsBody.match(/\bprofile\s*:\s*"([^"]+)"/);
97
+ result.preset = presetMatch?.[1] ?? profileMatch?.[1] ?? null;
98
+ const chaptersBaseMatch =
99
+ optsBody.match(/\bchaptersBase\s*:\s*'([^']+)'/) ||
100
+ optsBody.match(/\bchaptersBase\s*:\s*"([^"]+)"/);
101
+ result.chaptersBase = chaptersBaseMatch?.[1] ?? null;
102
+ }
103
+ // First existing file wins (priority: .ts > .mjs > .js).
104
+ return result;
105
+ }
106
+ // No content.config.{ts,mjs,js} at all.
107
+ return result;
108
+ }
109
+
37
110
  /**
38
111
  * Read the consumer's `content.config.ts` (or `.mjs` / `.js`) and extract
39
112
  * the `loader.base` path for the `chapters` content collection.
@@ -46,6 +119,9 @@ export async function* walkMdx(dir, baseDir = dir) {
46
119
  * consumer's config file and returns the actual base path so both scripts
47
120
  * discover the consumer's chapter files.
48
121
  *
122
+ * v4.7.0 (closes #75): when the raw Astro form isn't present, also consult
123
+ * `readBookSchemaConfig()` for `defineBookSchemas({ chaptersBase })`.
124
+ *
49
125
  * Strategy: regex-parse the source file (avoid runtime import; the file
50
126
  * imports from `astro:content` / `astro/loaders` which don't resolve in
51
127
  * plain Node). Matches both single- and double-quoted string literals;
@@ -55,6 +131,7 @@ export async function* walkMdx(dir, baseDir = dir) {
55
131
  * `${projectRoot}/src/content/chapters` when:
56
132
  * - content.config.{ts,mjs,js} doesn't exist
57
133
  * - the file exists but no `chapters` collection or `loader.base` found
134
+ * AND no `defineBookSchemas({ chaptersBase: ... })` form found
58
135
  * - the matched base path uses dynamic forms (variables, template literals)
59
136
  * instead of a string literal
60
137
  *
@@ -91,6 +168,12 @@ export async function readChaptersBase(projectRoot) {
91
168
  if (captured) {
92
169
  return resolve(projectRoot, captured);
93
170
  }
171
+ // v4.7.0 (closes #75): no raw Astro form match — try the v4.5+ form
172
+ // `defineBookSchemas({ chaptersBase: '...' })`.
173
+ const schemaConfig = await readBookSchemaConfig(projectRoot);
174
+ if (schemaConfig.chaptersBase) {
175
+ return resolve(projectRoot, schemaConfig.chaptersBase);
176
+ }
94
177
  // File exists but no override found — assume the consumer uses the
95
178
  // scaffold's defineBookSchemas() default.
96
179
  return DEFAULT_BASE;
package/src/schemas.ts CHANGED
@@ -70,6 +70,46 @@ export const chapterStatus = [
70
70
  'planned',
71
71
  ] as const;
72
72
 
73
+ // ===== Provenance (v4.8.0) — process-as-artifact audit trail =====
74
+ //
75
+ // Optional per-chapter block attached to EVERY profile schema below.
76
+ // components/Provenance.astro renders it as a collapsible "How this was made"
77
+ // disclosure (opt-out: absent → fallback). Distinct from AICollaborationDisclosure
78
+ // (book-level, manual). Paths are repo-relative, so prompts_archive / decisions_log
79
+ // use plain z.string() — NOT .url() (which would reject "DECISIONS.md#anchor").
80
+ // audit_history.type is a free string (real audit types vary: 'routine',
81
+ // 'independent', 'first-deploy', ...); citation_backstop is a controlled vocabulary.
82
+ export const citationBackstops = ['research-kb', 'manual', 'unverified'] as const;
83
+
84
+ export const provenanceObject = z
85
+ .object({
86
+ ai_tools: z.array(z.string()).default([]),
87
+ prompts_archive: z.string().optional(),
88
+ decisions_log: z.string().optional(),
89
+ audit_history: z
90
+ .array(z.object({ date: z.date(), type: z.string(), file: z.string() }))
91
+ .default([]),
92
+ citation_backstop: z.enum(citationBackstops).optional(),
93
+ })
94
+ // .strict(): a misspelled key (e.g. `desisions_log`) must fail loud at build,
95
+ // not be silently stripped — silent data loss is the opposite of an audit trail.
96
+ .strict();
97
+
98
+ // Attached to every chapter schema as an optional field. The `.refine` makes
99
+ // "present ⇒ non-empty": a bare `provenance: {}` is author error (omit the key
100
+ // to opt out instead), so it fails fast rather than rendering a meaningless block.
101
+ export const provenanceSchema = provenanceObject
102
+ .refine(
103
+ (p) =>
104
+ p.ai_tools.length > 0 ||
105
+ p.audit_history.length > 0 ||
106
+ Boolean(p.citation_backstop) ||
107
+ Boolean(p.prompts_archive) ||
108
+ Boolean(p.decisions_log),
109
+ { message: 'provenance is present but empty — omit the key, or set at least one field' },
110
+ )
111
+ .optional();
112
+
73
113
  // ===== Chapter schemas — one per profile =====
74
114
 
75
115
  export const academicChapterSchema = z.object({
@@ -90,6 +130,8 @@ export const academicChapterSchema = z.object({
90
130
  updated: z.date().optional(),
91
131
  tags: z.array(z.string()).default([]),
92
132
  image: z.string().optional(),
133
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
134
+ provenance: provenanceSchema,
93
135
  });
94
136
 
95
137
  export const toolsChapterSchema = z.object({
@@ -109,6 +151,8 @@ export const toolsChapterSchema = z.object({
109
151
  published: z.date().optional(),
110
152
  tags: z.array(z.string()).default([]),
111
153
  image: z.string().optional(),
154
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
155
+ provenance: provenanceSchema,
112
156
  });
113
157
 
114
158
  /** Minimal profile currently aliases the tools schema. */
@@ -168,6 +212,8 @@ export const courseNotesChapterSchema = z.object({
168
212
  published: z.date().optional(),
169
213
  updated: z.date().optional(),
170
214
  image: z.string().optional(),
215
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
216
+ provenance: provenanceSchema,
171
217
  });
172
218
 
173
219
  /**
@@ -255,6 +301,8 @@ export const researchPortfolioChapterSchema = z.object({
255
301
  author: z.string().optional(),
256
302
  published: z.date().optional(),
257
303
  image: z.string().optional(),
304
+ // v4.8.0: optional process-as-artifact audit trail (Provenance.astro).
305
+ provenance: provenanceSchema,
258
306
  });
259
307
 
260
308
  // ===== Inferred chapter types — one per schema =====
@@ -269,6 +317,7 @@ export type ToolsChapter = z.infer<typeof toolsChapterSchema>;
269
317
  export type MinimalChapter = z.infer<typeof minimalChapterSchema>;
270
318
  export type CourseNotesChapter = z.infer<typeof courseNotesChapterSchema>;
271
319
  export type ResearchPortfolioChapter = z.infer<typeof researchPortfolioChapterSchema>;
320
+ export type Provenance = z.infer<typeof provenanceObject>;
272
321
 
273
322
  // ===== Collateral collection schemas (tools-profile; always-defined) =====
274
323