mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
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:
+10
-5
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user