mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: refresh dynamic og images (#2180)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 571 KiB |
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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, "&")
|
||||
.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}`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
@@ -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
@@ -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, "&")
|
||||
.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() || "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" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./plugin.png";
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./profile.png";
|
||||
@@ -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" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./skill.png";
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./soul.png";
|
||||
@@ -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
@@ -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
@@ -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() ?? "";
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user