@brandon_m_behring/book-scaffold-astro 5.1.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/CLAUDE.md +19 -0
- package/README.md +21 -0
- package/assets/og-fonts/Inter-Bold.ttf +0 -0
- package/assets/og-fonts/Inter-Regular.ttf +0 -0
- package/assets/og-fonts/LICENSE.txt +89 -0
- package/assets/og-fonts/SOURCE.md +13 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +888 -6
- package/dist/schemas.d.ts +1 -1
- package/dist/{types-D1QZgKMO.d.ts → types-DjqMnwHw.d.ts} +22 -4
- package/layouts/Base.astro +101 -3
- package/package.json +4 -1
- package/recipes/26-generated-og-cards.md +205 -0
- package/recipes/README.md +1 -0
- package/src/lib/og-cards.ts +1135 -0
- package/src/types.ts +22 -3
package/dist/schemas.d.ts
CHANGED
|
@@ -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
|
|
750
|
-
*
|
|
751
|
-
*
|
|
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 };
|
package/layouts/Base.astro
CHANGED
|
@@ -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
|
-
?
|
|
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
|
|
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.
|
|
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,12 +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",
|
|
206
208
|
"ajv": "^8.20.0",
|
|
207
209
|
"pagefind": "^1.5.2",
|
|
208
210
|
"parse5": "^7.3.0",
|
|
209
211
|
"remark-math": "^6.0.0",
|
|
210
212
|
"remark-mdx": "^3.1.1",
|
|
211
213
|
"remark-parse": "^11.0.0",
|
|
214
|
+
"satori": "^0.26.0",
|
|
212
215
|
"unified": "^11.0.5",
|
|
213
216
|
"vite": "^7.3.2",
|
|
214
217
|
"yaml": "^2.9.0"
|
|
@@ -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
|
@@ -31,6 +31,7 @@ Terse pointers into canonical code for the most common book-authoring workflows.
|
|
|
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
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 |
|
|
34
35
|
|
|
35
36
|
## How to read recipes
|
|
36
37
|
|