Sanitize plugin overview rendering: no raw HTML, https links only, no relative links (#6244)

* fix(plugins): restrict overview content to safe Markdown and HTTPS destinations

Registry overviews are author-controlled content read away from their repositories. Apply a shared presentation policy instead of the documentation renderer so relative links, non-HTTPS URLs, raw HTML, and author styles never become active overview content.

* fix(plugins): choose the first allowed screenshot for previews
This commit is contained in:
Mohamed Boudra
2026-10-06 18:31:53 +01:00
committed by GitHub
parent a9fef4879a
commit fc3b2c4eb7
16 changed files with 1673 additions and 43 deletions
+7
View File
@@ -123,6 +123,13 @@ Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own. selection is unavailable for registry installs; install an explicit source to select your own.
Registry overviews are untrusted author content, even before installation. Render them with
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
URLs, and isolated external links. The website adapter and corpus tests live in
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
this policy. Installed-plugin descriptions in the app remain plain text.
Use `host/owner/slug` for a registry at `https://host`, or set Use `host/owner/slug` for a registry at `https://host`, or set
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix). `PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
Private registry credentials live in daemon config under Private registry credentials live in daemon config under
+1
View File
@@ -40174,6 +40174,7 @@
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.1", "@vitejs/plugin-react": "^4.5.1",
"parse5": "^7.3.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.3", "vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
@@ -1,3 +1,4 @@
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible(); await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0); await expect(page.getByLabel("npm-review running")).toHaveCount(0);
} }
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
await installPluginWithUntrustedDescription(pluginEnvironment, description);
await gotoAppShell(page);
await openPluginSettings(page);
const text = page.getByText(description, { exact: true });
await expect(text).toBeVisible();
// This field must remain a text node, with no Markdown links, images, or HTML children.
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
});
async function installPluginWithUntrustedDescription(
environment: PluginEnvironment,
description: string,
) {
const directory = await createDirectoryPlugin(
environment.directory,
"plain-text-plugin",
description,
"Plain text plugin",
);
await environment.client.installPluginSource({ source: directory });
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { pluginOverviewUrl } from "./plugin-overview.js";
describe("plugin overview destinations", () => {
it.each([
undefined,
"",
"./docs",
"../docs",
"/login",
"//example.com",
"#section",
"?login",
"http://example.com",
"mailto:hello@example.com",
"ftp://example.com",
"javascript:alert(1)",
"data:image/png;base64,AAAA",
"vbscript:msgbox(1)",
"javascript:alert(1)",
"javascript%3Aalert(1)",
"javascript%253Aalert(1)",
"java\tscript:alert(1)",
" https://example.com",
"https://example.com\n",
"https:example.com",
"https:///example.com",
"https://paseo.sh@evil.example",
"https://example.com\\@evil.example",
"https://",
])("leaves %s as text", (url) => {
expect(pluginOverviewUrl(url)).toBeUndefined();
});
it.each([
"https://example.com/path?q=a&b=c#section",
"HTTPS://example.com/path",
"https://example.com/%3Cscript%3E",
])("allows %s without decoding it again", (url) => {
expect(pluginOverviewUrl(url)).toBe(url);
});
});
+49
View File
@@ -0,0 +1,49 @@
/** Presentation contract for author-controlled registry content, independent of the UI runtime. */
export const pluginOverviewPolicy = {
rawHtml: false,
elements: [
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"ul",
"ol",
"li",
"strong",
"em",
"del",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
],
link: { rel: "noopener noreferrer nofollow", target: "_blank" },
} as const;
/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */
export function pluginOverviewUrl(value: string | undefined): string | undefined {
// URL parsers discard controls and normalize backslashes; reject those ambiguous spellings.
// eslint-disable-next-line no-control-regex
if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value))
return undefined;
try {
const url = new URL(value);
if (url.protocol !== "https:" || !url.hostname || url.username || url.password)
return undefined;
return value;
} catch {
return undefined;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
{
"id": "security/overview",
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
"description": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
"artifact": {
"kind": "npm",
"package": "@alhassanaraouf/paseo-base2tone-theme",
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz",
"integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg=="
},
"license": "MIT",
"repository": {
"url": "javascript:alert(1)"
},
"categories": ["themes"],
"author": {
"github": "security",
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">"
},
"screenshots": [
"data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>",
"http://example.com/image.png"
],
"submittedAt": "2026-09-24T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-24T17:12:05.467Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "## Overview security\n\n</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">",
"icon": "data:image/png;base64,AAAA"
}
+72 -2
View File
@@ -1,3 +1,4 @@
import securityPlugin from "./overview.fixture.json" with { type: "json" };
import { expect, test, type Page, type Locator } from "playwright/test"; import { expect, test, type Page, type Locator } from "playwright/test";
import registry from "./registry.fixture.json" with { type: "json" }; import registry from "./registry.fixture.json" with { type: "json" };
import { CATEGORIES } from "../src/plugins/categories"; import { CATEGORIES } from "../src/plugins/categories";
@@ -35,7 +36,7 @@ test("browses from the directory into a category, a plugin, and its author", asy
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1); await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1); await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
await expect( await expect(
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
).toBeVisible(); ).toBeVisible();
await page.getByRole("button", { name: "Copy to clipboard" }).click(); await page.getByRole("button", { name: "Copy to clipboard" }).click();
await expect await expect
@@ -168,7 +169,7 @@ test.describe("search engine visits without JavaScript", () => {
expect(response?.headers()["x-robots-tag"]).toBeUndefined(); expect(response?.headers()["x-robots-tag"]).toBeUndefined();
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
await expect( await expect(
page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
).toBeVisible(); ).toBeVisible();
await expectPageMetadata( await expectPageMetadata(
page, page,
@@ -366,3 +367,72 @@ async function expectThumbnailCard(card: Locator, source: string, id: string) {
expect(box.width).toBeGreaterThan(0); expect(box.width).toBeGreaterThan(0);
expect(box.width / box.height).toBeCloseTo(1.6, 2); expect(box.width / box.height).toBeCloseTo(1.6, 2);
} }
test("keeps author content inert through SSR and hydration", async ({
page,
request,
}, testInfo) => {
await verifyPluginOverviewResponse(request);
await openUntrustedPlugin(page);
await expectPluginOverviewSafe(page);
await page.reload();
await expectPluginOverviewSafe(page);
await testInfo.attach("plugin-overview", {
body: await page.screenshot(),
contentType: "image/png",
});
});
async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) {
const response = await request.get("/plugins/security/overview");
expect(response.status()).toBe(200);
const html = await response.text();
expect(html).not.toContain("<script>globalThis.__overviewExecuted=1</script>");
expect(html).toContain("\\x3C/script>");
}
async function openUntrustedPlugin(page: Page) {
await page.goto("/plugins/security/overview");
await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible();
}
async function expectPluginOverviewSafe(page: Page) {
expect(
await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted),
).toBeUndefined();
// Security assertions inspect every emitted attribute, including inaccessible injected elements.
const violations = await page.locator(".docs-prose").evaluate((root) => {
const bad: string[] = [];
for (const element of root.querySelectorAll("*")) {
if (
["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes(
element.tagName,
)
)
bad.push(element.tagName);
for (const attr of element.attributes) {
if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name);
if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://"))
bad.push(attr.value);
}
if (
element.tagName === "A" &&
(element.getAttribute("rel") !== "noopener noreferrer nofollow" ||
element.getAttribute("target") !== "_blank")
)
bad.push("unsafe anchor");
}
return bad;
});
expect(violations).toEqual([]);
await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0);
await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name);
await expect(
page.getByRole("link", { name: securityPlugin.author.name, exact: true }),
).toHaveAttribute("href", "/plugins/security");
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
"content",
securityPlugin.description,
);
}
+12 -1
View File
@@ -1,6 +1,15 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import registry from "./registry.fixture.json" with { type: "json" }; import registry from "./registry.fixture.json" with { type: "json" };
import securityPlugin from "./overview.fixture.json" with { type: "json" };
import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" };
securityPlugin.readme +=
"\n\n" +
overviewCorpus
.map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```")
.join("\n\n");
// Serve the published registry contract so SSR and browser navigation use the same data. // Serve the published registry contract so SSR and browser navigation use the same data.
createServer((request, response) => { createServer((request, response) => {
response.setHeader("Content-Type", "application/json"); response.setHeader("Content-Type", "application/json");
@@ -8,7 +17,9 @@ createServer((request, response) => {
response.end(JSON.stringify(registry)); response.end(JSON.stringify(registry));
return; return;
} }
const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`); const plugin = [securityPlugin, ...registry.plugins].find(
(entry) => request.url === `/plugins/${entry.id}.json`,
);
response.statusCode = plugin ? 200 : 404; response.statusCode = plugin ? 200 : 404;
response.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); response.end(JSON.stringify(plugin ?? { error: "Plugin not found" }));
}).listen(8188, "127.0.0.1"); }).listen(8188, "127.0.0.1");
+1
View File
@@ -38,6 +38,7 @@
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.1", "@vitejs/plugin-react": "^4.5.1",
"parse5": "^7.3.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.3", "vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
@@ -0,0 +1,138 @@
import registry from "../../e2e/registry.fixture.json";
import { PluginTile } from "./plugin-tile";
import { PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
import React, { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
import { describe, expect, it } from "vitest";
import corpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { PluginOverview } from "./overview";
type Element = DefaultTreeAdapterMap["element"];
function elements(html: string): Element[] {
const result: Element[] = [];
function visit(node: DefaultTreeAdapterMap["node"]) {
if ("tagName" in node) result.push(node);
if ("childNodes" in node) node.childNodes.forEach(visit);
}
visit(parseFragment(html));
return result;
}
function render(markdown: string) {
return renderToStaticMarkup(<PluginOverview>{markdown}</PluginOverview>);
}
function attribute(element: Element, name: string) {
return element.attrs.find((attr) => attr.name === name)?.value;
}
const allowedTags = new Set([
"div",
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"ul",
"ol",
"li",
"strong",
"em",
"del",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
]);
describe("plugin overview security corpus", () => {
it.each(corpus)("$name", ({ markdown, links, images }) => {
const nodes = elements(render(markdown));
expect(
nodes.filter((node) => !allowedTags.has(node.tagName)).map((node) => node.tagName),
).toEqual([]);
expect(
nodes.flatMap((node) => node.attrs).filter((attr) => /^on|^style$|^srcdoc$/i.test(attr.name)),
).toEqual([]);
const anchors = nodes.filter((node) => node.tagName === "a");
expect(anchors.map((node) => attribute(node, "href"))).toEqual(links);
for (const anchor of anchors) {
expect(attribute(anchor, "rel")).toBe("noopener noreferrer nofollow");
expect(attribute(anchor, "target")).toBe("_blank");
}
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual(images);
});
it.each(corpus)("preserves $name inside code spans and fences", ({ markdown }) => {
for (const code of [
`\`${markdown.replaceAll("\n", " ")}\``,
`\`\`\`html\n${markdown}\n\`\`\``,
]) {
const nodes = elements(render(code));
expect(
nodes.filter((node) => ["a", "img", "script", "iframe", "svg"].includes(node.tagName)),
).toEqual([]);
const text = nodes
.filter((node) => node.tagName === "code")
.flatMap((node) => node.childNodes)
.map((child) => ("value" in child ? child.value : ""))
.join("");
expect(text.trim()).toBe(
code.startsWith("```html") ? markdown : markdown.replaceAll("\n", " "),
);
}
});
it("keeps long untrusted input inert", () => {
const nodes = elements(
render("a".repeat(100_000) + "\n\n" + "[click](javascript:alert(1)) ".repeat(1_000)),
);
expect(nodes.filter((node) => ["a", "img", "script"].includes(node.tagName))).toEqual([]);
});
});
describe("registry images", () => {
it.each([
"http://example.com/x",
"data:image/png;base64,AAAA",
"javascript:alert(1)",
"vbscript:msgbox(1)",
"./icon.png",
"//example.com/x",
])("does not load %s in cards or icons", (url) => {
const plugin = { ...registry.plugins[0], icon: url, screenshots: [url] } as Plugin;
for (const component of [
createElement(PluginTile, { plugin, size: "sm" }),
createElement(PluginCard, { plugin }),
]) {
expect(
elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"),
).toEqual([]);
}
});
});
it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () => {
const source = "https://example.com/screenshot.png";
const plugin = {
...registry.plugins[0],
icon: undefined,
screenshots: ["data:image/png;base64,AAAA", source],
} as Plugin;
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual([source]);
});
+56
View File
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
/** A rejected destination leaves its label as text, without even an empty anchor. */
export function PluginContentLink({
href,
children,
className,
}: {
href?: string;
children: ReactNode;
className?: string;
}) {
const destination = pluginOverviewUrl(href);
return destination ? (
<a href={destination} {...pluginOverviewPolicy.link} className={className}>
{children}
</a>
) : (
children
);
}
const allowedElements = [...pluginOverviewPolicy.elements];
const remarkPlugins = [remarkGfm];
const components: Components = {
a: ({ href, children }) => <PluginContentLink href={href}>{children}</PluginContentLink>,
img: ({ src, alt }) => {
const source = pluginOverviewUrl(typeof src === "string" ? src : undefined);
return source ? <img src={source} alt={alt ?? ""} loading="lazy" /> : alt;
},
// GFM alignment otherwise becomes an inline style in react-markdown.
th: ({ children }) => <th>{children}</th>,
td: ({ children }) => <td>{children}</td>,
};
/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */
export function PluginOverview({ children }: { children: string }) {
return (
<div className="docs-prose">
<ReactMarkdown
skipHtml={!pluginOverviewPolicy.rawHtml}
allowedElements={allowedElements}
unwrapDisallowed
remarkPlugins={remarkPlugins}
urlTransform={pluginOverviewUrl}
components={components}
>
{children}
</ReactMarkdown>
</div>
);
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import { pluginCardScreenshot } from "./thumbnails"; import { pluginCardScreenshot } from "./thumbnails";
import { pluginHref } from "./links"; import { pluginHref } from "./links";
@@ -7,9 +8,9 @@ import { formatInstalls, getCategory, type Plugin } from "./registry";
export const PLUGIN_GRID_CLASS = export const PLUGIN_GRID_CLASS =
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"; "grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */ /** First allowed screenshot, or the plugin tile on a quiet backdrop when there is none. */
function PluginShot({ plugin }: { plugin: Plugin }) { function PluginShot({ plugin }: { plugin: Plugin }) {
const url = plugin.screenshots[0]; const url = plugin.screenshots.find(pluginOverviewUrl);
return ( return (
<div <div
aria-hidden aria-hidden
+2 -1
View File
@@ -1,3 +1,4 @@
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import type { Plugin } from "./registry"; import type { Plugin } from "./registry";
const SIZE_CLASS = { const SIZE_CLASS = {
@@ -12,7 +13,7 @@ export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeo
aria-hidden aria-hidden
className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`} className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`}
> >
{plugin.icon ? ( {pluginOverviewUrl(plugin.icon) ? (
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" /> <img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
) : ( ) : (
plugin.name.charAt(0).toUpperCase() plugin.name.charAt(0).toUpperCase()
@@ -1,3 +1,4 @@
import { PluginContentLink } from "~/plugins/overview";
import { createFileRoute, notFound } from "@tanstack/react-router"; import { createFileRoute, notFound } from "@tanstack/react-router";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -64,21 +65,16 @@ function AuthorPage() {
</p> </p>
<div className="flex items-center gap-4 pt-1"> <div className="flex items-center gap-4 pt-1">
{authorNpmUrl(author) && ( {authorNpmUrl(author) && (
<a <PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}>
href={authorNpmUrl(author)}
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
npm npm
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </PluginContentLink>
)} )}
{github && ( {github && (
<a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}> <PluginContentLink href={github} className={LINK_CLASS}>
GitHub GitHub
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </PluginContentLink>
)} )}
</div> </div>
</div> </div>
@@ -1,7 +1,8 @@
import { createFileRoute, notFound } from "@tanstack/react-router"; import { createFileRoute, notFound } from "@tanstack/react-router";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { CodeBlock } from "~/components/code-block"; import { CodeBlock } from "~/components/code-block";
import { DocsMarkdown } from "~/components/docs-markdown"; import { PluginOverview, PluginContentLink } from "~/plugins/overview";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { SiteShell } from "~/components/site-shell"; import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta"; import { pageMeta } from "~/meta";
import { import {
@@ -34,7 +35,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo", loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo",
loaderData?.plugin?.description ?? "Plugin not found.", loaderData?.plugin?.description ?? "Plugin not found.",
`/plugins/${params.owner}/${params.slug}`, `/plugins/${params.owner}/${params.slug}`,
loaderData?.plugin.screenshots[0], loaderData?.plugin.screenshots.find(pluginOverviewUrl),
), ),
component: PluginPage, component: PluginPage,
notFoundComponent: () => ( notFoundComponent: () => (
@@ -75,46 +76,43 @@ function PluginPage() {
)} )}
<span className="tabular-nums">{formatInstalls(installs)} installs</span> <span className="tabular-nums">{formatInstalls(installs)} installs</span>
<span className="font-mono">{pluginVersion(plugin)}</span> <span className="font-mono">{pluginVersion(plugin)}</span>
<a <PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
href={plugin.repository.url}
target="_blank"
rel="noopener noreferrer"
className={META_LINK_CLASS}
>
Source Source
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </PluginContentLink>
{npm && ( {npm && (
<a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}> <PluginContentLink href={npm} className={META_LINK_CLASS}>
npm npm
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </PluginContentLink>
)} )}
</div> </div>
{plugin.screenshots.length > 0 && ( {plugin.screenshots.length > 0 && (
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0"> <div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
{plugin.screenshots.map((url, index) => ( {plugin.screenshots.map((url, index) =>
<a pluginOverviewUrl(url) ? (
key={url} <PluginContentLink
href={url} key={url}
target="_blank" href={url}
rel="noopener noreferrer" className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"
className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]" >
> <img
<img src={url}
src={url} alt={`${plugin.name} screenshot ${index + 1}`}
alt={`${plugin.name} screenshot ${index + 1}`} loading="lazy"
loading="lazy" className="h-full w-full object-cover object-top"
className="h-full w-full object-cover object-top" />
/> </PluginContentLink>
</a> ) : (
))} <p key={url}>{url}</p>
),
)}
</div> </div>
)} )}
<div className="mt-10 border-t border-white/10 pt-10"> <div className="mt-10 border-t border-white/10 pt-10">
<DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown> <PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
</div> </div>
</div> </div>
</SiteShell> </SiteShell>