@brandon_m_behring/book-scaffold-astro 4.5.0 → 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,6 +856,23 @@ function defineMdxComponents(components) {
824
856
  }
825
857
 
826
858
  // src/integration.ts
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) {
862
+ const serialized = `export default ${JSON.stringify(config)};`;
863
+ return {
864
+ name: "book-scaffold:book-config",
865
+ enforce: "pre",
866
+ resolveId(id) {
867
+ if (id === BOOK_CONFIG_VIRTUAL_ID) return BOOK_CONFIG_RESOLVED_ID;
868
+ return null;
869
+ },
870
+ load(id) {
871
+ if (id !== BOOK_CONFIG_RESOLVED_ID) return null;
872
+ return serialized;
873
+ }
874
+ };
875
+ }
827
876
  var PACKAGE_NAME = "@brandon_m_behring/book-scaffold-astro";
828
877
  var ROUTE_REGISTRY = {
829
878
  references: { pattern: "/references", file: "references.astro" },
@@ -870,10 +919,14 @@ function bookScaffoldIntegration(opts) {
870
919
  routes: userOverrides = {},
871
920
  extraStyles = [],
872
921
  mdxComponentsModule,
873
- // 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.
874
923
  title,
875
924
  description,
876
- 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
877
930
  } = opts;
878
931
  const def = PROFILES[profile];
879
932
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -918,17 +971,27 @@ function bookScaffoldIntegration(opts) {
918
971
  const enabledRouteNames = Object.entries(enabledRoutes).filter(([, on]) => on).map(([name]) => name);
919
972
  updateConfig({
920
973
  vite: {
921
- plugins: [makeMdxComponentsVitePlugin(resolvedMdxPath)],
974
+ plugins: [
975
+ makeMdxComponentsVitePlugin(resolvedMdxPath),
976
+ makeBookConfigVitePlugin({
977
+ title: title ?? null,
978
+ description: description ?? null,
979
+ portfolio: portfolio ?? false,
980
+ enabledRoutes: enabledRouteNames,
981
+ author: author ?? null,
982
+ seo: {
983
+ ogImage: seo?.ogImage ?? null,
984
+ twitterHandle: seo?.twitterHandle ?? null
985
+ }
986
+ })
987
+ ],
922
988
  define: {
989
+ // Preset/profile stay as env vars — preference-flag pattern where
990
+ // env-based override IS the convention (resolvePreset reads from
991
+ // process.env / .env explicitly). Config values (title, etc.) now
992
+ // route through the virtual module above to avoid that override.
923
993
  "import.meta.env.BOOK_PRESET": presetLiteral,
924
- "import.meta.env.BOOK_PROFILE": presetLiteral,
925
- // v4.5.0: landing-page data. JSON.stringify on undefined → 'undefined'
926
- // (which evaluates to JavaScript undefined at use site); on object →
927
- // the JSON literal; on false → 'false'.
928
- "import.meta.env.BOOK_TITLE": JSON.stringify(title ?? null),
929
- "import.meta.env.BOOK_DESCRIPTION": JSON.stringify(description ?? null),
930
- "import.meta.env.BOOK_PORTFOLIO": JSON.stringify(portfolio ?? null),
931
- "import.meta.env.BOOK_ROUTES_ENABLED": JSON.stringify(enabledRouteNames)
994
+ "import.meta.env.BOOK_PROFILE": presetLiteral
932
995
  }
933
996
  }
934
997
  });
@@ -1023,19 +1086,36 @@ async function defineBookConfig(opts) {
1023
1086
  ]);
1024
1087
  }
1025
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;
1026
1095
  const integrations = [
1027
1096
  mdx(),
1028
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),
1029
1103
  bookScaffoldIntegration({
1030
1104
  profile,
1031
1105
  routes: mergedRoutes,
1032
1106
  mdxComponentsModule,
1033
1107
  extraStyles: mergedExtraStyles,
1034
1108
  // v4.5.0: pass landing-page data through to the integration so it can
1035
- // be exposed to the auto-injected /index.astro via vite.define.
1109
+ // be exposed to the auto-injected /index.astro via the virtual module.
1036
1110
  title: opts.title,
1037
1111
  description: opts.description,
1038
- 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
1039
1119
  }),
1040
1120
  ...mergedExtraIntegrations
1041
1121
  ];
@@ -1074,6 +1154,9 @@ async function defineBookConfig(opts) {
1074
1154
  title: _title,
1075
1155
  description: _description,
1076
1156
  portfolio: _portfolio,
1157
+ // v4.6.0: strip new book-level SEO opts (author + seo block).
1158
+ author: _author,
1159
+ seo: _seo,
1077
1160
  ...rest
1078
1161
  } = opts;
1079
1162
  void _styles;
@@ -1088,6 +1171,8 @@ async function defineBookConfig(opts) {
1088
1171
  void _title;
1089
1172
  void _description;
1090
1173
  void _portfolio;
1174
+ void _author;
1175
+ void _seo;
1091
1176
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1092
1177
  const config = {
1093
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.0",
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
@@ -1,14 +1,24 @@
1
1
  ---
2
2
  /**
3
- * /index — minimal default landing page (v4.5.0).
3
+ * /index — minimal default landing page (v4.5.0; landing-config source
4
+ * refactored in v4.5.1 from env vars to virtual module).
4
5
  *
5
6
  * Auto-injected by bookScaffoldIntegration when routes.landing === true
6
7
  * (default for every profile). Consumers with their own src/pages/index.astro
7
8
  * override automatically — file-system routes win over injectRoute, no extra
8
9
  * config needed.
9
10
  *
10
- * Reads book identity + portfolio + enabled-routes from vite.define-injected
11
- * env vars (see integration.ts §4.5.0). Renders:
11
+ * Reads book identity + portfolio + enabled-routes from the
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
16
+ * import.meta.env.BOOK_* env vars; that pattern was vulnerable to silent
17
+ * override by consumer .env files (caught during DML deploy when a stale
18
+ * `BOOK_TITLE=web` in web/.env overrode defineBookConfig({title})). The
19
+ * virtual module isolates landing config from any env-based override.
20
+ *
21
+ * Renders:
12
22
  * - h1 with book title (fallback: 'book-scaffold-astro')
13
23
  * - lead paragraph with description (omitted if not set)
14
24
  * - "Read" list of links to enabled scaffold routes (filtered to only
@@ -23,13 +33,12 @@
23
33
  * or defineBookConfig({ portfolio: false })
24
34
  */
25
35
  import Base from '../layouts/Base.astro';
36
+ import bookConfig from 'virtual:book-scaffold/book-config';
26
37
 
27
- // Vite-injected at build time. JSON.stringify(value ?? null) is the
28
- // integration's convention; null means "not set", an object/string means real value.
29
- const title = (import.meta.env.BOOK_TITLE as string | null) ?? 'book-scaffold-astro';
30
- const description = import.meta.env.BOOK_DESCRIPTION as string | null;
31
- const portfolio = import.meta.env.BOOK_PORTFOLIO as { url: string; label: string } | false | null;
32
- const enabledRoutes = (import.meta.env.BOOK_ROUTES_ENABLED as string[] | undefined) ?? [];
38
+ const title = bookConfig.title ?? 'book-scaffold-astro';
39
+ const description = bookConfig.description;
40
+ const portfolio = bookConfig.portfolio;
41
+ const enabledRoutes = bookConfig.enabledRoutes;
33
42
 
34
43
  // Map from internal route name → display label + URL. Only routes that
35
44
  // produce a single landing-list entry are listed here (frontmatter is a
@@ -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 =====