@farcaster/snap-hono 1.1.8 → 1.2.1

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.
@@ -0,0 +1,628 @@
1
+ import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX } from "@farcaster/snap";
2
+ import satori from "satori";
3
+ import { Resvg, initWasm } from "@resvg/resvg-wasm";
4
+ /** Content width inside the OG card (`OG_CARD_OUTER_WIDTH_PX` − horizontal padding 24px×2). */
5
+ const OG_CARD_INNER_WIDTH_PX = 412;
6
+ /** White card width (border-box), matches in-app snap preview (~420px). */
7
+ const OG_CARD_OUTER_WIDTH_PX = 460;
8
+ /** Gray margin between PNG edge and card on all sides. */
9
+ const OG_OUTER_MARGIN_PX = 24;
10
+ const OG_CARD_PADDING_PX = 24;
11
+ const OG_ELEMENT_GAP_PX = 12;
12
+ const OG_BUTTONS_TOP_GAP_PX = 12;
13
+ /** Padding to avoid Yoga/Satori rounding clipping stacked content. */
14
+ const OG_HEIGHT_SAFETY_PX = 8;
15
+ const OG_MIN_HEIGHT_PX = 200;
16
+ const OG_MAX_HEIGHT_PX = 2400;
17
+ const DEFAULT_OG_WIDTH_PX = OG_CARD_OUTER_WIDTH_PX + 2 * OG_OUTER_MARGIN_PX;
18
+ // ─── ETag ────────────────────────────────────────────────
19
+ export function etagForPage(snapJson) {
20
+ let h = 0;
21
+ for (let i = 0; i < snapJson.length; i++) {
22
+ h = (Math.imul(31, h) + snapJson.charCodeAt(i)) | 0;
23
+ }
24
+ return `"snap-${(h >>> 0).toString(36)}"`;
25
+ }
26
+ // ─── Singleflight ─────────────────────────────────────────
27
+ const inflight = new Map();
28
+ export async function renderWithDedup(key, render) {
29
+ const existing = inflight.get(key);
30
+ if (existing)
31
+ return existing;
32
+ const promise = render().finally(() => inflight.delete(key));
33
+ inflight.set(key, promise);
34
+ return promise;
35
+ }
36
+ // ─── SSRF-safe image fetching ─────────────────────────────
37
+ const IMAGE_FETCH_TIMEOUT_MS = 3000;
38
+ const IMAGE_MAX_BYTES = 2 * 1024 * 1024;
39
+ async function safeFetchImage(url) {
40
+ try {
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
43
+ const res = await fetch(url, {
44
+ signal: controller.signal,
45
+ redirect: "follow",
46
+ });
47
+ clearTimeout(timer);
48
+ if (!res.ok)
49
+ return null;
50
+ const cl = res.headers.get("content-length");
51
+ if (cl && parseInt(cl, 10) > IMAGE_MAX_BYTES)
52
+ return null;
53
+ const buf = await res.arrayBuffer();
54
+ if (buf.byteLength > IMAGE_MAX_BYTES)
55
+ return null;
56
+ const mime = res.headers.get("content-type") ?? "image/png";
57
+ const bytes = new Uint8Array(buf);
58
+ let binary = "";
59
+ for (let i = 0; i < bytes.length; i++)
60
+ binary += String.fromCharCode(bytes[i]);
61
+ return `data:${mime};base64,${btoa(binary)}`;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ // ─── Font loading ──────────────────────────────────────────
68
+ const fontCache = new Map();
69
+ // Satori requires TTF, OTF, or WOFF (v1). WOFF2 is not supported by its
70
+ // underlying opentype.js parser. @fontsource/inter v4 ships WOFF v1 files.
71
+ const CDN_FONT_URL_400 = "https://cdn.jsdelivr.net/npm/@fontsource/inter@4.5.15/files/inter-latin-400-normal.woff";
72
+ const CDN_FONT_URL_700 = "https://cdn.jsdelivr.net/npm/@fontsource/inter@4.5.15/files/inter-latin-700-normal.woff";
73
+ let cdnFontData400;
74
+ let cdnFontData700;
75
+ async function tryLoadFontFromPath(path) {
76
+ try {
77
+ const { readFile } = await import("node:fs/promises");
78
+ const cached = fontCache.get(path);
79
+ if (cached)
80
+ return cached;
81
+ const buf = (await readFile(path)).buffer;
82
+ fontCache.set(path, buf);
83
+ return buf;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ async function buildFontList(specs) {
90
+ if (specs && specs.length > 0) {
91
+ const fonts = [];
92
+ for (const spec of specs) {
93
+ const data = await tryLoadFontFromPath(spec.path);
94
+ if (data) {
95
+ fonts.push({
96
+ name: "Inter",
97
+ data,
98
+ weight: spec.weight,
99
+ style: (spec.style ?? "normal"),
100
+ });
101
+ }
102
+ }
103
+ if (fonts.length > 0)
104
+ return fonts;
105
+ }
106
+ // CDN fallback: Inter 400 + 700 so title/labels match the HTML card weights
107
+ if (!cdnFontData400) {
108
+ const res = await fetch(CDN_FONT_URL_400);
109
+ if (!res.ok)
110
+ throw new Error("Failed to load Inter 400 fallback from CDN");
111
+ cdnFontData400 = await res.arrayBuffer();
112
+ }
113
+ if (!cdnFontData700) {
114
+ const res = await fetch(CDN_FONT_URL_700);
115
+ if (!res.ok)
116
+ throw new Error("Failed to load Inter 700 fallback from CDN");
117
+ cdnFontData700 = await res.arrayBuffer();
118
+ }
119
+ return [
120
+ {
121
+ name: "Inter",
122
+ data: cdnFontData400,
123
+ weight: 400,
124
+ style: "normal",
125
+ },
126
+ {
127
+ name: "Inter",
128
+ data: cdnFontData700,
129
+ weight: 700,
130
+ style: "normal",
131
+ },
132
+ ];
133
+ }
134
+ // ─── resvg-wasm init ───────────────────────────────────────
135
+ let resvgInitPromise;
136
+ async function ensureResvg() {
137
+ if (!resvgInitPromise) {
138
+ // In Node.js, resolve wasm from node_modules to avoid network round-trip
139
+ let wasmSource;
140
+ try {
141
+ const { readFile } = await import("node:fs/promises");
142
+ const { createRequire } = await import("node:module");
143
+ const req = createRequire(import.meta.url);
144
+ const wasmPath = req.resolve("@resvg/resvg-wasm/index_bg.wasm");
145
+ wasmSource = (await readFile(wasmPath)).buffer;
146
+ }
147
+ catch {
148
+ wasmSource = fetch("https://cdn.jsdelivr.net/npm/@resvg/resvg-wasm@2.6.2/index_bg.wasm");
149
+ }
150
+ resvgInitPromise = initWasm(wasmSource);
151
+ }
152
+ await resvgInitPromise;
153
+ }
154
+ function h(type, style, ...children) {
155
+ const kids = children.filter((c) => c != null);
156
+ const childValue = kids.length === 0 ? undefined : kids.length === 1 ? kids[0] : kids;
157
+ return {
158
+ type,
159
+ props: childValue !== undefined ? { style, children: childValue } : { style },
160
+ };
161
+ }
162
+ function accentHex(accent) {
163
+ return (PALETTE_LIGHT_HEX[accent] ??
164
+ PALETTE_LIGHT_HEX[DEFAULT_THEME_ACCENT]);
165
+ }
166
+ function colorHex(color, accent) {
167
+ if (!color || color === "accent")
168
+ return accent;
169
+ return PALETTE_LIGHT_HEX[color] ?? accent;
170
+ }
171
+ function mapText(el) {
172
+ const style = el.style;
173
+ const align = el.align ?? "left";
174
+ let content = String(el.content ?? "");
175
+ // Inter WOFF subset: normalize arrows / punctuation so glyphs don't substitute badly in Satori.
176
+ if (style === "caption" || style === "body") {
177
+ content = content
178
+ .replace(/\u2192/g, "->")
179
+ .replace(/\u2190/g, "<-")
180
+ .replace(/\u27a1/gi, "->");
181
+ }
182
+ // Match `renderSnapPage` `renderText` (card HTML): title 20px #111, body/caption/list tones.
183
+ const styleMap = {
184
+ title: { fontSize: 20, fontWeight: 700, color: "#111111", lineHeight: 1.3 },
185
+ body: { fontSize: 15, color: "#374151", lineHeight: 1.5 },
186
+ caption: { fontSize: 13, color: "#9CA3AF", lineHeight: 1.4 },
187
+ label: {
188
+ fontSize: 13,
189
+ fontWeight: 600,
190
+ color: "#6B7280",
191
+ textTransform: "uppercase",
192
+ letterSpacing: "0.5px",
193
+ },
194
+ };
195
+ const ts = styleMap[style] ?? styleMap["body"];
196
+ return h("div", {
197
+ display: "flex",
198
+ width: OG_CARD_INNER_WIDTH_PX,
199
+ textAlign: align,
200
+ ...ts,
201
+ }, content);
202
+ }
203
+ function mapImage(el, imageMap) {
204
+ const url = el.url;
205
+ const dataUri = imageMap.get(url);
206
+ if (dataUri) {
207
+ return h("div", {
208
+ display: "flex",
209
+ borderRadius: 8,
210
+ overflow: "hidden",
211
+ backgroundColor: "#F3F4F6",
212
+ width: "100%",
213
+ }, {
214
+ type: "img",
215
+ props: { src: dataUri, style: { width: "100%", objectFit: "cover" } },
216
+ });
217
+ }
218
+ return h("div", {
219
+ display: "flex",
220
+ borderRadius: 8,
221
+ backgroundColor: "#E5E7EB",
222
+ height: 80,
223
+ width: "100%",
224
+ alignItems: "center",
225
+ justifyContent: "center",
226
+ }, h("div", { display: "flex", fontSize: 12, color: "#9CA3AF" }, "image"));
227
+ }
228
+ function mapDivider() {
229
+ return h("div", {
230
+ display: "flex",
231
+ height: 1,
232
+ backgroundColor: "#E5E7EB",
233
+ width: "100%",
234
+ });
235
+ }
236
+ function mapProgress(el, accent) {
237
+ const value = el.value;
238
+ const max = el.max;
239
+ const color = colorHex(el.color, accent);
240
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
241
+ const labelNode = el.label
242
+ ? h("div", { display: "flex", fontSize: 13, color: "#6B7280" }, String(el.label))
243
+ : null;
244
+ return h("div", { display: "flex", flexDirection: "column", gap: 4 }, labelNode, h("div", {
245
+ display: "flex",
246
+ height: 8,
247
+ backgroundColor: "#E5E7EB",
248
+ borderRadius: 4,
249
+ overflow: "hidden",
250
+ width: "100%",
251
+ }, h("div", {
252
+ display: "flex",
253
+ height: "100%",
254
+ width: `${pct}%`,
255
+ backgroundColor: color,
256
+ borderRadius: 4,
257
+ })));
258
+ }
259
+ function mapList(el) {
260
+ const style = el.style ?? "ordered";
261
+ const items = el.items ?? [];
262
+ const rows = items.slice(0, 4).map((item, i) => {
263
+ const prefix = style === "ordered" ? `${i + 1}.` : style === "unordered" ? "•" : "";
264
+ const prefixNode = prefix !== ""
265
+ ? h("div", { display: "flex", color: "#9CA3AF", fontSize: 13, minWidth: 20 }, prefix)
266
+ : null;
267
+ return h("div", {
268
+ display: "flex",
269
+ flexDirection: "row",
270
+ gap: 8,
271
+ alignItems: "center",
272
+ paddingTop: 6,
273
+ paddingBottom: 6,
274
+ }, prefixNode, h("div", { display: "flex", flex: 1, fontSize: 14, color: "#374151" }, item.content), item.trailing
275
+ ? h("div", { display: "flex", fontSize: 13, color: "#9CA3AF" }, item.trailing)
276
+ : null);
277
+ });
278
+ return h("div", { display: "flex", flexDirection: "column" }, ...rows);
279
+ }
280
+ function mapButtonGroup(el, _accent) {
281
+ const options = el.options ?? [];
282
+ const layout = el.style ?? "row";
283
+ const isRow = layout !== "stack";
284
+ const sliced = options.slice(0, 4);
285
+ const n = sliced.length;
286
+ const gapPx = 8;
287
+ // Match `renderSnapPage` `renderButtonGroup`: white pills, gray border, dark text.
288
+ // Do not use flexGrow/flexBasis for row pills — Yoga in Satori overlaps labels.
289
+ const children = sliced.map((opt) => {
290
+ const pill = {
291
+ display: "flex",
292
+ alignItems: "center",
293
+ justifyContent: "center",
294
+ paddingTop: 10,
295
+ paddingBottom: 10,
296
+ paddingLeft: 12,
297
+ paddingRight: 12,
298
+ borderRadius: 8,
299
+ border: "1px solid #E5E7EB",
300
+ backgroundColor: "#FFFFFF",
301
+ color: "#374151",
302
+ fontSize: 14,
303
+ fontWeight: 400,
304
+ overflow: "hidden",
305
+ textAlign: "center",
306
+ boxSizing: "border-box",
307
+ flexShrink: 0,
308
+ };
309
+ if (isRow && n > 0) {
310
+ const totalGaps = (n - 1) * gapPx;
311
+ pill.width = Math.floor((OG_CARD_INNER_WIDTH_PX - totalGaps) / n);
312
+ }
313
+ else {
314
+ pill.width = OG_CARD_INNER_WIDTH_PX;
315
+ pill.alignSelf = "center";
316
+ }
317
+ return h("div", pill, opt);
318
+ });
319
+ return h("div", {
320
+ display: "flex",
321
+ flexDirection: isRow ? "row" : "column",
322
+ alignItems: isRow ? "flex-start" : "stretch",
323
+ width: OG_CARD_INNER_WIDTH_PX,
324
+ gap: gapPx,
325
+ }, ...children);
326
+ }
327
+ function mapBarChart(el, accent) {
328
+ const bars = el.bars ?? [];
329
+ const maxVal = el.max ?? Math.max(...bars.map((b) => b.value), 1);
330
+ const chartDefault = colorHex(el.color, accent);
331
+ const barNodes = bars.slice(0, 6).map((bar) => {
332
+ const color = bar.color !== undefined && bar.color !== ""
333
+ ? colorHex(bar.color, accent)
334
+ : chartDefault;
335
+ const pct = maxVal > 0 ? (bar.value / maxVal) * 100 : 0;
336
+ return h("div", {
337
+ display: "flex",
338
+ flex: 1,
339
+ flexDirection: "column",
340
+ alignItems: "center",
341
+ height: "100%",
342
+ justifyContent: "flex-end",
343
+ }, h("div", { display: "flex", fontSize: 11, color: "#6B7280", marginBottom: 4 }, String(bar.value)), h("div", {
344
+ display: "flex",
345
+ width: "100%",
346
+ height: `${pct}%`,
347
+ backgroundColor: color,
348
+ borderRadius: "4px 4px 0 0",
349
+ minHeight: 4,
350
+ }), h("div", { display: "flex", fontSize: 11, color: "#9CA3AF", marginTop: 4 }, bar.label.slice(0, 12)));
351
+ });
352
+ return h("div", {
353
+ display: "flex",
354
+ flexDirection: "row",
355
+ alignItems: "flex-end",
356
+ gap: 12,
357
+ height: 100,
358
+ width: "100%",
359
+ }, ...barNodes);
360
+ }
361
+ function mapElement(el, accent, imageMap) {
362
+ const type = el.type;
363
+ switch (type) {
364
+ case "text":
365
+ return mapText(el);
366
+ case "image":
367
+ return mapImage(el, imageMap);
368
+ case "divider":
369
+ return mapDivider();
370
+ case "progress":
371
+ return mapProgress(el, accent);
372
+ case "list":
373
+ return mapList(el);
374
+ case "button_group":
375
+ return mapButtonGroup(el, accent);
376
+ case "bar_chart":
377
+ return mapBarChart(el, accent);
378
+ case "group": {
379
+ const children = el.children ?? [];
380
+ const childNodes = children
381
+ .map((c) => mapElement(c, accent, imageMap))
382
+ .filter((n) => n != null);
383
+ return h("div", { display: "flex", flexDirection: "row", gap: 12 }, ...childNodes.map((c) => h("div", { display: "flex", flex: 1 }, c)));
384
+ }
385
+ default:
386
+ return null;
387
+ }
388
+ }
389
+ function mapButton(btn, accent, i) {
390
+ const label = String(btn.label ?? "");
391
+ const style = btn.style ?? (i === 0 ? "primary" : "secondary");
392
+ const isPrimary = style === "primary";
393
+ // Primary CTA: generous vertical padding + minHeight so Satori/Yoga renders a tall tap target
394
+ // (small padding deltas are easy to miss; flexBasis:0 rows can also under-measure height).
395
+ const py = isPrimary ? 18 : 10;
396
+ const btnStyle = {
397
+ display: "flex",
398
+ flexGrow: 1,
399
+ flexShrink: 1,
400
+ flexBasis: 0,
401
+ minWidth: 0,
402
+ alignItems: "center",
403
+ justifyContent: "center",
404
+ paddingTop: py,
405
+ paddingBottom: py,
406
+ paddingLeft: 16,
407
+ paddingRight: 16,
408
+ borderRadius: 10,
409
+ backgroundColor: isPrimary ? accent : "transparent",
410
+ color: isPrimary ? "#fff" : accent,
411
+ border: isPrimary ? "none" : `2px solid ${accent}`,
412
+ fontSize: 14,
413
+ fontWeight: 600,
414
+ boxSizing: "border-box",
415
+ };
416
+ if (isPrimary) {
417
+ btnStyle.minHeight = 52;
418
+ }
419
+ return h("div", btnStyle, label);
420
+ }
421
+ function linesForWrappedText(charCount, innerWidthPx, avgCharPx) {
422
+ if (charCount <= 0)
423
+ return 1;
424
+ const cpl = Math.max(8, Math.floor(innerWidthPx / avgCharPx));
425
+ return Math.max(1, Math.ceil((charCount * 1.12) / cpl));
426
+ }
427
+ function estimateTextHeight(el) {
428
+ const style = el.style ?? "body";
429
+ const content = String(el.content ?? "");
430
+ const w = OG_CARD_INNER_WIDTH_PX;
431
+ switch (style) {
432
+ case "title":
433
+ return linesForWrappedText(content.length, w, 11) * 26;
434
+ case "body":
435
+ return linesForWrappedText(content.length, w, 7.5) * 23;
436
+ case "caption":
437
+ return linesForWrappedText(content.length, w, 7) * 18;
438
+ case "label":
439
+ return linesForWrappedText(content.length, w, 7) * 18;
440
+ default:
441
+ return linesForWrappedText(content.length, w, 7.5) * 23;
442
+ }
443
+ }
444
+ function estimateImageHeight(el, imageMap) {
445
+ const url = el.url;
446
+ if (!imageMap.has(url))
447
+ return 80;
448
+ const aspect = el.aspect ?? "16:9";
449
+ const parts = aspect.split(":").map(Number);
450
+ const aw = parts[0];
451
+ const ah = parts[1];
452
+ if (!aw || !ah || aw <= 0 || ah <= 0) {
453
+ return Math.round((OG_CARD_INNER_WIDTH_PX * 9) / 16);
454
+ }
455
+ return Math.round((OG_CARD_INNER_WIDTH_PX * ah) / aw);
456
+ }
457
+ function estimateProgressHeight(el) {
458
+ const label = el.label ? String(el.label) : "";
459
+ let h = 0;
460
+ if (label) {
461
+ const lines = linesForWrappedText(label.length, OG_CARD_INNER_WIDTH_PX, 7);
462
+ h += lines * 18 + 4;
463
+ }
464
+ h += 8;
465
+ return h;
466
+ }
467
+ function estimateListHeight(el) {
468
+ const items = el.items ?? [];
469
+ const textWidth = OG_CARD_INNER_WIDTH_PX - 28;
470
+ let total = 0;
471
+ for (const item of items.slice(0, 4)) {
472
+ const text = item.content ?? "";
473
+ const lines = linesForWrappedText(text.length, textWidth, 7.5);
474
+ total += 12 + Math.max(20, lines * 21);
475
+ }
476
+ return total;
477
+ }
478
+ function estimateButtonGroupHeight(el) {
479
+ const options = el.options ?? [];
480
+ const layout = el.style ?? "row";
481
+ const isRow = layout !== "stack";
482
+ const sliced = options.slice(0, 4);
483
+ const n = sliced.length;
484
+ if (n === 0)
485
+ return 0;
486
+ const pillW = isRow && n > 0
487
+ ? Math.floor((OG_CARD_INNER_WIDTH_PX - (n - 1) * 8) / n)
488
+ : OG_CARD_INNER_WIDTH_PX;
489
+ const pillHeights = sliced.map((opt) => {
490
+ const lines = linesForWrappedText(opt.length, Math.max(40, pillW - 24), 7.5);
491
+ return Math.max(42, 10 + 10 + lines * 16 + 2);
492
+ });
493
+ if (isRow)
494
+ return Math.max(...pillHeights);
495
+ return pillHeights.reduce((a, b) => a + b, 0) + (n - 1) * 8;
496
+ }
497
+ function estimateElementHeight(el, imageMap) {
498
+ const type = el.type;
499
+ switch (type) {
500
+ case "text":
501
+ return estimateTextHeight(el);
502
+ case "image":
503
+ return estimateImageHeight(el, imageMap);
504
+ case "divider":
505
+ return 1;
506
+ case "progress":
507
+ return estimateProgressHeight(el);
508
+ case "list":
509
+ return estimateListHeight(el);
510
+ case "button_group":
511
+ return estimateButtonGroupHeight(el);
512
+ case "bar_chart":
513
+ return 100;
514
+ case "group": {
515
+ const children = el.children ?? [];
516
+ if (children.length === 0)
517
+ return 0;
518
+ return Math.max(...children.map((c) => estimateElementHeight(c, imageMap)));
519
+ }
520
+ default:
521
+ return 0;
522
+ }
523
+ }
524
+ function estimateButtonsBlockHeight(buttons, buttonLayout) {
525
+ if (buttons.length === 0)
526
+ return 0;
527
+ const isRow = buttonLayout === "row";
528
+ const heights = buttons.map((btn, i) => {
529
+ const style = btn.style ?? (i === 0 ? "primary" : "secondary");
530
+ const isPrimary = style === "primary";
531
+ const label = String(btn.label ?? "");
532
+ const inner = isRow
533
+ ? Math.floor((OG_CARD_INNER_WIDTH_PX - (buttons.length - 1) * 8) / buttons.length)
534
+ : OG_CARD_INNER_WIDTH_PX;
535
+ const lines = linesForWrappedText(label.length, Math.max(40, inner - 24), 7);
536
+ if (isPrimary) {
537
+ return Math.max(52, 18 + 18 + lines * 17);
538
+ }
539
+ return Math.max(44, 10 + 10 + lines * 15 + 4);
540
+ });
541
+ if (isRow) {
542
+ return Math.max(...heights);
543
+ }
544
+ return heights.reduce((a, b) => a + b, 0) + (buttons.length - 1) * 8;
545
+ }
546
+ function estimateDefaultOgHeight(elements, imageMap, buttons, buttonLayout) {
547
+ let innerColumn = 0;
548
+ for (const el of elements) {
549
+ innerColumn += estimateElementHeight(el, imageMap) + OG_ELEMENT_GAP_PX;
550
+ }
551
+ if (buttons.length > 0) {
552
+ innerColumn +=
553
+ OG_BUTTONS_TOP_GAP_PX + estimateButtonsBlockHeight(buttons, buttonLayout);
554
+ }
555
+ const cardH = OG_CARD_PADDING_PX * 2 + innerColumn;
556
+ const outerH = 2 * OG_OUTER_MARGIN_PX + cardH + OG_HEIGHT_SAFETY_PX;
557
+ return Math.min(OG_MAX_HEIGHT_PX, Math.max(OG_MIN_HEIGHT_PX, Math.ceil(outerH)));
558
+ }
559
+ // ─── Main PNG renderer ─────────────────────────────────────
560
+ export async function renderSnapPageToPng(snap, options) {
561
+ const page = snap.page;
562
+ const accent = accentHex(page.theme?.accent);
563
+ // Pre-fetch all image URLs (SSRF-safe)
564
+ const imageUrls = page.elements.children
565
+ .filter((el) => el.type === "image")
566
+ .map((el) => el.url);
567
+ const unique = [...new Set(imageUrls)];
568
+ const fetched = await Promise.all(unique.map(async (url) => [url, await safeFetchImage(url)]));
569
+ const imageMap = new Map(fetched.filter(([, v]) => v != null));
570
+ const buttonLayout = page.button_layout;
571
+ const pageButtons = page.buttons ?? [];
572
+ const W = options?.width ?? DEFAULT_OG_WIDTH_PX;
573
+ const H = options?.height ??
574
+ estimateDefaultOgHeight(page.elements.children, imageMap, pageButtons, buttonLayout);
575
+ // Build element VNodes
576
+ const elementNodes = page.elements.children
577
+ .map((el) => mapElement(el, accent, imageMap))
578
+ .filter((n) => n != null);
579
+ // Build button VNodes
580
+ const buttonNodes = pageButtons.map((btn, i) => mapButton(btn, accent, i));
581
+ const cardChildren = [
582
+ ...elementNodes.map((n) => h("div", {
583
+ display: "flex",
584
+ marginBottom: 12,
585
+ width: OG_CARD_INNER_WIDTH_PX,
586
+ }, n)),
587
+ ...(buttonNodes.length > 0
588
+ ? [
589
+ h("div", {
590
+ display: "flex",
591
+ flexDirection: buttonLayout === "row" ? "row" : "column",
592
+ gap: 8,
593
+ marginTop: 12,
594
+ width: OG_CARD_INNER_WIDTH_PX,
595
+ }, ...buttonNodes),
596
+ ]
597
+ : []),
598
+ ];
599
+ const root = h("div", {
600
+ display: "flex",
601
+ width: W,
602
+ height: H,
603
+ backgroundColor: "#F3F4F6",
604
+ alignItems: "center",
605
+ justifyContent: "center",
606
+ }, h("div", {
607
+ display: "flex",
608
+ flexDirection: "column",
609
+ backgroundColor: "#FFFFFF",
610
+ borderRadius: 16,
611
+ padding: 24,
612
+ width: OG_CARD_OUTER_WIDTH_PX,
613
+ boxSizing: "border-box",
614
+ boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
615
+ }, ...cardChildren));
616
+ // Load fonts (disk paths → CDN fallback)
617
+ const fonts = await buildFontList(options?.fonts);
618
+ // Render SVG via satori (VNode is compatible with satori's element shape)
619
+ const svg = await satori(root, {
620
+ width: W,
621
+ height: H,
622
+ fonts,
623
+ });
624
+ // Convert SVG → PNG via resvg-wasm
625
+ await ensureResvg();
626
+ const renderer = new Resvg(svg, { background: "#F3F4F6" });
627
+ return renderer.render().asPng();
628
+ }
@@ -1,9 +1,9 @@
1
- import { type SnapResponseInput } from "@farcaster/snap";
1
+ import { type SnapHandlerResult } from "@farcaster/snap";
2
2
  type PayloadToResponseOptions = {
3
3
  resourcePath: string;
4
4
  mediaTypes: string[];
5
5
  };
6
- export declare function payloadToResponse(payload: SnapResponseInput, options?: Partial<PayloadToResponseOptions>): Response;
6
+ export declare function payloadToResponse(payload: SnapHandlerResult, options?: Partial<PayloadToResponseOptions>): Response;
7
7
  export declare function snapHeaders(resourcePath: string, currentMediaType: string, availableMediaTypes: string[]): {
8
8
  "Content-Type": string;
9
9
  Vary: string;
@@ -1,9 +1,9 @@
1
- import { MEDIA_TYPE, rootSchema, validatePage, } from "@farcaster/snap";
1
+ import { MEDIA_TYPE, validateSnapResponse, snapResponseSchema, } from "@farcaster/snap";
2
2
  const DEFAULT_LINK_MEDIA_TYPES = [MEDIA_TYPE, "text/html"];
3
3
  export function payloadToResponse(payload, options = {}) {
4
4
  const resourcePath = options.resourcePath ?? "/";
5
5
  const mediaTypes = options.mediaTypes ?? [...DEFAULT_LINK_MEDIA_TYPES];
6
- const validation = validatePage(payload);
6
+ const validation = validateSnapResponse(payload);
7
7
  if (!validation.valid) {
8
8
  return new Response(JSON.stringify({
9
9
  error: "invalid snap page",
@@ -15,7 +15,7 @@ export function payloadToResponse(payload, options = {}) {
15
15
  },
16
16
  });
17
17
  }
18
- const finalized = rootSchema.parse(payload);
18
+ const finalized = snapResponseSchema.parse(payload);
19
19
  return new Response(JSON.stringify(finalized), {
20
20
  status: 200,
21
21
  headers: {
@@ -1,2 +1,19 @@
1
- import type { SnapResponseInput } from "@farcaster/snap";
2
- export declare function renderSnapPage(snap: SnapResponseInput, snapOrigin: string): string;
1
+ import type { SnapHandlerResult } from "@farcaster/snap";
2
+ type SnapPage = SnapHandlerResult["page"];
3
+ export type RenderSnapPageOptions = {
4
+ /** Absolute URL of the /~/og-image PNG route. */
5
+ ogImageUrl?: string;
6
+ /** Canonical pathname + search of the snap page (e.g. "/snap" or "/"). */
7
+ resourcePath?: string;
8
+ /** Optional og:site_name value (e.g. from SNAP_OG_SITE_NAME env). */
9
+ siteName?: string;
10
+ };
11
+ type PageMeta = {
12
+ title: string;
13
+ description: string;
14
+ imageUrl?: string;
15
+ imageAlt?: string;
16
+ };
17
+ export declare function extractPageMeta(page: SnapPage): PageMeta;
18
+ export declare function renderSnapPage(snap: SnapHandlerResult, snapOrigin: string, opts?: RenderSnapPageOptions): string;
19
+ export {};