mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user