diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 1b91c0a94d..274c68a5c0 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -47,10 +47,11 @@ export function BrowsePage({ query: BrowseQuery; }) { const category = query.category ? getCategory(query.category) : null; - const matches = query.q ? searchPlugins(plugins, query.q) : plugins; - const scoped = category ? getPluginsInCategory(matches, category.slug) : matches; - const results = - query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window); + const ordered = + query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window); + // Search ranks by relevance; the tab's order breaks ties. + const matches = query.q ? searchPlugins(ordered, query.q) : ordered; + const results = category ? getPluginsInCategory(matches, category.slug) : matches; const title = category?.label ?? "All plugins"; const clearHref = browseHref({ ...query, q: undefined }); return ( @@ -65,7 +66,7 @@ export function BrowsePage({

{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title} - {scoped.length} + {results.length}

{query.q && ( diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index ca678f6453..b6135351b3 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -228,6 +228,32 @@ describe("plugin registry", () => { expect(searchPlugins(plugins, " ")).toEqual(plugins); expect(searchPlugins(plugins, "nothing")).toEqual([]); }); + it("ranks name, then author, then description matches, keeping the given order on ties", () => { + const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({ + ...plugin, + id, + name, + description, + author: { github }, + }); + // Given in install order: description-only matches first, the exact name last. + const plugins = [ + named("acme/editor", "Remote Editor", "Composer pill to open a workspace"), + named("acme/kit", "PromptKit", "Rewrite prompts"), + named("omp/tools", "Tools", "Helpers", "omp"), + named("acme/history", "History", "Inspect raw composer records"), + named("acme/omp", "OMP", "Paseo integration for OMP"), + ]; + expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([ + "acme/omp", + "acme/kit", + "omp/tools", + "acme/editor", + "acme/history", + ]); + expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]); + expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]); + }); it("strips the README title and quoted description that the page already shows", () => { expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n"); diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts index c06ea3dc5c..13bfc0d1ca 100644 --- a/packages/website/src/plugins/registry.ts +++ b/packages/website/src/plugins/registry.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { CATEGORIES, type Category, type CategorySlug } from "./categories"; import type { InstallCounts, InstallWindow } from "./installs"; export { CATEGORIES, type Category, type CategorySlug }; @@ -66,16 +67,39 @@ export function formatInstalls(count: number): string { const k = count / 1000; return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; } -/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */ +/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */ +function searchFieldGroups(plugin: Plugin): string[][] { + const author = getAuthor(plugin); + const name = [plugin.name, plugin.id.split("/")[1]]; + const owner = [author.name, author.github, plugin.id]; + return [name, owner, [plugin.description], [...name, ...owner, plugin.description]]; +} +interface SearchRank { + field: number; + tier: number; +} +function searchRank(plugin: Plugin, term: string): SearchRank | null { + const groups = searchFieldGroups(plugin); + for (let field = 0; field < groups.length; field += 1) { + const score = scoreTextFields(term, groups[field], { subsequence: false }); + if (score) return { field, tier: score.tier }; + } + return null; +} +/** + * Plugins whose name, author, ID, or description contains every word of the term, ignoring case. + * Name matches come first, then author, then description; within each, exact words beat partial + * ones, and equal matches keep the given order. + */ export function searchPlugins(plugins: Plugin[], term: string): Plugin[] { - const needle = term.trim().toLowerCase(); - if (!needle) return plugins; - return plugins.filter((plugin) => - [plugin.name, plugin.description, plugin.id, getAuthor(plugin).name] - .join(" ") - .toLowerCase() - .includes(needle), - ); + if (!term.trim()) return plugins; + return plugins + .flatMap((plugin) => { + const rank = searchRank(plugin, term); + return rank ? [{ plugin, rank }] : []; + }) + .sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier) + .map(({ plugin }) => plugin); } /** The featured IDs that name a listed plugin, in the registry's order, each once. */ export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] {