import { I18nProvider } from "@workspace/i18n" import { SearchDialog, SearchProvider, type SearchAdapter, type SearchResultItem, } from "@workspace/search" import { messages as searchMessagesEnUS } from "@workspace/search/locales/en-US" import { messages as searchMessagesZhHans } from "@workspace/search/locales/zh-Hans" import React from "react" import { loadDocSearchIndex } from "../content/search-index.client" import type { DocLocale, DocSearchEntry } from "../content/types" import { useLocale } from "../lib/locale" const searchCatalogs = { "en-US": searchMessagesEnUS, "zh-Hans": searchMessagesZhHans, } as const type DocsSearchPayload = { href: string } export function DocsSearchProvider({ children, }: { children: React.ReactNode }) { const locale = useLocale() const adapter = React.useMemo(() => createDocsSearchAdapter(locale), [locale]) return ( { if (isDocsSearchPayload(item.payload)) navigate(item.payload.href) }} > {children} ) } function createDocsSearchAdapter(locale: DocLocale): SearchAdapter { return { async search({ query, signal }) { const normalizedQuery = normalize(query) if (!normalizedQuery) return { groups: [] } const index = await loadDocSearchIndex(locale) if (signal.aborted) return { groups: [] } const terms = normalizedQuery.split(/\s+/).filter(Boolean) const groups = new Map< string, { id: string items: Array<{ item: SearchResultItem; score: number }> label: string total: number } >() for (const entry of index) { const score = getMatchScore(normalizedQuery, terms, entry) if (score < 0) continue const group = groups.get(entry.packageKey) ?? { id: entry.packageKey, items: [], label: entry.packageLabel, total: 0, } group.items.push({ item: createResultItem(entry), score }) group.total += 1 groups.set(entry.packageKey, group) } const results = Array.from(groups.values(), (group) => ({ id: group.id, label: group.label, total: group.total, items: group.items .toSorted( (left, right) => right.score - left.score || left.item.title.localeCompare(right.item.title, locale) ) .map((result) => result.item), })) if (signal.aborted) return { groups: [] } return { groups: results } }, } } function createResultItem(entry: DocSearchEntry): SearchResultItem { return { id: entry.id, title: entry.title, description: entry.description, keywords: [ entry.packageLabel, entry.sectionLabel, ...entry.toc, entry.body, ], meta: [entry.sectionLabel], payload: { href: entry.href, } satisfies DocsSearchPayload, } } function getMatchScore( query: string, terms: readonly string[], entry: DocSearchEntry ) { const title = normalize(entry.title) const description = normalize(entry.description) const packageName = normalize(entry.packageLabel) const toc = normalize(entry.toc.join(" ")) const body = normalize(entry.body) const searchableText = [packageName, title, description, toc, body].join(" ") if (!terms.every((term) => searchableText.includes(term))) return -1 let score = 0 if (title === query) score += 100 else if (title.startsWith(query)) score += 70 else if (title.includes(query)) score += 50 if (packageName === query) score += 40 else if (packageName.includes(query)) score += 20 if (description.includes(query)) score += 15 if (toc.includes(query)) score += 10 if (entry.id === "overview/overview") score += 2 return score } function normalize(value: string) { return value.normalize("NFKC").toLocaleLowerCase().trim() } function isDocsSearchPayload(value: unknown): value is DocsSearchPayload { return ( typeof value === "object" && value !== null && "href" in value && typeof value.href === "string" ) } function navigate(href: string) { const destination = new URL(href, window.location.href) if (destination.href === window.location.href) return window.history.pushState(null, "", destination) window.scrollTo({ left: 0, top: 0 }) window.dispatchEvent(new PopStateEvent("popstate")) }