fix: polish publisher social embeds

This commit is contained in:
Jesse Merhi
2026-07-24 10:39:25 +10:00
parent 6d27f3c6fb
commit b04ec06450
8 changed files with 153 additions and 15 deletions
+31 -1
View File
@@ -1,6 +1,6 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { normalizeOgLogoDataUrl } from "./normalizeLogoDataUrl";
import { normalizeOgAvatarDataUrl, normalizeOgLogoDataUrl } from "./normalizeLogoDataUrl";
async function makeLogoDataUrl(padding: number) {
const size = 96;
@@ -82,3 +82,33 @@ describe("normalizeOgLogoDataUrl", () => {
).resolves.toBeNull();
});
});
describe("normalizeOgAvatarDataUrl", () => {
it("converts WebP avatars to PNG for the SVG renderer", async () => {
const webp = await sharp({
create: {
width: 85,
height: 84,
channels: 4,
background: { r: 96, g: 165, b: 250, alpha: 0.8 },
},
})
.webp()
.toBuffer();
const normalized = await normalizeOgAvatarDataUrl(
`data:image/webp;base64,${webp.toString("base64")}`,
);
expect(normalized).toMatch(/^data:image\/png;base64,/);
await expect(
sharp(Buffer.from(normalized?.split(",")[1] ?? "", "base64")).metadata(),
).resolves.toMatchObject({ format: "png", width: 85, height: 84 });
});
it("returns null when an avatar cannot be decoded", async () => {
await expect(
normalizeOgAvatarDataUrl("data:image/webp;base64,bm90LWFuLWltYWdl"),
).resolves.toBeNull();
});
});
+18
View File
@@ -35,3 +35,21 @@ export async function normalizeOgLogoDataUrl(dataUrl: string | null | undefined)
return null;
}
}
export async function normalizeOgAvatarDataUrl(dataUrl: string | null | undefined) {
if (!dataUrl) return null;
const parsed = readDataUrl(dataUrl);
if (!parsed || !parsed.mimeType.startsWith("image/")) return null;
try {
const normalized = await sharp(parsed.buffer, {
limitInputPixels: NORMALIZED_LOGO_MAX_INPUT_PIXELS,
})
.ensureAlpha()
.png()
.toBuffer();
return `data:image/png;base64,${normalized.toString("base64")}`;
} catch {
return null;
}
}
+30 -1
View File
@@ -7,6 +7,7 @@ const setHeaderMock = vi.fn();
const fetchImageDataUrlMock = vi.fn();
const fetchPublisherProfileImageDataUrlMock = vi.fn();
const fetchPublisherOgMetaMock = vi.fn();
const normalizeOgAvatarDataUrlMock = vi.fn();
const normalizeOgLogoDataUrlMock = vi.fn();
const getClawHubLogoDataUrlMock = vi.fn();
const ensureResvgWasmMock = vi.fn();
@@ -47,6 +48,7 @@ vi.mock("./fetchPublisherOgMeta", () => ({
}));
vi.mock("./normalizeLogoDataUrl", () => ({
normalizeOgAvatarDataUrl: (...args: unknown[]) => normalizeOgAvatarDataUrlMock(...args),
normalizeOgLogoDataUrl: (...args: unknown[]) => normalizeOgLogoDataUrlMock(...args),
}));
@@ -73,6 +75,7 @@ beforeEach(() => {
fetchImageDataUrlMock.mockReset();
fetchPublisherProfileImageDataUrlMock.mockReset();
fetchPublisherOgMetaMock.mockReset();
normalizeOgAvatarDataUrlMock.mockReset();
normalizeOgLogoDataUrlMock.mockReset();
getClawHubLogoDataUrlMock.mockReset();
ensureResvgWasmMock.mockReset();
@@ -87,6 +90,9 @@ beforeEach(() => {
getPublisherFontBuffersMock.mockResolvedValue([new Uint8Array([1, 2, 3])]);
fetchPublisherProfileImageDataUrlMock.mockResolvedValue("data:image/png;base64,QVZBVEFS");
fetchImageDataUrlMock.mockImplementation(async (url: string) => `data:image/png;base64,${url}`);
normalizeOgAvatarDataUrlMock.mockImplementation(
async (dataUrl: string) => `${dataUrl}-normalized`,
);
normalizeOgLogoDataUrlMock.mockImplementation(async (dataUrl: string) => `${dataUrl}-normalized`);
buildPublisherOgSvgMock.mockReturnValue("<svg>profile</svg>");
renderAsPngMock.mockReturnValue(new Uint8Array([7, 8, 9]));
@@ -131,10 +137,11 @@ describe("profile og route", () => {
"https://cdn.example.com/avatar.png",
);
expect(fetchImageDataUrlMock).not.toHaveBeenCalled();
expect(normalizeOgAvatarDataUrlMock).toHaveBeenCalledWith("data:image/png;base64,QVZBVEFS");
expect(normalizeOgLogoDataUrlMock).not.toHaveBeenCalled();
expect(buildPublisherOgSvgMock).toHaveBeenCalledWith({
clawHubLogoDataUrl: "data:image/png;base64,TE9HTw==",
avatarDataUrl: "data:image/png;base64,QVZBVEFS",
avatarDataUrl: "data:image/png;base64,QVZBVEFS-normalized",
avatarShape: "rounded",
official: false,
title: "NVIDIA",
@@ -145,6 +152,28 @@ describe("profile og route", () => {
});
});
it("passes a missing avatar through to the SVG empty state", async () => {
getQueryMock.mockReturnValue({
handle: "no-avatar",
title: "No Avatar",
downloads: "0",
kind: "org",
official: "0",
orgState: "0",
orgImages: "0",
});
fetchPublisherProfileImageDataUrlMock.mockResolvedValue(null);
normalizeOgAvatarDataUrlMock.mockResolvedValue(null);
const handler = (await import("../routes/og/profile.png")).default;
await handler({} as never);
expect(normalizeOgAvatarDataUrlMock).toHaveBeenCalledWith(null);
expect(buildPublisherOgSvgMock).toHaveBeenCalledWith(
expect.objectContaining({ avatarDataUrl: null, title: "No Avatar" }),
);
});
it("verifies trust indicators before rendering query-requested official state", async () => {
process.env.VITE_CONVEX_URL = "https://convex.example";
getQueryMock.mockReturnValue({
+42 -2
View File
@@ -70,14 +70,14 @@ describe("buildPublisherOgSvg", () => {
expect(svg).toContain('stop-color="#6C1B2B" stop-opacity="0"');
expect(svg).not.toContain("#D4453A");
expect(svg).toContain('<tspan x="542" dy="0">Matt Van Horn</tspan>');
expect(svg).toContain('<svg x="1061.33" y="198.24" width="42" height="42"');
expect(svg).toContain('<svg x="1065" y="198.24" 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="1028.33" y="145.24" width="42" height="42"');
expect(svg).toContain('<svg x="1060.33" y="145.24" width="42" height="42"');
});
it("positions the official badge from the current one-line title length", () => {
@@ -131,6 +131,46 @@ describe("buildPublisherOgSvg", () => {
}
});
it("leaves a visible gap between a publisher title and its official badge", () => {
const svg = buildSvg({
official: true,
title: "Youdotcom Oss",
handleLabel: "@youdotcom-oss",
});
expect(svg).toContain('<tspan x="447" dy="0">Youdotcom Oss</tspan>');
expect(svg).toContain('<svg x="974.53" y="201.22" width="42" height="42"');
});
it("vertically centers the avatar and content for long profiles without organizations", () => {
const svg = buildSvg({
title: "Youdotcom Oss",
handleLabel: "@youdotcom-oss",
avatarShape: "rounded",
});
expect(svg).toContain('<rect x="110" y="176" width="278" height="278"');
expect(svg).toContain('<text x="447" y="244"');
expect(svg).toContain('<text x="447" y="304"');
expect(svg).toContain('<text x="447" y="381"');
expect(svg).toContain('<text x="447" y="493"');
});
it("renders an initial when the publisher has no usable avatar", () => {
const svg = buildSvg({
avatarDataUrl: null,
avatarShape: "rounded",
title: "Youdotcom Oss",
handleLabel: "@youdotcom-oss",
});
expect(svg).toContain('<text x="249" y="355"');
expect(svg).toContain(">Y</text>");
expect(svg).not.toContain(
`<image href="${clawHubLogoDataUrl}" x="87" y="153" width="324" height="324"`,
);
});
it("renders organization state when affiliations exist", () => {
const svg = buildSvg({
organizationLogos: [transparentPixel, transparentPixel, transparentPixel],
+26 -7
View File
@@ -18,7 +18,8 @@ const OFFICIAL_BADGE_SIZE = 42;
const OFFICIAL_BADGE_STROKE = 1.71;
const OFFICIAL_BADGE_VISIBLE_LEFT_INSET = (3.85 / 24) * OFFICIAL_BADGE_SIZE;
const OFFICIAL_BADGE_VISIBLE_CENTER_INSET = (12 / 24) * OFFICIAL_BADGE_SIZE;
const OFFICIAL_BADGE_VISIBLE_GAP = 32;
const OFFICIAL_BADGE_VISIBLE_GAP = 64;
const OFFICIAL_BADGE_RESERVED_GAP = 32;
const OFFICIAL_BADGE_TRUNCATED_VISIBLE_GAP = 80;
const LONG_LAYOUT_BADGE_MAX_X = 1084;
const NORMAL_TITLE_WIDTH_SCALE = 0.94;
@@ -29,6 +30,7 @@ const PUBLISHER_GRADIENT_FADE = "#6C1B2B";
const PUBLISHER_TEXT_WEIGHT = 700;
const PUBLISHER_LABEL_SIZE = 24;
const PUBLISHER_VALUE_SIZE = 44;
const LONG_NO_ORGANIZATION_Y_OFFSET = 93;
const GRAPHEME_SEGMENTER =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
@@ -74,7 +76,7 @@ function normalOfficialTitleMaxWidth(contentWidth: number) {
contentWidth,
(contentWidth -
OFFICIAL_BADGE_SIZE -
OFFICIAL_BADGE_VISIBLE_GAP +
OFFICIAL_BADGE_RESERVED_GAP +
OFFICIAL_BADGE_VISIBLE_LEFT_INSET) /
NORMAL_TITLE_WIDTH_SCALE,
);
@@ -267,7 +269,10 @@ function orgLogoTiles(
export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
const rawTitle = params.title.trim() || params.handleLabel;
const avatar = params.avatarDataUrl || params.clawHubLogoDataUrl;
const avatarFallbackLabel =
textSegments(rawTitle)
.find((segment) => segment.trim())
?.toLocaleUpperCase() ?? "@";
const avatarShape = params.avatarShape ?? "circle";
const organizationLogos = params.organizationLogos?.filter(Boolean) ?? [];
const organizationCount = Math.min(
@@ -301,6 +306,7 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
const creatorNeedsOverflow =
estimateTextWidth(params.handleLabel, 46) > normalLayout.creatorWidth;
const usesLongLayout = titleNeedsOverflow || creatorNeedsOverflow;
const contentYOffset = usesLongLayout && !hasOrganizations ? LONG_NO_ORGANIZATION_Y_OFFSET : 0;
const organizationExtraGap = usesLongLayout ? 41 : 0;
const layout = usesLongLayout
? {
@@ -331,7 +337,7 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
)
.join("");
const titleY = usesLongLayout
? 151
? 151 + contentYOffset
: hasOrganizations
? titleLines.length > 1
? 138
@@ -359,7 +365,12 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
? { cx: 249, cy: 222, imageX: 87, imageY: 60 }
: { cx: 308, cy: 262, imageX: 146, imageY: 100 }
: usesLongLayout
? { cx: 249, cy: 222, imageX: 87, imageY: 60 }
? {
cx: 249,
cy: 222 + contentYOffset,
imageX: 87,
imageY: 60 + contentYOffset,
}
: { cx: 276, cy: 315, imageX: 114, imageY: 153 };
const statsMarkup = usesLongLayout
? `${statColumn("Creator", params.handleLabel, layout.detailX, detailY, layout.creatorWidth, { truncateWithDots: true })}
@@ -367,13 +378,21 @@ export function buildPublisherOgSvg(params: PublisherOgSvgParams) {
: `${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 avatarImage = params.avatarDataUrl
? `<image href="${params.avatarDataUrl}" x="${avatarCircle.imageX}" y="${avatarCircle.imageY}" width="324" height="324" clip-path="url(#publisherAvatar${avatarShape === "circle" ? "Circle" : "Rounded"}Clip)" preserveAspectRatio="xMidYMid slice"/>`
: `<text x="${avatarCircle.cx}" y="${avatarCircle.cy + 40}"
fill="${PUBLISHER_RED}"
font-size="118"
font-weight="${PUBLISHER_TEXT_WEIGHT}"
text-anchor="middle"
font-family="${FONT_SANS}, sans-serif">${escapeXml(avatarFallbackLabel)}</text>`;
const avatarFrame =
avatarShape === "circle"
? `<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"/>
${avatarImage}
<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"/>
${avatarImage}
<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"?>
+4 -2
View File
@@ -3,7 +3,7 @@ import { defineEventHandler, getQuery, setHeader } from "h3";
import { fetchImageDataUrl, fetchPublisherProfileImageDataUrl } from "../../og/fetchImageDataUrl";
import { fetchPublisherOgMeta } from "../../og/fetchPublisherOgMeta";
import { readOgDownloadsQuery, resolveOgDownloadsDisplay } from "../../og/formatOgStats";
import { normalizeOgLogoDataUrl } from "../../og/normalizeLogoDataUrl";
import { normalizeOgAvatarDataUrl, normalizeOgLogoDataUrl } from "../../og/normalizeLogoDataUrl";
import {
ensureResvgWasm,
FONT_MONO,
@@ -97,7 +97,9 @@ export default defineEventHandler(async (event) => {
getClawHubLogoDataUrl(),
ensureResvgWasm().then(() => getPublisherFontBuffers()),
]);
const avatarDataUrl = await fetchPublisherProfileImageDataUrl(avatarUrl);
const avatarDataUrl = await normalizeOgAvatarDataUrl(
await fetchPublisherProfileImageDataUrl(avatarUrl),
);
const organizationImageUrls =
verifiedMeta?.affiliations
.map((affiliation) => affiliation.image)
+1 -1
View File
@@ -61,7 +61,7 @@ describe("og helpers", () => {
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=8");
expect(meta.image).toContain("v=9");
expect(meta.image).toContain("handle=byungkyu");
expect(meta.image).toContain("title=byungkyu");
expect(meta.image).not.toContain("description=");
+1 -1
View File
@@ -53,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 = "8";
const OG_PUBLISHER_IMAGE_LAYOUT_VERSION = "9";
function getSiteUrl() {
return getClawHubSiteUrl();