feat(docs): add bilingual package documentation site
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
import {
|
||||
defaultDocLocale,
|
||||
docLocales,
|
||||
docSections,
|
||||
type DocLocale,
|
||||
type DocLocation,
|
||||
type DocPackage,
|
||||
type DocPage,
|
||||
type DocSection,
|
||||
type DocTocItem,
|
||||
type MdxComponents,
|
||||
} from "./types"
|
||||
import { trans, type Translation } from "../lib/locale"
|
||||
|
||||
type DocModule = {
|
||||
default: React.ComponentType<{ components?: MdxComponents }>
|
||||
frontmatter: Record<string, unknown>
|
||||
}
|
||||
|
||||
type LocalizedText = Translation<string>
|
||||
|
||||
type PackageDefinition = {
|
||||
key: string
|
||||
label: string
|
||||
description: LocalizedText
|
||||
}
|
||||
|
||||
type ParsedDocPage = DocPage & {
|
||||
packageKey: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const packageDefinitions: readonly PackageDefinition[] = [
|
||||
{
|
||||
key: "blocks",
|
||||
label: "Blocks",
|
||||
description: {
|
||||
zh: "构建应用外壳,并接入导航、通知、聊天、媒体和外观控制。",
|
||||
us: "Build an application shell and add navigation, notifications, chat, media, and appearance controls.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "i18n",
|
||||
label: "I18n",
|
||||
description: {
|
||||
zh: "接入应用自有词典、本地化运行时、开发工具和完整翻译工作流。",
|
||||
us: "Add application-owned catalogs, localized runtime rendering, development tools, and translation workflows.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "lexical",
|
||||
label: "Lexical",
|
||||
description: {
|
||||
zh: "从根节点和内容区域开始,逐步构建支持自定义操作与媒体的富文本编辑器。",
|
||||
us: "Build a rich-text editor progressively from its root and content surface to custom actions and media.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "preferences",
|
||||
label: "Preferences",
|
||||
description: {
|
||||
zh: "使用 SSR 快照、持久化回调和可组合副作用管理类型安全且可扩展的应用偏好。",
|
||||
us: "Manage typed, extensible application preferences with SSR snapshots, persistence callbacks, and composable effects.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: "Search",
|
||||
description: {
|
||||
zh: "通过可替换的 Adapter、分组结果、分页和搜索历史构建应用级搜索体验。",
|
||||
us: "Build application search with a replaceable adapter, grouped results, pagination, and search history.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ui",
|
||||
label: "UI",
|
||||
description: {
|
||||
zh: "配置共享 UI 层,并学习组合组件、Hooks、图标和本地化词典。",
|
||||
us: "Set up the shared UI layer and learn how to compose its components, hooks, icons, and locale catalogs.",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const docModules = import.meta.glob<DocModule>(
|
||||
"../../../packages/*/docs/*/**/*.mdx",
|
||||
{ eager: true }
|
||||
)
|
||||
|
||||
const docSources = import.meta.glob<string>(
|
||||
"../../../packages/*/docs/*/**/*.mdx",
|
||||
{ eager: true, import: "default", query: "?docs-search-raw" }
|
||||
)
|
||||
|
||||
const parsedPages = Object.entries(docModules).map(([path, module]) =>
|
||||
parseDocPage(path, module, docSources[path] ?? "")
|
||||
)
|
||||
|
||||
const packagesByLocale: Record<DocLocale, readonly DocPackage[]> = {
|
||||
"zh-Hans": packageDefinitions.map((definition) =>
|
||||
createDocPackage(definition, "zh-Hans")
|
||||
),
|
||||
"en-US": packageDefinitions.map((definition) =>
|
||||
createDocPackage(definition, "en-US")
|
||||
),
|
||||
}
|
||||
|
||||
validateLocalizedPagePairs(packagesByLocale)
|
||||
|
||||
export function getDocPackages(locale: DocLocale = defaultDocLocale) {
|
||||
return packagesByLocale[locale]
|
||||
}
|
||||
|
||||
export function getDocHref(docPackage: DocPackage, page?: DocPage) {
|
||||
const prefix = getLocalePrefix(docPackage.locale)
|
||||
const target = page ?? getOverviewPage(docPackage)
|
||||
const pagePath =
|
||||
target.section === "overview"
|
||||
? ""
|
||||
: `/${target.section}/${encodeURIComponent(target.slug)}`
|
||||
|
||||
return `${prefix}/articles/${docPackage.key}${pagePath}`
|
||||
}
|
||||
|
||||
export function getDocsHomeHref(locale: DocLocale) {
|
||||
return getLocalePrefix(locale) || "/"
|
||||
}
|
||||
|
||||
export function getLocalizedHref(pathname: string, locale: DocLocale) {
|
||||
if (/^(?:\/(?:en-US|zh-Hans))?\/settings\/?$/.test(pathname)) {
|
||||
return `${getLocalePrefix(locale)}/settings`
|
||||
}
|
||||
|
||||
const location = resolveDocLocation(pathname)
|
||||
if (!location) return getDocsHomeHref(locale)
|
||||
|
||||
const docPackage = getDocPackages(locale).find(
|
||||
(item) => item.key === location.package.key
|
||||
)
|
||||
const page = docPackage?.pages.find(
|
||||
(item) =>
|
||||
item.section === location.page.section && item.slug === location.page.slug
|
||||
)
|
||||
|
||||
return docPackage && page
|
||||
? getDocHref(docPackage, page)
|
||||
: getDocsHomeHref(locale)
|
||||
}
|
||||
|
||||
export function resolveDocLocale(pathname: string): DocLocale {
|
||||
const firstSegment = pathname.split("/").filter(Boolean)[0]
|
||||
return isDocLocale(firstSegment) ? firstSegment : defaultDocLocale
|
||||
}
|
||||
|
||||
export function resolveDocLocation(pathname: string): DocLocation | undefined {
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let locale: DocLocale = defaultDocLocale
|
||||
if (isDocLocale(segments[0])) {
|
||||
locale = segments.shift() as DocLocale
|
||||
}
|
||||
const [resource, packageKey, sectionValue, pageSlug, ...extra] = segments
|
||||
|
||||
if (resource !== "articles" || extra.length > 0) return undefined
|
||||
|
||||
const docPackage = getDocPackages(locale).find(
|
||||
(item) => item.key === packageKey
|
||||
)
|
||||
if (!docPackage) return undefined
|
||||
|
||||
if (sectionValue === undefined) {
|
||||
const page = getOverviewPage(docPackage)
|
||||
return { locale, package: docPackage, page }
|
||||
}
|
||||
|
||||
if (sectionValue === "overview" || !isDocSection(sectionValue) || !pageSlug) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const page = docPackage.pages.find(
|
||||
(item) => item.section === sectionValue && item.slug === pageSlug
|
||||
)
|
||||
|
||||
return page ? { locale, package: docPackage, page } : undefined
|
||||
}
|
||||
|
||||
export function getDocSectionLabel(section: DocSection, locale: DocLocale) {
|
||||
const labels: Record<DocSection, Translation> = {
|
||||
overview: { zh: "概览", us: "Overview" },
|
||||
guide: { zh: "指南", us: "Guide" },
|
||||
advanced: { zh: "进阶", us: "Advanced" },
|
||||
examples: { zh: "示例", us: "Examples" },
|
||||
}
|
||||
|
||||
return trans(locale, labels[section])
|
||||
}
|
||||
|
||||
export function getDocSearchText(page: DocPage) {
|
||||
return (page as ParsedDocPage).searchText
|
||||
}
|
||||
|
||||
function createDocPackage(
|
||||
definition: PackageDefinition,
|
||||
locale: DocLocale
|
||||
): DocPackage {
|
||||
const pages = parsedPages
|
||||
.filter(
|
||||
(page) => page.packageKey === definition.key && page.locale === locale
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
docSections.indexOf(left.section) -
|
||||
docSections.indexOf(right.section) ||
|
||||
left.order - right.order ||
|
||||
left.slug.localeCompare(right.slug)
|
||||
)
|
||||
|
||||
const overviewCount = pages.filter(
|
||||
(page) => page.section === "overview"
|
||||
).length
|
||||
if (overviewCount !== 1) {
|
||||
throw new Error(
|
||||
`${definition.key}/${locale} must contain exactly one docs/${locale}/overview.mdx file.`
|
||||
)
|
||||
}
|
||||
|
||||
for (const section of docSections.slice(1)) {
|
||||
if (!pages.some((page) => page.section === section)) {
|
||||
throw new Error(
|
||||
`${definition.key}/${locale} must contain at least one ${section} document.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
description: trans(locale, definition.description),
|
||||
locale,
|
||||
pages,
|
||||
}
|
||||
}
|
||||
|
||||
function parseDocPage(
|
||||
path: string,
|
||||
module: DocModule,
|
||||
source: string
|
||||
): ParsedDocPage {
|
||||
const match = path.match(
|
||||
/\/packages\/([^/]+)\/docs\/([^/]+)\/(overview|guide|advanced|examples)(?:\/([^/]+))?\.mdx$/
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid documentation path "${path}". Expected docs/<locale>/overview.mdx or docs/<locale>/<guide|advanced|examples>/*.mdx.`
|
||||
)
|
||||
}
|
||||
|
||||
const [, packageKey, localeValue, sectionValue, nestedSlug] = match
|
||||
if (!isDocLocale(localeValue)) {
|
||||
throw new Error(
|
||||
`Unsupported documentation locale "${localeValue}" in "${path}".`
|
||||
)
|
||||
}
|
||||
if (!isDocSection(sectionValue)) {
|
||||
throw new Error(`Unsupported documentation section in "${path}".`)
|
||||
}
|
||||
if (
|
||||
(sectionValue === "overview" && nestedSlug !== undefined) ||
|
||||
(sectionValue !== "overview" && nestedSlug === undefined)
|
||||
) {
|
||||
throw new Error(`Documentation file is in the wrong directory: "${path}".`)
|
||||
}
|
||||
|
||||
const frontmatter = module.frontmatter
|
||||
return {
|
||||
Content: module.default,
|
||||
description: readString(frontmatter, "description"),
|
||||
locale: localeValue,
|
||||
order: readNumber(frontmatter, "order"),
|
||||
packageKey,
|
||||
searchText: source,
|
||||
section: sectionValue,
|
||||
slug: nestedSlug ?? "overview",
|
||||
title: readString(frontmatter, "title"),
|
||||
toc: readToc(frontmatter.toc),
|
||||
}
|
||||
}
|
||||
|
||||
function validateLocalizedPagePairs(
|
||||
packages: Record<DocLocale, readonly DocPackage[]>
|
||||
) {
|
||||
for (const definition of packageDefinitions) {
|
||||
const keysByLocale = docLocales.map((locale) => {
|
||||
const docPackage = packages[locale].find(
|
||||
(item) => item.key === definition.key
|
||||
)!
|
||||
return docPackage.pages.map((page) => `${page.section}/${page.slug}`)
|
||||
})
|
||||
|
||||
if (keysByLocale[0]?.join("\n") !== keysByLocale[1]?.join("\n")) {
|
||||
throw new Error(
|
||||
`${definition.key} documentation must contain matching zh-Hans and en-US page paths.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOverviewPage(docPackage: DocPackage) {
|
||||
return docPackage.pages.find((page) => page.section === "overview")!
|
||||
}
|
||||
|
||||
function getLocalePrefix(locale: DocLocale) {
|
||||
return locale === defaultDocLocale ? "" : `/${locale}`
|
||||
}
|
||||
|
||||
function isDocLocale(value: unknown): value is DocLocale {
|
||||
return docLocales.some((locale) => locale === value)
|
||||
}
|
||||
|
||||
function isDocSection(value: unknown): value is DocSection {
|
||||
return docSections.some((section) => section === value)
|
||||
}
|
||||
|
||||
function readString(frontmatter: Record<string, unknown>, key: string) {
|
||||
const value = frontmatter[key]
|
||||
if (typeof value === "string" && value.trim()) return value
|
||||
throw new Error(`Documentation frontmatter requires a string "${key}".`)
|
||||
}
|
||||
|
||||
function readNumber(frontmatter: Record<string, unknown>, key: string) {
|
||||
const value = frontmatter[key]
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value
|
||||
throw new Error(`Documentation frontmatter requires a number "${key}".`)
|
||||
}
|
||||
|
||||
function readToc(value: unknown): readonly DocTocItem[] {
|
||||
if (value === undefined) return []
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Documentation frontmatter "toc" must be an array.')
|
||||
}
|
||||
|
||||
return value.map((item) => {
|
||||
if (!isRecord(item)) {
|
||||
throw new Error('Each documentation "toc" entry must be an object.')
|
||||
}
|
||||
|
||||
return {
|
||||
id: readString(item, "id"),
|
||||
title: readString(item, "title"),
|
||||
items: item.items === undefined ? undefined : readToc(item.items),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ComponentType, ElementType } from "react"
|
||||
|
||||
export const docLocales = ["zh-Hans", "en-US"] as const
|
||||
export const defaultDocLocale = "zh-Hans" as const
|
||||
export const docSections = [
|
||||
"overview",
|
||||
"guide",
|
||||
"advanced",
|
||||
"examples",
|
||||
] as const
|
||||
|
||||
export type DocLocale = (typeof docLocales)[number]
|
||||
export type DocSection = (typeof docSections)[number]
|
||||
export type MdxComponents = Record<string, ElementType>
|
||||
|
||||
export type DocTocItem = {
|
||||
id: string
|
||||
title: string
|
||||
items?: readonly DocTocItem[]
|
||||
}
|
||||
|
||||
export type DocPage = {
|
||||
Content: ComponentType<{ components?: MdxComponents }>
|
||||
description: string
|
||||
locale: DocLocale
|
||||
order: number
|
||||
section: DocSection
|
||||
slug: string
|
||||
title: string
|
||||
toc: readonly DocTocItem[]
|
||||
}
|
||||
|
||||
export type DocPackage = {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
locale: DocLocale
|
||||
pages: readonly DocPage[]
|
||||
}
|
||||
|
||||
export type DocLocation = {
|
||||
locale: DocLocale
|
||||
package: DocPackage
|
||||
page: DocPage
|
||||
}
|
||||
Reference in New Issue
Block a user