diff --git a/apps/svelte.dev/src/routes/search/+page.server.js b/apps/svelte.dev/src/routes/search/+page.server.js index a558f43c40..467f6d1751 100644 --- a/apps/svelte.dev/src/routes/search/+page.server.js +++ b/apps/svelte.dev/src/routes/search/+page.server.js @@ -11,7 +11,7 @@ export async function load({ url, fetch }) { const query = url.searchParams.get('q') ?? ''; - const results = query ? search(query) : []; + const results = query ? search(query, '') : []; return { query, diff --git a/packages/site-kit/src/lib/search/SearchBox.svelte b/packages/site-kit/src/lib/search/SearchBox.svelte index 7934af4c0f..706a2e2d73 100644 --- a/packages/site-kit/src/lib/search/SearchBox.svelte +++ b/packages/site-kit/src/lib/search/SearchBox.svelte @@ -10,6 +10,7 @@ It appears when the user clicks on the `Search` component or presses the corresp import Icon from '../components/Icon.svelte'; import SearchResults from './SearchResults.svelte'; import SearchWorker from './search-worker.js?worker'; + import { page } from '$app/stores'; interface Props { placeholder?: string; @@ -94,7 +95,14 @@ It appears when the user clicks on the `Search` component or presses the corresp const id = uid++; pending.add(id); - worker.postMessage({ type: 'query', id, payload: $search_query }); + worker.postMessage({ + type: 'query', + id, + payload: { + query: $search_query, + path: $page.url.pathname + } + }); } }); diff --git a/packages/site-kit/src/lib/search/search-worker.ts b/packages/site-kit/src/lib/search/search-worker.ts index 6534d038bb..95d163a9da 100644 --- a/packages/site-kit/src/lib/search/search-worker.ts +++ b/packages/site-kit/src/lib/search/search-worker.ts @@ -12,8 +12,8 @@ addEventListener('message', async (event) => { } if (type === 'query') { - const query = payload; - const results = search(query); + const { query, path } = payload; + const results = search(query, path); postMessage({ type: 'results', payload: { results, query } }); } diff --git a/packages/site-kit/src/lib/search/search.ts b/packages/site-kit/src/lib/search/search.ts index 235fececeb..56e022118f 100644 --- a/packages/site-kit/src/lib/search/search.ts +++ b/packages/site-kit/src/lib/search/search.ts @@ -44,56 +44,91 @@ export function init(blocks: Block[]) { inited = true; } +const CURRENT_SECTION_BOOST = 2; +const EXACT_MATCH_BOOST = 10; +const WORD_MATCH_BOOST = 4; +const NEAR_MATCH_BOOST = 2; +const BREADCRUMB_LENGTH_BOOST = 0.2; + +interface Entry { + block: Block; + score: number; + rank: number; +} + /** * Search for a given query in the existing index */ -export function search(query: string): BlockGroup[] { +export function search(query: string, path: string): BlockGroup[] { const escaped = query.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); - const regex = new RegExp(`(^|\\b)${escaped}`, 'i'); + const exact_match = new RegExp(`^${escaped}$`, 'i'); + const word_match = new RegExp(`(^|\\b)${escaped}($|\\b)`, 'i'); + const near_match = new RegExp(`(^|\\b)${escaped}`, 'i'); + + const parts = path.split('/'); const blocks = indexes .flatMap((index) => index.search(query)) // @ts-expect-error flexsearch types are wrong i think? .map(lookup) - .map((block, rank) => ({ block: block as Block, rank })) - .sort((a, b) => { - // If rank is way lower, give that priority - if (Math.abs(a.rank - b.rank) > 3) { - return a.rank - b.rank; + .map((block, rank) => { + const block_parts = block.href.split('/'); + + // prioritise current section + let score = block_parts.findIndex((part, i) => part !== parts[i]); + if (score === -1) score = block_parts.length; + score *= CURRENT_SECTION_BOOST; + + if (block.breadcrumbs.some((text) => exact_match.test(text))) { + console.log('EXACT MATCH', block.breadcrumbs); + score += EXACT_MATCH_BOOST; + } else if (block.breadcrumbs.some((text) => word_match.test(text))) { + score += WORD_MATCH_BOOST; + } else if (block.breadcrumbs.some((text) => near_match.test(text))) { + score += NEAR_MATCH_BOOST; } - const a_title_matches = regex.test(a.block.breadcrumbs.at(-1)!); - const b_title_matches = regex.test(b.block.breadcrumbs.at(-1)!); + // prioritise branches over leaves + score -= block.breadcrumbs.length * BREADCRUMB_LENGTH_BOOST; - // massage the order a bit, so that title matches - // are given higher priority - if (a_title_matches !== b_title_matches) { - return a_title_matches ? -1 : 1; - } + const entry: Entry = { block, score, rank }; - return a.block.breadcrumbs.length - b.block.breadcrumbs.length || a.rank - b.rank; - }) - .map(({ block }) => block); + return entry; + }); - const groups: Record = {}; + const grouped: Record = {}; - for (const block of blocks) { - const breadcrumbs = block.breadcrumbs.slice(0, 2); - - const group = (groups[breadcrumbs.join('::')] ??= { + for (const entry of blocks) { + const breadcrumbs = entry.block.breadcrumbs.slice(0, 2); + const group = (grouped[breadcrumbs.join('::')] ??= { breadcrumbs, - blocks: [] + entries: [] }); - group.blocks.push(block); + group.entries.push(entry); } - return Object.values(groups); + const sorted = Object.values(grouped); + + // sort blocks within groups... + for (const group of sorted) { + group.entries.sort((a, b) => b.score - a.score || a.rank - b.rank); + } + + // ...then sort groups + sorted.sort((a, b) => b.entries[0].score - a.entries[0].score); + + return sorted.map((group) => { + return { + breadcrumbs: group.breadcrumbs, + blocks: group.entries.map((entry) => entry.block) + }; + }); } /** * Get a block with details by its href */ export function lookup(href: string) { - return map.get(href); + return map.get(href)!; }