169 lines
4.6 KiB
TypeScript
169 lines
4.6 KiB
TypeScript
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 (
|
|
<I18nProvider catalogs={searchCatalogs} locale={locale}>
|
|
<SearchProvider
|
|
adapter={adapter}
|
|
historyStorageKey={`docs-search-history:${locale}`}
|
|
onSelect={({ item }) => {
|
|
if (isDocsSearchPayload(item.payload)) navigate(item.payload.href)
|
|
}}
|
|
>
|
|
{children}
|
|
<SearchDialog className="font-sans" />
|
|
</SearchProvider>
|
|
</I18nProvider>
|
|
)
|
|
}
|
|
|
|
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"))
|
|
}
|