feat: refresh dynamic og images (#2180)

This commit is contained in:
Vyctor H. Brzezowski
2026-05-19 13:37:36 -07:00
committed by GitHub
parent c7935b6800
commit b8efe83d1b
32 changed files with 1558 additions and 314 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

+18
View File
@@ -39,6 +39,24 @@ const ibmPlex500Source = await resolveExistingPath(
);
const copies = [
{
source: path.resolve("public/clawd-logo.png"),
targets: [
path.resolve(".output/server/clawd-logo.png"),
path.resolve(".output/server/public/clawd-logo.png"),
path.resolve(".vercel/output/functions/__server.func/clawd-logo.png"),
path.resolve(".vercel/output/functions/__server.func/public/clawd-logo.png"),
],
},
{
source: path.resolve("public/og-clawhub-watermark.png"),
targets: [
path.resolve(".output/server/og-clawhub-watermark.png"),
path.resolve(".output/server/public/og-clawhub-watermark.png"),
path.resolve(".vercel/output/functions/__server.func/og-clawhub-watermark.png"),
path.resolve(".vercel/output/functions/__server.func/public/og-clawhub-watermark.png"),
],
},
{
source: path.resolve("public/clawd-mark.png"),
targets: [
+82
View File
@@ -0,0 +1,82 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchImageDataUrl, isTrustedOgImageUrl } from "./fetchImageDataUrl";
describe("fetchImageDataUrl", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("only trusts known public avatar image hosts over https", () => {
expect(isTrustedOgImageUrl("https://avatars.githubusercontent.com/u/1?v=4")).toBe(true);
expect(isTrustedOgImageUrl("https://www.gravatar.com/avatar/hash?s=160")).toBe(true);
expect(isTrustedOgImageUrl("http://avatars.githubusercontent.com/u/1")).toBe(false);
expect(isTrustedOgImageUrl("https://127.0.0.1/avatar.png")).toBe(false);
expect(isTrustedOgImageUrl("https://example.com/avatar.png")).toBe(false);
});
it("does not fetch untrusted image URLs", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(fetchImageDataUrl("https://127.0.0.1/avatar.png")).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("converts trusted image responses to data URLs", async () => {
const fetchMock = vi.fn(async () => {
return new Response(new Uint8Array([1, 2]), {
status: 200,
headers: { "content-type": "image/png" },
});
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchImageDataUrl("https://avatars.githubusercontent.com/u/1?v=4")).resolves.toBe(
"data:image/png;base64,AQI=",
);
expect(fetchMock).toHaveBeenCalledWith(
new URL("https://avatars.githubusercontent.com/u/1?v=4"),
{
headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/*" },
redirect: "manual",
signal: expect.any(AbortSignal),
},
);
});
it("rejects trusted image responses that declare oversized bodies", async () => {
const fetchMock = vi.fn(async () => {
return new Response(new Uint8Array([1, 2]), {
status: 200,
headers: {
"content-type": "image/png",
"content-length": "1500001",
},
});
});
vi.stubGlobal("fetch", fetchMock);
await expect(
fetchImageDataUrl("https://avatars.githubusercontent.com/u/1?v=4"),
).resolves.toBeNull();
});
it("rejects trusted image responses that stream past the byte cap", async () => {
const fetchMock = vi.fn(async () => {
return new Response(new Uint8Array(1_500_001), {
status: 200,
headers: { "content-type": "image/png" },
});
});
vi.stubGlobal("fetch", fetchMock);
await expect(
fetchImageDataUrl("https://avatars.githubusercontent.com/u/1?v=4"),
).resolves.toBeNull();
});
});
+85
View File
@@ -0,0 +1,85 @@
const MAX_IMAGE_BYTES = 1_500_000;
const IMAGE_FETCH_TIMEOUT_MS = 1_500;
const TRUSTED_IMAGE_HOSTS = new Set([
"avatars.githubusercontent.com",
"camo.githubusercontent.com",
"github.githubassets.com",
"raw.githubusercontent.com",
"user-images.githubusercontent.com",
"gravatar.com",
"secure.gravatar.com",
"www.gravatar.com",
]);
export function isTrustedOgImageUrl(url: string | null | undefined) {
if (!url) return false;
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
return TRUSTED_IMAGE_HOSTS.has(parsed.hostname.toLowerCase());
} catch {
return false;
}
}
export async function fetchImageDataUrl(url: string | null | undefined) {
if (!url) return null;
try {
const parsed = new URL(url);
if (!isTrustedOgImageUrl(parsed.toString())) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(parsed, {
headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/*" },
redirect: "manual",
signal: controller.signal,
});
if (!response.ok) return null;
const contentType = response.headers.get("content-type")?.split(";")[0]?.trim();
if (!contentType?.startsWith("image/")) return null;
const buffer = await readLimitedImageBody(response);
if (!buffer) return null;
return `data:${contentType};base64,${buffer.toString("base64")}`;
} finally {
clearTimeout(timeout);
}
} catch {
return null;
}
}
async function readLimitedImageBody(response: Response) {
const contentLength = response.headers.get("content-length");
if (contentLength) {
const expectedBytes = Number.parseInt(contentLength, 10);
if (Number.isFinite(expectedBytes) && expectedBytes > MAX_IMAGE_BYTES) return null;
}
const reader = response.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > MAX_IMAGE_BYTES) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
if (totalBytes === 0) return null;
return Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)),
totalBytes,
);
}
+61
View File
@@ -0,0 +1,61 @@
export type PluginOgMeta = {
name: string | null;
displayName: string | null;
summary: string | null;
owner: string | null;
ownerImage: string | null;
latestVersion: string | null;
stats: {
downloads: number;
};
verification: {
scanStatus: string | null;
} | null;
};
export async function fetchPluginOgMeta(
packageName: string,
apiBase: string,
): Promise<PluginOgMeta | null> {
try {
const url = new URL(`/api/v1/packages/${encodeURIComponent(packageName)}`, apiBase);
const response = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!response.ok) return null;
const payload = (await response.json()) as {
package?: {
name?: string;
displayName?: string;
summary?: string | null;
latestVersion?: string | null;
stats?: unknown;
verification?: { scanStatus?: string | null } | null;
} | null;
owner?: { handle?: string | null; image?: string | null } | null;
};
const stats = readStats(payload.package?.stats);
return {
name: payload.package?.name ?? null,
displayName: payload.package?.displayName ?? null,
summary: payload.package?.summary ?? null,
owner: payload.owner?.handle ?? null,
ownerImage: payload.owner?.image ?? null,
latestVersion: payload.package?.latestVersion ?? null,
stats: {
downloads: readNumber(stats.downloads),
},
verification: payload.package?.verification
? { scanStatus: payload.package.verification.scanStatus ?? null }
: null,
};
} catch {
return null;
}
}
function readStats(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
+53
View File
@@ -0,0 +1,53 @@
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../convex/_generated/api";
export type PublisherOgMeta = {
handle: string | null;
kind: "user" | "org";
displayName: string | null;
bio: string | null;
image: string | null;
stats: {
downloads: number;
};
};
type PublisherProfileResult = {
handle?: string | null;
kind?: "user" | "org";
displayName?: string | null;
bio?: string | null;
image?: string | null;
stats?: {
downloads?: number;
};
} | null;
export async function fetchPublisherOgMeta(
handle: string,
convexUrl: string,
): Promise<PublisherOgMeta | null> {
try {
const client = new ConvexHttpClient(convexUrl);
const profile = (await client.query(api.publishers.getProfileByHandle, {
handle,
})) as PublisherProfileResult;
if (!profile) return null;
return {
handle: profile.handle ?? null,
kind: profile.kind === "org" ? "org" : "user",
displayName: profile.displayName ?? null,
bio: profile.bio ?? null,
image: profile.image ?? null,
stats: {
downloads: readNumber(profile.stats?.downloads),
},
};
} catch {
return null;
}
}
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
+36 -2
View File
@@ -2,7 +2,16 @@ export type SkillOgMeta = {
displayName: string | null;
summary: string | null;
owner: string | null;
ownerImage: string | null;
version: string | null;
stats: {
downloads: number;
};
moderation: {
verdict: "clean" | "suspicious" | "malicious" | null;
isSuspicious: boolean;
isMalwareBlocked: boolean;
} | null;
};
export async function fetchSkillOgMeta(slug: string, apiBase: string): Promise<SkillOgMeta | null> {
@@ -11,17 +20,42 @@ export async function fetchSkillOgMeta(slug: string, apiBase: string): Promise<S
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;
skill?: { displayName?: string; summary?: string | null; stats?: unknown } | null;
owner?: { handle?: string | null; image?: string | null } | null;
latestVersion?: { version?: string | null } | null;
moderation?: {
verdict?: "clean" | "suspicious" | "malicious";
isSuspicious?: boolean;
isMalwareBlocked?: boolean;
} | null;
};
const stats = readStats(payload.skill?.stats);
return {
displayName: payload.skill?.displayName ?? null,
summary: payload.skill?.summary ?? null,
owner: payload.owner?.handle ?? null,
ownerImage: payload.owner?.image ?? null,
version: payload.latestVersion?.version ?? null,
stats: {
downloads: readNumber(stats.downloads),
},
moderation: payload.moderation
? {
verdict: payload.moderation.verdict ?? null,
isSuspicious: Boolean(payload.moderation.isSuspicious),
isMalwareBlocked: Boolean(payload.moderation.isMalwareBlocked),
}
: null,
};
} catch {
return null;
}
}
function readStats(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
+7
View File
@@ -0,0 +1,7 @@
export function formatOgStat(value: number | null | undefined) {
const numericValue = typeof value === "number" && Number.isFinite(value) ? value : 0;
const abs = Math.abs(numericValue);
if (abs >= 1_000_000) return `${(numericValue / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}m`;
if (abs >= 1_000) return `${(numericValue / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`;
return String(numericValue);
}
+26 -5
View File
@@ -15,18 +15,39 @@ vi.mock("@resvg/resvg-wasm", () => ({
describe("ogAssets", () => {
beforeEach(() => {
vi.resetModules();
delete (globalThis as { __nitro_main__?: unknown }).__nitro_main__;
readFileMock.mockReset();
initWasmMock.mockReset();
});
it("falls back to the packaged public mark asset", async () => {
it("loads the packaged local OG watermark asset", async () => {
readFileMock.mockImplementation(async (input: unknown) => {
const path = String(input);
if (path.includes("public/clawd-mark.png")) {
if (path.includes("public/og-clawhub-watermark.png")) {
return Buffer.from("watermark");
}
if (path.includes("og-clawhub-watermark.png")) {
throw new Error("missing root watermark");
}
throw new Error(`unexpected read: ${path}`);
});
const { getWatermarkDataUrl } = await import("./ogAssets");
await expect(getWatermarkDataUrl()).resolves.toBe("data:image/png;base64,d2F0ZXJtYXJr");
expect(readFileMock).toHaveBeenCalledTimes(2);
expect(String(readFileMock.mock.calls[0]?.[0])).toContain("og-clawhub-watermark.png");
expect(String(readFileMock.mock.calls[1]?.[0])).toContain("public/og-clawhub-watermark.png");
});
it("falls back to the packaged public site logo asset", async () => {
readFileMock.mockImplementation(async (input: unknown) => {
const path = String(input);
if (path.includes("public/clawd-logo.png")) {
return Buffer.from("png");
}
if (path.includes("clawd-mark.png")) {
if (path.includes("clawd-logo.png")) {
throw new Error("missing root mark");
}
throw new Error(`unexpected read: ${path}`);
@@ -36,8 +57,8 @@ describe("ogAssets", () => {
await expect(getMarkDataUrl()).resolves.toBe("data:image/png;base64,cG5n");
expect(readFileMock).toHaveBeenCalledTimes(2);
expect(String(readFileMock.mock.calls[0]?.[0])).toContain("clawd-mark.png");
expect(String(readFileMock.mock.calls[1]?.[0])).toContain("public/clawd-mark.png");
expect(String(readFileMock.mock.calls[0]?.[0])).toContain("clawd-logo.png");
expect(String(readFileMock.mock.calls[1]?.[0])).toContain("public/clawd-logo.png");
});
it("initializes resvg wasm only once per process", async () => {
+29 -1
View File
@@ -10,6 +10,7 @@ type GlobalNitroMain = {
};
let markDataUrlPromise: Promise<string> | null = null;
let watermarkDataUrlPromise: Promise<string> | null = null;
let resvgWasmPromise: Promise<Uint8Array> | null = null;
let fontBuffersPromise: Promise<Uint8Array[]> | null = null;
let resvgInitPromise: Promise<void> | null = null;
@@ -33,7 +34,12 @@ function getServerUrl(pathname: string) {
export async function getMarkDataUrl() {
if (!markDataUrlPromise) {
markDataUrlPromise = (async () => {
const candidates = [getServerUrl("clawd-mark.png"), getServerUrl("public/clawd-mark.png")];
const candidates = [
getServerUrl("clawd-logo.png"),
getServerUrl("public/clawd-logo.png"),
getServerUrl("clawd-mark.png"),
getServerUrl("public/clawd-mark.png"),
];
let lastError: unknown = null;
for (const url of candidates) {
try {
@@ -49,6 +55,28 @@ export async function getMarkDataUrl() {
return markDataUrlPromise;
}
export async function getWatermarkDataUrl() {
if (!watermarkDataUrlPromise) {
watermarkDataUrlPromise = (async () => {
const candidates = [
getServerUrl("og-clawhub-watermark.png"),
getServerUrl("public/og-clawhub-watermark.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 watermarkDataUrlPromise;
}
export async function getResvgWasm() {
if (!resvgWasmPromise) {
resvgWasmPromise = readFile(getServerUrl("node_modules/@resvg/resvg-wasm/index_bg.wasm")).then(
+35
View File
@@ -0,0 +1,35 @@
import { buildRegistryOgSvg, type RegistryOgCommand, type RegistryOgStat } from "./registryOgSvg";
export type PluginOgSvgParams = {
markDataUrl: string;
watermarkDataUrl?: string | null;
avatarDataUrl?: string | null;
title: string;
description: string;
packageName: string;
ownerLabel: string;
installCommand?: RegistryOgCommand | null;
stats?: RegistryOgStat[];
};
export function buildPluginOgSvg(params: PluginOgSvgParams) {
return buildRegistryOgSvg({
markDataUrl: params.markDataUrl,
watermarkDataUrl: params.watermarkDataUrl,
avatarDataUrl: params.avatarDataUrl,
avatarShape: "rounded",
avatarFit: "contain",
surfaceLabel: "Plugin",
eyebrow: params.ownerLabel,
title: params.title,
description: params.description,
installCommand: params.installCommand,
stats:
params.stats && params.stats.length > 0
? params.stats
: [
{ value: params.packageName, label: "Package" },
{ value: params.ownerLabel, label: "Publisher" },
],
});
}
+10
View File
@@ -0,0 +1,10 @@
export function pngResponse(png: Uint8Array, cacheControl: string) {
const body = new ArrayBuffer(png.byteLength);
new Uint8Array(body).set(png);
return new Response(body, {
headers: {
"Cache-Control": cacheControl,
"Content-Type": "image/png",
},
});
}
+132
View File
@@ -0,0 +1,132 @@
import { FONT_SANS } from "./ogAssets";
import { escapeXml, OPENCLAW_RED, type RegistryOgStat, wrapText } from "./registryOgSvg";
export type PublisherOgSvgParams = {
markDataUrl: string;
watermarkDataUrl?: string | null;
avatarDataUrl?: string | null;
avatarShape?: "circle" | "rounded";
title: string;
description: string;
handleLabel: string;
stats?: RegistryOgStat[];
};
function statBlock(stats: RegistryOgStat[] | undefined, x: number, y: number) {
const stat = stats?.[0] ?? { value: "ClawHub", label: "Publisher" };
return `<g>
<text x="${x}" y="${y}"
fill="#9D9692"
font-size="22"
font-weight="700"
font-family="${FONT_SANS}, sans-serif">${escapeXml(stat.label)}</text>
<text x="${x}" y="${y + 44}"
fill="#F7F1EA"
font-size="44"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${escapeXml(stat.value)}</text>
</g>`;
}
export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
const rawTitle = params.title.trim() || params.handleLabel;
const rawDescription = params.description.trim() || "Publisher on ClawHub.";
const avatar = params.avatarDataUrl || params.markDataUrl;
const watermark = params.watermarkDataUrl || params.markDataUrl;
const avatarShape = params.avatarShape ?? "circle";
const contentX = 430;
const contentWidth = 650;
const titleLines = wrapText(rawTitle, contentWidth, 72, 2);
const titleFontSize = titleLines.length > 1 ? 62 : 72;
const normalizedTitleLines = wrapText(rawTitle, contentWidth, titleFontSize, 2);
const descriptionLines = wrapText(rawDescription, contentWidth, 30, 2);
const titleTspans = normalizedTitleLines
.map(
(line, index) =>
`<tspan x="${contentX}" dy="${index === 0 ? 0 : 70}">${escapeXml(line)}</tspan>`,
)
.join("");
const descriptionTspans = descriptionLines
.map(
(line, index) =>
`<tspan x="${contentX}" dy="${index === 0 ? 0 : 40}">${escapeXml(line)}</tspan>`,
)
.join("");
const descriptionY = normalizedTitleLines.length > 1 ? 354 : 340;
const statsY = descriptionY + descriptionLines.length * 40 + 34;
const avatarFrame =
avatarShape === "circle"
? `<circle cx="211" cy="305" r="139" fill="#FFFFFF" fill-opacity="0.055" stroke="#FFFFFF" stroke-opacity="0.16"/>
<image href="${avatar}" x="49" y="143" width="324" height="324" clip-path="url(#publisherAvatarCircleClip)" preserveAspectRatio="xMidYMid slice"/>
<circle cx="211" cy="305" r="139" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="1.5"/>`
: `<rect x="71" y="165" width="280" height="280" rx="58" fill="#FFFFFF" fill-opacity="0.055" stroke="#FFFFFF" stroke-opacity="0.16"/>
<image href="${avatar}" x="49" y="143" width="324" height="324" clip-path="url(#publisherAvatarRoundedClip)" preserveAspectRatio="xMidYMid slice"/>
<rect x="71.75" y="165.75" width="278.5" height="278.5" rx="57.25" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="1.5"/>`;
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="bgBase" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
<stop stop-color="#12090A"/>
<stop offset="0.46" stop-color="#08090A"/>
<stop offset="1" stop-color="#07100E"/>
</linearGradient>
<radialGradient id="bgAccent" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1064 78) rotate(152) scale(520 260)">
<stop stop-color="${OPENCLAW_RED}" stop-opacity="0.17"/>
<stop offset="1" stop-color="${OPENCLAW_RED}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="bgDepth" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(178 590) rotate(-18) scale(600 250)">
<stop stop-color="#0D7A67" stop-opacity="0.13"/>
<stop offset="1" stop-color="#0D7A67" stop-opacity="0"/>
</radialGradient>
<radialGradient id="bgCorner" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(96 84) rotate(24) scale(440 240)">
<stop stop-color="#7F1D2D" stop-opacity="0.2"/>
<stop offset="1" stop-color="#6C1B2B" stop-opacity="0"/>
</radialGradient>
<clipPath id="publisherAvatarCircleClip">
<circle cx="211" cy="305" r="139"/>
</clipPath>
<clipPath id="publisherAvatarRoundedClip">
<rect x="71" y="165" width="280" height="280" rx="58"/>
</clipPath>
</defs>
<rect width="1200" height="630" fill="url(#bgBase)"/>
<rect width="1200" height="630" fill="url(#bgAccent)"/>
<rect width="1200" height="630" fill="url(#bgDepth)"/>
<rect width="1200" height="630" fill="url(#bgCorner)"/>
<g>
<image href="${watermark}" x="905" y="365" width="430" height="430" opacity="0.035" preserveAspectRatio="xMidYMid meet"/>
<g>${avatarFrame}</g>
<g>
<image href="${params.markDataUrl}" x="958" y="34" width="44" height="44" opacity="0.92" preserveAspectRatio="xMidYMid meet"/>
<text x="1016" y="66"
fill="#F7F1EA"
font-size="28"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">ClawHub</text>
</g>
<text x="${contentX}" y="132"
fill="${OPENCLAW_RED}"
font-size="25"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${escapeXml(params.handleLabel)} / Publisher</text>
<text x="${contentX}" y="${normalizedTitleLines.length > 1 ? 216 : 248}"
fill="#F7F1EA"
font-size="${titleFontSize}"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${titleTspans}</text>
<text x="${contentX}" y="${descriptionY}"
fill="#B9B0AA"
font-size="30"
font-weight="500"
font-family="${FONT_SANS}, sans-serif">${descriptionTspans}</text>
${statBlock(params.stats, contentX, statsY)}
</g>
</svg>`;
}
+305
View File
@@ -0,0 +1,305 @@
import { FONT_MONO, FONT_SANS } from "./ogAssets";
export const OPENCLAW_RED = "#D4453A";
export type RegistryOgStat = {
value: string;
label: string;
};
export type RegistryOgCommand = {
subject: string;
action: string;
target: string;
};
export type RegistryOgSvgParams = {
markDataUrl: string;
watermarkDataUrl?: string | null;
avatarDataUrl?: string | null;
avatarShape?: "circle" | "rounded";
avatarFit?: "cover" | "contain";
surfaceLabel: string;
title: string;
description: string;
eyebrow?: string;
installCommand?: RegistryOgCommand | null;
stats?: RegistryOgStat[];
};
export function escapeXml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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}`;
}
export 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) 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 (let wordIndex = 0; wordIndex < words.length; wordIndex += 1) {
const word = words[wordIndex];
if (estimateTextWidth(word, fontSize) > maxWidth) {
if (current) {
if (lines.length >= maxLines - 1) {
pushLine(
truncateToWidth(`${current} ${words.slice(wordIndex).join(" ")}`, maxWidth, fontSize),
);
current = "";
break;
}
pushLine(current);
current = "";
if (lines.length >= maxLines - 1) {
current = truncateToWidth(words.slice(wordIndex).join(" "), maxWidth, fontSize);
break;
}
}
for (const part of splitLongWord(word)) {
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);
if (lines.length >= maxLines - 1) {
current = truncateToWidth(words.slice(wordIndex).join(" "), maxWidth, fontSize);
break;
}
current = word;
}
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.length > 0) {
lines[lines.length - 1] = truncateToWidth(lines.at(-1) ?? "", maxWidth, fontSize);
}
return lines;
}
function statColumns(stats: RegistryOgStat[], contentX: number) {
return stats
.slice(0, 2)
.map((stat, index) => {
const x = contentX + index * 190;
return `<g>
<text x="${x}" y="424"
fill="#9D9692"
font-size="21"
font-weight="700"
font-family="${FONT_SANS}, sans-serif">${escapeXml(stat.label)}</text>
<text x="${x}" y="464"
fill="#F7F1EA"
font-size="34"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${escapeXml(stat.value)}</text>
</g>`;
})
.join("");
}
function installCommand(command: RegistryOgCommand | null | undefined, contentX: number) {
if (!command) return "";
const maxWidth = 780;
const rightPadding = 34;
const fontSize = 22;
const textWidthFactor = 1.16;
const subject = `${command.subject} `;
const action = `${command.action} `;
const prefixWidth =
estimateTextWidth("openclaw ", fontSize) +
estimateTextWidth(subject, fontSize) +
estimateTextWidth(action, fontSize);
const targetMaxWidth = Math.max(
120,
(maxWidth - rightPadding - prefixWidth * textWidthFactor) / textWidthFactor,
);
const target = truncateToWidth(command.target, targetMaxWidth, fontSize);
return `<g>
<text x="${contentX}" y="559"
font-size="${fontSize}"
font-weight="500"
font-family="${FONT_MONO}, monospace">
<tspan fill="#AFA8A2">openclaw </tspan>
<tspan fill="${OPENCLAW_RED}">${escapeXml(subject)}</tspan>
<tspan fill="#AFA8A2">${escapeXml(action)}</tspan>
<tspan fill="#F7F1EA" font-weight="700">${escapeXml(target)}</tspan>
</text>
</g>`;
}
export function buildRegistryOgSvg(params: RegistryOgSvgParams) {
const contentX = 72;
const rawTitle = params.title.trim() || "ClawHub";
const rawDescription = params.description.trim() || "Published on ClawHub.";
const avatar = params.avatarDataUrl || params.markDataUrl;
const watermark = params.watermarkDataUrl || params.markDataUrl;
const avatarShape = params.avatarShape ?? "rounded";
const avatarFit = params.avatarFit ?? "cover";
const avatarImage =
avatarFit === "contain" ? { x: 935, y: 56, size: 166 } : { x: 910, y: 31, size: 216 };
const avatarFrame =
avatarShape === "circle"
? `<circle cx="1018" cy="139" r="83" fill="#FFFFFF" fill-opacity="0.06" stroke="#FFFFFF" stroke-opacity="0.16"/>
<image href="${avatar}" x="${avatarImage.x}" y="${avatarImage.y}" width="${avatarImage.size}" height="${avatarImage.size}" clip-path="url(#avatarCircleClip)" preserveAspectRatio="xMidYMid slice"/>
<circle cx="1018" cy="139" r="83" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="1.5"/>`
: `<rect x="935" y="56" width="166" height="166" rx="38" fill="#FFFFFF" fill-opacity="0.06" stroke="#FFFFFF" stroke-opacity="0.16"/>
<image href="${avatar}" x="${avatarImage.x}" y="${avatarImage.y}" width="${avatarImage.size}" height="${avatarImage.size}" clip-path="url(#avatarRoundedClip)" preserveAspectRatio="xMidYMid slice"/>
<rect x="935.75" y="56.75" width="164.5" height="164.5" rx="37.25" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="1.5"/>`;
const titleMaxWidth = 810;
const titleProbe = wrapText(rawTitle, titleMaxWidth, 68, 2);
const titleFontSize = titleProbe.length > 1 ? 60 : 68;
const titleLines = wrapText(rawTitle, titleMaxWidth, titleFontSize, 2);
const descLines = wrapText(rawDescription, 760, 28, 2);
const titleLineHeight = 66;
const titleY = titleLines.length > 1 ? 174 : 184;
const descY = titleLines.length > 1 ? 324 : 290;
const eyebrow = [params.eyebrow, params.surfaceLabel].filter(Boolean).join(" / ");
const stats =
params.stats && params.stats.length > 0
? params.stats
: [{ value: "ClawHub", label: "Registry" }];
const titleTspans = titleLines
.map(
(line, index) =>
`<tspan x="${contentX}" dy="${index === 0 ? 0 : titleLineHeight}">${escapeXml(line)}</tspan>`,
)
.join("");
const descTspans = descLines
.map(
(line, index) =>
`<tspan x="${contentX}" dy="${index === 0 ? 0 : 38}">${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="bgBase" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
<stop stop-color="#12090A"/>
<stop offset="0.46" stop-color="#08090A"/>
<stop offset="1" stop-color="#07100E"/>
</linearGradient>
<radialGradient id="bgAccent" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1064 78) rotate(152) scale(520 260)">
<stop stop-color="${OPENCLAW_RED}" stop-opacity="0.17"/>
<stop offset="1" stop-color="${OPENCLAW_RED}" stop-opacity="0"/>
</radialGradient>
<radialGradient id="bgDepth" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(178 590) rotate(-18) scale(600 250)">
<stop stop-color="#0D7A67" stop-opacity="0.13"/>
<stop offset="1" stop-color="#0D7A67" stop-opacity="0"/>
</radialGradient>
<radialGradient id="bgCorner" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(96 84) rotate(24) scale(440 240)">
<stop stop-color="#7F1D2D" stop-opacity="0.2"/>
<stop offset="1" stop-color="#6C1B2B" stop-opacity="0"/>
</radialGradient>
<clipPath id="avatarRoundedClip">
<rect x="935" y="56" width="166" height="166" rx="38"/>
</clipPath>
<clipPath id="avatarCircleClip">
<circle cx="1018" cy="139" r="83"/>
</clipPath>
</defs>
<rect width="1200" height="630" fill="url(#bgBase)"/>
<rect width="1200" height="630" fill="url(#bgAccent)"/>
<rect width="1200" height="630" fill="url(#bgDepth)"/>
<rect width="1200" height="630" fill="url(#bgCorner)"/>
<g>
<image href="${watermark}" x="940" y="402" width="360" height="360" opacity="0.05" preserveAspectRatio="xMidYMid meet"/>
<g>${avatarFrame}</g>
<image href="${params.markDataUrl}" x="${contentX}" y="64" width="46" height="46" opacity="0.92" preserveAspectRatio="xMidYMid meet"/>
<text x="${contentX + 62}" y="96"
fill="${OPENCLAW_RED}"
font-size="25"
font-weight="800"
font-family="${FONT_MONO}, monospace">${escapeXml(eyebrow)}</text>
<text x="${contentX}" y="${titleY}"
fill="#F7F1EA"
font-size="${titleFontSize}"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${titleTspans}</text>
<text x="${contentX}" y="${descY}"
fill="#B9B0AA"
font-size="28"
font-weight="500"
font-family="${FONT_SANS}, sans-serif">${descTspans}</text>
<g>${statColumns(stats, contentX)}</g>
${installCommand(params.installCommand, contentX)}
</g>
</svg>`;
}
+21 -11
View File
@@ -9,27 +9,38 @@ describe("skill OG SVG", () => {
description: "Quick diagnosis and repair for Discord bot.",
ownerLabel: "@jhillock",
versionLabel: "v1.2.3",
footer: "clawhub.ai/jhillock/discord-doctor",
installCommand: {
subject: "skills",
action: "install",
target: "discord-doctor",
},
stats: [
{ value: "1.2k", label: "Downloads" },
{ value: "PASS", label: "Audit" },
],
});
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("clawhub.ai/jhillock/discord-doctor");
expect(svg).toContain("PASS");
expect(svg).toContain("Audit");
expect(svg).toContain("openclaw");
expect(svg).toContain("skills");
expect(svg).toContain("install");
expect(svg).toContain("discord-doctor");
});
it("wraps long titles to avoid clipping", () => {
const svg = buildSkillOgSvg({
markDataUrl: "data:image/png;base64,AAA=",
title: "Excalidraw Flowchart",
title: "Excalidraw Flowchart Generator",
description: "Create Excalidraw flowcharts from descriptions.",
ownerLabel: "@swiftlysisngh",
versionLabel: "v1.0.2",
footer: "clawhub.ai/swiftlysisngh/excalidraw-flowchart",
});
const titleBlock = svg.match(/<text[^>]*font-weight="800"[\s\S]*?<\/text>/)?.[0] ?? "";
const titleBlock = svg.match(/<text x="72" y="(?:174|184)"[\s\S]*?<\/text>/)?.[0] ?? "";
const titleTspans = titleBlock.match(/<tspan /g) ?? [];
expect(titleTspans.length).toBe(2);
expect(svg).toContain("Excalidraw");
@@ -44,16 +55,15 @@ describe("skill OG SVG", () => {
description: `Prefix ${longWord} suffix`,
ownerLabel: "@pasogott",
versionLabel: "v0.1.0",
footer: "clawhub.ai/pasogott/gurkerlcli",
});
expect(svg).toContain('<clipPath id="cardClip">');
expect(svg).toContain('clip-path="url(#cardClip)"');
expect(svg).toContain('<svg width="1200" height="630"');
expect(svg).toContain('fill="url(#bgBase)"');
expect(svg).not.toContain(longWord);
expect(svg).toContain("…");
const descBlock = svg.match(/<text[^>]*font-size="26"[\s\S]*?<\/text>/)?.[0] ?? "";
const descBlock = svg.match(/<text[^>]*font-size="28"[\s\S]*?<\/text>/)?.[0] ?? "";
const descTspans = descBlock.match(/<tspan /g) ?? [];
expect(descTspans.length).toBeLessThanOrEqual(3);
expect(descTspans.length).toBeLessThanOrEqual(2);
});
});
+23 -247
View File
@@ -1,258 +1,34 @@
import { FONT_MONO, FONT_SANS } from "./ogAssets";
import { buildRegistryOgSvg, type RegistryOgCommand, type RegistryOgStat } from "./registryOgSvg";
export type SkillOgSvgParams = {
markDataUrl: string;
watermarkDataUrl?: string | null;
avatarDataUrl?: string | null;
title: string;
description: string;
ownerLabel: string;
versionLabel: string;
footer: string;
installCommand?: RegistryOgCommand | null;
stats?: RegistryOgStat[];
};
function escapeXml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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() || "ClawHub Skill";
const rawDescription = params.description.trim() || "Published on ClawHub.";
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>`;
return buildRegistryOgSvg({
markDataUrl: params.markDataUrl,
watermarkDataUrl: params.watermarkDataUrl,
avatarDataUrl: params.avatarDataUrl,
avatarShape: "circle",
surfaceLabel: "Skill",
eyebrow: params.ownerLabel,
title: params.title,
description: params.description,
installCommand: params.installCommand,
stats:
params.stats && params.stats.length > 0
? params.stats
: [
{ value: params.ownerLabel, label: "Publisher" },
{ value: params.versionLabel, label: "Version" },
],
});
}
+184
View File
@@ -0,0 +1,184 @@
/* @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getQueryMock = vi.fn();
const getRequestHostMock = vi.fn();
const setHeaderMock = vi.fn();
const fetchPluginOgMetaMock = vi.fn();
const getMarkDataUrlMock = vi.fn();
const getWatermarkDataUrlMock = vi.fn();
const ensureResvgWasmMock = vi.fn();
const getFontBuffersMock = vi.fn();
const buildPluginOgSvgMock = vi.fn();
const renderAsPngMock = vi.fn();
const freeMock = vi.fn();
const resvgCtorMock = vi.fn();
class ResvgMockClass {
constructor(...args: unknown[]) {
resvgCtorMock(...args);
}
render() {
return { asPng: renderAsPngMock };
}
free() {
return freeMock();
}
}
vi.mock("h3", () => ({
defineEventHandler: (handler: unknown) => handler,
getQuery: (...args: unknown[]) => getQueryMock(...args),
getRequestHost: (...args: unknown[]) => getRequestHostMock(...args),
setHeader: (...args: unknown[]) => setHeaderMock(...args),
}));
vi.mock("../../og/fetchPluginOgMeta", () => ({
fetchPluginOgMeta: (...args: unknown[]) => fetchPluginOgMetaMock(...args),
}));
vi.mock("../../og/ogAssets", () => ({
FONT_MONO: "IBM Plex Mono",
FONT_SANS: "Bricolage Grotesque",
getMarkDataUrl: (...args: unknown[]) => getMarkDataUrlMock(...args),
getWatermarkDataUrl: (...args: unknown[]) => getWatermarkDataUrlMock(...args),
ensureResvgWasm: (...args: unknown[]) => ensureResvgWasmMock(...args),
getFontBuffers: (...args: unknown[]) => getFontBuffersMock(...args),
}));
vi.mock("../../og/fetchImageDataUrl", () => ({
fetchImageDataUrl: vi.fn(async () => null),
}));
vi.mock("../../og/pluginOgSvg", () => ({
buildPluginOgSvg: (...args: unknown[]) => buildPluginOgSvgMock(...args),
}));
vi.mock("@resvg/resvg-wasm", () => ({
Resvg: ResvgMockClass,
}));
beforeEach(() => {
getQueryMock.mockReset();
getRequestHostMock.mockReset();
setHeaderMock.mockReset();
fetchPluginOgMetaMock.mockReset();
getMarkDataUrlMock.mockReset();
getWatermarkDataUrlMock.mockReset();
ensureResvgWasmMock.mockReset();
getFontBuffersMock.mockReset();
buildPluginOgSvgMock.mockReset();
renderAsPngMock.mockReset();
freeMock.mockReset();
resvgCtorMock.mockReset();
getMarkDataUrlMock.mockResolvedValue("data:image/png;base64,AAA=");
getWatermarkDataUrlMock.mockResolvedValue("data:image/png;base64,WWW=");
ensureResvgWasmMock.mockResolvedValue(undefined);
getFontBuffersMock.mockResolvedValue([new Uint8Array([1, 2, 3])]);
buildPluginOgSvgMock.mockReturnValue("<svg>plugin</svg>");
renderAsPngMock.mockReturnValue(new Uint8Array([7, 8, 9]));
});
afterEach(() => {
delete process.env.VITE_CONVEX_SITE_URL;
delete process.env.SITE_URL;
delete process.env.VITE_SITE_URL;
});
describe("plugin og route", () => {
it("returns plain text when name is missing", async () => {
getQueryMock.mockReturnValue({});
const handler = (await import("./plugin.png")).default;
await expect(handler({} as never)).resolves.toBe("Missing `name` query param.");
expect(setHeaderMock).toHaveBeenCalledWith({}, "Content-Type", "text/plain; charset=utf-8");
expect(fetchPluginOgMetaMock).not.toHaveBeenCalled();
expect(resvgCtorMock).not.toHaveBeenCalled();
});
it("does not render pending plugin scans as passing", async () => {
getQueryMock.mockReturnValue({ name: "@openclaw/codex" });
getRequestHostMock.mockReturnValue("preview.clawhub.ai");
fetchPluginOgMetaMock.mockResolvedValue({
name: "@openclaw/codex",
owner: "openclaw",
ownerImage: null,
latestVersion: null,
displayName: "Codex",
summary: "OpenClaw Codex harness.",
stats: { downloads: 1200 },
verification: { scanStatus: "pending" },
});
const handler = (await import("./plugin.png")).default;
const response = (await handler({} as never)) as Response;
expect(fetchPluginOgMetaMock).toHaveBeenCalledWith(
"@openclaw/codex",
"https://preview.clawhub.ai",
);
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(buildPluginOgSvgMock).toHaveBeenCalledWith(
expect.objectContaining({
stats: [
{ value: "1.2k", label: "Downloads" },
{ value: "PENDING", label: "Audit" },
],
}),
);
});
it("only renders PASS for explicit clean plugin scans", async () => {
getQueryMock.mockReturnValue({ name: "@openclaw/codex" });
fetchPluginOgMetaMock.mockResolvedValue({
name: "@openclaw/codex",
owner: "openclaw",
ownerImage: null,
latestVersion: "1.0.0",
displayName: "Codex",
summary: "OpenClaw Codex harness.",
stats: { downloads: 1200 },
verification: { scanStatus: "clean" },
});
const handler = (await import("./plugin.png")).default;
const response = (await handler({} as never)) as Response;
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(buildPluginOgSvgMock).toHaveBeenCalledWith(
expect.objectContaining({
stats: [
{ value: "1.2k", label: "Downloads" },
{ value: "PASS", label: "Audit" },
],
}),
);
});
it("renders unknown audit state when metadata is not fetched", async () => {
getQueryMock.mockReturnValue({
name: "@openclaw/codex",
owner: "openclaw",
title: "Codex",
description: "OpenClaw Codex harness.",
});
const handler = (await import("./plugin.png")).default;
await handler({} as never);
expect(fetchPluginOgMetaMock).not.toHaveBeenCalled();
expect(buildPluginOgSvgMock).toHaveBeenCalledWith(
expect.objectContaining({
stats: [
{ value: "0", label: "Downloads" },
{ value: "UNKNOWN", label: "Audit" },
],
}),
);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { Resvg } from "@resvg/resvg-wasm";
import { defineEventHandler, getQuery, getRequestHost, setHeader } from "h3";
import { fetchImageDataUrl } from "../../og/fetchImageDataUrl";
import { fetchPluginOgMeta } from "../../og/fetchPluginOgMeta";
import { formatOgStat } from "../../og/formatOgStats";
import {
ensureResvgWasm,
FONT_MONO,
FONT_SANS,
getFontBuffers,
getMarkDataUrl,
getWatermarkDataUrl,
} from "../../og/ogAssets";
import { buildPluginOgSvg } from "../../og/pluginOgSvg";
import { pngResponse } from "../../og/pngResponse";
type OgQuery = {
name?: string;
owner?: string;
title?: string;
description?: string;
downloads?: string;
audit?: string;
avatar?: string;
v?: string;
};
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://clawhub.ai";
}
function getAuditLabel(status: string | null | undefined) {
const normalized = status?.trim().toLowerCase();
if (normalized === "malicious") return "Audit BLOCK";
if (normalized === "suspicious") return "Audit REVIEW";
if (normalized === "clean" || normalized === "benign" || normalized === "pass") {
return "Audit PASS";
}
if (normalized === "pending" || normalized === "not-run") return "Audit PENDING";
return "Audit UNKNOWN";
}
export default defineEventHandler(async (event) => {
const query = getQuery(event) as OgQuery;
const name = cleanString(query.name);
if (!name) {
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
return "Missing `name` query param.";
}
const ownerFromQuery = cleanString(query.owner);
const titleFromQuery = cleanString(query.title);
const descriptionFromQuery = cleanString(query.description);
const downloadsFromQuery = cleanString(query.downloads);
const auditFromQuery = cleanString(query.audit);
const avatarFromQuery = cleanString(query.avatar);
const needFetch = !ownerFromQuery || !titleFromQuery || !descriptionFromQuery;
const meta = needFetch ? await fetchPluginOgMeta(name, getApiBase(getRequestHost(event))) : null;
const packageName = meta?.name || name;
const owner = ownerFromQuery || meta?.owner || "";
const ownerLabel = owner ? `@${owner}` : "clawhub";
const title = titleFromQuery || meta?.displayName || packageName;
const description =
descriptionFromQuery || meta?.summary || "OpenClaw plugin published on ClawHub.";
const cacheKey = meta?.latestVersion
? "public, max-age=31536000, immutable"
: "public, max-age=3600";
const [markDataUrl, watermarkDataUrl, fontBuffers] = await Promise.all([
getMarkDataUrl(),
getWatermarkDataUrl(),
ensureResvgWasm().then(() => getFontBuffers()),
]);
const avatarDataUrl = await fetchImageDataUrl(avatarFromQuery || meta?.ownerImage);
const svg = buildPluginOgSvg({
markDataUrl,
watermarkDataUrl,
avatarDataUrl,
title,
description,
packageName,
ownerLabel,
installCommand: {
subject: "plugins",
action: "install",
target: `clawhub:${packageName}`,
},
stats: [
{
value: downloadsFromQuery || formatOgStat(meta?.stats.downloads),
label: "Downloads",
},
{
value: (auditFromQuery || getAuditLabel(meta?.verification?.scanStatus)).replace(
/^Audit\s+/i,
"",
),
label: "Audit",
},
],
});
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: 1200 },
font: {
fontBuffers,
defaultFontFamily: FONT_SANS,
sansSerifFamily: FONT_SANS,
monospaceFamily: FONT_MONO,
},
});
const png = resvg.render().asPng();
resvg.free();
return pngResponse(png, cacheKey);
});
+1
View File
@@ -0,0 +1 @@
export { default } from "./plugin.png";
+91
View File
@@ -0,0 +1,91 @@
import { Resvg } from "@resvg/resvg-wasm";
import { defineEventHandler, getQuery, setHeader } from "h3";
import { fetchImageDataUrl } from "../../og/fetchImageDataUrl";
import { fetchPublisherOgMeta } from "../../og/fetchPublisherOgMeta";
import { formatOgStat } from "../../og/formatOgStats";
import {
ensureResvgWasm,
FONT_MONO,
FONT_SANS,
getFontBuffers,
getMarkDataUrl,
getWatermarkDataUrl,
} from "../../og/ogAssets";
import { pngResponse } from "../../og/pngResponse";
import { buildPublisherOgSvg } from "../../og/publisherOgSvg";
type OgQuery = {
handle?: string;
title?: string;
description?: string;
downloads?: string;
kind?: string;
avatar?: string;
v?: string;
};
function cleanString(value: unknown) {
if (typeof value !== "string") return "";
return value.trim();
}
function getConvexUrl() {
return process.env.VITE_CONVEX_URL?.trim() || process.env.CONVEX_URL?.trim() || null;
}
export default defineEventHandler(async (event) => {
const query = getQuery(event) as OgQuery;
const handle = cleanString(query.handle).replace(/^@+/, "");
if (!handle) {
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
return "Missing `handle` query param.";
}
const titleFromQuery = cleanString(query.title);
const descriptionFromQuery = cleanString(query.description);
const downloadsFromQuery = cleanString(query.downloads);
const kindFromQuery = cleanString(query.kind);
const avatarFromQuery = cleanString(query.avatar);
const convexUrl = getConvexUrl();
const needFetch = !titleFromQuery || !descriptionFromQuery || !downloadsFromQuery;
const meta = needFetch && convexUrl ? await fetchPublisherOgMeta(handle, convexUrl) : null;
const handleLabel = `@${meta?.handle || handle}`;
const title = titleFromQuery || meta?.displayName || handleLabel;
const description = descriptionFromQuery || meta?.bio || "Publisher on ClawHub.";
const [markDataUrl, watermarkDataUrl, fontBuffers] = await Promise.all([
getMarkDataUrl(),
getWatermarkDataUrl(),
ensureResvgWasm().then(() => getFontBuffers()),
]);
const avatarDataUrl = await fetchImageDataUrl(avatarFromQuery || meta?.image);
const svg = buildPublisherOgSvg({
markDataUrl,
watermarkDataUrl,
avatarDataUrl,
avatarShape: kindFromQuery === "org" || meta?.kind === "org" ? "rounded" : "circle",
title,
description,
handleLabel,
stats: [
{
value: downloadsFromQuery || formatOgStat(meta?.stats.downloads),
label: "Downloads",
},
],
});
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: 1200 },
font: {
fontBuffers,
defaultFontFamily: FONT_SANS,
sansSerifFamily: FONT_SANS,
monospaceFamily: FONT_MONO,
},
});
const png = resvg.render().asPng();
resvg.free();
return pngResponse(png, "public, max-age=3600");
});
+1
View File
@@ -0,0 +1 @@
export { default } from "./profile.png";
+32 -10
View File
@@ -7,6 +7,7 @@ const getRequestHostMock = vi.fn();
const setHeaderMock = vi.fn();
const fetchSkillOgMetaMock = vi.fn();
const getMarkDataUrlMock = vi.fn();
const getWatermarkDataUrlMock = vi.fn();
const ensureResvgWasmMock = vi.fn();
const getFontBuffersMock = vi.fn();
const buildSkillOgSvgMock = vi.fn();
@@ -43,10 +44,15 @@ vi.mock("../../og/ogAssets", () => ({
FONT_MONO: "IBM Plex Mono",
FONT_SANS: "Bricolage Grotesque",
getMarkDataUrl: (...args: unknown[]) => getMarkDataUrlMock(...args),
getWatermarkDataUrl: (...args: unknown[]) => getWatermarkDataUrlMock(...args),
ensureResvgWasm: (...args: unknown[]) => ensureResvgWasmMock(...args),
getFontBuffers: (...args: unknown[]) => getFontBuffersMock(...args),
}));
vi.mock("../../og/fetchImageDataUrl", () => ({
fetchImageDataUrl: vi.fn(async () => null),
}));
vi.mock("../../og/skillOgSvg", () => ({
buildSkillOgSvg: (...args: unknown[]) => buildSkillOgSvgMock(...args),
}));
@@ -61,6 +67,7 @@ beforeEach(() => {
setHeaderMock.mockReset();
fetchSkillOgMetaMock.mockReset();
getMarkDataUrlMock.mockReset();
getWatermarkDataUrlMock.mockReset();
ensureResvgWasmMock.mockReset();
getFontBuffersMock.mockReset();
buildSkillOgSvgMock.mockReset();
@@ -69,6 +76,7 @@ beforeEach(() => {
resvgCtorMock.mockReset();
getMarkDataUrlMock.mockResolvedValue("data:image/png;base64,AAA=");
getWatermarkDataUrlMock.mockResolvedValue("data:image/png;base64,WWW=");
ensureResvgWasmMock.mockResolvedValue(undefined);
getFontBuffersMock.mockResolvedValue([new Uint8Array([1, 2, 3])]);
buildSkillOgSvgMock.mockReturnValue("<svg>skill</svg>");
@@ -103,22 +111,29 @@ describe("skill og route", () => {
});
const handler = (await import("./skill.png")).default;
await expect(handler({} as never)).resolves.toEqual(new Uint8Array([7, 8, 9]));
const response = (await handler({} as never)) as Response;
await expect(response.arrayBuffer()).resolves.toEqual(new Uint8Array([7, 8, 9]).buffer);
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(fetchSkillOgMetaMock).not.toHaveBeenCalled();
expect(setHeaderMock).toHaveBeenCalledWith(
{},
"Cache-Control",
"public, max-age=31536000, immutable",
);
expect(setHeaderMock).toHaveBeenCalledWith({}, "Content-Type", "image/png");
expect(buildSkillOgSvgMock).toHaveBeenCalledWith({
markDataUrl: "data:image/png;base64,AAA=",
watermarkDataUrl: "data:image/png;base64,WWW=",
avatarDataUrl: null,
title: "Gifgrep",
description: "Search GIFs fast",
ownerLabel: "@steipete",
versionLabel: "v1.0.1",
footer: "clawhub.ai/steipete/gifgrep",
installCommand: {
subject: "skills",
action: "install",
target: "gifgrep",
},
stats: [
{ value: "0", label: "Downloads" },
{ value: "PASS", label: "Audit" },
],
});
expect(resvgCtorMock).toHaveBeenCalledWith("<svg>skill</svg>", {
fitTo: { mode: "width", value: 1200 },
@@ -140,19 +155,26 @@ describe("skill og route", () => {
version: null,
displayName: "Gifgrep",
summary: "Search GIFs fast",
ownerImage: null,
stats: { downloads: 1200 },
moderation: { verdict: "clean", isSuspicious: false, isMalwareBlocked: false },
});
const handler = (await import("./skill.png")).default;
await handler({} as never);
const response = (await handler({} as never)) as Response;
expect(fetchSkillOgMetaMock).toHaveBeenCalledWith("gifgrep", "https://preview.clawhub.ai");
expect(setHeaderMock).toHaveBeenCalledWith({}, "Cache-Control", "public, max-age=3600");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=3600");
expect(buildSkillOgSvgMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Gifgrep",
description: "Search GIFs fast",
ownerLabel: "@steipete",
versionLabel: "latest",
stats: [
{ value: "1.2k", label: "Downloads" },
{ value: "PASS", label: "Audit" },
],
}),
);
});
+35 -7
View File
@@ -1,13 +1,17 @@
import { Resvg } from "@resvg/resvg-wasm";
import { defineEventHandler, getQuery, getRequestHost, setHeader } from "h3";
import { fetchImageDataUrl } from "../../og/fetchImageDataUrl";
import { fetchSkillOgMeta } from "../../og/fetchSkillOgMeta";
import { formatOgStat } from "../../og/formatOgStats";
import {
ensureResvgWasm,
FONT_MONO,
FONT_SANS,
getFontBuffers,
getMarkDataUrl,
getWatermarkDataUrl,
} from "../../og/ogAssets";
import { pngResponse } from "../../og/pngResponse";
import { buildSkillOgSvg } from "../../og/skillOgSvg";
type OgQuery = {
@@ -16,6 +20,9 @@ type OgQuery = {
version?: string;
title?: string;
description?: string;
downloads?: string;
audit?: string;
avatar?: string;
v?: string;
};
@@ -47,6 +54,9 @@ export default defineEventHandler(async (event) => {
const versionFromQuery = cleanString(query.version);
const titleFromQuery = cleanString(query.title);
const descriptionFromQuery = cleanString(query.description);
const downloadsFromQuery = cleanString(query.downloads);
const auditFromQuery = cleanString(query.audit);
const avatarFromQuery = cleanString(query.avatar);
const needFetch =
!titleFromQuery || !descriptionFromQuery || !ownerFromQuery || !versionFromQuery;
@@ -59,24 +69,42 @@ export default defineEventHandler(async (event) => {
const ownerLabel = owner ? `@${owner}` : "clawhub";
const versionLabel = version ? `v${version}` : "latest";
const footer = owner ? `clawhub.ai/${owner}/${slug}` : `clawhub.ai/skills/${slug}`;
const auditLabel =
auditFromQuery ||
(meta?.moderation?.isMalwareBlocked || meta?.moderation?.verdict === "malicious"
? "Audit BLOCK"
: meta?.moderation?.isSuspicious || meta?.moderation?.verdict === "suspicious"
? "Audit REVIEW"
: "Audit PASS");
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, fontBuffers] = await Promise.all([
const [markDataUrl, watermarkDataUrl, fontBuffers] = await Promise.all([
getMarkDataUrl(),
getWatermarkDataUrl(),
ensureResvgWasm().then(() => getFontBuffers()),
]);
const avatarDataUrl = await fetchImageDataUrl(avatarFromQuery || meta?.ownerImage);
const svg = buildSkillOgSvg({
markDataUrl,
watermarkDataUrl,
avatarDataUrl,
title,
description,
ownerLabel,
versionLabel,
footer,
installCommand: {
subject: "skills",
action: "install",
target: slug,
},
stats: [
{
value: downloadsFromQuery || formatOgStat(meta?.stats.downloads),
label: "Downloads",
},
{ value: auditLabel.replace(/^Audit\s+/i, ""), label: "Audit" },
],
});
const resvg = new Resvg(svg, {
@@ -90,5 +118,5 @@ export default defineEventHandler(async (event) => {
});
const png = resvg.render().asPng();
resvg.free();
return png;
return pngResponse(png, cacheKey);
});
+1
View File
@@ -0,0 +1 @@
export { default } from "./skill.png";
+6 -9
View File
@@ -104,14 +104,15 @@ describe("soul og route", () => {
});
const handler = (await import("./soul.png")).default;
await expect(handler({} as never)).resolves.toEqual(new Uint8Array([4, 5, 6]));
const response = (await handler({} as never)) as Response;
await expect(response.arrayBuffer()).resolves.toEqual(new Uint8Array([4, 5, 6]).buffer);
expect(fetchSoulOgMetaMock).toHaveBeenCalledWith(
"lorekeeper",
"https://souls-preview.example.com",
);
expect(setHeaderMock).toHaveBeenCalledWith({}, "Cache-Control", "public, max-age=3600");
expect(setHeaderMock).toHaveBeenCalledWith({}, "Content-Type", "image/png");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=3600");
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(buildSoulOgSvgMock).toHaveBeenCalledWith({
markDataUrl: "data:image/png;base64,AAA=",
title: "Lorekeeper",
@@ -133,14 +134,10 @@ describe("soul og route", () => {
});
const handler = (await import("./soul.png")).default;
await handler({} as never);
const response = (await handler({} as never)) as Response;
expect(fetchSoulOgMetaMock).not.toHaveBeenCalled();
expect(setHeaderMock).toHaveBeenCalledWith(
{},
"Cache-Control",
"public, max-age=31536000, immutable",
);
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(buildSoulOgSvgMock).toHaveBeenCalledWith(
expect.objectContaining({
ownerLabel: "@steipete",
+2 -4
View File
@@ -9,6 +9,7 @@ import {
getFontBuffers,
getMarkDataUrl,
} from "../../og/ogAssets";
import { pngResponse } from "../../og/pngResponse";
import { buildSoulOgSvg } from "../../og/soulOgSvg";
type OgQuery = {
@@ -70,9 +71,6 @@ export default defineEventHandler(async (event) => {
const footer = buildFooter(slug, owner || null);
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, fontBuffers] = await Promise.all([
getMarkDataUrl(),
ensureResvgWasm().then(() => getFontBuffers()),
@@ -98,5 +96,5 @@ export default defineEventHandler(async (event) => {
});
const png = resvg.render().asPng();
resvg.free();
return png;
return pngResponse(png, cacheKey);
});
+1
View File
@@ -0,0 +1 @@
export { default } from "./soul.png";
+2 -2
View File
@@ -335,11 +335,11 @@ describe("skill route loader", () => {
{ property: "og:url", content: "https://clawhub.ai/steipete/weather" },
{
property: "og:image",
content: "https://clawhub.ai/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
content: "https://clawhub.ai/og/skill?v=7&slug=weather&owner=steipete&version=1.0.0",
},
{
name: "twitter:image",
content: "https://clawhub.ai/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
content: "https://clawhub.ai/og/skill?v=7&slug=weather&owner=steipete&version=1.0.0",
},
]),
);
+42 -4
View File
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildSkillMeta, buildSoulMeta, fetchSkillMeta, fetchSoulMeta } from "./og";
import {
buildPluginMeta,
buildPublisherMeta,
buildSkillMeta,
buildSoulMeta,
fetchSkillMeta,
fetchSoulMeta,
} from "./og";
describe("og helpers", () => {
afterEach(() => {
@@ -18,8 +25,8 @@ describe("og helpers", () => {
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("v=5");
expect(meta.image).toContain("/og/skill?");
expect(meta.image).toContain("v=7");
expect(meta.image).toContain("slug=weather");
expect(meta.image).toContain("owner=steipete");
expect(meta.image).toContain("version=1.2.3");
@@ -39,13 +46,44 @@ describe("og helpers", () => {
expect(meta.description).toBe("Personal north star notes.");
expect(meta.url).toContain("/souls/north-star");
expect(meta.owner).toBe("someone");
expect(meta.image).toContain("/og/soul.png?");
expect(meta.image).toContain("/og/soul?");
expect(meta.image).toContain("v=1");
expect(meta.image).toContain("slug=north-star");
expect(meta.image).toContain("owner=someone");
expect(meta.image).toContain("version=0.1.0");
});
it("builds plugin metadata", () => {
const meta = buildPluginMeta({
name: "@openclaw/codex",
owner: "openclaw",
displayName: "Codex",
summary: "OpenClaw Codex harness.",
latestVersion: "1.0.0",
});
expect(meta.title).toBe("Codex — ClawHub Plugins");
expect(meta.description).toBe("OpenClaw Codex harness.");
expect(meta.url).toBe("https://clawhub.ai/plugins/@openclaw/codex");
expect(meta.image).toContain("/og/plugin?");
expect(meta.image).toContain("v=2");
expect(meta.image).toContain("name=%40openclaw%2Fcodex");
expect(meta.image).toContain("version=1.0.0");
});
it("builds publisher metadata", () => {
const meta = buildPublisherMeta({
handle: "@byungkyu",
displayName: "byungkyu",
bio: "maton.ai",
});
expect(meta.title).toBe("byungkyu — ClawHub");
expect(meta.description).toBe("maton.ai");
expect(meta.url).toBe("https://clawhub.ai/user/byungkyu");
expect(meta.image).toContain("/og/profile?");
expect(meta.image).toContain("v=2");
expect(meta.image).toContain("handle=byungkyu");
});
it("uses defaults when owner and summary are missing", () => {
const meta = buildSkillMeta({ slug: "parser" });
expect(meta.title).toBe("parser — ClawHub");
+65 -3
View File
@@ -34,10 +34,33 @@ type SoulMeta = {
owner: string | null;
};
type PluginMetaSource = {
name: string;
displayName?: string | null;
summary?: string | null;
owner?: string | null;
latestVersion?: string | null;
};
type PublisherMetaSource = {
handle: string;
displayName?: string | null;
bio?: string | null;
};
type BasicMeta = {
title: string;
description: string;
image: string;
url: string;
};
const DEFAULT_DESCRIPTION = "ClawHub — a fast skill registry for agents, with vector search.";
const DEFAULT_SOUL_DESCRIPTION = "SoulHub — the home for SOUL.md bundles and personal system lore.";
const OG_SKILL_IMAGE_LAYOUT_VERSION = "5";
const OG_SKILL_IMAGE_LAYOUT_VERSION = "7";
const OG_SOUL_IMAGE_LAYOUT_VERSION = "1";
const OG_PLUGIN_IMAGE_LAYOUT_VERSION = "2";
const OG_PUBLISHER_IMAGE_LAYOUT_VERSION = "2";
function getSiteUrl() {
return getClawHubSiteUrl();
@@ -117,7 +140,7 @@ export function buildSkillMeta(source: SkillMetaSource): SkillMeta {
return {
title,
description: truncate(description, 200),
image: `${siteUrl}/og/skill.png?${imageParams.toString()}`,
image: `${siteUrl}/og/skill?${imageParams.toString()}`,
url,
owner: owner || null,
};
@@ -141,12 +164,51 @@ export function buildSoulMeta(source: SoulMetaSource): SoulMeta {
return {
title,
description: truncate(description, 200),
image: `${siteUrl}/og/soul.png?${imageParams.toString()}`,
image: `${siteUrl}/og/soul?${imageParams.toString()}`,
url,
owner: owner || null,
};
}
export function buildPluginMeta(source: PluginMetaSource): BasicMeta {
const siteUrl = getSiteUrl();
const displayName = clean(source.displayName) || clean(source.name);
const summary = clean(source.summary);
const owner = clean(source.owner);
const latestVersion = clean(source.latestVersion);
const title = `${displayName} — ClawHub Plugins`;
const description = summary || (owner ? `Plugin by @${owner} on ClawHub.` : DEFAULT_DESCRIPTION);
const url = `${siteUrl}/plugins/${source.name.startsWith("@") ? source.name : encodeURIComponent(source.name)}`;
const imageParams = new URLSearchParams();
imageParams.set("v", OG_PLUGIN_IMAGE_LAYOUT_VERSION);
imageParams.set("name", source.name);
if (latestVersion) imageParams.set("version", latestVersion);
return {
title,
description: truncate(description, 200),
image: `${siteUrl}/og/plugin?${imageParams.toString()}`,
url,
};
}
export function buildPublisherMeta(source: PublisherMetaSource): BasicMeta {
const siteUrl = getSiteUrl();
const handle = clean(source.handle).replace(/^@+/, "");
const displayName = clean(source.displayName) || `@${handle}`;
const bio = clean(source.bio);
const title = `${displayName} — ClawHub`;
const description = bio || `Publisher @${handle} on ClawHub.`;
const imageParams = new URLSearchParams();
imageParams.set("v", OG_PUBLISHER_IMAGE_LAYOUT_VERSION);
imageParams.set("handle", handle);
return {
title,
description: truncate(description, 200),
image: `${siteUrl}/og/profile?${imageParams.toString()}`,
url: `${siteUrl}/user/${handle}`,
};
}
function clean(value?: string | null) {
return value?.trim() ?? "";
}
+22 -9
View File
@@ -15,6 +15,7 @@ import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { formatRetryDelay } from "../../lib/formatRetryDelay";
import { buildPluginMeta } from "../../lib/og";
import { getOpenClawPackageCandidateNames } from "../../lib/openClawExtensionSlugs";
import {
fetchPackageDetail,
@@ -110,18 +111,30 @@ export async function loadPluginDetail(requestedName: string): Promise<PluginDet
}
export function pluginDetailHead(name: string, loaderData?: PluginDetailLoaderData) {
const meta = buildPluginMeta({
name: loaderData?.detail.package?.name ?? name,
displayName: loaderData?.detail.package?.displayName,
summary: loaderData?.detail.package?.summary,
owner: loaderData?.detail.owner?.handle,
latestVersion: loaderData?.detail.package?.latestVersion,
});
return {
meta: [
{
title: loaderData?.detail.package?.displayName
? `${loaderData.detail.package.displayName} · Plugins`
: name,
},
{
name: "description",
content: loaderData?.detail.package?.summary ?? `Plugin ${name}`,
},
{ title: meta.title },
{ name: "description", content: meta.description },
{ property: "og:title", content: meta.title },
{ property: "og:description", content: meta.description },
{ property: "og:url", content: meta.url },
{ property: "og:image", content: meta.image },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: meta.title },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: meta.title },
{ name: "twitter:description", content: meta.description },
{ name: "twitter:image", content: meta.image },
],
links: [{ rel: "canonical", href: meta.url }],
};
}
+22
View File
@@ -22,6 +22,7 @@ import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import { Skeleton } from "../../components/ui/skeleton";
import { formatCompactStat } from "../../lib/numberFormat";
import { buildPublisherMeta } from "../../lib/og";
import type {
PublicPublisher,
PublicPublisherCatalogItem,
@@ -29,6 +30,27 @@ import type {
} from "../../lib/publicUser";
export const Route = createFileRoute("/user/$handle")({
head: ({ params }) => {
const meta = buildPublisherMeta({ handle: params.handle });
return {
meta: [
{ title: meta.title },
{ name: "description", content: meta.description },
{ property: "og:title", content: meta.title },
{ property: "og:description", content: meta.description },
{ property: "og:url", content: meta.url },
{ property: "og:image", content: meta.image },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: meta.title },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: meta.title },
{ name: "twitter:description", content: meta.description },
{ name: "twitter:image", content: meta.image },
],
links: [{ rel: "canonical", href: meta.url }],
};
},
component: PublisherProfile,
});