diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts
index 92a48d0c84..910f14cc1b 100644
--- a/packages/website/e2e/plugins.spec.ts
+++ b/packages/website/e2e/plugins.spec.ts
@@ -1,4 +1,5 @@
-import { expect, test, type Page } from "playwright/test";
+import { expect, test, type Page, type Locator } from "playwright/test";
+import registry from "./registry.fixture.json" with { type: "json" };
import { CATEGORIES } from "../src/plugins/categories";
async function openPlugins(page: Page) {
@@ -306,6 +307,26 @@ test("keeps the directory unlinked until the coordinated announcement", async ({
test.describe("registry fixture layout", () => {
test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture");
+ test("uses density-aware card thumbnails and keeps detail screenshots original", async ({
+ page,
+ }) => {
+ const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!;
+ const source = plugin.screenshots[0];
+ await openPlugins(page);
+ await expectThumbnailCard(
+ page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }),
+ source,
+ plugin.id,
+ );
+ await page.goto("/plugins/all");
+ const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ });
+ await expectThumbnailCard(card, source, plugin.id);
+ await card.click();
+ await expect(
+ page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }),
+ ).toHaveAttribute("src", source);
+ });
+
test("lists the nine categories in order with counts, and the newest plugins first", async ({
page,
}) => {
@@ -320,3 +341,28 @@ test.describe("registry fixture layout", () => {
).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]);
});
});
+
+async function expectThumbnailCard(card: Locator, source: string, id: string) {
+ // Card screenshots are decorative and hidden from the accessibility tree.
+ const image = card.locator("img").first();
+ const path = (width: number) =>
+ `/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`;
+ await expect(image).toHaveAttribute("src", path(592));
+ await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`);
+ await expect(image).toHaveAttribute("loading", "lazy");
+ await expect(image).toHaveAttribute("decoding", "async");
+ await image.scrollIntoViewIfNeeded();
+ await expect
+ .poll(
+ () =>
+ image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0),
+ { timeout: 15_000 },
+ )
+ .toBe(true);
+ const box = await image.evaluate((element) => {
+ const rect = element.parentElement!.getBoundingClientRect();
+ return { width: rect.width, height: rect.height };
+ });
+ expect(box.width).toBeGreaterThan(0);
+ expect(box.width / box.height).toBeCloseTo(1.6, 2);
+}
diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx
index d8fdb01fc4..4df1d70d1d 100644
--- a/packages/website/src/plugins/plugin-card.tsx
+++ b/packages/website/src/plugins/plugin-card.tsx
@@ -1,4 +1,5 @@
import { Download } from "lucide-react";
+import { pluginCardScreenshot } from "./thumbnails";
import { pluginHref } from "./links";
import { PluginTile } from "./plugin-tile";
import { formatInstalls, getCategory, type Plugin } from "./registry";
@@ -16,9 +17,10 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
>
{url ? (
) : (
diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts
index ebf7e5256b..b57d16ec2a 100644
--- a/packages/website/src/plugins/published.ts
+++ b/packages/website/src/plugins/published.ts
@@ -4,6 +4,7 @@ import {
PublishedPluginDetailSchema,
} from "@getpaseo/protocol/plugin-registry";
import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache";
+import { handlePluginThumbnailRequest } from "./thumbnails";
import { CATEGORIES } from "./categories";
import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs";
export interface RegistryEnvironment {
@@ -63,6 +64,10 @@ export async function handlePluginRegistryRequest(
const url = new URL(request.url);
if (request.method !== "GET") return null;
const base = env.PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins";
+ if (url.pathname.startsWith("/plugins/thumb/")) {
+ const index = await loadRegistryIndex(base, context);
+ return handlePluginThumbnailRequest(request, index.plugins);
+ }
if (url.hostname === "plugins.paseo.sh" && url.pathname === "/index.json")
return Response.json(await loadRegistryIndex(base, context));
if (url.pathname === "/sitemap-plugins.xml") {
diff --git a/packages/website/src/plugins/thumbnails.test.ts b/packages/website/src/plugins/thumbnails.test.ts
new file mode 100644
index 0000000000..ec089be62e
--- /dev/null
+++ b/packages/website/src/plugins/thumbnails.test.ts
@@ -0,0 +1,234 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails";
+import { NewPluginCard, PluginCard } from "./plugin-card";
+import type { Plugin } from "./registry";
+
+const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png";
+const plugin: Plugin = {
+ id: "acme/example",
+ name: "Example",
+ description: "An example",
+ categories: ["themes"],
+ author: { github: "acme" },
+ repository: { url: "https://github.com/acme/example" },
+ artifact: {
+ kind: "npm",
+ package: "paseo-example",
+ version: "1.2.3",
+ resolved: "https://registry.npmjs.org/example.tgz",
+ integrity: "sha512-YWJj",
+ },
+ screenshots: [source],
+ submittedAt: "2026-10-03",
+ reviewedAt: "2026-10-03",
+ updatedAt: "2026-10-03",
+ publishedAt: "2026-10-03",
+ installs: 42,
+};
+
+describe("plugin card thumbnails", () => {
+ it("keeps a screenshot on an unsupported host visible at its original URL", () => {
+ const screenshot = "https://example.com/preview.png";
+ const html = renderToStaticMarkup(
+ createElement(PluginCard, { plugin: { ...plugin, screenshots: [screenshot] } }),
+ );
+ expect(html).toContain(`src="${screenshot}"`);
+ expect(html).not.toContain("/plugins/thumb/");
+ });
+
+ it.each([PluginCard, NewPluginCard])(
+ "renders a thumbnail instead of downloading the original screenshot (%s)",
+ (Card) => {
+ const html = renderToStaticMarkup(createElement(Card, { plugin, added: "today" }));
+ expect(html).toContain(
+ `src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
+ );
+ expect(html).toContain(
+ `srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
+ );
+ expect(html).toContain('loading="lazy"');
+ expect(html).toContain('decoding="async"');
+ },
+ );
+});
+
+function thumbnailRequest(
+ sourceUrl = source,
+ width = 592,
+ id = plugin.id,
+ accept = "image/avif,image/webp",
+) {
+ return new Request(
+ `https://paseo.sh/plugins/thumb/${width}/${encodeURIComponent(sourceUrl)}?plugin=${encodeURIComponent(id)}`,
+ { headers: { accept } },
+ );
+}
+
+function imageResponse(body: string, headers: Record = {}) {
+ return new Response(body, { headers: { "content-type": "image/png", ...headers } });
+}
+
+// A typed fetch dependency, returning real Responses without patching the global fetch.
+function imageFetch(...responses: (Response | Error)[]) {
+ const calls: { input: Parameters[0]; init: Parameters[1] }[] = [];
+ const fetchImage: typeof fetch = async (input, init) => {
+ calls.push({ input, init });
+ const response = responses.shift();
+ if (!response) throw new Error("Unexpected fetch");
+ if (response instanceof Error) throw response;
+ return response;
+ };
+ return { calls, fetchImage };
+}
+
+describe("thumbnail route", () => {
+ it("sandboxes an SVG fallback when opened as a document on the website origin", async () => {
+ const svg =
+ '';
+ const upstream = imageFetch(
+ new Response("unsupported", { status: 415 }),
+ imageResponse(svg, { "content-type": "image/svg+xml" }),
+ );
+ const response = await handlePluginThumbnailRequest(
+ thumbnailRequest(),
+ [plugin],
+ upstream.fetchImage,
+ );
+ expect(await response.text()).toBe(svg);
+ expect(response.headers.get("content-security-policy")).toBe("sandbox");
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
+ });
+
+ it.each([
+ [592, source, "image/avif,image/webp", "avif"],
+ [592, source, "image/avif;q=0,image/webp", "webp"],
+ [592, source, "image/avif;q=0,image/webp;q=0", undefined],
+ [
+ 1184,
+ "https://raw.githubusercontent.com/acme/example/abc123/preview.png",
+ "image/webp",
+ "webp",
+ ],
+ [592, source, "image/png,image/*;q=0.8", undefined],
+ ] as const)(
+ "transforms a registered screenshot at %s from %s for %s",
+ async (width, screenshot, accept, format) => {
+ const upstream = imageFetch(imageResponse("thumbnail"));
+ const response = await handlePluginThumbnailRequest(
+ thumbnailRequest(screenshot, width, plugin.id, accept),
+ [{ ...plugin, screenshots: [screenshot] }],
+ upstream.fetchImage,
+ );
+ expect(upstream.calls).toEqual([
+ {
+ input: screenshot,
+ init: {
+ cf: { image: { width, fit: "scale-down", format, quality: 80 } },
+ },
+ },
+ ]);
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe("thumbnail");
+ expect(response.headers.get("content-type")).toBe("image/png");
+ expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
+ expect(response.headers.get("vary")).toBe("Accept");
+ },
+ );
+
+ it.each([
+ ["Cloudflare error", () => new Response("error 9401", { status: 403 })],
+ [
+ "non-image success",
+ () => new Response("unavailable", { headers: { "content-type": "text/html" } }),
+ ],
+ ["Cf-Resized error", () => imageResponse("error", { "cf-resized": "err=9401" })],
+ ["network exception", () => new Error("Transform unavailable")],
+ ] as const)("proxies the original with immutable caching on %s", async (_, failure) => {
+ const upstream = imageFetch(failure(), imageResponse("original"));
+ const response = await handlePluginThumbnailRequest(
+ thumbnailRequest(),
+ [plugin],
+ upstream.fetchImage,
+ );
+ expect(upstream.calls.map((call) => call.input)).toEqual([source, source]);
+ expect(upstream.calls[1].init).toBeUndefined();
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe("original");
+ expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
+ });
+
+ it.each([
+ ["arbitrary width", thumbnailRequest(source, 593), [plugin], 400],
+ ["unlisted source on allowed host", thumbnailRequest(source + "?different"), [plugin], 404],
+ ["wrong plugin ID", thumbnailRequest(source, 592, "acme/other"), [plugin], 404],
+ [
+ "missing plugin ID",
+ new Request(`https://paseo.sh/plugins/thumb/592/${encodeURIComponent(source)}`),
+ [plugin],
+ 404,
+ ],
+ ["removed plugin", thumbnailRequest(), [], 404],
+ ["malformed encoding", new Request("https://paseo.sh/plugins/thumb/592/%ZZ"), [plugin], 400],
+ ["malformed URL", thumbnailRequest("not a URL"), [plugin], 400],
+ ...[
+ "https://example.com/preview.png",
+ "https://cdn.jsdelivr.net.evil.test/preview.png",
+ "http://cdn.jsdelivr.net/preview.png",
+ "https://user:pass@cdn.jsdelivr.net/preview.png",
+ "https://cdn.jsdelivr.net:8080/preview.png",
+ ].map(
+ (url) =>
+ [
+ "disallowed registered source",
+ thumbnailRequest(url),
+ [{ ...plugin, screenshots: [url] }],
+ 400,
+ ] satisfies [string, Request, Plugin[], number],
+ ),
+ ] satisfies [string, Request, Plugin[], number][])(
+ "rejects %s without fetching an image",
+ async (_, request, plugins, status) => {
+ const upstream = imageFetch();
+ const response = await handlePluginThumbnailRequest(
+ request,
+ [...plugins],
+ upstream.fetchImage,
+ );
+ expect(response.status).toBe(status);
+ expect(upstream.calls).toEqual([]);
+ expect(response.headers.get("cache-control")).toBeNull();
+ },
+ );
+
+ it.each([
+ () => new Response("missing", { status: 404 }),
+ () => new Response("error", { headers: { "content-type": "text/html" } }),
+ () => new Error("Origin unavailable"),
+ ])("does not cache a failed original for a year", async (failure) => {
+ const upstream = imageFetch(new Response("transform error", { status: 502 }), failure());
+ const response = await handlePluginThumbnailRequest(
+ thumbnailRequest(),
+ [plugin],
+ upstream.fetchImage,
+ );
+ expect(response.status).toBe(502);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ });
+
+ it("round-trips screenshot URL punctuation through the card URL", async () => {
+ const screenshot = source + "?name=a&other=b#preview";
+ const upstream = imageFetch(imageResponse("thumbnail"));
+ const request = new Request(
+ "https://paseo.sh" + pluginCardScreenshot(plugin.id, screenshot).src,
+ );
+ const response = await handlePluginThumbnailRequest(
+ request,
+ [{ ...plugin, screenshots: [screenshot] }],
+ upstream.fetchImage,
+ );
+ expect(response.status).toBe(200);
+ expect(upstream.calls[0].input).toBe(screenshot);
+ });
+});
diff --git a/packages/website/src/plugins/thumbnails.ts b/packages/website/src/plugins/thumbnails.ts
new file mode 100644
index 0000000000..755854d257
--- /dev/null
+++ b/packages/website/src/plugins/thumbnails.ts
@@ -0,0 +1,118 @@
+import type { Plugin } from "./registry";
+
+// The single-column grid reaches 591px just below sm (640px minus 48px padding).
+const CARD_WIDTH = 592;
+const WIDTHS = new Set([CARD_WIDTH, CARD_WIDTH * 2]);
+const SOURCE_HOSTS = new Set(["cdn.jsdelivr.net", "raw.githubusercontent.com"]);
+
+export function pluginCardScreenshot(id: string, source: string): { src: string; srcSet?: string } {
+ if (!parseSource(source)) return { src: source };
+ return {
+ src: pluginThumbnailUrl(id, source, 1),
+ srcSet: `${pluginThumbnailUrl(id, source, 1)} 1x, ${pluginThumbnailUrl(id, source, 2)} 2x`,
+ };
+}
+
+function pluginThumbnailUrl(id: string, source: string, scale: 1 | 2): string {
+ return `/plugins/thumb/${CARD_WIDTH * scale}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`;
+}
+
+/** Only registry-listed screenshots are eligible for transformation or original fallback. */
+export async function handlePluginThumbnailRequest(
+ request: Request,
+ plugins: Plugin[],
+ fetchImage: typeof fetch = fetch,
+): Promise {
+ const url = new URL(request.url);
+ const match = /^\/plugins\/thumb\/(\d+)\/([^/]+)$/.exec(url.pathname);
+ if (!match || !WIDTHS.has(Number(match[1])))
+ return new Response("Invalid thumbnail width or source", { status: 400 });
+
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(match[2]);
+ } catch {
+ return new Response("Invalid thumbnail source", { status: 400 });
+ }
+ const source = parseSource(decoded);
+ if (!source) return new Response("Invalid thumbnail source", { status: 400 });
+
+ const plugin = plugins.find((entry) => entry.id === url.searchParams.get("plugin"));
+ if (!plugin?.screenshots.includes(source))
+ return new Response("Screenshot not found", { status: 404 });
+
+ const format = negotiatedFormat(request.headers.get("accept") ?? "");
+ let response: Response | undefined;
+ try {
+ response = await fetchImage(source, {
+ cf: { image: { width: Number(match[1]), fit: "scale-down", format, quality: 80 } },
+ });
+ } catch {
+ // An unavailable transformation must not prevent fetching the original.
+ }
+ if (!response || !isImage(response) || /err=/i.test(response.headers.get("cf-resized") ?? "")) {
+ await response?.body?.cancel();
+ try {
+ response = await fetchImage(source);
+ } catch {
+ return unavailableImage();
+ }
+ }
+ if (!isImage(response)) {
+ await response.body?.cancel();
+ return unavailableImage();
+ }
+ return new Response(response.body, {
+ headers: {
+ "content-type": response.headers.get("content-type")!,
+ "cache-control": "public, max-age=31536000, immutable",
+ vary: "Accept",
+ // Originals can be SVGs. Keep direct navigation from executing them on our origin.
+ "content-security-policy": "sandbox",
+ "x-content-type-options": "nosniff",
+ },
+ });
+}
+
+function isImage(response: Response): boolean {
+ return response.ok && /^image\//i.test(response.headers.get("content-type") ?? "");
+}
+
+function unavailableImage(): Response {
+ return new Response("Screenshot unavailable", {
+ status: 502,
+ headers: { "cache-control": "no-store" },
+ });
+}
+
+function parseSource(source: string): string | null {
+ try {
+ const url = new URL(source);
+ if (
+ url.protocol !== "https:" ||
+ !SOURCE_HOSTS.has(url.hostname) ||
+ url.username ||
+ url.password ||
+ url.port
+ )
+ return null;
+ return source;
+ } catch {
+ return null;
+ }
+}
+
+function negotiatedFormat(accept: string): "avif" | "webp" | undefined {
+ // The Worker API requires explicit negotiation; only the URL API accepts format=auto.
+ const accepted = new Map(
+ accept
+ .toLowerCase()
+ .split(",")
+ .map((range) => {
+ const [type, ...parameters] = range.split(";").map((part) => part.trim());
+ const quality = parameters.find((parameter) => parameter.startsWith("q="));
+ return [type, quality === undefined ? 1 : Number(quality.slice(2))] as const;
+ }),
+ );
+ return (["avif", "webp"] as const).find((format) => (accepted.get(`image/${format}`) ?? 0) > 0);
+}