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
+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
.ssr
.output .output
*.local *.local
+35 -30
View File
@@ -1,35 +1,40 @@
# React + TypeScript + Vite # Documentation site
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. 文档站使用 Vite、React、Satteri 和 MDX。默认语言为 `zh-Hans`,英文页面位于 `/en-US`
Currently, two official plugins are available: ## Development
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ```sh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) bun run --cwd docs dev
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
You can also try [the experimental native React Compiler support in plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md#rust-react-compiler) by using `compiler: true` in the plugin options instead of using the Babel plugin.
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
``` ```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. 开发服务器直接监听 `packages/*/docs/*/**/*.mdx`。新增或修改文档后,页面注册表与开发搜索索引会随 Vite 模块图更新。
## Static generation
```sh
bun run --cwd docs build
```
构建过程依次执行:
1. 类型检查并构建浏览器资源;
2. 构建仅供预渲染使用的 SSR 入口;
3. 枚举注册表中的首页、设置页和所有双语文章路由;
4. 将每个路由预渲染到 `dist/<route>/index.html`
5. 生成 `dist/404.html` 和按语言拆分的搜索索引;
6. 删除临时 `.ssr` 目录。
生成的 `dist` 可以直接部署到静态文件服务。页面在浏览器中使用 hydration 恢复交互,后续站内导航仍由轻量客户端路由处理。
## Search data
搜索正文不会打进入口 JavaScript
- 生产构建从 MDX 原始文本生成 `dist/search/zh-Hans.json``dist/search/en-US.json`
- 用户第一次执行搜索时,浏览器只请求当前语言的索引,并在当前会话中缓存;
- 切换语言后才会按需请求另一份索引;
- MDX 页面组件按文章拆分为独立 chunk;
- 开发模式使用相同的索引生成逻辑,但从 Vite 监听中的 MDX 源文件即时构建。
新增符合 `packages/<package>/docs/<locale>/**` 目录约定的文档后,无需手工维护搜索列表。
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir .ssr && bun scripts/prerender.ts",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -b" "typecheck": "tsc -b"
+88
View File
@@ -0,0 +1,88 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { dirname, join } from "node:path"
import { pathToFileURL } from "node:url"
import { docLocales } from "../src/content/types.ts"
type StaticPage = {
html: string
locale: (typeof docLocales)[number]
metadata: { description: string; title: string }
}
type ServerEntry = {
getStaticDocRoutes: () => readonly string[]
getStaticSearchIndex: (locale: (typeof docLocales)[number]) => unknown
renderStaticPage: (pathname: string) => Promise<StaticPage>
}
const docsDirectory = dirname(dirname(import.meta.filename))
const outputDirectory = join(docsDirectory, "dist")
const serverDirectory = join(docsDirectory, ".ssr")
const serverEntryUrl = pathToFileURL(
join(serverDirectory, "entry-server.js")
).href
const server = (await import(serverEntryUrl)) as ServerEntry
const template = await readFile(join(outputDirectory, "index.html"), "utf8")
await Promise.all(
server.getStaticDocRoutes().map(async (route) => {
const page = await server.renderStaticPage(route)
const filename = getRouteFilename(route)
await mkdir(dirname(filename), { recursive: true })
await writeFile(filename, createHtml(template, page), "utf8")
})
)
const notFoundPage = await server.renderStaticPage("/404")
await writeFile(
join(outputDirectory, "404.html"),
createHtml(template, notFoundPage),
"utf8"
)
const searchDirectory = join(outputDirectory, "search")
await mkdir(searchDirectory, { recursive: true })
await Promise.all(
docLocales.map((locale) =>
writeFile(
join(searchDirectory, `${locale}.json`),
JSON.stringify(server.getStaticSearchIndex(locale)),
"utf8"
)
)
)
await rm(serverDirectory, { recursive: true })
function getRouteFilename(route: string) {
return route === "/"
? join(outputDirectory, "index.html")
: join(outputDirectory, route.slice(1), "index.html")
}
function createHtml(
source: string,
page: Awaited<ReturnType<typeof server.renderStaticPage>>
) {
const description = `<meta name="description" content="${escapeAttribute(page.metadata.description)}" />`
return source
.replace(/<html lang="[^"]*">/, `<html lang="${page.locale}">`)
.replace(
/<title>[^<]*<\/title>/,
`<title>${escapeText(page.metadata.title)}</title>\n ${description}`
)
.replace('<div id="root"></div>', `<div id="root">${page.html}</div>`)
}
function escapeText(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
}
function escapeAttribute(value: string) {
return escapeText(value).replaceAll('"', "&quot;")
}
+24 -6
View File
@@ -12,13 +12,15 @@ import { DocsPreferencesProvider } from "./components/preferences-provider"
import { DocsSearchProvider } from "./components/docs-search-provider" import { DocsSearchProvider } from "./components/docs-search-provider"
import { resolveDocLocale } from "./content/registry" import { resolveDocLocale } from "./content/registry"
import { useTranslate } from "./lib/locale" import { useTranslate } from "./lib/locale"
import { PathnameProvider } from "./lib/router"
import type { Preferences } from "@workspace/preferences"
const Index = lazy(() => import("./pages/index")) const Index = lazy(() => import("./pages/index"))
const Article = lazy(() => import("./pages/article")) const Article = lazy(() => import("./pages/article"))
const Settings = lazy(() => import("./pages/settings")) const Settings = lazy(() => import("./pages/settings"))
type RouteDefinition = { type RouteDefinition = {
Component: ComponentType Component: ComponentType<{ pathname: string }>
matches: (pathname: string) => boolean matches: (pathname: string) => boolean
} }
@@ -40,8 +42,15 @@ const routes: RouteDefinition[] = [
}, },
] ]
function App() { export type AppProps = {
const [currentUrl, setCurrentUrl] = useState(() => window.location.href) initialPreferences?: Preferences
initialUrl?: string
}
function App({ initialPreferences, initialUrl }: AppProps) {
const [currentUrl, setCurrentUrl] = useState(
() => initialUrl ?? window.location.href
)
useEffect(() => { useEffect(() => {
function updateRoute() { function updateRoute() {
@@ -99,13 +108,17 @@ function App() {
} }
}, []) }, [])
const { pathname } = new URL(currentUrl) const { pathname } = new URL(currentUrl, "https://docs.local")
const Route = routes.find((route) => route.matches(pathname))?.Component const Route = routes.find((route) => route.matches(pathname))?.Component
const locale = resolveDocLocale(pathname) const locale = resolveDocLocale(pathname)
return ( return (
<DocsPreferencesProvider routeLocale={locale}> <DocsPreferencesProvider
initialPreferences={initialPreferences}
routeLocale={locale}
>
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<PathnameProvider pathname={pathname}>
<DocsSearchProvider> <DocsSearchProvider>
<div style={rootCSSVariables}> <div style={rootCSSVariables}>
<Suspense <Suspense
@@ -115,10 +128,15 @@ function App() {
</p> </p>
} }
> >
{Route ? <Route key={pathname} /> : <NotFound />} {Route ? (
<Route key={pathname} pathname={pathname} />
) : (
<NotFound />
)}
</Suspense> </Suspense>
</div> </div>
</DocsSearchProvider> </DocsSearchProvider>
</PathnameProvider>
</LocaleProvider> </LocaleProvider>
</DocsPreferencesProvider> </DocsPreferencesProvider>
) )
+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 { messages as searchMessagesZhHans } from "@workspace/search/locales/zh-Hans"
import React from "react" import React from "react"
import { import { loadDocSearchIndex } from "../content/search-index.client"
getDocHref, import type { DocLocale, DocSearchEntry } from "../content/types"
getDocPackages,
getDocSearchText,
getDocSectionLabel,
} from "../content/registry"
import type {
DocLocale,
DocPackage,
DocPage,
DocTocItem,
} from "../content/types"
import { useLocale } from "../lib/locale" import { useLocale } from "../lib/locale"
const searchCatalogs = { const searchCatalogs = {
@@ -57,69 +47,73 @@ export function DocsSearchProvider({
} }
function createDocsSearchAdapter(locale: DocLocale): SearchAdapter { function createDocsSearchAdapter(locale: DocLocale): SearchAdapter {
const docPackages = getDocPackages(locale)
return { return {
async search({ query, signal }) { async search({ query, signal }) {
const normalizedQuery = normalize(query) const normalizedQuery = normalize(query)
if (!normalizedQuery) return { groups: [] } if (!normalizedQuery) return { groups: [] }
const index = await loadDocSearchIndex(locale)
if (signal.aborted) return { groups: [] }
const terms = normalizedQuery.split(/\s+/).filter(Boolean) const terms = normalizedQuery.split(/\s+/).filter(Boolean)
const groups = docPackages.flatMap((docPackage) => { const groups = new Map<
const items = docPackage.pages string,
.map((page) => ({ {
item: createResultItem(docPackage, page, locale), id: string
score: getMatchScore( items: Array<{ item: SearchResultItem; score: number }>
normalizedQuery, label: string
terms, total: number
docPackage.label, }
page >()
),
})) for (const entry of index) {
.filter((result) => result.score >= 0) const score = getMatchScore(normalizedQuery, terms, entry)
.sort( 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) => (left, right) =>
right.score - left.score || right.score - left.score ||
left.item.title.localeCompare(right.item.title, locale) left.item.title.localeCompare(right.item.title, locale)
) )
.map((result) => result.item) .map((result) => result.item),
}))
return items.length > 0
? [
{
id: docPackage.key,
label: docPackage.label,
items,
total: items.length,
},
]
: []
})
if (signal.aborted) return { groups: [] } if (signal.aborted) return { groups: [] }
return { groups } return { groups: results }
}, },
} }
} }
function createResultItem( function createResultItem(entry: DocSearchEntry): SearchResultItem {
docPackage: DocPackage,
page: DocPage,
locale: DocLocale
): SearchResultItem {
return { return {
id: `${page.section}/${page.slug}`, id: entry.id,
title: page.title, title: entry.title,
description: page.description, description: entry.description,
keywords: [ keywords: [
docPackage.label, entry.packageLabel,
getDocSectionLabel(page.section, locale), entry.sectionLabel,
...page.toc.flatMap(flattenTocTitles), ...entry.toc,
getDocSearchText(page), entry.body,
], ],
meta: [getDocSectionLabel(page.section, locale)], meta: [entry.sectionLabel],
payload: { payload: {
href: getDocHref(docPackage, page), href: entry.href,
} satisfies DocsSearchPayload, } satisfies DocsSearchPayload,
} }
} }
@@ -127,14 +121,13 @@ function createResultItem(
function getMatchScore( function getMatchScore(
query: string, query: string,
terms: readonly string[], terms: readonly string[],
packageLabel: string, entry: DocSearchEntry
page: DocPage
) { ) {
const title = normalize(page.title) const title = normalize(entry.title)
const description = normalize(page.description) const description = normalize(entry.description)
const packageName = normalize(packageLabel) const packageName = normalize(entry.packageLabel)
const toc = normalize(page.toc.flatMap(flattenTocTitles).join(" ")) const toc = normalize(entry.toc.join(" "))
const body = normalize(getDocSearchText(page)) const body = normalize(entry.body)
const searchableText = [packageName, title, description, toc, body].join(" ") const searchableText = [packageName, title, description, toc, body].join(" ")
if (!terms.every((term) => searchableText.includes(term))) return -1 if (!terms.every((term) => searchableText.includes(term))) return -1
@@ -147,15 +140,11 @@ function getMatchScore(
else if (packageName.includes(query)) score += 20 else if (packageName.includes(query)) score += 20
if (description.includes(query)) score += 15 if (description.includes(query)) score += 15
if (toc.includes(query)) score += 10 if (toc.includes(query)) score += 10
if (page.section === "overview") score += 2 if (entry.id === "overview/overview") score += 2
return score return score
} }
function flattenTocTitles(item: DocTocItem): readonly string[] {
return [item.title, ...(item.items?.flatMap(flattenTocTitles) ?? [])]
}
function normalize(value: string) { function normalize(value: string) {
return value.normalize("NFKC").toLocaleLowerCase().trim() return value.normalize("NFKC").toLocaleLowerCase().trim()
} }
+39 -3
View File
@@ -36,12 +36,17 @@ type PreferencesMessage =
export function DocsPreferencesProvider({ export function DocsPreferencesProvider({
children, children,
initialPreferences,
routeLocale, routeLocale,
}: { }: {
children: ReactNode children: ReactNode
initialPreferences?: Preferences
routeLocale: DocLocale routeLocale: DocLocale
}) { }) {
const [initialPreferences] = useState(() => readDocsPreferences(routeLocale)) const hydrateStoredPreferences = initialPreferences !== undefined
const [providerInitialPreferences] = useState(
() => initialPreferences ?? readDocsPreferences(routeLocale)
)
const channelRef = useRef<BroadcastChannel>(null) const channelRef = useRef<BroadcastChannel>(null)
const applyingExternalUpdateRef = useRef(false) const applyingExternalUpdateRef = useRef(false)
@@ -58,12 +63,13 @@ export function DocsPreferencesProvider({
return ( return (
<PreferencesProvider <PreferencesProvider
effects={docsPreferenceEffects} effects={docsPreferenceEffects}
initialPreferences={initialPreferences} initialPreferences={providerInitialPreferences}
onPreferenceChange={handlePreferenceChange} onPreferenceChange={handlePreferenceChange}
> >
<PreferencesRuntime <PreferencesRuntime
applyingExternalUpdateRef={applyingExternalUpdateRef} applyingExternalUpdateRef={applyingExternalUpdateRef}
channelRef={channelRef} channelRef={channelRef}
hydrateStoredPreferences={hydrateStoredPreferences}
routeLocale={routeLocale} routeLocale={routeLocale}
/> />
{children} {children}
@@ -74,10 +80,12 @@ export function DocsPreferencesProvider({
function PreferencesRuntime({ function PreferencesRuntime({
applyingExternalUpdateRef, applyingExternalUpdateRef,
channelRef, channelRef,
hydrateStoredPreferences,
routeLocale, routeLocale,
}: { }: {
applyingExternalUpdateRef: MutableRefObject<boolean> applyingExternalUpdateRef: MutableRefObject<boolean>
channelRef: MutableRefObject<BroadcastChannel | null> channelRef: MutableRefObject<BroadcastChannel | null>
hydrateStoredPreferences: boolean
routeLocale: DocLocale routeLocale: DocLocale
}) { }) {
const preferences = usePreferences() const preferences = usePreferences()
@@ -89,6 +97,32 @@ function PreferencesRuntime({
const [, setLocale] = usePreference("locale") const [, setLocale] = usePreference("locale")
const [, setThemeMode] = usePreference("theme-mode") 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(() => { useLayoutEffect(() => {
preferencesRef.current = preferences preferencesRef.current = preferences
}, [preferences]) }, [preferences])
@@ -148,10 +182,12 @@ function PreferencesRuntime({
if (!message || typeof message !== "object") return if (!message || typeof message !== "object") return
if (message.type === "request") { if (message.type === "request") {
/* oxlint-disable unicorn/require-post-message-target-origin -- BroadcastChannel has no targetOrigin argument. */
channel.postMessage({ channel.postMessage({
type: "snapshot", type: "snapshot",
preferences: preferencesRef.current, preferences: preferencesRef.current,
} satisfies PreferencesMessage) } satisfies PreferencesMessage)
/* oxlint-enable unicorn/require-post-message-target-origin */
return return
} }
@@ -175,6 +211,7 @@ function PreferencesRuntime({
} }
channel.addEventListener("message", handleMessage) 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) channel.postMessage({ type: "request" } satisfies PreferencesMessage)
return () => { return () => {
@@ -191,7 +228,6 @@ function PreferencesRuntime({
setLocaleMode, setLocaleMode,
setThemeMode, setThemeMode,
]) ])
useEffect(() => { useEffect(() => {
if (preferences["locale-mode"] !== "auto") return if (preferences["locale-mode"] !== "auto") return
+32 -24
View File
@@ -1,3 +1,5 @@
import { lazy } from "react"
import { import {
defaultDocLocale, defaultDocLocale,
docLocales, docLocales,
@@ -12,23 +14,17 @@ import {
} from "./types" } from "./types"
import { trans, type Translation } from "../lib/locale" import { trans, type Translation } from "../lib/locale"
type DocModule = { type DocContentModule = {
default: React.ComponentType<{ components?: MdxComponents }> default: React.ComponentType<{ components?: MdxComponents }>
frontmatter: Record<string, unknown>
} }
type LocalizedText = Translation<string>
type PackageDefinition = { type PackageDefinition = {
key: string key: string
label: string label: string
description: LocalizedText description: Translation<string>
} }
type ParsedDocPage = DocPage & { type ParsedDocPage = DocPage & { packageKey: string }
packageKey: string
searchText: string
}
const packageDefinitions: readonly PackageDefinition[] = [ const packageDefinitions: readonly PackageDefinition[] = [
{ {
@@ -81,18 +77,23 @@ const packageDefinitions: readonly PackageDefinition[] = [
}, },
] ]
const docModules = import.meta.glob<DocModule>( const docModuleLoaders = import.meta.glob<DocContentModule>(
"../../../packages/*/docs/*/**/*.mdx", "../../../packages/*/docs/*/**/*.mdx",
{ eager: true } { eager: false }
) )
const docSources = import.meta.glob<string>( const docFrontmatter = import.meta.glob<Record<string, unknown>>(
"../../../packages/*/docs/*/**/*.mdx", "../../../packages/*/docs/*/**/*.mdx",
{ eager: true, import: "default", query: "?docs-search-raw" } { eager: true, import: "frontmatter" }
) )
const parsedPages = Object.entries(docModules).map(([path, module]) => const parsedPages = Object.entries(docFrontmatter).map(
parseDocPage(path, module, docSources[path] ?? "") ([path, frontmatter]) => {
const load = docModuleLoaders[path]
if (!load) throw new Error(`Missing documentation module for "${path}".`)
return parseDocPage(path, frontmatter, lazy(load))
}
) )
const packagesByLocale: Record<DocLocale, readonly DocPackage[]> = { const packagesByLocale: Record<DocLocale, readonly DocPackage[]> = {
@@ -147,7 +148,7 @@ export function getLocalizedHref(pathname: string, locale: DocLocale) {
} }
export function resolveDocLocale(pathname: string): DocLocale { export function resolveDocLocale(pathname: string): DocLocale {
const firstSegment = pathname.split("/").filter(Boolean)[0] const firstSegment = pathname.split("/").find(Boolean)
return isDocLocale(firstSegment) ? firstSegment : defaultDocLocale return isDocLocale(firstSegment) ? firstSegment : defaultDocLocale
} }
@@ -193,8 +194,17 @@ export function getDocSectionLabel(section: DocSection, locale: DocLocale) {
return trans(locale, labels[section]) return trans(locale, labels[section])
} }
export function getDocSearchText(page: DocPage) { export function getStaticDocRoutes() {
return (page as ParsedDocPage).searchText return docLocales.flatMap((locale) => {
const home = getDocsHomeHref(locale)
return [
home,
`${home === "/" ? "" : home}/settings`,
...getDocPackages(locale).flatMap((docPackage) =>
docPackage.pages.map((page) => getDocHref(docPackage, page))
),
]
})
} }
function createDocPackage( function createDocPackage(
@@ -205,7 +215,7 @@ function createDocPackage(
.filter( .filter(
(page) => page.packageKey === definition.key && page.locale === locale (page) => page.packageKey === definition.key && page.locale === locale
) )
.sort( .toSorted(
(left, right) => (left, right) =>
docSections.indexOf(left.section) - docSections.indexOf(left.section) -
docSections.indexOf(right.section) || docSections.indexOf(right.section) ||
@@ -241,8 +251,8 @@ function createDocPackage(
function parseDocPage( function parseDocPage(
path: string, path: string,
module: DocModule, frontmatter: Record<string, unknown>,
source: string Content: DocPage["Content"]
): ParsedDocPage { ): ParsedDocPage {
const match = path.match( const match = path.match(
/\/packages\/([^/]+)\/docs\/([^/]+)\/(overview|guide|advanced|examples)(?:\/([^/]+))?\.mdx$/ /\/packages\/([^/]+)\/docs\/([^/]+)\/(overview|guide|advanced|examples)(?:\/([^/]+))?\.mdx$/
@@ -269,14 +279,12 @@ function parseDocPage(
throw new Error(`Documentation file is in the wrong directory: "${path}".`) throw new Error(`Documentation file is in the wrong directory: "${path}".`)
} }
const frontmatter = module.frontmatter
return { return {
Content: module.default, Content,
description: readString(frontmatter, "description"), description: readString(frontmatter, "description"),
locale: localeValue, locale: localeValue,
order: readNumber(frontmatter, "order"), order: readNumber(frontmatter, "order"),
packageKey, packageKey,
searchText: source,
section: sectionValue, section: sectionValue,
slug: nestedSlug ?? "overview", slug: nestedSlug ?? "overview",
title: readString(frontmatter, "title"), title: readString(frontmatter, "title"),
+31
View File
@@ -0,0 +1,31 @@
import type { DocLocale, DocSearchEntry } from "./types"
const indexes = new Map<DocLocale, Promise<readonly DocSearchEntry[]>>()
export function loadDocSearchIndex(locale: DocLocale) {
const existing = indexes.get(locale)
if (existing) return existing
const index = (
import.meta.env.DEV
? import("./search-index.source").then((module) =>
module.getDocSearchIndex(locale)
)
: fetch(
`${import.meta.env.BASE_URL}search/${encodeURIComponent(locale)}.json`
).then(async (response) => {
if (!response.ok) {
throw new Error(
`Unable to load the ${locale} documentation search index.`
)
}
return (await response.json()) as readonly DocSearchEntry[]
})
).catch((error: unknown) => {
indexes.delete(locale)
throw error
})
indexes.set(locale, index)
return index
}
+11
View File
@@ -0,0 +1,11 @@
import { createDocSearchIndex } from "./search-index"
import type { DocLocale } from "./types"
const docSources = import.meta.glob<string>(
"../../../packages/*/docs/*/**/*.mdx",
{ eager: true, import: "default", query: "?docs-search-raw" }
)
export function getDocSearchIndex(locale: DocLocale) {
return createDocSearchIndex(locale, docSources)
}
+49
View File
@@ -0,0 +1,49 @@
import { getDocHref, getDocPackages, getDocSectionLabel } from "./registry"
import type { DocLocale, DocSearchEntry, DocTocItem } from "./types"
export function createDocSearchIndex(
locale: DocLocale,
sources: Readonly<Record<string, string>>
): readonly DocSearchEntry[] {
const sourcesByPage = new Map(
Object.entries(sources).map(([path, source]) => [
getSourcePageKey(path),
stripFrontmatter(source),
])
)
return getDocPackages(locale).flatMap((docPackage) =>
docPackage.pages.map((page) => ({
body:
sourcesByPage.get(
`${docPackage.key}/${locale}/${page.section}/${page.slug}`
) ?? "",
description: page.description,
href: getDocHref(docPackage, page),
id: `${page.section}/${page.slug}`,
packageKey: docPackage.key,
packageLabel: docPackage.label,
sectionLabel: getDocSectionLabel(page.section, locale),
title: page.title,
toc: page.toc.flatMap(flattenTocTitles),
}))
)
}
function getSourcePageKey(path: string) {
const match = path.match(
/\/packages\/([^/]+)\/docs\/([^/]+)\/(overview|guide|advanced|examples)(?:\/([^/]+))?\.mdx$/
)
if (!match) throw new Error(`Invalid documentation search source: "${path}".`)
const [, packageKey, locale, section, nestedSlug] = match
return `${packageKey}/${locale}/${section}/${nestedSlug ?? "overview"}`
}
function flattenTocTitles(item: DocTocItem): readonly string[] {
return [item.title, ...(item.items?.flatMap(flattenTocTitles) ?? [])]
}
function stripFrontmatter(source: string) {
return source.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "")
}
+12
View File
@@ -30,6 +30,18 @@ export type DocPage = {
toc: readonly DocTocItem[] toc: readonly DocTocItem[]
} }
export type DocSearchEntry = {
body: string
description: string
href: string
id: string
packageKey: string
packageLabel: string
sectionLabel: string
title: string
toc: readonly string[]
}
export type DocPackage = { export type DocPackage = {
key: string key: string
label: string label: string
+68
View File
@@ -0,0 +1,68 @@
import { StrictMode } from "react"
import { prerender } from "react-dom/static"
import App from "./App"
import {
getStaticDocRoutes,
resolveDocLocale,
resolveDocLocation,
} from "./content/registry"
import { getDocSearchIndex } from "./content/search-index.source"
import type { DocLocale } from "./content/types"
import { createDefaultDocsPreferences } from "./lib/preferences"
export { getStaticDocRoutes, getDocSearchIndex as getStaticSearchIndex }
export async function renderStaticPage(pathname: string) {
const locale = resolveDocLocale(pathname)
const { prelude } = await prerender(
<StrictMode>
<App
initialPreferences={createDefaultDocsPreferences(locale)}
initialUrl={new URL(pathname, "https://docs.local").href}
/>
</StrictMode>
)
return {
html: await new Response(prelude).text(),
locale,
metadata: getPageMetadata(pathname, locale),
}
}
function getPageMetadata(pathname: string, locale: DocLocale) {
const location = resolveDocLocation(pathname)
if (location) {
return {
description: location.page.description,
title: `${location.page.title} · ${location.package.label}`,
}
}
const english = locale === "en-US"
if (/\/settings\/?$/.test(pathname)) {
return {
description: english
? "Configure the documentation theme, colors, and language."
: "配置文档站的主题、颜色和语言。",
title: english ? "Settings · Documentation" : "设置 · 文档",
}
}
if (pathname === "/" || pathname === "/en-US") {
return {
description: english
? "Documentation for the my-shadcn-ui workspace packages."
: "my-shadcn-ui 工作区各个 package 的使用文档。",
title: english ? "my-shadcn-ui Documentation" : "my-shadcn-ui 文档",
}
}
return {
description: english
? "The requested documentation page does not exist."
: "请求的文档页面不存在。",
title: english ? "Page not found · Documentation" : "页面不存在 · 文档",
}
}
+13
View File
@@ -105,6 +105,19 @@ export function readDocsPreferences(
return preferences return preferences
} }
export function createDefaultDocsPreferences(
locale: DocLocale = defaultDocLocale
): Preferences {
return Object.fromEntries(
(
Object.keys(docsPreferenceDefinitions) as Array<keyof DocsPreferences>
).map((key) => [
key,
key === "locale" ? locale : docsPreferenceDefinitions[key].defaultValue,
])
) as DocsPreferences
}
export function persistDocsPreference(update: PreferenceUpdate) { export function persistDocsPreference(update: PreferenceUpdate) {
const definition = docsPreferenceDefinitions[update.key] const definition = docsPreferenceDefinitions[update.key]
const serializedValue = const serializedValue =
+21
View File
@@ -0,0 +1,21 @@
import { createContext, useContext, type ReactNode } from "react"
const PathnameContext = createContext("/")
export function PathnameProvider({
children,
pathname,
}: {
children: ReactNode
pathname: string
}) {
return (
<PathnameContext.Provider value={pathname}>
{children}
</PathnameContext.Provider>
)
}
export function usePathname() {
return useContext(PathnameContext)
}
+24 -7
View File
@@ -1,18 +1,35 @@
import { StrictMode } from "react" import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot, hydrateRoot } from "react-dom/client"
import App from "./App.tsx" import App from "./App.tsx"
// oxlint-disable-next-line import/no-unassigned-import -- The entry point installs the global stylesheet.
import "./index.css" import "./index.css"
import { initializeDocsPreferences } from "./lib/preferences" import { resolveDocLocale } from "./content/registry"
import {
createDefaultDocsPreferences,
initializeDocsPreferences,
} from "./lib/preferences"
document.documentElement.lang = window.location.pathname.startsWith("/en-US") const root = document.getElementById("root")!
? "en-US" const prerendered = root.hasChildNodes()
: "zh-Hans" const locale = resolveDocLocale(window.location.pathname)
document.documentElement.lang = locale
initializeDocsPreferences() initializeDocsPreferences()
createRoot(document.getElementById("root")!).render( const app = (
<StrictMode> <StrictMode>
<App /> <App
initialPreferences={
prerendered ? createDefaultDocsPreferences(locale) : undefined
}
initialUrl={window.location.href}
/>
</StrictMode> </StrictMode>
) )
if (prerendered) {
hydrateRoot(root, app)
} else {
createRoot(root).render(app)
}
+6 -15
View File
@@ -8,20 +8,16 @@ import {
getDocsHomeHref, getDocsHomeHref,
resolveDocLocation, resolveDocLocation,
} from "../content/registry" } from "../content/registry"
import type { import type { DocLocation, DocPage, DocTocItem } from "../content/types"
DocLocation,
DocPage,
DocTocItem,
} from "../content/types"
import { Actions, Container, HomeButton, SlashLine } from "../components/layout" import { Actions, Container, HomeButton, SlashLine } from "../components/layout"
import { mdxComponents } from "../components/mdx" import { mdxComponents } from "../components/mdx"
import { useLocale, useTranslate } from "../lib/locale" import { useLocale, useTranslate } from "../lib/locale"
const noTocItems: readonly DocTocItem[] = [] const noTocItems: readonly DocTocItem[] = []
export default function ArticlePage() { export default function ArticlePage({ pathname }: { pathname: string }) {
const t = useTranslate() const t = useTranslate()
const docLocation = resolveDocLocation(window.location.pathname) const docLocation = resolveDocLocation(pathname)
const visibleTocIds = useVisibleTocIds(docLocation?.page.toc ?? noTocItems) const visibleTocIds = useVisibleTocIds(docLocation?.page.toc ?? noTocItems)
const [mobilePanel, setMobilePanel] = React.useState< const [mobilePanel, setMobilePanel] = React.useState<
"navigation" | "toc" | null "navigation" | "toc" | null
@@ -401,8 +397,6 @@ function TableOfContents({
height: number height: number
top: number top: number
} | null>(null) } | null>(null)
const activeKey = activeIds.join("\n")
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const root = rootRef.current const root = rootRef.current
const rail = railRef.current const rail = railRef.current
@@ -437,7 +431,7 @@ function TableOfContents({
resizeObserver.disconnect() resizeObserver.disconnect()
window.removeEventListener("resize", measureMarker) window.removeEventListener("resize", measureMarker)
} }
}, [activeKey, activeIds]) }, [activeIds])
return ( return (
<aside <aside
@@ -514,7 +508,7 @@ function TocAnchor({
<a <a
href={href} href={href}
data-active={active || undefined} data-active={active || undefined}
className="text-slate-600 transition-colors hover:text-indigo-500 data-active:font-medium data-active:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-300 dark:data-active:text-indigo-400" className="text-slate-600 transition-colors hover:text-indigo-500 dark:text-slate-400 dark:hover:text-indigo-300 data-active:font-medium data-active:text-indigo-600 dark:data-active:text-indigo-400"
> >
{title} {title}
</a> </a>
@@ -532,10 +526,7 @@ function useVisibleTocIds(items: readonly DocTocItem[]) {
const [activeIds, setActiveIds] = React.useState<readonly string[]>([]) const [activeIds, setActiveIds] = React.useState<readonly string[]>([])
React.useEffect(() => { React.useEffect(() => {
if (entries.length === 0) { if (entries.length === 0) return
setActiveIds([])
return
}
let animationFrame: number | undefined let animationFrame: number | undefined
const articleElement = document.querySelector<HTMLElement>( const articleElement = document.querySelector<HTMLElement>(
+4 -2
View File
@@ -21,6 +21,7 @@ import {
import { useLocale, useTranslate, type Translation } from "../lib/locale" import { useLocale, useTranslate, type Translation } from "../lib/locale"
import { navigateToLocale } from "../lib/locale-navigation" import { navigateToLocale } from "../lib/locale-navigation"
import { detectLocale } from "../lib/preferences" import { detectLocale } from "../lib/preferences"
import { usePathname } from "../lib/router"
const foregroundOptions: ReadonlyArray<{ const foregroundOptions: ReadonlyArray<{
label: Translation label: Translation
@@ -54,6 +55,7 @@ const messages = {
export default function SettingsPage() { export default function SettingsPage() {
const locale = useLocale() const locale = useLocale()
const pathname = usePathname()
const t = useTranslate() const t = useTranslate()
const appearance = useAppearance() const appearance = useAppearance()
const [localeMode, setLocaleMode] = usePreference("locale-mode") const [localeMode, setLocaleMode] = usePreference("locale-mode")
@@ -231,7 +233,7 @@ export default function SettingsPage() {
<div className="w-full space-y-1 rounded-xl border p-1 sm:w-fit sm:min-w-48 dark:border-slate-700"> <div className="w-full space-y-1 rounded-xl border p-1 sm:w-fit sm:min-w-48 dark:border-slate-700">
<LocaleLink <LocaleLink
disabled={autoDetectLocale} disabled={autoDetectLocale}
href={getLocalizedHref(window.location.pathname, "zh-Hans")} href={getLocalizedHref(pathname, "zh-Hans")}
label="简体中文" label="简体中文"
locale="zh-Hans" locale="zh-Hans"
selected={locale === "zh-Hans"} selected={locale === "zh-Hans"}
@@ -239,7 +241,7 @@ export default function SettingsPage() {
/> />
<LocaleLink <LocaleLink
disabled={autoDetectLocale} disabled={autoDetectLocale}
href={getLocalizedHref(window.location.pathname, "en-US")} href={getLocalizedHref(pathname, "en-US")}
label="English" label="English"
locale="en-US" locale="en-US"
selected={locale === "en-US"} selected={locale === "en-US"}
+1 -1
View File
@@ -19,5 +19,5 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts", "scripts"]
} }