mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: dynamic skill OG images
This commit is contained in:
@@ -16,7 +16,6 @@ count.txt
|
||||
.wrangler
|
||||
.output
|
||||
.vinxi
|
||||
*.bun-build
|
||||
todos.json
|
||||
.cta.json
|
||||
.vscode
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Web: dynamic OG image cards for skills (name, description, version).
|
||||
|
||||
## 0.1.0 - 2026-01-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user