From 9635de56e8395ee3399390f1148eb6f78bfae6c9 Mon Sep 17 00:00:00 2001 From: Maofeng Date: Sun, 20 Sep 2026 16:23:32 +0800 Subject: [PATCH] feat(docs): add static generation and search indexes --- .gitignore | 1 + docs/README.md | 65 +++++----- docs/package.json | 2 +- docs/scripts/prerender.ts | 88 +++++++++++++ docs/src/App.tsx | 54 +++++--- docs/src/components/docs-search-provider.tsx | 123 +++++++++---------- docs/src/components/preferences-provider.tsx | 42 ++++++- docs/src/content/registry.ts | 56 +++++---- docs/src/content/search-index.client.ts | 31 +++++ docs/src/content/search-index.source.ts | 11 ++ docs/src/content/search-index.ts | 49 ++++++++ docs/src/content/types.ts | 12 ++ docs/src/entry-server.tsx | 68 ++++++++++ docs/src/lib/preferences.ts | 13 ++ docs/src/lib/router.tsx | 21 ++++ docs/src/main.tsx | 31 +++-- docs/src/pages/article.tsx | 21 +--- docs/src/pages/settings.tsx | 6 +- docs/tsconfig.node.json | 4 +- 19 files changed, 529 insertions(+), 169 deletions(-) create mode 100644 docs/scripts/prerender.ts create mode 100644 docs/src/content/search-index.client.ts create mode 100644 docs/src/content/search-index.source.ts create mode 100644 docs/src/content/search-index.ts create mode 100644 docs/src/entry-server.tsx create mode 100644 docs/src/lib/router.tsx diff --git a/.gitignore b/.gitignore index af09617..6064a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +.ssr .output *.local diff --git a/docs/README.md b/docs/README.md index 355e0e2..d53b8c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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//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//docs//**` 目录约定的文档后,无需手工维护搜索列表。 \ No newline at end of file diff --git a/docs/package.json b/docs/package.json index c5257f9..f96b6ff 100644 --- a/docs/package.json +++ b/docs/package.json @@ -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" diff --git a/docs/scripts/prerender.ts b/docs/scripts/prerender.ts new file mode 100644 index 0000000..05c3110 --- /dev/null +++ b/docs/scripts/prerender.ts @@ -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 +} + +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> +) { + const description = `` + + return source + .replace(//, ``) + .replace( + /[^<]*<\/title>/, + `<title>${escapeText(page.metadata.title)}\n ${description}` + ) + .replace('
', `
${page.html}
`) +} + +function escapeText(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") +} + +function escapeAttribute(value: string) { + return escapeText(value).replaceAll('"', """) +} diff --git a/docs/src/App.tsx b/docs/src/App.tsx index f7f35b2..15e5766 100644 --- a/docs/src/App.tsx +++ b/docs/src/App.tsx @@ -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 ( - + - -
- - Loading… -

- } - > - {Route ? : } -
-
-
+ + +
+ + Loading… +

+ } + > + {Route ? ( + + ) : ( + + )} +
+
+
+
) diff --git a/docs/src/components/docs-search-provider.tsx b/docs/src/components/docs-search-provider.tsx index 1ccc3c8..6353a5c 100644 --- a/docs/src/components/docs-search-provider.tsx +++ b/docs/src/components/docs-search-provider.tsx @@ -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() } diff --git a/docs/src/components/preferences-provider.tsx b/docs/src/components/preferences-provider.tsx index de90e10..b147ff7 100644 --- a/docs/src/components/preferences-provider.tsx +++ b/docs/src/components/preferences-provider.tsx @@ -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(null) const applyingExternalUpdateRef = useRef(false) @@ -58,12 +63,13 @@ export function DocsPreferencesProvider({ return ( {children} @@ -74,10 +80,12 @@ export function DocsPreferencesProvider({ function PreferencesRuntime({ applyingExternalUpdateRef, channelRef, + hydrateStoredPreferences, routeLocale, }: { applyingExternalUpdateRef: MutableRefObject channelRef: MutableRefObject + 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 diff --git a/docs/src/content/registry.ts b/docs/src/content/registry.ts index b9c38fd..3da098a 100644 --- a/docs/src/content/registry.ts +++ b/docs/src/content/registry.ts @@ -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 } -type LocalizedText = Translation - type PackageDefinition = { key: string label: string - description: LocalizedText + description: Translation } -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( +const docModuleLoaders = import.meta.glob( "../../../packages/*/docs/*/**/*.mdx", - { eager: true } + { eager: false } ) -const docSources = import.meta.glob( +const docFrontmatter = import.meta.glob>( "../../../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 = { @@ -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, + 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"), diff --git a/docs/src/content/search-index.client.ts b/docs/src/content/search-index.client.ts new file mode 100644 index 0000000..6317179 --- /dev/null +++ b/docs/src/content/search-index.client.ts @@ -0,0 +1,31 @@ +import type { DocLocale, DocSearchEntry } from "./types" + +const indexes = new Map>() + +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 +} diff --git a/docs/src/content/search-index.source.ts b/docs/src/content/search-index.source.ts new file mode 100644 index 0000000..5be8f40 --- /dev/null +++ b/docs/src/content/search-index.source.ts @@ -0,0 +1,11 @@ +import { createDocSearchIndex } from "./search-index" +import type { DocLocale } from "./types" + +const docSources = import.meta.glob( + "../../../packages/*/docs/*/**/*.mdx", + { eager: true, import: "default", query: "?docs-search-raw" } +) + +export function getDocSearchIndex(locale: DocLocale) { + return createDocSearchIndex(locale, docSources) +} diff --git a/docs/src/content/search-index.ts b/docs/src/content/search-index.ts new file mode 100644 index 0000000..2f32f42 --- /dev/null +++ b/docs/src/content/search-index.ts @@ -0,0 +1,49 @@ +import { getDocHref, getDocPackages, getDocSectionLabel } from "./registry" +import type { DocLocale, DocSearchEntry, DocTocItem } from "./types" + +export function createDocSearchIndex( + locale: DocLocale, + sources: Readonly> +): 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|$)/, "") +} diff --git a/docs/src/content/types.ts b/docs/src/content/types.ts index d1915c8..1f33357 100644 --- a/docs/src/content/types.ts +++ b/docs/src/content/types.ts @@ -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 diff --git a/docs/src/entry-server.tsx b/docs/src/entry-server.tsx new file mode 100644 index 0000000..aefa261 --- /dev/null +++ b/docs/src/entry-server.tsx @@ -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( + + + + ) + + 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" : "页面不存在 · 文档", + } +} diff --git a/docs/src/lib/preferences.ts b/docs/src/lib/preferences.ts index aaac2d0..33a9c30 100644 --- a/docs/src/lib/preferences.ts +++ b/docs/src/lib/preferences.ts @@ -105,6 +105,19 @@ export function readDocsPreferences( return preferences } +export function createDefaultDocsPreferences( + locale: DocLocale = defaultDocLocale +): Preferences { + return Object.fromEntries( + ( + Object.keys(docsPreferenceDefinitions) as Array + ).map((key) => [ + key, + key === "locale" ? locale : docsPreferenceDefinitions[key].defaultValue, + ]) + ) as DocsPreferences +} + export function persistDocsPreference(update: PreferenceUpdate) { const definition = docsPreferenceDefinitions[update.key] const serializedValue = diff --git a/docs/src/lib/router.tsx b/docs/src/lib/router.tsx new file mode 100644 index 0000000..b2e93a8 --- /dev/null +++ b/docs/src/lib/router.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, type ReactNode } from "react" + +const PathnameContext = createContext("/") + +export function PathnameProvider({ + children, + pathname, +}: { + children: ReactNode + pathname: string +}) { + return ( + + {children} + + ) +} + +export function usePathname() { + return useContext(PathnameContext) +} diff --git a/docs/src/main.tsx b/docs/src/main.tsx index 42d888b..06bfac2 100644 --- a/docs/src/main.tsx +++ b/docs/src/main.tsx @@ -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 = ( - + ) + +if (prerendered) { + hydrateRoot(root, app) +} else { + createRoot(root).render(app) +} diff --git a/docs/src/pages/article.tsx b/docs/src/pages/article.tsx index 1d7fc97..ceb98c0 100644 --- a/docs/src/pages/article.tsx +++ b/docs/src/pages/article.tsx @@ -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 (