@farcaster/snap-hono 1.2.0 → 1.3.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.
@@ -1,16 +1,115 @@
1
- import type { SnapHandlerResult } from "@farcaster/snap";
2
-
3
- const PALETTE: Record<string, string> = {
4
- gray: "#8F8F8F",
5
- blue: "#006BFF",
6
- red: "#FC0036",
7
- amber: "#FFAE00",
8
- green: "#28A948",
9
- teal: "#00AC96",
10
- purple: "#8B5CF6",
11
- pink: "#F32782",
1
+ import type {
2
+ SnapPageElementInput,
3
+ SnapHandlerResult,
4
+ PaletteColor,
5
+ } from "@farcaster/snap";
6
+ import {
7
+ DEFAULT_THEME_ACCENT,
8
+ PALETTE_LIGHT_HEX,
9
+ PALETTE_COLOR_ACCENT,
10
+ } from "@farcaster/snap";
11
+
12
+ type SnapPage = SnapHandlerResult["page"];
13
+ type SnapPageButton = NonNullable<SnapPage["buttons"]>[number];
14
+
15
+ // ─── OG meta ────────────────────────────────────────────
16
+
17
+ export type RenderSnapPageOptions = {
18
+ /** Absolute URL of the /~/og-image PNG route. */
19
+ ogImageUrl?: string;
20
+ /** Canonical pathname + search of the snap page (e.g. "/snap" or "/"). */
21
+ resourcePath?: string;
22
+ /** Optional og:site_name value (e.g. from SNAP_OG_SITE_NAME env). */
23
+ siteName?: string;
12
24
  };
13
25
 
26
+ type PageMeta = {
27
+ title: string;
28
+ description: string;
29
+ imageUrl?: string;
30
+ imageAlt?: string;
31
+ };
32
+
33
+ export function extractPageMeta(page: SnapPage): PageMeta {
34
+ let title = "Farcaster Snap";
35
+ let description = "";
36
+ let imageUrl: string | undefined;
37
+ let imageAlt: string | undefined;
38
+
39
+ for (const el of page.elements.children) {
40
+ if (el.type === "text") {
41
+ const style = el.style;
42
+ const content = el.content;
43
+ if (style === "title" && title === "Farcaster Snap" && content) {
44
+ title = content;
45
+ } else if (
46
+ (style === "body" || style === "caption") &&
47
+ !description &&
48
+ content
49
+ ) {
50
+ description = content;
51
+ }
52
+ }
53
+ if (el.type === "image" && !imageUrl) {
54
+ imageUrl = el.url;
55
+ imageAlt = el.alt;
56
+ }
57
+ }
58
+
59
+ return {
60
+ title,
61
+ description: description || title,
62
+ imageUrl,
63
+ imageAlt,
64
+ };
65
+ }
66
+
67
+ function buildOgMeta(opts: {
68
+ title: string;
69
+ description: string;
70
+ pageUrl: string;
71
+ ogImageUrl?: string;
72
+ imageAlt?: string;
73
+ siteName?: string;
74
+ }): string {
75
+ const { title, description, pageUrl, ogImageUrl, imageAlt, siteName } = opts;
76
+
77
+ const imgUrl = ogImageUrl ?? undefined;
78
+ const twitterCard = imgUrl ? "summary_large_image" : "summary";
79
+
80
+ const lines = [
81
+ `<meta name="description" content="${esc(description)}">`,
82
+ `<meta property="og:title" content="${esc(title)}">`,
83
+ `<meta property="og:description" content="${esc(description)}">`,
84
+ `<meta property="og:url" content="${esc(pageUrl)}">`,
85
+ `<meta property="og:type" content="website">`,
86
+ `<meta property="og:locale" content="en_US">`,
87
+ ];
88
+
89
+ if (siteName) {
90
+ lines.push(`<meta property="og:site_name" content="${esc(siteName)}">`);
91
+ }
92
+
93
+ if (imgUrl) {
94
+ lines.push(`<meta property="og:image" content="${esc(imgUrl)}">`);
95
+ lines.push(
96
+ `<meta property="og:image:alt" content="${esc(imageAlt ?? title)}">`,
97
+ );
98
+ }
99
+
100
+ lines.push(
101
+ `<meta name="twitter:card" content="${twitterCard}">`,
102
+ `<meta name="twitter:title" content="${esc(title)}">`,
103
+ `<meta name="twitter:description" content="${esc(description)}">`,
104
+ );
105
+
106
+ if (imgUrl) {
107
+ lines.push(`<meta name="twitter:image" content="${esc(imgUrl)}">`);
108
+ }
109
+
110
+ return lines.join("\n");
111
+ }
112
+
14
113
  const FC_ICON = `<svg viewBox="0 0 520 457" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M519.801 0V61.6809H458.172V123.31H477.054V123.331H519.801V456.795H416.57L416.507 456.49L363.832 207.03C358.81 183.251 345.667 161.736 326.827 146.434C307.988 131.133 284.255 122.71 260.006 122.71H259.8C235.551 122.71 211.818 131.133 192.979 146.434C174.139 161.736 160.996 183.259 155.974 207.03L103.239 456.795H0V123.323H42.7471V123.31H61.6262V61.6809H0V0H519.801Z" fill="currentColor"/></svg>`;
15
114
 
16
115
  function esc(s: string): string {
@@ -21,20 +120,24 @@ function esc(s: string): string {
21
120
  .replace(/"/g, "&quot;");
22
121
  }
23
122
 
24
- function accentHex(accent: string | undefined): string {
25
- return PALETTE[accent ?? "purple"] ?? PALETTE.purple!;
123
+ function accentHex(accent: PaletteColor | undefined): string {
124
+ return accent && PALETTE_LIGHT_HEX[accent]
125
+ ? PALETTE_LIGHT_HEX[accent]
126
+ : PALETTE_LIGHT_HEX[DEFAULT_THEME_ACCENT];
26
127
  }
27
128
 
28
- function colorHex(color: string | undefined, accent: string): string {
29
- if (!color || color === "accent") return accent;
30
- return PALETTE[color] ?? accent;
129
+ function colorHex(
130
+ color: PaletteColor | typeof PALETTE_COLOR_ACCENT | undefined,
131
+ accent: string,
132
+ ): string {
133
+ if (!color || color === PALETTE_COLOR_ACCENT) return accent;
134
+ return PALETTE_LIGHT_HEX[color] ?? accent;
31
135
  }
32
136
 
33
137
  // ─── Element renderers ──────────────────────────────────
34
138
 
35
- function renderElement(el: Record<string, unknown>, accent: string): string {
36
- const type = el.type as string;
37
- switch (type) {
139
+ function renderElement(el: SnapPageElementInput, accent: string): string {
140
+ switch (el.type) {
38
141
  case "text":
39
142
  return renderText(el, accent);
40
143
  case "image":
@@ -59,25 +162,20 @@ function renderElement(el: Record<string, unknown>, accent: string): string {
59
162
  return renderGroup(el, accent);
60
163
  case "divider":
61
164
  return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
62
- case "spacer": {
63
- const sizes: Record<string, string> = {
64
- small: "8px",
65
- medium: "16px",
66
- large: "24px",
67
- };
68
- return `<div style="height:${
69
- sizes[(el.size as string) ?? "medium"] ?? "16px"
70
- }"></div>`;
71
- }
165
+ case "spacer":
166
+ return renderSpacer(el);
72
167
  default:
73
168
  return "";
74
169
  }
75
170
  }
76
171
 
77
- function renderText(el: Record<string, unknown>, _accent: string): string {
78
- const style = el.style as string;
79
- const content = esc(el.content as string);
80
- const align = (el.align as string) ?? "left";
172
+ function renderText(
173
+ el: Extract<SnapPageElementInput, { type: "text" }>,
174
+ _accent: string,
175
+ ): string {
176
+ const style = el.style;
177
+ const content = esc(el.content);
178
+ const align = el.align ?? "left";
81
179
  const styles: Record<string, string> = {
82
180
  title: "font-size:20px;font-weight:700;color:#111",
83
181
  body: "font-size:15px;line-height:1.5;color:#374151",
@@ -90,27 +188,24 @@ function renderText(el: Record<string, unknown>, _accent: string): string {
90
188
  };text-align:${align}">${content}</div>`;
91
189
  }
92
190
 
93
- function renderImage(el: Record<string, unknown>): string {
94
- const url = esc(el.url as string);
95
- const aspect = (el.aspect as string) ?? "16:9";
191
+ function renderImage(
192
+ el: Extract<SnapPageElementInput, { type: "image" }>,
193
+ ): string {
194
+ const url = esc(el.url);
195
+ const aspect = el.aspect ?? "16:9";
96
196
  const [w, h] = aspect.split(":").map(Number);
97
197
  const ratio = w && h ? `${w}/${h}` : "16/9";
98
198
  return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(
99
- (el.alt as string) ?? "",
199
+ el.alt ?? "",
100
200
  )}" style="width:100%;height:100%;object-fit:cover"></div>`;
101
201
  }
102
202
 
103
- function renderGrid(el: Record<string, unknown>): string {
104
- const cols = el.cols as number;
105
- const rows = el.rows as number;
106
- const cells = el.cells as Array<{
107
- row: number;
108
- col: number;
109
- color?: string;
110
- content?: string;
111
- }>;
112
- const cellSize = (el.cellSize as string) ?? "auto";
113
- const gap = (el.gap as string) ?? "small";
203
+ function renderGrid(
204
+ el: Extract<SnapPageElementInput, { type: "grid" }>,
205
+ ): string {
206
+ const { cols, rows, cells } = el;
207
+ const cellSize = el.cellSize ?? "auto";
208
+ const gap = el.gap ?? "small";
114
209
  const gapPx: Record<string, string> = {
115
210
  none: "0",
116
211
  small: "2px",
@@ -135,11 +230,12 @@ function renderGrid(el: Record<string, unknown>): string {
135
230
  }">${cellsHtml}</div>`;
136
231
  }
137
232
 
138
- function renderProgress(el: Record<string, unknown>, accent: string): string {
139
- const value = el.value as number;
140
- const max = el.max as number;
141
- const label = el.label as string | undefined;
142
- const color = colorHex(el.color as string | undefined, accent);
233
+ function renderProgress(
234
+ el: Extract<SnapPageElementInput, { type: "progress" }>,
235
+ accent: string,
236
+ ): string {
237
+ const { value, max, label } = el;
238
+ const color = colorHex(el.color, accent);
143
239
  const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
144
240
  const labelHtml = label
145
241
  ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(
@@ -149,19 +245,18 @@ function renderProgress(el: Record<string, unknown>, accent: string): string {
149
245
  return `<div>${labelHtml}<div style="height:8px;background:#E5E7EB;border-radius:4px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${color};border-radius:4px"></div></div></div>`;
150
246
  }
151
247
 
152
- function renderBarChart(el: Record<string, unknown>, accent: string): string {
153
- const bars = el.bars as Array<{
154
- label: string;
155
- value: number;
156
- color?: string;
157
- }>;
248
+ function renderBarChart(
249
+ el: Extract<SnapPageElementInput, { type: "bar_chart" }>,
250
+ accent: string,
251
+ ): string {
252
+ const { bars } = el;
158
253
  const max =
159
- (el.max as number | undefined) ?? Math.max(...bars.map((b) => b.value), 1);
160
- const defaultColor = colorHex(el.color as string | undefined, accent);
254
+ el.max ?? Math.max(...bars.map((b: { value: number }) => b.value), 1);
255
+ const defaultColor = colorHex(el.color, accent);
161
256
 
162
257
  let html = `<div style="display:flex;align-items:flex-end;gap:12px;height:120px">`;
163
258
  for (const bar of bars) {
164
- const color = bar.color ? PALETTE[bar.color] ?? defaultColor : defaultColor;
259
+ const color = colorHex(bar.color, defaultColor);
165
260
  const pct = max > 0 ? (bar.value / max) * 100 : 0;
166
261
  html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end">`;
167
262
  html += `<div style="font-size:11px;color:#6B7280;margin-bottom:4px">${bar.value}</div>`;
@@ -175,12 +270,11 @@ function renderBarChart(el: Record<string, unknown>, accent: string): string {
175
270
  return html;
176
271
  }
177
272
 
178
- function renderList(el: Record<string, unknown>): string {
179
- const style = (el.style as string) ?? "ordered";
180
- const items = el.items as Array<{
181
- content: string;
182
- trailing?: string;
183
- }>;
273
+ function renderList(
274
+ el: Extract<SnapPageElementInput, { type: "list" }>,
275
+ ): string {
276
+ const style = el.style ?? "ordered";
277
+ const { items } = el;
184
278
 
185
279
  let html = "";
186
280
  for (let i = 0; i < items.length; i++) {
@@ -204,11 +298,11 @@ function renderList(el: Record<string, unknown>): string {
204
298
  }
205
299
 
206
300
  function renderButtonGroup(
207
- el: Record<string, unknown>,
301
+ el: Extract<SnapPageElementInput, { type: "button_group" }>,
208
302
  accent: string,
209
303
  ): string {
210
- const options = el.options as string[];
211
- const layout = (el.style as string) ?? "row";
304
+ const { options } = el;
305
+ const layout = el.style ?? "row";
212
306
  const dir = layout === "stack" ? "column" : "row";
213
307
  let html = `<div style="display:flex;flex-direction:${dir};gap:8px">`;
214
308
  for (const opt of options) {
@@ -220,13 +314,12 @@ function renderButtonGroup(
220
314
  return html;
221
315
  }
222
316
 
223
- function renderSlider(el: Record<string, unknown>, accent: string): string {
224
- const label = el.label as string | undefined;
225
- const min = el.min as number;
226
- const max = el.max as number;
227
- const value = (el.value as number) ?? (min + max) / 2;
228
- const minLabel = el.minLabel as string | undefined;
229
- const maxLabel = el.maxLabel as string | undefined;
317
+ function renderSlider(
318
+ el: Extract<SnapPageElementInput, { type: "slider" }>,
319
+ accent: string,
320
+ ): string {
321
+ const { label, min, max, minLabel, maxLabel } = el;
322
+ const value = el.value ?? (min + max) / 2;
230
323
 
231
324
  const labelHtml = label
232
325
  ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(
@@ -243,14 +336,19 @@ function renderSlider(el: Record<string, unknown>, accent: string): string {
243
336
  return `<div>${labelHtml}<div style="display:flex;align-items:center;gap:8px">${minL}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="flex:1;accent-color:${accent};opacity:0.7">${maxL}</div></div>`;
244
337
  }
245
338
 
246
- function renderTextInput(el: Record<string, unknown>): string {
247
- const placeholder = esc((el.placeholder as string) ?? "");
339
+ function renderTextInput(
340
+ el: Extract<SnapPageElementInput, { type: "text_input" }>,
341
+ ): string {
342
+ const placeholder = esc(el.placeholder ?? "");
248
343
  return `<input type="text" placeholder="${placeholder}" disabled style="width:100%;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-size:14px;color:#9CA3AF;font-family:inherit;box-sizing:border-box">`;
249
344
  }
250
345
 
251
- function renderToggle(el: Record<string, unknown>, accent: string): string {
252
- const label = esc(el.label as string);
253
- const value = el.value as boolean;
346
+ function renderToggle(
347
+ el: Extract<SnapPageElementInput, { type: "toggle" }>,
348
+ accent: string,
349
+ ): string {
350
+ const label = esc(el.label);
351
+ const { value } = el;
254
352
  const bg = value ? accent : "#D1D5DB";
255
353
  const tx = value ? "20px" : "2px";
256
354
  return `<div style="display:flex;align-items:center;justify-content:space-between">
@@ -259,10 +357,23 @@ function renderToggle(el: Record<string, unknown>, accent: string): string {
259
357
  </div>`;
260
358
  }
261
359
 
262
- function renderGroup(el: Record<string, unknown>, accent: string): string {
263
- const children = el.children as Array<Record<string, unknown>>;
360
+ function renderSpacer(
361
+ el: Extract<SnapPageElementInput, { type: "spacer" }>,
362
+ ): string {
363
+ const sizes: Record<string, string> = {
364
+ small: "8px",
365
+ medium: "16px",
366
+ large: "24px",
367
+ };
368
+ return `<div style="height:${sizes[el.size ?? "medium"] ?? "16px"}"></div>`;
369
+ }
370
+
371
+ function renderGroup(
372
+ el: Extract<SnapPageElementInput, { type: "group" }>,
373
+ accent: string,
374
+ ): string {
264
375
  let html = `<div style="display:flex;gap:12px">`;
265
- for (const child of children) {
376
+ for (const child of el.children) {
266
377
  html += `<div style="flex:1">${renderElement(child, accent)}</div>`;
267
378
  }
268
379
  html += `</div>`;
@@ -272,8 +383,8 @@ function renderGroup(el: Record<string, unknown>, accent: string): string {
272
383
  // ─── Buttons ────────────────────────────────────────────
273
384
 
274
385
  function renderButtons(
275
- buttons: Array<Record<string, unknown>> | undefined,
276
- layout: string | undefined,
386
+ buttons: SnapPage["buttons"],
387
+ layout: SnapPage["button_layout"],
277
388
  accent: string,
278
389
  ): string {
279
390
  if (!buttons || buttons.length === 0) return "";
@@ -293,13 +404,15 @@ function renderButtons(
293
404
 
294
405
  let html = `<div style="${wrap}${dir};gap:8px;margin-top:12px">`;
295
406
  for (let i = 0; i < buttons.length; i++) {
296
- const btn = buttons[i]!;
297
- const label = esc(btn.label as string);
298
- const style = (btn.style as string) ?? (i === 0 ? "primary" : "secondary");
407
+ const btn: SnapPageButton = buttons[i]!;
408
+ const label = esc(btn.label);
409
+ const style = btn.style ?? (i === 0 ? "primary" : "secondary");
299
410
  const bg = style === "primary" ? accent : "transparent";
300
411
  const color = style === "primary" ? "#fff" : accent;
301
412
  const border = style === "primary" ? "none" : `2px solid ${accent}`;
302
- html += `<button onclick="showModal()" style="flex:1;padding:10px 16px;border-radius:10px;background:${bg};color:${color};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit">${label}</button>`;
413
+ const pad = style === "primary" ? "18px 16px" : "10px 16px";
414
+ const minH = style === "primary" ? "min-height:52px;" : "";
415
+ html += `<button onclick="showModal()" style="flex:1;${minH}padding:${pad};border-radius:10px;background:${bg};color:${color};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;box-sizing:border-box">${label}</button>`;
303
416
  }
304
417
  html += `</div>`;
305
418
  return html;
@@ -310,16 +423,23 @@ function renderButtons(
310
423
  export function renderSnapPage(
311
424
  snap: SnapHandlerResult,
312
425
  snapOrigin: string,
426
+ opts?: RenderSnapPageOptions,
313
427
  ): string {
314
428
  const page = snap.page;
315
429
  const accent = accentHex(page.theme?.accent);
316
430
 
317
- // Extract title for <title> tag
318
- const titleEl = page.elements.children.find(
319
- (el) =>
320
- el.type === "text" && (el as Record<string, unknown>).style === "title",
321
- ) as Record<string, unknown> | undefined;
322
- const pageTitle = titleEl ? esc(titleEl.content as string) : "Farcaster Snap";
431
+ const meta = extractPageMeta(page);
432
+ const pageTitle = esc(meta.title);
433
+ const resourcePath = opts?.resourcePath ?? "/";
434
+ const pageUrl = snapOrigin.replace(/\/$/, "") + resourcePath;
435
+ const ogMeta = buildOgMeta({
436
+ title: meta.title,
437
+ description: meta.description,
438
+ pageUrl,
439
+ ogImageUrl: opts?.ogImageUrl,
440
+ imageAlt: meta.imageAlt ?? meta.imageUrl ? meta.title : undefined,
441
+ siteName: opts?.siteName,
442
+ });
323
443
 
324
444
  const snapUrl = encodeURIComponent(snapOrigin + "/");
325
445
 
@@ -327,17 +447,13 @@ export function renderSnapPage(
327
447
  let elementsHtml = "";
328
448
  for (const el of page.elements.children) {
329
449
  elementsHtml += `<div style="margin-bottom:12px">${renderElement(
330
- el as Record<string, unknown>,
450
+ el,
331
451
  accent,
332
452
  )}</div>`;
333
453
  }
334
454
 
335
455
  // Render buttons
336
- const buttonsHtml = renderButtons(
337
- page.buttons as Array<Record<string, unknown>> | undefined,
338
- page.button_layout as string | undefined,
339
- accent,
340
- );
456
+ const buttonsHtml = renderButtons(page.buttons, page.button_layout, accent);
341
457
 
342
458
  return `<!DOCTYPE html>
343
459
  <html lang="en">
@@ -345,6 +461,7 @@ export function renderSnapPage(
345
461
  <meta charset="utf-8">
346
462
  <meta name="viewport" content="width=device-width, initial-scale=1">
347
463
  <title>${pageTitle}</title>
464
+ ${ogMeta}
348
465
  <style>
349
466
  *{margin:0;padding:0;box-sizing:border-box}
350
467
  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0A0A0A;min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:24px}