@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
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, build-time Open Graph cards (#157).
|
|
3
|
+
*
|
|
4
|
+
* The integration intentionally works from Astro's rendered HTML rather than
|
|
5
|
+
* source collections. That keeps consumer-owned pages in scope and makes the
|
|
6
|
+
* rendered canonical metadata the single source of truth.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import {
|
|
10
|
+
lstat,
|
|
11
|
+
mkdir,
|
|
12
|
+
readFile,
|
|
13
|
+
readdir,
|
|
14
|
+
realpath,
|
|
15
|
+
stat,
|
|
16
|
+
unlink,
|
|
17
|
+
writeFile,
|
|
18
|
+
} from 'node:fs/promises';
|
|
19
|
+
import { extname, join, relative, sep } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import type { AstroIntegration } from 'astro';
|
|
22
|
+
import { parse } from 'parse5';
|
|
23
|
+
import type { BookCorpus } from '../types.js';
|
|
24
|
+
import type { BookProfile } from '../profiles/index.js';
|
|
25
|
+
|
|
26
|
+
const CARD_WIDTH = 1200;
|
|
27
|
+
const CARD_HEIGHT = 630;
|
|
28
|
+
const TEMPLATE_VERSION = 1;
|
|
29
|
+
const TITLE_LIMIT = 96;
|
|
30
|
+
const DESCRIPTION_LIMIT = 180;
|
|
31
|
+
const BOOK_TITLE_LIMIT = 72;
|
|
32
|
+
const HOSTNAME_LIMIT = 80;
|
|
33
|
+
|
|
34
|
+
const ALLOWED_CONFIG_KEYS = new Set(['enabled', 'exclude']);
|
|
35
|
+
const UNSUPPORTED_GLOB_TOKENS = /[?\[\]{}]/u;
|
|
36
|
+
|
|
37
|
+
export interface ResolvedOgCardsConfig {
|
|
38
|
+
readonly enabled: true;
|
|
39
|
+
readonly exclude: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CorpusBookLike {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly title: string;
|
|
45
|
+
readonly description?: string;
|
|
46
|
+
readonly image?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface OgCardsIntegrationOptions {
|
|
50
|
+
profile: BookProfile;
|
|
51
|
+
corpus?: BookCorpus | null;
|
|
52
|
+
title?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
ogCards: ResolvedOgCardsConfig;
|
|
55
|
+
/** Top-level `seo.ogImage`; exact corpus-book images take precedence. */
|
|
56
|
+
staticOgImage?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface HtmlAttribute {
|
|
60
|
+
name: string;
|
|
61
|
+
value: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface HtmlLocation {
|
|
65
|
+
startOffset: number;
|
|
66
|
+
endOffset: number;
|
|
67
|
+
startTag?: { startOffset: number; endOffset: number };
|
|
68
|
+
endTag?: { startOffset: number; endOffset: number };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface HtmlNode {
|
|
72
|
+
nodeName: string;
|
|
73
|
+
tagName?: string;
|
|
74
|
+
value?: string;
|
|
75
|
+
attrs?: HtmlAttribute[];
|
|
76
|
+
childNodes?: HtmlNode[];
|
|
77
|
+
sourceCodeLocation?: HtmlLocation | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ParsedPage {
|
|
81
|
+
document: HtmlNode;
|
|
82
|
+
head: HtmlNode;
|
|
83
|
+
route: string;
|
|
84
|
+
file: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface PageIdentity {
|
|
88
|
+
book: CorpusBookLike | null;
|
|
89
|
+
surface: 'corpus' | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface CardPayload {
|
|
93
|
+
templateVersion: number;
|
|
94
|
+
width: number;
|
|
95
|
+
height: number;
|
|
96
|
+
profile: BookProfile;
|
|
97
|
+
bookId: string | null;
|
|
98
|
+
bookTitle: string;
|
|
99
|
+
title: string;
|
|
100
|
+
description: string;
|
|
101
|
+
hostname: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface PlannedCard {
|
|
105
|
+
page: ParsedPage;
|
|
106
|
+
source: string;
|
|
107
|
+
payload: CardPayload;
|
|
108
|
+
payloadJson: string;
|
|
109
|
+
hash: string;
|
|
110
|
+
imageUrl: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface PlannedStaticImage {
|
|
114
|
+
page: ParsedPage;
|
|
115
|
+
source: string;
|
|
116
|
+
imageUrl: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface Theme {
|
|
120
|
+
background: string;
|
|
121
|
+
accent: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface CardLayoutBox {
|
|
125
|
+
left: number;
|
|
126
|
+
top: number;
|
|
127
|
+
width: number;
|
|
128
|
+
height: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const PROFILE_THEMES: Readonly<Record<BookProfile, Theme>> = Object.freeze({
|
|
132
|
+
academic: Object.freeze({ background: '#1A1816', accent: '#7297BB' }),
|
|
133
|
+
tools: Object.freeze({ background: '#26231F', accent: '#AB80A5' }),
|
|
134
|
+
minimal: Object.freeze({ background: '#1A1816', accent: '#D2B575' }),
|
|
135
|
+
'course-notes': Object.freeze({ background: '#1A1816', accent: '#7DA275' }),
|
|
136
|
+
'research-portfolio': Object.freeze({ background: '#26231F', accent: '#D29287' }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** Fail-loud config normalization shared by defineBookConfig and tests. */
|
|
140
|
+
export function normalizeOgCardsConfig(value: unknown): ResolvedOgCardsConfig | null {
|
|
141
|
+
if (value === undefined || value === false) return null;
|
|
142
|
+
if (value === true) return Object.freeze({ enabled: true, exclude: Object.freeze([]) });
|
|
143
|
+
|
|
144
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
'seo.ogCards must be true, false, or an object with enabled and exclude fields.',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const input = value as Record<string, unknown>;
|
|
151
|
+
const unknown = Object.keys(input).filter((key) => !ALLOWED_CONFIG_KEYS.has(key));
|
|
152
|
+
if (unknown.length > 0) {
|
|
153
|
+
throw new Error(`seo.ogCards contains unknown ${unknown.length === 1 ? 'field' : 'fields'}: ${unknown.join(', ')}.`);
|
|
154
|
+
}
|
|
155
|
+
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
|
|
156
|
+
throw new Error('seo.ogCards.enabled must be a boolean.');
|
|
157
|
+
}
|
|
158
|
+
if (input.exclude !== undefined && !Array.isArray(input.exclude)) {
|
|
159
|
+
throw new Error('seo.ogCards.exclude must be an array of route patterns.');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const exclude = (input.exclude ?? []) as unknown[];
|
|
163
|
+
const normalized = Array.from(exclude, (pattern, index) => validateExcludePattern(pattern, index));
|
|
164
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
165
|
+
throw new Error('seo.ogCards.exclude must not contain duplicate patterns.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (input.enabled === false) return null;
|
|
169
|
+
return Object.freeze({ enabled: true, exclude: Object.freeze(normalized) });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function validateExcludePattern(value: unknown, index: number): string {
|
|
173
|
+
const label = `seo.ogCards.exclude[${index}]`;
|
|
174
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
175
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
176
|
+
}
|
|
177
|
+
if (!value.startsWith('/')) {
|
|
178
|
+
throw new Error(`${label} must be base-relative and start with "/".`);
|
|
179
|
+
}
|
|
180
|
+
if (value.startsWith('//') || value.includes('\\') || /[\u0000-\u001F\u007F]/u.test(value)) {
|
|
181
|
+
throw new Error(`${label} must be a local route pattern without a host, backslash, or control character.`);
|
|
182
|
+
}
|
|
183
|
+
if (value.includes('?') || value.includes('#')) {
|
|
184
|
+
throw new Error(`${label} must not contain a query string or fragment.`);
|
|
185
|
+
}
|
|
186
|
+
if (UNSUPPORTED_GLOB_TOKENS.test(value)) {
|
|
187
|
+
throw new Error(`${label} supports only literal segments plus whole "*" and "**" segments.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const segments = value.slice(1).split('/');
|
|
191
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
|
|
192
|
+
const segment = segments[segmentIndex];
|
|
193
|
+
const trailingSlash = segmentIndex === segments.length - 1 && segment === '';
|
|
194
|
+
if (!trailingSlash && segment === '') {
|
|
195
|
+
throw new Error(`${label} must not contain an empty route segment.`);
|
|
196
|
+
}
|
|
197
|
+
if (segment === '.' || segment === '..') {
|
|
198
|
+
throw new Error(`${label} must not contain "." or ".." route segments.`);
|
|
199
|
+
}
|
|
200
|
+
if (segment.includes('*') && segment !== '*' && segment !== '**') {
|
|
201
|
+
throw new Error(`${label}: "*" and "**" must occupy a complete route segment.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function escapeRegExp(value: string): string {
|
|
208
|
+
return value.replace(/[|\\{}()[\]^$+?.-]/g, '\\$&');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function exclusionMatcher(pattern: string): RegExp {
|
|
212
|
+
const segments = pattern.split('/');
|
|
213
|
+
let source = '^';
|
|
214
|
+
|
|
215
|
+
// The first split segment is empty because every valid pattern starts `/`.
|
|
216
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
217
|
+
const segment = segments[index];
|
|
218
|
+
const isLast = index === segments.length - 1;
|
|
219
|
+
if (segment === '' && isLast) {
|
|
220
|
+
source += '/';
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (segment === '**') {
|
|
224
|
+
source += isLast ? '(?:/.*)?' : '(?:/[^/]+)*';
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (segment === '*') {
|
|
228
|
+
source += '/[^/]+';
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
source += `/${escapeRegExp(segment)}`;
|
|
232
|
+
}
|
|
233
|
+
return new RegExp(`${source}$`, 'u');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeBase(base: string | undefined): string {
|
|
237
|
+
const trimmed = (base ?? '/').trim();
|
|
238
|
+
if (!trimmed || trimmed === '/') return '/';
|
|
239
|
+
return `/${trimmed.replace(/^\/+|\/+$/g, '')}/`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function cardUrl(site: URL, base: string, hash: string): string {
|
|
243
|
+
const url = new URL(site.origin);
|
|
244
|
+
url.pathname = `${normalizeBase(base)}_og/${hash}.png`.replace(/\/{2,}/g, '/');
|
|
245
|
+
return url.toString();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveStaticImageUrl(value: string, site: URL, base: string): string {
|
|
249
|
+
const trimmed = value.trim();
|
|
250
|
+
if (!trimmed) throw new Error('OG card static image must not be blank.');
|
|
251
|
+
if (/^https?:\/\//iu.test(trimmed)) {
|
|
252
|
+
return absoluteHttpUrl(trimmed, 'static OG image', '(config)').toString();
|
|
253
|
+
}
|
|
254
|
+
if (trimmed.startsWith('//')) {
|
|
255
|
+
if (trimmed.startsWith('///')) {
|
|
256
|
+
throw new Error(`OG card static image has an invalid protocol-relative URL: ${JSON.stringify(value)}.`);
|
|
257
|
+
}
|
|
258
|
+
return absoluteHttpUrl(`${site.protocol}${trimmed}`, 'static OG image', '(config)').toString();
|
|
259
|
+
}
|
|
260
|
+
if (/^[a-z][a-z\d+.-]*:/iu.test(trimmed)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`OG card static image must be local, protocol-relative, or http(s) (got ${JSON.stringify(value)}).`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (trimmed.includes('\\') || /[\u0000-\u001F\u007F]/u.test(trimmed)) {
|
|
266
|
+
throw new Error('OG card local static image must not contain a backslash or control character.');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const rawPath = trimmed.split(/[?#]/u, 1)[0];
|
|
270
|
+
if (!rawPath) throw new Error('OG card local static image must contain a path.');
|
|
271
|
+
for (const rawSegment of rawPath.split('/')) {
|
|
272
|
+
let decoded = rawSegment;
|
|
273
|
+
try {
|
|
274
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
275
|
+
const next = decodeURIComponent(decoded);
|
|
276
|
+
if (next === decoded) break;
|
|
277
|
+
decoded = next;
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
throw new Error(`OG card local static image contains invalid percent encoding: ${JSON.stringify(value)}.`);
|
|
281
|
+
}
|
|
282
|
+
if (
|
|
283
|
+
decoded === '.'
|
|
284
|
+
|| decoded === '..'
|
|
285
|
+
|| decoded.includes('/')
|
|
286
|
+
|| decoded.includes('\\')
|
|
287
|
+
|| /[\u0000-\u001F\u007F]/u.test(decoded)
|
|
288
|
+
) {
|
|
289
|
+
throw new Error(`OG card local static image must not contain encoded path traversal: ${JSON.stringify(value)}.`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const localPath = `/${rawPath.replace(/^\/+/, '')}`;
|
|
294
|
+
const suffix = trimmed.slice(rawPath.length);
|
|
295
|
+
const normalizedBase = normalizeBase(base);
|
|
296
|
+
const baseWithoutTrailingSlash = normalizedBase.replace(/\/+$/u, '');
|
|
297
|
+
const alreadyPrefixed = normalizedBase === '/'
|
|
298
|
+
|| localPath === baseWithoutTrailingSlash
|
|
299
|
+
|| localPath.startsWith(normalizedBase);
|
|
300
|
+
const resolvedPath = alreadyPrefixed
|
|
301
|
+
? localPath
|
|
302
|
+
: `${normalizedBase}${localPath.slice(1)}`;
|
|
303
|
+
|
|
304
|
+
let url: URL;
|
|
305
|
+
try {
|
|
306
|
+
url = new URL(`${resolvedPath}${suffix}`, site.origin);
|
|
307
|
+
} catch {
|
|
308
|
+
throw new Error(`OG card static image ${JSON.stringify(value)} is not a valid URL.`);
|
|
309
|
+
}
|
|
310
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
311
|
+
throw new Error(`OG card static image must resolve to an http(s) URL (got ${JSON.stringify(value)}).`);
|
|
312
|
+
}
|
|
313
|
+
return url.toString();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function walk(node: HtmlNode, visit: (node: HtmlNode) => void): void {
|
|
317
|
+
visit(node);
|
|
318
|
+
for (const child of node.childNodes ?? []) walk(child, visit);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function attr(node: HtmlNode, name: string): string | null {
|
|
322
|
+
const found = node.attrs?.find((candidate) => candidate.name.toLowerCase() === name);
|
|
323
|
+
return found?.value ?? null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function elements(document: HtmlNode, tagName: string): HtmlNode[] {
|
|
327
|
+
const found: HtmlNode[] = [];
|
|
328
|
+
walk(document, (node) => {
|
|
329
|
+
if (node.tagName?.toLowerCase() === tagName) found.push(node);
|
|
330
|
+
});
|
|
331
|
+
return found;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function textContent(node: HtmlNode): string {
|
|
335
|
+
if (node.nodeName === '#text') return node.value ?? '';
|
|
336
|
+
return (node.childNodes ?? []).map(textContent).join('');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function metas(document: HtmlNode, attribute: 'name' | 'property', value: string): HtmlNode[] {
|
|
340
|
+
const normalizedValue = value.toLowerCase();
|
|
341
|
+
return elements(document, 'meta').filter(
|
|
342
|
+
(node) => attr(node, attribute)?.trim().toLowerCase() === normalizedValue,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function contentOfUniqueMeta(
|
|
347
|
+
document: HtmlNode,
|
|
348
|
+
attribute: 'name' | 'property',
|
|
349
|
+
value: string,
|
|
350
|
+
file: string,
|
|
351
|
+
): string | null {
|
|
352
|
+
const matches = metas(document, attribute, value);
|
|
353
|
+
if (matches.length > 1) {
|
|
354
|
+
throw new Error(`${file}: duplicate <meta ${attribute}=${JSON.stringify(value)}> tags are ambiguous.`);
|
|
355
|
+
}
|
|
356
|
+
if (matches.length === 0) return null;
|
|
357
|
+
const content = attr(matches[0], 'content')?.trim() ?? '';
|
|
358
|
+
if (!content) {
|
|
359
|
+
throw new Error(`${file}: <meta ${attribute}=${JSON.stringify(value)}> requires non-empty content.`);
|
|
360
|
+
}
|
|
361
|
+
return content;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function absoluteHttpUrl(value: string, label: string, file: string): URL {
|
|
365
|
+
let url: URL;
|
|
366
|
+
try {
|
|
367
|
+
url = new URL(value);
|
|
368
|
+
} catch {
|
|
369
|
+
throw new Error(`${file}: ${label} must be an absolute http(s) URL.`);
|
|
370
|
+
}
|
|
371
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
|
|
372
|
+
throw new Error(`${file}: ${label} must be an absolute http(s) URL.`);
|
|
373
|
+
}
|
|
374
|
+
return url;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function parsePage(source: string, file: string, route: string): ParsedPage {
|
|
378
|
+
const document = parse(source, { sourceCodeLocationInfo: true }) as unknown as HtmlNode;
|
|
379
|
+
const html = elements(document, 'html');
|
|
380
|
+
const heads = elements(document, 'head');
|
|
381
|
+
if (html.length !== 1 || heads.length !== 1) {
|
|
382
|
+
throw new Error(`${file}: generated OG cards require exactly one <html> and one <head> element.`);
|
|
383
|
+
}
|
|
384
|
+
const head = heads[0];
|
|
385
|
+
return { document, head, route, file };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isBuiltInErrorRoute(route: string): boolean {
|
|
389
|
+
return /(?:^|\/)(?:404|500)(?:\/|\.html)$/u.test(route);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isRedirect(document: HtmlNode): boolean {
|
|
393
|
+
return elements(document, 'meta').some(
|
|
394
|
+
(node) => attr(node, 'http-equiv')?.trim().toLowerCase() === 'refresh',
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isNoIndex(document: HtmlNode): boolean {
|
|
399
|
+
return elements(document, 'meta').some((node) => {
|
|
400
|
+
const name = attr(node, 'name')?.trim().toLowerCase();
|
|
401
|
+
if (name !== 'robots' && name !== 'googlebot') return false;
|
|
402
|
+
const directives = (attr(node, 'content') ?? '')
|
|
403
|
+
.toLowerCase()
|
|
404
|
+
.split(/[\s,]+/u);
|
|
405
|
+
return directives.includes('noindex') || directives.includes('none');
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function existingAuthoredOgImage(document: HtmlNode, file: string): string | null {
|
|
410
|
+
const value = contentOfUniqueMeta(document, 'property', 'og:image', file);
|
|
411
|
+
if (value === null) return null;
|
|
412
|
+
absoluteHttpUrl(value, 'og:image content', file);
|
|
413
|
+
return value;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function uniqueMarker(document: HtmlNode, name: string, file: string): string | null {
|
|
417
|
+
const values: string[] = [];
|
|
418
|
+
walk(document, (node) => {
|
|
419
|
+
const value = attr(node, name);
|
|
420
|
+
if (value !== null) values.push(value.trim());
|
|
421
|
+
});
|
|
422
|
+
if (values.length > 1) {
|
|
423
|
+
throw new Error(`${file}: duplicate ${name} identity markers are ambiguous.`);
|
|
424
|
+
}
|
|
425
|
+
if (values.length === 0) return null;
|
|
426
|
+
if (!values[0]) throw new Error(`${file}: ${name} identity marker must not be empty.`);
|
|
427
|
+
return values[0];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function pagefindIdentities(document: HtmlNode): { books: string[]; corpusSurface: boolean } {
|
|
431
|
+
const books = new Set<string>();
|
|
432
|
+
let corpusSurface = false;
|
|
433
|
+
walk(document, (node) => {
|
|
434
|
+
const filter = attr(node, 'data-pagefind-filter');
|
|
435
|
+
if (filter === null) return;
|
|
436
|
+
for (const token of filter.split(/[;,\s]+/u)) {
|
|
437
|
+
if (token.startsWith('book:') && token.length > 5) books.add(token.slice(5));
|
|
438
|
+
if (token === 'surface:corpus') corpusSurface = true;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
return { books: [...books].sort(), corpusSurface };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function resolvePageIdentity(
|
|
445
|
+
document: HtmlNode,
|
|
446
|
+
file: string,
|
|
447
|
+
corpus: BookCorpus | null | undefined,
|
|
448
|
+
): PageIdentity {
|
|
449
|
+
const markerBook = uniqueMarker(document, 'data-book-scaffold-book', file);
|
|
450
|
+
const markerSurface = uniqueMarker(document, 'data-book-scaffold-surface', file);
|
|
451
|
+
const fallback = pagefindIdentities(document);
|
|
452
|
+
|
|
453
|
+
if (markerSurface !== null && markerSurface !== 'corpus') {
|
|
454
|
+
throw new Error(`${file}: unknown data-book-scaffold-surface ${JSON.stringify(markerSurface)}.`);
|
|
455
|
+
}
|
|
456
|
+
if (fallback.books.length > 1) {
|
|
457
|
+
throw new Error(`${file}: multiple Pagefind book identities are ambiguous.`);
|
|
458
|
+
}
|
|
459
|
+
const fallbackBook = fallback.books[0] ?? null;
|
|
460
|
+
if (markerBook && fallbackBook && markerBook !== fallbackBook) {
|
|
461
|
+
throw new Error(`${file}: book identity marker ${JSON.stringify(markerBook)} disagrees with Pagefind ${JSON.stringify(fallbackBook)}.`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const bookId = markerBook ?? fallbackBook;
|
|
465
|
+
const corpusSurface = markerSurface === 'corpus' || fallback.corpusSurface;
|
|
466
|
+
if (bookId && corpusSurface) {
|
|
467
|
+
throw new Error(`${file}: a page cannot carry both book and corpus-surface identity.`);
|
|
468
|
+
}
|
|
469
|
+
if (!corpus) {
|
|
470
|
+
if (bookId || corpusSurface) {
|
|
471
|
+
throw new Error(`${file}: corpus identity markers were rendered without a configured corpus.`);
|
|
472
|
+
}
|
|
473
|
+
return { book: null, surface: null };
|
|
474
|
+
}
|
|
475
|
+
if (!bookId) return { book: null, surface: corpusSurface ? 'corpus' : null };
|
|
476
|
+
|
|
477
|
+
const book = corpus.books.find((candidate) => candidate.id === bookId) ?? null;
|
|
478
|
+
if (!book) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`${file}: unknown rendered corpus book ${JSON.stringify(bookId)}; expected ${corpus.books.map(({ id }) => id).join(' | ')}.`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return { book, surface: null };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function uniqueTitle(document: HtmlNode, file: string): string | null {
|
|
487
|
+
const ogTitle = contentOfUniqueMeta(document, 'property', 'og:title', file);
|
|
488
|
+
if (ogTitle) return ogTitle;
|
|
489
|
+
const titles = elements(document, 'title');
|
|
490
|
+
if (titles.length > 1) throw new Error(`${file}: duplicate <title> elements are ambiguous.`);
|
|
491
|
+
if (titles.length === 0) return null;
|
|
492
|
+
const title = textContent(titles[0]).trim();
|
|
493
|
+
if (!title) throw new Error(`${file}: <title> must not be empty.`);
|
|
494
|
+
return title;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function pageDescription(document: HtmlNode, file: string): string | null {
|
|
498
|
+
return (
|
|
499
|
+
contentOfUniqueMeta(document, 'property', 'og:description', file) ??
|
|
500
|
+
contentOfUniqueMeta(document, 'name', 'description', file)
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function canonicalUrl(document: HtmlNode, file: string): URL {
|
|
505
|
+
const canonicals = elements(document, 'link').filter((node) =>
|
|
506
|
+
(attr(node, 'rel') ?? '')
|
|
507
|
+
.toLowerCase()
|
|
508
|
+
.split(/\s+/u)
|
|
509
|
+
.includes('canonical'),
|
|
510
|
+
);
|
|
511
|
+
if (canonicals.length !== 1) {
|
|
512
|
+
throw new Error(`${file}: generated OG cards require exactly one rendered canonical link.`);
|
|
513
|
+
}
|
|
514
|
+
const href = attr(canonicals[0], 'href')?.trim() ?? '';
|
|
515
|
+
return absoluteHttpUrl(href, 'canonical href', file);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Normalize whitespace and truncate by Unicode code point at a word boundary. */
|
|
519
|
+
function clampText(value: string, limit: number): string {
|
|
520
|
+
const normalized = value.replace(/\s+/gu, ' ').trim();
|
|
521
|
+
const points = Array.from(normalized);
|
|
522
|
+
if (points.length <= limit) return normalized;
|
|
523
|
+
|
|
524
|
+
const budget = limit - 1;
|
|
525
|
+
const prefix = points.slice(0, budget).join('');
|
|
526
|
+
const boundary = prefix.lastIndexOf(' ');
|
|
527
|
+
const wordSafe = boundary >= Math.floor(budget * 0.6)
|
|
528
|
+
? prefix.slice(0, boundary)
|
|
529
|
+
: prefix;
|
|
530
|
+
return `${wordSafe.replace(/[\s,.;:!?\-–—]+$/u, '')}…`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function payloadForPage(
|
|
534
|
+
page: ParsedPage,
|
|
535
|
+
options: OgCardsIntegrationOptions,
|
|
536
|
+
identity: PageIdentity,
|
|
537
|
+
): CardPayload {
|
|
538
|
+
const rawTitle = uniqueTitle(page.document, page.file);
|
|
539
|
+
if (!rawTitle) throw new Error(`${page.file}: generated OG cards require og:title or <title>.`);
|
|
540
|
+
const canonical = canonicalUrl(page.document, page.file);
|
|
541
|
+
const rawBookTitle = identity.book?.title ?? options.title ?? 'book-scaffold-astro';
|
|
542
|
+
const rawDescription =
|
|
543
|
+
pageDescription(page.document, page.file) ??
|
|
544
|
+
identity.book?.description ??
|
|
545
|
+
options.description ??
|
|
546
|
+
'';
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
templateVersion: TEMPLATE_VERSION,
|
|
550
|
+
width: CARD_WIDTH,
|
|
551
|
+
height: CARD_HEIGHT,
|
|
552
|
+
profile: options.profile,
|
|
553
|
+
bookId: identity.book?.id ?? null,
|
|
554
|
+
bookTitle: clampText(rawBookTitle, BOOK_TITLE_LIMIT),
|
|
555
|
+
title: clampText(rawTitle, TITLE_LIMIT),
|
|
556
|
+
description: clampText(rawDescription, DESCRIPTION_LIMIT),
|
|
557
|
+
hostname: clampText(canonical.hostname.toLowerCase(), HOSTNAME_LIMIT),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function stablePayloadJson(payload: CardPayload): string {
|
|
562
|
+
// CardPayload is constructed in this exact property order. JSON is the hash
|
|
563
|
+
// input so template changes remain explicit and reviewable.
|
|
564
|
+
return JSON.stringify(payload);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function shortHash(payloadJson: string): string {
|
|
568
|
+
return createHash('sha256').update(payloadJson, 'utf8').digest('hex').slice(0, 16);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function htmlEscape(value: string): string {
|
|
572
|
+
return value
|
|
573
|
+
.replaceAll('&', '&')
|
|
574
|
+
.replaceAll('"', '"')
|
|
575
|
+
.replaceAll('<', '<')
|
|
576
|
+
.replaceAll('>', '>');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function insertIntoHead(page: ParsedPage, source: string, tags: readonly string[]): string {
|
|
580
|
+
if (tags.length === 0) return source;
|
|
581
|
+
const insertionOffset = page.head.sourceCodeLocation?.endTag?.startOffset;
|
|
582
|
+
if (insertionOffset === undefined) {
|
|
583
|
+
throw new Error(`${page.file}: cannot locate </head> for OG metadata insertion.`);
|
|
584
|
+
}
|
|
585
|
+
const block = `${tags.map((tag) => ` ${tag}`).join('\n')}\n`;
|
|
586
|
+
return `${source.slice(0, insertionOffset)}${block}${source.slice(insertionOffset)}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function ensureNoDuplicateImageMetadata(document: HtmlNode, file: string): void {
|
|
590
|
+
for (const [attribute, value] of [
|
|
591
|
+
['property', 'og:image:width'],
|
|
592
|
+
['property', 'og:image:height'],
|
|
593
|
+
['property', 'og:image:type'],
|
|
594
|
+
['name', 'twitter:image'],
|
|
595
|
+
] as const) {
|
|
596
|
+
const matches = metas(document, attribute, value);
|
|
597
|
+
if (matches.length > 1) {
|
|
598
|
+
throw new Error(`${file}: duplicate ${value} metadata is ambiguous.`);
|
|
599
|
+
}
|
|
600
|
+
if (matches.length === 1 && !(attr(matches[0], 'content') ?? '').trim()) {
|
|
601
|
+
throw new Error(`${file}: ${value} metadata requires non-empty content.`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function staticImageTags(page: ParsedPage, imageUrl: string): string[] {
|
|
607
|
+
ensureNoDuplicateImageMetadata(page.document, page.file);
|
|
608
|
+
const tags = [
|
|
609
|
+
`<meta property="og:image" content="${htmlEscape(imageUrl)}">`,
|
|
610
|
+
];
|
|
611
|
+
if (metas(page.document, 'name', 'twitter:image').length === 0) {
|
|
612
|
+
tags.push(`<meta name="twitter:image" content="${htmlEscape(imageUrl)}">`);
|
|
613
|
+
}
|
|
614
|
+
return tags;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function generatedImageTags(page: ParsedPage, imageUrl: string): string[] {
|
|
618
|
+
ensureNoDuplicateImageMetadata(page.document, page.file);
|
|
619
|
+
const expected = [
|
|
620
|
+
{ attribute: 'property' as const, name: 'og:image:width', content: String(CARD_WIDTH) },
|
|
621
|
+
{ attribute: 'property' as const, name: 'og:image:height', content: String(CARD_HEIGHT) },
|
|
622
|
+
{ attribute: 'property' as const, name: 'og:image:type', content: 'image/png' },
|
|
623
|
+
{ attribute: 'name' as const, name: 'twitter:image', content: imageUrl },
|
|
624
|
+
];
|
|
625
|
+
const tags = [`<meta property="og:image" content="${htmlEscape(imageUrl)}">`];
|
|
626
|
+
|
|
627
|
+
for (const item of expected) {
|
|
628
|
+
const matches = metas(page.document, item.attribute, item.name);
|
|
629
|
+
if (matches.length === 1) {
|
|
630
|
+
const actual = attr(matches[0], 'content')?.trim() ?? '';
|
|
631
|
+
if (actual !== item.content) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`${page.file}: existing ${item.name}=${JSON.stringify(actual)} conflicts with generated value ${JSON.stringify(item.content)}.`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
tags.push(
|
|
639
|
+
`<meta ${item.attribute}="${item.name}" content="${htmlEscape(item.content)}">`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
return tags;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function routeForHtml(outputDir: string, file: string): string {
|
|
646
|
+
const local = relative(outputDir, file).split(sep).join('/');
|
|
647
|
+
if (local === 'index.html') return '/';
|
|
648
|
+
if (local.endsWith('/index.html')) return `/${local.slice(0, -'index.html'.length)}`;
|
|
649
|
+
return `/${local}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function htmlFiles(outputDir: string): Promise<string[]> {
|
|
653
|
+
const files: string[] = [];
|
|
654
|
+
async function visit(directory: string): Promise<void> {
|
|
655
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
656
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
|
657
|
+
for (const entry of entries) {
|
|
658
|
+
const target = join(directory, entry.name);
|
|
659
|
+
if (entry.isDirectory()) await visit(target);
|
|
660
|
+
else if (entry.isFile() && extname(entry.name).toLowerCase() === '.html') files.push(target);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
await visit(outputDir);
|
|
664
|
+
return files.sort((left, right) =>
|
|
665
|
+
routeForHtml(outputDir, left).localeCompare(routeForHtml(outputDir, right), 'en'),
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function cardTree(payload: CardPayload, theme: Theme): Record<string, unknown> {
|
|
670
|
+
const bookLength = Array.from(payload.bookTitle).length;
|
|
671
|
+
const titleLength = Array.from(payload.title).length;
|
|
672
|
+
const descriptionLength = Array.from(payload.description).length;
|
|
673
|
+
const hostnameLength = Array.from(payload.hostname).length;
|
|
674
|
+
const children: Record<string, unknown>[] = [
|
|
675
|
+
{
|
|
676
|
+
type: 'div',
|
|
677
|
+
key: 'og-book',
|
|
678
|
+
props: {
|
|
679
|
+
style: {
|
|
680
|
+
display: 'flex',
|
|
681
|
+
alignItems: 'center',
|
|
682
|
+
width: '100%',
|
|
683
|
+
maxWidth: 1056,
|
|
684
|
+
flexShrink: 0,
|
|
685
|
+
fontSize: bookLength > 56 ? 22 : 25,
|
|
686
|
+
fontWeight: 700,
|
|
687
|
+
lineHeight: 1.15,
|
|
688
|
+
letterSpacing: '-0.01em',
|
|
689
|
+
wordBreak: 'break-word',
|
|
690
|
+
color: '#F7F5F0',
|
|
691
|
+
},
|
|
692
|
+
children: payload.bookTitle,
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
type: 'div',
|
|
697
|
+
key: 'og-title',
|
|
698
|
+
props: {
|
|
699
|
+
style: {
|
|
700
|
+
display: 'flex',
|
|
701
|
+
width: '100%',
|
|
702
|
+
marginTop: 28,
|
|
703
|
+
maxWidth: 1040,
|
|
704
|
+
flexShrink: 0,
|
|
705
|
+
fontSize: titleLength > 80 ? 42 : titleLength > 60 ? 48 : titleLength > 40 ? 54 : 62,
|
|
706
|
+
fontWeight: 700,
|
|
707
|
+
lineHeight: 1.08,
|
|
708
|
+
letterSpacing: '-0.035em',
|
|
709
|
+
wordBreak: 'break-word',
|
|
710
|
+
color: '#FDFCF9',
|
|
711
|
+
},
|
|
712
|
+
children: payload.title,
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
];
|
|
716
|
+
|
|
717
|
+
if (payload.description) {
|
|
718
|
+
children.push({
|
|
719
|
+
type: 'div',
|
|
720
|
+
key: 'og-description',
|
|
721
|
+
props: {
|
|
722
|
+
style: {
|
|
723
|
+
display: 'flex',
|
|
724
|
+
width: '100%',
|
|
725
|
+
marginTop: 22,
|
|
726
|
+
maxWidth: 1010,
|
|
727
|
+
flexShrink: 0,
|
|
728
|
+
fontSize: descriptionLength > 140 ? 20 : descriptionLength > 90 ? 22 : 25,
|
|
729
|
+
lineHeight: 1.3,
|
|
730
|
+
wordBreak: 'break-word',
|
|
731
|
+
color: '#E8E5DD',
|
|
732
|
+
},
|
|
733
|
+
children: payload.description,
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
children.push({
|
|
739
|
+
type: 'div',
|
|
740
|
+
key: 'og-footer',
|
|
741
|
+
props: {
|
|
742
|
+
style: {
|
|
743
|
+
display: 'flex',
|
|
744
|
+
width: '100%',
|
|
745
|
+
flexShrink: 0,
|
|
746
|
+
justifyContent: 'space-between',
|
|
747
|
+
alignItems: 'flex-end',
|
|
748
|
+
gap: 32,
|
|
749
|
+
marginTop: 'auto',
|
|
750
|
+
lineHeight: 1.15,
|
|
751
|
+
color: '#E8E5DD',
|
|
752
|
+
},
|
|
753
|
+
children: [
|
|
754
|
+
{
|
|
755
|
+
type: 'div',
|
|
756
|
+
key: 'og-hostname',
|
|
757
|
+
props: {
|
|
758
|
+
style: {
|
|
759
|
+
display: 'flex',
|
|
760
|
+
width: 640,
|
|
761
|
+
minWidth: 0,
|
|
762
|
+
flexShrink: 1,
|
|
763
|
+
fontSize: hostnameLength > 60 ? 16 : hostnameLength > 40 ? 18 : 20,
|
|
764
|
+
lineHeight: 1.15,
|
|
765
|
+
wordBreak: 'break-all',
|
|
766
|
+
},
|
|
767
|
+
children: payload.hostname,
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
type: 'div',
|
|
772
|
+
key: 'og-profile',
|
|
773
|
+
props: {
|
|
774
|
+
style: {
|
|
775
|
+
display: 'flex',
|
|
776
|
+
width: 384,
|
|
777
|
+
maxWidth: 384,
|
|
778
|
+
flexShrink: 0,
|
|
779
|
+
justifyContent: 'flex-end',
|
|
780
|
+
fontSize: 17,
|
|
781
|
+
fontWeight: 700,
|
|
782
|
+
lineHeight: 1.15,
|
|
783
|
+
letterSpacing: '0.08em',
|
|
784
|
+
textAlign: 'right',
|
|
785
|
+
textTransform: 'uppercase',
|
|
786
|
+
wordBreak: 'break-word',
|
|
787
|
+
color: '#F7F5F0',
|
|
788
|
+
},
|
|
789
|
+
children: `BOOK SCAFFOLD · ${payload.profile.replaceAll('-', ' ')}`,
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
],
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
type: 'div',
|
|
798
|
+
key: 'og-root',
|
|
799
|
+
props: {
|
|
800
|
+
style: {
|
|
801
|
+
display: 'flex',
|
|
802
|
+
flexDirection: 'column',
|
|
803
|
+
width: CARD_WIDTH,
|
|
804
|
+
height: CARD_HEIGHT,
|
|
805
|
+
padding: '66px 72px 58px',
|
|
806
|
+
borderTop: `14px solid ${theme.accent}`,
|
|
807
|
+
backgroundColor: theme.background,
|
|
808
|
+
color: '#FDFCF9',
|
|
809
|
+
fontFamily: 'Inter',
|
|
810
|
+
},
|
|
811
|
+
children,
|
|
812
|
+
},
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function validateCardLayout(boxes: ReadonlyMap<string, CardLayoutBox>): void {
|
|
817
|
+
const required = ['og-root', 'og-book', 'og-title', 'og-footer', 'og-hostname', 'og-profile'];
|
|
818
|
+
for (const role of required) {
|
|
819
|
+
if (!boxes.has(role)) throw new Error(`OG renderer did not report the ${role} layout box.`);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const epsilon = 0.01;
|
|
823
|
+
for (const [role, box] of boxes) {
|
|
824
|
+
if (
|
|
825
|
+
!Number.isFinite(box.left)
|
|
826
|
+
|| !Number.isFinite(box.top)
|
|
827
|
+
|| !Number.isFinite(box.width)
|
|
828
|
+
|| !Number.isFinite(box.height)
|
|
829
|
+
|| box.left < -epsilon
|
|
830
|
+
|| box.top < -epsilon
|
|
831
|
+
|| box.left + box.width > CARD_WIDTH + epsilon
|
|
832
|
+
|| box.top + box.height > CARD_HEIGHT + epsilon
|
|
833
|
+
) {
|
|
834
|
+
throw new Error(`OG renderer placed ${role} outside the ${CARD_WIDTH}x${CARD_HEIGHT} card.`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const footer = boxes.get('og-footer')!;
|
|
839
|
+
const contentBottom = Math.max(
|
|
840
|
+
boxes.get('og-book')!.top + boxes.get('og-book')!.height,
|
|
841
|
+
boxes.get('og-title')!.top + boxes.get('og-title')!.height,
|
|
842
|
+
...(boxes.has('og-description')
|
|
843
|
+
? [boxes.get('og-description')!.top + boxes.get('og-description')!.height]
|
|
844
|
+
: []),
|
|
845
|
+
);
|
|
846
|
+
if (contentBottom > footer.top + epsilon) {
|
|
847
|
+
throw new Error('OG renderer content overlaps the footer; shorten or resize the card template.');
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const hostname = boxes.get('og-hostname')!;
|
|
851
|
+
const profile = boxes.get('og-profile')!;
|
|
852
|
+
if (hostname.left + hostname.width > profile.left + epsilon) {
|
|
853
|
+
throw new Error('OG renderer hostname overlaps the profile mark.');
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function readFont(name: string): Promise<Buffer> {
|
|
858
|
+
const candidates = [
|
|
859
|
+
new URL(`../../assets/og-fonts/${name}`, import.meta.url),
|
|
860
|
+
new URL(`../assets/og-fonts/${name}`, import.meta.url),
|
|
861
|
+
];
|
|
862
|
+
for (const candidate of candidates) {
|
|
863
|
+
try {
|
|
864
|
+
return await readFile(candidate);
|
|
865
|
+
} catch (error) {
|
|
866
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
throw new Error(`Cannot locate package-owned OG font asset ${name}.`);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function renderCard(payload: CardPayload): Promise<Buffer> {
|
|
873
|
+
const [{ default: satori }, { Resvg }, regular, bold] = await Promise.all([
|
|
874
|
+
import('satori'),
|
|
875
|
+
loadResvg(),
|
|
876
|
+
readFont('Inter-Regular.ttf'),
|
|
877
|
+
readFont('Inter-Bold.ttf'),
|
|
878
|
+
]);
|
|
879
|
+
const boxes = new Map<string, CardLayoutBox>();
|
|
880
|
+
const svg = await satori(cardTree(payload, PROFILE_THEMES[payload.profile]), {
|
|
881
|
+
width: CARD_WIDTH,
|
|
882
|
+
height: CARD_HEIGHT,
|
|
883
|
+
fonts: [
|
|
884
|
+
{ name: 'Inter', data: regular, weight: 400, style: 'normal' },
|
|
885
|
+
{ name: 'Inter', data: bold, weight: 700, style: 'normal' },
|
|
886
|
+
],
|
|
887
|
+
onNodeDetected(node) {
|
|
888
|
+
if (typeof node.key !== 'string' || !node.key.startsWith('og-')) return;
|
|
889
|
+
if (boxes.has(node.key)) throw new Error(`OG renderer reported duplicate ${node.key} layout boxes.`);
|
|
890
|
+
boxes.set(node.key, {
|
|
891
|
+
left: node.left,
|
|
892
|
+
top: node.top,
|
|
893
|
+
width: node.width,
|
|
894
|
+
height: node.height,
|
|
895
|
+
});
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
validateCardLayout(boxes);
|
|
899
|
+
const png = new Resvg(svg, {
|
|
900
|
+
fitTo: { mode: 'width', value: CARD_WIDTH },
|
|
901
|
+
}).render().asPng();
|
|
902
|
+
return Buffer.from(png);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function loadResvg(): Promise<typeof import('@resvg/resvg-js')> {
|
|
906
|
+
try {
|
|
907
|
+
return await import('@resvg/resvg-js');
|
|
908
|
+
} catch (error) {
|
|
909
|
+
throw new Error(
|
|
910
|
+
'Generated OG cards could not load the @resvg/resvg-js platform binding. ' +
|
|
911
|
+
'Reinstall dependencies for this platform without `--omit=optional` and rebuild.',
|
|
912
|
+
{ cause: error },
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function writeCollisionSafeImage(file: string, bytes: Buffer): Promise<void> {
|
|
918
|
+
let info;
|
|
919
|
+
try {
|
|
920
|
+
info = await lstat(file);
|
|
921
|
+
} catch (error) {
|
|
922
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
923
|
+
await writeFile(file, bytes, { flag: 'wx' });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
927
|
+
throw new Error(`${file}: OG card output must be a regular file, never a symlink.`);
|
|
928
|
+
}
|
|
929
|
+
const existing = await readFile(file);
|
|
930
|
+
if (!existing.equals(bytes)) {
|
|
931
|
+
throw new Error(`${file}: OG content hash collision produced different PNG bytes.`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async function safeOgDirectory(outputDir: string, create: boolean): Promise<string | null> {
|
|
936
|
+
const ogDir = join(outputDir, '_og');
|
|
937
|
+
let info;
|
|
938
|
+
try {
|
|
939
|
+
info = await lstat(ogDir);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
942
|
+
if (!create) return null;
|
|
943
|
+
await mkdir(ogDir, { recursive: true });
|
|
944
|
+
info = await lstat(ogDir);
|
|
945
|
+
}
|
|
946
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
947
|
+
throw new Error(`${ogDir}: OG card output must be a real directory, never a symlink.`);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const [outputReal, ogReal] = await Promise.all([realpath(outputDir), realpath(ogDir)]);
|
|
951
|
+
if (ogReal !== join(outputReal, '_og')) {
|
|
952
|
+
throw new Error(`${ogDir}: OG card output resolved outside the Astro output directory.`);
|
|
953
|
+
}
|
|
954
|
+
return ogDir;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function pruneStaleCards(
|
|
958
|
+
ogDir: string | null,
|
|
959
|
+
expectedHashes: ReadonlySet<string>,
|
|
960
|
+
): Promise<number> {
|
|
961
|
+
if (ogDir === null) return 0;
|
|
962
|
+
const entries = await readdir(ogDir, { withFileTypes: true });
|
|
963
|
+
|
|
964
|
+
let removed = 0;
|
|
965
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
|
966
|
+
for (const entry of entries) {
|
|
967
|
+
const match = /^([a-f\d]{16})\.png$/u.exec(entry.name);
|
|
968
|
+
if (!match || expectedHashes.has(match[1])) continue;
|
|
969
|
+
if (entry.isSymbolicLink()) {
|
|
970
|
+
throw new Error(`${join(ogDir, entry.name)}: scaffold-owned OG filenames must not be symlinks.`);
|
|
971
|
+
}
|
|
972
|
+
if (!entry.isFile()) continue;
|
|
973
|
+
await unlink(join(ogDir, entry.name));
|
|
974
|
+
removed += 1;
|
|
975
|
+
}
|
|
976
|
+
return removed;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function processBuild(
|
|
980
|
+
outputDir: string,
|
|
981
|
+
site: URL,
|
|
982
|
+
base: string,
|
|
983
|
+
options: OgCardsIntegrationOptions,
|
|
984
|
+
): Promise<{
|
|
985
|
+
generated: number;
|
|
986
|
+
reused: number;
|
|
987
|
+
staticImages: number;
|
|
988
|
+
skipped: number;
|
|
989
|
+
pruned: number;
|
|
990
|
+
}> {
|
|
991
|
+
const matchers = options.ogCards.exclude.map(exclusionMatcher);
|
|
992
|
+
const generatedPlans: PlannedCard[] = [];
|
|
993
|
+
const staticPlans: PlannedStaticImage[] = [];
|
|
994
|
+
let skipped = 0;
|
|
995
|
+
|
|
996
|
+
for (const file of await htmlFiles(outputDir)) {
|
|
997
|
+
const route = routeForHtml(outputDir, file);
|
|
998
|
+
if (isBuiltInErrorRoute(route) || matchers.some((matcher) => matcher.test(route))) {
|
|
999
|
+
skipped += 1;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const source = await readFile(file, 'utf8');
|
|
1004
|
+
const page = parsePage(source, file, route);
|
|
1005
|
+
if (isRedirect(page.document) || isNoIndex(page.document)) {
|
|
1006
|
+
skipped += 1;
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (existingAuthoredOgImage(page.document, file)) {
|
|
1010
|
+
skipped += 1;
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const identity = resolvePageIdentity(page.document, file, options.corpus);
|
|
1015
|
+
const staticImage = identity.book?.image ?? options.staticOgImage;
|
|
1016
|
+
if (staticImage !== undefined) {
|
|
1017
|
+
staticPlans.push({
|
|
1018
|
+
page,
|
|
1019
|
+
source,
|
|
1020
|
+
imageUrl: resolveStaticImageUrl(staticImage, site, base),
|
|
1021
|
+
});
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const payload = payloadForPage(page, options, identity);
|
|
1026
|
+
const payloadJson = stablePayloadJson(payload);
|
|
1027
|
+
const hash = shortHash(payloadJson);
|
|
1028
|
+
generatedPlans.push({
|
|
1029
|
+
page,
|
|
1030
|
+
source,
|
|
1031
|
+
payload,
|
|
1032
|
+
payloadJson,
|
|
1033
|
+
hash,
|
|
1034
|
+
imageUrl: cardUrl(site, base, hash),
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Catch a truncated SHA-256 collision before rendering or mutating output.
|
|
1039
|
+
const payloadByHash = new Map<string, string>();
|
|
1040
|
+
for (const plan of generatedPlans) {
|
|
1041
|
+
const previous = payloadByHash.get(plan.hash);
|
|
1042
|
+
if (previous !== undefined && previous !== plan.payloadJson) {
|
|
1043
|
+
throw new Error(`OG payload hash collision at ${plan.hash}; refusing to overwrite card output.`);
|
|
1044
|
+
}
|
|
1045
|
+
payloadByHash.set(plan.hash, plan.payloadJson);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const ogDir = await safeOgDirectory(outputDir, generatedPlans.length > 0);
|
|
1049
|
+
const rendered = new Map<string, Buffer>();
|
|
1050
|
+
let reused = 0;
|
|
1051
|
+
for (const plan of generatedPlans) {
|
|
1052
|
+
if (rendered.has(plan.hash)) {
|
|
1053
|
+
reused += 1;
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
const bytes = await renderCard(plan.payload);
|
|
1057
|
+
if (bytes.length === 0) throw new Error(`${plan.page.file}: OG renderer returned an empty PNG.`);
|
|
1058
|
+
await writeCollisionSafeImage(join(ogDir!, `${plan.hash}.png`), bytes);
|
|
1059
|
+
rendered.set(plan.hash, bytes);
|
|
1060
|
+
}
|
|
1061
|
+
const pruned = await pruneStaleCards(ogDir, new Set(generatedPlans.map(({ hash }) => hash)));
|
|
1062
|
+
|
|
1063
|
+
// Patch after every requested image exists. Only append absent tags; parse5
|
|
1064
|
+
// is an inspector, never a serializer, so consumer HTML bytes stay stable.
|
|
1065
|
+
for (const plan of staticPlans) {
|
|
1066
|
+
const tags = staticImageTags(plan.page, plan.imageUrl);
|
|
1067
|
+
await writeFile(plan.page.file, insertIntoHead(plan.page, plan.source, tags), 'utf8');
|
|
1068
|
+
}
|
|
1069
|
+
for (const plan of generatedPlans) {
|
|
1070
|
+
const tags = generatedImageTags(plan.page, plan.imageUrl);
|
|
1071
|
+
await writeFile(plan.page.file, insertIntoHead(plan.page, plan.source, tags), 'utf8');
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return {
|
|
1075
|
+
generated: rendered.size,
|
|
1076
|
+
reused,
|
|
1077
|
+
staticImages: staticPlans.length,
|
|
1078
|
+
skipped,
|
|
1079
|
+
pruned,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Create the last-in-chain Astro integration that patches fully rendered
|
|
1085
|
+
* static HTML and emits package-owned deterministic PNGs.
|
|
1086
|
+
*/
|
|
1087
|
+
export function createOgCardsIntegration(options: OgCardsIntegrationOptions): AstroIntegration {
|
|
1088
|
+
if (!options.ogCards?.enabled) {
|
|
1089
|
+
throw new Error('createOgCardsIntegration requires an enabled normalized ogCards config.');
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let site: URL | null = null;
|
|
1093
|
+
let base = '/';
|
|
1094
|
+
let output: string | null = null;
|
|
1095
|
+
|
|
1096
|
+
return {
|
|
1097
|
+
name: 'book-scaffold-og-cards',
|
|
1098
|
+
hooks: {
|
|
1099
|
+
'astro:config:done': ({ config }) => {
|
|
1100
|
+
output = config.output;
|
|
1101
|
+
base = normalizeBase(config.base);
|
|
1102
|
+
if (output !== 'static') {
|
|
1103
|
+
throw new Error(
|
|
1104
|
+
`Generated OG cards require Astro output "static" (received ${JSON.stringify(output)}).`,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
if (!config.site) {
|
|
1108
|
+
throw new Error('Generated OG cards require an absolute http(s) Astro site URL.');
|
|
1109
|
+
}
|
|
1110
|
+
site = new URL(config.site);
|
|
1111
|
+
if ((site.protocol !== 'http:' && site.protocol !== 'https:') || !site.hostname) {
|
|
1112
|
+
throw new Error('Generated OG cards require an absolute http(s) Astro site URL.');
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
'astro:build:done': async ({ dir, logger }) => {
|
|
1116
|
+
if (output !== 'static' || !site) {
|
|
1117
|
+
throw new Error(
|
|
1118
|
+
'Generated OG cards were not initialized with a supported static Astro site configuration.',
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
const outputDir = fileURLToPath(dir);
|
|
1122
|
+
const info = await stat(outputDir);
|
|
1123
|
+
if (!info.isDirectory()) {
|
|
1124
|
+
throw new Error(`Generated OG card output is not a directory: ${outputDir}`);
|
|
1125
|
+
}
|
|
1126
|
+
const result = await processBuild(outputDir, site, base, options);
|
|
1127
|
+
logger.info(
|
|
1128
|
+
`OG cards: ${result.generated} generated, ${result.reused} reused, ` +
|
|
1129
|
+
`${result.staticImages} static-precedence, ${result.skipped} skipped, ` +
|
|
1130
|
+
`${result.pruned} stale pruned.`,
|
|
1131
|
+
);
|
|
1132
|
+
},
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
1135
|
+
}
|