feat(docs): add static generation and search indexes
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user