mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix: polish publisher social embeds
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"?>
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user