mirror of
https://github.com/hanzoai/bot-hub.git
synced 2026-10-30 18:46:42 +00:00
Migration from Convex/Vercel to self-hosted infrastructure: API Backend (api/): - Hono server with PostgreSQL (Drizzle ORM) + pgvector - Skills CRUD, search (hybrid vector + lexical), auth (hanzo.id OAuth) - Upload (MinIO S3), stars, comments, API tokens - Rate limiting middleware, session management Frontend Adapter (src/lib/): - api.ts: REST API client replacing Convex SDK - hooks.ts: React hooks replacing convex/react useQuery/useMutation - AuthContext.tsx: Auth provider replacing ConvexAuthProvider Deployment: - Dockerfile (multi-stage: API build + web build + production) - K8s manifests for hub.hanzo.bot on hanzo-k8s - GitHub Actions CI/CD (build → GHCR → deploy) Rebrand: - clawhub/clawdhub/ClawHub → bothub/Bot Hub - Domain: clawhub.ai → hub.hanzo.bot - CLI: clawhub → bothub - Package: @hanzo/bot-hub-api
259 lines
8.6 KiB
TypeScript
259 lines
8.6 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
function glyphWidthFactor(char: string) {
|
|
if (char === ' ') return 0.28
|
|
if (char === '…') return 0.62
|
|
if (/[ilI.,:;|!'"`]/.test(char)) return 0.28
|
|
if (/[mwMW@%&]/.test(char)) return 0.9
|
|
if (/[A-Z]/.test(char)) return 0.68
|
|
if (/[0-9]/.test(char)) return 0.6
|
|
return 0.56
|
|
}
|
|
|
|
function estimateTextWidth(value: string, fontSize: number) {
|
|
let width = 0
|
|
for (const char of value) width += glyphWidthFactor(char) * fontSize
|
|
return width
|
|
}
|
|
|
|
function truncateToWidth(value: string, maxWidth: number, fontSize: number) {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return ''
|
|
if (estimateTextWidth(trimmed, fontSize) <= maxWidth) return trimmed
|
|
|
|
const ellipsis = '…'
|
|
const ellipsisWidth = estimateTextWidth(ellipsis, fontSize)
|
|
let out = ''
|
|
for (const char of trimmed) {
|
|
const next = out + char
|
|
if (estimateTextWidth(next, fontSize) + ellipsisWidth > maxWidth) break
|
|
out = next
|
|
}
|
|
return `${out.replace(/\s+$/g, '').replace(/[.。,;:!?]+$/g, '')}${ellipsis}`
|
|
}
|
|
|
|
function wrapText(value: string, maxWidth: number, fontSize: number, maxLines: number) {
|
|
const words = value.trim().split(/\s+/).filter(Boolean)
|
|
const lines: string[] = []
|
|
let current = ''
|
|
|
|
function pushLine(line: string) {
|
|
if (!line) return
|
|
lines.push(line)
|
|
}
|
|
|
|
function splitLongWord(word: string) {
|
|
if (estimateTextWidth(word, fontSize) <= maxWidth) return [word]
|
|
const parts: string[] = []
|
|
let remaining = word
|
|
while (remaining && estimateTextWidth(remaining, fontSize) > maxWidth) {
|
|
let chunk = ''
|
|
for (const char of remaining) {
|
|
const next = chunk + char
|
|
if (estimateTextWidth(`${next}…`, fontSize) > maxWidth) break
|
|
chunk = next
|
|
}
|
|
if (!chunk) break
|
|
parts.push(`${chunk}…`)
|
|
remaining = remaining.slice(chunk.length)
|
|
}
|
|
if (remaining) parts.push(remaining)
|
|
return parts
|
|
}
|
|
|
|
for (const word of words) {
|
|
if (estimateTextWidth(word, fontSize) > maxWidth) {
|
|
if (current) {
|
|
pushLine(current)
|
|
current = ''
|
|
if (lines.length >= maxLines - 1) break
|
|
}
|
|
const parts = splitLongWord(word)
|
|
for (const part of parts) {
|
|
pushLine(part)
|
|
if (lines.length >= maxLines) break
|
|
}
|
|
current = ''
|
|
if (lines.length >= maxLines - 1) break
|
|
continue
|
|
}
|
|
|
|
const next = current ? `${current} ${word}` : word
|
|
if (estimateTextWidth(next, fontSize) <= maxWidth) {
|
|
current = next
|
|
continue
|
|
}
|
|
pushLine(current)
|
|
current = word
|
|
if (lines.length >= maxLines - 1) break
|
|
}
|
|
if (lines.length < maxLines && current) pushLine(current)
|
|
if (lines.length > maxLines) lines.length = maxLines
|
|
|
|
const usedWords = lines.join(' ').split(/\s+/).filter(Boolean).length
|
|
if (usedWords < words.length) {
|
|
lines[lines.length - 1] = truncateToWidth(lines.at(-1) ?? '', maxWidth, fontSize)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
export function buildSkillOgSvg(params: SkillOgSvgParams) {
|
|
const rawTitle = params.title.trim() || 'Bot Hub Skill'
|
|
const rawDescription = params.description.trim() || 'Published on Bot Hub.'
|
|
|
|
const cardX = 72
|
|
const cardY = 96
|
|
const cardW = 640
|
|
const cardH = 456
|
|
const cardR = 34
|
|
|
|
const contentX = 114
|
|
const contentRightPadding = 28
|
|
const contentMaxWidth = cardX + cardW - contentX - contentRightPadding
|
|
|
|
const titleMaxLines = 2
|
|
const descMaxLines = 3
|
|
|
|
const titleProbeLines = wrapText(rawTitle, contentMaxWidth, 80, titleMaxLines)
|
|
const titleFontSize = titleProbeLines.length > 1 ? 72 : 80
|
|
const titleLines = wrapText(rawTitle, contentMaxWidth, titleFontSize, titleMaxLines)
|
|
|
|
const descLines = wrapText(rawDescription, contentMaxWidth, 26, descMaxLines)
|
|
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 underlineY = cardY + cardH - 80
|
|
const footerY = cardY + cardH - 18
|
|
|
|
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>
|
|
|
|
<clipPath id="cardClip">
|
|
<rect x="${cardX}" y="${cardY}" width="${cardW}" height="${cardH}" rx="${cardR}"/>
|
|
</clipPath>
|
|
</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="${cardX}" y="${cardY}" width="${cardW}" height="${cardH}" rx="${cardR}" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
|
|
</g>
|
|
|
|
<g clip-path="url(#cardClip)">
|
|
<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="${underlineY}" width="110" height="6" rx="3" fill="#E86A47"/>
|
|
<text x="114" y="${footerY}"
|
|
fill="#F6EFE4"
|
|
font-size="20"
|
|
font-weight="500"
|
|
opacity="0.90"
|
|
font-family="${FONT_MONO}, monospace">${escapeXml(params.footer)}</text>
|
|
</g>
|
|
</svg>`
|
|
}
|