@farcaster/snap-hono 1.3.2 → 1.4.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/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/src/renderSnapPage.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
SnapPageElementInput,
|
|
3
2
|
SnapHandlerResult,
|
|
3
|
+
SnapSpec,
|
|
4
|
+
SnapUIElement,
|
|
4
5
|
PaletteColor,
|
|
5
6
|
} from "@farcaster/snap";
|
|
6
7
|
import {
|
|
@@ -9,17 +10,11 @@ import {
|
|
|
9
10
|
PALETTE_COLOR_ACCENT,
|
|
10
11
|
} from "@farcaster/snap";
|
|
11
12
|
|
|
12
|
-
type SnapPage = SnapHandlerResult["page"];
|
|
13
|
-
type SnapPageButton = NonNullable<SnapPage["buttons"]>[number];
|
|
14
|
-
|
|
15
13
|
// ─── OG meta ────────────────────────────────────────────
|
|
16
14
|
|
|
17
15
|
export type RenderSnapPageOptions = {
|
|
18
|
-
/** Absolute URL of the /~/og-image PNG route. */
|
|
19
16
|
ogImageUrl?: string;
|
|
20
|
-
/** Canonical pathname + search of the snap page (e.g. "/snap" or "/"). */
|
|
21
17
|
resourcePath?: string;
|
|
22
|
-
/** Optional og:site_name value (e.g. from SNAP_OG_SITE_NAME env). */
|
|
23
18
|
siteName?: string;
|
|
24
19
|
};
|
|
25
20
|
|
|
@@ -30,29 +25,25 @@ type PageMeta = {
|
|
|
30
25
|
imageAlt?: string;
|
|
31
26
|
};
|
|
32
27
|
|
|
33
|
-
export function extractPageMeta(
|
|
28
|
+
export function extractPageMeta(spec: SnapSpec): PageMeta {
|
|
34
29
|
let title = "Farcaster Snap";
|
|
35
30
|
let description = "";
|
|
36
31
|
let imageUrl: string | undefined;
|
|
37
32
|
let imageAlt: string | undefined;
|
|
38
33
|
|
|
39
|
-
for (const el of
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
!description &&
|
|
48
|
-
content
|
|
49
|
-
) {
|
|
50
|
-
description = content;
|
|
34
|
+
for (const el of Object.values(spec.elements)) {
|
|
35
|
+
const e = el as SnapUIElement;
|
|
36
|
+
if (e.type === "item") {
|
|
37
|
+
if (title === "Farcaster Snap" && e.props?.title) {
|
|
38
|
+
title = String(e.props.title);
|
|
39
|
+
}
|
|
40
|
+
if (!description && e.props?.description) {
|
|
41
|
+
description = String(e.props.description);
|
|
51
42
|
}
|
|
52
43
|
}
|
|
53
|
-
if (
|
|
54
|
-
imageUrl =
|
|
55
|
-
imageAlt =
|
|
44
|
+
if (e.type === "image" && !imageUrl) {
|
|
45
|
+
imageUrl = e.props?.url ? String(e.props.url) : undefined;
|
|
46
|
+
imageAlt = e.props?.alt ? String(e.props.alt) : undefined;
|
|
56
47
|
}
|
|
57
48
|
}
|
|
58
49
|
|
|
@@ -73,7 +64,6 @@ function buildOgMeta(opts: {
|
|
|
73
64
|
siteName?: string;
|
|
74
65
|
}): string {
|
|
75
66
|
const { title, description, pageUrl, ogImageUrl, imageAlt, siteName } = opts;
|
|
76
|
-
|
|
77
67
|
const imgUrl = ogImageUrl ?? undefined;
|
|
78
68
|
const twitterCard = imgUrl ? "summary_large_image" : "summary";
|
|
79
69
|
|
|
@@ -89,24 +79,18 @@ function buildOgMeta(opts: {
|
|
|
89
79
|
if (siteName) {
|
|
90
80
|
lines.push(`<meta property="og:site_name" content="${esc(siteName)}">`);
|
|
91
81
|
}
|
|
92
|
-
|
|
93
82
|
if (imgUrl) {
|
|
94
83
|
lines.push(`<meta property="og:image" content="${esc(imgUrl)}">`);
|
|
95
|
-
lines.push(
|
|
96
|
-
`<meta property="og:image:alt" content="${esc(imageAlt ?? title)}">`,
|
|
97
|
-
);
|
|
84
|
+
lines.push(`<meta property="og:image:alt" content="${esc(imageAlt ?? title)}">`);
|
|
98
85
|
}
|
|
99
|
-
|
|
100
86
|
lines.push(
|
|
101
87
|
`<meta name="twitter:card" content="${twitterCard}">`,
|
|
102
88
|
`<meta name="twitter:title" content="${esc(title)}">`,
|
|
103
89
|
`<meta name="twitter:description" content="${esc(description)}">`,
|
|
104
90
|
);
|
|
105
|
-
|
|
106
91
|
if (imgUrl) {
|
|
107
92
|
lines.push(`<meta name="twitter:image" content="${esc(imgUrl)}">`);
|
|
108
93
|
}
|
|
109
|
-
|
|
110
94
|
return lines.join("\n");
|
|
111
95
|
}
|
|
112
96
|
|
|
@@ -127,295 +111,173 @@ function accentHex(accent: PaletteColor | undefined): string {
|
|
|
127
111
|
}
|
|
128
112
|
|
|
129
113
|
function colorHex(
|
|
130
|
-
color:
|
|
114
|
+
color: string | undefined,
|
|
131
115
|
accent: string,
|
|
132
116
|
): string {
|
|
133
117
|
if (!color || color === PALETTE_COLOR_ACCENT) return accent;
|
|
134
|
-
return PALETTE_LIGHT_HEX[color] ?? accent;
|
|
118
|
+
return (PALETTE_LIGHT_HEX as Record<string, string>)[color] ?? accent;
|
|
135
119
|
}
|
|
136
120
|
|
|
137
121
|
// ─── Element renderers ──────────────────────────────────
|
|
138
122
|
|
|
139
|
-
function renderElement(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return renderText(el, accent);
|
|
143
|
-
case "image":
|
|
144
|
-
return renderImage(el);
|
|
145
|
-
case "grid":
|
|
146
|
-
return renderGrid(el);
|
|
147
|
-
case "progress":
|
|
148
|
-
return renderProgress(el, accent);
|
|
149
|
-
case "bar_chart":
|
|
150
|
-
return renderBarChart(el, accent);
|
|
151
|
-
case "list":
|
|
152
|
-
return renderList(el);
|
|
153
|
-
case "button_group":
|
|
154
|
-
return renderButtonGroup(el, accent);
|
|
155
|
-
case "slider":
|
|
156
|
-
return renderSlider(el, accent);
|
|
157
|
-
case "text_input":
|
|
158
|
-
return renderTextInput(el);
|
|
159
|
-
case "toggle":
|
|
160
|
-
return renderToggle(el, accent);
|
|
161
|
-
case "group":
|
|
162
|
-
return renderGroup(el, accent);
|
|
163
|
-
case "divider":
|
|
164
|
-
return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
|
|
165
|
-
case "spacer":
|
|
166
|
-
return renderSpacer(el);
|
|
167
|
-
default:
|
|
168
|
-
return "";
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
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";
|
|
179
|
-
const styles: Record<string, string> = {
|
|
180
|
-
title: "font-size:20px;font-weight:700;color:#111",
|
|
181
|
-
body: "font-size:15px;line-height:1.5;color:#374151",
|
|
182
|
-
caption: "font-size:13px;color:#9CA3AF",
|
|
183
|
-
label:
|
|
184
|
-
"font-size:13px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:0.5px",
|
|
185
|
-
};
|
|
186
|
-
return `<div style="${
|
|
187
|
-
styles[style] ?? styles.body
|
|
188
|
-
};text-align:${align}">${content}</div>`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function renderImage(
|
|
192
|
-
el: Extract<SnapPageElementInput, { type: "image" }>,
|
|
193
|
-
): string {
|
|
194
|
-
const url = esc(el.url);
|
|
195
|
-
const aspect = el.aspect ?? "16:9";
|
|
196
|
-
const [w, h] = aspect.split(":").map(Number);
|
|
197
|
-
const ratio = w && h ? `${w}/${h}` : "16/9";
|
|
198
|
-
return `<div style="aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(
|
|
199
|
-
el.alt ?? "",
|
|
200
|
-
)}" style="width:100%;height:100%;object-fit:cover"></div>`;
|
|
201
|
-
}
|
|
202
|
-
|
|
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";
|
|
209
|
-
const gapPx: Record<string, string> = {
|
|
210
|
-
none: "0",
|
|
211
|
-
small: "2px",
|
|
212
|
-
medium: "4px",
|
|
213
|
-
};
|
|
214
|
-
const cellMap = new Map<string, (typeof cells)[0]>();
|
|
215
|
-
for (const c of cells) cellMap.set(`${c.row},${c.col}`, c);
|
|
216
|
-
|
|
217
|
-
let cellsHtml = "";
|
|
218
|
-
for (let r = 0; r < rows; r++) {
|
|
219
|
-
for (let c = 0; c < cols; c++) {
|
|
220
|
-
const cell = cellMap.get(`${r},${c}`);
|
|
221
|
-
const bg = cell?.color ?? "transparent";
|
|
222
|
-
const content = cell?.content ? esc(cell.content) : "";
|
|
223
|
-
const sq = cellSize === "square" ? "aspect-ratio:1;" : "";
|
|
224
|
-
cellsHtml += `<div style="${sq}background:${bg};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;border-radius:2px">${content}</div>`;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return `<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:${
|
|
229
|
-
gapPx[gap] ?? "2px"
|
|
230
|
-
}">${cellsHtml}</div>`;
|
|
231
|
-
}
|
|
232
|
-
|
|
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);
|
|
239
|
-
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
240
|
-
const labelHtml = label
|
|
241
|
-
? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(
|
|
242
|
-
label,
|
|
243
|
-
)}</div>`
|
|
244
|
-
: "";
|
|
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>`;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function renderBarChart(
|
|
249
|
-
el: Extract<SnapPageElementInput, { type: "bar_chart" }>,
|
|
123
|
+
function renderElement(
|
|
124
|
+
key: string,
|
|
125
|
+
spec: SnapSpec,
|
|
250
126
|
accent: string,
|
|
251
127
|
): string {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const defaultColor = colorHex(el.color, accent);
|
|
256
|
-
|
|
257
|
-
let html = `<div style="display:flex;align-items:flex-end;gap:12px;height:120px">`;
|
|
258
|
-
for (const bar of bars) {
|
|
259
|
-
const color = colorHex(bar.color, defaultColor);
|
|
260
|
-
const pct = max > 0 ? (bar.value / max) * 100 : 0;
|
|
261
|
-
html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;height:100%;justify-content:flex-end">`;
|
|
262
|
-
html += `<div style="font-size:11px;color:#6B7280;margin-bottom:4px">${bar.value}</div>`;
|
|
263
|
-
html += `<div style="width:100%;height:${pct}%;background:${color};border-radius:4px 4px 0 0;min-height:4px"></div>`;
|
|
264
|
-
html += `<div style="font-size:11px;color:#9CA3AF;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%">${esc(
|
|
265
|
-
bar.label,
|
|
266
|
-
)}</div>`;
|
|
267
|
-
html += `</div>`;
|
|
268
|
-
}
|
|
269
|
-
html += `</div>`;
|
|
270
|
-
return html;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function renderList(
|
|
274
|
-
el: Extract<SnapPageElementInput, { type: "list" }>,
|
|
275
|
-
): string {
|
|
276
|
-
const style = el.style ?? "ordered";
|
|
277
|
-
const { items } = el;
|
|
128
|
+
const el = spec.elements[key] as SnapUIElement | undefined;
|
|
129
|
+
if (!el) return "";
|
|
130
|
+
const p = el.props ?? {};
|
|
278
131
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
132
|
+
switch (el.type) {
|
|
133
|
+
case "icon": {
|
|
134
|
+
const color = colorHex(p.color as string | undefined, accent);
|
|
135
|
+
const size = String(p.size ?? "md") === "sm" ? 16 : 20;
|
|
136
|
+
// Simplified inline SVG for common icons; falls back to a circle for unknown
|
|
137
|
+
const name = String(p.name ?? "info");
|
|
138
|
+
const iconSvgs: Record<string, string> = {
|
|
139
|
+
check: `<polyline points="20 6 9 17 4 12"/>`,
|
|
140
|
+
x: `<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`,
|
|
141
|
+
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"/>`,
|
|
142
|
+
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"/>`,
|
|
143
|
+
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"/>`,
|
|
144
|
+
"arrow-right": `<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>`,
|
|
145
|
+
"chevron-right": `<polyline points="9 18 15 12 9 6"/>`,
|
|
146
|
+
"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"/>`,
|
|
147
|
+
zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
|
|
148
|
+
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"/>`,
|
|
149
|
+
clock: `<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`,
|
|
150
|
+
};
|
|
151
|
+
const inner = iconSvgs[name] ?? `<circle cx="12" cy="12" r="4"/>`;
|
|
152
|
+
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>`;
|
|
153
|
+
}
|
|
154
|
+
case "badge": {
|
|
155
|
+
const color = colorHex(p.color as string | undefined, accent);
|
|
156
|
+
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>`;
|
|
157
|
+
}
|
|
158
|
+
case "image": {
|
|
159
|
+
const url = esc(String(p.url ?? ""));
|
|
160
|
+
const aspect = String(p.aspect ?? "16:9");
|
|
161
|
+
const [w, h] = aspect.split(":").map(Number);
|
|
162
|
+
const ratio = w && h ? `${w}/${h}` : "16/9";
|
|
163
|
+
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>`;
|
|
164
|
+
}
|
|
165
|
+
case "item": {
|
|
166
|
+
const variant = String(p.variant ?? "default");
|
|
167
|
+
const variantStyles: Record<string, string> = {
|
|
168
|
+
default: "",
|
|
169
|
+
outline: "border:1px solid #E5E7EB;border-radius:8px;padding:12px;",
|
|
170
|
+
muted: "background:#F9FAFB;border-radius:8px;padding:12px;",
|
|
171
|
+
};
|
|
172
|
+
const descHtml = p.description ? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>` : "";
|
|
173
|
+
const childIds = el.children ?? [];
|
|
174
|
+
const actionsHtml = childIds.length > 0
|
|
175
|
+
? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds.map((id) => renderElement(id, spec, accent)).join("")}</div>`
|
|
287
176
|
: "";
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const dir =
|
|
393
|
-
layout === "row"
|
|
394
|
-
? "flex-direction:row"
|
|
395
|
-
: layout === "grid"
|
|
396
|
-
? "display:grid;grid-template-columns:1fr 1fr"
|
|
397
|
-
: "flex-direction:column";
|
|
398
|
-
const wrap =
|
|
399
|
-
layout === "row"
|
|
400
|
-
? "display:flex;"
|
|
401
|
-
: layout === "grid"
|
|
402
|
-
? ""
|
|
403
|
-
: "display:flex;";
|
|
404
|
-
|
|
405
|
-
let html = `<div style="${wrap}${dir};gap:8px;margin-top:12px">`;
|
|
406
|
-
for (let i = 0; i < buttons.length; i++) {
|
|
407
|
-
const btn: SnapPageButton = buttons[i]!;
|
|
408
|
-
const label = esc(btn.label);
|
|
409
|
-
const style = btn.style ?? (i === 0 ? "primary" : "secondary");
|
|
410
|
-
const bg = style === "primary" ? accent : "transparent";
|
|
411
|
-
const color = style === "primary" ? "#fff" : accent;
|
|
412
|
-
const border = style === "primary" ? "none" : `2px solid ${accent}`;
|
|
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>`;
|
|
177
|
+
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>`;
|
|
178
|
+
}
|
|
179
|
+
case "item_group": {
|
|
180
|
+
const childIds = el.children ?? [];
|
|
181
|
+
const border = Boolean(p.border);
|
|
182
|
+
const separator = Boolean(p.separator);
|
|
183
|
+
const outerStyle = border ? "border:1px solid #E5E7EB;border-radius:8px;overflow:hidden" : "";
|
|
184
|
+
let html = `<div style="display:flex;flex-direction:column;${outerStyle}">`;
|
|
185
|
+
for (let i = 0; i < childIds.length; i++) {
|
|
186
|
+
if (separator && i > 0) {
|
|
187
|
+
html += `<hr style="border:none;border-top:1px solid #E5E7EB;margin:0 12px">`;
|
|
188
|
+
}
|
|
189
|
+
const pad = border ? "padding:8px 12px;" : separator ? "padding:8px 0;" : "";
|
|
190
|
+
html += `<div style="${pad}">${renderElement(childIds[i]!, spec, accent)}</div>`;
|
|
191
|
+
}
|
|
192
|
+
html += `</div>`;
|
|
193
|
+
return html;
|
|
194
|
+
}
|
|
195
|
+
case "progress": {
|
|
196
|
+
const value = Number(p.value ?? 0);
|
|
197
|
+
const max = Number(p.max ?? 100);
|
|
198
|
+
const color = accent;
|
|
199
|
+
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
200
|
+
const labelHtml = p.label ? `<div style="font-size:13px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
|
|
201
|
+
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>`;
|
|
202
|
+
}
|
|
203
|
+
case "separator": {
|
|
204
|
+
const orientation = String(p.orientation ?? "horizontal");
|
|
205
|
+
if (orientation === "vertical") return `<div style="width:1px;background:#E5E7EB;align-self:stretch;min-height:16px"></div>`;
|
|
206
|
+
return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
|
|
207
|
+
}
|
|
208
|
+
case "slider": {
|
|
209
|
+
const min = Number(p.min ?? 0);
|
|
210
|
+
const max = Number(p.max ?? 100);
|
|
211
|
+
const value = p.defaultValue !== undefined ? Number(p.defaultValue) : (min + max) / 2;
|
|
212
|
+
const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
|
|
213
|
+
return `<div>${labelHtml}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="width:100%;accent-color:${accent};opacity:0.7"></div>`;
|
|
214
|
+
}
|
|
215
|
+
case "switch": {
|
|
216
|
+
const checked = Boolean(p.defaultChecked);
|
|
217
|
+
const bg = checked ? accent : "#D1D5DB";
|
|
218
|
+
const tx = checked ? "20px" : "2px";
|
|
219
|
+
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>`;
|
|
220
|
+
}
|
|
221
|
+
case "input": {
|
|
222
|
+
const labelHtml = p.label ? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</label>` : "";
|
|
223
|
+
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>`;
|
|
224
|
+
}
|
|
225
|
+
case "toggle_group": {
|
|
226
|
+
const options = Array.isArray(p.options) ? p.options as string[] : [];
|
|
227
|
+
const orientation = String(p.orientation ?? "horizontal");
|
|
228
|
+
const dir = orientation === "vertical" ? "column" : "row";
|
|
229
|
+
const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
|
|
230
|
+
let html = `<div>${labelHtml}<div style="display:flex;flex-direction:${dir};gap:4px;padding:4px;background:#F3F4F6;border-radius:8px">`;
|
|
231
|
+
for (const opt of options) {
|
|
232
|
+
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>`;
|
|
233
|
+
}
|
|
234
|
+
html += `</div></div>`;
|
|
235
|
+
return html;
|
|
236
|
+
}
|
|
237
|
+
case "button": {
|
|
238
|
+
const variant = String(p.variant ?? "default");
|
|
239
|
+
const bg = variant === "default" ? accent : "transparent";
|
|
240
|
+
const color = variant === "default" ? "#fff" : accent;
|
|
241
|
+
const border = variant === "default" ? "none" : `2px solid ${accent}`;
|
|
242
|
+
const pad = variant === "default" ? "18px 16px" : "10px 16px";
|
|
243
|
+
const minH = variant === "default" ? "min-height:52px;" : "";
|
|
244
|
+
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>`;
|
|
245
|
+
}
|
|
246
|
+
case "text": {
|
|
247
|
+
const size = String(p.size ?? "md");
|
|
248
|
+
const weight = String(p.weight ?? (size === "lg" ? "bold" : "normal"));
|
|
249
|
+
const align = String(p.align ?? "left");
|
|
250
|
+
const styles: Record<string, string> = {
|
|
251
|
+
lg: "font-size:20px",
|
|
252
|
+
md: "font-size:15px;line-height:1.5",
|
|
253
|
+
sm: "font-size:13px",
|
|
254
|
+
};
|
|
255
|
+
const weights: Record<string, string> = {
|
|
256
|
+
bold: "font-weight:700",
|
|
257
|
+
medium: "font-weight:500",
|
|
258
|
+
normal: "font-weight:400",
|
|
259
|
+
};
|
|
260
|
+
return `<div style="${styles[size] ?? styles.md};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
|
|
261
|
+
}
|
|
262
|
+
case "stack": {
|
|
263
|
+
const direction = String(p.direction ?? "vertical");
|
|
264
|
+
const gap: Record<string, string> = { none: "0", sm: "4px", md: "8px", lg: "16px" };
|
|
265
|
+
const gapVal = gap[String(p.gap ?? "md")] ?? "8px";
|
|
266
|
+
const dir = direction === "horizontal" ? "row" : "column";
|
|
267
|
+
const justifyMap: Record<string, string> = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around" };
|
|
268
|
+
const jc = p.justify ? justifyMap[String(p.justify)] : undefined;
|
|
269
|
+
const childIds = el.children ?? [];
|
|
270
|
+
let html = `<div style="display:flex;flex-direction:${dir};gap:${gapVal}${jc ? `;justify-content:${jc}` : ""}">`;
|
|
271
|
+
for (const childKey of childIds) {
|
|
272
|
+
const flex = direction === "horizontal" ? "flex:1;" : "";
|
|
273
|
+
html += `<div style="${flex}">${renderElement(childKey, spec, accent)}</div>`;
|
|
274
|
+
}
|
|
275
|
+
html += `</div>`;
|
|
276
|
+
return html;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
return "";
|
|
416
280
|
}
|
|
417
|
-
html += `</div>`;
|
|
418
|
-
return html;
|
|
419
281
|
}
|
|
420
282
|
|
|
421
283
|
// ─── Main renderer ──────────────────────────────────────
|
|
@@ -425,10 +287,10 @@ export function renderSnapPage(
|
|
|
425
287
|
snapOrigin: string,
|
|
426
288
|
opts?: RenderSnapPageOptions,
|
|
427
289
|
): string {
|
|
428
|
-
const
|
|
429
|
-
const accent = accentHex(
|
|
290
|
+
const spec = snap.ui as unknown as SnapSpec;
|
|
291
|
+
const accent = accentHex(snap.theme?.accent);
|
|
430
292
|
|
|
431
|
-
const meta = extractPageMeta(
|
|
293
|
+
const meta = extractPageMeta(spec);
|
|
432
294
|
const pageTitle = esc(meta.title);
|
|
433
295
|
const resourcePath = opts?.resourcePath ?? "/";
|
|
434
296
|
const pageUrl = snapOrigin.replace(/\/$/, "") + resourcePath;
|
|
@@ -442,18 +304,7 @@ export function renderSnapPage(
|
|
|
442
304
|
});
|
|
443
305
|
|
|
444
306
|
const snapUrl = encodeURIComponent(snapOrigin + "/");
|
|
445
|
-
|
|
446
|
-
// Render elements
|
|
447
|
-
let elementsHtml = "";
|
|
448
|
-
for (const el of page.elements.children) {
|
|
449
|
-
elementsHtml += `<div style="margin-bottom:12px">${renderElement(
|
|
450
|
-
el,
|
|
451
|
-
accent,
|
|
452
|
-
)}</div>`;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Render buttons
|
|
456
|
-
const buttonsHtml = renderButtons(page.buttons, page.button_layout, accent);
|
|
307
|
+
const bodyHtml = renderElement(spec.root, spec, accent);
|
|
457
308
|
|
|
458
309
|
return `<!DOCTYPE html>
|
|
459
310
|
<html lang="en">
|
|
@@ -484,8 +335,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
|
|
|
484
335
|
</head>
|
|
485
336
|
<body>
|
|
486
337
|
<div class="card">
|
|
487
|
-
${
|
|
488
|
-
${buttonsHtml}
|
|
338
|
+
${bodyHtml}
|
|
489
339
|
</div>
|
|
490
340
|
<div class="foot">
|
|
491
341
|
<a href="https://farcaster.xyz">${FC_ICON} Farcaster</a>
|