feat(docs): add bilingual package documentation site
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user