mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfddde086a | ||
|
|
f2e2d58ba7 | ||
|
|
2d85fe4e04 | ||
|
|
1463396ebc | ||
|
|
d4ea77b910 | ||
|
|
f715221de8 | ||
|
|
aedba5ec27 | ||
|
|
bdc58fb07c |
@@ -58,7 +58,12 @@ async function probeDaemonStatus(
|
||||
let live: Record<string, unknown> = {};
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
|
||||
try {
|
||||
client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 });
|
||||
// An explicit host may sit behind SSH or a relay, so it gets the usual connect budget.
|
||||
client = await connectToDaemon({
|
||||
target,
|
||||
instance: instance ?? undefined,
|
||||
timeout: target.kind === "endpoint" ? undefined : 1_500,
|
||||
});
|
||||
} catch (error) {
|
||||
const failure = buildDaemonConnectionCommandError({ target, error });
|
||||
if (target.kind === "endpoint") throw failure;
|
||||
|
||||
@@ -93,6 +93,8 @@ describe("plugin scaffold", () => {
|
||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
|
||||
{
|
||||
id: "hello-plugin",
|
||||
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
|
||||
media: [],
|
||||
requirements: { paseo: `>=${cliPackageJson.version}` },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -206,7 +206,17 @@ export async function scaffoldPluginDirectory(
|
||||
const files = new Map<string, string>([
|
||||
[
|
||||
"paseo-plugin.json",
|
||||
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id,
|
||||
$comment:
|
||||
'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.',
|
||||
media: [],
|
||||
requirements: { paseo: `>=${version}` },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
],
|
||||
["OVERVIEW.md", OVERVIEW],
|
||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Daemon Status over SSH ===\n");
|
||||
|
||||
// The stub stands in for OpenSSH; Windows cannot run it as `ssh` from PATH.
|
||||
if (process.platform === "win32") {
|
||||
console.log("Skipped on Windows\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const SSH_SETUP_MS = 2500;
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-status-ssh-"));
|
||||
let daemon: Awaited<ReturnType<typeof startTestDaemon>> | undefined;
|
||||
|
||||
try {
|
||||
daemon = await startTestDaemon();
|
||||
// Opens the -W stream only after the delay a real SSH handshake takes.
|
||||
await writeFile(
|
||||
join(root, "ssh"),
|
||||
`#!${process.execPath}
|
||||
const net = require("node:net");
|
||||
const args = process.argv.slice(2);
|
||||
const [host, port] = args[args.indexOf("-W") + 1].split(":");
|
||||
setTimeout(() => {
|
||||
const socket = net.connect(Number(port), host);
|
||||
process.stdin.pipe(socket);
|
||||
socket.pipe(process.stdout);
|
||||
socket.on("close", () => process.exit(0));
|
||||
}, ${SSH_SETUP_MS});
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
|
||||
console.log("Test 1: status reaches a daemon whose SSH stream takes 2.5s to open");
|
||||
const result = await runLocalPaseo(
|
||||
["daemon", "status", "--host", `ssh://build-box?daemonPort=${daemon.port}`, "--json"],
|
||||
{ PATH: `${root}${delimiter}${process.env.PATH ?? ""}` },
|
||||
);
|
||||
|
||||
assert.strictEqual(result.exitCode, 0, result.stderr);
|
||||
const status = JSON.parse(result.stdout);
|
||||
assert.strictEqual(status.connectedDaemon, "reachable", JSON.stringify(status));
|
||||
assert.strictEqual(typeof status.serverId, "string");
|
||||
console.log("✓ status waits for the SSH stream like other commands\n");
|
||||
} finally {
|
||||
await daemon?.stop();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("=== Daemon Status over SSH Tests Passed ===");
|
||||
@@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => {
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
},
|
||||
);
|
||||
it("preserves manifest metadata in plugin list responses", () => {
|
||||
const message = {
|
||||
type: "plugin.list.response",
|
||||
payload: {
|
||||
requestId: "metadata",
|
||||
plugins: [
|
||||
{
|
||||
id: "review",
|
||||
name: "Review tools",
|
||||
icon: "assets/icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
path: "/plugins/review",
|
||||
enabled: false,
|
||||
status: "disabled",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
});
|
||||
|
||||
it("parses plugin timeline append messages and advertises the capability", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
|
||||
@@ -6632,6 +6632,9 @@ export const PluginNpmInstallationSchema = z.object({
|
||||
|
||||
export const PluginListItemSchema = z.object({
|
||||
id: PluginIdSchema,
|
||||
name: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
media: z.array(z.string()).optional(),
|
||||
description: z.string().optional(),
|
||||
path: z.string(),
|
||||
enabled: z.boolean(),
|
||||
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
// Reproduction for #6006. Needs an OpenCode 2.x binary in OPENCODE_V2_BIN.
|
||||
const OPENCODE_V2_BIN = process.env.OPENCODE_V2_BIN;
|
||||
|
||||
const PROVIDER = "paseo-catalog-drift";
|
||||
const KEPT_MODEL = "kept-model";
|
||||
const RETIRED_MODEL = "retired-model";
|
||||
|
||||
function catalog(modelIds: string[]) {
|
||||
return {
|
||||
[PROVIDER]: {
|
||||
id: PROVIDER,
|
||||
env: ["PASEO_CATALOG_DRIFT_KEY"],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: "http://127.0.0.1:9/v1",
|
||||
name: "Catalog drift",
|
||||
models: Object.fromEntries(
|
||||
modelIds.map((id) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
name: id,
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
temperature: true,
|
||||
release_date: "2025-06-17",
|
||||
last_updated: "2025-06-17",
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
open_weights: false,
|
||||
limit: { context: 128_000, output: 8_000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.runIf(OPENCODE_V2_BIN)(
|
||||
"a model OpenCode dropped from its catalog stays in the picker and fails to apply",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "opencode-v2-catalog-drift-")));
|
||||
const modelsPath = path.join(root, "models.json");
|
||||
const cwd = path.join(root, "work");
|
||||
mkdirSync(cwd);
|
||||
writeFileSync(modelsPath, JSON.stringify(catalog([KEPT_MODEL, RETIRED_MODEL])));
|
||||
const provider = new OpenCodeV2AgentClient({
|
||||
logger,
|
||||
settings: {
|
||||
command: { mode: "replace", argv: [OPENCODE_V2_BIN!] },
|
||||
env: {
|
||||
OPENCODE_MODELS_PATH: modelsPath,
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
||||
OPENCODE_DISABLE_AUTO_UPDATE: "1",
|
||||
PASEO_CATALOG_DRIFT_KEY: "test",
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
},
|
||||
},
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({ agentClients: { opencode: provider }, logger });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const before = await client.listProviderModels("opencode", { cwd });
|
||||
expect(before.models?.map((entry) => entry.id)).toContain(`${PROVIDER}/${RETIRED_MODEL}`);
|
||||
|
||||
// OpenCode's catalog drops the model while Paseo sits idle.
|
||||
writeFileSync(modelsPath, JSON.stringify(catalog([KEPT_MODEL])));
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "catalog drift",
|
||||
model: `${PROVIDER}/${KEPT_MODEL}`,
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
const listed = await client.listProviderModels("opencode", { cwd });
|
||||
const offered = listed.models?.map((entry) => entry.id) ?? [];
|
||||
let switchError: string | null = null;
|
||||
if (offered.includes(`${PROVIDER}/${RETIRED_MODEL}`)) {
|
||||
try {
|
||||
await client.setAgentModel(agent.id, `${PROVIDER}/${RETIRED_MODEL}`);
|
||||
} catch (error) {
|
||||
switchError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
// Every model the picker offers must be one OpenCode accepts.
|
||||
expect({ offered, switchError }).toEqual({
|
||||
offered: [`${PROVIDER}/${KEPT_MODEL}`],
|
||||
switchError: null,
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
@@ -419,7 +419,7 @@ describe("PluginService", () => {
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
|
||||
it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => {
|
||||
it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const running = await createPlugin("running", "export default () => () => {};");
|
||||
@@ -427,7 +427,13 @@ describe("PluginService", () => {
|
||||
const malformed = await createPlugin("malformed", "export default () => () => {};");
|
||||
await writeFile(
|
||||
path.join(running, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "running", description: "Runs checks" }),
|
||||
JSON.stringify({
|
||||
id: "running",
|
||||
description: "Runs checks",
|
||||
name: "Checks",
|
||||
icon: "icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(disabled, "paseo-plugin.json"),
|
||||
@@ -443,11 +449,23 @@ describe("PluginService", () => {
|
||||
await service.start();
|
||||
|
||||
expect(
|
||||
(await service.listPlugins()).map(({ id, description }) => ({ id, description })),
|
||||
(await service.listPlugins()).map(({ id, description, name, icon, media }) => ({
|
||||
id,
|
||||
description,
|
||||
name,
|
||||
icon,
|
||||
media,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "disabled", description: "Waits until enabled" },
|
||||
{ id: "malformed", description: undefined },
|
||||
{ id: "running", description: "Runs checks" },
|
||||
{
|
||||
id: "running",
|
||||
description: "Runs checks",
|
||||
name: "Checks",
|
||||
icon: "icon.png",
|
||||
media: ["screenshot.png", "https://example.com/demo.mp4"],
|
||||
},
|
||||
]);
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
@@ -216,7 +216,12 @@ export class PluginService {
|
||||
}),
|
||||
};
|
||||
const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
|
||||
if (manifest?.description) item.description = manifest.description;
|
||||
if (manifest) {
|
||||
item.name = manifest.name;
|
||||
item.description = manifest.description;
|
||||
item.icon = manifest.icon;
|
||||
item.media = manifest.media;
|
||||
}
|
||||
item.installation = await this.managedSources
|
||||
?.describe(id, source.path)
|
||||
.catch(() => undefined);
|
||||
|
||||
@@ -24,6 +24,69 @@ describe("plugin manifest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads display metadata and ignores future top-level fields", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
const manifest = {
|
||||
id: "review",
|
||||
name: "Review tools",
|
||||
icon: "assets/icon.png",
|
||||
media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"],
|
||||
};
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ ...manifest, futureField: { enabled: true } }),
|
||||
);
|
||||
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: " " },
|
||||
{ name: 42 },
|
||||
{ icon: "" },
|
||||
{ icon: 42 },
|
||||
{ icon: "icon.svg" },
|
||||
{ icon: "/icon.png" },
|
||||
{ icon: "../icon.png" },
|
||||
{ icon: "assets/../../icon.png" },
|
||||
{ icon: "https://example.com/icon.png" },
|
||||
{ icon: "C:/icon.png" },
|
||||
{ icon: "assets\\icon.png" },
|
||||
{ icon: "icon.png?query" },
|
||||
{ media: "screenshot.png" },
|
||||
{ media: [42] },
|
||||
{ media: [""] },
|
||||
{ media: ["../demo.mp4"] },
|
||||
{ media: ["/demo.mp4"] },
|
||||
{ media: ["http://example.com/demo.mp4"] },
|
||||
{ media: ["https://"] },
|
||||
{ media: ["//example.com/demo.mp4"] },
|
||||
{ media: ["file:///demo.mp4"] },
|
||||
{ media: ["data:image/png;base64,abc"] },
|
||||
{ media: ["."] },
|
||||
{ media: ["assets/"] },
|
||||
{ id: "INVALID" },
|
||||
{ description: 42 },
|
||||
{ requirements: { paseoo: ">=0.11.0" } },
|
||||
])("rejects invalid known fields: %j", async (fields) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "example", futureField: true, ...fields }),
|
||||
);
|
||||
await expect(readPluginManifest(directory)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("accepts omitted metadata and empty media", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) {
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest));
|
||||
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads and validates requirements before any plugin code runs", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
|
||||
@@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json";
|
||||
const PluginBuildCommandSchema = z
|
||||
.array(z.string().refine((argument) => argument.trim().length > 0))
|
||||
.min(1);
|
||||
const PluginManifestSchema = z
|
||||
.object({
|
||||
id: PluginIdSchema,
|
||||
description: z.string().trim().min(1).optional(),
|
||||
requirements: PluginRequirementsSchema.strict().optional(),
|
||||
build: z.array(PluginBuildCommandSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
// Manifest asset paths use forward slashes and stay inside the plugin package.
|
||||
const PluginAssetPathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) =>
|
||||
value === value.trim() &&
|
||||
!value.startsWith("/") &&
|
||||
!/[\\:?#\p{Cc}]/u.test(value) &&
|
||||
!value.split("/").includes("..") &&
|
||||
!value.endsWith("/") &&
|
||||
value.split("/").at(-1) !== ".",
|
||||
"Expected a relative file path inside the plugin package",
|
||||
);
|
||||
const PluginManifestSchema = z.object({
|
||||
id: PluginIdSchema,
|
||||
name: z.string().trim().min(1).optional(),
|
||||
description: z.string().trim().min(1).optional(),
|
||||
icon: PluginAssetPathSchema.refine(
|
||||
(value) => /\.png$/i.test(value),
|
||||
"Expected a relative path to a PNG",
|
||||
).optional(),
|
||||
media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(),
|
||||
// A misspelled requirement must not silently disable compatibility checks.
|
||||
requirements: PluginRequirementsSchema.strict().optional(),
|
||||
build: z.array(PluginBuildCommandSchema).min(1).optional(),
|
||||
});
|
||||
|
||||
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
|
||||
|
||||
|
||||
@@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as
|
||||
|
||||
session.send({ type: "input", data: `${command}\r` });
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("shell-ok");
|
||||
await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok");
|
||||
});
|
||||
|
||||
it("lists subdirectory terminals when querying the workspace root", async () => {
|
||||
|
||||
@@ -8,6 +8,13 @@ async function openPlugins(page: Page) {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
|
||||
}
|
||||
|
||||
function pluginCards(page: Page, section: string): Locator {
|
||||
return page
|
||||
.getByRole("region", { name: section })
|
||||
.getByRole("link")
|
||||
.filter({ hasNotText: /^See all$/ });
|
||||
}
|
||||
|
||||
test("browses from the directory into a category, a plugin, and its author", async ({
|
||||
page,
|
||||
context,
|
||||
@@ -508,11 +515,7 @@ test.describe("registry fixture layout", () => {
|
||||
await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured");
|
||||
const featured = page.getByRole("region", { name: "Featured" });
|
||||
await expect(featured).toContainText("A selection of hand picked plugins");
|
||||
await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([
|
||||
/Dracula/,
|
||||
/Herald/,
|
||||
/launchd Jobs/,
|
||||
]);
|
||||
await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]);
|
||||
});
|
||||
|
||||
test("lists the nine categories in order with counts, and the newest plugins first", async ({
|
||||
@@ -524,9 +527,12 @@ test.describe("registry fixture layout", () => {
|
||||
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
|
||||
);
|
||||
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
|
||||
await expect(
|
||||
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }),
|
||||
).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]);
|
||||
await expect(pluginCards(page, "What’s new")).toHaveText([
|
||||
/Base2Tone/,
|
||||
/Sayr/,
|
||||
/PromptKit/,
|
||||
/Defer/,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode, useMemo } from "react";
|
||||
import { type Author, authorAvatarUrl } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
xs: "h-4 w-4 text-[8px]",
|
||||
sm: "h-5 w-5 text-[10px]",
|
||||
lg: "h-14 w-14 text-xl",
|
||||
} as const;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SiteShell } from "~/components/site-shell";
|
||||
import { ContributeLinks } from "./contribute-links";
|
||||
import type { InstallCounts } from "./installs";
|
||||
import { type BrowseQuery, browseHref } from "./links";
|
||||
import { InstallCount } from "./install-count";
|
||||
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
|
||||
import { PluginSearch } from "./plugin-search";
|
||||
import { PluginSection } from "./section";
|
||||
@@ -46,10 +47,11 @@ export function BrowsePage({
|
||||
query: BrowseQuery;
|
||||
}) {
|
||||
const category = query.category ? getCategory(query.category) : null;
|
||||
const matches = query.q ? searchPlugins(plugins, query.q) : plugins;
|
||||
const scoped = category ? getPluginsInCategory(matches, category.slug) : matches;
|
||||
const results =
|
||||
query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window);
|
||||
const ordered =
|
||||
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
|
||||
// Search ranks by relevance; the tab's order breaks ties.
|
||||
const matches = query.q ? searchPlugins(ordered, query.q) : ordered;
|
||||
const results = category ? getPluginsInCategory(matches, category.slug) : matches;
|
||||
const title = category?.label ?? "All plugins";
|
||||
const clearHref = browseHref({ ...query, q: undefined });
|
||||
return (
|
||||
@@ -64,7 +66,7 @@ export function BrowsePage({
|
||||
<h1 className="text-3xl font-medium tracking-tight">
|
||||
{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title}
|
||||
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
|
||||
{scoped.length}
|
||||
{results.length}
|
||||
</span>
|
||||
</h1>
|
||||
{query.q && (
|
||||
@@ -93,13 +95,13 @@ export function BrowsePage({
|
||||
<div className={PLUGIN_GRID_CLASS}>
|
||||
{results.map((plugin) =>
|
||||
query.sort === "new" ? (
|
||||
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
|
||||
<PluginCard key={plugin.id} plugin={plugin}>
|
||||
{addedAgo(plugin, now)}
|
||||
</PluginCard>
|
||||
) : (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
installs={installs[plugin.id]?.[query.window] ?? 0}
|
||||
/>
|
||||
<PluginCard key={plugin.id} plugin={plugin}>
|
||||
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
|
||||
</PluginCard>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,21 @@ export function ContributeLinks() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Closing section of the directory: what a plugin can do, with Read the docs and Submit buttons. */
|
||||
/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */
|
||||
export function ContributeButtons() {
|
||||
return (
|
||||
<>
|
||||
<a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}>
|
||||
Submit a plugin
|
||||
</a>
|
||||
<a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}>
|
||||
Read the docs
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */
|
||||
export function ContributeSection() {
|
||||
return (
|
||||
<section
|
||||
@@ -36,12 +50,7 @@ export function ContributeSection() {
|
||||
with the plugin docs, then submit it to get it listed here.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
|
||||
Read the docs
|
||||
</a>
|
||||
<a href={SUBMIT_URL} className={SECONDARY_BUTTON_CLASS}>
|
||||
Submit a plugin
|
||||
</a>
|
||||
<ContributeButtons />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ContributeButtons } from "./contribute-links";
|
||||
import type { BrowseQuery } from "./links";
|
||||
import { PluginSearch } from "./plugin-search";
|
||||
|
||||
interface PluginsHeroProps {
|
||||
pluginCount: number;
|
||||
authorCount: number;
|
||||
/** Where the search box sends its term. */
|
||||
searchScope: BrowseQuery;
|
||||
}
|
||||
|
||||
function plural(count: number, word: string): string {
|
||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */
|
||||
export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) {
|
||||
return (
|
||||
<header>
|
||||
<h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-3xl font-medium tracking-tight">Plugins</span>
|
||||
<span className="text-sm font-normal tabular-nums text-extra-muted-foreground">
|
||||
{plural(pluginCount, "plugin")} by {plural(authorCount, "author")}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||
Browse community plugins that extend Paseo with new functionality.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ContributeButtons />
|
||||
</div>
|
||||
<PluginSearch scope={searchScope} className="w-full sm:w-64" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { formatInstalls } from "./registry";
|
||||
|
||||
/** An install count as a download icon and the number; inherits the surrounding text style. */
|
||||
export function InstallCount({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 tabular-nums">
|
||||
<Download aria-hidden className="h-3 w-3" />
|
||||
{formatInstalls(count)}
|
||||
<span className="sr-only">installs</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Download } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthorAvatar } from "./author-link";
|
||||
import { InstallCount } from "./install-count";
|
||||
import { pluginCardScreenshot } from "./thumbnails";
|
||||
import { pluginHref } from "./links";
|
||||
import { PluginTile } from "./plugin-tile";
|
||||
import { firstMediaImage, formatInstalls, getCategory, type Plugin } from "./registry";
|
||||
import { firstMediaImage, getAuthor, 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";
|
||||
@@ -32,57 +34,49 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** What's new card: screenshot, name, and when it was added. */
|
||||
export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) {
|
||||
/**
|
||||
* What's new and Featured card, in three rows under the screenshot: tile and name, description,
|
||||
* then author and `children`, such as when the plugin was added or its install count.
|
||||
*/
|
||||
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
|
||||
const author = getAuthor(plugin);
|
||||
return (
|
||||
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
|
||||
<PluginShot plugin={plugin} />
|
||||
<div className="mt-3 flex items-center gap-2.5">
|
||||
<PluginTile plugin={plugin} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
<p className="truncate text-xs text-extra-muted-foreground">Added {added}</p>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<PluginTile plugin={plugin} size="xs" />
|
||||
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
|
||||
<AuthorAvatar author={author} size="xs" />
|
||||
<span className="truncate">{author.name}</span>
|
||||
{children !== undefined && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="flex-shrink-0">{children}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grid card: screenshot, name, one muted stat, and a two-line description. */
|
||||
export function PluginCard({
|
||||
plugin,
|
||||
installs,
|
||||
added,
|
||||
}: {
|
||||
plugin: Plugin;
|
||||
/** Shown with a download icon; otherwise `added` is shown. */
|
||||
installs?: number;
|
||||
added?: string;
|
||||
}) {
|
||||
/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */
|
||||
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
|
||||
return (
|
||||
<a href={pluginHref(plugin.id)} className="group block">
|
||||
<PluginShot plugin={plugin} />
|
||||
<div className="mt-3 flex items-start gap-2.5">
|
||||
<PluginTile plugin={plugin} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-1 text-xs tabular-nums text-extra-muted-foreground">
|
||||
{installs === undefined ? (
|
||||
added
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
{formatInstalls(installs)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<PluginTile plugin={plugin} size="xs" />
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p>
|
||||
<span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +104,9 @@ export function PluginRankRow({
|
||||
{getCategory(plugin.categories[0])?.label}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<InstallCount count={installs} />
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import type { Plugin } from "./registry";
|
||||
|
||||
const SIZE_CLASS = {
|
||||
xs: "h-6 w-6 rounded-md text-xs",
|
||||
sm: "h-9 w-9 rounded-lg text-sm",
|
||||
lg: "h-14 w-14 rounded-xl text-xl",
|
||||
} as const;
|
||||
|
||||
@@ -228,6 +228,32 @@ describe("plugin registry", () => {
|
||||
expect(searchPlugins(plugins, " ")).toEqual(plugins);
|
||||
expect(searchPlugins(plugins, "nothing")).toEqual([]);
|
||||
});
|
||||
it("ranks name, then author, then description matches, keeping the given order on ties", () => {
|
||||
const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({
|
||||
...plugin,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
author: { github },
|
||||
});
|
||||
// Given in install order: description-only matches first, the exact name last.
|
||||
const plugins = [
|
||||
named("acme/editor", "Remote Editor", "Composer pill to open a workspace"),
|
||||
named("acme/kit", "PromptKit", "Rewrite prompts"),
|
||||
named("omp/tools", "Tools", "Helpers", "omp"),
|
||||
named("acme/history", "History", "Inspect raw composer records"),
|
||||
named("acme/omp", "OMP", "Paseo integration for OMP"),
|
||||
];
|
||||
expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([
|
||||
"acme/omp",
|
||||
"acme/kit",
|
||||
"omp/tools",
|
||||
"acme/editor",
|
||||
"acme/history",
|
||||
]);
|
||||
expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]);
|
||||
expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]);
|
||||
});
|
||||
|
||||
it("strips the README title and quoted description that the page already shows", () => {
|
||||
expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { z } from "zod";
|
||||
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
|
||||
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
|
||||
import { scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import { CATEGORIES, type Category, type CategorySlug } from "./categories";
|
||||
import type { InstallCounts, InstallWindow } from "./installs";
|
||||
export { CATEGORIES, type Category, type CategorySlug };
|
||||
@@ -66,16 +67,39 @@ export function formatInstalls(count: number): string {
|
||||
const k = count / 1000;
|
||||
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
|
||||
}
|
||||
/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */
|
||||
/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */
|
||||
function searchFieldGroups(plugin: Plugin): string[][] {
|
||||
const author = getAuthor(plugin);
|
||||
const name = [plugin.name, plugin.id.split("/")[1]];
|
||||
const owner = [author.name, author.github, plugin.id];
|
||||
return [name, owner, [plugin.description], [...name, ...owner, plugin.description]];
|
||||
}
|
||||
interface SearchRank {
|
||||
field: number;
|
||||
tier: number;
|
||||
}
|
||||
function searchRank(plugin: Plugin, term: string): SearchRank | null {
|
||||
const groups = searchFieldGroups(plugin);
|
||||
for (let field = 0; field < groups.length; field += 1) {
|
||||
const score = scoreTextFields(term, groups[field], { subsequence: false });
|
||||
if (score) return { field, tier: score.tier };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Plugins whose name, author, ID, or description contains every word of the term, ignoring case.
|
||||
* Name matches come first, then author, then description; within each, exact words beat partial
|
||||
* ones, and equal matches keep the given order.
|
||||
*/
|
||||
export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
|
||||
const needle = term.trim().toLowerCase();
|
||||
if (!needle) return plugins;
|
||||
return plugins.filter((plugin) =>
|
||||
[plugin.name, plugin.description, plugin.id, getAuthor(plugin).name]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(needle),
|
||||
);
|
||||
if (!term.trim()) return plugins;
|
||||
return plugins
|
||||
.flatMap((plugin) => {
|
||||
const rank = searchRank(plugin, term);
|
||||
return rank ? [{ plugin, rank }] : [];
|
||||
})
|
||||
.sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier)
|
||||
.map(({ plugin }) => plugin);
|
||||
}
|
||||
/** The featured IDs that name a listed plugin, in the registry's order, each once. */
|
||||
export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] {
|
||||
|
||||
@@ -56,20 +56,20 @@ describe("plugin card thumbnails", () => {
|
||||
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"');
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
["PluginCard", () => createElement(PluginCard, { plugin }, "today")],
|
||||
["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")],
|
||||
])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => {
|
||||
const html = renderToStaticMarkup(card());
|
||||
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(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "~/plugins";
|
||||
import { AuthorAvatar } from "~/plugins/author-link";
|
||||
import { PluginsNotFound } from "~/plugins/not-found";
|
||||
import { InstallCount } from "~/plugins/install-count";
|
||||
import { PluginCard } from "~/plugins/plugin-card";
|
||||
import "~/styles.css";
|
||||
|
||||
@@ -82,7 +83,9 @@ function AuthorPage() {
|
||||
|
||||
<div className="mt-12 grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} />
|
||||
<PluginCard key={plugin.id} plugin={plugin}>
|
||||
<InstallCount count={installs[plugin.id]?.all ?? 0} />
|
||||
</PluginCard>
|
||||
))}
|
||||
</div>
|
||||
</SiteShell>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SiteShell } from "~/components/site-shell";
|
||||
import { pageMeta } from "~/meta";
|
||||
import {
|
||||
firstMediaImage,
|
||||
formatInstalls,
|
||||
getAuthor,
|
||||
getCategory,
|
||||
getRegistry,
|
||||
@@ -16,7 +15,8 @@ import {
|
||||
pluginVersion,
|
||||
readmeBody,
|
||||
} from "~/plugins";
|
||||
import { AuthorLink } from "~/plugins/author-link";
|
||||
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
|
||||
import { InstallCount } from "~/plugins/install-count";
|
||||
import { categoryHref } from "~/plugins/links";
|
||||
import { MediaGallery } from "~/plugins/media-gallery";
|
||||
import { PluginsNotFound } from "~/plugins/not-found";
|
||||
@@ -48,6 +48,8 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
|
||||
|
||||
const META_LINK_CLASS =
|
||||
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
|
||||
const AUTHOR_LINK_CLASS =
|
||||
"inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
|
||||
|
||||
function PluginPage() {
|
||||
const { plugin, installs } = Route.useLoaderData();
|
||||
@@ -57,43 +59,42 @@ function PluginPage() {
|
||||
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<div className="max-w-3xl">
|
||||
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Plugins
|
||||
</a>
|
||||
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
|
||||
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
|
||||
<div className="mt-6">
|
||||
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
|
||||
<AuthorLink author={author} className={META_LINK_CLASS}>
|
||||
{author.name}
|
||||
</AuthorLink>
|
||||
{category && (
|
||||
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
|
||||
{category.label}
|
||||
</a>
|
||||
)}
|
||||
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
|
||||
<span className="font-mono">{pluginVersion(plugin)}</span>
|
||||
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
|
||||
Source
|
||||
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Plugins
|
||||
</a>
|
||||
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
|
||||
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
|
||||
<div className="mt-6 w-fit max-w-full">
|
||||
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
|
||||
<AuthorLink author={author} className={AUTHOR_LINK_CLASS}>
|
||||
<AuthorAvatar author={author} size="xs" />
|
||||
{author.name}
|
||||
</AuthorLink>
|
||||
{category && (
|
||||
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
|
||||
{category.label}
|
||||
</a>
|
||||
)}
|
||||
<InstallCount count={installs} />
|
||||
<span className="font-mono">{pluginVersion(plugin)}</span>
|
||||
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
|
||||
Source
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
{npm && (
|
||||
<PluginContentLink href={npm} className={META_LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
{npm && (
|
||||
<PluginContentLink href={npm} className={META_LINK_CLASS}>
|
||||
npm
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</PluginContentLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaGallery name={plugin.name} media={plugin.media} />
|
||||
<MediaGallery name={plugin.name} media={plugin.media} />
|
||||
|
||||
<div className="mt-10 border-t border-white/10 pt-10">
|
||||
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
|
||||
</div>
|
||||
<div className="mt-10 border-t border-white/10 pt-10">
|
||||
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
type InstallWindow,
|
||||
mostInstalled,
|
||||
newestFirst,
|
||||
pluginOwner,
|
||||
} from "~/plugins";
|
||||
import { ContributeSection } from "~/plugins/contribute-links";
|
||||
import { PluginsHero } from "~/plugins/hero";
|
||||
import {
|
||||
type BrowseQuery,
|
||||
browseHref,
|
||||
@@ -37,10 +39,9 @@ import {
|
||||
parseSearchTerm,
|
||||
parseSort,
|
||||
parseWindow,
|
||||
SUBMIT_URL,
|
||||
} from "~/plugins/links";
|
||||
import { InstallCount } from "~/plugins/install-count";
|
||||
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
|
||||
import { PluginSearch } from "~/plugins/plugin-search";
|
||||
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
|
||||
import { WindowSwitch } from "~/plugins/window-switch";
|
||||
import "~/styles.css";
|
||||
@@ -60,7 +61,6 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
|
||||
utils: Wrench,
|
||||
};
|
||||
|
||||
const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
|
||||
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
|
||||
const CARD_ROW_CLASS =
|
||||
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
|
||||
@@ -110,45 +110,37 @@ function PluginsPage() {
|
||||
[],
|
||||
);
|
||||
const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]);
|
||||
const authorCount = new Set(plugins.map(pluginOwner)).size;
|
||||
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl font-medium tracking-tight">
|
||||
Plugins
|
||||
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
|
||||
{plugins.length}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<a href={SUBMIT_URL} className={SUBMIT_CLASS}>
|
||||
Submit a plugin
|
||||
</a>
|
||||
<PluginSearch scope={searchScope} className="w-full sm:w-56" />
|
||||
</div>
|
||||
</div>
|
||||
<PluginsHero
|
||||
pluginCount={plugins.length}
|
||||
authorCount={authorCount}
|
||||
searchScope={searchScope}
|
||||
/>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-14">
|
||||
<div className="mt-16 flex flex-col gap-14">
|
||||
{featured.length > 0 && (
|
||||
<PluginSection labelledBy="featured">
|
||||
<PluginSectionHeader>
|
||||
<div>
|
||||
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
A selection of hand picked plugins
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">A selection of hand picked plugins</p>
|
||||
</div>
|
||||
<a
|
||||
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
|
||||
className={SEE_ALL_CLASS}
|
||||
>
|
||||
See all plugins
|
||||
See all
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</PluginSectionHeader>
|
||||
<div className={CARD_ROW_CLASS}>
|
||||
{featured.map((plugin) => (
|
||||
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
|
||||
<NewPluginCard key={plugin.id} plugin={plugin}>
|
||||
<InstallCount count={installs[plugin.id]?.all ?? 0} />
|
||||
</NewPluginCard>
|
||||
))}
|
||||
</div>
|
||||
</PluginSection>
|
||||
@@ -164,7 +156,9 @@ function PluginsPage() {
|
||||
</PluginSectionHeader>
|
||||
<div className={CARD_ROW_CLASS}>
|
||||
{newest.map((plugin) => (
|
||||
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
|
||||
<NewPluginCard key={plugin.id} plugin={plugin}>
|
||||
{addedAgo(plugin, now)}
|
||||
</NewPluginCard>
|
||||
))}
|
||||
</div>
|
||||
</PluginSection>
|
||||
|
||||
@@ -152,6 +152,39 @@ In the app, enter only the source identifier.
|
||||
|
||||
:::
|
||||
|
||||
## Icons and screenshots
|
||||
|
||||
Declare your display name, icon, screenshots, and demo videos in `paseo-plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "review-tools",
|
||||
"name": "Review tools",
|
||||
"description": "Reviews changes before merge",
|
||||
"icon": "assets/icon.png",
|
||||
"media": ["assets/screenshot.png", "https://example.com/review-demo.mp4"],
|
||||
"requirements": { "paseo": ">=0.11.0" }
|
||||
}
|
||||
```
|
||||
|
||||
All three fields are optional. `name` gives the registry and website a display name without
|
||||
changing the installation ID. When omitted, the registry falls back to a humanized ID.
|
||||
Registry and website display requires the registry builder to read these fields from the pinned
|
||||
manifest. That update is a separate follow-up; until it lands, these fields are exposed only
|
||||
through the daemon's installed-plugin list.
|
||||
`icon` must point to a PNG inside the plugin package. `media` accepts paths to images and videos
|
||||
inside the package or HTTPS URLs, in display order. Paths are relative to the manifest; use
|
||||
forward slashes and keep them inside the package. See the [manifest reference](/docs/plugins/reference#project-files)
|
||||
for validation rules.
|
||||
|
||||
Commit local assets alongside the manifest at the pinned revision. For npm, add their directory
|
||||
(for example, `assets/`) to `package.json`'s `files` list and verify it with `npm pack --dry-run`.
|
||||
The scaffold documents these fields in its manifest's `$comment`; add your display name and
|
||||
assets before publishing.
|
||||
|
||||
Manifests using these fields fail to install on daemons before 0.11.0. Set
|
||||
`requirements.paseo` to `>=0.11.0` or a narrower supported range.
|
||||
|
||||
## Your listing page
|
||||
|
||||
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
|
||||
@@ -170,8 +203,7 @@ The repository overview takes precedence over a registry import stopgap. Online
|
||||
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
|
||||
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
|
||||
requires a repository overview and removes the stopgap in the same PR. A bump without the
|
||||
repository overview fails validation. Neither README files nor a `paseo-listing.json`
|
||||
`readme` override supplies the overview.
|
||||
repository overview fails validation. README files do not supply the overview.
|
||||
|
||||
Author overviews and registry stopgaps follow the same content contract, in this order.
|
||||
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
|
||||
|
||||
@@ -52,12 +52,24 @@ The required root manifest is `paseo-plugin.json`:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Behavior |
|
||||
| -------------- | -------- | ---------------------------------------------------------------------- |
|
||||
| `id` | Yes | Default installation ID. |
|
||||
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
|
||||
| `requirements` | No | Supported Paseo versions, described below. |
|
||||
| `build` | No | Preparation commands, described in the CLI reference. |
|
||||
| Field | Required | Behavior |
|
||||
| -------------- | -------- | ----------------------------------------------------------------------------------------- |
|
||||
| `id` | Yes | Default installation ID. |
|
||||
| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. |
|
||||
| `icon` | No | Relative path to a PNG inside the plugin package. |
|
||||
| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
|
||||
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
|
||||
| `requirements` | No | Supported Paseo versions, described below. |
|
||||
| `build` | No | Preparation commands, described in the CLI reference. |
|
||||
|
||||
Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..`
|
||||
segments. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) for an example.
|
||||
Paseo validates the references without opening local assets or fetching URLs.
|
||||
|
||||
Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside
|
||||
`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check.
|
||||
Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject
|
||||
these fields during installation.
|
||||
|
||||
### Requirements
|
||||
|
||||
|
||||
@@ -657,8 +657,7 @@ The repository overview takes precedence over a registry import stopgap. Online
|
||||
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
|
||||
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
|
||||
requires a repository overview and removes the stopgap in the same PR. A bump without the
|
||||
repository overview fails validation. Neither README files nor a `paseo-listing.json`
|
||||
`readme` override supplies the overview.
|
||||
repository overview fails validation. README files do not supply the overview.
|
||||
|
||||
Author overviews and registry stopgaps follow the same content contract, in this order.
|
||||
Choose headings only when they help; length follows complexity. A theme needs one paragraph.
|
||||
|
||||
Reference in New Issue
Block a user