@farcaster/snap-hono 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/og-image.js +33 -11
- package/dist/payloadToResponse.js +15 -9
- package/dist/renderSnapPage.d.ts +2 -6
- package/dist/renderSnapPage.js +166 -223
- package/package.json +2 -2
- package/src/og-image.ts +36 -13
- package/src/payloadToResponse.ts +20 -12
- package/src/renderSnapPage.ts +175 -325
package/dist/og-image.js
CHANGED
|
@@ -371,7 +371,7 @@ function mapElement(el, accent, imageMap) {
|
|
|
371
371
|
return mapProgress(el, accent);
|
|
372
372
|
case "list":
|
|
373
373
|
return mapList(el);
|
|
374
|
-
case "
|
|
374
|
+
case "toggle_group":
|
|
375
375
|
return mapButtonGroup(el, accent);
|
|
376
376
|
case "bar_chart":
|
|
377
377
|
return mapBarChart(el, accent);
|
|
@@ -507,7 +507,7 @@ function estimateElementHeight(el, imageMap) {
|
|
|
507
507
|
return estimateProgressHeight(el);
|
|
508
508
|
case "list":
|
|
509
509
|
return estimateListHeight(el);
|
|
510
|
-
case "
|
|
510
|
+
case "toggle_group":
|
|
511
511
|
return estimateButtonGroupHeight(el);
|
|
512
512
|
case "bar_chart":
|
|
513
513
|
return 100;
|
|
@@ -556,24 +556,46 @@ function estimateDefaultOgHeight(elements, imageMap, buttons, buttonLayout) {
|
|
|
556
556
|
const outerH = 2 * OG_OUTER_MARGIN_PX + cardH + OG_HEIGHT_SAFETY_PX;
|
|
557
557
|
return Math.min(OG_MAX_HEIGHT_PX, Math.max(OG_MIN_HEIGHT_PX, Math.ceil(outerH)));
|
|
558
558
|
}
|
|
559
|
+
// ─── Spec helpers ─────────────────────────────────────
|
|
560
|
+
/** Walk the flat spec from root and collect top-level children as El objects for the OG renderer. */
|
|
561
|
+
function specToElementList(spec) {
|
|
562
|
+
const rootEl = spec.elements[spec.root];
|
|
563
|
+
if (!rootEl?.children)
|
|
564
|
+
return [];
|
|
565
|
+
return rootEl.children
|
|
566
|
+
.map((key) => {
|
|
567
|
+
const el = spec.elements[key];
|
|
568
|
+
if (!el)
|
|
569
|
+
return null;
|
|
570
|
+
return { type: el.type, ...el.props };
|
|
571
|
+
})
|
|
572
|
+
.filter((el) => el != null);
|
|
573
|
+
}
|
|
574
|
+
/** Extract button elements (type: "button") from the spec. */
|
|
575
|
+
function specToButtons(spec) {
|
|
576
|
+
return Object.values(spec.elements)
|
|
577
|
+
.filter((el) => el.type === "button")
|
|
578
|
+
.map((el) => ({ type: "button", ...el.props }));
|
|
579
|
+
}
|
|
559
580
|
// ─── Main PNG renderer ─────────────────────────────────────
|
|
560
581
|
export async function renderSnapPageToPng(snap, options) {
|
|
561
|
-
const
|
|
562
|
-
const
|
|
582
|
+
const accent = accentHex(snap.theme?.accent);
|
|
583
|
+
const spec = snap.ui;
|
|
584
|
+
const elements = specToElementList(spec);
|
|
585
|
+
const pageButtons = specToButtons(spec);
|
|
563
586
|
// Pre-fetch all image URLs (SSRF-safe)
|
|
564
|
-
const imageUrls =
|
|
587
|
+
const imageUrls = elements
|
|
565
588
|
.filter((el) => el.type === "image")
|
|
566
589
|
.map((el) => el.url);
|
|
567
590
|
const unique = [...new Set(imageUrls)];
|
|
568
591
|
const fetched = await Promise.all(unique.map(async (url) => [url, await safeFetchImage(url)]));
|
|
569
592
|
const imageMap = new Map(fetched.filter(([, v]) => v != null));
|
|
570
|
-
const buttonLayout = page.button_layout;
|
|
571
|
-
const pageButtons = page.buttons ?? [];
|
|
572
593
|
const W = options?.width ?? DEFAULT_OG_WIDTH_PX;
|
|
573
594
|
const H = options?.height ??
|
|
574
|
-
estimateDefaultOgHeight(
|
|
575
|
-
// Build element VNodes
|
|
576
|
-
const elementNodes =
|
|
595
|
+
estimateDefaultOgHeight(elements, imageMap, pageButtons, "column");
|
|
596
|
+
// Build element VNodes (skip buttons — handled separately)
|
|
597
|
+
const elementNodes = elements
|
|
598
|
+
.filter((el) => el.type !== "button")
|
|
577
599
|
.map((el) => mapElement(el, accent, imageMap))
|
|
578
600
|
.filter((n) => n != null);
|
|
579
601
|
// Build button VNodes
|
|
@@ -588,7 +610,7 @@ export async function renderSnapPageToPng(snap, options) {
|
|
|
588
610
|
? [
|
|
589
611
|
h("div", {
|
|
590
612
|
display: "flex",
|
|
591
|
-
flexDirection:
|
|
613
|
+
flexDirection: "column",
|
|
592
614
|
gap: 8,
|
|
593
615
|
marginTop: 12,
|
|
594
616
|
width: OG_CARD_INNER_WIDTH_PX,
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { MEDIA_TYPE, validateSnapResponse, snapResponseSchema, } from "@farcaster/snap";
|
|
2
|
+
import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
|
|
2
3
|
const DEFAULT_LINK_MEDIA_TYPES = [MEDIA_TYPE, "text/html"];
|
|
3
4
|
export function payloadToResponse(payload, options = {}) {
|
|
4
5
|
const resourcePath = options.resourcePath ?? "/";
|
|
5
6
|
const mediaTypes = options.mediaTypes ?? [...DEFAULT_LINK_MEDIA_TYPES];
|
|
7
|
+
// Validate snap envelope (version, theme, effects, ui shape)
|
|
6
8
|
const validation = validateSnapResponse(payload);
|
|
7
9
|
if (!validation.valid) {
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
},
|
|
16
|
-
});
|
|
10
|
+
return errorResponse("invalid snap page", validation.issues);
|
|
11
|
+
}
|
|
12
|
+
// Validate ui against catalog (element types, props, actions)
|
|
13
|
+
const catalogResult = snapJsonRenderCatalog.validate(payload.ui);
|
|
14
|
+
if (!catalogResult.success) {
|
|
15
|
+
const issues = catalogResult.error?.issues ?? [];
|
|
16
|
+
return errorResponse("invalid snap ui", issues);
|
|
17
17
|
}
|
|
18
18
|
const finalized = snapResponseSchema.parse(payload);
|
|
19
19
|
return new Response(JSON.stringify(finalized), {
|
|
@@ -23,6 +23,12 @@ export function payloadToResponse(payload, options = {}) {
|
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
function errorResponse(error, issues) {
|
|
27
|
+
return new Response(JSON.stringify({ error, issues }), {
|
|
28
|
+
status: 400,
|
|
29
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
26
32
|
export function snapHeaders(resourcePath, currentMediaType, availableMediaTypes) {
|
|
27
33
|
return {
|
|
28
34
|
"Content-Type": `${currentMediaType}; charset=utf-8`,
|
package/dist/renderSnapPage.d.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import type { SnapHandlerResult } from "@farcaster/snap";
|
|
2
|
-
type SnapPage = SnapHandlerResult["page"];
|
|
1
|
+
import type { SnapHandlerResult, SnapSpec } from "@farcaster/snap";
|
|
3
2
|
export type RenderSnapPageOptions = {
|
|
4
|
-
/** Absolute URL of the /~/og-image PNG route. */
|
|
5
3
|
ogImageUrl?: string;
|
|
6
|
-
/** Canonical pathname + search of the snap page (e.g. "/snap" or "/"). */
|
|
7
4
|
resourcePath?: string;
|
|
8
|
-
/** Optional og:site_name value (e.g. from SNAP_OG_SITE_NAME env). */
|
|
9
5
|
siteName?: string;
|
|
10
6
|
};
|
|
11
7
|
type PageMeta = {
|
|
@@ -14,6 +10,6 @@ type PageMeta = {
|
|
|
14
10
|
imageUrl?: string;
|
|
15
11
|
imageAlt?: string;
|
|
16
12
|
};
|
|
17
|
-
export declare function extractPageMeta(
|
|
13
|
+
export declare function extractPageMeta(spec: SnapSpec): PageMeta;
|
|
18
14
|
export declare function renderSnapPage(snap: SnapHandlerResult, snapOrigin: string, opts?: RenderSnapPageOptions): string;
|
|
19
15
|
export {};
|
package/dist/renderSnapPage.js
CHANGED
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX, PALETTE_COLOR_ACCENT, } from "@farcaster/snap";
|
|
2
|
-
export function extractPageMeta(
|
|
2
|
+
export function extractPageMeta(spec) {
|
|
3
3
|
let title = "Farcaster Snap";
|
|
4
4
|
let description = "";
|
|
5
5
|
let imageUrl;
|
|
6
6
|
let imageAlt;
|
|
7
|
-
for (const el of
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
title = content;
|
|
7
|
+
for (const el of Object.values(spec.elements)) {
|
|
8
|
+
const e = el;
|
|
9
|
+
if (e.type === "item") {
|
|
10
|
+
if (title === "Farcaster Snap" && e.props?.title) {
|
|
11
|
+
title = String(e.props.title);
|
|
13
12
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
content) {
|
|
17
|
-
description = content;
|
|
13
|
+
if (!description && e.props?.description) {
|
|
14
|
+
description = String(e.props.description);
|
|
18
15
|
}
|
|
19
16
|
}
|
|
20
|
-
if (
|
|
21
|
-
imageUrl =
|
|
22
|
-
imageAlt =
|
|
17
|
+
if (e.type === "image" && !imageUrl) {
|
|
18
|
+
imageUrl = e.props?.url ? String(e.props.url) : undefined;
|
|
19
|
+
imageAlt = e.props?.alt ? String(e.props.alt) : undefined;
|
|
23
20
|
}
|
|
24
21
|
}
|
|
25
22
|
return {
|
|
@@ -73,214 +70,167 @@ function colorHex(color, accent) {
|
|
|
73
70
|
return PALETTE_LIGHT_HEX[color] ?? accent;
|
|
74
71
|
}
|
|
75
72
|
// ─── Element renderers ──────────────────────────────────
|
|
76
|
-
function renderElement(
|
|
73
|
+
function renderElement(key, spec, accent) {
|
|
74
|
+
const el = spec.elements[key];
|
|
75
|
+
if (!el)
|
|
76
|
+
return "";
|
|
77
|
+
const p = el.props ?? {};
|
|
77
78
|
switch (el.type) {
|
|
78
|
-
case "
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
79
|
+
case "icon": {
|
|
80
|
+
const color = colorHex(p.color, accent);
|
|
81
|
+
const size = String(p.size ?? "md") === "sm" ? 16 : 20;
|
|
82
|
+
// Simplified inline SVG for common icons; falls back to a circle for unknown
|
|
83
|
+
const name = String(p.name ?? "info");
|
|
84
|
+
const iconSvgs = {
|
|
85
|
+
check: `<polyline points="20 6 9 17 4 12"/>`,
|
|
86
|
+
x: `<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`,
|
|
87
|
+
heart: `<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>`,
|
|
88
|
+
star: `<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>`,
|
|
89
|
+
info: `<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>`,
|
|
90
|
+
"arrow-right": `<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>`,
|
|
91
|
+
"chevron-right": `<polyline points="9 18 15 12 9 6"/>`,
|
|
92
|
+
"external-link": `<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>`,
|
|
93
|
+
zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
|
|
94
|
+
user: `<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>`,
|
|
95
|
+
clock: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`,
|
|
96
|
+
};
|
|
97
|
+
const inner = iconSvgs[name] ?? `<circle cx="12" cy="12" r="4"/>`;
|
|
98
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:middle">${inner}</svg>`;
|
|
99
|
+
}
|
|
100
|
+
case "badge": {
|
|
101
|
+
const color = colorHex(p.color, accent);
|
|
102
|
+
return `<span style="display:inline-block;padding:2px 10px;border-radius:9999px;font-size:12px;font-weight:500;line-height:1.5;background:${color};color:#fff">${esc(String(p.label ?? ""))}</span>`;
|
|
103
|
+
}
|
|
104
|
+
case "image": {
|
|
105
|
+
const url = esc(String(p.url ?? ""));
|
|
106
|
+
const aspect = String(p.aspect ?? "16:9");
|
|
107
|
+
const [w, h] = aspect.split(":").map(Number);
|
|
108
|
+
const ratio = w && h ? `${w}/${h}` : "16/9";
|
|
109
|
+
return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(String(p.alt ?? ""))}" style="width:100%;height:100%;object-fit:cover"></div>`;
|
|
110
|
+
}
|
|
111
|
+
case "item": {
|
|
112
|
+
const variant = String(p.variant ?? "default");
|
|
113
|
+
const variantStyles = {
|
|
114
|
+
default: "",
|
|
115
|
+
outline: "border:1px solid #E5E7EB;border-radius:8px;padding:12px;",
|
|
116
|
+
muted: "background:#F9FAFB;border-radius:8px;padding:12px;",
|
|
117
|
+
};
|
|
118
|
+
const descHtml = p.description ? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>` : "";
|
|
119
|
+
const childIds = el.children ?? [];
|
|
120
|
+
const actionsHtml = childIds.length > 0
|
|
121
|
+
? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds.map((id) => renderElement(id, spec, accent)).join("")}</div>`
|
|
122
|
+
: "";
|
|
123
|
+
return `<div style="display:flex;align-items:flex-start;${variantStyles[variant] ?? ""}"><div style="flex:1;min-width:0"><div style="font-size:15px;font-weight:500;color:#111">${esc(String(p.title ?? ""))}</div>${descHtml}</div>${actionsHtml}</div>`;
|
|
124
|
+
}
|
|
125
|
+
case "item_group": {
|
|
126
|
+
const childIds = el.children ?? [];
|
|
127
|
+
const border = Boolean(p.border);
|
|
128
|
+
const separator = Boolean(p.separator);
|
|
129
|
+
const outerStyle = border ? "border:1px solid #E5E7EB;border-radius:8px;overflow:hidden" : "";
|
|
130
|
+
let html = `<div style="display:flex;flex-direction:column;${outerStyle}">`;
|
|
131
|
+
for (let i = 0; i < childIds.length; i++) {
|
|
132
|
+
if (separator && i > 0) {
|
|
133
|
+
html += `<hr style="border:none;border-top:1px solid #E5E7EB;margin:0 12px">`;
|
|
134
|
+
}
|
|
135
|
+
const pad = border ? "padding:8px 12px;" : separator ? "padding:8px 0;" : "";
|
|
136
|
+
html += `<div style="${pad}">${renderElement(childIds[i], spec, accent)}</div>`;
|
|
137
|
+
}
|
|
138
|
+
html += `</div>`;
|
|
139
|
+
return html;
|
|
140
|
+
}
|
|
141
|
+
case "progress": {
|
|
142
|
+
const value = Number(p.value ?? 0);
|
|
143
|
+
const max = Number(p.max ?? 100);
|
|
144
|
+
const color = accent;
|
|
145
|
+
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
146
|
+
const labelHtml = p.label ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
|
|
147
|
+
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>`;
|
|
148
|
+
}
|
|
149
|
+
case "separator": {
|
|
150
|
+
const orientation = String(p.orientation ?? "horizontal");
|
|
151
|
+
if (orientation === "vertical")
|
|
152
|
+
return `<div style="width:1px;background:#E5E7EB;align-self:stretch;min-height:16px"></div>`;
|
|
101
153
|
return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
|
|
102
|
-
|
|
103
|
-
|
|
154
|
+
}
|
|
155
|
+
case "slider": {
|
|
156
|
+
const min = Number(p.min ?? 0);
|
|
157
|
+
const max = Number(p.max ?? 100);
|
|
158
|
+
const value = p.defaultValue !== undefined ? Number(p.defaultValue) : (min + max) / 2;
|
|
159
|
+
const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
|
|
160
|
+
return `<div>${labelHtml}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="width:100%;accent-color:${accent};opacity:0.7"></div>`;
|
|
161
|
+
}
|
|
162
|
+
case "switch": {
|
|
163
|
+
const checked = Boolean(p.defaultChecked);
|
|
164
|
+
const bg = checked ? accent : "#D1D5DB";
|
|
165
|
+
const tx = checked ? "20px" : "2px";
|
|
166
|
+
return `<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-size:14px;color:#374151">${esc(String(p.label ?? ""))}</span><div style="width:44px;height:24px;background:${bg};border-radius:12px;position:relative;opacity:0.7"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx}"></div></div></div>`;
|
|
167
|
+
}
|
|
168
|
+
case "input": {
|
|
169
|
+
const labelHtml = p.label ? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</label>` : "";
|
|
170
|
+
return `<div>${labelHtml}<input type="text" placeholder="${esc(String(p.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"></div>`;
|
|
171
|
+
}
|
|
172
|
+
case "toggle_group": {
|
|
173
|
+
const options = Array.isArray(p.options) ? p.options : [];
|
|
174
|
+
const orientation = String(p.orientation ?? "horizontal");
|
|
175
|
+
const dir = orientation === "vertical" ? "column" : "row";
|
|
176
|
+
const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
|
|
177
|
+
let html = `<div>${labelHtml}<div style="display:flex;flex-direction:${dir};gap:4px;padding:4px;background:#F3F4F6;border-radius:8px">`;
|
|
178
|
+
for (const opt of options) {
|
|
179
|
+
html += `<button onclick="showModal()" style="flex:1;padding:8px 12px;border-radius:6px;border:none;background:#F3F4F6;font-size:13px;color:#374151;cursor:pointer;font-family:inherit">${esc(opt)}</button>`;
|
|
180
|
+
}
|
|
181
|
+
html += `</div></div>`;
|
|
182
|
+
return html;
|
|
183
|
+
}
|
|
184
|
+
case "button": {
|
|
185
|
+
const variant = String(p.variant ?? "default");
|
|
186
|
+
const bg = variant === "default" ? accent : "transparent";
|
|
187
|
+
const color = variant === "default" ? "#fff" : accent;
|
|
188
|
+
const border = variant === "default" ? "none" : `2px solid ${accent}`;
|
|
189
|
+
const pad = variant === "default" ? "18px 16px" : "10px 16px";
|
|
190
|
+
const minH = variant === "default" ? "min-height:52px;" : "";
|
|
191
|
+
return `<button onclick="showModal()" style="width:100%;${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">${esc(String(p.label ?? ""))}</button>`;
|
|
192
|
+
}
|
|
193
|
+
case "text": {
|
|
194
|
+
const size = String(p.size ?? "md");
|
|
195
|
+
const weight = String(p.weight ?? (size === "lg" ? "bold" : "normal"));
|
|
196
|
+
const align = String(p.align ?? "left");
|
|
197
|
+
const styles = {
|
|
198
|
+
lg: "font-size:20px",
|
|
199
|
+
md: "font-size:15px;line-height:1.5",
|
|
200
|
+
sm: "font-size:13px",
|
|
201
|
+
};
|
|
202
|
+
const weights = {
|
|
203
|
+
bold: "font-weight:700",
|
|
204
|
+
medium: "font-weight:500",
|
|
205
|
+
normal: "font-weight:400",
|
|
206
|
+
};
|
|
207
|
+
return `<div style="${styles[size] ?? styles.md};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
|
|
208
|
+
}
|
|
209
|
+
case "stack": {
|
|
210
|
+
const direction = String(p.direction ?? "vertical");
|
|
211
|
+
const gap = { none: "0", sm: "4px", md: "8px", lg: "16px" };
|
|
212
|
+
const gapVal = gap[String(p.gap ?? "md")] ?? "8px";
|
|
213
|
+
const dir = direction === "horizontal" ? "row" : "column";
|
|
214
|
+
const justifyMap = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around" };
|
|
215
|
+
const jc = p.justify ? justifyMap[String(p.justify)] : undefined;
|
|
216
|
+
const childIds = el.children ?? [];
|
|
217
|
+
let html = `<div style="display:flex;flex-direction:${dir};gap:${gapVal}${jc ? `;justify-content:${jc}` : ""}">`;
|
|
218
|
+
for (const childKey of childIds) {
|
|
219
|
+
const flex = direction === "horizontal" ? "flex:1;" : "";
|
|
220
|
+
html += `<div style="${flex}">${renderElement(childKey, spec, accent)}</div>`;
|
|
221
|
+
}
|
|
222
|
+
html += `</div>`;
|
|
223
|
+
return html;
|
|
224
|
+
}
|
|
104
225
|
default:
|
|
105
226
|
return "";
|
|
106
227
|
}
|
|
107
228
|
}
|
|
108
|
-
function renderText(el, _accent) {
|
|
109
|
-
const style = el.style;
|
|
110
|
-
const content = esc(el.content);
|
|
111
|
-
const align = el.align ?? "left";
|
|
112
|
-
const styles = {
|
|
113
|
-
title: "font-size:20px;font-weight:700;color:#111",
|
|
114
|
-
body: "font-size:15px;line-height:1.5;color:#374151",
|
|
115
|
-
caption: "font-size:13px;color:#9CA3AF",
|
|
116
|
-
label: "font-size:13px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px",
|
|
117
|
-
};
|
|
118
|
-
return `<div style="${styles[style] ?? styles.body};text-align:${align}">${content}</div>`;
|
|
119
|
-
}
|
|
120
|
-
function renderImage(el) {
|
|
121
|
-
const url = esc(el.url);
|
|
122
|
-
const aspect = el.aspect ?? "16:9";
|
|
123
|
-
const [w, h] = aspect.split(":").map(Number);
|
|
124
|
-
const ratio = w && h ? `${w}/${h}` : "16/9";
|
|
125
|
-
return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(el.alt ?? "")}" style="width:100%;height:100%;object-fit:cover"></div>`;
|
|
126
|
-
}
|
|
127
|
-
function renderGrid(el) {
|
|
128
|
-
const { cols, rows, cells } = el;
|
|
129
|
-
const cellSize = el.cellSize ?? "auto";
|
|
130
|
-
const gap = el.gap ?? "small";
|
|
131
|
-
const gapPx = {
|
|
132
|
-
none: "0",
|
|
133
|
-
small: "2px",
|
|
134
|
-
medium: "4px",
|
|
135
|
-
};
|
|
136
|
-
const cellMap = new Map();
|
|
137
|
-
for (const c of cells)
|
|
138
|
-
cellMap.set(`${c.row},${c.col}`, c);
|
|
139
|
-
let cellsHtml = "";
|
|
140
|
-
for (let r = 0; r < rows; r++) {
|
|
141
|
-
for (let c = 0; c < cols; c++) {
|
|
142
|
-
const cell = cellMap.get(`${r},${c}`);
|
|
143
|
-
const bg = cell?.color ?? "transparent";
|
|
144
|
-
const content = cell?.content ? esc(cell.content) : "";
|
|
145
|
-
const sq = cellSize === "square" ? "aspect-ratio:1;" : "";
|
|
146
|
-
cellsHtml += `<div style="${sq}background:${bg};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;border-radius:2px">${content}</div>`;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return `<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:${gapPx[gap] ?? "2px"}">${cellsHtml}</div>`;
|
|
150
|
-
}
|
|
151
|
-
function renderProgress(el, accent) {
|
|
152
|
-
const { value, max, label } = el;
|
|
153
|
-
const color = colorHex(el.color, accent);
|
|
154
|
-
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
155
|
-
const labelHtml = label
|
|
156
|
-
? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(label)}</div>`
|
|
157
|
-
: "";
|
|
158
|
-
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>`;
|
|
159
|
-
}
|
|
160
|
-
function renderBarChart(el, accent) {
|
|
161
|
-
const { bars } = el;
|
|
162
|
-
const max = el.max ?? Math.max(...bars.map((b) => b.value), 1);
|
|
163
|
-
const defaultColor = colorHex(el.color, accent);
|
|
164
|
-
let html = `<div style="display:flex;align-items:flex-end;gap:12px;height:120px">`;
|
|
165
|
-
for (const bar of bars) {
|
|
166
|
-
const color = colorHex(bar.color, defaultColor);
|
|
167
|
-
const pct = max > 0 ? (bar.value / max) * 100 : 0;
|
|
168
|
-
html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end">`;
|
|
169
|
-
html += `<div style="font-size:11px;color:#6B7280;margin-bottom:4px">${bar.value}</div>`;
|
|
170
|
-
html += `<div style="width:100%;height:${pct}%;background:${color};border-radius:4px 4px 0 0;min-height:4px"></div>`;
|
|
171
|
-
html += `<div style="font-size:11px;color:#9CA3AF;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%">${esc(bar.label)}</div>`;
|
|
172
|
-
html += `</div>`;
|
|
173
|
-
}
|
|
174
|
-
html += `</div>`;
|
|
175
|
-
return html;
|
|
176
|
-
}
|
|
177
|
-
function renderList(el) {
|
|
178
|
-
const style = el.style ?? "ordered";
|
|
179
|
-
const { items } = el;
|
|
180
|
-
let html = "";
|
|
181
|
-
for (let i = 0; i < items.length; i++) {
|
|
182
|
-
const item = items[i];
|
|
183
|
-
const prefix = style === "ordered"
|
|
184
|
-
? `<span style="color:#9CA3AF;min-width:20px">${i + 1}.</span>`
|
|
185
|
-
: style === "unordered"
|
|
186
|
-
? `<span style="color:#9CA3AF;min-width:20px">•</span>`
|
|
187
|
-
: "";
|
|
188
|
-
const trailing = item.trailing
|
|
189
|
-
? `<span style="color:#9CA3AF;font-size:13px;white-space:nowrap">${esc(item.trailing)}</span>`
|
|
190
|
-
: "";
|
|
191
|
-
html += `<div style="display:flex;align-items:center;gap:8px;padding:6px 0">${prefix}<span style="flex:1;font-size:14px;color:#374151">${esc(item.content)}</span>${trailing}</div>`;
|
|
192
|
-
}
|
|
193
|
-
return `<div>${html}</div>`;
|
|
194
|
-
}
|
|
195
|
-
function renderButtonGroup(el, accent) {
|
|
196
|
-
const { options } = el;
|
|
197
|
-
const layout = el.style ?? "row";
|
|
198
|
-
const dir = layout === "stack" ? "column" : "row";
|
|
199
|
-
let html = `<div style="display:flex;flex-direction:${dir};gap:8px">`;
|
|
200
|
-
for (const opt of options) {
|
|
201
|
-
html += `<button onclick="showModal()" style="flex:1;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#fff;font-size:14px;color:#374151;cursor:pointer;font-family:inherit">${esc(opt)}</button>`;
|
|
202
|
-
}
|
|
203
|
-
html += `</div>`;
|
|
204
|
-
return html;
|
|
205
|
-
}
|
|
206
|
-
function renderSlider(el, accent) {
|
|
207
|
-
const { label, min, max, minLabel, maxLabel } = el;
|
|
208
|
-
const value = el.value ?? (min + max) / 2;
|
|
209
|
-
const labelHtml = label
|
|
210
|
-
? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(label)}</div>`
|
|
211
|
-
: "";
|
|
212
|
-
const minL = minLabel
|
|
213
|
-
? `<span style="font-size:11px;color:#9CA3AF">${esc(minLabel)}</span>`
|
|
214
|
-
: "";
|
|
215
|
-
const maxL = maxLabel
|
|
216
|
-
? `<span style="font-size:11px;color:#9CA3AF">${esc(maxLabel)}</span>`
|
|
217
|
-
: "";
|
|
218
|
-
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>`;
|
|
219
|
-
}
|
|
220
|
-
function renderTextInput(el) {
|
|
221
|
-
const placeholder = esc(el.placeholder ?? "");
|
|
222
|
-
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">`;
|
|
223
|
-
}
|
|
224
|
-
function renderToggle(el, accent) {
|
|
225
|
-
const label = esc(el.label);
|
|
226
|
-
const { value } = el;
|
|
227
|
-
const bg = value ? accent : "#D1D5DB";
|
|
228
|
-
const tx = value ? "20px" : "2px";
|
|
229
|
-
return `<div style="display:flex;align-items:center;justify-content:space-between">
|
|
230
|
-
<span style="font-size:14px;color:#374151">${label}</span>
|
|
231
|
-
<div style="width:44px;height:24px;background:${bg};border-radius:12px;position:relative;opacity:0.7"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx};transition:left .2s"></div></div>
|
|
232
|
-
</div>`;
|
|
233
|
-
}
|
|
234
|
-
function renderSpacer(el) {
|
|
235
|
-
const sizes = {
|
|
236
|
-
small: "8px",
|
|
237
|
-
medium: "16px",
|
|
238
|
-
large: "24px",
|
|
239
|
-
};
|
|
240
|
-
return `<div style="height:${sizes[el.size ?? "medium"] ?? "16px"}"></div>`;
|
|
241
|
-
}
|
|
242
|
-
function renderGroup(el, accent) {
|
|
243
|
-
let html = `<div style="display:flex;gap:12px">`;
|
|
244
|
-
for (const child of el.children) {
|
|
245
|
-
html += `<div style="flex:1">${renderElement(child, accent)}</div>`;
|
|
246
|
-
}
|
|
247
|
-
html += `</div>`;
|
|
248
|
-
return html;
|
|
249
|
-
}
|
|
250
|
-
// ─── Buttons ────────────────────────────────────────────
|
|
251
|
-
function renderButtons(buttons, layout, accent) {
|
|
252
|
-
if (!buttons || buttons.length === 0)
|
|
253
|
-
return "";
|
|
254
|
-
const dir = layout === "row"
|
|
255
|
-
? "flex-direction:row"
|
|
256
|
-
: layout === "grid"
|
|
257
|
-
? "display:grid;grid-template-columns:1fr 1fr"
|
|
258
|
-
: "flex-direction:column";
|
|
259
|
-
const wrap = layout === "row"
|
|
260
|
-
? "display:flex;"
|
|
261
|
-
: layout === "grid"
|
|
262
|
-
? ""
|
|
263
|
-
: "display:flex;";
|
|
264
|
-
let html = `<div style="${wrap}${dir};gap:8px;margin-top:12px">`;
|
|
265
|
-
for (let i = 0; i < buttons.length; i++) {
|
|
266
|
-
const btn = buttons[i];
|
|
267
|
-
const label = esc(btn.label);
|
|
268
|
-
const style = btn.style ?? (i === 0 ? "primary" : "secondary");
|
|
269
|
-
const bg = style === "primary" ? accent : "transparent";
|
|
270
|
-
const color = style === "primary" ? "#fff" : accent;
|
|
271
|
-
const border = style === "primary" ? "none" : `2px solid ${accent}`;
|
|
272
|
-
const pad = style === "primary" ? "18px 16px" : "10px 16px";
|
|
273
|
-
const minH = style === "primary" ? "min-height:52px;" : "";
|
|
274
|
-
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>`;
|
|
275
|
-
}
|
|
276
|
-
html += `</div>`;
|
|
277
|
-
return html;
|
|
278
|
-
}
|
|
279
229
|
// ─── Main renderer ──────────────────────────────────────
|
|
280
230
|
export function renderSnapPage(snap, snapOrigin, opts) {
|
|
281
|
-
const
|
|
282
|
-
const accent = accentHex(
|
|
283
|
-
const meta = extractPageMeta(
|
|
231
|
+
const spec = snap.ui;
|
|
232
|
+
const accent = accentHex(snap.theme?.accent);
|
|
233
|
+
const meta = extractPageMeta(spec);
|
|
284
234
|
const pageTitle = esc(meta.title);
|
|
285
235
|
const resourcePath = opts?.resourcePath ?? "/";
|
|
286
236
|
const pageUrl = snapOrigin.replace(/\/$/, "") + resourcePath;
|
|
@@ -293,13 +243,7 @@ export function renderSnapPage(snap, snapOrigin, opts) {
|
|
|
293
243
|
siteName: opts?.siteName,
|
|
294
244
|
});
|
|
295
245
|
const snapUrl = encodeURIComponent(snapOrigin + "/");
|
|
296
|
-
|
|
297
|
-
let elementsHtml = "";
|
|
298
|
-
for (const el of page.elements.children) {
|
|
299
|
-
elementsHtml += `<div style="margin-bottom:12px">${renderElement(el, accent)}</div>`;
|
|
300
|
-
}
|
|
301
|
-
// Render buttons
|
|
302
|
-
const buttonsHtml = renderButtons(page.buttons, page.button_layout, accent);
|
|
246
|
+
const bodyHtml = renderElement(spec.root, spec, accent);
|
|
303
247
|
return `<!DOCTYPE html>
|
|
304
248
|
<html lang="en">
|
|
305
249
|
<head>
|
|
@@ -329,8 +273,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
|
|
|
329
273
|
</head>
|
|
330
274
|
<body>
|
|
331
275
|
<div class="card">
|
|
332
|
-
${
|
|
333
|
-
${buttonsHtml}
|
|
276
|
+
${bodyHtml}
|
|
334
277
|
</div>
|
|
335
278
|
<div class="foot">
|
|
336
279
|
<a href="https://farcaster.xyz">${FC_ICON} Farcaster</a>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farcaster/snap-hono",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "Hono integration for Farcaster Snap servers",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
30
30
|
"satori": "^0.10.0",
|
|
31
|
-
"@farcaster/snap": "
|
|
31
|
+
"@farcaster/snap": "2.0.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"hono": ">=4.0.0"
|