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.
This commit is contained in:
Mohamed Boudra
2026-10-06 23:50:27 +02:00
parent d4ea77b910
commit 1463396ebc
3 changed files with 65 additions and 14 deletions
+6 -5
View File
@@ -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({
<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}
{results.length}
</span>
</h1>
{query.q && (
@@ -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");
+33 -9
View File
@@ -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[] {