Files
simple-react-app-kit/docs/src/components/docs-search-provider.tsx
T

169 lines
4.6 KiB
TypeScript
Raw Normal View History

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"))
}