feat: update publisher social images

This commit is contained in:
Nancy
2026-06-25 21:30:07 -07:00
committed by Patrick Erichsen
parent 8df8d9f550
commit 2310b91ade
12 changed files with 654 additions and 60 deletions
+1
View File
@@ -53,6 +53,7 @@
"remark-gfm": "4.0.1",
"resend": "6.14.0",
"semver": "7.8.5",
"sharp": "0.34.5",
"shiki": "4.2.0",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
+1
View File
@@ -119,6 +119,7 @@
"remark-gfm": "4.0.1",
"resend": "6.14.0",
"semver": "7.8.5",
"sharp": "0.34.5",
"shiki": "4.2.0",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
+14
View File
@@ -36,6 +36,16 @@ describe("fetchPublisherOgMeta", () => {
displayName: "OpenClaw",
bio: "Build with claws.",
image: null,
official: true,
affiliations: [
{
publisher: {
handle: "github",
displayName: "GitHub",
image: "https://example.com/github.png",
},
},
],
stats: { downloads: 99, installs: 1200 },
});
@@ -47,5 +57,9 @@ describe("fetchPublisherOgMeta", () => {
handle: "openclaw",
});
expect(meta?.stats.downloads).toBe(99);
expect(meta?.official).toBe(true);
expect(meta?.affiliations).toEqual([
{ handle: "github", displayName: "GitHub", image: "https://example.com/github.png" },
]);
});
});
+32
View File
@@ -4,9 +4,15 @@ import { api } from "../../convex/_generated/api";
export type PublisherOgMeta = {
handle: string | null;
kind: "user" | "org";
official: boolean;
displayName: string | null;
bio: string | null;
image: string | null;
affiliations: Array<{
handle: string;
displayName: string;
image: string | null;
}>;
stats: {
downloads: number;
};
@@ -15,15 +21,25 @@ export type PublisherOgMeta = {
type PublisherProfileResult = {
handle?: string | null;
kind?: "user" | "org";
official?: boolean;
displayName?: string | null;
bio?: string | null;
image?: string | null;
affiliations?: Array<{
publisher?: {
handle?: string | null;
displayName?: string | null;
image?: string | null;
} | null;
}>;
stats?: {
downloads?: number;
installs?: number;
};
} | null;
type PublisherProfileAffiliations = NonNullable<PublisherProfileResult>["affiliations"];
export async function fetchPublisherOgMeta(
handle: string,
convexUrl: string,
@@ -37,9 +53,11 @@ export async function fetchPublisherOgMeta(
return {
handle: profile.handle ?? null,
kind: profile.kind === "org" ? "org" : "user",
official: profile.official === true,
displayName: profile.displayName ?? null,
bio: profile.bio ?? null,
image: profile.image ?? null,
affiliations: readAffiliations(profile.affiliations),
stats: {
downloads: readNumber(profile.stats?.downloads),
},
@@ -52,3 +70,17 @@ export async function fetchPublisherOgMeta(
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function readAffiliations(value: PublisherProfileAffiliations) {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
const handle = item?.publisher?.handle?.trim();
const displayName = item?.publisher?.displayName?.trim();
if (!handle || !displayName) return null;
return { handle, displayName, image: item.publisher?.image ?? null };
})
.filter((item): item is { handle: string; displayName: string; image: string | null } =>
Boolean(item),
);
}
+62
View File
@@ -0,0 +1,62 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { normalizeOgLogoDataUrl } from "./normalizeLogoDataUrl";
async function makeLogoDataUrl(padding: number) {
const size = 96;
const markSize = size - padding * 2;
const buffer = await sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{
input: Buffer.from(
`<svg width="${markSize}" height="${markSize}" viewBox="0 0 ${markSize} ${markSize}" xmlns="http://www.w3.org/2000/svg"><circle cx="${markSize / 2}" cy="${markSize / 2}" r="${markSize / 2}" fill="#D4453A"/></svg>`,
),
left: padding,
top: padding,
},
])
.png()
.toBuffer();
return `data:image/png;base64,${buffer.toString("base64")}`;
}
async function visibleAlphaBounds(dataUrl: string) {
const buffer = Buffer.from(dataUrl.split(",")[1] ?? "", "base64");
const image = sharp(buffer).ensureAlpha();
const metadata = await image.metadata();
const raw = await image.raw().toBuffer();
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = 0;
let maxY = 0;
for (let y = 0; y < (metadata.height ?? 0); y += 1) {
for (let x = 0; x < (metadata.width ?? 0); x += 1) {
const alpha = raw[(y * (metadata.width ?? 0) + x) * 4 + 3] ?? 0;
if (alpha === 0) continue;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
return { width: maxX - minX + 1, height: maxY - minY + 1 };
}
describe("normalizeOgLogoDataUrl", () => {
it("normalizes logos with different transparent padding to the same visible size", async () => {
const paddedLogo = await normalizeOgLogoDataUrl(await makeLogoDataUrl(24));
const tightLogo = await normalizeOgLogoDataUrl(await makeLogoDataUrl(4));
expect(paddedLogo).toMatch(/^data:image\/png;base64,/);
expect(tightLogo).toMatch(/^data:image\/png;base64,/);
await expect(visibleAlphaBounds(paddedLogo ?? "")).resolves.toEqual({ width: 48, height: 48 });
await expect(visibleAlphaBounds(tightLogo ?? "")).resolves.toEqual({ width: 48, height: 48 });
});
});
+34
View File
@@ -0,0 +1,34 @@
import sharp from "sharp";
const NORMALIZED_LOGO_SIZE = 48;
function readDataUrl(dataUrl: string) {
const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
if (!match) return null;
return {
mimeType: match[1],
buffer: Buffer.from(match[2], "base64"),
};
}
export async function normalizeOgLogoDataUrl(dataUrl: string | null | undefined) {
if (!dataUrl) return null;
const parsed = readDataUrl(dataUrl);
if (!parsed || !parsed.mimeType.startsWith("image/")) return dataUrl;
try {
const normalized = await sharp(parsed.buffer)
.ensureAlpha()
.trim({ threshold: 8 })
.resize(NORMALIZED_LOGO_SIZE, NORMALIZED_LOGO_SIZE, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
withoutEnlargement: false,
})
.png()
.toBuffer();
return `data:image/png;base64,${normalized.toString("base64")}`;
} catch {
return dataUrl;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { buildPublisherOgSvg } from "./publisherOgSvg";
const transparentPixel =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
function buildSvg(overrides: Partial<Parameters<typeof buildPublisherOgSvg>[0]> = {}) {
return buildPublisherOgSvg({
markDataUrl: transparentPixel,
watermarkDataUrl: transparentPixel,
avatarDataUrl: transparentPixel,
title: "Matt Van Horn",
description: "Publisher @mvanhorn on ClawHub.",
handleLabel: "@mvanhorn",
...overrides,
});
}
describe("buildPublisherOgSvg", () => {
it("renders the no-badge no-organization creator layout", () => {
const svg = buildSvg();
expect(svg).toContain("Matt Van Horn");
expect(svg).toContain("on ClawHub");
expect(svg).toContain("Creator");
expect(svg).toContain("@mvanhorn");
expect(svg).toContain("Downloads");
expect(svg).not.toContain("Publisher</text>");
expect(svg).not.toContain("Organization");
});
it("renders the verified badge when official", () => {
const svg = buildSvg({ official: true });
expect(svg).toContain("#60A5FA");
expect(svg).toContain('width="42" height="42"');
expect(svg).toContain('stroke-width="1.71"');
expect(svg).toContain("M3.85 8.62");
});
it("keeps the no-organization verified badge on the guide title line without shrinking text", () => {
const svg = buildSvg({ official: true });
expect(svg).toContain('font-size="72"');
expect(svg).toContain('<tspan x="542" dy="0">Matt Van Horn</tspan>');
expect(svg).toContain('<svg x="1040" y="198" width="42" height="42"');
});
it("keeps the organization verified badge on the guide title line", () => {
const svg = buildSvg({ official: true, organizationLogos: [transparentPixel] });
expect(svg).toContain('font-size="72"');
expect(svg).toContain('<tspan x="509" dy="0">Matt Van Horn</tspan>');
expect(svg).toContain('<svg x="1007" y="145" width="42" height="42"');
});
it("renders organization state when affiliations exist", () => {
const svg = buildSvg({
organizationLogos: [transparentPixel, transparentPixel, transparentPixel],
});
expect(svg).toContain("Organizations");
expect(svg).not.toContain("OpenClaw");
expect(svg).toContain("orgLogoClip0");
expect(svg).toContain("orgLogoClip2");
expect(svg).toContain('width="48" height="48" clip-path="url(#orgLogoClip0)"');
expect(svg).toContain('width="48" height="48" clip-path="url(#orgLogoClip2)"');
expect(svg).not.toContain('rx="8" fill="#F7F1EA"');
});
it("caps rendered organization logos at five", () => {
const svg = buildSvg({
organizationLogos: [
transparentPixel,
transparentPixel,
transparentPixel,
transparentPixel,
transparentPixel,
transparentPixel,
],
});
expect(svg).toContain("orgLogoClip4");
expect(svg).not.toContain("orgLogoClip5");
});
it("keeps long publisher names left aligned and within the content column", () => {
const svg = buildSvg({
official: true,
title: "Matt Van Horn lalalallalalalalalallallalalalalalalalala",
handleLabel: "@mvanhornfgfgfgfgfggfgfgfgfgfgsd",
organizationLogos: [
transparentPixel,
transparentPixel,
transparentPixel,
transparentPixel,
transparentPixel,
],
stats: [{ label: "Downloads", value: "41.9k" }],
});
expect(svg).not.toContain('text-anchor="middle"');
expect(svg).toContain('<tspan x="447" dy="0">');
expect(svg).toContain("Matt Van Horn");
expect(svg).not.toContain("lalalallalalalalalallallalalalalalalalala</tspan>");
expect(svg).toContain("#60A5FA");
expect(svg).toContain('font-size="46"');
expect(svg).toMatch(/@mvanhornfgfgfgfgfgg.*\.\.\./);
expect(svg).toContain("...");
expect(svg).not.toContain("…");
expect(svg).toContain('x="110" y="500" width="48" height="48"');
expect(svg).toContain('x="447" y="547"');
expect(svg).toContain(">41.9k</text>");
});
});
+312 -56
View File
@@ -1,69 +1,330 @@
import { FONT_SANS } from "./ogAssets";
import {
escapeXml,
OPENCLAW_RED,
type RegistryOgStat,
statLabelMarkup,
wrapText,
} from "./registryOgSvg";
import { escapeXml, OPENCLAW_RED, type RegistryOgStat } from "./registryOgSvg";
export type PublisherOgSvgParams = {
markDataUrl: string;
watermarkDataUrl?: string | null;
avatarDataUrl?: string | null;
avatarShape?: "circle" | "rounded";
official?: boolean;
title: string;
description: string;
handleLabel: string;
organizationLogos?: string[];
stats?: RegistryOgStat[];
};
function statBlock(stats: RegistryOgStat[] | undefined, x: number, y: number) {
const stat = stats?.[0] ?? { value: "ClawHub", label: "Publisher" };
const OFFICIAL_BLUE = "#60A5FA";
const OFFICIAL_BADGE_SIZE = 42;
const OFFICIAL_BADGE_STROKE = 1.71;
function estimateTextWidth(value: string, fontSize: number) {
return [...value].reduce((width, char) => {
if (char === " ") return width + fontSize * 0.28;
if (/[ilI.,:;|!'"`]/.test(char)) return width + fontSize * 0.28;
if (/[mwMW@%&]/.test(char)) return width + fontSize * 0.9;
if (/[A-Z]/.test(char)) return width + fontSize * 0.68;
if (/[0-9]/.test(char)) return width + fontSize * 0.6;
return width + fontSize * 0.56;
}, 0);
}
function estimateBadgeX(
value: string,
x: number,
fontSize: number,
maxX: number,
estimateScale: number,
) {
const estimatedWidth = estimateTextWidth(value, fontSize) * estimateScale;
return Math.min(maxX, Math.round(x + estimatedWidth + 19));
}
function officialBadge(x: number, y: number) {
return `<svg x="${x}" y="${y}" width="${OFFICIAL_BADGE_SIZE}" height="${OFFICIAL_BADGE_SIZE}" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" stroke="${OFFICIAL_BLUE}" stroke-width="${OFFICIAL_BADGE_STROKE}" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m9 12 2 2 4-4" stroke="${OFFICIAL_BLUE}" stroke-width="${OFFICIAL_BADGE_STROKE}" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
}
function wrapTextWithoutEllipsis(value: string, maxWidth: number, fontSize: number) {
const words = value.trim().split(/\s+/).filter(Boolean);
const lines: string[] = [];
let current = "";
function splitLongWord(word: string) {
if (estimateTextWidth(word, fontSize) <= maxWidth) return [word];
const parts: string[] = [];
let chunk = "";
for (const char of word) {
const next = chunk + char;
if (chunk && estimateTextWidth(next, fontSize) > maxWidth) {
parts.push(chunk);
chunk = char;
continue;
}
chunk = next;
}
if (chunk) parts.push(chunk);
return parts;
}
const tokens = words.flatMap((word, wordIndex) =>
splitLongWord(word).map((part, partIndex) => ({
text: part,
needsLeadingSpace: wordIndex > 0 && partIndex === 0,
})),
);
for (const token of tokens) {
const separator = current && token.needsLeadingSpace ? " " : "";
const next = current ? `${current}${separator}${token.text}` : token.text;
if (estimateTextWidth(next, fontSize) <= maxWidth) {
current = next;
continue;
}
if (current) lines.push(current);
current = token.text;
}
if (current) lines.push(current);
return lines.length > 0 ? lines : [value.trim()];
}
function fitMultilineText(
value: string,
maxWidth: number,
maxLines: number,
options: { maxFontSize: number; minFontSize: number },
) {
for (let fontSize = options.maxFontSize; fontSize >= options.minFontSize; fontSize -= 2) {
const lines = wrapTextWithoutEllipsis(value, maxWidth, fontSize);
if (lines.length <= maxLines) {
return { fontSize, lines };
}
}
const fontSize = options.minFontSize;
return { fontSize, lines: wrapTextWithoutEllipsis(value, maxWidth, fontSize) };
}
function fitMultilineTextWithDots(
value: string,
maxWidth: number,
maxLines: number,
fontSize: number,
) {
const wrappedLines = wrapTextWithoutEllipsis(value, maxWidth, fontSize);
if (wrappedLines.length <= maxLines) return { fontSize, lines: wrappedLines };
const visibleLines = wrappedLines.slice(0, maxLines - 1);
const hiddenText = wrappedLines.slice(maxLines - 1).join(" ");
return {
fontSize,
lines: [...visibleLines, truncateWithDots(hiddenText, maxWidth, fontSize)],
};
}
function fitSingleLineText(
value: string,
maxWidth: number,
maxFontSize: number,
minFontSize: number,
) {
for (let fontSize = maxFontSize; fontSize >= minFontSize; fontSize -= 1) {
if (estimateTextWidth(value, fontSize) <= maxWidth) return fontSize;
}
const estimatedAtMin = estimateTextWidth(value, minFontSize);
if (estimatedAtMin <= maxWidth) return minFontSize;
return Math.max(10, Math.floor((minFontSize * maxWidth) / Math.max(estimatedAtMin, 1)));
}
function truncateWithDots(value: string, maxWidth: number, fontSize: number) {
if (estimateTextWidth(value, fontSize) <= maxWidth) return value;
const dots = "...";
const dotsWidth = estimateTextWidth(dots, fontSize);
const chars = [...value];
while (chars.length > 0 && estimateTextWidth(chars.join(""), fontSize) + dotsWidth > maxWidth) {
chars.pop();
}
return `${chars.join("").trimEnd()}${dots}`;
}
function statColumn(
label: string,
value: string,
x: number,
y: number,
valueMaxWidth: number,
options?: { truncateWithDots?: boolean },
) {
const valueFontSize = options?.truncateWithDots
? 46
: fitSingleLineText(value, valueMaxWidth, 46, 18);
const displayValue =
options?.truncateWithDots && estimateTextWidth(value, valueFontSize) > valueMaxWidth
? truncateWithDots(value, valueMaxWidth, valueFontSize)
: value;
return `<g>
${statLabelMarkup(x, y, stat.label, { fontSize: 22 })}
<text x="${x}" y="${y + 44}"
<text x="${x}" y="${y}"
fill="#9D9692"
font-size="32"
font-weight="700"
font-family="${FONT_SANS}, sans-serif">${escapeXml(label)}</text>
<text x="${x}" y="${y + 63}"
fill="#F7F1EA"
font-size="44"
font-size="${valueFontSize}"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${escapeXml(stat.value)}</text>
font-family="${FONT_SANS}, sans-serif">${escapeXml(displayValue)}</text>
</g>`;
}
function orgLogoTiles(logos: string[], markDataUrl: string, x: number, yOffset: number) {
const visibleLogos = logos.slice(0, 5);
if (visibleLogos.length === 0) return "";
const y = 459 + yOffset;
const size = 48;
const gap = 10;
const tiles = visibleLogos
.map((logo, index) => {
const tileX = x + index * (size + gap);
const clipId = `orgLogoClip${index}`;
return `<g>
<clipPath id="${clipId}">
<rect x="${tileX}" y="${y}" width="${size}" height="${size}" rx="8"/>
</clipPath>
<image href="${logo || markDataUrl}" x="${tileX}" y="${y}" width="${size}" height="${size}" clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>
</g>`;
})
.join("");
return `<g>
<text x="${x}" y="${438 + yOffset}"
fill="${OPENCLAW_RED}"
font-size="32"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">Organizations</text>
${tiles}
</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
const organizationLogos = params.organizationLogos?.filter(Boolean) ?? [];
const hasOrganizations = organizationLogos.length > 0;
const normalLayout = hasOrganizations
? {
titleX: 509,
subtitleX: 509,
detailX: 509,
downloadsX: 861,
contentWidth: 610,
creatorWidth: 320,
downloadsWidth: 210,
}
: {
titleX: 542,
subtitleX: 542,
detailX: 542,
downloadsX: 913,
contentWidth: 565,
creatorWidth: 300,
downloadsWidth: 190,
};
const normalTitleMaxWidth = params.official
? hasOrganizations
? normalLayout.contentWidth - 78
: normalLayout.contentWidth
: normalLayout.contentWidth;
const titleNeedsOverflow = wrapTextWithoutEllipsis(rawTitle, normalTitleMaxWidth, 72).length > 1;
const creatorNeedsOverflow =
estimateTextWidth(params.handleLabel, 46) > normalLayout.creatorWidth;
const usesLongLayout = titleNeedsOverflow || creatorNeedsOverflow;
const contentYOffset = 0;
const organizationExtraGap = usesLongLayout ? 41 : 0;
const layout = usesLongLayout
? {
titleX: 447,
subtitleX: 447,
detailX: 447,
downloadsX: 447,
contentWidth: 650,
creatorWidth: 680,
downloadsWidth: 210,
}
: normalLayout;
const titleMaxWidth = params.official
? usesLongLayout
? layout.contentWidth
: hasOrganizations
? layout.contentWidth - 78
: layout.contentWidth
: layout.contentWidth;
const title = usesLongLayout
? fitMultilineTextWithDots(rawTitle, titleMaxWidth, 2, 66)
: fitMultilineText(rawTitle, titleMaxWidth, 2, { maxFontSize: 72, minFontSize: 30 });
const titleFontSize = title.fontSize;
const titleLines = title.lines;
const titleLineHeight = usesLongLayout ? 84 : Math.max(50, Math.round(titleFontSize * 0.98));
const titleTspans = titleLines
.map(
(line, index) =>
`<tspan x="${contentX}" dy="${index === 0 ? 0 : 70}">${escapeXml(line)}</tspan>`,
`<tspan x="${layout.titleX}" dy="${index === 0 ? 0 : titleLineHeight}">${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 titleY = usesLongLayout
? 151
: hasOrganizations
? titleLines.length > 1
? 138 + contentYOffset
: 190 + contentYOffset
: titleLines.length > 1
? 195 + contentYOffset
: 243 + contentYOffset;
const lastTitleLine = titleLines.at(-1) ?? rawTitle;
const badgeX =
params.official && !hasOrganizations && !usesLongLayout && titleLines.length === 1
? 1040
: params.official && hasOrganizations && !usesLongLayout && titleLines.length === 1
? 1007
: estimateBadgeX(
lastTitleLine,
layout.titleX,
titleFontSize,
usesLongLayout ? 1084 : layout.titleX + layout.contentWidth - OFFICIAL_BADGE_SIZE,
titleLines.length > 1 ? 1 : 0.875,
);
const badgeY =
params.official && !hasOrganizations && !usesLongLayout && titleLines.length === 1
? 198
: params.official && hasOrganizations && !usesLongLayout && titleLines.length === 1
? 145
: usesLongLayout
? 143
: titleY + (titleLines.length - 1) * titleLineHeight - 45;
const titleLastBaselineY = titleY + (titleLines.length - 1) * titleLineHeight;
const taglineY = titleLastBaselineY + 60;
const detailY = taglineY + 77;
const downloadsStat = params.stats?.[0] ?? { label: "Downloads", value: "0" };
const avatarCircle = hasOrganizations
? usesLongLayout
? { cx: 249, cy: 222, imageX: 87, imageY: 60 }
: { cx: 308, cy: 262 + contentYOffset, imageX: 146, imageY: 100 + contentYOffset }
: usesLongLayout
? { cx: 249, cy: 222, imageX: 87, imageY: 60 }
: { cx: 276, cy: 315 + contentYOffset, imageX: 114, imageY: 153 + contentYOffset };
const statsMarkup = usesLongLayout
? `${statColumn("Creator", params.handleLabel, layout.detailX, detailY, layout.creatorWidth, { truncateWithDots: true })}
${statColumn(downloadsStat.label, downloadsStat.value, layout.downloadsX, detailY + 112, layout.downloadsWidth)}`
: `${statColumn("Creator", params.handleLabel, layout.detailX, detailY, layout.creatorWidth, { truncateWithDots: true })}
${statColumn(downloadsStat.label, downloadsStat.value, layout.downloadsX, detailY, layout.downloadsWidth)}`;
const orgLogosX = usesLongLayout ? 110 : 169;
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"/>`;
? `<circle cx="${avatarCircle.cx}" cy="${avatarCircle.cy}" r="139" fill="#FFFFFF" fill-opacity="0.055" stroke="#FFFFFF" stroke-opacity="0.16"/>
<image href="${avatar}" x="${avatarCircle.imageX}" y="${avatarCircle.imageY}" width="324" height="324" clip-path="url(#publisherAvatarCircleClip)" preserveAspectRatio="xMidYMid slice"/>
<circle cx="${avatarCircle.cx}" cy="${avatarCircle.cy}" r="139" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="1.5"/>`
: `<rect x="${avatarCircle.cx - 139}" y="${avatarCircle.cy - 139}" width="278" height="278" rx="58" fill="#FFFFFF" fill-opacity="0.055" stroke="#FFFFFF" stroke-opacity="0.16"/>
<image href="${avatar}" x="${avatarCircle.imageX}" y="${avatarCircle.imageY}" width="324" height="324" clip-path="url(#publisherAvatarRoundedClip)" preserveAspectRatio="xMidYMid slice"/>
<rect x="${avatarCircle.cx - 138.25}" y="${avatarCircle.cy - 138.25}" width="276.5" height="276.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">
@@ -71,25 +332,25 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
<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"/>
<stop offset="1" stop-color="#050505"/>
</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"/>
<stop stop-color="#12090A" stop-opacity="0.08"/>
<stop offset="1" stop-color="#12090A" 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"/>
<circle cx="${avatarCircle.cx}" cy="${avatarCircle.cy}" r="139"/>
</clipPath>
<clipPath id="publisherAvatarRoundedClip">
<rect x="71" y="165" width="280" height="280" rx="58"/>
<rect x="${avatarCircle.cx - 139}" y="${avatarCircle.cy - 139}" width="278" height="278" rx="58"/>
</clipPath>
</defs>
@@ -98,7 +359,6 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
<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>
@@ -110,25 +370,21 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
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}"
<text x="${layout.titleX}" y="${titleY}"
fill="#F7F1EA"
font-size="${titleFontSize}"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">${titleTspans}</text>
${params.official ? officialBadge(badgeX, badgeY) : ""}
<text x="${contentX}" y="${descriptionY}"
fill="#B9B0AA"
font-size="30"
font-weight="500"
font-family="${FONT_SANS}, sans-serif">${descriptionTspans}</text>
<text x="${layout.subtitleX}" y="${taglineY}"
fill="${OPENCLAW_RED}"
font-size="44"
font-weight="800"
font-family="${FONT_SANS}, sans-serif">on ClawHub</text>
${statBlock(params.stats, contentX, statsY)}
${statsMarkup}
${orgLogoTiles(organizationLogos, params.markDataUrl, orgLogosX, contentYOffset + organizationExtraGap)}
</g>
</svg>`;
}
+45 -2
View File
@@ -1,8 +1,9 @@
import { Resvg } from "@resvg/resvg-wasm";
import { defineEventHandler, getQuery, setHeader } from "h3";
import { fetchPublisherProfileImageDataUrl } from "../../og/fetchImageDataUrl";
import { fetchImageDataUrl, fetchPublisherProfileImageDataUrl } from "../../og/fetchImageDataUrl";
import { fetchPublisherOgMeta } from "../../og/fetchPublisherOgMeta";
import { readOgDownloadsQuery, resolveOgDownloadsDisplay } from "../../og/formatOgStats";
import { normalizeOgLogoDataUrl } from "../../og/normalizeLogoDataUrl";
import {
ensureResvgWasm,
FONT_MONO,
@@ -22,6 +23,9 @@ type OgQuery = {
downloads?: string;
installs?: string;
kind?: string;
official?: string;
orgState?: string;
orgImages?: string;
avatar?: string;
v?: string;
};
@@ -35,6 +39,21 @@ function getConvexUrl() {
return process.env.VITE_CONVEX_URL?.trim() || process.env.CONVEX_URL?.trim() || null;
}
function readBooleanQuery(value: unknown) {
const raw = cleanString(value).toLowerCase();
return raw === "1" || raw === "true" || raw === "yes";
}
function readOrganizationImagesQuery(value: unknown) {
const raw = cleanString(value);
if (raw === "0") return [];
return raw
.split("|")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 5);
}
export default defineEventHandler(async (event) => {
const query = getQuery(event) as OgQuery;
const handle = cleanString(query.handle).replace(/^@+/, "");
@@ -47,9 +66,16 @@ export default defineEventHandler(async (event) => {
const descriptionFromQuery = cleanString(query.description);
const kindFromQuery = cleanString(query.kind);
const avatarFromQuery = cleanString(query.avatar);
const organizationImagesFromQuery = readOrganizationImagesQuery(query.orgImages);
const convexUrl = getConvexUrl();
const needFetch =
!titleFromQuery || !descriptionFromQuery || !readOgDownloadsQuery(query) || !avatarFromQuery;
!titleFromQuery ||
!descriptionFromQuery ||
!readOgDownloadsQuery(query) ||
!avatarFromQuery ||
!cleanString(query.official) ||
!cleanString(query.orgState) ||
!cleanString(query.orgImages);
const meta = needFetch && convexUrl ? await fetchPublisherOgMeta(handle, convexUrl) : null;
const handleLabel = `@${meta?.handle || handle}`;
const title = titleFromQuery || meta?.displayName || handleLabel;
@@ -61,15 +87,32 @@ export default defineEventHandler(async (event) => {
ensureResvgWasm().then(() => getFontBuffers()),
]);
const avatarDataUrl = await fetchPublisherProfileImageDataUrl(avatarFromQuery || meta?.image);
const organizationImageUrls =
organizationImagesFromQuery.length > 0
? organizationImagesFromQuery
: (meta?.affiliations.map((affiliation) => affiliation.image).filter(Boolean) ?? []);
const organizationLogoDataUrls = (
await Promise.all(
organizationImageUrls.map(async (imageUrl) => {
const dataUrl = await fetchImageDataUrl(imageUrl, {
allowPublicHttps: true,
followRedirects: true,
});
return normalizeOgLogoDataUrl(dataUrl);
}),
)
).filter((imageUrl): imageUrl is string => Boolean(imageUrl));
const svg = buildPublisherOgSvg({
markDataUrl,
watermarkDataUrl,
avatarDataUrl,
avatarShape: kindFromQuery === "org" || meta?.kind === "org" ? "rounded" : "circle",
official: cleanString(query.official) ? readBooleanQuery(query.official) : meta?.official,
title,
description,
handleLabel,
organizationLogos: organizationLogoDataUrls,
stats: [buildOgDownloadsStat(resolveOgDownloadsDisplay(query, meta?.stats.downloads))],
});
+23 -1
View File
@@ -51,21 +51,43 @@ describe("og helpers", () => {
bio: "maton.ai",
image: "https://example.com/logo.png",
kind: "org",
official: true,
affiliations: [
{ publisher: { displayName: "OpenClaw", image: "https://example.com/openclaw.png" } },
],
downloads: 1200,
});
expect(meta.title).toBe("byungkyu — ClawHub");
expect(meta.description).toBe("maton.ai");
expect(meta.url).toBe("https://clawhub.ai/byungkyu");
expect(meta.image).toContain("/og/profile?");
expect(meta.image).toContain("v=7");
expect(meta.image).toContain("v=8");
expect(meta.image).toContain("handle=byungkyu");
expect(meta.image).toContain("title=byungkyu");
expect(meta.image).toContain("description=maton.ai");
expect(meta.image).toContain("kind=org");
expect(meta.image).toContain("official=1");
expect(meta.image).toContain("orgState=1");
expect(meta.image).not.toContain("OpenClaw");
expect(meta.image).toContain("orgImages=https%3A%2F%2Fexample.com%2Fopenclaw.png");
expect(meta.image).toContain("avatar=https%3A%2F%2Fexample.com%2Flogo.png");
expect(meta.image).toContain("downloads=1200");
});
it("builds no-badge no-organization publisher metadata explicitly", () => {
const meta = buildPublisherMeta({
handle: "mvanhorn",
displayName: "Matt Van Horn",
bio: "Publisher @mvanhorn on ClawHub.",
kind: "user",
official: false,
affiliations: [],
});
expect(meta.image).toContain("official=0");
expect(meta.image).toContain("orgState=0");
expect(meta.image).not.toContain("kind=org");
});
it("uses defaults when owner and summary are missing", () => {
const meta = buildSkillMeta({ slug: "parser" });
expect(meta.title).toBe("parser — ClawHub");
+20 -1
View File
@@ -34,6 +34,13 @@ type PublisherMetaSource = {
bio?: string | null;
image?: string | null;
kind?: "user" | "org";
official?: boolean | null;
affiliations?: Array<{
publisher?: {
displayName?: string | null;
image?: string | null;
} | null;
}> | null;
downloads?: number | null;
};
@@ -46,7 +53,7 @@ type BasicMeta = {
const OG_SKILL_IMAGE_LAYOUT_VERSION = "10";
const OG_PLUGIN_IMAGE_LAYOUT_VERSION = "5";
const OG_PUBLISHER_IMAGE_LAYOUT_VERSION = "7";
const OG_PUBLISHER_IMAGE_LAYOUT_VERSION = "8";
function getSiteUrl() {
return getClawHubSiteUrl();
@@ -141,6 +148,18 @@ export function buildPublisherMeta(source: PublisherMetaSource): BasicMeta {
imageParams.set("title", displayName);
imageParams.set("description", truncate(description, 200));
if (source.kind === "org") imageParams.set("kind", "org");
imageParams.set("official", source.official ? "1" : "0");
const organizationCount = source.affiliations?.length ?? 0;
imageParams.set("orgState", organizationCount > 1 ? "many" : String(organizationCount));
const organizationImages =
source.affiliations
?.map((affiliation) => clean(affiliation.publisher?.image))
.map((imageUrl) => imageUrl.replace(/\|/g, ""))
.filter(Boolean) ?? [];
imageParams.set(
"orgImages",
organizationImages.length > 0 ? organizationImages.slice(0, 5).join("|") : "0",
);
if (image) imageParams.set("avatar", image);
if (typeof source.downloads === "number" && Number.isFinite(source.downloads)) {
imageParams.set("downloads", String(Math.max(0, Math.trunc(source.downloads))));
+2
View File
@@ -104,6 +104,8 @@ export const Route = createFileRoute("/user/$handle")({
bio: publisher?.bio,
image: publisher?.image,
kind: publisher?.kind,
official: publisher?.official,
affiliations: publisher?.affiliations,
downloads: publisher?.stats.downloads,
});
return {