@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/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.1.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,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