Files
paseo/packages/website/src/plugins/registry.ts
T
Mohamed Boudra c062280429 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
2026-10-04 20:38:39 +01:00

106 lines
4.0 KiB
TypeScript

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;
name: string;
github: string;
npm?: string;
}
const DAY_MS = 24 * 60 * 60 * 1000;
export function getCategory(slug: string): Category | null {
return CATEGORIES.find((category) => category.slug === slug) ?? null;
}
export function getPluginsInCategory(plugins: Plugin[], slug: string): Plugin[] {
return plugins.filter((plugin) => plugin.categories.includes(slug));
}
/** The namespace segment of the plugin ID. It is the route param for the plugin and its author. */
export function pluginOwner(plugin: Plugin): string {
return plugin.id.split("/")[0];
}
export function getAuthor(plugin: Plugin): Author {
const { author } = plugin;
return {
username: pluginOwner(plugin),
github: author.github,
name: author.name ?? author.github,
npm: author.npm,
};
}
export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] {
return plugins.filter((plugin) => pluginOwner(plugin) === owner);
}
export function installCommand(plugin: Plugin): string {
return `paseo plugin install ${plugin.id}`;
}
export function pluginVersion(plugin: Plugin): string {
return plugin.artifact.kind === "npm"
? plugin.artifact.version
: plugin.artifact.commit.slice(0, 12);
}
export function npmUrl(plugin: Plugin): string | undefined {
return plugin.artifact.kind === "npm"
? `https://www.npmjs.com/package/${plugin.artifact.package}`
: undefined;
}
export function authorNpmUrl(author: Author): string | undefined {
return author.npm ? `https://www.npmjs.com/~${author.npm}` : undefined;
}
export function authorGitHubUrl(author: Author): string {
return `https://github.com/${author.github}`;
}
export function authorAvatarUrl(author: Author): string {
return `https://github.com/${author.github}.png?size=80`;
}
export function formatInstalls(count: number): string {
if (count < 1000) return String(count);
const k = count / 1000;
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
}
/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */
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();
}