@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/CLAUDE.md +46 -3
- package/README.md +55 -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/bin/book-scaffold.mjs +4 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +894 -8
- 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 +5 -1
- package/recipes/25-qa-readiness.md +197 -0
- package/recipes/26-generated-og-cards.md +205 -0
- package/recipes/README.md +2 -0
- package/scripts/init-qa.mjs +209 -0
- package/scripts/qa-core.mjs +1511 -0
- package/scripts/qa.mjs +293 -0
- package/scripts/resolve-book-config.mjs +39 -0
- package/scripts/validate-core.mjs +1675 -0
- package/scripts/validate.mjs +18 -1492
- package/scripts/validation-artifacts.mjs +23 -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,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
|
|