@brandon_m_behring/book-scaffold-astro 4.5.1 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -58,6 +58,35 @@ sources: [] # array of source-manifest keys
58
58
  ---
59
59
  ```
60
60
 
61
+ ### Research-portfolio profile (`src/schemas.ts:researchPortfolioChapterSchema`)
62
+
63
+ Hybrid of academic + tools provenance with research-paper-style inline sources. Only `title` + `last_verified` are required; all hierarchy and classification fields are optional.
64
+
65
+ ```yaml
66
+ ---
67
+ title: "..." # required
68
+ last_verified: 2026-05-19 # date, required
69
+ # optional — hierarchy (use whichever fits; all may be omitted)
70
+ slug: ch01-introduction # defaults to filename
71
+ chapter: 1
72
+ part: 1 # number OR academic-style string enum
73
+ week: 1
74
+ # optional — status (AUTHORING state) vs freshness (EPISTEMIC type) are ORTHOGONAL
75
+ status: prose_only # 'scaffolded'|'prose_only'|'code_only'|'chapter_only'|'reading_only'|'implemented'|'planned'
76
+ freshness: experimental-result # 'experimental-result'|'literature-survey'|'theoretical'|'reference'
77
+ # optional — provenance + inline sources (T1-T4 tiers)
78
+ volatility: feature-surface # 'stable-principle'|'architectural-pattern'|'feature-surface'
79
+ tags: [prompt-injection, ...] # freeform string array
80
+ sources:
81
+ - tier: T1
82
+ url: https://...
83
+ label: Primary source
84
+ # optional: description, draft, updated, author, published, image (SEO/og:*)
85
+ ---
86
+ ```
87
+
88
+ **`status` vs `freshness` is the #1 author gotcha.** `status` = authoring state (have I written it?). `freshness` = epistemic type (what kind of evidence?). A chapter can be `status: scaffolded` (not written yet) AND `freshness: theoretical` (will be a math argument). See Recipe 13 for the full table.
89
+
61
90
  ## Component reference
62
91
 
63
92
  Two callout families coexist. Authors import what they need.
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(),
@@ -372,8 +398,8 @@ var academicProfile = defineProfile({
372
398
  references: true,
373
399
  search: true,
374
400
  print: true,
375
- chapters: false,
376
- // academic consumers ship their own week-based /chapters listing
401
+ chapters: true,
402
+ // v4.6.1 (#75 follow-up): auto-injected /chapters/[...slug]/ + /chapters/ index. Pre-v4.3.0 academic books shipped their own listing; v4.6.0 (#76 Layer 3c) removed the consumer template assuming auto-injection. Default flipped here to close the gap. Consumers wanting their own listing override via `routes: { chapters: false }` + their own src/pages/chapters/* — see recipe 18.
377
403
  convergence: false,
378
404
  // tools-profile-specific
379
405
  frontmatter: false,
@@ -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
@@ -578,7 +607,8 @@ var minimalProfile = defineProfile({
578
607
  references: true,
579
608
  search: true,
580
609
  print: true,
581
- chapters: false,
610
+ chapters: true,
611
+ // v4.6.1 (#75 follow-up): default-on across all profiles. Consumer override via routes: { chapters: false }.
582
612
  convergence: false,
583
613
  frontmatter: false,
584
614
  // opt-in per book; see #7
@@ -602,8 +632,8 @@ var courseNotesProfile = defineProfile({
602
632
  references: true,
603
633
  search: true,
604
634
  print: true,
605
- chapters: false,
606
- // multi-book consumers route via [book]/[slug] themselves
635
+ chapters: true,
636
+ // v4.6.1 (#75 follow-up): default-on. Multi-book consumers (DLAI-style) override via routes: { chapters: false } + own [book]/[slug] routes — see #15 deferred.
607
637
  convergence: false,
608
638
  frontmatter: false,
609
639
  // opt-in per book; see #7
@@ -616,7 +646,10 @@ var courseNotesProfile = defineProfile({
616
646
  },
617
647
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
618
648
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
619
- chaptersRenderer: fallbackChaptersRenderer
649
+ chaptersRenderer: fallbackChaptersRenderer,
650
+ // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
651
+ // view, crawl-redundant. Course-notes-profile default.
652
+ sitemapFilter: (page) => !page.includes("/print/")
620
653
  });
621
654
 
622
655
  // src/profiles/research-portfolio.ts
@@ -627,8 +660,8 @@ var researchPortfolioProfile = defineProfile({
627
660
  references: true,
628
661
  search: true,
629
662
  print: true,
630
- chapters: false,
631
- // portfolio books ship their own landing/index
663
+ chapters: true,
664
+ // v4.6.1 (#75 follow-up): default-on. Portfolios still ship their own /frontmatter/* + landing; /chapters/* renders the underlying chapter list.
632
665
  convergence: false,
633
666
  // tools-profile-specific
634
667
  frontmatter: true,
@@ -824,19 +857,19 @@ function defineMdxComponents(components) {
824
857
  }
825
858
 
826
859
  // 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) {
860
+ var BOOK_CONFIG_VIRTUAL_ID = "virtual:book-scaffold/book-config";
861
+ var BOOK_CONFIG_RESOLVED_ID = "\0" + BOOK_CONFIG_VIRTUAL_ID;
862
+ function makeBookConfigVitePlugin(config) {
830
863
  const serialized = `export default ${JSON.stringify(config)};`;
831
864
  return {
832
- name: "book-scaffold:landing-config",
865
+ name: "book-scaffold:book-config",
833
866
  enforce: "pre",
834
867
  resolveId(id) {
835
- if (id === LANDING_VIRTUAL_ID) return LANDING_RESOLVED_ID;
868
+ if (id === BOOK_CONFIG_VIRTUAL_ID) return BOOK_CONFIG_RESOLVED_ID;
836
869
  return null;
837
870
  },
838
871
  load(id) {
839
- if (id !== LANDING_RESOLVED_ID) return null;
872
+ if (id !== BOOK_CONFIG_RESOLVED_ID) return null;
840
873
  return serialized;
841
874
  }
842
875
  };
@@ -887,10 +920,14 @@ function bookScaffoldIntegration(opts) {
887
920
  routes: userOverrides = {},
888
921
  extraStyles = [],
889
922
  mdxComponentsModule,
890
- // v4.5.0: landing-page data, propagated via vite.define to /index.astro.
923
+ // v4.5.0: landing-page data, propagated via virtual module to /index.astro.
891
924
  title,
892
925
  description,
893
- portfolio
926
+ portfolio,
927
+ // v4.6.0: book-level author + SEO config, propagated through the
928
+ // (renamed) book-config virtual module to Base.astro + Chapter.astro.
929
+ author,
930
+ seo
894
931
  } = opts;
895
932
  const def = PROFILES[profile];
896
933
  const fmNormalized = normalizeFrontmatterConfig(userOverrides.frontmatter);
@@ -937,11 +974,16 @@ function bookScaffoldIntegration(opts) {
937
974
  vite: {
938
975
  plugins: [
939
976
  makeMdxComponentsVitePlugin(resolvedMdxPath),
940
- makeLandingConfigVitePlugin({
977
+ makeBookConfigVitePlugin({
941
978
  title: title ?? null,
942
979
  description: description ?? null,
943
980
  portfolio: portfolio ?? false,
944
- enabledRoutes: enabledRouteNames
981
+ enabledRoutes: enabledRouteNames,
982
+ author: author ?? null,
983
+ seo: {
984
+ ogImage: seo?.ogImage ?? null,
985
+ twitterHandle: seo?.twitterHandle ?? null
986
+ }
945
987
  })
946
988
  ],
947
989
  define: {
@@ -1045,19 +1087,36 @@ async function defineBookConfig(opts) {
1045
1087
  ]);
1046
1088
  }
1047
1089
  const resolvedPortfolio = opts.portfolio === false ? false : opts.portfolio ?? BRANDON_PORTFOLIO_DEFAULT;
1090
+ const profileSitemapFilter = PROFILES[profile]?.sitemapFilter;
1091
+ const sitemapFilter = opts.seo?.sitemap?.filter ?? profileSitemapFilter;
1092
+ const sitemapCustomPages = opts.seo?.sitemap?.customPages;
1093
+ const sitemapOptions = {};
1094
+ if (sitemapFilter) sitemapOptions.filter = sitemapFilter;
1095
+ if (sitemapCustomPages) sitemapOptions.customPages = sitemapCustomPages;
1048
1096
  const integrations = [
1049
1097
  mdx(),
1050
1098
  preact(),
1099
+ // v4.6.0: @astrojs/sitemap default integration. Emits
1100
+ // /sitemap-index.xml + per-route sitemaps from the resolved `site:`
1101
+ // (defineBookConfig throws above if site is missing, so the URL is
1102
+ // always available to the sitemap integration here).
1103
+ sitemap(sitemapOptions),
1051
1104
  bookScaffoldIntegration({
1052
1105
  profile,
1053
1106
  routes: mergedRoutes,
1054
1107
  mdxComponentsModule,
1055
1108
  extraStyles: mergedExtraStyles,
1056
1109
  // 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.
1110
+ // be exposed to the auto-injected /index.astro via the virtual module.
1058
1111
  title: opts.title,
1059
1112
  description: opts.description,
1060
- portfolio: resolvedPortfolio
1113
+ portfolio: resolvedPortfolio,
1114
+ // v4.6.0: book-level author + SEO config (ogImage, twitterHandle),
1115
+ // propagated through the renamed `book-config` virtual module to
1116
+ // Base.astro + Chapter.astro. `seo.sitemap` is NOT passed through —
1117
+ // it's consumed below at config-time by the @astrojs/sitemap call.
1118
+ author: opts.author,
1119
+ seo: opts.seo ? { ogImage: opts.seo.ogImage, twitterHandle: opts.seo.twitterHandle } : void 0
1061
1120
  }),
1062
1121
  ...mergedExtraIntegrations
1063
1122
  ];
@@ -1096,6 +1155,9 @@ async function defineBookConfig(opts) {
1096
1155
  title: _title,
1097
1156
  description: _description,
1098
1157
  portfolio: _portfolio,
1158
+ // v4.6.0: strip new book-level SEO opts (author + seo block).
1159
+ author: _author,
1160
+ seo: _seo,
1099
1161
  ...rest
1100
1162
  } = opts;
1101
1163
  void _styles;
@@ -1110,6 +1172,8 @@ async function defineBookConfig(opts) {
1110
1172
  void _title;
1111
1173
  void _description;
1112
1174
  void _portfolio;
1175
+ void _author;
1176
+ void _seo;
1113
1177
  const katexExternals = wantsKatex ? [] : ["remark-math", "rehype-katex", "katex"];
1114
1178
  const config = {
1115
1179
  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(),
@@ -257,8 +282,8 @@ var academicProfile = defineProfile({
257
282
  references: true,
258
283
  search: true,
259
284
  print: true,
260
- chapters: false,
261
- // academic consumers ship their own week-based /chapters listing
285
+ chapters: true,
286
+ // v4.6.1 (#75 follow-up): auto-injected /chapters/[...slug]/ + /chapters/ index. Pre-v4.3.0 academic books shipped their own listing; v4.6.0 (#76 Layer 3c) removed the consumer template assuming auto-injection. Default flipped here to close the gap. Consumers wanting their own listing override via `routes: { chapters: false }` + their own src/pages/chapters/* — see recipe 18.
262
287
  convergence: false,
263
288
  // tools-profile-specific
264
289
  frontmatter: false,
@@ -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
@@ -463,7 +491,8 @@ var minimalProfile = defineProfile({
463
491
  references: true,
464
492
  search: true,
465
493
  print: true,
466
- chapters: false,
494
+ chapters: true,
495
+ // v4.6.1 (#75 follow-up): default-on across all profiles. Consumer override via routes: { chapters: false }.
467
496
  convergence: false,
468
497
  frontmatter: false,
469
498
  // opt-in per book; see #7
@@ -487,8 +516,8 @@ var courseNotesProfile = defineProfile({
487
516
  references: true,
488
517
  search: true,
489
518
  print: true,
490
- chapters: false,
491
- // multi-book consumers route via [book]/[slug] themselves
519
+ chapters: true,
520
+ // v4.6.1 (#75 follow-up): default-on. Multi-book consumers (DLAI-style) override via routes: { chapters: false } + own [book]/[slug] routes — see #15 deferred.
492
521
  convergence: false,
493
522
  frontmatter: false,
494
523
  // opt-in per book; see #7
@@ -501,7 +530,10 @@ var courseNotesProfile = defineProfile({
501
530
  },
502
531
  styles: ["tokens.css", "layout.css", "callouts.css", "chapter.css", "typography.css", "print.css"],
503
532
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
504
- chaptersRenderer: fallbackChaptersRenderer
533
+ chaptersRenderer: fallbackChaptersRenderer,
534
+ // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
535
+ // view, crawl-redundant. Course-notes-profile default.
536
+ sitemapFilter: (page) => !page.includes("/print/")
505
537
  });
506
538
 
507
539
  // src/profiles/research-portfolio.ts
@@ -512,8 +544,8 @@ var researchPortfolioProfile = defineProfile({
512
544
  references: true,
513
545
  search: true,
514
546
  print: true,
515
- chapters: false,
516
- // portfolio books ship their own landing/index
547
+ chapters: true,
548
+ // v4.6.1 (#75 follow-up): default-on. Portfolios still ship their own /frontmatter/* + landing; /chapters/* renders the underlying chapter list.
517
549
  convergence: false,
518
550
  // tools-profile-specific
519
551
  frontmatter: true,
@@ -1,25 +1,33 @@
1
1
  ---
2
+ # required fields — schema will reject the chapter if missing
2
3
  title: "Chapter N — Title goes here"
3
- slug: chN-short-slug
4
- chapter: 1
5
- part: 1
4
+ last_verified: 2026-05-19 # required; YAML date (no quotes)
5
+
6
+ # optional — hierarchy (use whichever fits; all may be omitted)
7
+ slug: chN-short-slug # optional; defaults to filename
8
+ chapter: 1 # optional; tools-style numeric
9
+ part: 1 # optional; number OR academic-style string enum
6
10
  week: 1 # optional; omit if not on a weekly cadence
7
- status: prose_only # 'prose_only' | 'code_only' | 'implemented' | ...
8
- freshness: experimental-result # 'experimental-result' | 'literature-survey' | 'theoretical' | 'reference'
9
- volatility: feature-surface # 'stable-principle' | 'architectural-pattern' | 'feature-surface'
10
- tags:
11
+
12
+ # optional status (AUTHORING state) vs freshness (EPISTEMIC type); see Recipe 13
13
+ status: prose_only # 'scaffolded'|'prose_only'|'code_only'|'chapter_only'|'reading_only'|'implemented'|'planned'
14
+ freshness: experimental-result # 'experimental-result'|'literature-survey'|'theoretical'|'reference'
15
+
16
+ # optional — provenance
17
+ volatility: feature-surface # 'stable-principle'|'architectural-pattern'|'feature-surface'
18
+ tags: # optional; freeform string array
11
19
  - replace-me
12
20
  - with
13
21
  - real-tags
14
- sources:
22
+ sources: # optional; structured inline T1-T4
15
23
  - tier: T1
16
24
  url: https://example.invalid/primary-source
17
25
  label: Primary source (e.g., NVD CVE / arXiv paper / official spec)
18
26
  - tier: T2
19
27
  url: https://example.invalid/secondary
20
28
  label: Secondary corroboration
21
- last_verified: 2026-05-19
22
- draft: true
29
+
30
+ draft: true # optional; defaults to false
23
31
  ---
24
32
 
25
33
  import PreReleaseBanner from '@brandon_m_behring/book-scaffold-astro/components/PreReleaseBanner.astro';
@@ -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.1",
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;