@farcaster/snap-hono 1.4.7 → 1.4.9
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/fallback.d.ts +2 -0
- package/dist/fallback.js +9 -5
- package/dist/index.d.ts +10 -0
- package/dist/index.js +15 -4
- package/dist/renderSnapPage.d.ts +4 -0
- package/dist/renderSnapPage.js +67 -20
- package/package.json +2 -2
- package/src/fallback.ts +18 -7
- package/src/index.ts +27 -2
- package/src/renderSnapPage.ts +147 -49
package/dist/fallback.d.ts
CHANGED
package/dist/fallback.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
const FARCASTER_ICON_SVG = `<svg aria-hidden="true" focusable="false" 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>`;
|
|
2
|
+
const DEFAULT_BRANDED_TITLE = "Farcaster Snap";
|
|
3
|
+
const DEFAULT_BRANDED_DESCRIPTION = "An interactive embed for Farcaster.";
|
|
2
4
|
export function brandedFallbackHtml(snapOrigin, og) {
|
|
3
5
|
const snapUrl = encodeURIComponent(snapOrigin + "/");
|
|
4
6
|
const testUrl = `https://farcaster.xyz/~/developers/snaps?url=${snapUrl}`;
|
|
5
7
|
const pageUrl = snapOrigin + (og?.resourcePath ?? "/");
|
|
8
|
+
const pageTitle = og?.title ?? DEFAULT_BRANDED_TITLE;
|
|
9
|
+
const pageDescription = og?.description ?? DEFAULT_BRANDED_DESCRIPTION;
|
|
6
10
|
const ogLines = [
|
|
7
|
-
`<meta name="description" content="
|
|
8
|
-
`<meta property="og:title" content="
|
|
9
|
-
`<meta property="og:description" content="
|
|
11
|
+
`<meta name="description" content="${escHtml(pageDescription)}">`,
|
|
12
|
+
`<meta property="og:title" content="${escHtml(pageTitle)}">`,
|
|
13
|
+
`<meta property="og:description" content="${escHtml(pageDescription)}">`,
|
|
10
14
|
`<meta property="og:url" content="${escHtml(pageUrl)}">`,
|
|
11
15
|
`<meta property="og:type" content="website">`,
|
|
12
16
|
`<meta property="og:locale" content="en_US">`,
|
|
@@ -20,13 +24,13 @@ export function brandedFallbackHtml(snapOrigin, og) {
|
|
|
20
24
|
else {
|
|
21
25
|
ogLines.push(`<meta name="twitter:card" content="summary">`);
|
|
22
26
|
}
|
|
23
|
-
ogLines.push(`<meta name="twitter:title" content="
|
|
27
|
+
ogLines.push(`<meta name="twitter:title" content="${escHtml(pageTitle)}">`, `<meta name="twitter:description" content="${escHtml(pageDescription)}">`);
|
|
24
28
|
return `<!DOCTYPE html>
|
|
25
29
|
<html lang="en">
|
|
26
30
|
<head>
|
|
27
31
|
<meta charset="utf-8">
|
|
28
32
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29
|
-
<title
|
|
33
|
+
<title>${escHtml(pageTitle)}</title>
|
|
30
34
|
${ogLines.join("\n")}
|
|
31
35
|
<style>
|
|
32
36
|
*{margin:0;padding:0;box-sizing:border-box}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
2
|
import { type SnapFunction } from "@farcaster/snap";
|
|
3
3
|
import { type OgOptions } from "./og-image.js";
|
|
4
|
+
export type SnapOpenGraphMeta = {
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
};
|
|
4
8
|
export type SnapHandlerOptions = {
|
|
5
9
|
/**
|
|
6
10
|
* Route path to register GET and POST handlers on.
|
|
@@ -17,6 +21,12 @@ export type SnapHandlerOptions = {
|
|
|
17
21
|
* over the default branded fallback.
|
|
18
22
|
*/
|
|
19
23
|
fallbackHtml?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Open Graph title/description overrides for the HTML fallback page meta tags
|
|
26
|
+
* (and document title). When a field is omitted, the usual extraction from snap
|
|
27
|
+
* UI or branded defaults applies.
|
|
28
|
+
*/
|
|
29
|
+
openGraph?: SnapOpenGraphMeta;
|
|
20
30
|
/**
|
|
21
31
|
* Open Graph configuration. Set to `false` to disable OG tag injection and
|
|
22
32
|
* the `/~/og-image` route. Pass an `OgOptions` object to customize rendering.
|
package/dist/index.js
CHANGED
|
@@ -93,7 +93,7 @@ export function registerSnapHandler(app, snapFn, options = {}) {
|
|
|
93
93
|
const accept = c.req.header("Accept");
|
|
94
94
|
if (!clientWantsSnapResponse(accept)) {
|
|
95
95
|
const fallbackHtml = options.fallbackHtml ??
|
|
96
|
-
(await getFallbackHtml(c.req.raw, snapFn, ogEnabled ? buildOgImageUrl(c.req.raw, path) : undefined));
|
|
96
|
+
(await getFallbackHtml(c.req.raw, snapFn, ogEnabled ? buildOgImageUrl(c.req.raw, path) : undefined, options.openGraph));
|
|
97
97
|
return new Response(fallbackHtml, {
|
|
98
98
|
status: 200,
|
|
99
99
|
headers: snapHeaders(resourcePath, "text/html", [
|
|
@@ -172,7 +172,7 @@ function resourcePathFromRequest(url) {
|
|
|
172
172
|
const u = new URL(url);
|
|
173
173
|
return u.pathname + u.search;
|
|
174
174
|
}
|
|
175
|
-
async function getFallbackHtml(request, snapFn, ogImageUrl) {
|
|
175
|
+
async function getFallbackHtml(request, snapFn, ogImageUrl, openGraph) {
|
|
176
176
|
const origin = snapOriginFromRequest(request);
|
|
177
177
|
const siteName = process.env.SNAP_OG_SITE_NAME?.trim() ||
|
|
178
178
|
process.env.OG_SITE_NAME?.trim() ||
|
|
@@ -183,10 +183,21 @@ async function getFallbackHtml(request, snapFn, ogImageUrl) {
|
|
|
183
183
|
action: { type: ACTION_TYPE_GET },
|
|
184
184
|
request: stripAuthHeaders(request),
|
|
185
185
|
});
|
|
186
|
-
return renderSnapPage(snap, origin, {
|
|
186
|
+
return renderSnapPage(snap, origin, {
|
|
187
|
+
ogImageUrl,
|
|
188
|
+
resourcePath,
|
|
189
|
+
siteName,
|
|
190
|
+
openGraph,
|
|
191
|
+
});
|
|
187
192
|
}
|
|
188
193
|
catch {
|
|
189
|
-
return brandedFallbackHtml(origin, {
|
|
194
|
+
return brandedFallbackHtml(origin, {
|
|
195
|
+
ogImageUrl,
|
|
196
|
+
resourcePath,
|
|
197
|
+
siteName,
|
|
198
|
+
title: openGraph?.title,
|
|
199
|
+
description: openGraph?.description,
|
|
200
|
+
});
|
|
190
201
|
}
|
|
191
202
|
}
|
|
192
203
|
function snapOriginFromRequest(request) {
|
package/dist/renderSnapPage.d.ts
CHANGED
package/dist/renderSnapPage.js
CHANGED
|
@@ -155,7 +155,9 @@ function renderElement(key, spec, accent) {
|
|
|
155
155
|
const badgeVariant = String(p.variant ?? "default");
|
|
156
156
|
const isFilled = badgeVariant === "default";
|
|
157
157
|
const fg = isFilled ? fgForBg(color) : color;
|
|
158
|
-
const bgStyle = isFilled
|
|
158
|
+
const bgStyle = isFilled
|
|
159
|
+
? `background:${color};color:${fg}`
|
|
160
|
+
: `border:1px solid ${color};color:${color}`;
|
|
159
161
|
const iconName = p.icon ? String(p.icon) : undefined;
|
|
160
162
|
const iconHtml = iconName ? renderIcon(iconName, 12, fg) : "";
|
|
161
163
|
const gap = iconHtml ? "gap:4px;" : "";
|
|
@@ -169,10 +171,14 @@ function renderElement(key, spec, accent) {
|
|
|
169
171
|
return `<div style="flex:1;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>`;
|
|
170
172
|
}
|
|
171
173
|
case "item": {
|
|
172
|
-
const descHtml = p.description
|
|
174
|
+
const descHtml = p.description
|
|
175
|
+
? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>`
|
|
176
|
+
: "";
|
|
173
177
|
const childIds = el.children ?? [];
|
|
174
178
|
const actionsHtml = childIds.length > 0
|
|
175
|
-
? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds
|
|
179
|
+
? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds
|
|
180
|
+
.map((id) => renderElement(id, spec, accent))
|
|
181
|
+
.join("")}</div>`
|
|
176
182
|
: "";
|
|
177
183
|
return `<div style="display:flex;align-items:flex-start;padding:6px 10px"><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
184
|
}
|
|
@@ -180,13 +186,19 @@ function renderElement(key, spec, accent) {
|
|
|
180
186
|
const childIds = el.children ?? [];
|
|
181
187
|
const border = Boolean(p.border);
|
|
182
188
|
const separator = Boolean(p.separator);
|
|
183
|
-
const outerStyle = border
|
|
189
|
+
const outerStyle = border
|
|
190
|
+
? "border:1px solid #E5E7EB;border-radius:8px;overflow:hidden"
|
|
191
|
+
: "";
|
|
184
192
|
let html = `<div style="display:flex;flex-direction:column;${outerStyle}">`;
|
|
185
193
|
for (let i = 0; i < childIds.length; i++) {
|
|
186
194
|
if (separator && i > 0) {
|
|
187
195
|
html += `<hr style="border:none;border-top:1px solid #E5E7EB;margin:0 12px">`;
|
|
188
196
|
}
|
|
189
|
-
const pad = border
|
|
197
|
+
const pad = border
|
|
198
|
+
? "padding:8px 12px;"
|
|
199
|
+
: separator
|
|
200
|
+
? "padding:8px 0;"
|
|
201
|
+
: "";
|
|
190
202
|
html += `<div style="${pad}">${renderElement(childIds[i], spec, accent)}</div>`;
|
|
191
203
|
}
|
|
192
204
|
html += `</div>`;
|
|
@@ -196,7 +208,9 @@ function renderElement(key, spec, accent) {
|
|
|
196
208
|
const value = Number(p.value ?? 0);
|
|
197
209
|
const max = Number(p.max ?? 100);
|
|
198
210
|
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
199
|
-
const labelHtml = p.label
|
|
211
|
+
const labelHtml = p.label
|
|
212
|
+
? `<div style="font-size:12px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>`
|
|
213
|
+
: "";
|
|
200
214
|
return `<div style="display:flex;flex:1;flex-direction:column;gap:4px">${labelHtml}<div style="height:10px;background:#E5E7EB;border-radius:9999px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${accent};border-radius:9999px;transition:width 0.3s"></div></div></div>`;
|
|
201
215
|
}
|
|
202
216
|
case "separator": {
|
|
@@ -209,7 +223,9 @@ function renderElement(key, spec, accent) {
|
|
|
209
223
|
const min = Number(p.min ?? 0);
|
|
210
224
|
const max = Number(p.max ?? 100);
|
|
211
225
|
const value = p.defaultValue !== undefined ? Number(p.defaultValue) : (min + max) / 2;
|
|
212
|
-
const labelHtml = p.label
|
|
226
|
+
const labelHtml = p.label
|
|
227
|
+
? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>`
|
|
228
|
+
: "";
|
|
213
229
|
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="width:100%;height:10px;border-radius:9999px;accent-color:${accent};background:#E5E7EB;-webkit-appearance:none;appearance:none"></div>`;
|
|
214
230
|
}
|
|
215
231
|
case "switch": {
|
|
@@ -219,7 +235,9 @@ function renderElement(key, spec, accent) {
|
|
|
219
235
|
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px"><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;transition:background 0.2s"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx};transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2)"></div></div></div>`;
|
|
220
236
|
}
|
|
221
237
|
case "input": {
|
|
222
|
-
const labelHtml = p.label
|
|
238
|
+
const labelHtml = p.label
|
|
239
|
+
? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</label>`
|
|
240
|
+
: "";
|
|
223
241
|
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}<input type="text" placeholder="${esc(String(p.placeholder ?? ""))}" readonly style="width:100%;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#fff;font-size:14px;color:#374151;font-family:inherit;box-sizing:border-box"></div>`;
|
|
224
242
|
}
|
|
225
243
|
case "toggle_group": {
|
|
@@ -227,7 +245,9 @@ function renderElement(key, spec, accent) {
|
|
|
227
245
|
const orientation = String(p.orientation ?? "horizontal");
|
|
228
246
|
const dir = orientation === "vertical" ? "column" : "row";
|
|
229
247
|
const defaultVal = p.defaultValue !== undefined ? String(p.defaultValue) : undefined;
|
|
230
|
-
const labelHtml = p.label
|
|
248
|
+
const labelHtml = p.label
|
|
249
|
+
? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>`
|
|
250
|
+
: "";
|
|
231
251
|
let html = `<div>${labelHtml}<div style="display:flex;flex-direction:${dir};gap:4px;padding:4px;background:rgba(229,231,235,0.2);border-radius:8px">`;
|
|
232
252
|
for (const opt of options) {
|
|
233
253
|
const selected = defaultVal === opt;
|
|
@@ -269,14 +289,30 @@ function renderElement(key, spec, accent) {
|
|
|
269
289
|
case "stack": {
|
|
270
290
|
const direction = String(p.direction ?? "vertical");
|
|
271
291
|
const isHorizontal = direction === "horizontal";
|
|
272
|
-
const vGap = {
|
|
273
|
-
|
|
292
|
+
const vGap = {
|
|
293
|
+
none: "0",
|
|
294
|
+
sm: "8px",
|
|
295
|
+
md: "16px",
|
|
296
|
+
lg: "24px",
|
|
297
|
+
};
|
|
298
|
+
const hGap = {
|
|
299
|
+
none: "0",
|
|
300
|
+
sm: "4px",
|
|
301
|
+
md: "8px",
|
|
302
|
+
lg: "12px",
|
|
303
|
+
};
|
|
274
304
|
const gapMap = isHorizontal ? hGap : vGap;
|
|
275
305
|
const gapVal = gapMap[String(p.gap ?? "md")] ?? (isHorizontal ? "8px" : "16px");
|
|
276
306
|
const dir = isHorizontal ? "row" : "column";
|
|
277
307
|
const wrap = isHorizontal ? "flex-wrap:wrap;" : "";
|
|
278
308
|
const align = isHorizontal ? "align-items:center;" : "";
|
|
279
|
-
const justifyMap = {
|
|
309
|
+
const justifyMap = {
|
|
310
|
+
start: "flex-start",
|
|
311
|
+
center: "center",
|
|
312
|
+
end: "flex-end",
|
|
313
|
+
between: "space-between",
|
|
314
|
+
around: "space-around",
|
|
315
|
+
};
|
|
280
316
|
const jc = p.justify ? justifyMap[String(p.justify)] : undefined;
|
|
281
317
|
const childIds = el.children ?? [];
|
|
282
318
|
let html = `<div style="display:flex;width:100%;flex-direction:${dir};gap:${gapVal};${wrap}${align}${jc ? `justify-content:${jc};` : ""}">`;
|
|
@@ -288,9 +324,13 @@ function renderElement(key, spec, accent) {
|
|
|
288
324
|
return html;
|
|
289
325
|
}
|
|
290
326
|
case "bar_chart": {
|
|
291
|
-
const bars = Array.isArray(p.bars)
|
|
327
|
+
const bars = Array.isArray(p.bars)
|
|
328
|
+
? p.bars
|
|
329
|
+
: [];
|
|
292
330
|
const chartColor = colorHex(p.color, accent);
|
|
293
|
-
const maxVal = p.max != null
|
|
331
|
+
const maxVal = p.max != null
|
|
332
|
+
? Number(p.max)
|
|
333
|
+
: Math.max(...bars.map((b) => Number(b.value ?? 0)), 1);
|
|
294
334
|
let html = `<div style="display:flex;flex-direction:column;gap:8px;width:100%">`;
|
|
295
335
|
for (const bar of bars) {
|
|
296
336
|
const value = Number(bar.value ?? 0);
|
|
@@ -308,13 +348,18 @@ function renderElement(key, spec, accent) {
|
|
|
308
348
|
case "cell_grid": {
|
|
309
349
|
const cols = Number(p.cols ?? 2);
|
|
310
350
|
const rows = Number(p.rows ?? 2);
|
|
311
|
-
const cells = Array.isArray(p.cells)
|
|
351
|
+
const cells = Array.isArray(p.cells)
|
|
352
|
+
? p.cells
|
|
353
|
+
: [];
|
|
312
354
|
const gap = String(p.gap ?? "sm");
|
|
313
355
|
const gapMap = { none: 0, sm: 1, md: 2, lg: 4 };
|
|
314
356
|
const gapPx = gapMap[gap] ?? 1;
|
|
315
357
|
const cellMap = new Map();
|
|
316
358
|
for (const c of cells) {
|
|
317
|
-
cellMap.set(`${Number(c.row ?? 0)},${Number(c.col ?? 0)}`, {
|
|
359
|
+
cellMap.set(`${Number(c.row ?? 0)},${Number(c.col ?? 0)}`, {
|
|
360
|
+
color: c.color,
|
|
361
|
+
content: c.content,
|
|
362
|
+
});
|
|
318
363
|
}
|
|
319
364
|
let html = `<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:${gapPx}px;width:100%">`;
|
|
320
365
|
for (let r = 0; r < rows; r++) {
|
|
@@ -337,15 +382,17 @@ export function renderSnapPage(snap, snapOrigin, opts) {
|
|
|
337
382
|
const spec = snap.ui;
|
|
338
383
|
const accent = accentHex(snap.theme?.accent);
|
|
339
384
|
const meta = extractPageMeta(spec);
|
|
340
|
-
const
|
|
385
|
+
const title = opts?.openGraph?.title ?? meta.title;
|
|
386
|
+
const description = opts?.openGraph?.description ?? meta.description;
|
|
387
|
+
const pageTitle = esc(title);
|
|
341
388
|
const resourcePath = opts?.resourcePath ?? "/";
|
|
342
389
|
const pageUrl = snapOrigin.replace(/\/$/, "") + resourcePath;
|
|
343
390
|
const ogMeta = buildOgMeta({
|
|
344
|
-
title
|
|
345
|
-
description
|
|
391
|
+
title,
|
|
392
|
+
description,
|
|
346
393
|
pageUrl,
|
|
347
394
|
ogImageUrl: opts?.ogImageUrl,
|
|
348
|
-
imageAlt: meta.imageAlt ?? meta.imageUrl ?
|
|
395
|
+
imageAlt: meta.imageAlt ?? meta.imageUrl ? title : undefined,
|
|
349
396
|
siteName: opts?.siteName,
|
|
350
397
|
});
|
|
351
398
|
const snapUrl = encodeURIComponent(snapOrigin + "/");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farcaster/snap-hono",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.9",
|
|
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": "1.15.
|
|
31
|
+
"@farcaster/snap": "1.15.2"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"hono": ">=4.0.0"
|
package/src/fallback.ts
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
const FARCASTER_ICON_SVG = `<svg aria-hidden="true" focusable="false" 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>`;
|
|
2
2
|
|
|
3
|
+
const DEFAULT_BRANDED_TITLE = "Farcaster Snap";
|
|
4
|
+
const DEFAULT_BRANDED_DESCRIPTION = "An interactive embed for Farcaster.";
|
|
5
|
+
|
|
3
6
|
export function brandedFallbackHtml(
|
|
4
7
|
snapOrigin: string,
|
|
5
|
-
og?: {
|
|
8
|
+
og?: {
|
|
9
|
+
ogImageUrl?: string;
|
|
10
|
+
resourcePath?: string;
|
|
11
|
+
siteName?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
},
|
|
6
15
|
): string {
|
|
7
16
|
const snapUrl = encodeURIComponent(snapOrigin + "/");
|
|
8
17
|
const testUrl = `https://farcaster.xyz/~/developers/snaps?url=${snapUrl}`;
|
|
9
18
|
const pageUrl = snapOrigin + (og?.resourcePath ?? "/");
|
|
19
|
+
const pageTitle = og?.title ?? DEFAULT_BRANDED_TITLE;
|
|
20
|
+
const pageDescription = og?.description ?? DEFAULT_BRANDED_DESCRIPTION;
|
|
10
21
|
|
|
11
22
|
const ogLines = [
|
|
12
|
-
`<meta name="description" content="
|
|
13
|
-
`<meta property="og:title" content="
|
|
14
|
-
`<meta property="og:description" content="
|
|
23
|
+
`<meta name="description" content="${escHtml(pageDescription)}">`,
|
|
24
|
+
`<meta property="og:title" content="${escHtml(pageTitle)}">`,
|
|
25
|
+
`<meta property="og:description" content="${escHtml(pageDescription)}">`,
|
|
15
26
|
`<meta property="og:url" content="${escHtml(pageUrl)}">`,
|
|
16
27
|
`<meta property="og:type" content="website">`,
|
|
17
28
|
`<meta property="og:locale" content="en_US">`,
|
|
@@ -31,8 +42,8 @@ export function brandedFallbackHtml(
|
|
|
31
42
|
ogLines.push(`<meta name="twitter:card" content="summary">`);
|
|
32
43
|
}
|
|
33
44
|
ogLines.push(
|
|
34
|
-
`<meta name="twitter:title" content="
|
|
35
|
-
`<meta name="twitter:description" content="
|
|
45
|
+
`<meta name="twitter:title" content="${escHtml(pageTitle)}">`,
|
|
46
|
+
`<meta name="twitter:description" content="${escHtml(pageDescription)}">`,
|
|
36
47
|
);
|
|
37
48
|
|
|
38
49
|
return `<!DOCTYPE html>
|
|
@@ -40,7 +51,7 @@ export function brandedFallbackHtml(
|
|
|
40
51
|
<head>
|
|
41
52
|
<meta charset="utf-8">
|
|
42
53
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
43
|
-
<title
|
|
54
|
+
<title>${escHtml(pageTitle)}</title>
|
|
44
55
|
${ogLines.join("\n")}
|
|
45
56
|
<style>
|
|
46
57
|
*{margin:0;padding:0;box-sizing:border-box}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
type OgOptions,
|
|
17
17
|
} from "./og-image";
|
|
18
18
|
|
|
19
|
+
export type SnapOpenGraphMeta = {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
19
24
|
export type SnapHandlerOptions = {
|
|
20
25
|
/**
|
|
21
26
|
* Route path to register GET and POST handlers on.
|
|
@@ -35,6 +40,13 @@ export type SnapHandlerOptions = {
|
|
|
35
40
|
*/
|
|
36
41
|
fallbackHtml?: string;
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Open Graph title/description overrides for the HTML fallback page meta tags
|
|
45
|
+
* (and document title). When a field is omitted, the usual extraction from snap
|
|
46
|
+
* UI or branded defaults applies.
|
|
47
|
+
*/
|
|
48
|
+
openGraph?: SnapOpenGraphMeta;
|
|
49
|
+
|
|
38
50
|
/**
|
|
39
51
|
* Open Graph configuration. Set to `false` to disable OG tag injection and
|
|
40
52
|
* the `/~/og-image` route. Pass an `OgOptions` object to customize rendering.
|
|
@@ -152,6 +164,7 @@ export function registerSnapHandler(
|
|
|
152
164
|
c.req.raw,
|
|
153
165
|
snapFn,
|
|
154
166
|
ogEnabled ? buildOgImageUrl(c.req.raw, path) : undefined,
|
|
167
|
+
options.openGraph,
|
|
155
168
|
));
|
|
156
169
|
return new Response(fallbackHtml, {
|
|
157
170
|
status: 200,
|
|
@@ -255,6 +268,7 @@ async function getFallbackHtml(
|
|
|
255
268
|
request: Request,
|
|
256
269
|
snapFn: SnapFunction,
|
|
257
270
|
ogImageUrl?: string,
|
|
271
|
+
openGraph?: SnapOpenGraphMeta,
|
|
258
272
|
): Promise<string> {
|
|
259
273
|
const origin = snapOriginFromRequest(request);
|
|
260
274
|
const siteName =
|
|
@@ -268,9 +282,20 @@ async function getFallbackHtml(
|
|
|
268
282
|
action: { type: ACTION_TYPE_GET },
|
|
269
283
|
request: stripAuthHeaders(request),
|
|
270
284
|
});
|
|
271
|
-
return renderSnapPage(snap, origin, {
|
|
285
|
+
return renderSnapPage(snap, origin, {
|
|
286
|
+
ogImageUrl,
|
|
287
|
+
resourcePath,
|
|
288
|
+
siteName,
|
|
289
|
+
openGraph,
|
|
290
|
+
});
|
|
272
291
|
} catch {
|
|
273
|
-
return brandedFallbackHtml(origin, {
|
|
292
|
+
return brandedFallbackHtml(origin, {
|
|
293
|
+
ogImageUrl,
|
|
294
|
+
resourcePath,
|
|
295
|
+
siteName,
|
|
296
|
+
title: openGraph?.title,
|
|
297
|
+
description: openGraph?.description,
|
|
298
|
+
});
|
|
274
299
|
}
|
|
275
300
|
}
|
|
276
301
|
|
package/src/renderSnapPage.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type RenderSnapPageOptions = {
|
|
|
16
16
|
ogImageUrl?: string;
|
|
17
17
|
resourcePath?: string;
|
|
18
18
|
siteName?: string;
|
|
19
|
+
openGraph?: { title?: string; description?: string };
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
type PageMeta = {
|
|
@@ -97,7 +98,9 @@ function buildOgMeta(opts: {
|
|
|
97
98
|
}
|
|
98
99
|
if (imgUrl) {
|
|
99
100
|
lines.push(`<meta property="og:image" content="${esc(imgUrl)}">`);
|
|
100
|
-
lines.push(
|
|
101
|
+
lines.push(
|
|
102
|
+
`<meta property="og:image:alt" content="${esc(imageAlt ?? title)}">`,
|
|
103
|
+
);
|
|
101
104
|
}
|
|
102
105
|
lines.push(
|
|
103
106
|
`<meta name="twitter:card" content="${twitterCard}">`,
|
|
@@ -126,10 +129,7 @@ function accentHex(accent: PaletteColor | undefined): string {
|
|
|
126
129
|
: PALETTE_LIGHT_HEX[DEFAULT_THEME_ACCENT];
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
function colorHex(
|
|
130
|
-
color: string | undefined,
|
|
131
|
-
accent: string,
|
|
132
|
-
): string {
|
|
132
|
+
function colorHex(color: string | undefined, accent: string): string {
|
|
133
133
|
if (!color || color === PALETTE_COLOR_ACCENT) return accent;
|
|
134
134
|
return (PALETTE_LIGHT_HEX as Record<string, string>)[color] ?? accent;
|
|
135
135
|
}
|
|
@@ -189,11 +189,7 @@ function renderIcon(name: string, size: number, color: string): string {
|
|
|
189
189
|
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;flex-shrink:0">${inner}</svg>`;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
function renderElement(
|
|
193
|
-
key: string,
|
|
194
|
-
spec: SnapSpec,
|
|
195
|
-
accent: string,
|
|
196
|
-
): string {
|
|
192
|
+
function renderElement(key: string, spec: SnapSpec, accent: string): string {
|
|
197
193
|
const el = spec.elements[key] as SnapUIElement | undefined;
|
|
198
194
|
if (!el) return "";
|
|
199
195
|
const p = el.props ?? {};
|
|
@@ -203,46 +199,75 @@ function renderElement(
|
|
|
203
199
|
const color = colorHex(p.color as string | undefined, accent);
|
|
204
200
|
const size = String(p.size ?? "md") === "sm" ? 16 : 20;
|
|
205
201
|
const name = String(p.name ?? "info");
|
|
206
|
-
return `<span style="display:inline-flex;align-items:center">${renderIcon(
|
|
202
|
+
return `<span style="display:inline-flex;align-items:center">${renderIcon(
|
|
203
|
+
name,
|
|
204
|
+
size,
|
|
205
|
+
color,
|
|
206
|
+
)}</span>`;
|
|
207
207
|
}
|
|
208
208
|
case "badge": {
|
|
209
209
|
const color = colorHex(p.color as string | undefined, accent);
|
|
210
210
|
const badgeVariant = String(p.variant ?? "default");
|
|
211
211
|
const isFilled = badgeVariant === "default";
|
|
212
212
|
const fg = isFilled ? fgForBg(color) : color;
|
|
213
|
-
const bgStyle = isFilled
|
|
213
|
+
const bgStyle = isFilled
|
|
214
|
+
? `background:${color};color:${fg}`
|
|
215
|
+
: `border:1px solid ${color};color:${color}`;
|
|
214
216
|
const iconName = p.icon ? String(p.icon) : undefined;
|
|
215
217
|
const iconHtml = iconName ? renderIcon(iconName, 12, fg) : "";
|
|
216
218
|
const gap = iconHtml ? "gap:4px;" : "";
|
|
217
|
-
return `<span style="display:inline-flex;align-items:center;${gap}padding:2px 10px;border-radius:9999px;font-size:12px;font-weight:500;line-height:1.5;${bgStyle}">${iconHtml}${esc(
|
|
219
|
+
return `<span style="display:inline-flex;align-items:center;${gap}padding:2px 10px;border-radius:9999px;font-size:12px;font-weight:500;line-height:1.5;${bgStyle}">${iconHtml}${esc(
|
|
220
|
+
String(p.label ?? ""),
|
|
221
|
+
)}</span>`;
|
|
218
222
|
}
|
|
219
223
|
case "image": {
|
|
220
224
|
const url = esc(String(p.url ?? ""));
|
|
221
225
|
const aspect = String(p.aspect ?? "1:1");
|
|
222
226
|
const [w, h] = aspect.split(":").map(Number);
|
|
223
227
|
const ratio = w && h ? `${w}/${h}` : "1/1";
|
|
224
|
-
return `<div style="flex:1;aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(
|
|
228
|
+
return `<div style="flex:1;aspect-ratio:${ratio};border-radius:8px;overflow:hidden;background:#F3F4F6"><img src="${url}" alt="${esc(
|
|
229
|
+
String(p.alt ?? ""),
|
|
230
|
+
)}" style="width:100%;height:100%;object-fit:cover"></div>`;
|
|
225
231
|
}
|
|
226
232
|
case "item": {
|
|
227
|
-
const descHtml = p.description
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
233
|
+
const descHtml = p.description
|
|
234
|
+
? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(
|
|
235
|
+
String(p.description),
|
|
236
|
+
)}</div>`
|
|
231
237
|
: "";
|
|
232
|
-
|
|
238
|
+
const childIds = el.children ?? [];
|
|
239
|
+
const actionsHtml =
|
|
240
|
+
childIds.length > 0
|
|
241
|
+
? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds
|
|
242
|
+
.map((id) => renderElement(id, spec, accent))
|
|
243
|
+
.join("")}</div>`
|
|
244
|
+
: "";
|
|
245
|
+
return `<div style="display:flex;align-items:flex-start;padding:6px 10px"><div style="flex:1;min-width:0"><div style="font-size:15px;font-weight:500;color:#111">${esc(
|
|
246
|
+
String(p.title ?? ""),
|
|
247
|
+
)}</div>${descHtml}</div>${actionsHtml}</div>`;
|
|
233
248
|
}
|
|
234
249
|
case "item_group": {
|
|
235
250
|
const childIds = el.children ?? [];
|
|
236
251
|
const border = Boolean(p.border);
|
|
237
252
|
const separator = Boolean(p.separator);
|
|
238
|
-
const outerStyle = border
|
|
253
|
+
const outerStyle = border
|
|
254
|
+
? "border:1px solid #E5E7EB;border-radius:8px;overflow:hidden"
|
|
255
|
+
: "";
|
|
239
256
|
let html = `<div style="display:flex;flex-direction:column;${outerStyle}">`;
|
|
240
257
|
for (let i = 0; i < childIds.length; i++) {
|
|
241
258
|
if (separator && i > 0) {
|
|
242
259
|
html += `<hr style="border:none;border-top:1px solid #E5E7EB;margin:0 12px">`;
|
|
243
260
|
}
|
|
244
|
-
const pad = border
|
|
245
|
-
|
|
261
|
+
const pad = border
|
|
262
|
+
? "padding:8px 12px;"
|
|
263
|
+
: separator
|
|
264
|
+
? "padding:8px 0;"
|
|
265
|
+
: "";
|
|
266
|
+
html += `<div style="${pad}">${renderElement(
|
|
267
|
+
childIds[i]!,
|
|
268
|
+
spec,
|
|
269
|
+
accent,
|
|
270
|
+
)}</div>`;
|
|
246
271
|
}
|
|
247
272
|
html += `</div>`;
|
|
248
273
|
return html;
|
|
@@ -251,44 +276,69 @@ function renderElement(
|
|
|
251
276
|
const value = Number(p.value ?? 0);
|
|
252
277
|
const max = Number(p.max ?? 100);
|
|
253
278
|
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
254
|
-
const labelHtml = p.label
|
|
279
|
+
const labelHtml = p.label
|
|
280
|
+
? `<div style="font-size:12px;color:#6B7280;margin-bottom:4px">${esc(
|
|
281
|
+
String(p.label),
|
|
282
|
+
)}</div>`
|
|
283
|
+
: "";
|
|
255
284
|
return `<div style="display:flex;flex:1;flex-direction:column;gap:4px">${labelHtml}<div style="height:10px;background:#E5E7EB;border-radius:9999px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${accent};border-radius:9999px;transition:width 0.3s"></div></div></div>`;
|
|
256
285
|
}
|
|
257
286
|
case "separator": {
|
|
258
287
|
const orientation = String(p.orientation ?? "horizontal");
|
|
259
|
-
if (orientation === "vertical")
|
|
288
|
+
if (orientation === "vertical")
|
|
289
|
+
return `<div style="width:1px;background:#E5E7EB;align-self:stretch;min-height:16px"></div>`;
|
|
260
290
|
return `<hr style="border:none;border-top:1px solid #E5E7EB;margin:4px 0">`;
|
|
261
291
|
}
|
|
262
292
|
case "slider": {
|
|
263
293
|
const min = Number(p.min ?? 0);
|
|
264
294
|
const max = Number(p.max ?? 100);
|
|
265
|
-
const value =
|
|
266
|
-
|
|
295
|
+
const value =
|
|
296
|
+
p.defaultValue !== undefined ? Number(p.defaultValue) : (min + max) / 2;
|
|
297
|
+
const labelHtml = p.label
|
|
298
|
+
? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(
|
|
299
|
+
String(p.label),
|
|
300
|
+
)}</div>`
|
|
301
|
+
: "";
|
|
267
302
|
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}<input type="range" min="${min}" max="${max}" value="${value}" disabled style="width:100%;height:10px;border-radius:9999px;accent-color:${accent};background:#E5E7EB;-webkit-appearance:none;appearance:none"></div>`;
|
|
268
303
|
}
|
|
269
304
|
case "switch": {
|
|
270
305
|
const checked = Boolean(p.defaultChecked);
|
|
271
306
|
const bg = checked ? accent : "#D1D5DB";
|
|
272
307
|
const tx = checked ? "20px" : "2px";
|
|
273
|
-
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px"><span style="font-size:14px;color:#374151">${esc(
|
|
308
|
+
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px"><span style="font-size:14px;color:#374151">${esc(
|
|
309
|
+
String(p.label ?? ""),
|
|
310
|
+
)}</span><div style="width:44px;height:24px;background:${bg};border-radius:12px;position:relative;transition:background 0.2s"><div style="width:20px;height:20px;background:#fff;border-radius:50%;position:absolute;top:2px;left:${tx};transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2)"></div></div></div>`;
|
|
274
311
|
}
|
|
275
312
|
case "input": {
|
|
276
|
-
const labelHtml = p.label
|
|
277
|
-
|
|
313
|
+
const labelHtml = p.label
|
|
314
|
+
? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(
|
|
315
|
+
String(p.label),
|
|
316
|
+
)}</label>`
|
|
317
|
+
: "";
|
|
318
|
+
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}<input type="text" placeholder="${esc(
|
|
319
|
+
String(p.placeholder ?? ""),
|
|
320
|
+
)}" readonly style="width:100%;padding:10px 12px;border-radius:8px;border:1px solid #E5E7EB;background:#fff;font-size:14px;color:#374151;font-family:inherit;box-sizing:border-box"></div>`;
|
|
278
321
|
}
|
|
279
322
|
case "toggle_group": {
|
|
280
323
|
const options = Array.isArray(p.options) ? (p.options as string[]) : [];
|
|
281
324
|
const orientation = String(p.orientation ?? "horizontal");
|
|
282
325
|
const dir = orientation === "vertical" ? "column" : "row";
|
|
283
|
-
const defaultVal =
|
|
284
|
-
|
|
326
|
+
const defaultVal =
|
|
327
|
+
p.defaultValue !== undefined ? String(p.defaultValue) : undefined;
|
|
328
|
+
const labelHtml = p.label
|
|
329
|
+
? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(
|
|
330
|
+
String(p.label),
|
|
331
|
+
)}</div>`
|
|
332
|
+
: "";
|
|
285
333
|
let html = `<div>${labelHtml}<div style="display:flex;flex-direction:${dir};gap:4px;padding:4px;background:rgba(229,231,235,0.2);border-radius:8px">`;
|
|
286
334
|
for (const opt of options) {
|
|
287
335
|
const selected = defaultVal === opt;
|
|
288
336
|
const optBg = selected ? accent : "transparent";
|
|
289
337
|
const optColor = selected ? fgForBg(accent) : "#374151";
|
|
290
338
|
const optWeight = selected ? "600" : "500";
|
|
291
|
-
html += `<button onclick="showModal()" style="flex:1;padding:8px 12px;border-radius:6px;border:none;background:${optBg};font-size:13px;font-weight:${optWeight};color:${optColor};cursor:pointer;font-family:inherit;transition:background 0.15s,color 0.15s">${esc(
|
|
339
|
+
html += `<button onclick="showModal()" style="flex:1;padding:8px 12px;border-radius:6px;border:none;background:${optBg};font-size:13px;font-weight:${optWeight};color:${optColor};cursor:pointer;font-family:inherit;transition:background 0.15s,color 0.15s">${esc(
|
|
340
|
+
opt,
|
|
341
|
+
)}</button>`;
|
|
292
342
|
}
|
|
293
343
|
html += `</div></div>`;
|
|
294
344
|
return html;
|
|
@@ -304,7 +354,9 @@ function renderElement(
|
|
|
304
354
|
const iconName = p.icon ? String(p.icon) : undefined;
|
|
305
355
|
const iconHtml = iconName ? renderIcon(iconName, 16, fg) : "";
|
|
306
356
|
const gap = iconHtml ? "gap:8px;" : "";
|
|
307
|
-
return `<button onclick="showModal()" style="display:inline-flex;align-items:center;justify-content:center;${gap}width:100%;${minH}padding:${pad};border-radius:10px;background:${bg};color:${fg};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;box-sizing:border-box">${iconHtml}${esc(
|
|
357
|
+
return `<button onclick="showModal()" style="display:inline-flex;align-items:center;justify-content:center;${gap}width:100%;${minH}padding:${pad};border-radius:10px;background:${bg};color:${fg};border:${border};font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;box-sizing:border-box">${iconHtml}${esc(
|
|
358
|
+
String(p.label ?? ""),
|
|
359
|
+
)}</button>`;
|
|
308
360
|
}
|
|
309
361
|
case "text": {
|
|
310
362
|
const size = String(p.size ?? "md");
|
|
@@ -318,40 +370,74 @@ function renderElement(
|
|
|
318
370
|
bold: "font-weight:700",
|
|
319
371
|
normal: "font-weight:400",
|
|
320
372
|
};
|
|
321
|
-
return `<div style="flex:1;${styles[size] ?? styles.md};${
|
|
373
|
+
return `<div style="flex:1;${styles[size] ?? styles.md};${
|
|
374
|
+
weights[weight] ?? weights.normal
|
|
375
|
+
};color:#374151;text-align:${align}">${esc(
|
|
376
|
+
String(p.content ?? ""),
|
|
377
|
+
)}</div>`;
|
|
322
378
|
}
|
|
323
379
|
case "stack": {
|
|
324
380
|
const direction = String(p.direction ?? "vertical");
|
|
325
381
|
const isHorizontal = direction === "horizontal";
|
|
326
|
-
const vGap: Record<string, string> = {
|
|
327
|
-
|
|
382
|
+
const vGap: Record<string, string> = {
|
|
383
|
+
none: "0",
|
|
384
|
+
sm: "8px",
|
|
385
|
+
md: "16px",
|
|
386
|
+
lg: "24px",
|
|
387
|
+
};
|
|
388
|
+
const hGap: Record<string, string> = {
|
|
389
|
+
none: "0",
|
|
390
|
+
sm: "4px",
|
|
391
|
+
md: "8px",
|
|
392
|
+
lg: "12px",
|
|
393
|
+
};
|
|
328
394
|
const gapMap = isHorizontal ? hGap : vGap;
|
|
329
|
-
const gapVal =
|
|
395
|
+
const gapVal =
|
|
396
|
+
gapMap[String(p.gap ?? "md")] ?? (isHorizontal ? "8px" : "16px");
|
|
330
397
|
const dir = isHorizontal ? "row" : "column";
|
|
331
398
|
const wrap = isHorizontal ? "flex-wrap:wrap;" : "";
|
|
332
399
|
const align = isHorizontal ? "align-items:center;" : "";
|
|
333
|
-
const justifyMap: Record<string, string> = {
|
|
400
|
+
const justifyMap: Record<string, string> = {
|
|
401
|
+
start: "flex-start",
|
|
402
|
+
center: "center",
|
|
403
|
+
end: "flex-end",
|
|
404
|
+
between: "space-between",
|
|
405
|
+
around: "space-around",
|
|
406
|
+
};
|
|
334
407
|
const jc = p.justify ? justifyMap[String(p.justify)] : undefined;
|
|
335
408
|
const childIds = el.children ?? [];
|
|
336
|
-
let html = `<div style="display:flex;width:100%;flex-direction:${dir};gap:${gapVal};${wrap}${align}${
|
|
409
|
+
let html = `<div style="display:flex;width:100%;flex-direction:${dir};gap:${gapVal};${wrap}${align}${
|
|
410
|
+
jc ? `justify-content:${jc};` : ""
|
|
411
|
+
}">`;
|
|
337
412
|
for (const childKey of childIds) {
|
|
338
413
|
const flex = isHorizontal ? "flex:1;min-width:0;" : "";
|
|
339
|
-
html += `<div style="${flex}">${renderElement(
|
|
414
|
+
html += `<div style="${flex}">${renderElement(
|
|
415
|
+
childKey,
|
|
416
|
+
spec,
|
|
417
|
+
accent,
|
|
418
|
+
)}</div>`;
|
|
340
419
|
}
|
|
341
420
|
html += `</div>`;
|
|
342
421
|
return html;
|
|
343
422
|
}
|
|
344
423
|
case "bar_chart": {
|
|
345
|
-
const bars = Array.isArray(p.bars)
|
|
424
|
+
const bars = Array.isArray(p.bars)
|
|
425
|
+
? (p.bars as Array<{ label?: string; value?: number; color?: string }>)
|
|
426
|
+
: [];
|
|
346
427
|
const chartColor = colorHex(p.color as string | undefined, accent);
|
|
347
|
-
const maxVal =
|
|
428
|
+
const maxVal =
|
|
429
|
+
p.max != null
|
|
430
|
+
? Number(p.max)
|
|
431
|
+
: Math.max(...bars.map((b) => Number(b.value ?? 0)), 1);
|
|
348
432
|
let html = `<div style="display:flex;flex-direction:column;gap:8px;width:100%">`;
|
|
349
433
|
for (const bar of bars) {
|
|
350
434
|
const value = Number(bar.value ?? 0);
|
|
351
435
|
const pct = maxVal > 0 ? Math.min(100, (value / maxVal) * 100) : 0;
|
|
352
436
|
const fill = bar.color ? colorHex(bar.color, accent) : chartColor;
|
|
353
437
|
html += `<div style="display:flex;align-items:center;gap:8px">`;
|
|
354
|
-
html += `<span style="width:80px;flex-shrink:0;text-align:right;font-size:12px;color:#6B7280;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(
|
|
438
|
+
html += `<span style="width:80px;flex-shrink:0;text-align:right;font-size:12px;color:#6B7280;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(
|
|
439
|
+
String(bar.label ?? ""),
|
|
440
|
+
)}</span>`;
|
|
355
441
|
html += `<div style="flex:1;height:10px;background:#E5E7EB;border-radius:9999px;overflow:hidden"><div style="height:100%;width:${pct}%;background:${fill};border-radius:9999px;transition:width 0.3s"></div></div>`;
|
|
356
442
|
html += `<span style="width:32px;flex-shrink:0;font-size:12px;color:#6B7280;font-variant-numeric:tabular-nums">${value}</span>`;
|
|
357
443
|
html += `</div>`;
|
|
@@ -362,13 +448,23 @@ function renderElement(
|
|
|
362
448
|
case "cell_grid": {
|
|
363
449
|
const cols = Number(p.cols ?? 2);
|
|
364
450
|
const rows = Number(p.rows ?? 2);
|
|
365
|
-
const cells = Array.isArray(p.cells)
|
|
451
|
+
const cells = Array.isArray(p.cells)
|
|
452
|
+
? (p.cells as Array<{
|
|
453
|
+
row?: number;
|
|
454
|
+
col?: number;
|
|
455
|
+
color?: string;
|
|
456
|
+
content?: string;
|
|
457
|
+
}>)
|
|
458
|
+
: [];
|
|
366
459
|
const gap = String(p.gap ?? "sm");
|
|
367
460
|
const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
|
|
368
461
|
const gapPx = gapMap[gap] ?? 1;
|
|
369
462
|
const cellMap = new Map<string, { color?: string; content?: string }>();
|
|
370
463
|
for (const c of cells) {
|
|
371
|
-
cellMap.set(`${Number(c.row ?? 0)},${Number(c.col ?? 0)}`, {
|
|
464
|
+
cellMap.set(`${Number(c.row ?? 0)},${Number(c.col ?? 0)}`, {
|
|
465
|
+
color: c.color,
|
|
466
|
+
content: c.content,
|
|
467
|
+
});
|
|
372
468
|
}
|
|
373
469
|
let html = `<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:${gapPx}px;width:100%">`;
|
|
374
470
|
for (let r = 0; r < rows; r++) {
|
|
@@ -398,15 +494,17 @@ export function renderSnapPage(
|
|
|
398
494
|
const accent = accentHex(snap.theme?.accent);
|
|
399
495
|
|
|
400
496
|
const meta = extractPageMeta(spec);
|
|
401
|
-
const
|
|
497
|
+
const title = opts?.openGraph?.title ?? meta.title;
|
|
498
|
+
const description = opts?.openGraph?.description ?? meta.description;
|
|
499
|
+
const pageTitle = esc(title);
|
|
402
500
|
const resourcePath = opts?.resourcePath ?? "/";
|
|
403
501
|
const pageUrl = snapOrigin.replace(/\/$/, "") + resourcePath;
|
|
404
502
|
const ogMeta = buildOgMeta({
|
|
405
|
-
title
|
|
406
|
-
description
|
|
503
|
+
title,
|
|
504
|
+
description,
|
|
407
505
|
pageUrl,
|
|
408
506
|
ogImageUrl: opts?.ogImageUrl,
|
|
409
|
-
imageAlt: meta.imageAlt ?? meta.imageUrl ?
|
|
507
|
+
imageAlt: meta.imageAlt ?? meta.imageUrl ? title : undefined,
|
|
410
508
|
siteName: opts?.siteName,
|
|
411
509
|
});
|
|
412
510
|
|