feat: dynamic skill OG images

This commit is contained in:
Peter Steinberger
2026-01-08 05:47:27 +01:00
parent cf2ad58e86
commit d7650583dc
10 changed files with 310 additions and 4 deletions
-1
View File
@@ -16,7 +16,6 @@ count.txt
.wrangler
.output
.vinxi
*.bun-build
todos.json
.cta.json
.vscode
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## Unreleased
### Added
- Web: dynamic OG image cards for skills (name, description, version).
## 0.1.0 - 2026-01-07
### Added
+5 -1
View File
@@ -13,6 +13,7 @@
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-toggle-group": "^1.1.11",
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.0",
"@tanstack/react-router": "^1.144.0",
@@ -24,6 +25,7 @@
"clsx": "^2.1.1",
"convex": "^1.31.2",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.5",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.55.1",
"nitro": "^3.0.1-alpha.1",
@@ -58,7 +60,7 @@
},
"packages/clawdhub": {
"name": "clawdhub",
"version": "0.0.5",
"version": "0.1.0",
"bin": {
"clawdhub": "bin/clawdhub.js",
},
@@ -438,6 +440,8 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
+2
View File
@@ -29,6 +29,7 @@
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-toggle-group": "^1.1.11",
"@resvg/resvg-wasm": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.9.0",
"@tanstack/react-router": "^1.144.0",
@@ -40,6 +41,7 @@
"clsx": "^2.1.1",
"convex": "^1.31.2",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.5",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.55.1",
"nitro": "^3.0.1-alpha.1",
+265
View File
@@ -0,0 +1,265 @@
import { readFile } from 'node:fs/promises'
import { initWasm, Resvg } from '@resvg/resvg-wasm'
import { defineEventHandler, getQuery, setHeader } from 'h3'
type OgQuery = {
slug?: string
title?: string
owner?: string
version?: string
description?: string
}
let markDataUrlPromise: Promise<string> | null = null
let wasmInitPromise: Promise<void> | null = null
async function ensureWasm() {
if (!wasmInitPromise) {
wasmInitPromise = (async () => {
const wasm = await readFile(
new URL('../../../node_modules/@resvg/resvg-wasm/index_bg.wasm', import.meta.url),
)
await initWasm(wasm)
})()
}
await wasmInitPromise
}
function getNitroServerRootUrl() {
const nitroMain = (globalThis as unknown as { __nitro_main__?: unknown }).__nitro_main__
if (typeof nitroMain !== 'string') return null
try {
return new URL('./', nitroMain)
} catch {
return null
}
}
async function getMarkDataUrl() {
if (!markDataUrlPromise) {
markDataUrlPromise = (async () => {
const candidates = [
(() => {
const root = getNitroServerRootUrl()
return root ? new URL('./clawd-mark.png', root) : null
})(),
new URL('../../../public/clawd-mark.png', import.meta.url),
].filter((value): value is URL => Boolean(value))
let lastError: unknown = null
for (const url of candidates) {
try {
const buffer = await readFile(url)
return `data:image/png;base64,${buffer.toString('base64')}`
} catch (error) {
lastError = error
}
}
throw lastError
})()
}
return markDataUrlPromise
}
function escapeXml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function wrapText(value: string, maxChars: number, maxLines: number) {
const words = value.trim().split(/\s+/).filter(Boolean)
const lines: string[] = []
let current = ''
for (const word of words) {
const next = current ? `${current} ${word}` : word
if (next.length <= maxChars) {
current = next
continue
}
if (current) lines.push(current)
current = word
if (lines.length >= maxLines - 1) break
}
if (lines.length < maxLines && current) lines.push(current)
if (lines.length > maxLines) lines.length = maxLines
const usedWords = lines.join(' ').split(/\s+/).filter(Boolean).length
if (usedWords < words.length) {
const last = lines.at(-1) ?? ''
const trimmed = last.length > maxChars ? last.slice(0, maxChars) : last
lines[lines.length - 1] = `${trimmed.replace(/\s+$/g, '').replace(/[.。,;:!?]+$/g, '')}`
}
return lines
}
function buildSvg(params: {
markDataUrl: string
title: string
description: string
ownerLabel: string
versionLabel: string
footer: string
}) {
const rawTitle = params.title.trim() || 'ClawdHub Skill'
const rawDescription = params.description.trim() || 'Published on ClawdHub.'
const titleLines = wrapText(rawTitle, 22, 2)
const descLines = wrapText(rawDescription, 52, 3)
const titleFontSize = titleLines.length > 1 || rawTitle.length > 24 ? 72 : 80
const titleY = titleLines.length > 1 ? 258 : 280
const titleLineHeight = 84
const descY = titleLines.length > 1 ? 395 : 380
const descLineHeight = 34
const pillText = `${params.ownerLabel}${params.versionLabel}`
const titleTspans = titleLines
.map((line, index) => {
const dy = index === 0 ? 0 : titleLineHeight
return `<tspan x="114" dy="${dy}">${escapeXml(line)}</tspan>`
})
.join('')
const descTspans = descLines
.map((line, index) => {
const dy = index === 0 ? 0 : descLineHeight
return `<tspan x="114" dy="${dy}">${escapeXml(line)}</tspan>`
})
.join('')
return `<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
<stop stop-color="#14110F"/>
<stop offset="0.55" stop-color="#1A1512"/>
<stop offset="1" stop-color="#14110F"/>
</linearGradient>
<radialGradient id="glowOrange" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(260 60) rotate(120) scale(520 420)">
<stop stop-color="#E86A47" stop-opacity="0.55"/>
<stop offset="1" stop-color="#E86A47" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glowSea" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1050 120) rotate(140) scale(520 420)">
<stop stop-color="#4AD8B7" stop-opacity="0.35"/>
<stop offset="1" stop-color="#4AD8B7" stop-opacity="0"/>
</radialGradient>
<filter id="softBlur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="24"/>
</filter>
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#000000" flood-opacity="0.6"/>
</filter>
<linearGradient id="pill" x1="0" y1="0" x2="520" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#E86A47" stop-opacity="0.22"/>
<stop offset="1" stop-color="#E86A47" stop-opacity="0.08"/>
</linearGradient>
<linearGradient id="stroke" x1="0" y1="0" x2="0" y2="1">
<stop stop-color="#FFFFFF" stop-opacity="0.16"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.06"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<circle cx="260" cy="60" r="520" fill="url(#glowOrange)" filter="url(#softBlur)"/>
<circle cx="1050" cy="120" r="520" fill="url(#glowSea)" filter="url(#softBlur)"/>
<g opacity="0.08">
<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"/>
<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"/>
<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"/>
</g>
<g opacity="0.22" filter="url(#softBlur)">
<image href="${params.markDataUrl}" x="740" y="70" width="560" height="560" preserveAspectRatio="xMidYMid meet"/>
</g>
<g filter="url(#cardShadow)">
<rect x="72" y="96" width="640" height="438" rx="34" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
</g>
<image href="${params.markDataUrl}" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
<g>
<rect x="166" y="136" width="520" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
<text x="186" y="163"
fill="#F6EFE4"
font-size="18"
font-weight="650"
font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Helvetica, Arial, sans-serif"
opacity="0.92">${escapeXml(pillText)}</text>
</g>
<text x="114" y="${titleY}"
fill="#F6EFE4"
font-size="${titleFontSize}"
font-weight="760"
font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Helvetica, Arial, sans-serif">${titleTspans}</text>
<text x="114" y="${descY}"
fill="#C6B8A8"
font-size="26"
font-weight="520"
font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Helvetica, Arial, sans-serif">${descTspans}</text>
<rect x="114" y="472" width="110" height="6" rx="3" fill="#E86A47"/>
<text x="114" y="530"
fill="#F6EFE4"
font-size="20"
font-weight="650"
opacity="0.90"
font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace">${escapeXml(params.footer)}</text>
</svg>`
}
export default defineEventHandler(async (event) => {
const query = getQuery(event) as OgQuery
const slug = typeof query.slug === 'string' ? query.slug.trim() : ''
if (!slug) {
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
return 'Missing `slug` query param.'
}
const title = typeof query.title === 'string' ? query.title : slug
const description = typeof query.description === 'string' ? query.description : ''
const owner = typeof query.owner === 'string' ? query.owner.trim() : ''
const version = typeof query.version === 'string' ? query.version.trim() : ''
const ownerLabel = owner ? `@${owner}` : 'clawdhub'
const versionLabel = version ? `v${version}` : 'latest'
const footer = owner ? `clawdhub.com/${owner}/${slug}` : `clawdhub.com/skills/${slug}`
const cacheKey = version ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'
setHeader(event, 'Cache-Control', cacheKey)
setHeader(event, 'Content-Type', 'image/png')
const [markDataUrl] = await Promise.all([getMarkDataUrl(), ensureWasm()])
const svg = buildSvg({
markDataUrl,
title,
description,
ownerLabel,
versionLabel,
footer,
})
const resvg = new Resvg(svg, {
fitTo: { mode: 'width', value: 1200 },
font: { loadSystemFonts: true },
})
const png = resvg.render().asPng()
resvg.free()
return png
})
+8
View File
@@ -12,11 +12,16 @@ describe('og helpers', () => {
owner: 'steipete',
displayName: 'Weather',
summary: 'Forecasts for your area.',
version: '1.2.3',
})
expect(meta.title).toBe('Weather — ClawdHub')
expect(meta.description).toBe('Forecasts for your area.')
expect(meta.url).toContain('/steipete/weather')
expect(meta.owner).toBe('steipete')
expect(meta.image).toContain('/og/skill.png?')
expect(meta.image).toContain('slug=weather')
expect(meta.image).toContain('owner=steipete')
expect(meta.image).toContain('version=1.2.3')
})
it('uses defaults when owner and summary are missing', () => {
@@ -25,6 +30,7 @@ describe('og helpers', () => {
expect(meta.description).toMatch(/ClawdHub — a fast skill registry/i)
expect(meta.url).toContain('/skills/parser')
expect(meta.owner).toBeNull()
expect(meta.image).toContain('slug=parser')
})
it('truncates long descriptions', () => {
@@ -40,6 +46,7 @@ describe('og helpers', () => {
json: async () => ({
skill: { displayName: 'Weather', summary: 'Forecasts' },
owner: { handle: 'steipete' },
latestVersion: { version: '1.2.3' },
}),
}))
vi.stubGlobal('fetch', fetchMock)
@@ -49,6 +56,7 @@ describe('og helpers', () => {
displayName: 'Weather',
summary: 'Forecasts',
owner: 'steipete',
version: '1.2.3',
})
})
+11 -1
View File
@@ -3,6 +3,7 @@ type SkillMetaSource = {
owner?: string | null
displayName?: string | null
summary?: string | null
version?: string | null
}
type SkillMeta = {
@@ -33,11 +34,13 @@ export async function fetchSkillMeta(slug: string) {
const payload = (await response.json()) as {
skill?: { displayName?: string; summary?: string | null } | null
owner?: { handle?: string | null } | null
latestVersion?: { version?: string | null } | null
}
return {
displayName: payload.skill?.displayName ?? null,
summary: payload.skill?.summary ?? null,
owner: payload.owner?.handle ?? null,
version: payload.latestVersion?.version ?? null,
}
} catch {
return null
@@ -49,14 +52,21 @@ export function buildSkillMeta(source: SkillMetaSource): SkillMeta {
const owner = clean(source.owner)
const displayName = clean(source.displayName) || clean(source.slug)
const summary = clean(source.summary)
const version = clean(source.version)
const title = `${displayName} — ClawdHub`
const description =
summary || (owner ? `Agent skill by @${owner} on ClawdHub.` : DEFAULT_DESCRIPTION)
const url = owner ? `${siteUrl}/${owner}/${source.slug}` : `${siteUrl}/skills/${source.slug}`
const imageParams = new URLSearchParams()
imageParams.set('slug', source.slug)
imageParams.set('title', displayName)
if (owner) imageParams.set('owner', owner)
if (version) imageParams.set('version', version)
if (summary) imageParams.set('description', truncate(summary, 200))
return {
title,
description: truncate(description, 200),
image: `${siteUrl}/og.png`,
image: `${siteUrl}/og/skill.png?${imageParams.toString()}`,
url,
owner: owner || null,
}
+2
View File
@@ -9,6 +9,7 @@ export const Route = createFileRoute('/$owner/$slug')({
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
}
},
head: ({ params, loaderData }) => {
@@ -17,6 +18,7 @@ export const Route = createFileRoute('/$owner/$slug')({
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
})
return {
links: [
+2
View File
@@ -9,6 +9,7 @@ export const Route = createFileRoute('/skills/$slug')({
owner: data?.owner ?? null,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
}
},
head: ({ params, loaderData }) => {
@@ -17,6 +18,7 @@ export const Route = createFileRoute('/skills/$slug')({
owner: loaderData?.owner ?? null,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
})
return {
links: [
+10 -1
View File
@@ -1,3 +1,4 @@
import { createRequire } from 'node:module'
import tailwindcss from '@tailwindcss/vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
@@ -6,10 +7,18 @@ import { nitro } from 'nitro/vite'
import { defineConfig } from 'vite'
import viteTsConfigPaths from 'vite-tsconfig-paths'
const require = createRequire(import.meta.url)
const resvgWasmPath = require.resolve('@resvg/resvg-wasm/index_bg.wasm')
const config = defineConfig({
plugins: [
devtools(),
nitro(),
nitro({
serverDir: 'server',
externals: {
traceInclude: [resvgWasmPath],
},
}),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ['./tsconfig.json'],