mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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
|
||||
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
|
||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||
Private registry credentials live in daemon config under
|
||||
|
||||
Generated
+1
@@ -40174,6 +40174,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"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 { execFileSync } from "node:child_process";
|
||||
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.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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1202
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"
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||
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";
|
||||
@@ -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.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
|
||||
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();
|
||||
await page.getByRole("button", { name: "Copy to clipboard" }).click();
|
||||
await expect
|
||||
@@ -168,7 +169,7 @@ test.describe("search engine visits without JavaScript", () => {
|
||||
expect(response?.headers()["x-robots-tag"]).toBeUndefined();
|
||||
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
|
||||
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();
|
||||
await expectPageMetadata(
|
||||
page,
|
||||
@@ -366,3 +367,72 @@ async function expectThumbnailCard(card: Locator, source: string, id: string) {
|
||||
expect(box.width).toBeGreaterThan(0);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { createServer } from "node:http";
|
||||
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.
|
||||
createServer((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
@@ -8,7 +17,9 @@ createServer((request, response) => {
|
||||
response.end(JSON.stringify(registry));
|
||||
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.end(JSON.stringify(plugin ?? { error: "Plugin not found" }));
|
||||
}).listen(8188, "127.0.0.1");
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"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]);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import { Download } from "lucide-react";
|
||||
import { pluginCardScreenshot } from "./thumbnails";
|
||||
import { pluginHref } from "./links";
|
||||
@@ -7,9 +8,9 @@ import { formatInstalls, getCategory, type Plugin } from "./registry";
|
||||
export const PLUGIN_GRID_CLASS =
|
||||
"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 }) {
|
||||
const url = plugin.screenshots[0];
|
||||
const url = plugin.screenshots.find(pluginOverviewUrl);
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import type { Plugin } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
@@ -12,7 +13,7 @@ export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeo
|
||||
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]}`}
|
||||
>
|
||||
{plugin.icon ? (
|
||||
{pluginOverviewUrl(plugin.icon) ? (
|
||||
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
|
||||
) : (
|
||||
plugin.name.charAt(0).toUpperCase()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginContentLink } from "~/plugins/overview";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
@@ -64,21 +65,16 @@ function AuthorPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pt-1">
|
||||
{authorNpmUrl(author) && (
|
||||
<a
|
||||
href={authorNpmUrl(author)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
<PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</PluginContentLink>
|
||||
)}
|
||||
{github && (
|
||||
<a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
<PluginContentLink href={github} className={LINK_CLASS}>
|
||||
GitHub
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</PluginContentLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
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 { pageMeta } from "~/meta";
|
||||
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?.description ?? "Plugin not found.",
|
||||
`/plugins/${params.owner}/${params.slug}`,
|
||||
loaderData?.plugin.screenshots[0],
|
||||
loaderData?.plugin.screenshots.find(pluginOverviewUrl),
|
||||
),
|
||||
component: PluginPage,
|
||||
notFoundComponent: () => (
|
||||
@@ -75,46 +76,43 @@ function PluginPage() {
|
||||
)}
|
||||
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
|
||||
<span className="font-mono">{pluginVersion(plugin)}</span>
|
||||
<a
|
||||
href={plugin.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={META_LINK_CLASS}
|
||||
>
|
||||
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
|
||||
Source
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</PluginContentLink>
|
||||
{npm && (
|
||||
<a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}>
|
||||
<PluginContentLink href={npm} className={META_LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</PluginContentLink>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plugin.screenshots.length > 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) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
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)]"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`${plugin.name} screenshot ${index + 1}`}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
{plugin.screenshots.map((url, index) =>
|
||||
pluginOverviewUrl(url) ? (
|
||||
<PluginContentLink
|
||||
key={url}
|
||||
href={url}
|
||||
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
|
||||
src={url}
|
||||
alt={`${plugin.name} screenshot ${index + 1}`}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
</PluginContentLink>
|
||||
) : (
|
||||
<p key={url}>{url}</p>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 border-t border-white/10 pt-10">
|
||||
<DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown>
|
||||
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
|
||||
</div>
|
||||
</div>
|
||||
</SiteShell>
|
||||
|
||||
Reference in New Issue
Block a user