feat(docs): add static generation and search indexes

This commit is contained in:
Maofeng
2026-09-20 16:23:32 +08:00
parent a57fdbe7c6
commit 9635de56e8
19 changed files with 529 additions and 169 deletions
+56 -67
View File
@@ -9,18 +9,8 @@ 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 { loadDocSearchIndex } from "../content/search-index.client"
import type { DocLocale, DocSearchEntry } from "../content/types"
import { useLocale } from "../lib/locale"
const searchCatalogs = {
@@ -57,69 +47,73 @@ export function DocsSearchProvider({
}
function createDocsSearchAdapter(locale: DocLocale): SearchAdapter {
const docPackages = getDocPackages(locale)
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 = 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(
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)
return items.length > 0
? [
{
id: docPackage.key,
label: docPackage.label,
items,
total: items.length,
},
]
: []
})
.map((result) => result.item),
}))
if (signal.aborted) return { groups: [] }
return { groups }
return { groups: results }
},
}
}
function createResultItem(
docPackage: DocPackage,
page: DocPage,
locale: DocLocale
): SearchResultItem {
function createResultItem(entry: DocSearchEntry): SearchResultItem {
return {
id: `${page.section}/${page.slug}`,
title: page.title,
description: page.description,
id: entry.id,
title: entry.title,
description: entry.description,
keywords: [
docPackage.label,
getDocSectionLabel(page.section, locale),
...page.toc.flatMap(flattenTocTitles),
getDocSearchText(page),
entry.packageLabel,
entry.sectionLabel,
...entry.toc,
entry.body,
],
meta: [getDocSectionLabel(page.section, locale)],
meta: [entry.sectionLabel],
payload: {
href: getDocHref(docPackage, page),
href: entry.href,
} satisfies DocsSearchPayload,
}
}
@@ -127,14 +121,13 @@ function createResultItem(
function getMatchScore(
query: string,
terms: readonly string[],
packageLabel: string,
page: DocPage
entry: DocSearchEntry
) {
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 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
@@ -147,15 +140,11 @@ function getMatchScore(
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
if (entry.id === "overview/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()
}
+39 -3
View File
@@ -36,12 +36,17 @@ type PreferencesMessage =
export function DocsPreferencesProvider({
children,
initialPreferences,
routeLocale,
}: {
children: ReactNode
initialPreferences?: Preferences
routeLocale: DocLocale
}) {
const [initialPreferences] = useState(() => readDocsPreferences(routeLocale))
const hydrateStoredPreferences = initialPreferences !== undefined
const [providerInitialPreferences] = useState(
() => initialPreferences ?? readDocsPreferences(routeLocale)
)
const channelRef = useRef<BroadcastChannel>(null)
const applyingExternalUpdateRef = useRef(false)
@@ -58,12 +63,13 @@ export function DocsPreferencesProvider({
return (
<PreferencesProvider
effects={docsPreferenceEffects}
initialPreferences={initialPreferences}
initialPreferences={providerInitialPreferences}
onPreferenceChange={handlePreferenceChange}
>
<PreferencesRuntime
applyingExternalUpdateRef={applyingExternalUpdateRef}
channelRef={channelRef}
hydrateStoredPreferences={hydrateStoredPreferences}
routeLocale={routeLocale}
/>
{children}
@@ -74,10 +80,12 @@ export function DocsPreferencesProvider({
function PreferencesRuntime({
applyingExternalUpdateRef,
channelRef,
hydrateStoredPreferences,
routeLocale,
}: {
applyingExternalUpdateRef: MutableRefObject<boolean>
channelRef: MutableRefObject<BroadcastChannel | null>
hydrateStoredPreferences: boolean
routeLocale: DocLocale
}) {
const preferences = usePreferences()
@@ -89,6 +97,32 @@ function PreferencesRuntime({
const [, setLocale] = usePreference("locale")
const [, setThemeMode] = usePreference("theme-mode")
useEffect(() => {
if (!hydrateStoredPreferences) return
const storedPreferences = readDocsPreferences(routeLocale)
applyingExternalUpdateRef.current = true
try {
setAccentColor(storedPreferences["accent-color"])
setBackgroundShade(storedPreferences["background-shade"])
setForegroundShade(storedPreferences["foreground-shade"])
setLocaleMode(storedPreferences["locale-mode"])
setLocale(storedPreferences.locale)
setThemeMode(storedPreferences["theme-mode"])
} finally {
applyingExternalUpdateRef.current = false
}
}, [
applyingExternalUpdateRef,
hydrateStoredPreferences,
routeLocale,
setAccentColor,
setBackgroundShade,
setForegroundShade,
setLocale,
setLocaleMode,
setThemeMode,
])
useLayoutEffect(() => {
preferencesRef.current = preferences
}, [preferences])
@@ -148,10 +182,12 @@ function PreferencesRuntime({
if (!message || typeof message !== "object") return
if (message.type === "request") {
/* oxlint-disable unicorn/require-post-message-target-origin -- BroadcastChannel has no targetOrigin argument. */
channel.postMessage({
type: "snapshot",
preferences: preferencesRef.current,
} satisfies PreferencesMessage)
/* oxlint-enable unicorn/require-post-message-target-origin */
return
}
@@ -175,6 +211,7 @@ function PreferencesRuntime({
}
channel.addEventListener("message", handleMessage)
// oxlint-disable-next-line unicorn/require-post-message-target-origin -- BroadcastChannel has no targetOrigin argument.
channel.postMessage({ type: "request" } satisfies PreferencesMessage)
return () => {
@@ -191,7 +228,6 @@ function PreferencesRuntime({
setLocaleMode,
setThemeMode,
])
useEffect(() => {
if (preferences["locale-mode"] !== "auto") return