feat: Add edge function and hooks for search V2 (#51103)

This commit is contained in:
Jeremias Menichelli authored and GitHub committed 2026-09-30 18:12:07 -03:00
1 parent 8696762b4b
commit 99103b3571
10 files changed
+409 -10

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'content-type',
}
+32
View File
@@ -0,0 +1,32 @@
import { supabaseSearchV2 } from '~/scripts/search_v2/client'
import { type NextRequest } from 'next/server'
import { corsHeaders } from './cors'
export const runtime = 'edge'
export async function OPTIONS() {
return new Response(null, { headers: corsHeaders })
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q')?.trim()
const limit = Number(searchParams.get('limit')) || 10
if (!query) {
return Response.json({ error: 'Missing q parameter' }, { status: 400, headers: corsHeaders })
}
const { data, error } = await supabaseSearchV2().rpc('search_docs', {
query_text: query,
match_limit: limit,
})
if (error) {
console.error('Error running docs search v2:', error)
return Response.json({ error: error.message }, { status: 500, headers: corsHeaders })
}
return Response.json(data, { headers: corsHeaders })
}
@@ -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')
})
})
@@ -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 <strong>, 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(<strong key={i}>{text.slice(start, end)}</strong>)
cursor = end
})
if (cursor < text.length) nodes.push(text.slice(cursor))
return <>{nodes}</>
}
export { formatHeadingPath, highlightMatches }
+24 -9
View File
@@ -1,10 +1,10 @@
'use client'
import { useDocsSearch, type DocsSearchResult } from 'common'
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
@@ -25,14 +27,25 @@ interface SearchV2DialogProps {
export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) {
const router = useRouter()
const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearch()
const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2()
const [highlightQuery, setHighlightQuery] = useState('')
// Clear stale results once the dialog closes
useEffect(() => {
if (!open) resetSearch()
}, [open, resetSearch])
const results: DocsSearchResult[] =
// 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
: 'staleResults' in searchState
@@ -107,16 +120,18 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) {
<CommandGroup heading="Results" forceMount>
{results.map((page) => (
<CommandItem
key={page.id}
value={String(page.id)}
key={page.path}
value={page.path}
forceMount
onSelect={() => handleSelect(page.path)}
>
<div className="flex flex-col">
<span className="text-sm">{page.title}</span>
{(page.description || page.subtitle) && (
<span className="text-sm">
{highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)}
</span>
{page.excerpt && (
<span className="text-xs text-foreground-muted">
{page.description || page.subtitle}
{highlightMatches(page.excerpt, highlightQuery)}
</span>
)}
</div>
@@ -3,7 +3,6 @@
import { useFeatureFlags, useSearchParamsShallow } from 'common'
import { SEARCH_V2_FLAG, type SearchV2Variant } from './constants'
import { IS_PRODUCTION } from '@/lib/constants'
const VARIANTS: SearchV2Variant[] = ['control', 'search-v2-active']
+40
View File
@@ -0,0 +1,40 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
// Search V2 lives in its own Supabase project (see ./sql/setup.sql), separate from the main
// docs content DB `lib/supabase.ts` connects to — so it needs its own client and its own
// hand-written Database type (there's no generated schema for this project).
export type Database = {
public: {
Tables: Record<string, never>
Views: Record<string, never>
Functions: {
search_docs: {
Args: { query_text: string; match_limit?: number }
Returns: Array<{
slug: string
page_title: string
heading: string
heading_level: number
heading_path: string[]
excerpt: string
score: number
}>
}
}
Enums: Record<string, never>
CompositeTypes: Record<string, never>
}
}
let _supabaseSearchV2: SupabaseClient<Database>
export function supabaseSearchV2() {
if (!_supabaseSearchV2) {
_supabaseSearchV2 = createClient<Database>(
`https://${process.env.SEARCH_V2_SUPABASE_PROJECT_ID}.supabase.co`,
process.env.SEARCH_V2_SUPABASE_SECRET_KEY!
)
}
return _supabaseSearchV2
}
+2
View File
@@ -82,6 +82,8 @@
"LOGFLARE_INGESTION_API_KEY",
"LOGFLARE_SOURCE_TOKEN",
"OPENAI_API_KEY",
"SEARCH_V2_SUPABASE_PROJECT_ID",
"SEARCH_V2_SUPABASE_SECRET_KEY",
"SITE_NAME",
"SUPABASE_SECRET_KEY",
],