Skip to content

Commit 550e9cc

Browse files
authored
feat(catalog): auto-generated Open Graph cards per capture (#42201)
1 parent af127a6 commit 550e9cc

8 files changed

Lines changed: 307 additions & 4 deletions

File tree

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Binary file not shown.
Binary file not shown.
Binary file not shown.

‎packages/lab/catalog/catalog/worker.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { describe, expect, test } from "bun:test"
22
import wrangler from "../wrangler.jsonc"
3-
import { assetPath } from "../worker"
3+
import { assetPath, metaTags } from "../worker"
4+
5+
const catalog = {
6+
variants: [
7+
{ id: "opencode", label: "Opencode" },
8+
{ id: "tokyonight", label: "Tokyo Night" },
9+
],
10+
screens: [
11+
{
12+
id: "home",
13+
title: "Home",
14+
summary: "",
15+
frames: [{ variantId: "opencode" }, { variantId: "tokyonight" }],
16+
},
17+
],
18+
}
419

520
describe("catalog worker", () => {
621
test("serves the app shell for catalog routes", () => {
@@ -17,4 +32,25 @@ describe("catalog worker", () => {
1732
test("leaves HTML routing to the worker", () => {
1833
expect(wrangler.assets.html_handling).toBe("none")
1934
})
35+
36+
test("injects a per-capture Open Graph card for deep links", () => {
37+
const tags = metaTags(new URL("https://dev.opencode.ai/lab/catalog?screen=home&set=tokyonight"), catalog)
38+
expect(tags).toContain('content="Home — OpenCode Terminal Catalog"')
39+
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/home--tokyonight.png"')
40+
expect(tags).toContain("Tokyo Night theme")
41+
expect(tags).toContain('name="twitter:card" content="summary_large_image"')
42+
})
43+
44+
test("defaults the theme to the first variant", () => {
45+
const tags = metaTags(new URL("https://dev.opencode.ai/lab/catalog?screen=home"), catalog)
46+
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/home--opencode.png"')
47+
})
48+
49+
test("falls back to the default card for unknown or missing captures", () => {
50+
for (const search of ["", "?screen=missing", "?screen=home&set=missing"]) {
51+
const tags = metaTags(new URL(`https://dev.opencode.ai/lab/catalog${search}`), catalog)
52+
expect(tags).toContain('content="https://dev.opencode.ai/lab/catalog/og/default.png"')
53+
expect(tags).toContain('content="OpenCode Terminal Catalog"')
54+
}
55+
})
2056
})

‎packages/lab/catalog/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"dev": "bun ./server.ts",
1111
"lint": "cd ../../.. && bun run lint -- packages/lab/catalog/src packages/lab/catalog/catalog packages/lab/catalog/scripts packages/lab/catalog/scenarios packages/lab/catalog/server.ts packages/lab/catalog/worker.ts",
1212
"typecheck": "tsc --noEmit",
13-
"build": "rm -rf dist && bun build ./src/index.html --outdir dist --public-path=/lab/catalog/ --minify && cp -R public/captures public/catalog.json public/drive-captures.json dist/",
13+
"build": "rm -rf dist && bun build ./src/index.html --outdir dist --public-path=/lab/catalog/ --minify && cp -R public/captures public/catalog.json public/drive-captures.json dist/ && bun ./scripts/generate-og.ts",
1414
"deploy": "bun run generate && bun run build && bunx wrangler deploy",
1515
"doctor": "bunx -y react-doctor@latest .",
1616
"bench:capture-flow": "bun ./scripts/bench-capture-flow.ts",
@@ -28,6 +28,7 @@
2828
"react-dom": "^19.2.7"
2929
},
3030
"devDependencies": {
31+
"@napi-rs/canvas": "1.0.2",
3132
"@types/bun": "catalog:",
3233
"@types/react": "^19.2.17",
3334
"@types/react-dom": "^19.2.3",
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* Generates Open Graph cards for the catalog: one 1200x630 PNG per captured
3+
* screen/theme pair plus a default card, written to dist/og. Runs after the
4+
* production build so the images deploy as plain static assets.
5+
*/
6+
import { fileURLToPath } from "node:url"
7+
import { GlobalFonts, createCanvas } from "@napi-rs/canvas"
8+
import type { SKRSContext2D, Canvas } from "@napi-rs/canvas"
9+
import {
10+
CellHeight,
11+
CellWidth,
12+
DimAlpha,
13+
FontSize,
14+
StrikethroughOffset,
15+
TextStyle,
16+
UnderlineOffset,
17+
baselineOffset,
18+
drawBlockGlyph,
19+
} from "opencode-drive/frame"
20+
21+
const CardWidth = 1200
22+
const CardHeight = 630
23+
const Pad = 56
24+
25+
const FontStack = `"OpenCode Mono", "OpenCode Symbols", "OpenCode Symbols 2", "OpenCode Math"`
26+
27+
const driveRoot = new URL("../../", Bun.pathToFileURL(Bun.resolveSync("opencode-drive/frame", import.meta.dir)).href)
28+
for (const [file, family] of [
29+
["commit-mono/CommitMono-400-Regular.otf", "OpenCode Mono"],
30+
["commit-mono/CommitMono-700-Regular.otf", "OpenCode Mono"],
31+
["commit-mono/CommitMono-400-Italic.otf", "OpenCode Mono"],
32+
["commit-mono/CommitMono-700-Italic.otf", "OpenCode Mono"],
33+
["noto/NotoSansSymbols.ttf", "OpenCode Symbols"],
34+
["noto/NotoSansSymbols2-Regular.ttf", "OpenCode Symbols 2"],
35+
["noto/NotoSansMath-Regular.ttf", "OpenCode Math"],
36+
] as const) {
37+
const path = fileURLToPath(new URL(`assets/fonts/${file}`, driveRoot))
38+
if (!GlobalFonts.registerFromPath(path, family)) throw new Error(`Failed to register OG font: ${path}`)
39+
}
40+
41+
interface FrameSpan {
42+
readonly text: string
43+
readonly fg: readonly [number, number, number, number]
44+
readonly bg: readonly [number, number, number, number]
45+
readonly attributes: number
46+
readonly width: number
47+
}
48+
49+
interface FrameArtifact {
50+
readonly cols: number
51+
readonly rows: number
52+
readonly lines: ReadonlyArray<{ readonly spans: ReadonlyArray<FrameSpan> }>
53+
}
54+
55+
function color([red, green, blue, alpha]: FrameSpan["fg"], opacity = 1) {
56+
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`
57+
}
58+
59+
/** Mirrors the browser canvas renderer in src/components/TerminalFrame.tsx. */
60+
function renderFrame(frame: FrameArtifact) {
61+
const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight)
62+
const context = canvas.getContext("2d")
63+
context.fillStyle = "#080808"
64+
context.fillRect(0, 0, canvas.width, canvas.height)
65+
context.textBaseline = "alphabetic"
66+
context.textAlign = "center"
67+
68+
frame.lines.forEach((line, row) => {
69+
let column = 0
70+
line.spans.forEach((span) => {
71+
const attributes = span.attributes & 0xff
72+
const inverse = Boolean(attributes & TextStyle.inverse)
73+
const hidden = Boolean(attributes & TextStyle.invisible)
74+
const foreground = inverse ? span.bg : span.fg
75+
const background = inverse ? span.fg : span.bg
76+
const chars = [...span.text]
77+
let remaining = span.width
78+
79+
chars.forEach((char, index) => {
80+
const cells = Math.max(1, remaining - (chars.length - index - 1))
81+
const x = column * CellWidth
82+
const y = row * CellHeight
83+
if (background[3]) {
84+
context.fillStyle = color(background)
85+
context.fillRect(x, y, cells * CellWidth, CellHeight)
86+
}
87+
if (!hidden && char.codePointAt(0) !== 0x0a00) {
88+
context.fillStyle = color(foreground, attributes & TextStyle.dim ? DimAlpha : 1)
89+
if (!drawBlockGlyph(context, char, x, y, cells)) {
90+
const font = `${attributes & TextStyle.italic ? "italic " : ""}${attributes & TextStyle.bold ? "700 " : "400 "}${FontSize}px ${FontStack}`
91+
context.font = font
92+
context.fillText(char, x + (cells * CellWidth) / 2, y + baselineOffset(context, font), cells * CellWidth)
93+
}
94+
if (attributes & TextStyle.underline) context.fillRect(x, y + UnderlineOffset, cells * CellWidth, 1)
95+
if (attributes & TextStyle.strikethrough) context.fillRect(x, y + StrikethroughOffset, cells * CellWidth, 1)
96+
}
97+
column += cells
98+
remaining -= cells
99+
})
100+
while (remaining-- > 0) {
101+
if (background[3]) {
102+
context.fillStyle = color(background)
103+
context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight)
104+
}
105+
column++
106+
}
107+
})
108+
})
109+
return canvas
110+
}
111+
112+
function drawChrome(context: SKRSContext2D, kicker: string, title: string, chip: string) {
113+
context.fillStyle = "#0a0a0a"
114+
context.fillRect(0, 0, CardWidth, CardHeight)
115+
116+
context.textAlign = "left"
117+
context.textBaseline = "alphabetic"
118+
context.font = `700 20px ${FontStack}`
119+
context.fillStyle = "#6f6e69"
120+
context.fillText(kicker.toUpperCase(), Pad, Pad + 16)
121+
122+
context.font = `700 46px ${FontStack}`
123+
context.fillStyle = "#ededed"
124+
context.fillText(title, Pad - 2, Pad + 74, CardWidth - Pad * 2 - 220)
125+
126+
if (chip !== "") {
127+
context.font = `400 20px ${FontStack}`
128+
const width = context.measureText(chip).width + 36
129+
const x = CardWidth - Pad - width
130+
context.strokeStyle = "#2c2c2c"
131+
context.lineWidth = 1
132+
context.beginPath()
133+
context.roundRect(x, Pad + 38, width, 40, 20)
134+
context.stroke()
135+
context.fillStyle = "#9c9b96"
136+
context.fillText(chip, x + 18, Pad + 65)
137+
}
138+
}
139+
140+
/** Frame peeks from the bottom edge like a window, cropped by the card. */
141+
function drawFramePeek(context: SKRSContext2D, frame: Canvas) {
142+
const top = 182
143+
const width = CardWidth - Pad * 2
144+
const scale = width / frame.width
145+
const visible = CardHeight - top
146+
const radius = 10
147+
148+
context.save()
149+
context.beginPath()
150+
context.roundRect(Pad, top, width, visible + radius, radius)
151+
context.clip()
152+
context.drawImage(frame, 0, 0, frame.width, Math.min(frame.height, (visible + radius) / scale), Pad, top, width, Math.min(frame.height * scale, visible + radius))
153+
context.restore()
154+
155+
context.strokeStyle = "#2c2c2c"
156+
context.lineWidth = 2
157+
context.beginPath()
158+
context.roundRect(Pad + 1, top + 1, width - 2, visible + radius, radius)
159+
context.stroke()
160+
}
161+
162+
const root = new URL("../", import.meta.url)
163+
const outDir = new URL("dist/og/", root)
164+
const catalog = await Bun.file(new URL("dist/catalog.json", root)).json()
165+
166+
const variants = catalog.variants as ReadonlyArray<{ id: string; label: string }>
167+
const screens = catalog.screens as ReadonlyArray<{
168+
id: string
169+
title: string
170+
frames: ReadonlyArray<{ variantId: string; src: string }>
171+
}>
172+
173+
let generated = 0
174+
for (const screen of screens) {
175+
for (const frame of screen.frames) {
176+
const variant = variants.find((candidate) => candidate.id === frame.variantId)
177+
if (!variant) continue
178+
const artifact = (await Bun.file(new URL(frame.src, new URL("dist/", root))).json()) as FrameArtifact
179+
const card = createCanvas(CardWidth, CardHeight)
180+
const context = card.getContext("2d")
181+
drawChrome(context, "OpenCode · Terminal Catalog", screen.title, variant.label)
182+
drawFramePeek(context, renderFrame(artifact))
183+
await Bun.write(new URL(`${screen.id}--${variant.id}.png`, outDir), card.toBuffer("image/png"))
184+
generated++
185+
}
186+
}
187+
188+
const home = screens.find((screen) => screen.id === "home")
189+
const homeFrame = home?.frames.find((frame) => frame.variantId === variants[0]?.id) ?? home?.frames[0]
190+
const card = createCanvas(CardWidth, CardHeight)
191+
const context = card.getContext("2d")
192+
drawChrome(context, "OpenCode", "Terminal Catalog", `${screens.length} screens · ${variants.length} themes`)
193+
if (homeFrame) {
194+
const artifact = (await Bun.file(new URL(homeFrame.src, new URL("dist/", root))).json()) as FrameArtifact
195+
drawFramePeek(context, renderFrame(artifact))
196+
}
197+
await Bun.write(new URL("default.png", outDir), card.toBuffer("image/png"))
198+
199+
console.log(`generated ${generated + 1} Open Graph cards in dist/og`)

‎packages/lab/catalog/worker.ts‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,81 @@ interface Env {
22
readonly ASSETS: { fetch(request: Request): Promise<Response> }
33
}
44

5+
interface CatalogIndex {
6+
readonly variants: ReadonlyArray<{ readonly id: string; readonly label: string }>
7+
readonly screens: ReadonlyArray<{
8+
readonly id: string
9+
readonly title: string
10+
readonly summary: string
11+
readonly frames: ReadonlyArray<{ readonly variantId: string }>
12+
}>
13+
}
14+
15+
let catalogCache: Promise<CatalogIndex> | undefined
16+
517
export default {
618
async fetch(request: Request, env: Env): Promise<Response> {
719
const url = new URL(request.url)
8-
url.pathname = assetPath(url.pathname)
9-
return env.ASSETS.fetch(new Request(url, request))
20+
const path = assetPath(url.pathname)
21+
const assetUrl = new URL(url)
22+
assetUrl.pathname = path
23+
const response = await env.ASSETS.fetch(new Request(assetUrl, request))
24+
if (path !== "/index.html" || !response.ok) return response
25+
26+
catalogCache ??= env.ASSETS.fetch(new Request(new URL("/catalog.json", url.origin)))
27+
.then((asset) => asset.json() as Promise<CatalogIndex>)
28+
.catch((cause) => {
29+
catalogCache = undefined
30+
throw cause
31+
})
32+
const catalog = await catalogCache.catch(() => undefined)
33+
if (!catalog) return response
34+
35+
const html = await response.text()
36+
const headers = new Headers(response.headers)
37+
headers.delete("content-length")
38+
return new Response(html.replace("</head>", `${metaTags(url, catalog)}</head>`), {
39+
status: response.status,
40+
headers,
41+
})
1042
},
1143
}
1244

1345
export function assetPath(pathname: string) {
1446
const path = pathname.slice("/lab/catalog".length)
1547
return path === "" || path === "/" || !path.includes(".") ? "/index.html" : path
1648
}
49+
50+
export function metaTags(url: URL, catalog: CatalogIndex) {
51+
const screen = catalog.screens.find((candidate) => candidate.id === url.searchParams.get("screen"))
52+
const variantId = url.searchParams.get("set") ?? catalog.variants[0]?.id
53+
const variant = catalog.variants.find((candidate) => candidate.id === variantId)
54+
const captured = screen && variant && screen.frames.some((frame) => frame.variantId === variant.id)
55+
56+
const title = captured ? `${screen.title} — OpenCode Terminal Catalog` : "OpenCode Terminal Catalog"
57+
const description = captured
58+
? screen.summary || `The ${screen.title} state of the OpenCode TUI, captured live in the ${variant.label} theme.`
59+
: "A visual catalog of OpenCode TUI states, captured from the live terminal."
60+
const image = `${url.origin}/lab/catalog/og/${captured ? `${screen.id}--${variant.id}` : "default"}.png`
61+
62+
const tags: ReadonlyArray<readonly [string, string]> = [
63+
["og:title", title],
64+
["og:description", description],
65+
["og:type", "website"],
66+
["og:url", url.href],
67+
["og:image", image],
68+
["og:image:width", "1200"],
69+
["og:image:height", "630"],
70+
]
71+
return [
72+
...tags.map(([property, content]) => `<meta property="${property}" content="${escapeAttribute(content)}" />`),
73+
`<meta name="twitter:card" content="summary_large_image" />`,
74+
`<meta name="twitter:title" content="${escapeAttribute(title)}" />`,
75+
`<meta name="twitter:image" content="${escapeAttribute(image)}" />`,
76+
`<meta name="description" content="${escapeAttribute(description)}" />`,
77+
].join("\n ")
78+
}
79+
80+
function escapeAttribute(value: string) {
81+
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")
82+
}

0 commit comments

Comments
 (0)