fix(og): org profile images and Downloads metric in OG cards (#2840)
* fix(og): render org profile images in publisher OG cards Pass publisher avatar, kind, and installs into OG meta URLs and allow safely fetching public HTTPS org logos when generating profile images. * fix: render org profile images in publisher OG cards Org logos use public HTTPS URLs outside the GitHub/gravatar allowlist, so OG generation fell back to the default mark. Allow SSRF-safe public fetches for publisher profile avatars and embed avatar/kind metadata in OG URLs. * fix(og): show Downloads instead of Installs on OG cards Switch skill, plugin, and publisher OG image generators to read and label download counts, with legacy installs query param fallback. * fix(og): type skill API payload for canonical stat reads Export SkillStatReadable so fetchSkillOgMeta can pass API skill stats through readCanonicalStat without a TypeScript error. * fix(og): format compact downloads in OG cards Query-param download counts were rendered as raw integers on publisher OG images. Reuse formatCompactStat, add download icon + lowercase label, bump layout versions, and refresh org profile visual proof. * fix(og): use Downloads label without icon on OG cards Remove the download SVG from OG stat blocks and show only a muted "Downloads" label above compact values. Bump skill/plugin/publisher layout versions to bust cached social previews.
@@ -9,7 +9,7 @@ type SkillStatDeltas = {
|
||||
installsAllTime?: number;
|
||||
};
|
||||
|
||||
type SkillStatReadable = {
|
||||
export type SkillStatReadable = {
|
||||
stats: Partial<
|
||||
Pick<Doc<"skills">["stats"], "downloads" | "stars" | "installsCurrent" | "installsAllTime">
|
||||
>;
|
||||
|
||||
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 126 KiB |
@@ -0,0 +1,17 @@
|
||||
## Visual proof
|
||||
|
||||
Publisher OG images captured from local ClawHub at `http://localhost:3000/og/profile` against production Convex data.
|
||||
|
||||
Each card shows the org logo (or default lobster mark) and compact downloads (e.g. `43.5k`) with a muted **Downloads** label — not Installs.
|
||||
|
||||
### @nvidia (org with custom profile image + compact downloads)
|
||||
|
||||

|
||||
|
||||
### @openclaw (org with GitHub avatar + compact downloads)
|
||||
|
||||

|
||||
|
||||
### @expediagroup (org without profile image — default mark + compact downloads)
|
||||
|
||||

|
||||
@@ -1,7 +1,11 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchImageDataUrl, isTrustedOgImageUrl } from "./fetchImageDataUrl";
|
||||
import {
|
||||
fetchImageDataUrl,
|
||||
isSafePublicHttpsOgImageUrl,
|
||||
isTrustedOgImageUrl,
|
||||
} from "./fetchImageDataUrl";
|
||||
|
||||
describe("fetchImageDataUrl", () => {
|
||||
afterEach(() => {
|
||||
@@ -17,6 +21,18 @@ describe("fetchImageDataUrl", () => {
|
||||
expect(isTrustedOgImageUrl("https://example.com/avatar.png")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows public https org profile images on domain names", () => {
|
||||
expect(
|
||||
isSafePublicHttpsOgImageUrl("https://iprsoftwaremedia.com/219/files/202512/nvidia-logo.png"),
|
||||
).toBe(true);
|
||||
expect(isSafePublicHttpsOgImageUrl("https://avatars.githubusercontent.com/u/1?v=4")).toBe(true);
|
||||
expect(isSafePublicHttpsOgImageUrl("http://example.com/logo.png")).toBe(false);
|
||||
expect(isSafePublicHttpsOgImageUrl("https://127.0.0.1/logo.png")).toBe(false);
|
||||
expect(isSafePublicHttpsOgImageUrl("https://localhost/logo.png")).toBe(false);
|
||||
expect(isSafePublicHttpsOgImageUrl("https://metadata.google.internal/logo.png")).toBe(false);
|
||||
expect(isSafePublicHttpsOgImageUrl("https://192.168.0.10/logo.png")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fetch untrusted image URLs", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -26,6 +42,22 @@ describe("fetchImageDataUrl", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches public https org profile images", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(new Uint8Array([1, 2]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
fetchImageDataUrl("https://cdn.example.com/org-logo.png", {
|
||||
allowPublicHttps: true,
|
||||
}),
|
||||
).resolves.toBe("data:image/png;base64,AQI=");
|
||||
});
|
||||
|
||||
it("converts trusted image responses to data URLs", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(new Uint8Array([1, 2]), {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const MAX_IMAGE_BYTES = 1_500_000;
|
||||
const IMAGE_FETCH_TIMEOUT_MS = 1_500;
|
||||
const MAX_IMAGE_REDIRECTS = 3;
|
||||
const TRUSTED_IMAGE_HOSTS = new Set([
|
||||
"avatars.githubusercontent.com",
|
||||
"camo.githubusercontent.com",
|
||||
@@ -11,6 +12,17 @@ const TRUSTED_IMAGE_HOSTS = new Set([
|
||||
"www.gravatar.com",
|
||||
]);
|
||||
|
||||
const BLOCKED_IMAGE_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
"metadata.google.internal",
|
||||
"metadata.google",
|
||||
]);
|
||||
|
||||
type FetchImageDataUrlOptions = {
|
||||
allowPublicHttps?: boolean;
|
||||
followRedirects?: boolean;
|
||||
};
|
||||
|
||||
export function isTrustedOgImageUrl(url: string | null | undefined) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
@@ -22,20 +34,46 @@ export function isTrustedOgImageUrl(url: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImageDataUrl(url: string | null | undefined) {
|
||||
export function isSafePublicHttpsOgImageUrl(url: string | null | undefined) {
|
||||
if (!url) return false;
|
||||
if (isTrustedOgImageUrl(url)) return true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (parsed.username || parsed.password) return false;
|
||||
const hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
||||
if (BLOCKED_IMAGE_HOSTNAMES.has(hostname)) return false;
|
||||
if (
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isPrivateIpv4(hostname) || isPrivateIpv6(hostname)) return false;
|
||||
return !isIpv4Hostname(hostname) && !hostname.includes(":");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedOgImageUrl(url: string, options: FetchImageDataUrlOptions) {
|
||||
return options.allowPublicHttps ? isSafePublicHttpsOgImageUrl(url) : isTrustedOgImageUrl(url);
|
||||
}
|
||||
|
||||
export async function fetchImageDataUrl(
|
||||
url: string | null | undefined,
|
||||
options: FetchImageDataUrlOptions = {},
|
||||
) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!isTrustedOgImageUrl(parsed.toString())) return null;
|
||||
if (!isAllowedOgImageUrl(parsed.toString(), options)) return null;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(parsed, {
|
||||
headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/*" },
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const response = await fetchOgImageResponse(parsed, controller.signal, options);
|
||||
if (!response?.ok) return null;
|
||||
const contentType = response.headers.get("content-type")?.split(";")[0]?.trim();
|
||||
if (!contentType?.startsWith("image/")) return null;
|
||||
const buffer = await readLimitedImageBody(response);
|
||||
@@ -49,6 +87,72 @@ export async function fetchImageDataUrl(url: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPublisherProfileImageDataUrl(url: string | null | undefined) {
|
||||
return fetchImageDataUrl(url, { allowPublicHttps: true, followRedirects: true });
|
||||
}
|
||||
|
||||
async function fetchOgImageResponse(
|
||||
initialUrl: URL,
|
||||
signal: AbortSignal,
|
||||
options: FetchImageDataUrlOptions,
|
||||
) {
|
||||
let currentUrl = initialUrl;
|
||||
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop += 1) {
|
||||
const response = await fetch(currentUrl, {
|
||||
headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/*" },
|
||||
redirect: "manual",
|
||||
signal,
|
||||
});
|
||||
if (
|
||||
options.followRedirects &&
|
||||
response.status >= 300 &&
|
||||
response.status < 400 &&
|
||||
hop < MAX_IMAGE_REDIRECTS
|
||||
) {
|
||||
const location = response.headers.get("location")?.trim();
|
||||
if (!location) return null;
|
||||
const nextUrl = new URL(location, currentUrl);
|
||||
if (!isAllowedOgImageUrl(nextUrl.toString(), options)) return null;
|
||||
currentUrl = nextUrl;
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isIpv4Hostname(hostname: string) {
|
||||
return parseIpv4(hostname) !== null;
|
||||
}
|
||||
|
||||
function parseIpv4(hostname: string) {
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const nums = parts.map((part) => Number.parseInt(part, 10));
|
||||
if (nums.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) return null;
|
||||
return nums;
|
||||
}
|
||||
|
||||
function isPrivateIpv4(hostname: string) {
|
||||
const nums = parseIpv4(hostname);
|
||||
if (!nums) return false;
|
||||
const [a, b] = nums;
|
||||
if (a === 10 || a === 127 || a === 0) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIpv6(hostname: string) {
|
||||
const lower = hostname.toLowerCase();
|
||||
if (lower === "::1") return true;
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
||||
if (lower.startsWith("fe80")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readLimitedImageBody(response: Response) {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength) {
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("fetchPluginOgMeta", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reads installs from package API stats", async () => {
|
||||
it("reads downloads from package API stats", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -31,6 +31,6 @@ describe("fetchPluginOgMeta", () => {
|
||||
"https://clawhub.ai/api/v1/packages/%40openclaw%2Fcodex",
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
expect(meta?.stats.installs).toBe(1200);
|
||||
expect(meta?.stats.downloads).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export type PluginOgMeta = {
|
||||
ownerImage: string | null;
|
||||
latestVersion: string | null;
|
||||
stats: {
|
||||
installs: number;
|
||||
downloads: number;
|
||||
};
|
||||
verification: {
|
||||
scanStatus: string | null;
|
||||
@@ -41,7 +41,7 @@ export async function fetchPluginOgMeta(
|
||||
ownerImage: payload.owner?.image ?? null,
|
||||
latestVersion: payload.package?.latestVersion ?? null,
|
||||
stats: {
|
||||
installs: readNumber(stats.installs),
|
||||
downloads: readNumber(stats.downloads),
|
||||
},
|
||||
verification: payload.package?.verification
|
||||
? { scanStatus: payload.package.verification.scanStatus ?? null }
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("fetchPublisherOgMeta", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("reads installs from publisher profile stats", async () => {
|
||||
it("reads downloads from publisher profile stats", async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
handle: "openclaw",
|
||||
kind: "org",
|
||||
@@ -46,6 +46,6 @@ describe("fetchPublisherOgMeta", () => {
|
||||
expect(queryMock).toHaveBeenCalledWith("publishers.getProfileByHandle", {
|
||||
handle: "openclaw",
|
||||
});
|
||||
expect(meta?.stats.installs).toBe(1200);
|
||||
expect(meta?.stats.downloads).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ export type PublisherOgMeta = {
|
||||
bio: string | null;
|
||||
image: string | null;
|
||||
stats: {
|
||||
installs: number;
|
||||
downloads: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ type PublisherProfileResult = {
|
||||
bio?: string | null;
|
||||
image?: string | null;
|
||||
stats?: {
|
||||
downloads?: number;
|
||||
installs?: number;
|
||||
};
|
||||
} | null;
|
||||
@@ -40,7 +41,7 @@ export async function fetchPublisherOgMeta(
|
||||
bio: profile.bio ?? null,
|
||||
image: profile.image ?? null,
|
||||
stats: {
|
||||
installs: readNumber(profile.stats?.installs),
|
||||
downloads: readNumber(profile.stats?.downloads),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("fetchSkillOgMeta", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reads all-time installs from the public skill API stats", async () => {
|
||||
it("reads downloads from the public skill API stats", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -16,6 +16,7 @@ describe("fetchSkillOgMeta", () => {
|
||||
displayName: "Gifgrep",
|
||||
summary: "Search GIFs fast",
|
||||
stats: { downloads: 99, installsAllTime: 1200 },
|
||||
statsDownloads: 1200,
|
||||
},
|
||||
owner: { handle: "steipete", image: "https://avatars.githubusercontent.com/u/1?v=4" },
|
||||
latestVersion: { version: "1.0.1" },
|
||||
@@ -32,6 +33,6 @@ describe("fetchSkillOgMeta", () => {
|
||||
headers: { Accept: "application/json" },
|
||||
},
|
||||
);
|
||||
expect(meta?.stats.installsAllTime).toBe(1200);
|
||||
expect(meta?.stats.downloads).toBe(1200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { readCanonicalStat, type SkillStatReadable } from "../../convex/lib/skillStats";
|
||||
|
||||
type SkillApiPayload = SkillStatReadable & {
|
||||
displayName?: string;
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export type SkillOgMeta = {
|
||||
displayName: string | null;
|
||||
summary: string | null;
|
||||
@@ -5,7 +12,7 @@ export type SkillOgMeta = {
|
||||
ownerImage: string | null;
|
||||
version: string | null;
|
||||
stats: {
|
||||
installsAllTime: number;
|
||||
downloads: number;
|
||||
};
|
||||
moderation: {
|
||||
verdict: "clean" | "suspicious" | "malicious" | null;
|
||||
@@ -26,7 +33,7 @@ export async function fetchSkillOgMeta(
|
||||
const response = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) return null;
|
||||
const payload = (await response.json()) as {
|
||||
skill?: { displayName?: string; summary?: string | null; stats?: unknown } | null;
|
||||
skill?: SkillApiPayload | null;
|
||||
owner?: { handle?: string | null; image?: string | null } | null;
|
||||
latestVersion?: { version?: string | null } | null;
|
||||
moderation?: {
|
||||
@@ -35,7 +42,6 @@ export async function fetchSkillOgMeta(
|
||||
isMalwareBlocked?: boolean;
|
||||
} | null;
|
||||
};
|
||||
const stats = readStats(payload.skill?.stats);
|
||||
return {
|
||||
displayName: payload.skill?.displayName ?? null,
|
||||
summary: payload.skill?.summary ?? null,
|
||||
@@ -43,7 +49,7 @@ export async function fetchSkillOgMeta(
|
||||
ownerImage: payload.owner?.image ?? null,
|
||||
version: payload.latestVersion?.version ?? null,
|
||||
stats: {
|
||||
installsAllTime: readNumber(stats.installsAllTime),
|
||||
downloads: payload.skill ? readCanonicalStat(payload.skill, "downloads") : 0,
|
||||
},
|
||||
moderation: payload.moderation
|
||||
? {
|
||||
@@ -57,11 +63,3 @@ export async function fetchSkillOgMeta(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStats(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatOgStat, readOgDownloadsQuery, resolveOgDownloadsDisplay } from "./formatOgStats";
|
||||
|
||||
describe("formatOgStat", () => {
|
||||
it("formats large counts with compact k suffix", () => {
|
||||
expect(formatOgStat(43_456)).toBe("43.5k");
|
||||
expect(formatOgStat(282_345)).toBe("282k");
|
||||
});
|
||||
|
||||
it("formats millions with compact M suffix", () => {
|
||||
expect(formatOgStat(2_360_000)).toBe("2.4M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readOgDownloadsQuery", () => {
|
||||
it("prefers downloads over legacy installs", () => {
|
||||
expect(readOgDownloadsQuery({ downloads: "1200", installs: "9.9k" })).toBe("1200");
|
||||
});
|
||||
|
||||
it("falls back to installs when downloads is missing", () => {
|
||||
expect(readOgDownloadsQuery({ installs: "9.9k" })).toBe("9.9k");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOgDownloadsDisplay", () => {
|
||||
it("formats raw integer query params", () => {
|
||||
expect(resolveOgDownloadsDisplay({ downloads: "43456" })).toBe("43.5k");
|
||||
expect(resolveOgDownloadsDisplay({ downloads: "282345" })).toBe("282k");
|
||||
});
|
||||
|
||||
it("formats metadata fallback values", () => {
|
||||
expect(resolveOgDownloadsDisplay({}, 1200)).toBe("1.2k");
|
||||
});
|
||||
|
||||
it("passes through already-compact query values", () => {
|
||||
expect(resolveOgDownloadsDisplay({ downloads: "43.5k" })).toBe("43.5k");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,44 @@
|
||||
import { formatCompactStat } from "../../src/lib/numberFormat";
|
||||
|
||||
function cleanQueryString(value: unknown) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function readOgDownloadsQuery(query: { downloads?: unknown; installs?: unknown }) {
|
||||
const downloads = cleanQueryString(query.downloads);
|
||||
if (downloads) return downloads;
|
||||
return cleanQueryString(query.installs);
|
||||
}
|
||||
|
||||
export function formatOgStat(value: number | null | undefined) {
|
||||
const numericValue = typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
const abs = Math.abs(numericValue);
|
||||
if (abs >= 1_000_000) return `${(numericValue / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}m`;
|
||||
if (abs >= 1_000) return `${(numericValue / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`;
|
||||
return String(numericValue);
|
||||
return formatCompactStat(numericValue);
|
||||
}
|
||||
|
||||
function parseRawOgStatValue(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
const compact = /^(-?\d+(?:\.\d+)?)([kKmM])$/.exec(trimmed);
|
||||
if (!compact) return null;
|
||||
const base = Number.parseFloat(compact[1]);
|
||||
if (!Number.isFinite(base)) return null;
|
||||
return compact[2].toLowerCase() === "k" ? Math.round(base * 1_000) : Math.round(base * 1_000_000);
|
||||
}
|
||||
|
||||
export function resolveOgDownloadsDisplay(
|
||||
query: { downloads?: unknown; installs?: unknown },
|
||||
fallback?: number | null,
|
||||
) {
|
||||
const raw = readOgDownloadsQuery(query);
|
||||
if (raw) {
|
||||
const numeric = parseRawOgStatValue(raw);
|
||||
if (numeric !== null) return formatOgStat(numeric);
|
||||
return raw;
|
||||
}
|
||||
return formatOgStat(fallback);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { FONT_SANS } from "./ogAssets";
|
||||
import { escapeXml, OPENCLAW_RED, type RegistryOgStat, wrapText } from "./registryOgSvg";
|
||||
import {
|
||||
escapeXml,
|
||||
OPENCLAW_RED,
|
||||
type RegistryOgStat,
|
||||
statLabelMarkup,
|
||||
wrapText,
|
||||
} from "./registryOgSvg";
|
||||
|
||||
export type PublisherOgSvgParams = {
|
||||
markDataUrl: string;
|
||||
@@ -15,11 +21,7 @@ export type PublisherOgSvgParams = {
|
||||
function statBlock(stats: RegistryOgStat[] | undefined, x: number, y: number) {
|
||||
const stat = stats?.[0] ?? { value: "ClawHub", label: "Publisher" };
|
||||
return `<g>
|
||||
<text x="${x}" y="${y}"
|
||||
fill="#9D9692"
|
||||
font-size="22"
|
||||
font-weight="700"
|
||||
font-family="${FONT_SANS}, sans-serif">${escapeXml(stat.label)}</text>
|
||||
${statLabelMarkup(x, y, stat.label, { fontSize: 22 })}
|
||||
<text x="${x}" y="${y + 44}"
|
||||
fill="#F7F1EA"
|
||||
font-size="44"
|
||||
|
||||
@@ -7,6 +7,24 @@ export type RegistryOgStat = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function buildOgDownloadsStat(value: string): RegistryOgStat {
|
||||
return { value, label: "Downloads" };
|
||||
}
|
||||
|
||||
export function statLabelMarkup(
|
||||
x: number,
|
||||
y: number,
|
||||
label: string,
|
||||
options?: { fontSize?: number },
|
||||
) {
|
||||
const fontSize = options?.fontSize ?? 21;
|
||||
return `<text x="${x}" y="${y}"
|
||||
fill="#9D9692"
|
||||
font-size="${fontSize}"
|
||||
font-weight="700"
|
||||
font-family="${FONT_SANS}, sans-serif">${escapeXml(label)}</text>`;
|
||||
}
|
||||
|
||||
export type RegistryOgCommand = {
|
||||
subject: string;
|
||||
action: string;
|
||||
@@ -152,11 +170,7 @@ function statColumns(stats: RegistryOgStat[], contentX: number) {
|
||||
.map((stat, index) => {
|
||||
const x = contentX + index * 190;
|
||||
return `<g>
|
||||
<text x="${x}" y="424"
|
||||
fill="#9D9692"
|
||||
font-size="21"
|
||||
font-weight="700"
|
||||
font-family="${FONT_SANS}, sans-serif">${escapeXml(stat.label)}</text>
|
||||
${statLabelMarkup(x, 424, stat.label)}
|
||||
<text x="${x}" y="464"
|
||||
fill="#F7F1EA"
|
||||
font-size="34"
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("skill OG SVG", () => {
|
||||
target: "discord-doctor",
|
||||
},
|
||||
stats: [
|
||||
{ value: "1.2k", label: "Installs" },
|
||||
{ value: "1.2k", label: "Downloads" },
|
||||
{ value: "PASS", label: "Audit" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("plugin og route", () => {
|
||||
latestVersion: null,
|
||||
displayName: "Codex",
|
||||
summary: "OpenClaw Codex harness.",
|
||||
stats: { installs: 1200 },
|
||||
stats: { downloads: 1200 },
|
||||
verification: { scanStatus: "pending" },
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("plugin og route", () => {
|
||||
expect(buildPluginOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stats: [
|
||||
{ value: "1.2k", label: "Installs" },
|
||||
{ value: "1.2k", label: "Downloads" },
|
||||
{ value: "PENDING", label: "Audit" },
|
||||
],
|
||||
}),
|
||||
@@ -142,7 +142,7 @@ describe("plugin og route", () => {
|
||||
latestVersion: "1.0.0",
|
||||
displayName: "Codex",
|
||||
summary: "OpenClaw Codex harness.",
|
||||
stats: { installs: 1200 },
|
||||
stats: { downloads: 1200 },
|
||||
verification: { scanStatus: "clean" },
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("plugin og route", () => {
|
||||
expect(buildPluginOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stats: [
|
||||
{ value: "1.2k", label: "Installs" },
|
||||
{ value: "1.2k", label: "Downloads" },
|
||||
{ value: "PASS", label: "Audit" },
|
||||
],
|
||||
}),
|
||||
@@ -166,7 +166,7 @@ describe("plugin og route", () => {
|
||||
owner: "openclaw",
|
||||
title: "Codex",
|
||||
description: "OpenClaw Codex harness.",
|
||||
installs: "0",
|
||||
downloads: "0",
|
||||
});
|
||||
|
||||
const handler = (await import("./plugin.png")).default;
|
||||
@@ -176,21 +176,21 @@ describe("plugin og route", () => {
|
||||
expect(buildPluginOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stats: [
|
||||
{ value: "0", label: "Installs" },
|
||||
{ value: "0", label: "Downloads" },
|
||||
{ value: "UNKNOWN", label: "Audit" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses explicit installs over legacy downloads query params", async () => {
|
||||
it("uses explicit downloads over legacy installs query params", async () => {
|
||||
getQueryMock.mockReturnValue({
|
||||
name: "@openclaw/codex",
|
||||
owner: "openclaw",
|
||||
title: "Codex",
|
||||
description: "OpenClaw Codex harness.",
|
||||
installs: "0",
|
||||
downloads: "9.9k",
|
||||
downloads: "0",
|
||||
installs: "9.9k",
|
||||
});
|
||||
|
||||
const handler = (await import("./plugin.png")).default;
|
||||
@@ -200,7 +200,7 @@ describe("plugin og route", () => {
|
||||
expect(buildPluginOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stats: [
|
||||
{ value: "0", label: "Installs" },
|
||||
{ value: "0", label: "Downloads" },
|
||||
{ value: "UNKNOWN", label: "Audit" },
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Resvg } from "@resvg/resvg-wasm";
|
||||
import { defineEventHandler, getQuery, getRequestHost, setHeader } from "h3";
|
||||
import { fetchImageDataUrl } from "../../og/fetchImageDataUrl";
|
||||
import { fetchPluginOgMeta } from "../../og/fetchPluginOgMeta";
|
||||
import { formatOgStat } from "../../og/formatOgStats";
|
||||
import { resolveOgDownloadsDisplay } from "../../og/formatOgStats";
|
||||
import {
|
||||
ensureResvgWasm,
|
||||
FONT_MONO,
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
} from "../../og/ogAssets";
|
||||
import { buildPluginOgSvg } from "../../og/pluginOgSvg";
|
||||
import { pngResponse } from "../../og/pngResponse";
|
||||
import { buildOgDownloadsStat } from "../../og/registryOgSvg";
|
||||
|
||||
type OgQuery = {
|
||||
name?: string;
|
||||
owner?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
downloads?: string;
|
||||
installs?: string;
|
||||
audit?: string;
|
||||
avatar?: string;
|
||||
@@ -63,7 +65,6 @@ export default defineEventHandler(async (event) => {
|
||||
const ownerFromQuery = cleanString(query.owner);
|
||||
const titleFromQuery = cleanString(query.title);
|
||||
const descriptionFromQuery = cleanString(query.description);
|
||||
const installsFromQuery = cleanString(query.installs);
|
||||
const auditFromQuery = cleanString(query.audit);
|
||||
const avatarFromQuery = cleanString(query.avatar);
|
||||
const needFetch = !ownerFromQuery || !titleFromQuery || !descriptionFromQuery;
|
||||
@@ -99,10 +100,7 @@ export default defineEventHandler(async (event) => {
|
||||
target: `clawhub:${packageName}`,
|
||||
},
|
||||
stats: [
|
||||
{
|
||||
value: installsFromQuery || formatOgStat(meta?.stats.installs),
|
||||
label: "Installs",
|
||||
},
|
||||
buildOgDownloadsStat(resolveOgDownloadsDisplay(query, meta?.stats.downloads)),
|
||||
{
|
||||
value: (auditFromQuery || getAuditLabel(meta?.verification?.scanStatus)).replace(
|
||||
/^Audit\s+/i,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Resvg } from "@resvg/resvg-wasm";
|
||||
import { defineEventHandler, getQuery, setHeader } from "h3";
|
||||
import { fetchImageDataUrl } from "../../og/fetchImageDataUrl";
|
||||
import { fetchPublisherProfileImageDataUrl } from "../../og/fetchImageDataUrl";
|
||||
import { fetchPublisherOgMeta } from "../../og/fetchPublisherOgMeta";
|
||||
import { formatOgStat } from "../../og/formatOgStats";
|
||||
import { readOgDownloadsQuery, resolveOgDownloadsDisplay } from "../../og/formatOgStats";
|
||||
import {
|
||||
ensureResvgWasm,
|
||||
FONT_MONO,
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
} from "../../og/ogAssets";
|
||||
import { pngResponse } from "../../og/pngResponse";
|
||||
import { buildPublisherOgSvg } from "../../og/publisherOgSvg";
|
||||
import { buildOgDownloadsStat } from "../../og/registryOgSvg";
|
||||
|
||||
type OgQuery = {
|
||||
handle?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
downloads?: string;
|
||||
installs?: string;
|
||||
kind?: string;
|
||||
avatar?: string;
|
||||
@@ -43,11 +45,11 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const titleFromQuery = cleanString(query.title);
|
||||
const descriptionFromQuery = cleanString(query.description);
|
||||
const installsFromQuery = cleanString(query.installs);
|
||||
const kindFromQuery = cleanString(query.kind);
|
||||
const avatarFromQuery = cleanString(query.avatar);
|
||||
const convexUrl = getConvexUrl();
|
||||
const needFetch = !titleFromQuery || !descriptionFromQuery || !installsFromQuery;
|
||||
const needFetch =
|
||||
!titleFromQuery || !descriptionFromQuery || !readOgDownloadsQuery(query) || !avatarFromQuery;
|
||||
const meta = needFetch && convexUrl ? await fetchPublisherOgMeta(handle, convexUrl) : null;
|
||||
const handleLabel = `@${meta?.handle || handle}`;
|
||||
const title = titleFromQuery || meta?.displayName || handleLabel;
|
||||
@@ -58,7 +60,7 @@ export default defineEventHandler(async (event) => {
|
||||
getWatermarkDataUrl(),
|
||||
ensureResvgWasm().then(() => getFontBuffers()),
|
||||
]);
|
||||
const avatarDataUrl = await fetchImageDataUrl(avatarFromQuery || meta?.image);
|
||||
const avatarDataUrl = await fetchPublisherProfileImageDataUrl(avatarFromQuery || meta?.image);
|
||||
|
||||
const svg = buildPublisherOgSvg({
|
||||
markDataUrl,
|
||||
@@ -68,12 +70,7 @@ export default defineEventHandler(async (event) => {
|
||||
title,
|
||||
description,
|
||||
handleLabel,
|
||||
stats: [
|
||||
{
|
||||
value: installsFromQuery || formatOgStat(meta?.stats.installs),
|
||||
label: "Installs",
|
||||
},
|
||||
],
|
||||
stats: [buildOgDownloadsStat(resolveOgDownloadsDisplay(query, meta?.stats.downloads))],
|
||||
});
|
||||
|
||||
const resvg = new Resvg(svg, {
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("skill og route", () => {
|
||||
version: "1.0.1",
|
||||
title: "Gifgrep",
|
||||
description: "Search GIFs fast",
|
||||
installs: "0",
|
||||
downloads: "0",
|
||||
});
|
||||
|
||||
const handler = (await import("./skill.png")).default;
|
||||
@@ -132,7 +132,7 @@ describe("skill og route", () => {
|
||||
target: "gifgrep",
|
||||
},
|
||||
stats: [
|
||||
{ value: "0", label: "Installs" },
|
||||
{ value: "0", label: "Downloads" },
|
||||
{ value: "PASS", label: "Audit" },
|
||||
],
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe("skill og route", () => {
|
||||
displayName: "Gifgrep",
|
||||
summary: "Search GIFs fast",
|
||||
ownerImage: null,
|
||||
stats: { installsAllTime: 1200 },
|
||||
stats: { downloads: 1200 },
|
||||
moderation: { verdict: "clean", isSuspicious: false, isMalwareBlocked: false },
|
||||
});
|
||||
|
||||
@@ -177,22 +177,22 @@ describe("skill og route", () => {
|
||||
ownerLabel: "@steipete",
|
||||
versionLabel: "latest",
|
||||
stats: [
|
||||
{ value: "1.2k", label: "Installs" },
|
||||
{ value: "1.2k", label: "Downloads" },
|
||||
{ value: "PASS", label: "Audit" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses explicit installs over legacy downloads query params", async () => {
|
||||
it("uses explicit downloads over legacy installs query params", async () => {
|
||||
getQueryMock.mockReturnValue({
|
||||
slug: "gifgrep",
|
||||
owner: "steipete",
|
||||
version: "1.0.1",
|
||||
title: "Gifgrep",
|
||||
description: "Search GIFs fast",
|
||||
installs: "0",
|
||||
downloads: "9.9k",
|
||||
downloads: "0",
|
||||
installs: "9.9k",
|
||||
});
|
||||
|
||||
const handler = (await import("./skill.png")).default;
|
||||
@@ -202,7 +202,30 @@ describe("skill og route", () => {
|
||||
expect(buildSkillOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stats: [
|
||||
{ value: "0", label: "Installs" },
|
||||
{ value: "0", label: "Downloads" },
|
||||
{ value: "PASS", label: "Audit" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats raw integer downloads query params", async () => {
|
||||
getQueryMock.mockReturnValue({
|
||||
slug: "gifgrep",
|
||||
owner: "steipete",
|
||||
version: "1.0.1",
|
||||
title: "Gifgrep",
|
||||
description: "Search GIFs fast",
|
||||
downloads: "43456",
|
||||
});
|
||||
|
||||
const handler = (await import("./skill.png")).default;
|
||||
await handler({} as never);
|
||||
|
||||
expect(buildSkillOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stats: [
|
||||
{ value: "43.5k", label: "Downloads" },
|
||||
{ value: "PASS", label: "Audit" },
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Resvg } from "@resvg/resvg-wasm";
|
||||
import { defineEventHandler, getQuery, getRequestHost, setHeader } from "h3";
|
||||
import { fetchImageDataUrl } from "../../og/fetchImageDataUrl";
|
||||
import { fetchSkillOgMeta } from "../../og/fetchSkillOgMeta";
|
||||
import { formatOgStat } from "../../og/formatOgStats";
|
||||
import { resolveOgDownloadsDisplay } from "../../og/formatOgStats";
|
||||
import {
|
||||
ensureResvgWasm,
|
||||
FONT_MONO,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getWatermarkDataUrl,
|
||||
} from "../../og/ogAssets";
|
||||
import { pngResponse } from "../../og/pngResponse";
|
||||
import { buildOgDownloadsStat } from "../../og/registryOgSvg";
|
||||
import { buildSkillOgSvg } from "../../og/skillOgSvg";
|
||||
|
||||
type OgQuery = {
|
||||
@@ -20,6 +21,7 @@ type OgQuery = {
|
||||
version?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
downloads?: string;
|
||||
installs?: string;
|
||||
audit?: string;
|
||||
avatar?: string;
|
||||
@@ -54,7 +56,6 @@ export default defineEventHandler(async (event) => {
|
||||
const versionFromQuery = cleanString(query.version);
|
||||
const titleFromQuery = cleanString(query.title);
|
||||
const descriptionFromQuery = cleanString(query.description);
|
||||
const installsFromQuery = cleanString(query.installs);
|
||||
const auditFromQuery = cleanString(query.audit);
|
||||
const avatarFromQuery = cleanString(query.avatar);
|
||||
|
||||
@@ -101,10 +102,7 @@ export default defineEventHandler(async (event) => {
|
||||
target: slug,
|
||||
},
|
||||
stats: [
|
||||
{
|
||||
value: installsFromQuery || formatOgStat(meta?.stats.installsAllTime),
|
||||
label: "Installs",
|
||||
},
|
||||
buildOgDownloadsStat(resolveOgDownloadsDisplay(query, meta?.stats.downloads)),
|
||||
{ value: auditLabel.replace(/^Audit\s+/i, ""), label: "Audit" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -337,11 +337,11 @@ describe("skill route loader", () => {
|
||||
{ property: "og:url", content: "https://clawhub.ai/steipete/skills/weather" },
|
||||
{
|
||||
property: "og:image",
|
||||
content: "https://clawhub.ai/og/skill?v=8&slug=weather&owner=steipete&version=1.0.0",
|
||||
content: "https://clawhub.ai/og/skill?v=10&slug=weather&owner=steipete&version=1.0.0",
|
||||
},
|
||||
{
|
||||
name: "twitter:image",
|
||||
content: "https://clawhub.ai/og/skill?v=8&slug=weather&owner=steipete&version=1.0.0",
|
||||
content: "https://clawhub.ai/og/skill?v=10&slug=weather&owner=steipete&version=1.0.0",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("og helpers", () => {
|
||||
expect(meta.url).toContain("/steipete/skills/weather");
|
||||
expect(meta.owner).toBe("steipete");
|
||||
expect(meta.image).toContain("/og/skill?");
|
||||
expect(meta.image).toContain("v=8");
|
||||
expect(meta.image).toContain("v=10");
|
||||
expect(meta.image).toContain("slug=weather");
|
||||
expect(meta.image).toContain("owner=steipete");
|
||||
expect(meta.image).toContain("version=1.2.3");
|
||||
@@ -39,7 +39,7 @@ describe("og helpers", () => {
|
||||
expect(meta.description).toBe("OpenClaw Codex harness.");
|
||||
expect(meta.url).toBe("https://clawhub.ai/openclaw/plugins/codex");
|
||||
expect(meta.image).toContain("/og/plugin?");
|
||||
expect(meta.image).toContain("v=3");
|
||||
expect(meta.image).toContain("v=5");
|
||||
expect(meta.image).toContain("name=%40openclaw%2Fcodex");
|
||||
expect(meta.image).toContain("version=1.0.0");
|
||||
});
|
||||
@@ -49,13 +49,21 @@ describe("og helpers", () => {
|
||||
handle: "@byungkyu",
|
||||
displayName: "byungkyu",
|
||||
bio: "maton.ai",
|
||||
image: "https://example.com/logo.png",
|
||||
kind: "org",
|
||||
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=3");
|
||||
expect(meta.image).toContain("v=7");
|
||||
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("avatar=https%3A%2F%2Fexample.com%2Flogo.png");
|
||||
expect(meta.image).toContain("downloads=1200");
|
||||
});
|
||||
|
||||
it("uses defaults when owner and summary are missing", () => {
|
||||
|
||||
@@ -32,6 +32,9 @@ type PublisherMetaSource = {
|
||||
handle: string;
|
||||
displayName?: string | null;
|
||||
bio?: string | null;
|
||||
image?: string | null;
|
||||
kind?: "user" | "org";
|
||||
downloads?: number | null;
|
||||
};
|
||||
|
||||
type BasicMeta = {
|
||||
@@ -41,9 +44,9 @@ type BasicMeta = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
const OG_SKILL_IMAGE_LAYOUT_VERSION = "8";
|
||||
const OG_PLUGIN_IMAGE_LAYOUT_VERSION = "3";
|
||||
const OG_PUBLISHER_IMAGE_LAYOUT_VERSION = "3";
|
||||
const OG_SKILL_IMAGE_LAYOUT_VERSION = "10";
|
||||
const OG_PLUGIN_IMAGE_LAYOUT_VERSION = "5";
|
||||
const OG_PUBLISHER_IMAGE_LAYOUT_VERSION = "7";
|
||||
|
||||
function getSiteUrl() {
|
||||
return getClawHubSiteUrl();
|
||||
@@ -129,11 +132,19 @@ export function buildPublisherMeta(source: PublisherMetaSource): BasicMeta {
|
||||
const handle = clean(source.handle).replace(/^@+/, "");
|
||||
const displayName = clean(source.displayName) || `@${handle}`;
|
||||
const bio = clean(source.bio);
|
||||
const image = clean(source.image);
|
||||
const title = `${displayName} — ClawHub`;
|
||||
const description = bio || `Publisher @${handle} on ClawHub.`;
|
||||
const imageParams = new URLSearchParams();
|
||||
imageParams.set("v", OG_PUBLISHER_IMAGE_LAYOUT_VERSION);
|
||||
imageParams.set("handle", handle);
|
||||
imageParams.set("title", displayName);
|
||||
imageParams.set("description", truncate(description, 200));
|
||||
if (source.kind === "org") imageParams.set("kind", "org");
|
||||
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))));
|
||||
}
|
||||
return {
|
||||
title,
|
||||
description: truncate(description, 200),
|
||||
|
||||
@@ -19,6 +19,9 @@ export const Route = createFileRoute("/$slug")({
|
||||
handle: publisher.handle ?? params.slug,
|
||||
displayName: publisher.displayName,
|
||||
bio: publisher.bio,
|
||||
image: publisher.image,
|
||||
kind: publisher.kind,
|
||||
downloads: publisher.stats.downloads,
|
||||
});
|
||||
return {
|
||||
meta: [
|
||||
|
||||
@@ -94,8 +94,16 @@ export const Route = createFileRoute("/user/$handle")({
|
||||
if (!publisher) throw notFound();
|
||||
return { publisher };
|
||||
},
|
||||
head: ({ params }) => {
|
||||
const meta = buildPublisherMeta({ handle: params.handle });
|
||||
head: ({ params, loaderData }) => {
|
||||
const publisher = loaderData?.publisher;
|
||||
const meta = buildPublisherMeta({
|
||||
handle: publisher?.handle ?? params.handle,
|
||||
displayName: publisher?.displayName,
|
||||
bio: publisher?.bio,
|
||||
image: publisher?.image,
|
||||
kind: publisher?.kind,
|
||||
downloads: publisher?.stats.downloads,
|
||||
});
|
||||
return {
|
||||
meta: [
|
||||
{ title: meta.title },
|
||||
|
||||