From 1463396ebca49782cfa8aad036b6b3a50e196714 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 23:50:27 +0200 Subject: [PATCH] fix(website): plugin search ranks name matches first Search filtered by substring and the browse page then sorted by installs, so an exact name like "OMP" landed behind plugins that only mentioned it in a description. Results now rank by the field matched (name, author, description), then match quality from the shared text matcher, with the tab's order breaking ties. --- packages/website/src/plugins/browse-page.tsx | 11 ++--- packages/website/src/plugins/registry.test.ts | 26 ++++++++++++ packages/website/src/plugins/registry.ts | 42 +++++++++++++++---- 3 files changed, 65 insertions(+), 14 deletions(-) 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[] {