Skip to content

Commit d765058

Browse files
committed
feat: dynamic skill OG images
1 parent cf2ad58 commit d765058

File tree

10 files changed

+310
-4
lines changed

10 files changed

+310
-4
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ count.txt
1616
.wrangler
1717
.output
1818
.vinxi
19-
*.bun-build
2019
todos.json
2120
.cta.json
2221
.vscode

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
- Web: dynamic OG image cards for skills (name, description, version).
7+
38
## 0.1.0 - 2026-01-07
49

510
### Added

bun.lock

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@monaco-editor/react": "^4.7.0",
3030
"@radix-ui/react-dropdown-menu": "^2.1.16",
3131
"@radix-ui/react-toggle-group": "^1.1.11",
32+
"@resvg/resvg-wasm": "^2.6.2",
3233
"@tailwindcss/vite": "^4.1.18",
3334
"@tanstack/react-devtools": "^0.9.0",
3435
"@tanstack/react-router": "^1.144.0",
@@ -40,6 +41,7 @@
4041
"clsx": "^2.1.1",
4142
"convex": "^1.31.2",
4243
"fflate": "^0.8.2",
44+
"h3": "2.0.1-rc.5",
4345
"lucide-react": "^0.562.0",
4446
"monaco-editor": "^0.55.1",
4547
"nitro": "^3.0.1-alpha.1",

server/routes/og/skill.png.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { readFile } from 'node:fs/promises'
2+
3+
import { initWasm, Resvg } from '@resvg/resvg-wasm'
4+
import { defineEventHandler, getQuery, setHeader } from 'h3'
5+
6+
type OgQuery = {
7+
slug?: string
8+
title?: string
9+
owner?: string
10+
version?: string
11+
description?: string
12+
}
13+
14+
let markDataUrlPromise: Promise<string> | null = null
15+
let wasmInitPromise: Promise<void> | null = null
16+
17+
async function ensureWasm() {
18+
if (!wasmInitPromise) {
19+
wasmInitPromise = (async () => {
20+
const wasm = await readFile(
21+
new URL('../../../node_modules/@resvg/resvg-wasm/index_bg.wasm', import.meta.url),
22+
)
23+
await initWasm(wasm)
24+
})()
25+
}
26+
await wasmInitPromise
27+
}
28+
29+
function getNitroServerRootUrl() {
30+
const nitroMain = (globalThis as unknown as { __nitro_main__?: unknown }).__nitro_main__
31+
if (typeof nitroMain !== 'string') return null
32+
try {
33+
return new URL('./', nitroMain)
34+
} catch {
35+
return null
36+
}
37+
}
38+
39+
async function getMarkDataUrl() {
40+
if (!markDataUrlPromise) {
41+
markDataUrlPromise = (async () => {
42+
const candidates = [
43+
(() => {
44+
const root = getNitroServerRootUrl()
45+
return root ? new URL('./clawd-mark.png', root) : null
46+
})(),
47+
new URL('../../../public/clawd-mark.png', import.meta.url),
48+
].filter((value): value is URL => Boolean(value))
49+
50+
let lastError: unknown = null
51+
for (const url of candidates) {
52+
try {
53+
const buffer = await readFile(url)
54+
return `data:image/png;base64,${buffer.toString('base64')}`
55+
} catch (error) {
56+
lastError = error
57+
}
58+
}
59+
throw lastError
60+
})()
61+
}
62+
return markDataUrlPromise
63+
}
64+
65+
function escapeXml(value: string) {
66+
return value
67+
.replace(/&/g, '&amp;')
68+
.replace(/</g, '&lt;')
69+
.replace(/>/g, '&gt;')
70+
.replace(/"/g, '&quot;')
71+
.replace(/'/g, '&#39;')
72+
}
73+
74+
function wrapText(value: string, maxChars: number, maxLines: number) {
75+
const words = value.trim().split(/\s+/).filter(Boolean)
76+
const lines: string[] = []
77+
let current = ''
78+
for (const word of words) {
79+
const next = current ? `${current} ${word}` : word
80+
if (next.length <= maxChars) {
81+
current = next
82+
continue
83+
}
84+
if (current) lines.push(current)
85+
current = word
86+
if (lines.length >= maxLines - 1) break
87+
}
88+
if (lines.length < maxLines && current) lines.push(current)
89+
if (lines.length > maxLines) lines.length = maxLines
90+
91+
const usedWords = lines.join(' ').split(/\s+/).filter(Boolean).length
92+
if (usedWords < words.length) {
93+
const last = lines.at(-1) ?? ''
94+
const trimmed = last.length > maxChars ? last.slice(0, maxChars) : last
95+
lines[lines.length - 1] = `${trimmed.replace(/\s+$/g, '').replace(/[.,;:!?]+$/g, '')}…`
96+
}
97+
return lines
98+
}
99+
100+
function buildSvg(params: {
101+
markDataUrl: string
102+
title: string
103+
description: string
104+
ownerLabel: string
105+
versionLabel: string
106+
footer: string
107+
}) {
108+
const rawTitle = params.title.trim() || 'ClawdHub Skill'
109+
const rawDescription = params.description.trim() || 'Published on ClawdHub.'
110+
111+
const titleLines = wrapText(rawTitle, 22, 2)
112+
const descLines = wrapText(rawDescription, 52, 3)
113+
114+
const titleFontSize = titleLines.length > 1 || rawTitle.length > 24 ? 72 : 80
115+
const titleY = titleLines.length > 1 ? 258 : 280
116+
const titleLineHeight = 84
117+
118+
const descY = titleLines.length > 1 ? 395 : 380
119+
const descLineHeight = 34
120+
121+
const pillText = `${params.ownerLabel}${params.versionLabel}`
122+
123+
const titleTspans = titleLines
124+
.map((line, index) => {
125+
const dy = index === 0 ? 0 : titleLineHeight
126+
return `<tspan x="114" dy="${dy}">${escapeXml(line)}</tspan>`
127+
})
128+
.join('')
129+
130+
const descTspans = descLines
131+
.map((line, index) => {
132+
const dy = index === 0 ? 0 : descLineHeight
133+
return `<tspan x="114" dy="${dy}">${escapeXml(line)}</tspan>`
134+
})
135+
.join('')
136+
137+
return `<?xml version="1.0" encoding="UTF-8"?>
138+
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
139+
<defs>
140+
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
141+
<stop stop-color="#14110F"/>
142+
<stop offset="0.55" stop-color="#1A1512"/>
143+
<stop offset="1" stop-color="#14110F"/>
144+
</linearGradient>
145+
146+
<radialGradient id="glowOrange" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(260 60) rotate(120) scale(520 420)">
147+
<stop stop-color="#E86A47" stop-opacity="0.55"/>
148+
<stop offset="1" stop-color="#E86A47" stop-opacity="0"/>
149+
</radialGradient>
150+
151+
<radialGradient id="glowSea" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1050 120) rotate(140) scale(520 420)">
152+
<stop stop-color="#4AD8B7" stop-opacity="0.35"/>
153+
<stop offset="1" stop-color="#4AD8B7" stop-opacity="0"/>
154+
</radialGradient>
155+
156+
<filter id="softBlur" x="-40%" y="-40%" width="180%" height="180%">
157+
<feGaussianBlur stdDeviation="24"/>
158+
</filter>
159+
160+
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
161+
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#000000" flood-opacity="0.6"/>
162+
</filter>
163+
164+
<linearGradient id="pill" x1="0" y1="0" x2="520" y2="0" gradientUnits="userSpaceOnUse">
165+
<stop stop-color="#E86A47" stop-opacity="0.22"/>
166+
<stop offset="1" stop-color="#E86A47" stop-opacity="0.08"/>
167+
</linearGradient>
168+
169+
<linearGradient id="stroke" x1="0" y1="0" x2="0" y2="1">
170+
<stop stop-color="#FFFFFF" stop-opacity="0.16"/>
171+
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.06"/>
172+
</linearGradient>
173+
</defs>
174+
175+
<rect width="1200" height="630" fill="url(#bg)"/>
176+
<circle cx="260" cy="60" r="520" fill="url(#glowOrange)" filter="url(#softBlur)"/>
177+
<circle cx="1050" cy="120" r="520" fill="url(#glowSea)" filter="url(#softBlur)"/>
178+
179+
<g opacity="0.08">
180+
<path d="M0 84 C160 120 340 40 520 86 C700 132 820 210 1200 160" stroke="#FFFFFF" stroke-opacity="0.10" stroke-width="2"/>
181+
<path d="M0 188 C220 240 360 160 560 204 C760 248 900 330 1200 300" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="2"/>
182+
<path d="M0 440 C240 380 420 520 620 470 C820 420 960 500 1200 460" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="2"/>
183+
</g>
184+
185+
<g opacity="0.22" filter="url(#softBlur)">
186+
<image href="${params.markDataUrl}" x="740" y="70" width="560" height="560" preserveAspectRatio="xMidYMid meet"/>
187+
</g>
188+
189+
<g filter="url(#cardShadow)">
190+
<rect x="72" y="96" width="640" height="438" rx="34" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
191+
</g>
192+
193+
<image href="${params.markDataUrl}" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
194+
195+
<g>
196+
<rect x="166" y="136" width="520" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
197+
<text x="186" y="163"
198+
fill="#F6EFE4"
199+
font-size="18"
200+
font-weight="650"
201+
font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Helvetica, Arial, sans-serif"
202+
opacity="0.92">${escapeXml(pillText)}</text>
203+
</g>
204+
205+
<text x="114" y="${titleY}"
206+
fill="#F6EFE4"
207+
font-size="${titleFontSize}"
208+
font-weight="760"
209+
font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Helvetica, Arial, sans-serif">${titleTspans}</text>
210+
211+
<text x="114" y="${descY}"
212+
fill="#C6B8A8"
213+
font-size="26"
214+
font-weight="520"
215+
font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Helvetica, Arial, sans-serif">${descTspans}</text>
216+
217+
<rect x="114" y="472" width="110" height="6" rx="3" fill="#E86A47"/>
218+
<text x="114" y="530"
219+
fill="#F6EFE4"
220+
font-size="20"
221+
font-weight="650"
222+
opacity="0.90"
223+
font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace">${escapeXml(params.footer)}</text>
224+
</svg>`
225+
}
226+
227+
export default defineEventHandler(async (event) => {
228+
const query = getQuery(event) as OgQuery
229+
const slug = typeof query.slug === 'string' ? query.slug.trim() : ''
230+
if (!slug) {
231+
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
232+
return 'Missing `slug` query param.'
233+
}
234+
235+
const title = typeof query.title === 'string' ? query.title : slug
236+
const description = typeof query.description === 'string' ? query.description : ''
237+
const owner = typeof query.owner === 'string' ? query.owner.trim() : ''
238+
const version = typeof query.version === 'string' ? query.version.trim() : ''
239+
240+
const ownerLabel = owner ? `@${owner}` : 'clawdhub'
241+
const versionLabel = version ? `v${version}` : 'latest'
242+
const footer = owner ? `clawdhub.com/${owner}/${slug}` : `clawdhub.com/skills/${slug}`
243+
244+
const cacheKey = version ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'
245+
setHeader(event, 'Cache-Control', cacheKey)
246+
setHeader(event, 'Content-Type', 'image/png')
247+
248+
const [markDataUrl] = await Promise.all([getMarkDataUrl(), ensureWasm()])
249+
const svg = buildSvg({
250+
markDataUrl,
251+
title,
252+
description,
253+
ownerLabel,
254+
versionLabel,
255+
footer,
256+
})
257+
258+
const resvg = new Resvg(svg, {
259+
fitTo: { mode: 'width', value: 1200 },
260+
font: { loadSystemFonts: true },
261+
})
262+
const png = resvg.render().asPng()
263+
resvg.free()
264+
return png
265+
})

src/lib/og.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@ describe('og helpers', () => {
1212
owner: 'steipete',
1313
displayName: 'Weather',
1414
summary: 'Forecasts for your area.',
15+
version: '1.2.3',
1516
})
1617
expect(meta.title).toBe('Weather — ClawdHub')
1718
expect(meta.description).toBe('Forecasts for your area.')
1819
expect(meta.url).toContain('/steipete/weather')
1920
expect(meta.owner).toBe('steipete')
21+
expect(meta.image).toContain('/og/skill.png?')
22+
expect(meta.image).toContain('slug=weather')
23+
expect(meta.image).toContain('owner=steipete')
24+
expect(meta.image).toContain('version=1.2.3')
2025
})
2126

2227
it('uses defaults when owner and summary are missing', () => {
@@ -25,6 +30,7 @@ describe('og helpers', () => {
2530
expect(meta.description).toMatch(/ClawdHub a fast skill registry/i)
2631
expect(meta.url).toContain('/skills/parser')
2732
expect(meta.owner).toBeNull()
33+
expect(meta.image).toContain('slug=parser')
2834
})
2935

3036
it('truncates long descriptions', () => {
@@ -40,6 +46,7 @@ describe('og helpers', () => {
4046
json: async () => ({
4147
skill: { displayName: 'Weather', summary: 'Forecasts' },
4248
owner: { handle: 'steipete' },
49+
latestVersion: { version: '1.2.3' },
4350
}),
4451
}))
4552
vi.stubGlobal('fetch', fetchMock)
@@ -49,6 +56,7 @@ describe('og helpers', () => {
4956
displayName: 'Weather',
5057
summary: 'Forecasts',
5158
owner: 'steipete',
59+
version: '1.2.3',
5260
})
5361
})
5462

0 commit comments

Comments
 (0)