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[] {