|
| 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, '&') |
| 68 | + .replace(/</g, '<') |
| 69 | + .replace(/>/g, '>') |
| 70 | + .replace(/"/g, '"') |
| 71 | + .replace(/'/g, ''') |
| 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 | +}) |
0 commit comments