@brandon_m_behring/book-scaffold-astro 4.5.1 → 4.6.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/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-DLIpEgTm.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-DLIpEgTm.js';
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';
4
4
  import 'astro/zod';
5
5
 
6
6
  /**
package/dist/index.mjs CHANGED
@@ -117,6 +117,7 @@ var init_katex_macros = __esm({
117
117
  // src/config.ts
118
118
  import mdx from "@astrojs/mdx";
119
119
  import preact from "@astrojs/preact";
120
+ import sitemap from "@astrojs/sitemap";
120
121
 
121
122
  // src/profile-kit.ts
122
123
  function defineProfile(p) {
@@ -177,7 +178,14 @@ var academicChapterSchema = z.object({
177
178
  tests_path: z.string().optional(),
178
179
  notebook_path: z.string().optional(),
179
180
  description: z.string().optional(),
180
- draft: z.boolean().default(false)
181
+ draft: z.boolean().default(false),
182
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
183
+ // All optional; existing chapters without these continue to work.
184
+ author: z.string().optional(),
185
+ published: z.date().optional(),
186
+ updated: z.date().optional(),
187
+ tags: z.array(z.string()).default([]),
188
+ image: z.string().optional()
181
189
  });
182
190
  var toolsChapterSchema = z.object({
183
191
  title: z.string().min(1),
@@ -189,7 +197,13 @@ var toolsChapterSchema = z.object({
189
197
  sources: z.array(z.string()).default([]),
190
198
  description: z.string().optional(),
191
199
  draft: z.boolean().default(false),
192
- updated: z.date().optional()
200
+ updated: z.date().optional(),
201
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
202
+ // `updated` already existed; the rest are new.
203
+ author: z.string().optional(),
204
+ published: z.date().optional(),
205
+ tags: z.array(z.string()).default([]),
206
+ image: z.string().optional()
193
207
  });
194
208
  var minimalChapterSchema = toolsChapterSchema;
195
209
  var sourceTiersResearch = ["T1", "T2", "T3", "T4"];
@@ -216,7 +230,14 @@ var courseNotesChapterSchema = z.object({
216
230
  last_verified: z.date(),
217
231
  volatility: z.enum(volatilityLevels).default("architectural-pattern"),
218
232
  sources: z.array(z.string()).default([]),
219
- draft: z.boolean().default(false)
233
+ draft: z.boolean().default(false),
234
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
235
+ // `tags` already existed; the rest are new. `instructor` (line 130) is
236
+ // attribution metadata — distinct from `author` (the note-writer/curator).
237
+ author: z.string().optional(),
238
+ published: z.date().optional(),
239
+ updated: z.date().optional(),
240
+ image: z.string().optional()
220
241
  });
221
242
  var researchPortfolioChapterSchema = z.object({
222
243
  // Identity
@@ -269,7 +290,12 @@ var researchPortfolioChapterSchema = z.object({
269
290
  // Status + dates.
270
291
  last_verified: z.date(),
271
292
  updated: z.date().optional(),
272
- draft: z.boolean().default(false)
293
+ draft: z.boolean().default(false),
294
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
295
+ // `tags` + `updated` already existed; `author` + `published` + `image` are new.
296
+ author: z.string().optional(),
297
+ published: z.date().optional(),
298
+ image: z.string().optional()
273
299
  });
274
300
  var sourcesSchema = z.object({
275
301
  url: z.string().url(),
@@ -387,8 +413,11 @@ var academicProfile = defineProfile({
387
413
  },
388
414
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
389
415
  katex: true,
390
- chaptersRenderer: academicChaptersRenderer
416
+ chaptersRenderer: academicChaptersRenderer,
391
417
  // v3.7.0 (#35) — owns /chapters semantics if consumer opts in via routes.chapters
418
+ // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
419
+ // view, crawl-redundant. Academic-profile default.
420
+ sitemapFilter: (page) => !page.includes("/print/")
392
421
  });
393
422
 
394
423
  // src/lib/freshness.ts
@@ -616,7 +645,10 @@ var courseNotesProfile = defineProfile({
616
645
  },
617
646
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
618
647
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
619
- chaptersRenderer: fallbackChaptersRenderer
648
+ chaptersRenderer: fallbackChaptersRenderer,
649
+ // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
650
+ // view, crawl-redundant. Course-notes-profile default.
651
+ sitemapFilter: (page) => !page.includes("/print/")
620
652
  });
621
653
 
622
654
  // src/profiles/research-portfolio.ts
@@ -824,19 +856,19 @@ function defineMdxComponents(components) {
824
856
  }
825
857
 
826
858
  // src/integration.ts
827
- var LANDING_VIRTUAL_ID = "virtual:book-scaffold/landing-config";
828
- var LANDING_RESOLVED_ID = "\0" + LANDING_VIRTUAL_ID;
829
- function makeLandingConfigVitePlugin(config) {
859
+ var BOOK_CONFIG_VIRTUAL_ID = "virtual:book-scaffold/book-config";
860
+ var BOOK_CONFIG_RESOLVED_ID = "\0" + BOOK_CONFIG_VIRTUAL_ID;
861
+ function makeBookConfigVitePlugin(config) {
830
862
  const serialized = `export default ${JSON.stringify(config)};`;
831
863
  return {
832
- name: "book-scaffold:landing-config",
864
+ name: "book-scaffold:book-config",
833
865
  enforce: "pre",
834
866
  resolveId(id) {
835
- if (id === LANDING_VIRTUAL_ID) return LANDING_RESOLVED_ID;
867
+ if (id === BOOK_CONFIG_VIRTUAL_ID) return BOOK_CONFIG_RESOLVED_ID;
836
868
  return null;
837
869
  },
838
870
  load(id) {
839
- if (id !== LANDING_RESOLVED_ID) return null;
871
+ if (id !== BOOK_CONFIG_RESOLVED_ID) return null;
840
872
  return serialized;
841
873
  }
842
874
  };
@@ -887,10 +919,14 @@ function bookScaffoldIntegration(opts) {
887
919
  routes: userOverrides = {},
888
920
  extraStyles = [],
889
921
  mdxComponentsModule,
890
- // v4.5.0: landing-page data, propagated via vite.define to /index.astro.
922
+ // v4.5.0: landing-page data, propagated via virtual module to /index.astro.
891
923
  title,
892
924
  description,
893
- portfolio
925
+ portfolio,
926
+ // v4.6.0: book-level author + SEO config, propagated through the
927
+ // (renamed) book-config virtual module to Base.astro + Chapter.astro.
928
+ author,
929
+ seo
894
930
  } = opts;
895
931
  const def = PROFILES[profile];
896
932
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -937,11 +973,16 @@ function bookScaffoldIntegration(opts) {
937
973
  vite: {
938
974
  plugins: [
939
975
  makeMdxComponentsVitePlugin(resolvedMdxPath),
940
- makeLandingConfigVitePlugin({
976
+ makeBookConfigVitePlugin({
941
977
  title: title ?? null,
942
978
  description: description ?? null,
943
979
  portfolio: portfolio ?? false,
944
- enabledRoutes: enabledRouteNames
980
+ enabledRoutes: enabledRouteNames,
981
+ author: author ?? null,
982
+ seo: {
983
+ ogImage: seo?.ogImage ?? null,
984
+ twitterHandle: seo?.twitterHandle ?? null
985
+ }
945
986
  })
946
987
  ],
947
988
  define: {
@@ -1045,19 +1086,36 @@ async function defineBookConfig(opts) {
1045
1086
  ]);
1046
1087
  }
1047
1088
  const resolvedPortfolio = opts.portfolio === false ? false : opts.portfolio ?? BRANDON_PORTFOLIO_DEFAULT;
1089
+ const profileSitemapFilter = PROFILES[profile]?.sitemapFilter;
1090
+ const sitemapFilter = opts.seo?.sitemap?.filter ?? profileSitemapFilter;
1091
+ const sitemapCustomPages = opts.seo?.sitemap?.customPages;
1092
+ const sitemapOptions = {};
1093
+ if (sitemapFilter) sitemapOptions.filter = sitemapFilter;
1094
+ if (sitemapCustomPages) sitemapOptions.customPages = sitemapCustomPages;
1048
1095
  const integrations = [
1049
1096
  mdx(),
1050
1097
  preact(),
1098
+ // v4.6.0: @astrojs/sitemap default integration. Emits
1099
+ // /sitemap-index.xml + per-route sitemaps from the resolved `site:`
1100
+ // (defineBookConfig throws above if site is missing, so the URL is
1101
+ // always available to the sitemap integration here).
1102
+ sitemap(sitemapOptions),
1051
1103
  bookScaffoldIntegration({
1052
1104
  profile,
1053
1105
  routes: mergedRoutes,
1054
1106
  mdxComponentsModule,
1055
1107
  extraStyles: mergedExtraStyles,
1056
1108
  // v4.5.0: pass landing-page data through to the integration so it can
1057
- // be exposed to the auto-injected /index.astro via vite.define.
1109
+ // be exposed to the auto-injected /index.astro via the virtual module.
1058
1110
  title: opts.title,
1059
1111
  description: opts.description,
1060
- portfolio: resolvedPortfolio
1112
+ portfolio: resolvedPortfolio,
1113
+ // v4.6.0: book-level author + SEO config (ogImage, twitterHandle),
1114
+ // propagated through the renamed `book-config` virtual module to
1115
+ // Base.astro + Chapter.astro. `seo.sitemap` is NOT passed through —
1116
+ // it's consumed below at config-time by the @astrojs/sitemap call.
1117
+ author: opts.author,
1118
+ seo: opts.seo ? { ogImage: opts.seo.ogImage, twitterHandle: opts.seo.twitterHandle } : void 0
1061
1119
  }),
1062
1120
  ...mergedExtraIntegrations
1063
1121
  ];
@@ -1096,6 +1154,9 @@ async function defineBookConfig(opts) {
1096
1154
  title: _title,
1097
1155
  description: _description,
1098
1156
  portfolio: _portfolio,
1157
+ // v4.6.0: strip new book-level SEO opts (author + seo block).
1158
+ author: _author,
1159
+ seo: _seo,
1099
1160
  ...rest
1100
1161
  } = opts;
1101
1162
  void _styles;
@@ -1110,6 +1171,8 @@ async function defineBookConfig(opts) {
1110
1171
  void _title;
1111
1172
  void _description;
1112
1173
  void _portfolio;
1174
+ void _author;
1175
+ void _seo;
1113
1176
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1114
1177
  const config = {
1115
1178
  site,
package/dist/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { g as BookSchemasOptions } from './types-DLIpEgTm.js';
2
+ import { g as BookSchemasOptions } from './types-B2bO9Nga.js';
3
3
  import 'astro';
4
4
  import 'astro/zod';
5
5
 
package/dist/schemas.mjs CHANGED
@@ -62,7 +62,14 @@ var academicChapterSchema = z.object({
62
62
  tests_path: z.string().optional(),
63
63
  notebook_path: z.string().optional(),
64
64
  description: z.string().optional(),
65
- draft: z.boolean().default(false)
65
+ draft: z.boolean().default(false),
66
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
67
+ // All optional; existing chapters without these continue to work.
68
+ author: z.string().optional(),
69
+ published: z.date().optional(),
70
+ updated: z.date().optional(),
71
+ tags: z.array(z.string()).default([]),
72
+ image: z.string().optional()
66
73
  });
67
74
  var toolsChapterSchema = z.object({
68
75
  title: z.string().min(1),
@@ -74,7 +81,13 @@ var toolsChapterSchema = z.object({
74
81
  sources: z.array(z.string()).default([]),
75
82
  description: z.string().optional(),
76
83
  draft: z.boolean().default(false),
77
- updated: z.date().optional()
84
+ updated: z.date().optional(),
85
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
86
+ // `updated` already existed; the rest are new.
87
+ author: z.string().optional(),
88
+ published: z.date().optional(),
89
+ tags: z.array(z.string()).default([]),
90
+ image: z.string().optional()
78
91
  });
79
92
  var minimalChapterSchema = toolsChapterSchema;
80
93
  var sourceTiersResearch = ["T1", "T2", "T3", "T4"];
@@ -101,7 +114,14 @@ var courseNotesChapterSchema = z.object({
101
114
  last_verified: z.date(),
102
115
  volatility: z.enum(volatilityLevels).default("architectural-pattern"),
103
116
  sources: z.array(z.string()).default([]),
104
- draft: z.boolean().default(false)
117
+ draft: z.boolean().default(false),
118
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
119
+ // `tags` already existed; the rest are new. `instructor` (line 130) is
120
+ // attribution metadata — distinct from `author` (the note-writer/curator).
121
+ author: z.string().optional(),
122
+ published: z.date().optional(),
123
+ updated: z.date().optional(),
124
+ image: z.string().optional()
105
125
  });
106
126
  var researchPortfolioChapterSchema = z.object({
107
127
  // Identity
@@ -154,7 +174,12 @@ var researchPortfolioChapterSchema = z.object({
154
174
  // Status + dates.
155
175
  last_verified: z.date(),
156
176
  updated: z.date().optional(),
157
- draft: z.boolean().default(false)
177
+ draft: z.boolean().default(false),
178
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
179
+ // `tags` + `updated` already existed; `author` + `published` + `image` are new.
180
+ author: z.string().optional(),
181
+ published: z.date().optional(),
182
+ image: z.string().optional()
158
183
  });
159
184
  var sourcesSchema = z.object({
160
185
  url: z.string().url(),
@@ -272,8 +297,11 @@ var academicProfile = defineProfile({
272
297
  },
273
298
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
274
299
  katex: true,
275
- chaptersRenderer: academicChaptersRenderer
300
+ chaptersRenderer: academicChaptersRenderer,
276
301
  // v3.7.0 (#35) — owns /chapters semantics if consumer opts in via routes.chapters
302
+ // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
303
+ // view, crawl-redundant. Academic-profile default.
304
+ sitemapFilter: (page) => !page.includes("/print/")
277
305
  });
278
306
 
279
307
  // src/lib/freshness.ts
@@ -501,7 +529,10 @@ var courseNotesProfile = defineProfile({
501
529
  },
502
530
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
503
531
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
504
- chaptersRenderer: fallbackChaptersRenderer
532
+ chaptersRenderer: fallbackChaptersRenderer,
533
+ // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
534
+ // view, crawl-redundant. Course-notes-profile default.
535
+ sitemapFilter: (page) => !page.includes("/print/")
505
536
  });
506
537
 
507
538
  // src/profiles/research-portfolio.ts
@@ -50,6 +50,10 @@ import '../styles/print.css';
50
50
  import VersionSelector from '@brandon_m_behring/book-scaffold-astro/components/VersionSelector';
51
51
  import ToolFilter from '@brandon_m_behring/book-scaffold-astro/components/ToolFilter';
52
52
  import Sidebar from '../components/Sidebar.astro';
53
+ // v4.6.0: SEO meta tags read book-level identity (title fallback,
54
+ // description fallback, ogImage default, twitterHandle) from the
55
+ // book-config virtual module (was landing-config in v4.5.1).
56
+ import bookConfig from 'virtual:book-scaffold/book-config';
53
57
 
54
58
  const profile = import.meta.env.BOOK_PROFILE ?? 'minimal';
55
59
  const showToolsChrome = profile !== 'academic';
@@ -59,9 +63,37 @@ interface Props {
59
63
  description?: string;
60
64
  lang?: string;
61
65
  showSidebar?: boolean;
66
+ /** v4.6.0: Open Graph image URL (relative to site root, or absolute). */
67
+ ogImage?: string;
68
+ /** v4.6.0: Open Graph type. Defaults to 'website'; Chapter.astro passes 'article'. */
69
+ ogType?: string;
62
70
  }
63
71
 
64
- const { title, description, lang = 'en', showSidebar = true } = Astro.props;
72
+ const {
73
+ title,
74
+ description,
75
+ lang = 'en',
76
+ showSidebar = true,
77
+ ogImage,
78
+ ogType = 'website',
79
+ } = Astro.props;
80
+
81
+ // v4.6.0: canonical + og:url need Astro.site. defineBookConfig throws at
82
+ // config-load if `site` is missing, so Astro.site is guaranteed populated
83
+ // here. `new URL` resolves the relative pathname against it.
84
+ const canonicalURL = new URL(Astro.url.pathname, Astro.site).toString();
85
+
86
+ // Per D3: og:image emits only when an image is explicitly set — either via
87
+ // per-page Astro.props.ogImage or book-level bookConfig.seo.ogImage. No
88
+ // '/og-default.png' fallback to avoid broken-link meta tags on consumers
89
+ // without an OG image authored.
90
+ const resolvedOgImage = ogImage ?? bookConfig.seo?.ogImage ?? null;
91
+ const absoluteOgImage = resolvedOgImage
92
+ ? new URL(resolvedOgImage, Astro.site).toString()
93
+ : null;
94
+ const twitterHandle = bookConfig.seo?.twitterHandle ?? null;
95
+ const ogSiteName = bookConfig.title ?? title;
96
+ const ogDescription = description ?? bookConfig.description ?? '';
65
97
  ---
66
98
 
67
99
  <!doctype html>
@@ -74,6 +106,27 @@ const { title, description, lang = 'en', showSidebar = true } = Astro.props;
74
106
  <meta name="generator" content={Astro.generator} />
75
107
  <title>{title}</title>
76
108
  {description && <meta name="description" content={description} />}
109
+
110
+ {/* v4.6.0: SEO meta-tag block (issue #76 Primary). canonical + og:* +
111
+ twitter:* on every page; article:* lives in Chapter.astro via the
112
+ Base `<slot name="head" />` (below). og:image + twitter:image emit
113
+ only when an image is explicitly set (per D3, avoids broken-link
114
+ OG tags). sitemap link points at @astrojs/sitemap's auto-emitted
115
+ index. */}
116
+ <link rel="canonical" href={canonicalURL} />
117
+ <link rel="sitemap" type="application/xml" href="/sitemap-index.xml" />
118
+ <meta property="og:title" content={title} />
119
+ {ogDescription && <meta property="og:description" content={ogDescription} />}
120
+ <meta property="og:url" content={canonicalURL} />
121
+ <meta property="og:type" content={ogType} />
122
+ <meta property="og:site_name" content={ogSiteName} />
123
+ {absoluteOgImage && <meta property="og:image" content={absoluteOgImage} />}
124
+ <meta name="twitter:card" content="summary_large_image" />
125
+ <meta name="twitter:title" content={title} />
126
+ {ogDescription && <meta name="twitter:description" content={ogDescription} />}
127
+ {absoluteOgImage && <meta name="twitter:image" content={absoluteOgImage} />}
128
+ {twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
129
+
77
130
  {/* FOUC prevention — apply saved theme before any paint. */}
78
131
  <script is:inline>
79
132
  (function () {
@@ -12,10 +12,17 @@
12
12
  *
13
13
  * Driven by a CollectionEntry<'chapters'> passed in Astro.props.entry
14
14
  * plus the `headings` array Astro's MDX renderer provides.
15
+ *
16
+ * v4.6.0: passes ogType="article" to Base + emits article:* meta tags via
17
+ * Base's `<slot name="head" />`. Article tags read from chapter frontmatter
18
+ * (`author`, `published`, `updated`, `tags`) with `author` falling back to
19
+ * book-level `bookConfig.author`. `entry.data.image` (chapter-specific OG
20
+ * image) is passed as `ogImage` for richer per-chapter social cards.
15
21
  */
16
22
  import type { CollectionEntry } from 'astro:content';
17
23
  import type { MarkdownHeading } from 'astro';
18
24
  import Base from './Base.astro';
25
+ import bookConfig from 'virtual:book-scaffold/book-config';
19
26
  import ChapterHeader from '../components/ChapterHeader.astro';
20
27
  import ChapterTOC from '../components/ChapterTOC.astro';
21
28
  import ChapterNav from '../components/ChapterNav.astro';
@@ -25,9 +32,36 @@ interface Props {
25
32
  headings: MarkdownHeading[];
26
33
  }
27
34
  const { entry, headings } = Astro.props;
35
+
36
+ // v4.6.0: article:* meta-tag data. All fields are Zod-optional in the
37
+ // chapter schemas (academic / tools / course-notes / research-portfolio),
38
+ // so any of these may be undefined. `author` falls back to bookConfig.author
39
+ // (the top-level defineBookConfig field) so single-author books don't
40
+ // repeat themselves per chapter.
41
+ const articleAuthor =
42
+ (entry.data as { author?: string }).author ?? bookConfig.author ?? null;
43
+ const articlePublished = (entry.data as { published?: Date }).published;
44
+ const articleUpdated = (entry.data as { updated?: Date }).updated;
45
+ const articleTags = ((entry.data as { tags?: string[] }).tags ?? []) as string[];
46
+ const chapterImage = (entry.data as { image?: string }).image;
28
47
  ---
29
48
 
30
- <Base title={entry.data.title} description={entry.data.description}>
49
+ <Base
50
+ title={entry.data.title}
51
+ description={entry.data.description}
52
+ ogType="article"
53
+ ogImage={chapterImage}
54
+ >
55
+ <Fragment slot="head">
56
+ {articleAuthor && <meta property="article:author" content={articleAuthor} />}
57
+ {articlePublished && (
58
+ <meta property="article:published_time" content={articlePublished.toISOString()} />
59
+ )}
60
+ {articleUpdated && (
61
+ <meta property="article:modified_time" content={articleUpdated.toISOString()} />
62
+ )}
63
+ {articleTags.map((t) => <meta property="article:tag" content={t} />)}
64
+ </Fragment>
31
65
  <article class="prose">
32
66
  <ChapterHeader data={entry.data} />
33
67
  <ChapterTOC headings={headings} />
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.5.1",
4
+ "version": "4.6.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -152,6 +152,7 @@
152
152
  "remark-math": { "optional": true }
153
153
  },
154
154
  "dependencies": {
155
+ "@astrojs/sitemap": "^3.6.1",
155
156
  "@citation-js/core": "^0.7.21",
156
157
  "@citation-js/plugin-bibtex": "^0.7.21",
157
158
  "@fontsource-variable/roboto": "^5.2.10",
package/pages/index.astro CHANGED
@@ -9,8 +9,10 @@
9
9
  * config needed.
10
10
  *
11
11
  * Reads book identity + portfolio + enabled-routes from the
12
- * `virtual:book-scaffold/landing-config` virtual module exposed by
13
- * makeLandingConfigVitePlugin (see integration.ts §4.5.1). v4.5.0 used
12
+ * `virtual:book-scaffold/book-config` virtual module exposed by
13
+ * makeBookConfigVitePlugin (renamed from makeLandingConfigVitePlugin in
14
+ * v4.6.0 since Base.astro now also imports the module on every page).
15
+ * v4.5.0 used
14
16
  * import.meta.env.BOOK_* env vars; that pattern was vulnerable to silent
15
17
  * override by consumer .env files (caught during DML deploy when a stale
16
18
  * `BOOK_TITLE=web` in web/.env overrode defineBookConfig({title})). The
@@ -31,7 +33,7 @@
31
33
  * or defineBookConfig({ portfolio: false })
32
34
  */
33
35
  import Base from '../layouts/Base.astro';
34
- import bookConfig from 'virtual:book-scaffold/landing-config';
36
+ import bookConfig from 'virtual:book-scaffold/book-config';
35
37
 
36
38
  const title = bookConfig.title ?? 'book-scaffold-astro';
37
39
  const description = bookConfig.description;
@@ -0,0 +1,87 @@
1
+ # Recipe 18 — Chapter-route ownership (v4.3.0+)
2
+
3
+ Since v4.3.0, `book-scaffold-astro` **auto-injects** the per-chapter route `/chapters/[...slug]/` when `routes.chapters: true` is in `defineBookConfig`. Consumers that pre-date v4.3.0 (or were scaffolded from older templates) may carry their own `src/pages/chapters/[...slug].astro` that **shadows** the auto-injected one — Astro's filesystem routing picks the consumer file over `injectRoute`, with no error or warning until v4.6.0.
4
+
5
+ This recipe explains the three valid states and how to pick one.
6
+
7
+ ---
8
+
9
+ ## TL;DR
10
+
11
+ If your `src/pages/chapters/[...slug].astro` is a stock copy from a pre-v4.3.0 template (mechanical boilerplate, no custom layout work), **delete it**. The scaffold's auto-injected route handles every academic / tools / research-portfolio consumer's per-chapter rendering identically.
12
+
13
+ If you customized the file (e.g., book-specific chapter chrome, citation styling, sidebar variations), **keep it** AND set `routes: { chapters: false }` in your `defineBookConfig` to signal the override is intentional.
14
+
15
+ The `book-scaffold validate` CLI emits a warning (v4.6.0+) when it detects a consumer-owned file without the `chapters: false` override, prompting you to pick one of the two states.
16
+
17
+ ---
18
+
19
+ ## Three states
20
+
21
+ ### State 1 — Default (90% case, recommended)
22
+
23
+ No `src/pages/chapters/[...slug].astro` in your consumer repo. Scaffold's auto-injected route handles per-chapter rendering. `routes.chapters` defaults to whatever your profile sets (`true` for academic by default).
24
+
25
+ ```
26
+ your-book/
27
+ └── src/
28
+ └── pages/
29
+ └── chapters.astro ← optional: the /chapters/ index page (also auto-injected if missing)
30
+ # no chapters/ directory; scaffold provides /chapters/[...slug]/
31
+ ```
32
+
33
+ Pros: zero per-consumer code. Future scaffold improvements (e.g., richer chapter sidebars, better TOC rendering) propagate automatically on the next scaffold bump.
34
+
35
+ ### State 2 — Intentional override (custom chapter layout)
36
+
37
+ `src/pages/chapters/[...slug].astro` exists in your consumer repo AND `defineBookConfig({ routes: { chapters: false } })` is set. The consumer file owns the route; scaffold's auto-injected route is suppressed.
38
+
39
+ ```ts
40
+ // astro.config.mjs
41
+ export default await defineBookConfig({
42
+ styles: [academicStyle],
43
+ site: 'https://your-book.example/',
44
+ routes: { chapters: false }, // ← signal: I own the chapter route
45
+ });
46
+ ```
47
+
48
+ ```
49
+ your-book/
50
+ └── src/
51
+ └── pages/
52
+ └── chapters/
53
+ └── [...slug].astro ← consumer-owned; scaffold defers
54
+ ```
55
+
56
+ Pros: full control over per-chapter chrome, sidebar, TOC layout, etc. Cons: must hand-maintain when scaffold ships chapter-level improvements (the consumer file is now your source of truth for chapter rendering).
57
+
58
+ ### State 3 — Anti-pattern (shadow, fixed by v4.6 validator warning)
59
+
60
+ `src/pages/chapters/[...slug].astro` exists in your consumer repo BUT `routes.chapters` is undefined or `true`. Astro's filesystem routing wins, so the consumer file renders — but the scaffold is also trying to inject a route at the same pattern. Functionally works today (Astro picks the consumer file), but:
61
+
62
+ - Future scaffold improvements to chapter rendering silently no-op for this consumer.
63
+ - Dual-source ownership is confusing for future maintainers.
64
+ - `book-scaffold validate` emits a warning in v4.6.0+.
65
+
66
+ **Fix**: pick State 1 (delete the file) or State 2 (set `chapters: false`).
67
+
68
+ ---
69
+
70
+ ## Migration from pre-v4.3.0 templates
71
+
72
+ Reference consumers `double_ml_time_series` (Phase 1f, 2026-05-26) and `ssm-foundations` (Phase 1c, 2026-05-26) both migrated from State 3 → State 1 — the consumer-owned `src/pages/chapters/[...slug].astro` was a mechanical copy of the same boilerplate that scaffold v4.3.0+ auto-injects, so deletion was lossless.
73
+
74
+ Migration steps:
75
+
76
+ 1. `diff` your consumer's `src/pages/chapters/[...slug].astro` against the scaffold's `package/pages/chapters/[...slug].astro` to confirm no customization. If they're equivalent (ignoring trivial formatting), proceed.
77
+ 2. `rm src/pages/chapters/'[...slug].astro'` (note the shell-escaped brackets).
78
+ 3. `git commit -m "chore: delete chapter-route override (scaffold v4.3.0+ auto-injects)"`.
79
+ 4. Push + redeploy. The scaffold's auto-injected route takes over without behavior change.
80
+
81
+ If your file has real customization, prefer State 2: keep the file and add `routes: { chapters: false }` to `defineBookConfig`.
82
+
83
+ ---
84
+
85
+ ## Why this matters
86
+
87
+ Layer-3 cleanup is part of [issue #76](https://github.com/brandon-behring/book-scaffold-astro/issues/76)'s v4.6 bundle. Related companion: [recipe 19 — prevalidate-hook](./19-prevalidate-hook.md), which fixes another silent-CI gap surfaced during the same first-deploy sessions.
@@ -0,0 +1,113 @@
1
+ # Recipe 19 — `prevalidate` npm hook (v4.6.0+)
2
+
3
+ `book-scaffold validate` checks `<Cite key="...">` against `src/data/references.json` (academic profile) and `<XRef id="...">` against `src/data/labels.json`. Both JSON files are **derived artifacts** regenerated from `bibliography.bib` + chapter MDX by `book-scaffold build-bib` + `book-scaffold build-labels`. Both are gitignored.
4
+
5
+ When `npm run validate` runs **standalone** (e.g., a reusable deploy workflow runs only the validate command, without the full `npm run build` chain), the prereq scripts don't fire and the validator chokes on apparently-missing keys.
6
+
7
+ The `prevalidate` npm lifecycle hook is the canonical fix: it auto-runs the prereqs whenever `npm run validate` is invoked, regardless of how validate is called.
8
+
9
+ ---
10
+
11
+ ## TL;DR
12
+
13
+ ```json
14
+ {
15
+ "scripts": {
16
+ "prevalidate": "npm run build:bib && npm run build:labels --if-present",
17
+ "validate": "book-scaffold validate"
18
+ }
19
+ }
20
+ ```
21
+
22
+ `npm run validate` → automatically runs `prevalidate` first (build:bib + build:labels) → then runs `validate`. CI and local behave identically; no separate `ci:validate` wrapper script needed.
23
+
24
+ ---
25
+
26
+ ## Why the workaround was needed
27
+
28
+ `brandon-behring/deploy-workflows@v1` (the reusable Cloudflare Workers deploy) runs `npm run <validate-command>` between `npm ci` and `npm run build`. Without the `prevalidate` hook OR an explicit wrapper script, the validate step ran against missing artifacts.
29
+
30
+ The Phase 1c first-deploy of `ssm-foundations` (2026-05-26) hit this — validate emitted 25+ "Unknown bibkey" errors that pointed at chapter content instead of the missing `references.json` artifact. The deploy-time fix shipped as a `ci:validate` wrapper:
31
+
32
+ ```json
33
+ {
34
+ "scripts": {
35
+ "ci:validate": "npm run build:bib && npm run build:labels --if-present && npm run validate"
36
+ }
37
+ }
38
+ ```
39
+
40
+ …with `validate-command: ci:validate` in `.github/workflows/deploy.yml`. This worked but introduced consumer-side ceremony for what is structurally a scaffold-level convention.
41
+
42
+ The cleaner long-term fix is the `prevalidate` npm-lifecycle hook (this recipe). v4.6.0's `create-book` automatically scaffolds it for academic + research-portfolio profiles.
43
+
44
+ ---
45
+
46
+ ## Migration: from `ci:validate` to `prevalidate`
47
+
48
+ Existing consumers (DML, ssm, dlai when it ships) that adopted the `ci:validate` wrapper during 2026-05-26 deploys can migrate when they bump to scaffold `^4.6.0`. Three-file mechanical change per consumer:
49
+
50
+ ### 1. `package.json` — rename `ci:validate` → `prevalidate`
51
+
52
+ ```diff
53
+ {
54
+ "scripts": {
55
+ - "ci:validate": "npm run build:bib && npm run build:labels --if-present && npm run validate",
56
+ + "prevalidate": "npm run build:bib && npm run build:labels --if-present",
57
+ "validate": "book-scaffold validate"
58
+ }
59
+ }
60
+ ```
61
+
62
+ Note the renamed script no longer needs to call `validate` itself — npm's lifecycle invokes it automatically after `prevalidate` completes.
63
+
64
+ ### 2. `.github/workflows/deploy.yml` — revert `validate-command`
65
+
66
+ ```diff
67
+ jobs:
68
+ deploy:
69
+ uses: brandon-behring/deploy-workflows/.github/workflows/deploy-astro-worker.yml@v2
70
+ secrets: inherit
71
+ with:
72
+ working-directory: web
73
+ - validate-command: ci:validate
74
+ + validate-command: validate
75
+ enable-pr-previews: true
76
+ ```
77
+
78
+ The reusable workflow's `validate-command` now points at the native `validate` script; the `prevalidate` hook handles its own prereqs transparently.
79
+
80
+ ### 3. Simplify `prebuild` (optional)
81
+
82
+ If the consumer's `prebuild` still has the long chain:
83
+
84
+ ```diff
85
+ {
86
+ "scripts": {
87
+ - "prebuild": "npm run build:bib --if-present && npm run build:labels --if-present && npm run validate --if-present",
88
+ + "prebuild": "npm run validate --if-present",
89
+ "build": "astro build && pagefind --site dist"
90
+ }
91
+ }
92
+ ```
93
+
94
+ Now `npm run build` → triggers `prebuild` (which runs `validate`) → triggers `prevalidate` (which runs the prereqs) → validate runs cleanly. Single source of truth for the prereq chain.
95
+
96
+ ---
97
+
98
+ ## When `prevalidate` is NOT needed
99
+
100
+ Profiles that don't run cite-key or XRef validation don't need `prevalidate`. Specifically:
101
+
102
+ - `tools`, `minimal`: no `<Cite>` resolution against `references.json`.
103
+ - `course-notes`: depends on whether the consumer uses `<Cite>` (some do for source-tier attribution).
104
+
105
+ For these profiles, `book-scaffold validate` operates on chapter structure + XRef IDs only; `prevalidate` would be a no-op. The v4.6.0 create-book template adds `prevalidate` ONLY for academic + research-portfolio.
106
+
107
+ ---
108
+
109
+ ## Why this matters
110
+
111
+ Recipe 19 is part of [issue #76](https://github.com/brandon-behring/book-scaffold-astro/issues/76)'s v4.6 bundle. Companion: [recipe 18 — chapter-route ownership](./18-chapter-route-ownership.md), which fixes another silent-CI surface from the same Phase 1c first-deploys.
112
+
113
+ The `prevalidate` convention also closes [issue #77](https://github.com/brandon-behring/book-scaffold-astro/issues/77) — the v4.6 validator now emits a single re-framed error pointing at this recipe when `references.json` is missing, replacing the noisy 25-symptom output.
@@ -121,6 +121,46 @@ const PRESET =
121
121
  const PROFILE = PRESET;
122
122
  const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
123
123
 
124
+ // v4.6.0 (issue #76 Layer 3b): chapter-route shadow warning. Detect a
125
+ // consumer-owned `src/pages/chapters/[...slug].astro` that shadows the
126
+ // scaffold v4.3.0+ auto-injected route. Non-blocking — emits to stderr,
127
+ // validate continues normally. Suppressed when the consumer explicitly
128
+ // disabled the scaffold's chapter route (intentional override).
129
+ //
130
+ // Edge cases per issue #76:
131
+ // file + routes.chapters undefined/true → WARN
132
+ // file + routes.chapters: false → silent (intentional override)
133
+ // no file (any routes config) → silent
134
+ //
135
+ // Heuristic for "routes.chapters: false": regex-grep astro.config.mjs for
136
+ // the literal `chapters: false`. Light-touch detection that matches the
137
+ // issue's warning-not-error intent; consumers wanting a stricter detector
138
+ // can run `astro check` separately.
139
+ {
140
+ const consumerChapterRoute = resolve(ROOT, 'src/pages/chapters/[...slug].astro');
141
+ if (existsSync(consumerChapterRoute)) {
142
+ const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
143
+ let chaptersDisabled = false;
144
+ if (existsSync(astroConfigPath)) {
145
+ const astroConfig = readFileSync(astroConfigPath, 'utf8');
146
+ // Match `chapters: false` (with optional whitespace) inside a routes
147
+ // object. Slight false-positive risk on commented-out code; acceptable
148
+ // for a non-blocking warning.
149
+ chaptersDisabled = /\bchapters\s*:\s*false\b/.test(astroConfig);
150
+ }
151
+ if (!chaptersDisabled) {
152
+ console.warn(
153
+ `\n⚠ Consumer-owned chapter route at src/pages/chapters/[...slug].astro\n` +
154
+ ` shadows the scaffold v4.3.0+ auto-injected route. Either:\n` +
155
+ ` • Delete the consumer file to defer to the scaffold (recommended), OR\n` +
156
+ ` • Set 'routes: { chapters: false }' in defineBookConfig to keep\n` +
157
+ ` your override (intentional).\n` +
158
+ ` See: package/recipes/18-chapter-route-ownership.md\n`,
159
+ );
160
+ }
161
+ }
162
+ }
163
+
124
164
  const errors = [];
125
165
  const warnings = [];
126
166
  const fail = (file, line, msg) => errors.push({ file, line, msg });
@@ -231,6 +271,44 @@ for (const rel of chapterFiles) {
231
271
  }
232
272
  }
233
273
 
274
+ // ===== v4.6.0 (issue #77): missing-prereq re-framing =====
275
+ //
276
+ // When errors are downstream symptoms of a missing artifact (references.json
277
+ // or labels.json), abort with ONE leading error pointing at the prereq
278
+ // instead of printing 25 "Unknown bibkey" / "Unknown XRef" symptoms. Single
279
+ // clean signal: fix the prereq. Per D12 of the v4.6.0 plan.
280
+ {
281
+ if (PROFILE === 'academic') {
282
+ const refsPath = join(DATA_DIR, 'references.json');
283
+ const hasBibkeyErrors = errors.some((e) => /Unknown bibkey/.test(e.msg));
284
+ if (hasBibkeyErrors && !existsSync(refsPath)) {
285
+ console.error(
286
+ `\n✗ Validate cannot run: src/data/references.json is missing.\n\n` +
287
+ `This file is generated from bibliography.bib by 'npm run build:bib'.\n` +
288
+ `Run that first, OR adopt the prevalidate npm hook convention so\n` +
289
+ `'npm run validate' regenerates it automatically:\n\n` +
290
+ ` "prevalidate": "npm run build:bib && npm run build:labels --if-present"\n` +
291
+ ` "validate": "book-scaffold validate"\n\n` +
292
+ `See package/recipes/19-prevalidate-hook.md.\n`,
293
+ );
294
+ process.exit(1);
295
+ }
296
+ }
297
+ const labelsPath = join(DATA_DIR, 'labels.json');
298
+ const hasXrefErrors = errors.some((e) => /Unknown XRef/.test(e.msg));
299
+ if (hasXrefErrors && !existsSync(labelsPath)) {
300
+ console.error(
301
+ `\n✗ Validate cannot run: src/data/labels.json is missing.\n\n` +
302
+ `This file is generated from <Theorem id="..."> and <Figure id="..."> markers\n` +
303
+ `in chapter MDX by 'npm run build:labels'. Run that first, OR adopt the\n` +
304
+ `prevalidate npm hook convention so 'npm run validate' regenerates it:\n\n` +
305
+ ` "prevalidate": "npm run build:bib && npm run build:labels --if-present"\n\n` +
306
+ `See package/recipes/19-prevalidate-hook.md.\n`,
307
+ );
308
+ process.exit(1);
309
+ }
310
+ }
311
+
234
312
  // ===== Report =====
235
313
  const format = ({ file, line, msg }) => ` ${file}:${line} ${msg}`;
236
314
  if (warnings.length > 0) {
@@ -105,6 +105,23 @@ export interface ProfileDefinition {
105
105
  * fallbackChaptersRenderer (field-presence dispatch) at route render time.
106
106
  */
107
107
  chaptersRenderer?: ChaptersRenderer;
108
+ /**
109
+ * v4.6.0 (issue #76 Secondary): per-profile default sitemap filter.
110
+ * Predicate run against every page URL by `@astrojs/sitemap` — return
111
+ * true to include in sitemap, false to exclude.
112
+ *
113
+ * Defaults:
114
+ * academic + course-notes → excludes `/print/` (print-friendly view,
115
+ * crawl-redundant)
116
+ * tools + minimal + research-portfolio → omitted (include everything)
117
+ *
118
+ * Per D7 of the v4.6.0 plan: when a consumer sets
119
+ * `defineBookConfig({ seo: { sitemap: { filter } } })`, the consumer's
120
+ * filter REPLACES this profile default (not composed). Consumers wanting
121
+ * to also exclude /print/ on top of additional exclusions copy the
122
+ * profile-default predicate's behavior into their own filter.
123
+ */
124
+ sitemapFilter?: (page: string) => boolean;
108
125
  }
109
126
 
110
127
  /**
@@ -30,4 +30,7 @@ export const academicProfile = defineProfile({
30
30
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
31
31
  katex: true,
32
32
  chaptersRenderer: academicChaptersRenderer, // v3.7.0 (#35) — owns /chapters semantics if consumer opts in via routes.chapters
33
+ // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
34
+ // view, crawl-redundant. Academic-profile default.
35
+ sitemapFilter: (page: string) => !page.includes('/print/'),
33
36
  });
@@ -32,4 +32,7 @@ export const courseNotesProfile = defineProfile({
32
32
  styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
33
33
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
34
34
  chaptersRenderer: fallbackChaptersRenderer,
35
+ // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
36
+ // view, crawl-redundant. Course-notes-profile default.
37
+ sitemapFilter: (page: string) => !page.includes('/print/'),
35
38
  });
package/src/schemas.ts CHANGED
@@ -83,6 +83,13 @@ export const academicChapterSchema = z.object({
83
83
  notebook_path: z.string().optional(),
84
84
  description: z.string().optional(),
85
85
  draft: z.boolean().default(false),
86
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
87
+ // All optional; existing chapters without these continue to work.
88
+ author: z.string().optional(),
89
+ published: z.date().optional(),
90
+ updated: z.date().optional(),
91
+ tags: z.array(z.string()).default([]),
92
+ image: z.string().optional(),
86
93
  });
87
94
 
88
95
  export const toolsChapterSchema = z.object({
@@ -96,6 +103,12 @@ export const toolsChapterSchema = z.object({
96
103
  description: z.string().optional(),
97
104
  draft: z.boolean().default(false),
98
105
  updated: z.date().optional(),
106
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
107
+ // `updated` already existed; the rest are new.
108
+ author: z.string().optional(),
109
+ published: z.date().optional(),
110
+ tags: z.array(z.string()).default([]),
111
+ image: z.string().optional(),
99
112
  });
100
113
 
101
114
  /** Minimal profile currently aliases the tools schema. */
@@ -147,6 +160,14 @@ export const courseNotesChapterSchema = z.object({
147
160
  volatility: z.enum(volatilityLevels).default('architectural-pattern'),
148
161
  sources: z.array(z.string()).default([]),
149
162
  draft: z.boolean().default(false),
163
+
164
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
165
+ // `tags` already existed; the rest are new. `instructor` (line 130) is
166
+ // attribution metadata — distinct from `author` (the note-writer/curator).
167
+ author: z.string().optional(),
168
+ published: z.date().optional(),
169
+ updated: z.date().optional(),
170
+ image: z.string().optional(),
150
171
  });
151
172
 
152
173
  /**
@@ -228,6 +249,12 @@ export const researchPortfolioChapterSchema = z.object({
228
249
  last_verified: z.date(),
229
250
  updated: z.date().optional(),
230
251
  draft: z.boolean().default(false),
252
+
253
+ // v4.6.0: optional SEO / article:* fields consumed by Chapter.astro.
254
+ // `tags` + `updated` already existed; `author` + `published` + `image` are new.
255
+ author: z.string().optional(),
256
+ published: z.date().optional(),
257
+ image: z.string().optional(),
231
258
  });
232
259
 
233
260
  // ===== Inferred chapter types — one per schema =====