Ship the plugin directory redesign and keep registry installs behind a daemon flag (#6080)

* website: plugin page design mockups (four directions, throwaway routes)

* website: final plugin page mockup (A directory, B browse frame, list cards, D detail)

* website: browse pages use a four-column card grid in the final plugin mockup

* feat(plugins): keep registry installs behind a daemon flag, off by default

Off restores the pre-registry behavior: bare owner/repo is GitHub shorthand and the
daemon never resolves or contacts a registry on install, update preview, or update.
Enable with pluginRegistryEnabled: true or PASEO_PLUGIN_REGISTRY_ENABLED=1.

* feat(website): ship the plugin directory, browse pages, and single-column detail

Directory: count, build/submit links, What's new, the nine categories, and Most installed
with a This week / This month / All time switch. Browse pages at /plugins/all and
/plugins/category/<slug> with a category sidebar, tabs, and a four-column grid; old
/plugins?category= links redirect. Install counts gain daily KV buckets kept 60 days.

* fix(website): keep a README's leading GitHub alert on the plugin page

* feat(website): search plugins from the directory and browse pages

Typing replaces the URL with /plugins/all?q=<term> (or the current category
with ?q=), which renders matching plugins on the server. Matching covers
name, description, ID, and author. Old /plugins?q= links redirect.

* fix(website): keep the directory's ranking window when searching
This commit is contained in:
Mohamed Boudra
2026-10-04 20:38:39 +01:00
committed by GitHub
parent febe9611bf
commit c062280429
33 changed files with 1335 additions and 681 deletions
+10 -5
View File
@@ -117,7 +117,12 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
artifact, and GitHub shorthand requires `github:`. 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.
@@ -133,13 +138,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory
still wins over source resolution.
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
URL. An existing directory still wins over source resolution.
```bash
paseo plugin install github:owner/repository
paseo plugin install owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install github:owner/monorepo:plugins/review
paseo plugin install owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
+1 -1
View File
@@ -218,7 +218,7 @@ export function createPluginCommand(): Command {
.description("Trust and install a plugin from a directory, Git repository, or npm package")
.argument(
"<source>",
"Registry owner/slug or host/owner/slug, host directory, github:owner/repo, git: or npm: source",
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
)
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
+2
View File
@@ -414,6 +414,7 @@ export interface PaseoDaemonConfig {
plugins?: Record<string, PluginSource>;
pluginRegistries?: PluginRegistries;
pluginRegistryUrl?: string;
pluginRegistryEnabled?: boolean;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -624,6 +625,7 @@ export async function createPaseoDaemon(
usageSession: (id) => agentManager.usageSession(id),
},
managedSources: new ManagedPluginSources(config.paseoHome, {
enabled: config.pluginRegistryEnabled ?? false,
registries: config.pluginRegistries,
defaultUrl: config.pluginRegistryUrl,
}),
@@ -30,6 +30,7 @@ export const DAEMON_SETTING_ENV_KEYS = [
"PASEO_LOG_ROTATE_SIZE",
"PASEO_PASSWORD",
"PASEO_PLUGIN_REGISTRY",
"PASEO_PLUGIN_REGISTRY_ENABLED",
"PASEO_RELAY_ENABLED",
"PASEO_RELAY_ENDPOINT",
"PASEO_RELAY_PUBLIC_ENDPOINT",
@@ -211,6 +211,15 @@ test("loads private plugin registry settings through the configuration boundary"
});
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
expect(config.pluginRegistries).toEqual(pluginRegistries);
expect(config.pluginRegistryEnabled).toBe(false);
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }),
);
expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true);
expect(
loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled,
).toBe(false);
} finally {
await rm(home, { recursive: true, force: true });
}
+4
View File
@@ -628,6 +628,10 @@ export function resolveConfigFromPersisted(
plugins: persisted.plugins,
pluginRegistries: persisted.pluginRegistries,
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
pluginRegistryEnabled:
parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ??
persisted.pluginRegistryEnabled ??
false,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -308,6 +308,7 @@ export const PersistedConfigSchema = z
providers: ProvidersSchema.optional(),
pluginRegistries: PluginRegistriesSchema.optional(),
pluginRegistryEnabled: z.boolean().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
worktrees: WorktreesConfigSchema.optional(),
@@ -36,6 +36,25 @@ async function commitAll(repository: string, message: string): Promise<string> {
return stdout.trim();
}
/** Points https://github.com/fixture/repository.git at a local repository for the callback. */
async function withGitHubFixture(repository: string, run: () => Promise<void>): Promise<void> {
const overlay = {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`,
GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git",
};
const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]]));
Object.assign(process.env, overlay);
try {
await run();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
describe("managed Git plugin sources", () => {
it("does not expose Git URL credentials when cloning fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-"));
@@ -52,14 +71,7 @@ describe("managed Git plugin sources", () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-"));
roots.push(home);
const overlay = {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`,
GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git",
};
const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]]));
Object.assign(process.env, overlay);
try {
await withGitHubFixture(repository, async () => {
const sources = new ManagedPluginSources(home);
for (const source of ["github:fixture/repository", "git:fixture/repository"]) {
const candidate = await sources.prepareInstall({ source });
@@ -69,12 +81,7 @@ describe("managed Git plugin sources", () => {
});
await sources.discard(candidate);
}
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
}, 30_000);
it("offers current default HEAD after installing a tag", async () => {
@@ -273,6 +280,37 @@ describe("managed Git plugin sources", () => {
});
describe("registry plugin sources", () => {
it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-"));
roots.push(home);
const requests: Array<string | undefined> = [];
const server = createServer((req, res) => {
requests.push(req.url);
res.statusCode = 404;
res.end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
try {
await withGitHubFixture(repository, async () => {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
const candidate = await sources.prepareInstall({ source: "fixture/repository" });
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
await sources.discard(candidate);
});
expect(requests).toEqual([]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);
it("installs a bare id from a private registry and polls its pin without install intent", async () => {
const repository = await createRepository();
await mkdir(path.join(repository, "packages/example"), { recursive: true });
@@ -331,6 +369,7 @@ describe("registry plugin sources", () => {
const url = `http://${host}/internal`;
try {
const sources = new ManagedPluginSources(home, {
enabled: true,
defaultUrl: url,
registries: { [host]: { authorization: "Bearer test" } },
});
@@ -405,7 +444,7 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const options = { defaultUrl: `http://127.0.0.1:${address.port}` };
const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` };
try {
const sources = new ManagedPluginSources(home, options);
const candidate = await sources.place(
@@ -147,7 +147,9 @@ export class ManagedPluginSources {
input: InstallInput,
target?: PluginUpdateTarget,
): Promise<ManagedPluginCandidate> {
const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl);
const registry = this.registryOptions.enabled
? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl)
: null;
if (registry) {
if (input.ref || input.pluginPath)
throw new Error("Registry sources use their reviewed artifact path and revision");
@@ -173,7 +175,7 @@ export class ManagedPluginSources {
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
record = { kind: "npm" };
} else {
const remote = normalizeGitSource(input.source);
const remote = normalizeGitSource(input.source, !this.registryOptions.enabled);
sourceRoot = path.join(versionRoot, "checkout");
await clone(remote, sourceRoot);
const commit = await resolveRequestedRef(sourceRoot, input.ref);
@@ -225,7 +227,7 @@ export class ManagedPluginSources {
const location = this.locate(pluginId, configuredPath);
let target: PluginUpdateTarget;
let outcome: PluginUpdatePreview["outcome"];
if (current.identity.registry) {
if (this.registryOptions.enabled && current.identity.registry) {
if (selection) throw new Error("Registry updates use only the reviewed registry pin");
const resolved = await resolveRegistryPlugin(
current.identity.registry,
@@ -302,7 +304,7 @@ export class ManagedPluginSources {
await this.assertCurrent(proposal, configuredPath);
const identity = this.locate(proposal.id, configuredPath).identity;
const record = this.records[proposal.id];
if (identity.kind !== "directory" && identity.registry) {
if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) {
const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false);
assertRegistryArtifactIdentity(identity, resolved.artifact);
if (!isDeepStrictEqual(resolved.target, proposal.target))
@@ -434,8 +436,10 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s
}
return [];
}
function normalizeGitSource(source: string): string {
const explicit = source.startsWith("github:") || source.startsWith("git:");
/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */
function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string {
const expandsShorthand =
bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:");
if (source.startsWith("github:")) {
const shorthand = source.slice(7);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
@@ -446,7 +450,7 @@ function normalizeGitSource(source: string): string {
source = source.slice(4);
}
const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source);
if (github && explicit) {
if (github && expandsShorthand) {
const repository = github[2].replace(/\.git$/, "");
return `https://github.com/${github[1]}/${repository}.git`;
}
@@ -6,6 +6,8 @@ import {
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
export interface RegistryOptions {
/** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */
enabled?: boolean;
defaultUrl?: string;
registries?: PluginRegistries;
}
+140 -73
View File
@@ -2,9 +2,8 @@ import { expect, test, type Page } from "playwright/test";
import { CATEGORIES } from "../src/plugins/categories";
async function openPlugins(page: Page) {
// Wait for hydration so typing reaches React rather than the server-rendered input.
await page.goto("/plugins", { waitUntil: "networkidle" });
await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible();
await page.goto("/plugins");
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
}
test("browses from the directory into a category, a plugin, and its author", async ({
@@ -25,88 +24,122 @@ test("browses from the directory into a category, a plugin, and its author", asy
});
await openPlugins(page);
const themes = page.getByRole("region", { name: "Themes" });
await expect(themes.getByRole("link", { name: /Dracula/ })).toBeVisible();
await page.getByRole("region", { name: "Categories" }).getByRole("link", { name: /Git/ }).click();
await expect(page).toHaveURL(/\/plugins\/category\/git$/);
await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
await browseCategory(page, "Git");
await expect(page).toHaveURL(/category=/);
await expect(page.getByRole("heading", { level: 2 })).toHaveCount(1);
await expect(page.getByRole("region", { name: "Themes" })).toHaveCount(0);
await openPlugin(page, /Fresh Worktrees/);
await page.getByRole("link", { name: /Fresh Worktrees/ }).click();
await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/);
await expect(
page.getByRole("heading", { level: 1, name: "Fresh Worktrees", exact: true }),
).toBeVisible();
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
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 }),
).toBeVisible();
await copyInstallCommand(page);
await page.getByRole("button", { name: "Copy to clipboard" }).click();
await expect
.poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied))
.toBe("paseo plugin install omercnet/fresh-worktrees");
await expect(page.getByRole("link", { name: "Git", exact: true })).toHaveAttribute(
"href",
"/plugins/category/git",
);
const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" });
await expect(breadcrumbs).toContainText("Plugins");
await expect(breadcrumbs).toContainText("Git");
await expect(breadcrumbs).toContainText("Fresh Worktrees");
await openAuthor(page, "Omer Cohen");
await page.getByRole("link", { name: "Omer Cohen" }).click();
await expect(page).toHaveURL(/\/plugins\/omercnet$/);
await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible();
await expect(page.getByRole("link", { name: /Agent Monitor/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0);
});
test("filters by search and clears to the full directory", async ({ page }) => {
test("ranks plugins on browse pages by installs in a window or by newest", async ({ page }) => {
await page.goto("/plugins/all");
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute(
"aria-current",
"page",
);
await page.getByRole("link", { name: "This month" }).click();
await expect(page).toHaveURL(/\/plugins\/all\?window=month$/);
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
"aria-current",
"true",
);
await page.getByRole("link", { name: "Newest" }).click();
await expect(page).toHaveURL(/\/plugins\/all\?sort=new$/);
await expect(page.getByRole("navigation", { name: "Time window" })).toHaveCount(0);
await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText(
"ago",
);
});
test("filters by search and clears to all plugins", async ({ page }) => {
await page.goto("/");
await openPlugins(page);
await searchPlugins(page, "graphite");
await expect(page).toHaveURL(/q=graphite/);
await expect(page.getByRole("heading", { level: 2, name: /Results for/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Graphite/ })).toBeVisible();
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
await expect(
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
).toBeVisible();
await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
await searchPlugins(page, "zzzz-nothing");
await expect(page.getByText("No plugins match.")).toBeVisible();
await page.getByRole("link", { name: "Clear filters" }).click();
await expect(page).toHaveURL(/\/plugins\/?$/);
await expect(page.getByRole("region", { name: "Themes" })).toBeVisible();
await expect(page).toHaveURL(/\/plugins\/all$/);
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("");
});
test("keeps the directory's ranking window when searching", async ({ page }) => {
await page.goto("/plugins?window=month");
await searchPlugins(page, "graphite");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute(
"aria-current",
"true",
);
});
test("replaces history while typing a search", async ({ page }) => {
await page.goto("/");
await page.goto("/plugins/all");
await searchPlugins(page, "graphite");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
});
test("keeps old category links working", async ({ page }) => {
await page.goto("/plugins?category=git&sort=new");
await expect(page).toHaveURL(/\/plugins\/category\/git\?sort=new$/);
await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible();
});
test("explains a plugin that is not listed", async ({ page }) => {
await page.goto("/plugins/acme/does-not-exist");
await expect(page.getByRole("heading", { level: 1, name: "Plugin not found" })).toBeVisible();
await page.getByRole("link", { name: "Browse all plugins" }).click();
await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
});
async function copyInstallCommand(page: Page) {
await page.getByRole("button", { name: "Copy to clipboard" }).first().click();
}
async function browseCategory(page: Page, category: string) {
await page.getByRole("link", { name: category, exact: true }).click();
}
async function openPlugin(page: Page, name: RegExp) {
await page.getByRole("link", { name }).first().click();
}
async function openAuthor(page: Page, name: string) {
await page.getByRole("link", { name }).first().click();
}
async function searchPlugins(page: Page, query: string) {
await page.getByRole("searchbox", { name: "Search plugins" }).fill(query);
}
test.describe("search engine visits without JavaScript", () => {
test.use({ javaScriptEnabled: false });
test("reads directory, author, and complete plugin HTML with page metadata", async ({ page }) => {
test("reads directory, category, author, and complete plugin HTML with page metadata", async ({
page,
}) => {
await openPlugins(page);
await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible();
await expect(page.getByRole("link", { name: /Base2Tone/ }).first()).toBeVisible();
await expectPageMetadata(page, "Plugins – Extend Paseo with community plugins", "/plugins");
await page.goto("/plugins/category/git");
await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible();
await expectPageMetadata(page, "Git – Paseo plugins", "/plugins/category/git");
await page.goto("/plugins/omercnet");
await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible();
await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible();
@@ -143,7 +176,27 @@ test.describe("search engine visits without JavaScript", () => {
);
});
test("returns real 404 pages for unknown plugins and authors", async ({ page }) => {
test("renders search results and submits the search form", async ({ page }) => {
await page.goto("/plugins/all?q=graphite");
await expect(
page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }),
).toBeVisible();
await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible();
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
await page.goto("/plugins/category/git");
await searchPlugins(page, "fresh");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/plugins\/category\/git\?q=fresh$/);
await expect(
page.getByRole("heading", { level: 1, name: /^Results for “fresh” in Git/ }),
).toBeVisible();
await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible();
await page.getByRole("link", { name: "Clear", exact: true }).click();
await expect(page).toHaveURL(/\/plugins\/category\/git$/);
});
test("returns real 404 pages for unknown plugins, authors, and categories", async ({ page }) => {
const plugin = await page.goto("/plugins/acme/does-not-exist");
expect(plugin?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible();
@@ -152,9 +205,24 @@ test.describe("search engine visits without JavaScript", () => {
expect(author?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Author not found" })).toBeVisible();
await expect(page).toHaveTitle("Author not found – Paseo");
const category = await page.goto("/plugins/category/does-not-exist");
expect(category?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Category not found" })).toBeVisible();
await expect(page).toHaveTitle("Category not found – Paseo");
});
test("discovers plugin and author URLs through robots and the sitemap index", async ({
test("redirects old category links permanently", async ({ request }) => {
const response = await request.get("/plugins?category=git", { maxRedirects: 0 });
expect(response.status()).toBe(301);
expect(response.headers().location).toMatch(/\/plugins\/category\/git$/);
const search = await request.get("/plugins?q=graphite", { maxRedirects: 0 });
expect(search.status()).toBe(301);
expect(search.headers().location).toMatch(/\/plugins\/all\?q=graphite$/);
const windowed = await request.get("/plugins?q=graphite&window=month", { maxRedirects: 0 });
expect(windowed.headers().location).toMatch(/\/plugins\/all\?q=graphite&window=month$/);
});
test("discovers plugin, category, and author URLs through robots and the sitemap index", async ({
request,
}) => {
const robots = await request.get("/robots.txt");
@@ -164,13 +232,18 @@ test.describe("search engine visits without JavaScript", () => {
const plugins = await request.get("/sitemap-plugins.xml");
expect(plugins.status()).toBe(200);
expect(plugins.headers()["content-type"]).toContain("application/xml");
expect(await plugins.text()).toContain("<loc>https://paseo.sh/plugins/omercnet</loc>");
expect(await plugins.text()).toContain(
"<loc>https://paseo.sh/plugins/omercnet/fresh-worktrees</loc>",
);
const sitemap = await plugins.text();
expect(sitemap).toContain("<loc>https://paseo.sh/plugins/all</loc>");
expect(sitemap).toContain("<loc>https://paseo.sh/plugins/category/git</loc>");
expect(sitemap).toContain("<loc>https://paseo.sh/plugins/omercnet</loc>");
expect(sitemap).toContain("<loc>https://paseo.sh/plugins/omercnet/fresh-worktrees</loc>");
});
});
async function searchPlugins(page: Page, term: string) {
await page.getByRole("searchbox", { name: "Search plugins" }).fill(term);
}
async function expectPageMetadata(page: Page, title: string, path: string) {
await expect(page).toHaveTitle(title);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
@@ -210,30 +283,24 @@ test("keeps the directory unlinked until the coordinated announcement", async ({
await expect(page.locator('a[href="/plugins"]')).toHaveCount(0);
const response = await page.goto("/plugins");
expect(response?.status()).toBe(200);
await expect(page.getByRole("heading", { level: 1, name: "Plugins" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
});
// External deployments own their registry contents; these assertions use the local fixture.
test.describe("registry category layout", () => {
test.describe("registry fixture layout", () => {
test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture");
test("orders populated categories and hides the empty Extras category", async ({ page }) => {
test("lists the nine categories in order with counts, and the newest plugins first", async ({
page,
}) => {
await openPlugins(page);
await expect(page.getByRole("heading", { level: 2 })).toHaveText([
"Daemon management",
"Themes",
"Providers",
"Orchestration",
"Git",
"Workspaces",
"Sidebar",
"Utils",
]);
await expect(page.getByRole("region", { name: "Extras", exact: true })).toHaveCount(0);
for (const category of CATEGORIES.filter((entry) => entry.slug !== "extras")) {
await expect(page.getByRole("region", { name: category.label, exact: true })).toContainText(
category.description,
);
}
const categories = page.getByRole("region", { name: "Categories" });
await expect(categories.getByRole("link")).toHaveText(
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/]);
});
});
@@ -3,12 +3,17 @@ import { getAlternativePages } from "~/data/alternative-pages";
import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads";
interface SiteFooterProps {
width?: "default" | "prose";
width?: "default" | "prose" | "wide";
}
const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = {
prose: "max-w-prose p-6 md:p-12 md:pt-0",
default: "max-w-5xl p-6 md:p-20 md:pt-0",
wide: "max-w-7xl p-6 md:p-20 md:pt-0",
};
export function SiteFooter({ width = "default" }: SiteFooterProps) {
const widthClasses =
width === "prose" ? "max-w-prose p-6 md:p-12 md:pt-0" : "max-w-5xl p-6 md:p-20 md:pt-0";
const widthClasses = WIDTH_CLASSES[width];
const alternatives = getAlternativePages();
return (
<footer className={`${widthClasses} mx-auto`}>
@@ -4,12 +4,17 @@ import { SiteHeader } from "~/components/site-header";
interface SiteShellProps {
children: ReactNode;
width: "default" | "prose";
width: "default" | "prose" | "wide";
}
const MAIN_CLASSES: Record<SiteShellProps["width"], string> = {
prose: "max-w-prose p-6 md:p-12 mx-auto",
default: "max-w-5xl p-6 md:p-20 mx-auto",
wide: "max-w-7xl p-6 md:p-20 mx-auto",
};
export function SiteShell({ children, width }: SiteShellProps) {
const mainClasses =
width === "prose" ? "max-w-prose p-6 md:p-12 mx-auto" : "max-w-5xl p-6 md:p-20 mx-auto";
const mainClasses = MAIN_CLASSES[width];
return (
<div className="min-h-screen bg-background">
<main className={mainClasses}>
@@ -0,0 +1,214 @@
import { useMemo } from "react";
import { SiteShell } from "~/components/site-shell";
import { ContributeLinks } from "./contribute-links";
import type { InstallCounts } from "./installs";
import { type BrowseQuery, browseHref } from "./links";
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
import { PluginSearch } from "./plugin-search";
import {
addedAgo,
CATEGORIES,
type CategorySlug,
getCategory,
getPluginsInCategory,
mostInstalled,
newestFirst,
type Plugin,
searchPlugins,
} from "./registry";
import { WindowSwitch } from "./window-switch";
const NAV_BASE =
"flex items-center justify-between gap-3 rounded-md px-2.5 py-1.5 text-sm transition-colors";
const NAV_ON = `${NAV_BASE} bg-white/[0.06] text-white`;
const NAV_OFF = `${NAV_BASE} text-muted-foreground hover:text-foreground`;
const CHIP_BASE =
"inline-flex flex-shrink-0 items-center rounded-full border px-3 py-1.5 text-xs transition-colors";
const CHIP_ON = `${CHIP_BASE} border-white/20 bg-white/[0.07] text-white`;
const CHIP_OFF = `${CHIP_BASE} border-white/10 text-muted-foreground`;
const TAB_BASE = "-mb-px border-b pb-2 text-sm transition-colors";
const TAB_ON = `${TAB_BASE} border-white text-white`;
const TAB_OFF = `${TAB_BASE} border-transparent text-muted-foreground hover:text-foreground`;
const CLEAR_CLASS =
"text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const CONTRIBUTE_CLASS =
"text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground";
/** All plugins or one category: sidebar, Most installed / Newest tabs, and the card grid. */
export function BrowsePage({
plugins,
installs,
now,
query,
}: {
plugins: Plugin[];
installs: Record<string, InstallCounts>;
now: string;
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 title = category?.label ?? "All plugins";
const clearHref = browseHref({ ...query, q: undefined });
return (
<SiteShell width="wide">
<div className="lg:flex lg:gap-12">
<aside className="mb-8 lg:mb-0 lg:w-48 lg:flex-shrink-0">
<PluginSearch scope={query} className="mb-4 lg:mb-6" />
<CategoryNav plugins={matches} query={query} />
</aside>
<div className="min-w-0 flex-1">
<div className="mb-6 flex items-baseline justify-between gap-4">
<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}
</span>
</h1>
{query.q && (
<a href={clearHref} className={CLEAR_CLASS}>
Clear
</a>
)}
</div>
<SortTabs query={query} />
{results.length === 0 && query.q && (
<div className="mt-8 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<a
href={clearHref}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
</a>
</div>
)}
{results.length === 0 && !query.q && (
<p className="mt-8 text-sm text-muted-foreground">No plugins in this category yet.</p>
)}
{results.length > 0 && (
<div className={`mt-8 ${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}
installs={installs[plugin.id]?.[query.window] ?? 0}
/>
),
)}
</div>
)}
</div>
</div>
</SiteShell>
);
}
interface NavEntry {
href: string;
label: string;
chip: string;
/** Absent for all plugins. */
slug?: CategorySlug;
count: number;
}
/** Category sidebar on wide screens, a row of chips on narrow ones. Counts follow the search. */
function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery }) {
const entries: NavEntry[] = [
{
href: browseHref({ ...query, category: undefined }),
label: "All plugins",
chip: "All",
count: plugins.length,
},
...CATEGORIES.map((category) => ({
href: browseHref({ ...query, category: category.slug }),
label: category.label,
chip: category.label,
slug: category.slug,
count: getPluginsInCategory(plugins, category.slug).length,
})),
];
return (
<>
<nav aria-label="Categories" className="-ml-2.5 hidden lg:block">
{entries.map((entry, index) => (
<div key={entry.label}>
<a
href={entry.href}
aria-current={query.category === entry.slug ? "page" : undefined}
className={query.category === entry.slug ? NAV_ON : NAV_OFF}
>
{entry.label}
<span className="text-xs tabular-nums text-extra-muted-foreground">
{entry.count}
</span>
</a>
{index === 0 && <div className="my-3 h-px bg-white/10" />}
</div>
))}
<div className="my-3 h-px bg-white/10" />
<div className="flex flex-col gap-1.5 px-2.5">
<ContributeLinks className={CONTRIBUTE_CLASS} />
</div>
</nav>
<nav aria-label="Categories" className="-mx-6 flex gap-2 overflow-x-auto px-6 pb-1 lg:hidden">
{entries.map((entry) => (
<a
key={entry.label}
href={entry.href}
aria-current={query.category === entry.slug ? "page" : undefined}
className={query.category === entry.slug ? CHIP_ON : CHIP_OFF}
>
{entry.chip}
</a>
))}
</nav>
</>
);
}
function SortTabs({ query }: { query: BrowseQuery }) {
const windowHrefs = useMemo(
() => ({
week: browseHref({ ...query, window: "week" }),
month: browseHref({ ...query, window: "month" }),
all: browseHref({ ...query, window: "all" }),
}),
[query],
);
const windowSwitch = query.sort === "installs" && (
<WindowSwitch current={query.window} hrefs={windowHrefs} />
);
return (
<>
<div className="flex items-baseline justify-between gap-6 border-b border-white/10">
<div className="flex gap-6">
<a
href={browseHref({ ...query, sort: "installs" })}
aria-current={query.sort === "installs" ? "page" : undefined}
className={query.sort === "installs" ? TAB_ON : TAB_OFF}
>
Most installed
</a>
<a
href={browseHref({ ...query, sort: "new" })}
aria-current={query.sort === "new" ? "page" : undefined}
className={query.sort === "new" ? TAB_ON : TAB_OFF}
>
Newest
</a>
</div>
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
</div>
{windowSwitch && <div className="mt-4 sm:hidden">{windowSwitch}</div>}
</>
);
}
@@ -1,36 +0,0 @@
import { Link } from "@tanstack/react-router";
import { type ReactNode, useMemo } from "react";
import type { CategorySlug, PluginSort } from "./registry";
interface CategoryLinkProps {
/** `null` links to the unfiltered list. */
category: CategorySlug | null;
q?: string;
sort?: PluginSort;
replace?: boolean;
className?: string;
children: ReactNode;
}
/** Link into the plugin list filtered by category, keeping any search and sort. */
export function CategoryLink({
category,
q,
sort,
replace,
className,
children,
}: CategoryLinkProps) {
const search = useMemo(() => {
const next: { category?: CategorySlug; q?: string; sort?: PluginSort } = {};
if (category) next.category = category;
if (q) next.q = q;
if (sort === "new") next.sort = sort;
return next;
}, [category, q, sort]);
return (
<Link to="/plugins" search={search} replace={replace} className={className}>
{children}
</Link>
);
}
@@ -0,0 +1,17 @@
import { BUILD_URL, SUBMIT_URL } from "./links";
const LINK_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
/** "Build a plugin" and "Submit a plugin", as two quiet links. */
export function ContributeLinks({ className = LINK_CLASS }: { className?: string }) {
return (
<>
<a href={BUILD_URL} className={className}>
Build a plugin
</a>
<a href={SUBMIT_URL} className={className}>
Submit a plugin
</a>
</>
);
}
+73
View File
@@ -0,0 +1,73 @@
// Install counts live in the website KV. The all-time counter predates the daily record and
// stays the "All time" number; the daily record keeps the last 60 days for the shorter windows.
export type InstallWindow = "week" | "month" | "all";
export type InstallCounts = Record<InstallWindow, number>;
/** UTC day (YYYY-MM-DD) to installs that day. */
export type DailyInstalls = Record<string, number>;
const DAY_MS = 24 * 60 * 60 * 1000;
const DAYS_KEPT = 60;
const WINDOW_DAYS = { week: 7, month: 30 } as const;
const totalKey = (id: string) => `plugin-installs:${id}`;
const dailyKey = (id: string) => `plugin-installs-daily:${id}`;
export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> {
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
await Promise.all([
cache.put(totalKey(id), String(Number(total ?? 0) + 1)),
cache.put(dailyKey(id), JSON.stringify(addInstall(daily, now))),
]);
}
export async function readInstallCounts(
cache: KVNamespace | null,
ids: string[],
now: Date,
): Promise<Record<string, InstallCounts>> {
const entries = await Promise.all(
ids.map(async (id) => {
if (!cache) return [id, countInstalls({}, 0, now)] as const;
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
return [id, countInstalls(daily, Number(total ?? 0), now)] as const;
}),
);
return Object.fromEntries(entries);
}
export function addInstall(daily: DailyInstalls, now: Date): DailyInstalls {
const oldest = utcDay(now, DAYS_KEPT - 1);
const kept = Object.fromEntries(Object.entries(daily).filter(([day]) => day >= oldest));
const today = utcDay(now, 0);
kept[today] = (kept[today] ?? 0) + 1;
return kept;
}
export function countInstalls(daily: DailyInstalls, total: number, now: Date): InstallCounts {
return {
week: installsSince(daily, utcDay(now, WINDOW_DAYS.week - 1)),
month: installsSince(daily, utcDay(now, WINDOW_DAYS.month - 1)),
all: total,
};
}
function installsSince(daily: DailyInstalls, oldest: string): number {
return Object.entries(daily).reduce(
(sum, [day, count]) => (day >= oldest ? sum + count : sum),
0,
);
}
function utcDay(now: Date, daysBefore: number): string {
return new Date(now.getTime() - daysBefore * DAY_MS).toISOString().slice(0, 10);
}
async function readDaily(cache: KVNamespace, id: string): Promise<DailyInstalls> {
const value: unknown = await cache.get(dailyKey(id), { type: "json" });
if (typeof value !== "object" || value === null) return {};
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, number] => typeof entry[1] === "number",
),
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { CategorySlug } from "./categories";
import type { InstallWindow } from "./installs";
export const BUILD_URL = "/docs/plugins";
export const SUBMIT_URL =
"https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml";
export type BrowseSort = "installs" | "new";
export interface BrowseQuery {
/** Absent for all plugins. */
category?: CategorySlug;
sort: BrowseSort;
window: InstallWindow;
/** Search term; absent when not searching. */
q?: string;
}
export const DEFAULT_WINDOW: InstallWindow = "week";
export function parseWindow(value: unknown): InstallWindow | undefined {
return value === "month" || value === "all" ? value : undefined;
}
export function parseSearchTerm(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function parseSort(value: unknown): BrowseSort | undefined {
return value === "new" ? value : undefined;
}
export function pluginHref(id: string): string {
return `/plugins/${id}`;
}
export function categoryHref(category: CategorySlug): string {
return `/plugins/category/${category}`;
}
export function browseHref({ category, sort, window, q }: BrowseQuery): string {
const path = category ? categoryHref(category) : "/plugins/all";
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort === "new") params.set("sort", sort);
else if (window !== DEFAULT_WINDOW) params.set("window", window);
const search = params.toString();
return search ? `${path}?${search}` : path;
}
/** The directory's Most installed section in a time window. */
export function mostInstalledHref(window: InstallWindow): string {
return `/plugins${window === DEFAULT_WINDOW ? "" : `?window=${window}`}#most-installed`;
}
+15 -4
View File
@@ -1,14 +1,25 @@
import { createServerFn } from "@tanstack/react-start";
import { env } from "cloudflare:workers";
import { getWebsiteCacheContext } from "~/cloudflare-cache";
import { loadRegistryIndex, loadRegistryPlugin, type RegistryEnvironment } from "./published";
import {
loadInstallCounts,
loadRegistryIndex,
loadRegistryPlugin,
type RegistryEnvironment,
} from "./published";
function registryBase(): string {
return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins";
}
export const getRegistry = createServerFn({ method: "GET" }).handler(() =>
loadRegistryIndex(registryBase(), getWebsiteCacheContext()),
);
/** Listed plugins in index order, their install counts per window, and the server's clock. */
export const getRegistry = createServerFn({ method: "GET" }).handler(async () => {
const context = getWebsiteCacheContext();
const [index, installs] = await Promise.all([
loadRegistryIndex(registryBase(), context),
loadInstallCounts(registryBase(), context),
]);
return { plugins: index.plugins, installs, now: new Date().toISOString() };
});
export const getRegistryPlugin = createServerFn({ method: "GET" })
.validator((id: string) => id)
.handler(({ data }) => loadRegistryPlugin(registryBase(), data, getWebsiteCacheContext()));
+102 -35
View File
@@ -1,49 +1,116 @@
import { Link } from "@tanstack/react-router";
import { Download } from "lucide-react";
import { useMemo } from "react";
import { pluginHref } from "./links";
import { PluginTile } from "./plugin-tile";
import { formatInstalls, getAuthor, getCategory, type Plugin } from "./registry";
import { formatInstalls, getCategory, type Plugin } from "./registry";
export const PLUGIN_GRID_CLASS = "grid gap-3 sm:grid-cols-2 lg:grid-cols-3";
export const PLUGIN_GRID_CLASS =
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
export function PluginCard({ plugin }: { plugin: Plugin }) {
const author = getAuthor(plugin);
const category = getCategory(plugin.categories[0]);
const params = useMemo(() => {
const [owner, slug] = plugin.id.split("/");
return { owner, slug };
}, [plugin.id]);
/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */
function PluginShot({ plugin }: { plugin: Plugin }) {
const url = plugin.screenshots[0];
return (
<Link
to="/plugins/$owner/$slug"
params={params}
className="flex flex-col gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4 hover:border-white/20 hover:bg-white/[0.05] transition-colors"
<div
aria-hidden
className="aspect-[16/10] overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] transition-colors group-hover:border-white/20"
>
<div className="flex items-center gap-3">
{url ? (
<img
src={url}
alt=""
loading="lazy"
className="h-full w-full object-cover object-left-top"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[radial-gradient(circle_at_30%_20%,rgba(35,153,86,0.12),transparent_60%)]">
<PluginTile plugin={plugin} size="lg" />
</div>
)}
</div>
);
}
/** What's new card: screenshot, name, and when it was added. */
export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) {
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>
{author && <p className="truncate text-xs text-extra-muted-foreground">{author.name}</p>}
<p className="truncate text-xs text-extra-muted-foreground">Added {added}</p>
</div>
</div>
<p className="line-clamp-2 flex-1 text-sm leading-relaxed text-muted-foreground">
{plugin.description}
</p>
<div className="flex items-center justify-between gap-2 text-xs text-white/40">
{category ? (
<span className="truncate rounded-full border border-white/10 px-2 py-1">
{category.label}
</span>
) : (
<span />
)}
{plugin.installs !== undefined && (
<span className="inline-flex flex-shrink-0 items-center gap-1 tabular-nums">
<Download className="h-3 w-3" />
{formatInstalls(plugin.installs)} installs
</span>
)}
</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;
}) {
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>
</Link>
</a>
);
}
/** Ranked row for the directory's Most installed list. */
export function PluginRankRow({
plugin,
rank,
installs,
}: {
plugin: Plugin;
rank: number;
installs: number;
}) {
return (
<a
href={pluginHref(plugin.id)}
className="flex items-center gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-white/[0.04]"
>
<span className="w-4 text-right text-sm tabular-nums text-extra-muted-foreground">
{rank}
</span>
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{plugin.name}</p>
<p className="truncate text-xs text-extra-muted-foreground">
{getCategory(plugin.categories[0])?.label}
</p>
</div>
<span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span>
</a>
);
}
@@ -1,10 +0,0 @@
import { ShieldAlert } from "lucide-react";
export function PluginsTrustNote() {
return (
<p className="inline-flex items-start gap-2 text-xs text-extra-muted-foreground">
<ShieldAlert className="mt-0.5 h-3 w-3 flex-shrink-0" />
Plugins run unsandboxed on your machine. Read the source before you install.
</p>
);
}
@@ -0,0 +1,53 @@
import { useNavigate } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useState } from "react";
import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links";
/**
* Search box for the plugin pages. Typing replaces the URL with the browse page for the term,
* keeping `scope`'s category and sort. Without JavaScript the form submits the same URL.
*/
export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) {
const navigate = useNavigate();
const [term, setTerm] = useState(scope.q ?? "");
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const next = event.target.value;
setTerm(next);
void navigate({
href: browseHref({ ...scope, q: next.trim() ? next : undefined }),
replace: true,
});
},
[navigate, scope],
);
const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []);
return (
<form
role="search"
method="get"
action={browseHref({ ...scope, q: undefined, sort: "installs", window: DEFAULT_WINDOW })}
onSubmit={handleSubmit}
className={className}
>
{scope.sort === "new" && <input type="hidden" name="sort" value="new" />}
{scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && (
<input type="hidden" name="window" value={scope.window} />
)}
<label className="relative block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-extra-muted-foreground" />
<input
type="search"
name="q"
value={term}
onChange={handleChange}
placeholder="Search plugins"
aria-label="Search plugins"
// Typing on the directory lands here; keep the caret in the box.
autoFocus={Boolean(scope.q)}
className="w-full rounded-lg border border-white/10 bg-white/[0.03] py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-extra-muted-foreground focus:border-white/20 focus:outline-none"
/>
</label>
</form>
);
}
+29 -18
View File
@@ -4,6 +4,8 @@ import {
PublishedPluginDetailSchema,
} from "@getpaseo/protocol/plugin-registry";
import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache";
import { CATEGORIES } from "./categories";
import { type InstallCounts, readInstallCounts, recordInstall } from "./installs";
export interface RegistryEnvironment {
PLUGINS_REGISTRY_URL?: string;
WEBSITE_CACHE?: KVNamespace;
@@ -23,6 +25,20 @@ export function loadRegistryIndex(base: string, context: WebsiteCacheContext) {
fetchFresh: async () => PluginRegistryIndexSchema.parse(await documentAt(base, "index.json")),
});
}
/** Install counts per window for every listed plugin, refreshed with the index cache. */
export async function loadInstallCounts(base: string, context: WebsiteCacheContext) {
const index = await loadRegistryIndex(base, context);
const ids = index.plugins.map((plugin) => plugin.id);
return getBlockingColdCache({
context,
key: `plugins:installs:v1:${base}`,
isValue: (value): value is Record<string, InstallCounts> =>
typeof value === "object" &&
value !== null &&
Object.values(value).every((counts) => typeof counts?.all === "number"),
fetchFresh: () => readInstallCounts(context.cache, ids, new Date()),
});
}
export async function loadRegistryPlugin(base: string, id: string, context: WebsiteCacheContext) {
if (!PluginRegistryIdSchema.safeParse(id).success) return null;
return getBlockingColdCache({
@@ -52,7 +68,11 @@ export async function handlePluginRegistryRequest(
if (url.pathname === "/sitemap-plugins.xml") {
const index = await loadRegistryIndex(base, context);
// IDs are validated owner/slug strings, so these paths contain no XML metacharacters.
const paths = new Set<string>(["/plugins"]);
const paths = new Set<string>(["/plugins", "/plugins/all"]);
for (const category of CATEGORIES) {
if (index.plugins.some((plugin) => plugin.categories.includes(category.slug)))
paths.add(`/plugins/category/${category.slug}`);
}
for (const plugin of index.plugins) {
paths.add(`/plugins/${plugin.id.split("/")[0]}`);
paths.add(`/plugins/${plugin.id}`);
@@ -70,16 +90,14 @@ export async function handlePluginRegistryRequest(
}
if (url.pathname === "/api/plugins/installs") {
const index = await loadRegistryIndex(base, context);
const entries = await Promise.all(
index.plugins.map(
async (plugin) =>
[
plugin.id,
Number((await context.cache?.get(`plugin-installs:${plugin.id}`)) ?? 0),
] as const,
),
const counts = await readInstallCounts(
context.cache,
index.plugins.map((plugin) => plugin.id),
new Date(),
);
return Response.json(
Object.fromEntries(Object.entries(counts).map(([id, count]) => [id, count.all])),
);
return Response.json(Object.fromEntries(entries));
}
const match =
/^\/api\/plugins\/resolve\/([^/]+\/[^/]+)$/.exec(url.pathname) ??
@@ -93,14 +111,7 @@ export async function handlePluginRegistryRequest(
url.searchParams.get("intent") === "install"
) {
const cache = context.cache;
if (cache)
context.waitUntil(
(async () => {
const key = `plugin-installs:${id}`;
const count = Number((await cache.get(key)) ?? 0);
await cache.put(key, String(count + 1));
})().catch(() => undefined),
);
if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined));
}
return Response.json(plugin);
}
+77 -21
View File
@@ -3,10 +3,13 @@ import { describe, expect, it } from "vitest";
import {
formatInstalls,
installCommand,
pinnedInstallCommand,
queryPlugins,
mostInstalled,
newestFirst,
type Plugin,
readmeBody,
searchPlugins,
} from "./registry";
import { readInstallCounts, recordInstall } from "./installs";
import { loadRegistryIndex, loadRegistryPlugin } from "./published";
const plugin: Plugin = {
id: "acme/example",
@@ -56,29 +59,82 @@ describe("plugin registry", () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("filters namespaced IDs and ranks by installs", () => {
const popular = { ...plugin, id: "acme/popular", installs: 100 };
expect(queryPlugins([plugin, popular], { sort: "popular" }).map((p) => p.id)).toEqual([
"acme/popular",
it("orders by submission date and by installs in a window, keeping index order for ties", () => {
const older = { ...plugin, id: "acme/older", submittedAt: "2026-09-01" };
const twin = { ...plugin, id: "acme/twin" };
expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([
"acme/example",
"acme/twin",
"acme/older",
]);
expect(queryPlugins([plugin, popular], { q: "acme/example", sort: "new" })).toEqual([plugin]);
expect(queryPlugins([plugin], { category: "utils", sort: "popular" })).toEqual([]);
const installs = {
"acme/example": { week: 1, month: 5, all: 9 },
"acme/older": { week: 3, month: 3, all: 3 },
"acme/twin": { week: 1, month: 1, all: 1 },
};
expect(mostInstalled([plugin, older, twin], installs, "week").map((p) => p.id)).toEqual([
"acme/older",
"acme/example",
"acme/twin",
]);
expect(mostInstalled([plugin, older, twin], installs, "month")[0].id).toBe("acme/example");
});
it("offers a registry command and an exact explicit command", () => {
expect(installCommand(plugin)).toBe("paseo plugin install acme/example");
expect(pinnedInstallCommand(plugin)).toBe("paseo plugin install npm:paseo-example@1.2.3");
it("counts installs this week, this month, and all time from the daily record", async () => {
const cache = memoryKv();
const day = (date: string) => new Date(`${date}T12:00:00.000Z`);
await cache.put("plugin-installs:acme/example", "40");
await recordInstall(cache, "acme/example", day("2026-07-01"));
await recordInstall(cache, "acme/example", day("2026-09-10"));
await recordInstall(cache, "acme/example", day("2026-09-30"));
await recordInstall(cache, "acme/example", day("2026-10-04"));
expect(await readInstallCounts(cache, ["acme/example"], day("2026-10-04"))).toEqual({
"acme/example": { week: 2, month: 3, all: 44 },
});
expect(
pinnedInstallCommand({
...plugin,
artifact: {
kind: "git",
remote: "https://github.com/acme/plugins.git",
commit: "a".repeat(40),
pluginPath: "packages/example",
},
}),
).toBe(`paseo plugin install github:acme/plugins:packages/example --ref ${"a".repeat(40)}`);
Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")),
).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]);
});
it("searches name, description, ID, and author, ignoring case", () => {
const other = {
...plugin,
id: "zed/other",
name: "Other",
description: "Unrelated",
author: { github: "zed" },
};
const plugins = [plugin, other];
expect(searchPlugins(plugins, "EXAMPLE").map((p) => p.id)).toEqual(["acme/example"]);
expect(searchPlugins(plugins, "unrelated").map((p) => p.id)).toEqual(["zed/other"]);
expect(searchPlugins(plugins, "zed/").map((p) => p.id)).toEqual(["zed/other"]);
expect(searchPlugins(plugins, "acme").map((p) => p.id)).toEqual(["acme/example"]);
expect(searchPlugins(plugins, " ")).toEqual(plugins);
expect(searchPlugins(plugins, "nothing")).toEqual([]);
});
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");
expect(readmeBody("## Usage\n\n> Note\n")).toBe("## Usage\n\n> Note\n");
expect(readmeBody("# Example\n\n> [!WARNING]\n> Needs Docker\n\n## Usage\n")).toBe(
"> [!WARNING]\n> Needs Docker\n\n## Usage\n",
);
});
it("offers a registry install command", () => {
expect(installCommand(plugin)).toBe("paseo plugin install acme/example");
expect(formatInstalls(1250)).toBe("1.3k");
});
});
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
function memoryKv(): KVNamespace {
const values = new Map<string, string>();
const kv = {
get: async (key: string, options?: { type?: string }) => {
const value = values.get(key) ?? null;
return options?.type === "json" && value !== null ? JSON.parse(value) : value;
},
put: async (key: string, value: string) => {
values.set(key, value);
},
};
return kv as unknown as KVNamespace;
}
+43 -32
View File
@@ -1,7 +1,9 @@
import type { z } from "zod";
import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { CATEGORIES, type Category, type CategorySlug } from "./categories";
import type { InstallCounts, InstallWindow } from "./installs";
export { CATEGORIES, type Category, type CategorySlug };
export type { InstallCounts, InstallWindow };
export type Plugin = z.infer<typeof PublishedPluginSchema>;
export interface Author {
username: string;
@@ -9,7 +11,7 @@ export interface Author {
github: string;
npm?: string;
}
export type PluginSort = "popular" | "new";
const DAY_MS = 24 * 60 * 60 * 1000;
export function getCategory(slug: string): Category | null {
return CATEGORIES.find((category) => category.slug === slug) ?? null;
}
@@ -35,16 +37,6 @@ export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] {
export function installCommand(plugin: Plugin): string {
return `paseo plugin install ${plugin.id}`;
}
export function pinnedInstallCommand(plugin: Plugin): string {
const artifact = plugin.artifact;
if (artifact.kind === "npm")
return `paseo plugin install npm:${artifact.package}@${artifact.version}`;
const source = artifact.remote
.replace(/^https:\/\/github.com\//, "github:")
.replace(/\.git$/, "");
const location = artifact.pluginPath ? `${source}:${artifact.pluginPath}` : source;
return `paseo plugin install ${location} --ref ${artifact.commit}`;
}
export function pluginVersion(plugin: Plugin): string {
return plugin.artifact.kind === "npm"
? plugin.artifact.version
@@ -69,26 +61,45 @@ export function formatInstalls(count: number): string {
const k = count / 1000;
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
}
export interface PluginQuery {
category?: CategorySlug;
q?: string;
sort: PluginSort;
}
export function queryPlugins(plugins: Plugin[], query: PluginQuery): Plugin[] {
const needle = query.q?.trim().toLowerCase() ?? "";
const matches = plugins.filter(
(plugin) =>
(!query.category || plugin.categories.includes(query.category)) &&
(!needle ||
`${plugin.name} ${plugin.description} ${plugin.id}`.toLowerCase().includes(needle)),
);
return sortPlugins(matches, query.sort);
}
export function sortPlugins(list: Plugin[], sort: PluginSort): Plugin[] {
return [...list].sort(
(a, b) =>
(sort === "new"
? b.updatedAt.localeCompare(a.updatedAt)
: (b.installs ?? 0) - (a.installs ?? 0)) || a.name.localeCompare(b.name),
/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */
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),
);
}
/** Newest submissions first; equal dates keep index order. */
export function newestFirst(plugins: Plugin[]): Plugin[] {
return [...plugins].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
}
/** Most installs in the window first; equal counts keep index order. */
export function mostInstalled(
plugins: Plugin[],
installs: Record<string, InstallCounts>,
window: InstallWindow,
): Plugin[] {
const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0;
return [...plugins].sort((a, b) => count(b) - count(a));
}
/** How long ago the registry accepted the plugin, relative to the loader's clock. */
export function addedAgo(plugin: Plugin, now: string): string {
const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 14) return `${days}d ago`;
return `${Math.round(days / 7)}w ago`;
}
/**
* A README usually opens with the plugin's name and quoted description; the page shows both.
* A leading GitHub alert (`> [!WARNING]`) is a note for the reader and stays.
*/
export function readmeBody(readme: string): string {
return readme
.replace(/^\s*#\s[^\n]*\n+/, "")
.replace(/^(?!>\s*\[!)(>[^\n]*\n)+\n*/, "")
.trimStart();
}
@@ -0,0 +1,35 @@
import type { InstallWindow } from "./installs";
const WINDOWS: { value: InstallWindow; label: string }[] = [
{ value: "week", label: "This week" },
{ value: "month", label: "This month" },
{ value: "all", label: "All time" },
];
/** Plain text time-window switch for install rankings. */
export function WindowSwitch({
current,
hrefs,
}: {
current: InstallWindow;
hrefs: Record<InstallWindow, string>;
}) {
return (
<nav aria-label="Time window" className="flex items-baseline gap-4 text-sm">
{WINDOWS.map((option) => (
<a
key={option.value}
href={hrefs[option.value]}
aria-current={option.value === current ? "true" : undefined}
className={
option.value === current
? "text-foreground"
: "text-extra-muted-foreground transition-colors hover:text-muted-foreground"
}
>
{option.label}
</a>
))}
</nav>
);
}
+45 -3
View File
@@ -63,6 +63,7 @@ import { Route as IndexRouteImport } from "./routes/index";
import { Route as PluginsIndexRouteImport } from "./routes/plugins/index";
import { Route as DocsIndexRouteImport } from "./routes/docs/index";
import { Route as BlogIndexRouteImport } from "./routes/blog/index";
import { Route as PluginsAllRouteImport } from "./routes/plugins/all";
import { Route as PluginsOwnerRouteImport } from "./routes/plugins/$owner";
import { Route as DownloadThanksRouteImport } from "./routes/download_.thanks";
import { Route as DocsSplatRouteImport } from "./routes/docs/$";
@@ -75,6 +76,7 @@ import { Route as AlternativesHappyCoderRouteImport } from "./routes/alternative
import { Route as AlternativesConductorRouteImport } from "./routes/alternatives/conductor";
import { Route as AlternativesCodexAppRouteImport } from "./routes/alternatives/codex-app";
import { Route as AlternativesClaudeDesktopRouteImport } from "./routes/alternatives/claude-desktop";
import { Route as PluginsCategorySlugRouteImport } from "./routes/plugins/category/$slug";
import { Route as PluginsOwnerSlugRouteImport } from "./routes/plugins/$owner_.$slug";
const VtcodeRoute = VtcodeRouteImport.update({
@@ -347,6 +349,11 @@ const BlogIndexRoute = BlogIndexRouteImport.update({
path: "/",
getParentRoute: () => BlogRoute,
} as any);
const PluginsAllRoute = PluginsAllRouteImport.update({
id: "/plugins/all",
path: "/plugins/all",
getParentRoute: () => rootRouteImport,
} as any);
const PluginsOwnerRoute = PluginsOwnerRouteImport.update({
id: "/plugins/$owner",
path: "/plugins/$owner",
@@ -409,6 +416,11 @@ const AlternativesClaudeDesktopRoute =
path: "/alternatives/claude-desktop",
getParentRoute: () => rootRouteImport,
} as any);
const PluginsCategorySlugRoute = PluginsCategorySlugRouteImport.update({
id: "/plugins/category/$slug",
path: "/plugins/category/$slug",
getParentRoute: () => rootRouteImport,
} as any);
const PluginsOwnerSlugRoute = PluginsOwnerSlugRouteImport.update({
id: "/plugins/$owner_/$slug",
path: "/plugins/$owner/$slug",
@@ -479,10 +491,12 @@ export interface FileRoutesByFullPath {
"/docs/$": typeof DocsSplatRoute;
"/download/thanks": typeof DownloadThanksRoute;
"/plugins/$owner": typeof PluginsOwnerRoute;
"/plugins/all": typeof PluginsAllRoute;
"/blog/": typeof BlogIndexRoute;
"/docs/": typeof DocsIndexRoute;
"/plugins/": typeof PluginsIndexRoute;
"/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute;
"/plugins/category/$slug": typeof PluginsCategorySlugRoute;
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
@@ -546,10 +560,12 @@ export interface FileRoutesByTo {
"/docs/$": typeof DocsSplatRoute;
"/download/thanks": typeof DownloadThanksRoute;
"/plugins/$owner": typeof PluginsOwnerRoute;
"/plugins/all": typeof PluginsAllRoute;
"/blog": typeof BlogIndexRoute;
"/docs": typeof DocsIndexRoute;
"/plugins": typeof PluginsIndexRoute;
"/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute;
"/plugins/category/$slug": typeof PluginsCategorySlugRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
@@ -616,10 +632,12 @@ export interface FileRoutesById {
"/docs/$": typeof DocsSplatRoute;
"/download_/thanks": typeof DownloadThanksRoute;
"/plugins/$owner": typeof PluginsOwnerRoute;
"/plugins/all": typeof PluginsAllRoute;
"/blog/": typeof BlogIndexRoute;
"/docs/": typeof DocsIndexRoute;
"/plugins/": typeof PluginsIndexRoute;
"/plugins/$owner_/$slug": typeof PluginsOwnerSlugRoute;
"/plugins/category/$slug": typeof PluginsCategorySlugRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
@@ -687,10 +705,12 @@ export interface FileRouteTypes {
| "/docs/$"
| "/download/thanks"
| "/plugins/$owner"
| "/plugins/all"
| "/blog/"
| "/docs/"
| "/plugins/"
| "/plugins/$owner/$slug";
| "/plugins/$owner/$slug"
| "/plugins/category/$slug";
fileRoutesByTo: FileRoutesByTo;
to:
| "/"
@@ -754,10 +774,12 @@ export interface FileRouteTypes {
| "/docs/$"
| "/download/thanks"
| "/plugins/$owner"
| "/plugins/all"
| "/blog"
| "/docs"
| "/plugins"
| "/plugins/$owner/$slug";
| "/plugins/$owner/$slug"
| "/plugins/category/$slug";
id:
| "__root__"
| "/"
@@ -823,10 +845,12 @@ export interface FileRouteTypes {
| "/docs/$"
| "/download_/thanks"
| "/plugins/$owner"
| "/plugins/all"
| "/blog/"
| "/docs/"
| "/plugins/"
| "/plugins/$owner_/$slug";
| "/plugins/$owner_/$slug"
| "/plugins/category/$slug";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
@@ -891,8 +915,10 @@ export interface RootRouteChildren {
AlternativesSupersetRoute: typeof AlternativesSupersetRoute;
DownloadThanksRoute: typeof DownloadThanksRoute;
PluginsOwnerRoute: typeof PluginsOwnerRoute;
PluginsAllRoute: typeof PluginsAllRoute;
PluginsIndexRoute: typeof PluginsIndexRoute;
PluginsOwnerSlugRoute: typeof PluginsOwnerSlugRoute;
PluginsCategorySlugRoute: typeof PluginsCategorySlugRoute;
}
declare module "@tanstack/react-router" {
@@ -1275,6 +1301,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof BlogIndexRouteImport;
parentRoute: typeof BlogRoute;
};
"/plugins/all": {
id: "/plugins/all";
path: "/plugins/all";
fullPath: "/plugins/all";
preLoaderRoute: typeof PluginsAllRouteImport;
parentRoute: typeof rootRouteImport;
};
"/plugins/$owner": {
id: "/plugins/$owner";
path: "/plugins/$owner";
@@ -1359,6 +1392,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof AlternativesClaudeDesktopRouteImport;
parentRoute: typeof rootRouteImport;
};
"/plugins/category/$slug": {
id: "/plugins/category/$slug";
path: "/plugins/category/$slug";
fullPath: "/plugins/category/$slug";
preLoaderRoute: typeof PluginsCategorySlugRouteImport;
parentRoute: typeof rootRouteImport;
};
"/plugins/$owner_/$slug": {
id: "/plugins/$owner_/$slug";
path: "/plugins/$owner/$slug";
@@ -1455,8 +1495,10 @@ const rootRouteChildren: RootRouteChildren = {
AlternativesSupersetRoute: AlternativesSupersetRoute,
DownloadThanksRoute: DownloadThanksRoute,
PluginsOwnerRoute: PluginsOwnerRoute,
PluginsAllRoute: PluginsAllRoute,
PluginsIndexRoute: PluginsIndexRoute,
PluginsOwnerSlugRoute: PluginsOwnerSlugRoute,
PluginsCategorySlugRoute: PluginsCategorySlugRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
@@ -10,12 +10,12 @@ import {
getAuthor,
getRegistry,
getPluginsByAuthor,
mostInstalled,
pluginOwner,
sortPlugins,
} from "~/plugins";
import { AuthorAvatar } from "~/plugins/author-link";
import { PluginsNotFound } from "~/plugins/not-found";
import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card";
import { PluginCard } from "~/plugins/plugin-card";
import "~/styles.css";
export const Route = createFileRoute("/plugins/$owner")({
@@ -23,7 +23,7 @@ export const Route = createFileRoute("/plugins/$owner")({
const registry = await getRegistry();
const first = registry.plugins.find((plugin) => pluginOwner(plugin) === params.owner);
if (!first) throw notFound();
return { plugins: registry.plugins, author: getAuthor(first) };
return { plugins: registry.plugins, installs: registry.installs, author: getAuthor(first) };
},
head: ({ params, loaderData }) =>
pageMeta(
@@ -43,13 +43,13 @@ const LINK_CLASS =
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
function AuthorPage() {
const { plugins: allPlugins, author } = Route.useLoaderData();
const { plugins: allPlugins, installs, author } = Route.useLoaderData();
const crumbs = useMemo<BreadcrumbItem[]>(
() => [{ label: "Plugins", href: "/plugins" }, { label: author.name }],
[author],
);
const plugins = sortPlugins(getPluginsByAuthor(allPlugins, author.username), "popular");
const plugins = mostInstalled(getPluginsByAuthor(allPlugins, author.username), installs, "all");
const github = authorGitHubUrl(author);
return (
@@ -84,9 +84,9 @@ function AuthorPage() {
</div>
</div>
<div className={`mt-12 ${PLUGIN_GRID_CLASS}`}>
<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} />
<PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} />
))}
</div>
</SiteShell>
@@ -1,7 +1,5 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { Download, ExternalLink } from "lucide-react";
import { useMemo } from "react";
import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs";
import { ExternalLink } from "lucide-react";
import { CodeBlock } from "~/components/code-block";
import { DocsMarkdown } from "~/components/docs-markdown";
import { SiteShell } from "~/components/site-shell";
@@ -12,25 +10,16 @@ import {
getCategory,
getRegistry,
getRegistryPlugin,
pinnedInstallCommand,
pluginVersion,
getPluginsByAuthor,
getPluginsInCategory,
installCommand,
npmUrl,
type Plugin,
sortPlugins,
pluginVersion,
readmeBody,
} from "~/plugins";
import { AuthorLink } from "~/plugins/author-link";
import { CategoryLink } from "~/plugins/category-link";
import { categoryHref } from "~/plugins/links";
import { PluginsNotFound } from "~/plugins/not-found";
import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card";
import { PluginsTrustNote } from "~/plugins/plugin-page-header";
import { PluginTile } from "~/plugins/plugin-tile";
import "~/styles.css";
const RELATED_COUNT = 3;
export const Route = createFileRoute("/plugins/$owner_/$slug")({
loader: async ({ params }) => {
const [registry, plugin] = await Promise.all([
@@ -38,7 +27,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
getRegistryPlugin({ data: `${params.owner}/${params.slug}` }),
]);
if (!plugin) throw notFound();
return { plugins: registry.plugins, plugin };
return { plugin, installs: registry.installs[plugin.id]?.all ?? 0 };
},
head: ({ params, loaderData }) =>
pageMeta(
@@ -55,181 +44,79 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
),
});
const LINK_CLASS =
const META_LINK_CLASS =
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const VIEW_ALL_CLASS =
"flex-shrink-0 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const CATEGORY_PILL_CLASS =
"rounded-full border border-white/10 px-2 py-1 text-xs text-white/40 transition-colors hover:border-white/20 hover:text-white/70";
function PluginPage() {
const { plugins, plugin } = Route.useLoaderData();
const primaryCategory = getCategory(plugin.categories[0]);
const crumbs = useMemo<BreadcrumbItem[]>(() => {
return [
{ label: "Plugins", href: "/plugins" },
...(primaryCategory
? [{ label: primaryCategory.label, href: `/plugins?category=${primaryCategory.slug}` }]
: []),
{ label: plugin.name },
];
}, [plugin, primaryCategory]);
const { plugin, installs } = Route.useLoaderData();
const category = getCategory(plugin.categories[0]);
const author = getAuthor(plugin);
const count = plugin.installs;
const readme = plugin.readme;
const npm = npmUrl(plugin);
return (
<SiteShell width="default">
<Breadcrumbs items={crumbs} />
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 items-start gap-4">
<PluginTile plugin={plugin} size="lg" />
<div className="min-w-0 space-y-2">
<h1 className="text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">{plugin.description}</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1">
{author && <AuthorLink author={author} />}
{count !== undefined && (
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground tabular-nums">
<Download className="h-3.5 w-3.5" />
{formatInstalls(count)}
<span className="text-extra-muted-foreground">installs</span>
</span>
)}
{plugin.categories.map((slug) => {
const category = getCategory(slug);
if (!category) return null;
return (
<CategoryLink key={slug} category={category.slug} className={CATEGORY_PILL_CLASS}>
{category.label}
</CategoryLink>
);
})}
</div>
</div>
</div>
<div className="w-full space-y-2 lg:w-[30rem] lg:flex-shrink-0">
<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>
<p className="text-xs text-muted-foreground">
{pluginVersion(plugin)} · Updated {plugin.updatedAt.slice(0, 10)}
</p>
<p className="text-xs text-muted-foreground">For older daemons</p>
<CodeBlock size="sm">{pinnedInstallCommand(plugin)}</CodeBlock>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<PluginsTrustNote />
<div className="flex items-center gap-4">
</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>
<a
href={plugin.repository.url}
target="_blank"
rel="noopener noreferrer"
className={META_LINK_CLASS}
>
Source
<ExternalLink className="h-3 w-3" />
</a>
{npm && (
<a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}>
npm
<ExternalLink className="h-3 w-3" />
</a>
)}
</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
href={plugin.repository.url}
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
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)]"
>
Source
<ExternalLink className="h-3 w-3" />
<img
src={url}
alt={`${plugin.name} screenshot ${index + 1}`}
loading="lazy"
className="h-full w-full object-cover object-top"
/>
</a>
{npmUrl(plugin) && (
<a
href={npmUrl(plugin)}
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
npm
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
))}
</div>
)}
<div className="mt-10 border-t border-white/10 pt-10">
<DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown>
</div>
</div>
{plugin.screenshots.length > 0 && (
<div className="-mx-6 mt-12 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%] lg: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>
))}
</div>
)}
<div className="mt-12 max-w-3xl">
<DocsMarkdown>{readme}</DocsMarkdown>
</div>
<RelatedPlugins plugin={plugin} plugins={plugins} />
</SiteShell>
);
}
function RelatedPlugins({ plugin, plugins }: { plugin: Plugin; plugins: Plugin[] }) {
const category = getCategory(plugin.categories[0]);
const author = getAuthor(plugin);
const inCategory = category
? sortPlugins(
getPluginsInCategory(plugins, category.slug).filter(
(candidate) => candidate.id !== plugin.id,
),
"popular",
).slice(0, RELATED_COUNT)
: [];
const byAuthor = author
? sortPlugins(
getPluginsByAuthor(plugins, author.username).filter(
(candidate) => candidate.id !== plugin.id && !inCategory.includes(candidate),
),
"popular",
).slice(0, RELATED_COUNT)
: [];
if (inCategory.length === 0 && byAuthor.length === 0) return null;
return (
<div className="mt-20 space-y-16">
{category && inCategory.length > 0 && (
<section>
<div className="mb-6 flex items-end justify-between gap-4">
<h2 className="text-xl font-medium">More in {category.label}</h2>
<CategoryLink category={category.slug} className={VIEW_ALL_CLASS}>
View all
</CategoryLink>
</div>
<div className={PLUGIN_GRID_CLASS}>
{inCategory.map((candidate) => (
<PluginCard key={candidate.id} plugin={candidate} />
))}
</div>
</section>
)}
{author && byAuthor.length > 0 && (
<section>
<div className="mb-6 flex items-end justify-between gap-4">
<h2 className="text-xl font-medium">More from {author.name}</h2>
<AuthorLink author={author} className={VIEW_ALL_CLASS}>
View all
</AuthorLink>
</div>
<div className={PLUGIN_GRID_CLASS}>
{byAuthor.map((candidate) => (
<PluginCard key={candidate.id} plugin={candidate} />
))}
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMemo } from "react";
import { pageMeta } from "~/meta";
import { getRegistry } from "~/plugins";
import { BrowsePage } from "~/plugins/browse-page";
import {
type BrowseQuery,
DEFAULT_WINDOW,
parseSearchTerm,
parseSort,
parseWindow,
} from "~/plugins/links";
import "~/styles.css";
export const Route = createFileRoute("/plugins/all")({
validateSearch: (search: Record<string, unknown>): Omit<Partial<BrowseQuery>, "category"> => ({
sort: parseSort(search.sort),
window: parseWindow(search.window),
q: parseSearchTerm(search.q),
}),
head: () =>
pageMeta(
"All plugins – Paseo plugins",
"Every community plugin for Paseo, by installs or newest first.",
"/plugins/all",
),
loader: () => getRegistry(),
component: AllPluginsPage,
});
function AllPluginsPage() {
const { plugins, installs, now } = Route.useLoaderData();
const search = Route.useSearch();
const query = useMemo<BrowseQuery>(
() => ({
sort: search.sort ?? "installs",
window: search.window ?? DEFAULT_WINDOW,
q: search.q,
}),
[search.sort, search.window, search.q],
);
return <BrowsePage plugins={plugins} installs={installs} now={now} query={query} />;
}
@@ -0,0 +1,52 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { useMemo } from "react";
import { pageMeta } from "~/meta";
import { getCategory, getRegistry } from "~/plugins";
import { BrowsePage } from "~/plugins/browse-page";
import {
type BrowseQuery,
DEFAULT_WINDOW,
parseSearchTerm,
parseSort,
parseWindow,
} from "~/plugins/links";
import { PluginsNotFound } from "~/plugins/not-found";
import "~/styles.css";
export const Route = createFileRoute("/plugins/category/$slug")({
validateSearch: (search: Record<string, unknown>): Omit<Partial<BrowseQuery>, "category"> => ({
sort: parseSort(search.sort),
window: parseWindow(search.window),
q: parseSearchTerm(search.q),
}),
loader: async ({ params }) => {
const category = getCategory(params.slug);
if (!category) throw notFound();
return { ...(await getRegistry()), category };
},
head: ({ params, loaderData }) =>
pageMeta(
loaderData ? `${loaderData.category.label} – Paseo plugins` : "Category not found – Paseo",
loaderData?.category.description ?? "Category not found.",
`/plugins/category/${params.slug}`,
),
component: CategoryPage,
notFoundComponent: () => (
<PluginsNotFound title="Category not found">There is no plugin category here.</PluginsNotFound>
),
});
function CategoryPage() {
const { plugins, installs, now, category } = Route.useLoaderData();
const search = Route.useSearch();
const query = useMemo<BrowseQuery>(
() => ({
category: category.slug,
sort: search.sort ?? "installs",
window: search.window ?? DEFAULT_WINDOW,
q: search.q,
}),
[category.slug, search.sort, search.window, search.q],
);
return <BrowsePage plugins={plugins} installs={installs} now={now} query={query} />;
}
+158 -225
View File
@@ -1,40 +1,82 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { type ChangeEvent, useCallback } from "react";
import { createFileRoute, redirect } from "@tanstack/react-router";
import {
Blocks,
Boxes,
ChevronRight,
GitBranch,
LayoutPanelLeft,
type LucideIcon,
Network,
Palette,
Server,
Sparkles,
Wrench,
} from "lucide-react";
import { useMemo } from "react";
import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
import {
addedAgo,
CATEGORIES,
type CategorySlug,
getCategory,
getRegistry,
getPluginsInCategory,
type Plugin,
type PluginSort,
queryPlugins,
sortPlugins,
getRegistry,
type InstallWindow,
mostInstalled,
newestFirst,
} from "~/plugins";
import { CategoryLink } from "~/plugins/category-link";
import { PLUGIN_GRID_CLASS, PluginCard } from "~/plugins/plugin-card";
import { PluginsTrustNote } from "~/plugins/plugin-page-header";
import { ContributeLinks } from "~/plugins/contribute-links";
import {
type BrowseQuery,
browseHref,
categoryHref,
DEFAULT_WINDOW,
mostInstalledHref,
parseSearchTerm,
parseSort,
parseWindow,
} from "~/plugins/links";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSearch } from "~/plugins/plugin-search";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
interface PluginsSearch {
category?: CategorySlug;
q?: string;
sort?: PluginSort;
}
const NEW_COUNT = 4;
const TOP_COUNT = 6;
const SECTION_PREVIEW_COUNT = 3;
const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
"daemon-management": Server,
themes: Palette,
providers: Boxes,
orchestration: Network,
git: GitBranch,
workspaces: Blocks,
sidebar: LayoutPanelLeft,
extras: Sparkles,
utils: Wrench,
};
const SEE_ALL_CLASS =
"inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground";
export const Route = createFileRoute("/plugins/")({
validateSearch: (search: Record<string, unknown>): PluginsSearch => {
const result: PluginsSearch = {};
const category = typeof search.category === "string" ? getCategory(search.category) : null;
if (category) result.category = category.slug;
if (typeof search.q === "string" && search.q.trim()) result.q = search.q;
if (search.sort === "new") result.sort = "new";
return result;
validateSearch: (search: Record<string, unknown>): { window?: InstallWindow } => {
const window = parseWindow(search.window);
return window ? { window } : {};
},
beforeLoad: ({ location }) => {
// Keep links from before browse pages existed: /plugins?q=<term>&category=<slug>&sort=new.
const params = new URLSearchParams(location.searchStr);
const category = getCategory(params.get("category") ?? "");
const sort = parseSort(params.get("sort")) ?? "installs";
const window = parseWindow(params.get("window")) ?? DEFAULT_WINDOW;
const q = parseSearchTerm(params.get("q"));
if (category || sort === "new" || q)
throw redirect({
href: browseHref({ category: category?.slug, sort, window, q }),
statusCode: 301,
});
},
head: () =>
pageMeta(
@@ -46,216 +88,107 @@ export const Route = createFileRoute("/plugins/")({
component: PluginsPage,
});
const PILL_BASE =
"inline-flex flex-shrink-0 items-center rounded-full border px-3 py-1.5 text-xs transition-colors";
const PILL_ACTIVE = `${PILL_BASE} border-white/20 bg-white/[0.07] text-white`;
const PILL_INACTIVE = `${PILL_BASE} border-white/10 bg-white/[0.025] text-muted-foreground hover:border-white/15 hover:bg-white/[0.04] hover:text-foreground`;
const VIEW_ALL_CLASS =
"flex-shrink-0 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
function PluginsPage() {
const search = Route.useSearch();
const { plugins } = Route.useLoaderData();
const navigate = useNavigate({ from: "/plugins/" });
const sort: PluginSort = search.sort ?? "popular";
const filtering = Boolean(search.category || search.q);
const handleSearchChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const q = event.target.value;
void navigate({
search: (prev) => (q ? { ...prev, q } : { ...prev, q: undefined }),
replace: true,
});
},
[navigate],
);
const handleSort = useCallback(
(next: PluginSort) => {
void navigate({
search: (prev) => ({ ...prev, sort: next === "new" ? next : undefined }),
replace: true,
});
},
[navigate],
const { plugins, installs, now } = Route.useLoaderData();
const window = Route.useSearch().window ?? DEFAULT_WINDOW;
const newest = newestFirst(plugins).slice(0, NEW_COUNT);
const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT);
const windowHrefs = useMemo(
() => ({
week: mostInstalledHref("week"),
month: mostInstalledHref("month"),
all: mostInstalledHref("all"),
}),
[],
);
const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]);
return (
<SiteShell width="default">
<h1 className="text-3xl font-medium tracking-tight mb-4">Plugins</h1>
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
Themes, providers, panels, and automations built by the Paseo community. Install any of them
with one command.
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-2">
<PluginsTrustNote />
<a
href="/docs/plugins"
className="text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Build your own
</a>
</div>
<div className="mt-12 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<label className="relative flex-1 md:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-extra-muted-foreground" />
<input
type="search"
value={search.q ?? ""}
onChange={handleSearchChange}
placeholder="Search plugins"
aria-label="Search plugins"
className="w-full rounded-lg border border-white/10 bg-white/[0.03] py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-extra-muted-foreground focus:border-white/20 focus:outline-none"
/>
</label>
<div role="group" aria-label="Sort" className="flex items-center gap-2">
<SortButton value="popular" current={sort} onSelect={handleSort}>
Popular
</SortButton>
<SortButton value="new" current={sort} onSelect={handleSort}>
New
</SortButton>
<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">
<PluginSearch scope={searchScope} className="w-full sm:w-56" />
<ContributeLinks />
</div>
</div>
<div className="-mx-6 mt-4 flex gap-2 overflow-x-auto px-6 pb-1 md:mx-0 md:flex-wrap md:px-0">
<CategoryLink
category={null}
q={search.q}
sort={sort}
replace
className={search.category ? PILL_INACTIVE : PILL_ACTIVE}
>
All
</CategoryLink>
{CATEGORIES.map((category) => {
if (getPluginsInCategory(plugins, category.slug).length === 0) return null;
return (
<CategoryLink
key={category.slug}
category={category.slug}
q={search.q}
sort={sort}
replace
className={search.category === category.slug ? PILL_ACTIVE : PILL_INACTIVE}
>
{category.label}
</CategoryLink>
);
})}
</div>
<section aria-labelledby="whats-new" className="mt-10">
<div className="mb-4 flex items-baseline justify-between gap-4">
<h2 id="whats-new" className="text-lg font-medium">
What’s new
</h2>
<a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}>
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</div>
<div className="-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">
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
))}
</div>
</section>
{filtering ? (
<FilteredPlugins search={search} sort={sort} plugins={plugins} />
) : (
<CategorySections sort={sort} plugins={plugins} />
)}
<section aria-labelledby="categories" className="mt-14">
<h2 id="categories" className="mb-4 text-lg font-medium">
Categories
</h2>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{CATEGORIES.map((category) => {
const Icon = CATEGORY_ICONS[category.slug];
return (
<a
key={category.slug}
href={categoryHref(category.slug)}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4"
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-sm leading-tight text-white">
{category.label}
</span>
<span className="text-xs tabular-nums text-extra-muted-foreground">
{getPluginsInCategory(plugins, category.slug).length}
</span>
</a>
);
})}
</div>
</section>
<section
id="most-installed"
aria-labelledby="most-installed-title"
className="mt-14 scroll-mt-8"
>
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 id="most-installed-title" className="text-lg font-medium">
<a
href={browseHref({ sort: "installs", window })}
className="group inline-flex items-center gap-1"
>
Most installed
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
</h2>
<WindowSwitch current={window} hrefs={windowHrefs} />
</div>
<div className="-mx-2 grid gap-x-8 md:grid-cols-2">
{top.map((plugin, index) => (
<PluginRankRow
key={plugin.id}
plugin={plugin}
rank={index + 1}
installs={installs[plugin.id]?.[window] ?? 0}
/>
))}
</div>
</section>
</SiteShell>
);
}
function SortButton({
value,
current,
onSelect,
children,
}: {
value: PluginSort;
current: PluginSort;
onSelect: (sort: PluginSort) => void;
children: string;
}) {
const handleClick = useCallback(() => onSelect(value), [onSelect, value]);
const active = value === current;
return (
<button
type="button"
onClick={handleClick}
aria-pressed={active}
className={active ? PILL_ACTIVE : PILL_INACTIVE}
>
{children}
</button>
);
}
function CategorySections({ sort, plugins }: { sort: PluginSort; plugins: Plugin[] }) {
return (
<div className="mt-12 space-y-16">
{CATEGORIES.map((category) => {
const all = sortPlugins(getPluginsInCategory(plugins, category.slug), sort);
if (all.length === 0) return null;
const shown = all.slice(0, SECTION_PREVIEW_COUNT);
return (
<section key={category.slug} aria-labelledby={`category-${category.slug}`}>
<div className="mb-6 flex items-end justify-between gap-4">
<div className="space-y-1">
<h2 id={`category-${category.slug}`} className="text-xl font-medium">
{category.label}
</h2>
<p className="text-sm text-muted-foreground">{category.description}</p>
</div>
{all.length > shown.length && (
<CategoryLink category={category.slug} sort={sort} className={VIEW_ALL_CLASS}>
View all ({all.length})
</CategoryLink>
)}
</div>
<div className={PLUGIN_GRID_CLASS}>
{shown.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin} />
))}
</div>
</section>
);
})}
</div>
);
}
function FilteredPlugins({
search,
sort,
plugins,
}: {
search: PluginsSearch;
sort: PluginSort;
plugins: Plugin[];
}) {
const category = search.category ? getCategory(search.category) : null;
const results = queryPlugins(plugins, { category: search.category, q: search.q, sort });
const total = plugins.length;
return (
<section className="mt-12" aria-live="polite">
<div className="mb-6 space-y-1">
<h2 className="text-xl font-medium">
{category ? category.label : `Results for “${search.q}”`}
</h2>
<p className="text-sm text-muted-foreground">
{category && !search.q ? category.description : null}
{search.q && category ? `Matching “${search.q}”.` : null}
{!category && search.q ? `${results.length} of ${total} plugins.` : null}
</p>
</div>
{results.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<CategoryLink
category={null}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
</CategoryLink>
</div>
) : (
<div className={PLUGIN_GRID_CLASS}>
{results.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin} />
))}
</div>
)}
</section>
);
}
+11 -8
View File
@@ -2253,12 +2253,11 @@ directory. The app does not expand `~`; your shell may expand it before the CLI
| Source | Accepted form | Example |
| -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
| Registry plugin | `owner/slug` or `host/owner/slug` | `omercnet/dracula` |
| Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` |
| GitHub repository | `github:owner/repository` | `github:acme/paseo-review` |
| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` |
| Git repository | `git:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` |
| npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` |
| Plugin below a source root | Append `:relative/plugin/path` to an explicit npm/Git source | `github:acme/monorepo:plugins/review` |
| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` |
Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use
`user@host:path`. `file://` selects Git acquisition, not directory installation.
@@ -2283,12 +2282,16 @@ Paseo resolves an identifier in this order:
across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix
that does not satisfy these rules stays part of the identifier.
4. Without an explicit prefix, an existing directory matching the remaining source wins.
5. Resolve bare `owner/slug` and `host/owner/slug` through the plugin registry. Registry records
own the artifact revision and plugin path. GitHub shorthand requires `github:`; `git:` also
accepts shorthand, URLs, and SCP sources.
5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub
HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources.
6. Resolve a remaining npm package name with its optional selector through the host's registry.
Reject anything else.
Plugin registry installs are off by default. When the daemon enables them with
`pluginRegistryEnabled: true` or `PASEO_PLUGIN_REGISTRY_ENABLED=1`, bare `owner/slug` and
`host/owner/slug` resolve through the plugin registry instead of GitHub, the registry record owns
the revision and plugin path, and GitHub shorthand requires `github:`.
Directory lookup happens on the daemon host. The app uses the `paseo-plugin.json` ID; the CLI
accepts `--id <runtime-id>` to override it. An existing installation ID is rejected without changing
its enabled state or files.
@@ -2334,9 +2337,9 @@ deletes its managed files; removing a directory plugin keeps your source directo
paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add github:owner/repository
paseo plugin add owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add github:owner/monorepo:plugins/review
paseo plugin add owner/monorepo:plugins/review
paseo plugin ls [id]
paseo plugin update <id>
paseo plugin update --all --check