diff --git a/src/plugin-catalog.test.ts b/src/plugin-catalog.test.ts index eedb6dff..8ee62a6e 100644 --- a/src/plugin-catalog.test.ts +++ b/src/plugin-catalog.test.ts @@ -119,4 +119,28 @@ describe('plugin catalog', () => { expect(result.plugins.map((plugin) => plugin.name)).toEqual(['flights']); expect(result.errors).toEqual([{ sourceId: 'bad', manifestUrl: 'https://bad.test/webcmd-plugin.json', message: 'network failed' }]); }); + + it('matches multi-word queries against a single-word plugin name, ignoring separators', async () => { + const catalog: PluginCatalog = { + version: 1, + sources: [{ id: 'ok', source: 'github:ok/repo', manifestUrl: 'https://ok.test/webcmd-plugin.json' }], + }; + const fetchJson = async () => ({ plugins: { hackernews: { path: 'plugins/hackernews', description: 'Browse Hacker News stories and comments' } } }); + + for (const query of ['hacker news', 'hacker-news', 'hackernews', 'hacker']) { + const result = await searchCatalogPlugins(catalog, { query, fetchJson }); + expect(result.plugins.map((plugin) => plugin.name)).toEqual(['hackernews']); + } + }); + + it('requires every query token to match, not just one', async () => { + const catalog: PluginCatalog = { + version: 1, + sources: [{ id: 'ok', source: 'github:ok/repo', manifestUrl: 'https://ok.test/webcmd-plugin.json' }], + }; + const fetchJson = async () => ({ plugins: { hackernews: { path: 'plugins/hackernews', description: 'Browse Hacker News stories and comments' } } }); + + const result = await searchCatalogPlugins(catalog, { query: 'hacker weather', fetchJson }); + expect(result.plugins).toEqual([]); + }); }); diff --git a/src/plugin-catalog.ts b/src/plugin-catalog.ts index c8b009f3..1a32e9ac 100644 --- a/src/plugin-catalog.ts +++ b/src/plugin-catalog.ts @@ -133,6 +133,21 @@ export function flattenPluginManifest(source: PluginCatalogSource, manifest: Plu }]; } +function normalizeSearchText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +function tokenizeSearchQuery(query: string | undefined): string[] { + return (query ?? '').trim().toLowerCase().split(/\s+/).map(normalizeSearchText).filter(Boolean); +} + +// Ignoring separators (spaces, hyphens) lets "hacker news", "hacker-news", and "hackernews" +// all match a plugin named "hackernews" — every token must appear somewhere in the haystack. +function matchesSearchTokens(plugin: PluginSearchRow, tokens: string[]): boolean { + const haystack = normalizeSearchText(`${plugin.name} ${plugin.description ?? ''}`); + return tokens.every((token) => haystack.includes(token)); +} + export async function searchCatalogPlugins( catalog: PluginCatalog, options: { query?: string; fetchJson?: FetchJson } = {}, @@ -150,9 +165,9 @@ export async function searchCatalogPlugins( } })); - const query = options.query?.trim().toLowerCase(); - const filtered = query - ? plugins.filter((plugin) => `${plugin.name} ${plugin.description ?? ''}`.toLowerCase().includes(query)) + const queryTokens = tokenizeSearchQuery(options.query); + const filtered = queryTokens.length + ? plugins.filter((plugin) => matchesSearchTokens(plugin, queryTokens)) : plugins; filtered.sort((a, b) => a.name.localeCompare(b.name));