372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import { lazy } from "react"
|
|
|
|
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 DocContentModule = {
|
|
default: React.ComponentType<{ components?: MdxComponents }>
|
|
}
|
|
|
|
type PackageDefinition = {
|
|
key: string
|
|
label: string
|
|
description: Translation<string>
|
|
}
|
|
|
|
type ParsedDocPage = DocPage & { packageKey: string }
|
|
|
|
const packageDefinitions: readonly PackageDefinition[] = [
|
|
{
|
|
key: "blocks",
|
|
label: "Blocks",
|
|
description: {
|
|
zh: "构建应用外壳,并接入导航、通知、聊天和外观控制。",
|
|
us: "Build an application shell and add navigation, notifications, chat, 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: "media",
|
|
label: "Media",
|
|
description: {
|
|
zh: "通过存储 Adapter 构建媒体浏览、上传、目录管理和资源选择流程。",
|
|
us: "Build media browsing, uploads, folder management, and asset selection with a storage adapter.",
|
|
},
|
|
},
|
|
{
|
|
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 docModuleLoaders = import.meta.glob<DocContentModule>(
|
|
"../../../packages/*/docs/*/**/*.mdx",
|
|
{ eager: false }
|
|
)
|
|
|
|
const docFrontmatter = import.meta.glob<Record<string, unknown>>(
|
|
"../../../packages/*/docs/*/**/*.mdx",
|
|
{ eager: true, import: "frontmatter" }
|
|
)
|
|
|
|
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[]> = {
|
|
"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("/").find(Boolean)
|
|
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 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(
|
|
definition: PackageDefinition,
|
|
locale: DocLocale
|
|
): DocPackage {
|
|
const pages = parsedPages
|
|
.filter(
|
|
(page) => page.packageKey === definition.key && page.locale === locale
|
|
)
|
|
.toSorted(
|
|
(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,
|
|
frontmatter: Record<string, unknown>,
|
|
Content: DocPage["Content"]
|
|
): 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}".`)
|
|
}
|
|
|
|
return {
|
|
Content,
|
|
description: readString(frontmatter, "description"),
|
|
locale: localeValue,
|
|
order: readNumber(frontmatter, "order"),
|
|
packageKey,
|
|
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
|
|
}
|