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