diff --git a/apps/docs/features/SearchV2/SearchV2.utils.test.tsx b/apps/docs/features/SearchV2/SearchV2.utils.test.tsx new file mode 100644 index 00000000000..d79b031082c --- /dev/null +++ b/apps/docs/features/SearchV2/SearchV2.utils.test.tsx @@ -0,0 +1,79 @@ +import { load } from 'cheerio' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { formatHeadingPath, highlightMatches } from './SearchV2.utils' + +describe('formatHeadingPath', () => { + it('joins multiple headings with " > "', () => { + expect(formatHeadingPath(['Title 1', 'Title 2'])).toBe('Title 1 > Title 2') + }) + + it('returns a single-element path unchanged', () => { + expect(formatHeadingPath(['Title 1'])).toBe('Title 1') + }) + + it('returns an empty string for an empty path', () => { + expect(formatHeadingPath([])).toBe('') + }) +}) + +function renderHighlight(text: string, query: string) { + const result = highlightMatches(text, query) + if (typeof result === 'string') return { html: result, strongTexts: [] as string[] } + + const html = renderToStaticMarkup(<>{result}) + const $ = load(html) + return { + html: $.root().text(), + strongTexts: $('strong') + .map((_, el) => $(el).text()) + .get(), + } +} + +describe('highlightMatches', () => { + it('highlights a single case-insensitive partial match', () => { + const result = highlightMatches('Bring your own MCP', 'mcp server') + expect(typeof result).not.toBe('string') + + const { strongTexts, html } = renderHighlight('Bring your own MCP', 'mcp server') + expect(strongTexts).toEqual(['MCP']) + expect(html).toBe('Bring your own MCP') + }) + + it('highlights multiple non-overlapping token matches independently', () => { + const { strongTexts } = renderHighlight('MCP servers for your server', 'mcp server') + expect(strongTexts).toEqual(['MCP', 'server', 'server']) + }) + + it('merges overlapping/adjacent matches into a single run', () => { + const { strongTexts } = renderHighlight('server', 'server serv') + expect(strongTexts).toEqual(['server']) + }) + + it('returns the original string unchanged when there is no match', () => { + const result = highlightMatches('Bring your own MCP', 'unrelated') + expect(result).toBe('Bring your own MCP') + }) + + it('is case-insensitive but preserves the original casing of the matched text', () => { + const { strongTexts } = renderHighlight('Bring your own MCP', 'MCP') + expect(strongTexts).toEqual(['MCP']) + }) + + it('returns the text unchanged for an empty or whitespace-only query', () => { + expect(highlightMatches('Bring your own MCP', '')).toBe('Bring your own MCP') + expect(highlightMatches('Bring your own MCP', ' ')).toBe('Bring your own MCP') + }) + + it('excludes common prepositions/articles/conjunctions from highlighting', () => { + const { strongTexts } = renderHighlight('The best MCP server for you', 'the mcp server') + expect(strongTexts).toEqual(['MCP', 'server']) + }) + + it('returns the text unchanged when the query is made up entirely of ignored words', () => { + const result = highlightMatches('The best MCP server', 'the of') + expect(result).toBe('The best MCP server') + }) +}) diff --git a/apps/docs/features/SearchV2/SearchV2.utils.tsx b/apps/docs/features/SearchV2/SearchV2.utils.tsx new file mode 100644 index 00000000000..d7db0f83e3c --- /dev/null +++ b/apps/docs/features/SearchV2/SearchV2.utils.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from 'react' + +/** Common English prepositions/articles/conjunctions, excluded from highlighting so a query like "the mcp server" doesn't bold "the". */ +const IGNORED_WORDS = new Set([ + 'a', + 'an', + 'the', + 'and', + 'or', + 'but', + 'of', + 'in', + 'on', + 'at', + 'to', + 'for', + 'from', + 'by', + 'with', + 'as', +]) + +/** Join a heading breadcrumb into a single display string, e.g. ['Title 1', 'Title 2'] -> 'Title 1 > Title 2'. */ +function formatHeadingPath(headingPath: string[]): string { + return headingPath.join(' > ') +} + +/** Case-insensitive, whitespace-split match ranges for every occurrence of every query token in text. */ +function getMatchRanges(text: string, query: string): Array<[number, number]> { + const tokens = query + .split(/\s+/) + .map((token) => token.trim()) + .filter((token) => token.length > 0 && !IGNORED_WORDS.has(token.toLowerCase())) + if (tokens.length === 0) return [] + + const lowerText = text.toLowerCase() + const ranges: Array<[number, number]> = [] + + for (const token of tokens) { + const lowerToken = token.toLowerCase() + let fromIndex = 0 + while (fromIndex <= lowerText.length) { + const idx = lowerText.indexOf(lowerToken, fromIndex) + if (idx === -1) break + ranges.push([idx, idx + lowerToken.length]) + fromIndex = idx + lowerToken.length + } + } + + if (ranges.length === 0) return [] + + ranges.sort((a, b) => a[0] - b[0]) + const merged: Array<[number, number]> = [ranges[0]] + for (const [start, end] of ranges.slice(1)) { + const last = merged[merged.length - 1] + if (start <= last[1]) { + last[1] = Math.max(last[1], end) + } else { + merged.push([start, end]) + } + } + return merged +} + +/** + * Highlight every case-insensitive, per-word partial match of `query` inside `text`. + * Returns the plain string when there's no match, otherwise a fragment with matches + * wrapped in , preserving the original casing of `text`. + */ +function highlightMatches(text: string, query: string): ReactNode { + const ranges = getMatchRanges(text, query) + if (ranges.length === 0) return text + + const nodes: ReactNode[] = [] + let cursor = 0 + ranges.forEach(([start, end], i) => { + if (start > cursor) nodes.push(text.slice(cursor, start)) + nodes.push({text.slice(start, end)}) + cursor = end + }) + if (cursor < text.length) nodes.push(text.slice(cursor)) + + return <>{nodes} +} + +export { formatHeadingPath, highlightMatches } diff --git a/apps/docs/features/SearchV2/SearchV2Dialog.tsx b/apps/docs/features/SearchV2/SearchV2Dialog.tsx index 63fa0640243..87a5ca3907a 100644 --- a/apps/docs/features/SearchV2/SearchV2Dialog.tsx +++ b/apps/docs/features/SearchV2/SearchV2Dialog.tsx @@ -4,7 +4,7 @@ import { useDocsSearchV2, type DocsSearchV2Result } from 'common' import { Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { VisuallyHidden } from 'radix-ui' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Command, CommandEmpty, @@ -18,6 +18,8 @@ import { DialogTitle, } from 'ui' +import { formatHeadingPath, highlightMatches } from './SearchV2.utils' + interface SearchV2DialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -26,12 +28,23 @@ interface SearchV2DialogProps { export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { const router = useRouter() const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2() + const [highlightQuery, setHighlightQuery] = useState('') // Clear stale results once the dialog closes useEffect(() => { if (!open) resetSearch() }, [open, resetSearch]) + // Only update the highlighted query once a new result set actually lands, so highlights + // don't shift on every keystroke while the debounced search is still in flight. + useEffect(() => { + if (searchState.status === 'results' || searchState.status === 'noResults') { + setHighlightQuery(searchState.query) + } else if (searchState.status === 'initial') { + setHighlightQuery('') + } + }, [searchState]) + const results: DocsSearchV2Result[] = 'results' in searchState ? searchState.results @@ -113,10 +126,12 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { onSelect={() => handleSelect(page.path)} >
- {page.title} - {(page.heading !== page.title ? page.heading : page.excerpt) && ( + + {highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)} + + {page.excerpt && ( - {page.heading !== page.title ? page.heading : page.excerpt} + {highlightMatches(page.excerpt, highlightQuery)} )}
diff --git a/packages/common/hooks/useDocsSearchV2.ts b/packages/common/hooks/useDocsSearchV2.ts index ce15eb748c6..5036c1a5566 100644 --- a/packages/common/hooks/useDocsSearchV2.ts +++ b/packages/common/hooks/useDocsSearchV2.ts @@ -16,24 +16,36 @@ interface DocsSearchV2Result { title: string heading: string excerpt: string + headingPath: string[] + score: number } type SearchState = | { status: 'initial'; key: number } | { status: 'loading'; key: number; staleResults: DocsSearchV2Result[] } - | { status: 'results'; key: number; results: DocsSearchV2Result[] } - | { status: 'noResults'; key: number } + | { status: 'results'; key: number; results: DocsSearchV2Result[]; query: string } + | { status: 'noResults'; key: number; query: string } | { status: 'error'; key: number; message: string } type Action = - | { type: 'resultsReturned'; key: number; results: DocsSearchV2Result[] } + | { type: 'resultsReturned'; key: number; results: DocsSearchV2Result[]; query: string } | { type: 'newSearchDispatched'; key: number } | { type: 'reset'; key: number } | { type: 'errored'; key: number; message: string } function reshapeResult(row: unknown): DocsSearchV2Result | null { if (typeof row !== 'object' || row === null) return null - if (!('slug' in row && 'page_title' in row && 'heading' in row && 'excerpt' in row)) return null + if ( + !( + 'slug' in row && + 'page_title' in row && + 'heading' in row && + 'excerpt' in row && + 'heading_path' in row && + 'score' in row + ) + ) + return null const slug = row.slug as string return { @@ -43,6 +55,8 @@ function reshapeResult(row: unknown): DocsSearchV2Result | null { title: row.page_title as string, heading: row.heading as string, excerpt: row.excerpt as string, + headingPath: row.heading_path as string[], + score: row.score as number, } } @@ -54,8 +68,8 @@ function reducer(state: SearchState, action: Action): SearchState { switch (action.type) { case 'resultsReturned': return action.results.length - ? { status: 'results', key: action.key, results: action.results } - : { status: 'noResults', key: action.key } + ? { status: 'results', key: action.key, results: action.results, query: action.query } + : { status: 'noResults', key: action.key, query: action.query } case 'newSearchDispatched': return { status: 'loading', @@ -92,6 +106,7 @@ const useDocsSearchV2 = () => { type: 'resultsReturned', key: localKey, results: compact(data.map(reshapeResult)), + query: query.trim(), }) } catch (error) { console.error(`[ERROR] Error fetching docs search v2 results: ${error}`)