refactor: modularize skill OG images

This commit is contained in:
Peter Steinberger
2026-01-08 06:07:36 +01:00
parent 0131229843
commit 57af81d054
7 changed files with 344 additions and 246 deletions
+27
View File
@@ -0,0 +1,27 @@
export type SkillOgMeta = {
displayName: string | null
summary: string | null
owner: string | null
version: string | null
}
export async function fetchSkillOgMeta(slug: string, apiBase: string): Promise<SkillOgMeta | null> {
try {
const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, apiBase)
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } })
if (!response.ok) return null
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
}
}
+80
View File
@@ -0,0 +1,80 @@
import { readFile } from 'node:fs/promises'
import { pathToFileURL } from 'node:url'
export const FONT_SANS = 'Bricolage Grotesque'
export const FONT_MONO = 'IBM Plex Mono'
type GlobalNitroMain = {
__nitro_main__?: unknown
}
let markDataUrlPromise: Promise<string> | null = null
let resvgWasmPromise: Promise<Uint8Array> | null = null
let fontBuffersPromise: Promise<Uint8Array[]> | null = null
function getServerRootUrl() {
const nitroMain = (globalThis as unknown as GlobalNitroMain).__nitro_main__
if (typeof nitroMain === 'string') {
try {
return new URL('./', nitroMain)
} catch {
// fall through
}
}
return pathToFileURL(`${process.cwd()}/`)
}
function getServerUrl(pathname: string) {
return new URL(pathname.replace(/^\//, ''), getServerRootUrl())
}
export async function getMarkDataUrl() {
if (!markDataUrlPromise) {
markDataUrlPromise = (async () => {
const candidates = [getServerUrl('clawd-mark.png'), getServerUrl('public/clawd-mark.png')]
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
}
export async function getResvgWasm() {
if (!resvgWasmPromise) {
resvgWasmPromise = readFile(getServerUrl('node_modules/@resvg/resvg-wasm/index_bg.wasm')).then(
(buffer) => new Uint8Array(buffer),
)
}
return resvgWasmPromise
}
export async function getFontBuffers() {
if (!fontBuffersPromise) {
fontBuffersPromise = Promise.all([
readFile(
getServerUrl(
'node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2',
),
),
readFile(
getServerUrl(
'node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2',
),
),
readFile(
getServerUrl(
'node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2',
),
),
]).then((buffers) => buffers.map((buffer) => new Uint8Array(buffer)))
}
return fontBuffersPromise
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { buildSkillOgSvg } from './skillOgSvg'
describe('skill OG SVG', () => {
it('includes title, description, and labels', () => {
const svg = buildSkillOgSvg({
markDataUrl: 'data:image/png;base64,AAA=',
title: 'Discord Doctor',
description: 'Quick diagnosis and repair for Discord bot.',
ownerLabel: '@jhillock',
versionLabel: 'v1.2.3',
footer: 'clawdhub.com/jhillock/discord-doctor',
})
expect(svg).toContain('Discord Doctor')
expect(svg).toContain('Quick diagnosis and repair')
expect(svg).toContain('@jhillock')
expect(svg).toContain('v1.2.3')
expect(svg).toContain('clawdhub.com/jhillock/discord-doctor')
})
})
+165
View File
@@ -0,0 +1,165 @@
import { FONT_MONO, FONT_SANS } from './ogAssets'
export type SkillOgSvgParams = {
markDataUrl: string
title: string
description: string
ownerLabel: string
versionLabel: string
footer: string
}
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
}
export function buildSkillOgSvg(params: SkillOgSvgParams) {
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="600"
font-family="${FONT_SANS}, sans-serif"
opacity="0.92">${escapeXml(pillText)}</text>
</g>
<text x="114" y="${titleY}"
fill="#F6EFE4"
font-size="${titleFontSize}"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${titleTspans}</text>
<text x="114" y="${descY}"
fill="#C6B8A8"
font-size="26"
font-weight="500"
font-family="${FONT_SANS}, 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="500"
opacity="0.90"
font-family="${FONT_MONO}, monospace">${escapeXml(params.footer)}</text>
</svg>`
}
+46 -244
View File
@@ -1,271 +1,72 @@
import { readFile } from 'node:fs/promises'
import { initWasm, Resvg } from '@resvg/resvg-wasm'
import { defineEventHandler, getQuery, setHeader } from 'h3'
import { fetchSkillOgMeta } from '../../og/fetchSkillOgMeta'
import {
FONT_MONO,
FONT_SANS,
getFontBuffers,
getMarkDataUrl,
getResvgWasm,
} from '../../og/ogAssets'
import { buildSkillOgSvg } from '../../og/skillOgSvg'
type OgQuery = {
slug?: string
title?: string
owner?: string
version?: string
title?: string
description?: string
v?: string
}
const FONT_SANS = 'Bricolage Grotesque'
const FONT_MONO = 'IBM Plex Mono'
let markDataUrlPromise: Promise<string> | null = null
let wasmInitPromise: Promise<void> | null = null
let fontBuffersPromise: Promise<Uint8Array[]> | null = null
function cleanString(value: unknown) {
if (typeof value !== 'string') return ''
return value.trim()
}
function getApiBase(eventHost: string | null) {
const direct = process.env.VITE_CONVEX_SITE_URL?.trim()
if (direct) return direct
const site = process.env.SITE_URL?.trim() || process.env.VITE_SITE_URL?.trim()
if (site) return site
if (eventHost) return `https://${eventHost}`
return 'https://clawdhub.com'
}
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)
})()
wasmInitPromise = getResvgWasm().then((wasm) => initWasm(wasm))
}
await wasmInitPromise
}
async function getFontBuffers() {
if (!fontBuffersPromise) {
fontBuffersPromise = Promise.all([
readFile(
new URL(
'../../../node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2',
import.meta.url,
),
),
readFile(
new URL(
'../../../node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2',
import.meta.url,
),
),
readFile(
new URL(
'../../../node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2',
import.meta.url,
),
),
]).then((buffers) => buffers.map((buffer) => new Uint8Array(buffer)))
}
return fontBuffersPromise
}
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="600"
font-family="${FONT_SANS}, sans-serif"
opacity="0.92">${escapeXml(pillText)}</text>
</g>
<text x="114" y="${titleY}"
fill="#F6EFE4"
font-size="${titleFontSize}"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${titleTspans}</text>
<text x="114" y="${descY}"
fill="#C6B8A8"
font-size="26"
font-weight="500"
font-family="${FONT_SANS}, 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="500"
opacity="0.90"
font-family="${FONT_MONO}, 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() : ''
const slug = cleanString(query.slug)
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 ownerFromQuery = cleanString(query.owner)
const versionFromQuery = cleanString(query.version)
const titleFromQuery = cleanString(query.title)
const descriptionFromQuery = cleanString(query.description)
const needFetch = !titleFromQuery || !descriptionFromQuery || !ownerFromQuery || !versionFromQuery
const meta = needFetch
? await fetchSkillOgMeta(slug, getApiBase(event.node.req.headers.host ?? null))
: null
const owner = ownerFromQuery || meta?.owner || ''
const version = versionFromQuery || meta?.version || ''
const title = titleFromQuery || meta?.displayName || slug
const description = descriptionFromQuery || meta?.summary || ''
const ownerLabel = owner ? `@${owner}` : 'clawdhub'
const versionLabel = version ? `v${version}` : 'latest'
@@ -279,7 +80,8 @@ export default defineEventHandler(async (event) => {
getMarkDataUrl(),
ensureWasm().then(() => getFontBuffers()),
])
const svg = buildSvg({
const svg = buildSkillOgSvg({
markDataUrl,
title,
description,
+3
View File
@@ -19,9 +19,12 @@ describe('og helpers', () => {
expect(meta.url).toContain('/steipete/weather')
expect(meta.owner).toBe('steipete')
expect(meta.image).toContain('/og/skill.png?')
expect(meta.image).toContain('v=2')
expect(meta.image).toContain('slug=weather')
expect(meta.image).toContain('owner=steipete')
expect(meta.image).toContain('version=1.2.3')
expect(meta.image).not.toContain('title=')
expect(meta.image).not.toContain('description=')
})
it('uses defaults when owner and summary are missing', () => {
+2 -2
View File
@@ -16,6 +16,7 @@ type SkillMeta = {
const DEFAULT_SITE = 'https://clawdhub.com'
const DEFAULT_DESCRIPTION = 'ClawdHub — a fast skill registry for agents, with vector search.'
const OG_SKILL_IMAGE_LAYOUT_VERSION = '2'
export function getSiteUrl() {
return import.meta.env.VITE_SITE_URL ?? DEFAULT_SITE
@@ -58,11 +59,10 @@ export function buildSkillMeta(source: SkillMetaSource): SkillMeta {
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('v', OG_SKILL_IMAGE_LAYOUT_VERSION)
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),