feat(docs): add static generation and search indexes
This commit is contained in:
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
.ssr
|
||||||
.output
|
.output
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
+35
-30
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(value: string) {
|
||||||
|
return escapeText(value).replaceAll('"', """)
|
||||||
|
}
|
||||||
+36
-18
@@ -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,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 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}>
|
||||||
<DocsSearchProvider>
|
<PathnameProvider pathname={pathname}>
|
||||||
<div style={rootCSSVariables}>
|
<DocsSearchProvider>
|
||||||
<Suspense
|
<div style={rootCSSVariables}>
|
||||||
fallback={
|
<Suspense
|
||||||
<p className="p-6 text-slate-500 dark:text-slate-400">
|
fallback={
|
||||||
Loading…
|
<p className="p-6 text-slate-500 dark:text-slate-400">
|
||||||
</p>
|
Loading…
|
||||||
}
|
</p>
|
||||||
>
|
}
|
||||||
{Route ? <Route key={pathname} /> : <NotFound />}
|
>
|
||||||
</Suspense>
|
{Route ? (
|
||||||
</div>
|
<Route key={pathname} pathname={pathname} />
|
||||||
</DocsSearchProvider>
|
) : (
|
||||||
|
<NotFound />
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</DocsSearchProvider>
|
||||||
|
</PathnameProvider>
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
</DocsPreferencesProvider>
|
</DocsPreferencesProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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|$)/, "")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" : "页面不存在 · 文档",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -19,5 +19,5 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "scripts"]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user