@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.
@@ -2,4 +2,6 @@ export declare function brandedFallbackHtml(snapOrigin: string, og?: {
2
2
  ogImageUrl?: string;
3
3
  resourcePath?: string;
4
4
  siteName?: string;
5
+ title?: string;
6
+ description?: string;
5
7
  }): string;
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="An interactive embed for Farcaster.">`,
8
- `<meta property="og:title" content="Farcaster Snap">`,
9
- `<meta property="og:description" content="An interactive embed for Farcaster.">`,
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="Farcaster Snap">`, `<meta name="twitter:description" content="An interactive embed for Farcaster.">`);
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>Farcaster Snap</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, { ogImageUrl, resourcePath, siteName });
186
+ return renderSnapPage(snap, origin, {
187
+ ogImageUrl,
188
+ resourcePath,
189
+ siteName,
190
+ openGraph,
191
+ });
187
192
  }
188
193
  catch {
189
- return brandedFallbackHtml(origin, { ogImageUrl, resourcePath, siteName });
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) {
@@ -3,6 +3,10 @@ export type RenderSnapPageOptions = {
3
3
  ogImageUrl?: string;
4
4
  resourcePath?: string;
5
5
  siteName?: string;
6
+ openGraph?: {
7
+ title?: string;
8
+ description?: string;
9
+ };
6
10
  };
7
11
  type PageMeta = {
8
12
  title: string;
@@ -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 ? `background:${color};color:${fg}` : `border:1px solid ${color};color:${color}`;
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 ? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>` : "";
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.map((id) => renderElement(id, spec, accent)).join("")}</div>`
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 ? "border:1px solid #E5E7EB;border-radius:8px;overflow:hidden" : "";
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 ? "padding:8px 12px;" : separator ? "padding:8px 0;" : "";
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 ? `<div style="font-size:12px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
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 ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>` : "";
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 ? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</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 ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>` : "";
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 = { none: "0", sm: "8px", md: "16px", lg: "24px" };
273
- const hGap = { none: "0", sm: "4px", md: "8px", lg: "12px" };
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 = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around" };
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) ? 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 ? Number(p.max) : Math.max(...bars.map((b) => Number(b.value ?? 0)), 1);
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) ? 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)}`, { color: c.color, content: c.content });
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 pageTitle = esc(meta.title);
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: meta.title,
345
- description: meta.description,
391
+ title,
392
+ description,
346
393
  pageUrl,
347
394
  ogImageUrl: opts?.ogImageUrl,
348
- imageAlt: meta.imageAlt ?? meta.imageUrl ? meta.title : undefined,
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.7",
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.1"
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?: { ogImageUrl?: string; resourcePath?: string; siteName?: string },
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="An interactive embed for Farcaster.">`,
13
- `<meta property="og:title" content="Farcaster Snap">`,
14
- `<meta property="og:description" content="An interactive embed for Farcaster.">`,
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="Farcaster Snap">`,
35
- `<meta name="twitter:description" content="An interactive embed for Farcaster.">`,
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>Farcaster Snap</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, { ogImageUrl, resourcePath, siteName });
285
+ return renderSnapPage(snap, origin, {
286
+ ogImageUrl,
287
+ resourcePath,
288
+ siteName,
289
+ openGraph,
290
+ });
272
291
  } catch {
273
- return brandedFallbackHtml(origin, { ogImageUrl, resourcePath, siteName });
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
 
@@ -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(`<meta property="og:image:alt" content="${esc(imageAlt ?? title)}">`);
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(name, size, color)}</span>`;
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 ? `background:${color};color:${fg}` : `border:1px solid ${color};color:${color}`;
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(String(p.label ?? ""))}</span>`;
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(String(p.alt ?? ""))}" style="width:100%;height:100%;object-fit:cover"></div>`;
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 ? `<div style="font-size:13px;color:#6B7280;margin-top:2px">${esc(String(p.description))}</div>` : "";
228
- const childIds = el.children ?? [];
229
- const actionsHtml = childIds.length > 0
230
- ? `<div style="margin-left:auto;padding-left:12px;display:flex;align-items:center;gap:4px">${childIds.map((id) => renderElement(id, spec, accent)).join("")}</div>`
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
- 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>`;
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 ? "border:1px solid #E5E7EB;border-radius:8px;overflow:hidden" : "";
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 ? "padding:8px 12px;" : separator ? "padding:8px 0;" : "";
245
- html += `<div style="${pad}">${renderElement(childIds[i]!, spec, accent)}</div>`;
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 ? `<div style="font-size:12px;color:#6B7280;margin-bottom:4px">${esc(String(p.label))}</div>` : "";
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") return `<div style="width:1px;background:#E5E7EB;align-self:stretch;min-height:16px"></div>`;
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 = p.defaultValue !== undefined ? Number(p.defaultValue) : (min + max) / 2;
266
- const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>` : "";
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(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>`;
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 ? `<label style="display:block;font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</label>` : "";
277
- 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>`;
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 = p.defaultValue !== undefined ? String(p.defaultValue) : undefined;
284
- const labelHtml = p.label ? `<div style="font-size:13px;font-weight:500;color:#374151;margin-bottom:6px">${esc(String(p.label))}</div>` : "";
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(opt)}</button>`;
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(String(p.label ?? ""))}</button>`;
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};${weights[weight] ?? weights.normal};color:#374151;text-align:${align}">${esc(String(p.content ?? ""))}</div>`;
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> = { none: "0", sm: "8px", md: "16px", lg: "24px" };
327
- const hGap: Record<string, string> = { none: "0", sm: "4px", md: "8px", lg: "12px" };
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 = gapMap[String(p.gap ?? "md")] ?? (isHorizontal ? "8px" : "16px");
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> = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around" };
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}${jc ? `justify-content:${jc};` : ""}">`;
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(childKey, spec, accent)}</div>`;
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) ? (p.bars as Array<{ label?: string; value?: number; color?: string }>) : [];
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 = p.max != null ? Number(p.max) : Math.max(...bars.map((b) => Number(b.value ?? 0)), 1);
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(String(bar.label ?? ""))}</span>`;
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) ? (p.cells as Array<{ row?: number; col?: number; color?: string; content?: string }>) : [];
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)}`, { color: c.color, content: c.content });
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 pageTitle = esc(meta.title);
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: meta.title,
406
- description: meta.description,
503
+ title,
504
+ description,
407
505
  pageUrl,
408
506
  ogImageUrl: opts?.ogImageUrl,
409
- imageAlt: meta.imageAlt ?? meta.imageUrl ? meta.title : undefined,
507
+ imageAlt: meta.imageAlt ?? meta.imageUrl ? title : undefined,
410
508
  siteName: opts?.siteName,
411
509
  });
412
510