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
dist
dist-ssr
.ssr
.output
*.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)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## 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 }]
}
}
```sh
bun run --cwd docs dev
```
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",
"scripts": {
"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",
"preview": "vite preview",
"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;")
}
+36 -18
View File
@@ -12,13 +12,15 @@ import { DocsPreferencesProvider } from "./components/preferences-provider"
import { DocsSearchProvider } from "./components/docs-search-provider"
import { resolveDocLocale } from "./content/registry"
import { useTranslate } from "./lib/locale"
import { PathnameProvider } from "./lib/router"
import type { Preferences } from "@workspace/preferences"
const Index = lazy(() => import("./pages/index"))
const Article = lazy(() => import("./pages/article"))
const Settings = lazy(() => import("./pages/settings"))
type RouteDefinition = {
Component: ComponentType
Component: ComponentType<{ pathname: string }>
matches: (pathname: string) => boolean
}
@@ -40,8 +42,15 @@ const routes: RouteDefinition[] = [
},
]
function App() {
const [currentUrl, setCurrentUrl] = useState(() => window.location.href)
export type AppProps = {
initialPreferences?: Preferences
initialUrl?: string
}
function App({ initialPreferences, initialUrl }: AppProps) {
const [currentUrl, setCurrentUrl] = useState(
() => initialUrl ?? window.location.href
)
useEffect(() => {
function updateRoute() {
@@ -99,26 +108,35 @@ 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 locale = resolveDocLocale(pathname)
return (
<DocsPreferencesProvider routeLocale={locale}>
<DocsPreferencesProvider
initialPreferences={initialPreferences}
routeLocale={locale}
>
<LocaleProvider locale={locale}>
<DocsSearchProvider>
<div style={rootCSSVariables}>
<Suspense
fallback={
<p className="p-6 text-slate-500 dark:text-slate-400">
Loading
</p>
}
>
{Route ? <Route key={pathname} /> : <NotFound />}
</Suspense>
</div>
</DocsSearchProvider>
<PathnameProvider pathname={pathname}>
<DocsSearchProvider>
<div style={rootCSSVariables}>
<Suspense
fallback={
<p className="p-6 text-slate-500 dark:text-slate-400">
Loading
</p>
}
>
{Route ? (
<Route key={pathname} pathname={pathname} />
) : (
<NotFound />
)}
</Suspense>
</div>
</DocsSearchProvider>
</PathnameProvider>
</LocaleProvider>
</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 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
+32 -24
View File
@@ -1,3 +1,5 @@
import { lazy } from "react"
import {
defaultDocLocale,
docLocales,
@@ -12,23 +14,17 @@ import {
} from "./types"
import { trans, type Translation } from "../lib/locale"
type DocModule = {
type DocContentModule = {
default: React.ComponentType<{ components?: MdxComponents }>
frontmatter: Record<string, unknown>
}
type LocalizedText = Translation<string>
type PackageDefinition = {
key: string
label: string
description: LocalizedText
description: Translation<string>
}
type ParsedDocPage = DocPage & {
packageKey: string
searchText: string
}
type ParsedDocPage = DocPage & { packageKey: string }
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",
{ eager: true }
{ eager: false }
)
const docSources = import.meta.glob<string>(
const docFrontmatter = import.meta.glob<Record<string, unknown>>(
"../../../packages/*/docs/*/**/*.mdx",
{ eager: true, import: "default", query: "?docs-search-raw" }
{ eager: true, import: "frontmatter" }
)
const parsedPages = Object.entries(docModules).map(([path, module]) =>
parseDocPage(path, module, docSources[path] ?? "")
const parsedPages = Object.entries(docFrontmatter).map(
([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[]> = {
@@ -147,7 +148,7 @@ export function getLocalizedHref(pathname: string, locale: 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
}
@@ -193,8 +194,17 @@ export function getDocSectionLabel(section: DocSection, locale: DocLocale) {
return trans(locale, labels[section])
}
export function getDocSearchText(page: DocPage) {
return (page as ParsedDocPage).searchText
export function getStaticDocRoutes() {
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(
@@ -205,7 +215,7 @@ function createDocPackage(
.filter(
(page) => page.packageKey === definition.key && page.locale === locale
)
.sort(
.toSorted(
(left, right) =>
docSections.indexOf(left.section) -
docSections.indexOf(right.section) ||
@@ -241,8 +251,8 @@ function createDocPackage(
function parseDocPage(
path: string,
module: DocModule,
source: string
frontmatter: Record<string, unknown>,
Content: DocPage["Content"]
): ParsedDocPage {
const match = path.match(
/\/packages\/([^/]+)\/docs\/([^/]+)\/(overview|guide|advanced|examples)(?:\/([^/]+))?\.mdx$/
@@ -269,14 +279,12 @@ function parseDocPage(
throw new Error(`Documentation file is in the wrong directory: "${path}".`)
}
const frontmatter = module.frontmatter
return {
Content: module.default,
Content,
description: readString(frontmatter, "description"),
locale: localeValue,
order: readNumber(frontmatter, "order"),
packageKey,
searchText: source,
section: sectionValue,
slug: nestedSlug ?? "overview",
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[]
}
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 = {
key: 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
}
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) {
const definition = docsPreferenceDefinitions[update.key]
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 { createRoot } from "react-dom/client"
import { createRoot, hydrateRoot } from "react-dom/client"
import App from "./App.tsx"
// oxlint-disable-next-line import/no-unassigned-import -- The entry point installs the global stylesheet.
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")
? "en-US"
: "zh-Hans"
const root = document.getElementById("root")!
const prerendered = root.hasChildNodes()
const locale = resolveDocLocale(window.location.pathname)
document.documentElement.lang = locale
initializeDocsPreferences()
createRoot(document.getElementById("root")!).render(
const app = (
<StrictMode>
<App />
<App
initialPreferences={
prerendered ? createDefaultDocsPreferences(locale) : undefined
}
initialUrl={window.location.href}
/>
</StrictMode>
)
if (prerendered) {
hydrateRoot(root, app)
} else {
createRoot(root).render(app)
}
+6 -15
View File
@@ -8,20 +8,16 @@ import {
getDocsHomeHref,
resolveDocLocation,
} from "../content/registry"
import type {
DocLocation,
DocPage,
DocTocItem,
} from "../content/types"
import type { DocLocation, DocPage, DocTocItem } from "../content/types"
import { Actions, Container, HomeButton, SlashLine } from "../components/layout"
import { mdxComponents } from "../components/mdx"
import { useLocale, useTranslate } from "../lib/locale"
const noTocItems: readonly DocTocItem[] = []
export default function ArticlePage() {
export default function ArticlePage({ pathname }: { pathname: string }) {
const t = useTranslate()
const docLocation = resolveDocLocation(window.location.pathname)
const docLocation = resolveDocLocation(pathname)
const visibleTocIds = useVisibleTocIds(docLocation?.page.toc ?? noTocItems)
const [mobilePanel, setMobilePanel] = React.useState<
"navigation" | "toc" | null
@@ -401,8 +397,6 @@ function TableOfContents({
height: number
top: number
} | null>(null)
const activeKey = activeIds.join("\n")
React.useLayoutEffect(() => {
const root = rootRef.current
const rail = railRef.current
@@ -437,7 +431,7 @@ function TableOfContents({
resizeObserver.disconnect()
window.removeEventListener("resize", measureMarker)
}
}, [activeKey, activeIds])
}, [activeIds])
return (
<aside
@@ -514,7 +508,7 @@ function TocAnchor({
<a
href={href}
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}
</a>
@@ -532,10 +526,7 @@ function useVisibleTocIds(items: readonly DocTocItem[]) {
const [activeIds, setActiveIds] = React.useState<readonly string[]>([])
React.useEffect(() => {
if (entries.length === 0) {
setActiveIds([])
return
}
if (entries.length === 0) return
let animationFrame: number | undefined
const articleElement = document.querySelector<HTMLElement>(
+4 -2
View File
@@ -21,6 +21,7 @@ import {
import { useLocale, useTranslate, type Translation } from "../lib/locale"
import { navigateToLocale } from "../lib/locale-navigation"
import { detectLocale } from "../lib/preferences"
import { usePathname } from "../lib/router"
const foregroundOptions: ReadonlyArray<{
label: Translation
@@ -54,6 +55,7 @@ const messages = {
export default function SettingsPage() {
const locale = useLocale()
const pathname = usePathname()
const t = useTranslate()
const appearance = useAppearance()
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">
<LocaleLink
disabled={autoDetectLocale}
href={getLocalizedHref(window.location.pathname, "zh-Hans")}
href={getLocalizedHref(pathname, "zh-Hans")}
label="简体中文"
locale="zh-Hans"
selected={locale === "zh-Hans"}
@@ -239,7 +241,7 @@ export default function SettingsPage() {
/>
<LocaleLink
disabled={autoDetectLocale}
href={getLocalizedHref(window.location.pathname, "en-US")}
href={getLocalizedHref(pathname, "en-US")}
label="English"
locale="en-US"
selected={locale === "en-US"}
+1 -1
View File
@@ -19,5 +19,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "scripts"]
}