@brandon_m_behring/book-scaffold-astro 5.0.0 → 5.2.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/schemas.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineCollection } from 'astro:content';
2
- import { i as BookSchemasOptions } from './types-D1QZgKMO.js';
2
+ import { i as BookSchemasOptions } from './types-DjqMnwHw.js';
3
3
  import 'astro';
4
4
  import './schemas-CKipJ5Ie.js';
5
5
  import 'astro/zod';
@@ -545,6 +545,17 @@ type SiblingBooks = Record<string, SiblingBookEntry>;
545
545
  interface SecurityHeadersConfig {
546
546
  contentSecurityPolicy?: string;
547
547
  }
548
+ /**
549
+ * v5.2.0 (#157): build-time Open Graph card generation.
550
+ *
551
+ * Omit `enabled` (or set it to `true`) to enable generation when the object
552
+ * form is present. `exclude` contains base-relative route patterns validated
553
+ * by the OG-card integration during config evaluation.
554
+ */
555
+ interface OgCardsConfig {
556
+ enabled?: boolean;
557
+ exclude?: readonly string[];
558
+ }
548
559
  /**
549
560
  * v4.26.2 (#149): book-level release state rendered by
550
561
  * `<PreReleaseBanner>` across every page.
@@ -746,9 +757,10 @@ interface BookConfigOptions {
746
757
  * `ogImage` — default Open Graph image URL (relative to the site root, or
747
758
  * absolute). When omitted, no `<meta property="og:image">` is emitted by
748
759
  * default; per-page `Astro.props.ogImage` can still set one. Consumers
749
- * opt-in to OG cards by adding e.g. `/og-default.png` to their `public/`
750
- * AND setting `seo: { ogImage: '/og-default.png' }`. Avoids broken-link
751
- * meta tags on consumers who haven't authored an OG image yet.
760
+ * opt in to a static default by adding e.g. `/og-default.png` to `public/`
761
+ * and setting `seo: { ogImage: '/og-default.png' }`. A static default
762
+ * suppresses generation; use `ogCards` below for generated cards. Omitting
763
+ * both avoids broken-link meta tags when no image has been authored.
752
764
  *
753
765
  * `twitterHandle` — adds `<meta name="twitter:site" content="@handle">`
754
766
  * when set. Omitted by default.
@@ -765,6 +777,12 @@ interface BookConfigOptions {
765
777
  seo?: {
766
778
  ogImage?: string;
767
779
  twitterHandle?: string;
780
+ /**
781
+ * v5.2.0 (#157): opt into deterministic, build-time social cards. `true`
782
+ * uses the defaults; the object form can disable generation explicitly or
783
+ * add base-relative route exclusions.
784
+ */
785
+ ogCards?: boolean | OgCardsConfig;
768
786
  sitemap?: {
769
787
  filter?: (page: string) => boolean;
770
788
  customPages?: string[];
@@ -963,4 +981,4 @@ declare function resolvePreset(explicitPreset?: BookPreset, explicitProfile?: Bo
963
981
  */
964
982
  declare function resolveProfile(explicit?: BookProfile): BookProfile;
965
983
 
966
- export { defineProfile as A, BOOK_PRESETS as B, CORPUS_APPARATUS_ROUTES as C, defineStyle as D, normalizeFrontmatterConfig as E, type FreshnessAffordance as F, resolvePreset as G, resolveProfile as H, NUMBER_STYLES as N, type PartKey as P, type ReleaseStatusConfig as R, type SecurityHeadersConfig as S, type VolatilityBadge as V, BOOK_PROFILES as a, BookConfigError as b, type BookConfigOptions as c, type BookCorpus as d, type BookCorpusInput as e, type BookPreset as f, type BookProfile as g, type BookScaffoldIntegrationOptions as h, type BookSchemasOptions as i, type ChapterFor as j, type ChaptersRenderer as k, type CorpusApparatusRoute as l, type CorpusBook as m, type CorpusBookInput as n, type FrontmatterRouteConfig as o, type NumberStyle as p, type PartialRouteToggles as q, type ProfileDefinition as r, type RouteToggles as s, type SiblingBookDescriptor as t, type SiblingBookEntry as u, type SiblingBooks as v, type StatusBadge as w, type Style as x, type StyleInput as y, composeStyles as z };
984
+ export { defineProfile as A, BOOK_PRESETS as B, CORPUS_APPARATUS_ROUTES as C, defineStyle as D, normalizeFrontmatterConfig as E, type FreshnessAffordance as F, resolvePreset as G, resolveProfile as H, NUMBER_STYLES as N, type OgCardsConfig as O, type PartKey as P, type ReleaseStatusConfig as R, type SecurityHeadersConfig as S, type VolatilityBadge as V, BOOK_PROFILES as a, BookConfigError as b, type BookConfigOptions as c, type BookCorpus as d, type BookCorpusInput as e, type BookPreset as f, type BookProfile as g, type BookScaffoldIntegrationOptions as h, type BookSchemasOptions as i, type ChapterFor as j, type ChaptersRenderer as k, type CorpusApparatusRoute as l, type CorpusBook as m, type CorpusBookInput as n, type FrontmatterRouteConfig as o, type NumberStyle as p, type PartialRouteToggles as q, type ProfileDefinition as r, type RouteToggles as s, type SiblingBookDescriptor as t, type SiblingBookEntry as u, type SiblingBooks as v, type StatusBadge as w, type Style as x, type StyleInput as y, composeStyles as z };
@@ -124,18 +124,112 @@ const showToolsChrome = (profile !== 'academic') && showChrome;
124
124
  // here. `new URL` resolves the relative pathname against it.
125
125
  const canonicalURL = new URL(Astro.url.pathname, Astro.site).toString();
126
126
 
127
+ const baseUrl = normalizeBase(import.meta.env.BASE_URL);
128
+
129
+ /**
130
+ * Public assets are authored relative to the deployment root, while `site`
131
+ * may already carry that same Astro base. Resolve against the origin and add
132
+ * the normalized base only when it is not already present so every local
133
+ * image receives the deployment prefix exactly once.
134
+ */
135
+ function resolvePublicAssetUrl(value: string): string {
136
+ const trimmed = value.trim();
137
+ if (!trimmed) {
138
+ throw new Error('Open Graph image must be a non-empty URL or public asset path.');
139
+ }
140
+ const site = new URL(Astro.site);
141
+
142
+ if (/^https?:\/\//i.test(trimmed)) {
143
+ try {
144
+ const absolute = new URL(trimmed);
145
+ if (absolute.protocol !== 'http:' && absolute.protocol !== 'https:') throw new Error();
146
+ } catch {
147
+ throw new Error(`Open Graph image is not a valid http(s) URL: ${JSON.stringify(value)}`);
148
+ }
149
+ return trimmed;
150
+ }
151
+ if (trimmed.startsWith('//')) {
152
+ if (trimmed.startsWith('///')) {
153
+ throw new Error(
154
+ `Open Graph image has an invalid protocol-relative URL: ${JSON.stringify(value)}`,
155
+ );
156
+ }
157
+ try {
158
+ const absolute = new URL(`${site.protocol}${trimmed}`);
159
+ if (absolute.protocol !== 'http:' && absolute.protocol !== 'https:') throw new Error();
160
+ return absolute.toString();
161
+ } catch {
162
+ throw new Error(
163
+ `Open Graph image has an invalid protocol-relative URL: ${JSON.stringify(value)}`,
164
+ );
165
+ }
166
+ }
167
+ if (/^[a-z][a-z\d+.-]*:/i.test(trimmed)) {
168
+ throw new Error(
169
+ `Open Graph image must use http(s) or a public asset path: ${JSON.stringify(value)}`,
170
+ );
171
+ }
172
+ if (trimmed.includes('\\') || /[\u0000-\u001F\u007F]/u.test(trimmed)) {
173
+ throw new Error(
174
+ `Open Graph image contains an invalid path character: ${JSON.stringify(value)}`,
175
+ );
176
+ }
177
+
178
+ const rawPath = trimmed.split(/[?#]/u, 1)[0] ?? '';
179
+ if (!rawPath) {
180
+ throw new Error(`Open Graph image must contain a public asset path: ${JSON.stringify(value)}`);
181
+ }
182
+ for (const rawSegment of rawPath.replace(/^\/+/, '').split('/')) {
183
+ let segment = rawSegment;
184
+ try {
185
+ for (let pass = 0; pass < 4; pass += 1) {
186
+ const decoded = decodeURIComponent(segment);
187
+ if (decoded === segment) break;
188
+ segment = decoded;
189
+ }
190
+ } catch {
191
+ throw new Error(
192
+ `Open Graph image contains invalid percent encoding: ${JSON.stringify(value)}`,
193
+ );
194
+ }
195
+ if (
196
+ segment === '.'
197
+ || segment === '..'
198
+ || segment.includes('/')
199
+ || segment.includes('\\')
200
+ || /[\u0000-\u001F\u007F]/u.test(segment)
201
+ ) {
202
+ throw new Error(
203
+ `Open Graph image contains an invalid decoded path segment: ${JSON.stringify(value)}`,
204
+ );
205
+ }
206
+ }
207
+
208
+ const localPath = `/${rawPath.replace(/^\/+/, '')}`;
209
+ const suffix = trimmed.slice(rawPath.length);
210
+ const basePath = baseUrl.startsWith('/') ? baseUrl : `/${baseUrl}`;
211
+ const baseWithoutTrailingSlash = basePath.replace(/\/+$/, '');
212
+ const alreadyPrefixed = basePath === '/'
213
+ || localPath === baseWithoutTrailingSlash
214
+ || localPath.startsWith(basePath);
215
+ const resolvedPath = alreadyPrefixed
216
+ ? localPath
217
+ : `${basePath}${localPath.slice(1)}`;
218
+
219
+ return new URL(`${resolvedPath}${suffix}`, site.origin).toString();
220
+ }
221
+
127
222
  // Per D3: og:image emits only when an image is explicitly set — either via
128
223
  // per-page Astro.props.ogImage or book-level bookConfig.seo.ogImage. No
129
224
  // '/og-default.png' fallback to avoid broken-link meta tags on consumers
130
225
  // without an OG image authored.
131
226
  const resolvedOgImage = ogImage ?? corpusBook?.image ?? bookConfig.seo?.ogImage ?? null;
132
227
  const absoluteOgImage = resolvedOgImage
133
- ? new URL(resolvedOgImage, Astro.site).toString()
228
+ ? resolvePublicAssetUrl(resolvedOgImage)
134
229
  : null;
135
230
  const twitterHandle = bookConfig.seo?.twitterHandle ?? null;
136
231
  const ogSiteName = corpusBook?.title ?? bookConfig.title ?? title;
137
232
  const ogDescription = description ?? corpusBook?.description ?? bookConfig.description ?? '';
138
- const baseUrl = normalizeBase(import.meta.env.BASE_URL);
139
233
  const searchHref = bookId
140
234
  ? `${baseUrl}search/?book=${encodeURIComponent(bookId)}`
141
235
  : `${baseUrl}search/`;
@@ -147,7 +241,11 @@ const pagefindFilter = bookId
147
241
  ---
148
242
 
149
243
  <!doctype html>
150
- <html lang={lang}>
244
+ <html
245
+ lang={lang}
246
+ data-book-scaffold-book={corpusBook?.id}
247
+ data-book-scaffold-surface={pagefindSurface === 'corpus' ? 'corpus' : undefined}
248
+ >
151
249
  <head>
152
250
  <meta charset="utf-8" />
153
251
  <link rel="icon" type="image/svg+xml" href={`${baseUrl}favicon.svg`} />
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 with five typed presets, Tufte typography, citations, search, PDF, and Cloudflare deployment.",
4
- "version": "5.0.0",
4
+ "version": "5.2.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -158,6 +158,7 @@
158
158
  "src/schemas.ts",
159
159
  "src/types.ts",
160
160
  "components",
161
+ "assets",
161
162
  "styles",
162
163
  "layouts",
163
164
  "pages",
@@ -203,11 +204,14 @@
203
204
  "@citation-js/plugin-bibtex": "^0.7.21",
204
205
  "@fontsource-variable/roboto": "^5.2.10",
205
206
  "@fontsource-variable/source-code-pro": "^5.2.7",
207
+ "@resvg/resvg-js": "^2.6.2",
208
+ "ajv": "^8.20.0",
206
209
  "pagefind": "^1.5.2",
207
210
  "parse5": "^7.3.0",
208
211
  "remark-math": "^6.0.0",
209
212
  "remark-mdx": "^3.1.1",
210
213
  "remark-parse": "^11.0.0",
214
+ "satori": "^0.26.0",
211
215
  "unified": "^11.0.5",
212
216
  "vite": "^7.3.2",
213
217
  "yaml": "^2.9.0"
@@ -0,0 +1,197 @@
1
+ # Recipe 25 — QA and content readiness
2
+
3
+ **Profile**: any, in single-book or corpus mode.
4
+
5
+ **TL;DR**: `book-scaffold qa` turns the scaffold's content contract and stable
6
+ MDX facts into one CI-safe readiness verdict. Human output is the default;
7
+ `--format json` emits deterministic schema-v1 JSON. Use
8
+ `book-scaffold init-qa` only when a portfolio QA engine needs a network-free
9
+ `guide_qa.yaml` registry.
10
+
11
+ ## Run the readiness check
12
+
13
+ Run the locally installed binary from the book root:
14
+
15
+ ```bash
16
+ npm exec -- book-scaffold qa
17
+ ```
18
+
19
+ This checks the implicit book in a single-book project. In a corpus, omission
20
+ of a selector checks every manifest book in manifest order. Select one exact
21
+ registered id or spell out the all-books default with:
22
+
23
+ ```bash
24
+ npm exec -- book-scaffold qa --book evaluation
25
+ npm exec -- book-scaffold qa --all
26
+ ```
27
+
28
+ `--book` is invalid in single-book mode. `--book` and `--all` are mutually
29
+ exclusive; an unknown book id is an invocation error rather than an empty
30
+ report.
31
+
32
+ ## What QA means
33
+
34
+ QA reuses the same content-contract library as `book-scaffold validate`, then
35
+ adds deterministic readiness facts:
36
+
37
+ | Check | Blocking condition |
38
+ |---|---|
39
+ | `content_contract` | Any validation error |
40
+ | `chapters` | No non-draft chapter |
41
+ | `links` | Any broken internal target or fragment |
42
+ | `learning_objectives` | Less than 100% anchor coverage when objectives apply |
43
+ | `components` | Never blocking; counts scaffold MDX components |
44
+ | `demo_fixtures` | Invalid non-generated JSON or a failing referenced schema |
45
+
46
+ An unavailable metric is `not_applicable`, not a fabricated zero. Component
47
+ counts are inventory facts, not universal prose-quality scores. `qa` also does
48
+ not replace `astro build`: rendering, content-collection Zod checks, and KaTeX
49
+ remain build responsibilities.
50
+
51
+ The traffic-light states are `green`, `amber`, `red`, and `not_applicable`.
52
+ Amber advisories stay visible but do not fail CI. Exit status is:
53
+
54
+ | Exit | Meaning |
55
+ |---|---|
56
+ | `0` | No blocking failure (`green` or `amber`) |
57
+ | `1` | At least one selected book or corpus-shared check is `red` |
58
+ | `2` | Invalid invocation, unresolved configuration, or internal failure |
59
+
60
+ The link check inspects internal links authored in chapter Markdown/MDX. It
61
+ uses the resolved scaffold route toggles, Astro page conventions, and public
62
+ assets as its route oracle. Fragments that depend on a consumer-defined MDX
63
+ component or a known non-chapter route are reported as amber
64
+ `fragment_unverified` advisories instead of guessed successes or blocking
65
+ failures.
66
+
67
+ Schema-v1 fixture validation supports JSON Schema draft-07 (also the default
68
+ when `$schema` is absent), 2019-09, and 2020-12. Recursive `$ref` resources must
69
+ remain inside the project after symlink resolution; QA never fetches network
70
+ schemas. The JSON Schema `format` keyword is annotation-only in v1. Unsupported
71
+ or mixed dialects and out-of-project references make that fixture red.
72
+
73
+ ## Human and JSON output
74
+
75
+ Human output is compact and terminal-oriented:
76
+
77
+ ```bash
78
+ npm exec -- book-scaffold qa --format human
79
+ ```
80
+
81
+ For automation, use the stable JSON form (`--json` is an alias):
82
+
83
+ ```bash
84
+ npm --offline exec -- book-scaffold qa --format json > qa-result.json
85
+ ```
86
+
87
+ JSON stdout contains only the schema-v1 document; progress and fatal
88
+ diagnostics use stderr. The document omits timestamps and durations, preserves
89
+ manifest/source order, and always contains `books`, `shared`, and `summary`.
90
+
91
+ `books` contains only the implicit `book` result or registered manifest ids.
92
+ `shared` is never a synthetic book. It is an always-present aggregate shaped
93
+ like a book result:
94
+
95
+ ```json
96
+ {
97
+ "verdict": "not_applicable",
98
+ "checks": {},
99
+ "diagnostics": []
100
+ }
101
+ ```
102
+
103
+ That exact value is used in single-book mode. In corpus mode,
104
+ `shared.checks.demo_fixtures` holds the normal `{ state, metrics,
105
+ diagnosticIds }` check payload for JSON under `src/data/` outside a registered
106
+ book directory. A shared failure affects the top-level corpus verdict and
107
+ summary, never an individual book verdict. Its diagnostics use
108
+ `book: "corpus"` rather than borrowing a manifest id.
109
+
110
+ ## Generate `guide_qa.yaml`
111
+
112
+ Portfolio-level QA engines can discover the scaffold check without a custom
113
+ consumer wrapper:
114
+
115
+ ```bash
116
+ npm exec -- book-scaffold init-qa
117
+ ```
118
+
119
+ A single-book project gets this deterministic file:
120
+
121
+ ```yaml
122
+ # Generated by book-scaffold init-qa.
123
+ # Regenerate with: book-scaffold init-qa --force
124
+ version: 1
125
+ guides:
126
+ - id: book
127
+ check_cmd: npm --offline exec -- book-scaffold qa --format json
128
+ ```
129
+
130
+ A corpus gets one entry per manifest book, in manifest order, with an exact
131
+ selector:
132
+
133
+ ```yaml
134
+ # Generated by book-scaffold init-qa.
135
+ # Regenerate with: book-scaffold init-qa --force
136
+ version: 1
137
+ guides:
138
+ - id: evaluation
139
+ check_cmd: npm --offline exec -- book-scaffold qa --book evaluation --format json
140
+ - id: llm-app-engineering
141
+ check_cmd: npm --offline exec -- book-scaffold qa --book llm-app-engineering --format json
142
+ ```
143
+
144
+ The generated `npm --offline exec --` commands can use only the installed
145
+ toolkit; they never fetch a package. `book-scaffold qa` does not read or
146
+ execute `guide_qa.yaml` itself.
147
+
148
+ An existing file is preserved and makes `init-qa` fail. Regenerate explicitly
149
+ with:
150
+
151
+ ```bash
152
+ npm exec -- book-scaffold init-qa --force
153
+ ```
154
+
155
+ `--force` replaces the whole `guide_qa.yaml` and changes no other file. A
156
+ portfolio engine may add presentation fields, but forced regeneration removes
157
+ those edits.
158
+
159
+ ## CI wiring
160
+
161
+ After the dependency install, offline execution prevents a typo or missing
162
+ local binary from becoming an implicit registry download:
163
+
164
+ ```yaml
165
+ - run: npm ci
166
+ - run: npm --offline exec -- book-scaffold qa --format json > qa-result.json
167
+ - run: npm run build
168
+ ```
169
+
170
+ Keep the build step. QA supplies a content-health verdict; the production
171
+ build remains the render/deployment gate.
172
+
173
+ ## Common gotchas
174
+
175
+ - **Treating N/A as zero** — unavailable profile/content metrics are explicitly
176
+ `not_applicable`.
177
+ - **Expecting `components` to grade prose** — counts are informational and
178
+ never impose cross-preset quotas.
179
+ - **Attributing shared corpus JSON to a book** — only files beneath
180
+ `src/data/<book>/` belong to that book. Other non-generated JSON is checked
181
+ once through the top-level `shared` result.
182
+ - **Counting generated indexes as fixtures** — scaffold outputs including
183
+ `labels.json`, `references.json`, `tips.json`, and `exercises.json` are
184
+ excluded.
185
+ - **Hand-editing generated commands** — use engine-owned presentation fields
186
+ if needed; `init-qa --force` intentionally restores canonical commands.
187
+ - **Using `--book` for a one-book project** — the implicit id is represented as
188
+ `book` in results and `guide_qa.yaml`, but it is not a selectable corpus id.
189
+
190
+ ## Canonical files
191
+
192
+ - `scripts/qa-core.mjs` — deterministic check and aggregate engine
193
+ - `scripts/init-qa.mjs` — deterministic, overwrite-safe registry generator
194
+ - `scripts/validate-core.mjs` — shared validation library used by QA's
195
+ content contract
196
+ - `recipes/09-validation.md` — individual diagnostics and prebuild wiring
197
+ - `../../docs/plans/active/qa-contract.md` — accepted schema and selector contract
@@ -0,0 +1,205 @@
1
+ # Recipe 26 — Generate Open Graph cards at build time
2
+
3
+ **Profile**: any, in single-book or corpus mode.
4
+
5
+ **TL;DR**: Set `seo.ogCards: true` to generate a deterministic 1200×630 PNG
6
+ for each eligible rendered HTML page that does not already have an authored
7
+ Open Graph image. Generation runs offline after all other integrations, writes
8
+ content-addressed files beneath `_og/`, and patches only the missing image
9
+ metadata. A page image, corpus-book manifest image, or static `seo.ogImage`
10
+ always wins.
11
+
12
+ ## Opt in
13
+
14
+ The short form enables generation with only the built-in exclusions:
15
+
16
+ ```ts
17
+ import {
18
+ academicStyle,
19
+ defineBookConfig,
20
+ } from '@brandon_m_behring/book-scaffold-astro';
21
+
22
+ export default await defineBookConfig({
23
+ styles: [academicStyle],
24
+ site: 'https://book.example/',
25
+ seo: {
26
+ ogCards: true,
27
+ },
28
+ });
29
+ ```
30
+
31
+ Use the object form when particular routes should not receive cards:
32
+
33
+ ```ts
34
+ seo: {
35
+ ogCards: {
36
+ enabled: true,
37
+ exclude: [
38
+ '/print/', // exact route
39
+ '/drafts/*/', // one complete path segment
40
+ '/archive/**', // zero or more complete path segments
41
+ ],
42
+ },
43
+ }
44
+ ```
45
+
46
+ `ogCards` is `false` by default. `false` and `{ enabled: false }` both disable
47
+ the generator. Exclusion patterns are base-relative route paths, even when
48
+ Astro `base` is not `/`; do not repeat the deployment base in the pattern.
49
+
50
+ The accepted grammar is deliberately small:
51
+
52
+ | Form | Meaning |
53
+ |---|---|
54
+ | `/some/route/` | Match that normalized route exactly |
55
+ | `*` path segment | Match exactly one complete segment |
56
+ | `**` path segment | Match zero or more complete segments |
57
+
58
+ Wildcards are whole segments, not substring syntax, and a trailing slash is
59
+ significant. Patterns must start `/` and cannot contain a host, query,
60
+ fragment, backslash, control character, empty internal segment, `.`/`..`, or
61
+ unsupported `?`/`[]`/`{}` glob syntax. Invalid and duplicate patterns fail
62
+ configuration instead of being ignored. The configured list augments built-in
63
+ skips for emitted 404/500 files, meta-refresh redirects, and
64
+ `robots`/`googlebot` `noindex` pages.
65
+
66
+ ## Know which image wins
67
+
68
+ Image precedence is strict:
69
+
70
+ 1. a page/layout `ogImage`, including chapter frontmatter `image`;
71
+ 2. the current corpus book's manifest `image`;
72
+ 3. the static application default at `seo.ogImage`;
73
+ 4. a generated per-page card.
74
+
75
+ The integration inspects the final HTML. If it finds a valid
76
+ `<meta property="og:image">`, it leaves that tag and the page untouched. This
77
+ makes a one-page override, corpus-book image, and application-wide static
78
+ default reliable opt-outs from generation without a second exclusion list.
79
+
80
+ When generation is selected, the integration adds this complete image set:
81
+
82
+ ```html
83
+ <meta property="og:image" content="https://book.example/_og/0123abcd4567ef89.png">
84
+ <meta property="og:image:width" content="1200">
85
+ <meta property="og:image:height" content="630">
86
+ <meta property="og:image:type" content="image/png">
87
+ <meta name="twitter:image" content="https://book.example/_og/0123abcd4567ef89.png">
88
+ ```
89
+
90
+ Existing title, description, canonical, and Twitter text metadata remain
91
+ authoritative. The integration does not change sitemap or Pagefind inclusion.
92
+
93
+ ## What the build emits
94
+
95
+ The generator runs in the final `astro:build:done` hook, after scaffold and
96
+ consumer integrations have rendered their output. It walks emitted HTML in
97
+ stable pathname order, derives the card from the rendered metadata, renders an
98
+ SVG tree with Satori, converts it with `@resvg/resvg-js`, and writes a 1200×630
99
+ PNG at:
100
+
101
+ ```text
102
+ _og/<first-16-hex-of-content-sha256>.png
103
+ ```
104
+
105
+ The hash covers template version 1, dimensions, resolved preset and exact
106
+ corpus book id (or `null`), the clamped book/page/description strings, and the
107
+ rendered canonical hostname. The route is not drawn on the card. Two pages
108
+ with the same visual payload reuse one file; changing a visual input produces
109
+ a new immutable URL. A same-hash/different-bytes collision fails the build.
110
+
111
+ Only images referenced by the current build remain. Astro normally empties the
112
+ output directory first; when `emptyOutDir: false` preserves it, the integration
113
+ automatically prunes stale scaffold-owned `_og` filenames matching exactly 16
114
+ hex characters plus `.png`. It leaves every other file untouched, so there is
115
+ no user-managed cache or cleanup step.
116
+
117
+ ## Offline fonts and deterministic content
118
+
119
+ Cards contain the page title, optional description, resolved book or corpus
120
+ title, canonical hostname, and a small scaffold/profile mark. After whitespace
121
+ normalization, values are clamped by Unicode code point: page title 96,
122
+ description 180, book/corpus title 72, and hostname 80. Truncated values use a
123
+ word-boundary ellipsis when possible.
124
+
125
+ Satori receives the package-owned Inter v4.1 assets
126
+ `assets/og-fonts/Inter-Regular.ttf` and `Inter-Bold.ttf` under the OFL 1.1. The
127
+ build never downloads a web font or uses a consumer machine's installed fonts,
128
+ so an unchanged payload produces unchanged bytes on supported platforms. The
129
+ five presets use one accessible layout and type scale with preset-specific
130
+ colors. Corpus books keep the corpus-wide preset; the generator never invents
131
+ a per-book profile.
132
+
133
+ ## Corpus identity and Astro base
134
+
135
+ Corpus pages use the identity marker emitted by the scaffold runtime. A book
136
+ chapter therefore receives its registered book title, even when two books
137
+ reuse the same local slug or page title. Corpus-level landing and search pages
138
+ use corpus identity and are not attributed to the first manifest book.
139
+
140
+ The physical file is `<outDir>/_og/<hash>.png`. Its metadata URL combines the
141
+ Astro `site` origin, normalized `base`, and `_og/<hash>.png` exactly once. Keep
142
+ exclusion patterns base-relative: a deployment at `base: '/manual/'` still
143
+ excludes its print page with `/print/`, while emitted metadata points beneath
144
+ `https://book.example/manual/_og/…`.
145
+
146
+ ## Failure and skip behavior
147
+
148
+ An enabled generator is fail-loud. A generated card requires one absolute
149
+ `http(s)` canonical link and a non-empty rendered `og:title` or `<title>`.
150
+ Missing, invalid, duplicate, or contradictory metadata/identity; unreadable
151
+ output; font or renderer failure; a hash collision; or inability to patch the
152
+ selected HTML fails the Astro build. It never publishes image metadata without
153
+ the corresponding PNG and never silently downgrades a requested card.
154
+
155
+ Expected exclusions are skips, not errors: a valid absolute `http(s)` authored
156
+ image, configured patterns, emitted 404/500 files, meta-refresh redirects,
157
+ `robots`/`googlebot` `noindex`, and non-HTML output do not generate a PNG.
158
+ Corpus/static precedence injects its image URL without rendering a PNG.
159
+ Server/SSR routes, runtime image services, remote templates, arbitrary
160
+ consumer JSX card templates, and animation are outside this build-time
161
+ contract.
162
+
163
+ ## Verify a deployment
164
+
165
+ Build normally, then inspect both the metadata and referenced file:
166
+
167
+ ```bash
168
+ npm run build
169
+ find dist -path '*/_og/*.png' -print
170
+ grep -R 'property="og:image"' dist --include='*.html'
171
+ ```
172
+
173
+ For a subpath deployment, confirm the absolute meta URL includes `base` once.
174
+ Build twice without changing metadata and confirm the same payload keeps the
175
+ same filename. Add a page override and confirm its authored image remains
176
+ unchanged.
177
+
178
+ ## Common gotchas
179
+
180
+ - **Setting both `seo.ogImage` and `seo.ogCards`** — the static image wins on
181
+ every page without a page or corpus-book image, so no generated fallback is
182
+ needed there.
183
+ - **Including Astro `base` in `exclude`** — patterns are matched after base
184
+ normalization; write `/print/`, not `/manual/print/`.
185
+ - **Using `*` for nested paths** — `*` crosses exactly one segment; use `**`
186
+ for any depth.
187
+ - **Expecting runtime generation** — cards are created only for static HTML
188
+ present at `astro:build:done`.
189
+
190
+ ## Canonical files
191
+
192
+ - `src/types.ts` — `OgCardsConfig` and `seo.ogCards`
193
+ - `src/config.ts` — opt-in normalization and final integration ordering
194
+ - `src/lib/og-cards.ts` — route matching, rendered-metadata extraction, rendering,
195
+ hashing, emission, and HTML patching
196
+ - `assets/og-fonts/` — package-owned Inter v4.1 TTF assets, `LICENSE.txt`, and
197
+ provenance in `SOURCE.md`
198
+ - `tests/og-cards.test.mjs` — precedence, routes, corpus/base, determinism, and
199
+ failure contracts
200
+
201
+ ## Reference implementation
202
+
203
+ The package fixtures exercise all five presets, root and non-root bases, and a
204
+ two-book corpus. A consumer needs no page source or runtime endpoint beyond the
205
+ `seo.ogCards` opt-in shown above.
package/recipes/README.md CHANGED
@@ -30,6 +30,8 @@ Terse pointers into canonical code for the most common book-authoring workflows.
30
30
  | 22 | [Responsive navigation and custom routing](22-responsive-nav-and-multibook-routing.md) | any | Mobile/desktop nav and the v4-compatible consumer-owned route-token API |
31
31
  | 23 | [Interactive demo substrate](23-interactive-demo-substrate.md) | any | Opt-in Preact shell, slider, stat cards, theme colors, a11y, and reduced motion |
32
32
  | 24 | [Figure authoring standard](24-figure-authoring-standard.md) | any | Warm–Tol semantics, Okabe–Ito series, dual-theme exports, contrast, and accessible descriptions |
33
+ | 25 | [QA and content readiness](25-qa-readiness.md) | any | Stable human/JSON readiness verdicts, corpus selection, CI, and `guide_qa.yaml` |
34
+ | 26 | [Build-time Open Graph cards](26-generated-og-cards.md) | any | Opt-in deterministic 1200×630 cards, precedence, exclusions, corpus identity, and base-safe URLs |
33
35
 
34
36
  ## How to read recipes
35
37