180 lines
4.7 KiB
TypeScript
180 lines
4.7 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 {
|
||
|
|
getDocHref,
|
||
|
|
getDocPackages,
|
||
|
|
getDocSearchText,
|
||
|
|
getDocSectionLabel,
|
||
|
|
} from "../content/registry"
|
||
|
|
import type {
|
||
|
|
DocLocale,
|
||
|
|
DocPackage,
|
||
|
|
DocPage,
|
||
|
|
DocTocItem,
|
||
|
|
} 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 {
|
||
|
|
const docPackages = getDocPackages(locale)
|
||
|
|
|
||
|
|
return {
|
||
|
|
async search({ query, signal }) {
|
||
|
|
const normalizedQuery = normalize(query)
|
||
|
|
if (!normalizedQuery) return { groups: [] }
|
||
|
|
|
||
|
|
const terms = normalizedQuery.split(/\s+/).filter(Boolean)
|
||
|
|
const groups = docPackages.flatMap((docPackage) => {
|
||
|
|
const items = docPackage.pages
|
||
|
|
.map((page) => ({
|
||
|
|
item: createResultItem(docPackage, page, locale),
|
||
|
|
score: getMatchScore(
|
||
|
|
normalizedQuery,
|
||
|
|
terms,
|
||
|
|
docPackage.label,
|
||
|
|
page
|
||
|
|
),
|
||
|
|
}))
|
||
|
|
.filter((result) => result.score >= 0)
|
||
|
|
.sort(
|
||
|
|
(left, right) =>
|
||
|
|
right.score - left.score ||
|
||
|
|
left.item.title.localeCompare(right.item.title, locale)
|
||
|
|
)
|
||
|
|
.map((result) => result.item)
|
||
|
|
|
||
|
|
return items.length > 0
|
||
|
|
? [
|
||
|
|
{
|
||
|
|
id: docPackage.key,
|
||
|
|
label: docPackage.label,
|
||
|
|
items,
|
||
|
|
total: items.length,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
: []
|
||
|
|
})
|
||
|
|
|
||
|
|
if (signal.aborted) return { groups: [] }
|
||
|
|
return { groups }
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function createResultItem(
|
||
|
|
docPackage: DocPackage,
|
||
|
|
page: DocPage,
|
||
|
|
locale: DocLocale
|
||
|
|
): SearchResultItem {
|
||
|
|
return {
|
||
|
|
id: `${page.section}/${page.slug}`,
|
||
|
|
title: page.title,
|
||
|
|
description: page.description,
|
||
|
|
keywords: [
|
||
|
|
docPackage.label,
|
||
|
|
getDocSectionLabel(page.section, locale),
|
||
|
|
...page.toc.flatMap(flattenTocTitles),
|
||
|
|
getDocSearchText(page),
|
||
|
|
],
|
||
|
|
meta: [getDocSectionLabel(page.section, locale)],
|
||
|
|
payload: {
|
||
|
|
href: getDocHref(docPackage, page),
|
||
|
|
} satisfies DocsSearchPayload,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getMatchScore(
|
||
|
|
query: string,
|
||
|
|
terms: readonly string[],
|
||
|
|
packageLabel: string,
|
||
|
|
page: DocPage
|
||
|
|
) {
|
||
|
|
const title = normalize(page.title)
|
||
|
|
const description = normalize(page.description)
|
||
|
|
const packageName = normalize(packageLabel)
|
||
|
|
const toc = normalize(page.toc.flatMap(flattenTocTitles).join(" "))
|
||
|
|
const body = normalize(getDocSearchText(page))
|
||
|
|
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 (page.section === "overview") score += 2
|
||
|
|
|
||
|
|
return score
|
||
|
|
}
|
||
|
|
|
||
|
|
function flattenTocTitles(item: DocTocItem): readonly string[] {
|
||
|
|
return [item.title, ...(item.items?.flatMap(flattenTocTitles) ?? [])]
|
||
|
|
}
|
||
|
|
|
||
|
|
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"))
|
||
|
|
}
|