From c062280429c2aa894d98ce8d5c8a270c519085c8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 4 Oct 2026 20:38:39 +0100 Subject: [PATCH] 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/ 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= (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 --- docs/plugins.md | 15 +- packages/cli/src/commands/plugin/index.ts | 2 +- packages/server/src/server/bootstrap.ts | 2 + .../server/src/server/config-environment.ts | 1 + packages/server/src/server/config.test.ts | 9 + packages/server/src/server/config.ts | 4 + .../server/src/server/persisted-config.ts | 1 + .../plugins/managed-source.posix.test.ts | 69 +++- .../src/server/plugins/managed-source.ts | 18 +- .../server/plugins/managed-source/registry.ts | 2 + packages/website/e2e/plugins.spec.ts | 213 ++++++---- .../website/src/components/site-footer.tsx | 11 +- .../website/src/components/site-shell.tsx | 11 +- packages/website/src/plugins/browse-page.tsx | 214 ++++++++++ .../website/src/plugins/category-link.tsx | 36 -- .../website/src/plugins/contribute-links.tsx | 17 + packages/website/src/plugins/installs.ts | 73 ++++ packages/website/src/plugins/links.ts | 48 +++ packages/website/src/plugins/loader.ts | 19 +- packages/website/src/plugins/plugin-card.tsx | 137 +++++-- .../src/plugins/plugin-page-header.tsx | 10 - .../website/src/plugins/plugin-search.tsx | 53 +++ packages/website/src/plugins/published.ts | 47 ++- packages/website/src/plugins/registry.test.ts | 98 ++++- packages/website/src/plugins/registry.ts | 75 ++-- .../website/src/plugins/window-switch.tsx | 35 ++ packages/website/src/routeTree.gen.ts | 48 ++- .../website/src/routes/plugins/$owner.tsx | 14 +- .../src/routes/plugins/$owner_.$slug.tsx | 237 +++-------- packages/website/src/routes/plugins/all.tsx | 43 ++ .../src/routes/plugins/category/$slug.tsx | 52 +++ packages/website/src/routes/plugins/index.tsx | 383 ++++++++---------- public-docs/plugins/reference.md | 19 +- 33 files changed, 1335 insertions(+), 681 deletions(-) create mode 100644 packages/website/src/plugins/browse-page.tsx delete mode 100644 packages/website/src/plugins/category-link.tsx create mode 100644 packages/website/src/plugins/contribute-links.tsx create mode 100644 packages/website/src/plugins/installs.ts create mode 100644 packages/website/src/plugins/links.ts delete mode 100644 packages/website/src/plugins/plugin-page-header.tsx create mode 100644 packages/website/src/plugins/plugin-search.tsx create mode 100644 packages/website/src/plugins/window-switch.tsx create mode 100644 packages/website/src/routes/plugins/all.tsx create mode 100644 packages/website/src/routes/plugins/category/$slug.tsx diff --git a/docs/plugins.md b/docs/plugins.md index 1093381c61..510ef68af1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 ``` diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 4250ba8620..993c690f68 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -218,7 +218,7 @@ export function createPluginCommand(): Command { .description("Trust and install a plugin from a directory, Git repository, or npm package") .argument( "", - "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 ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3f49f41a19..e63ce89fb3 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -414,6 +414,7 @@ export interface PaseoDaemonConfig { plugins?: Record; 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, }), diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts index 24134035de..2442ee5dc5 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -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", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index a71b5621e8..02a315c9a5 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -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 }); } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7b2d06560e..61481ce1f9 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -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"), diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 79e3d97218..f924a88ebc 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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(), diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 7723721eb4..40878ae74e 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -36,6 +36,25 @@ async function commitAll(repository: string, message: string): Promise { return stdout.trim(); } +/** Points https://github.com/fixture/repository.git at a local repository for the callback. */ +async function withGitHubFixture(repository: string, run: () => Promise): Promise { + 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 = []; + const server = createServer((req, res) => { + requests.push(req.url); + res.statusCode = 404; + res.end(); + }); + await new Promise((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((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((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( diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 366d8d53b0..8ee3b2b6f6 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -147,7 +147,9 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise { - 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`; } diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts index d57d0aea79..179bdc5da7 100644 --- a/packages/server/src/server/plugins/managed-source/registry.ts +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -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; } diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index e3b2eeb115..d2066bca61 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -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("https://paseo.sh/plugins/omercnet"); - expect(await plugins.text()).toContain( - "https://paseo.sh/plugins/omercnet/fresh-worktrees", - ); + const sitemap = await plugins.text(); + expect(sitemap).toContain("https://paseo.sh/plugins/all"); + expect(sitemap).toContain("https://paseo.sh/plugins/category/git"); + expect(sitemap).toContain("https://paseo.sh/plugins/omercnet"); + expect(sitemap).toContain("https://paseo.sh/plugins/omercnet/fresh-worktrees"); }); }); +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/]); }); }); diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index 42ee404279..62daa6ebd0 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -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, 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 (