@@ -6,6 +6,140 @@ import { DocsRouteFlag } from "@/app/components/RouteFlags";
66import type { PageTree } from "fumadocs-core/server" ;
77import { CopyTracking } from "@/app/components/CopyTracking" ;
88import { DocsPageViewTracker } from "@/app/components/DocsPageViewTracker" ;
9+ import { cookies } from "next/headers" ;
10+
11+ type Locale = "zh" | "en" ;
12+ type SourcePage = ReturnType < typeof source . getPages > [ number ] ;
13+
14+ /**
15+ * 从 slug 数组末尾剥离语言后缀(.en / .zh),返回 canonical slug + 识别出的后缀。
16+ *
17+ * 例:["ai", "rl.en"] → { canonical: ["ai", "rl"], suffix: "en" }
18+ * ["ai", "rl"] → { canonical: ["ai", "rl"], suffix: null }
19+ */
20+ function stripLocaleSuffix ( slugs : readonly string [ ] ) : {
21+ canonical : string [ ] ;
22+ suffix : Locale | null ;
23+ } {
24+ const last = slugs [ slugs . length - 1 ] ?? "" ;
25+ const m = / ^ ( .+ ) \. ( e n | z h ) $ / . exec ( last ) ;
26+ if ( m ) {
27+ return {
28+ canonical : [ ...slugs . slice ( 0 , - 1 ) , m [ 1 ] ] ,
29+ suffix : m [ 2 ] as Locale ,
30+ } ;
31+ }
32+ return { canonical : [ ...slugs ] , suffix : null } ;
33+ }
34+
35+ function slugsToUrl ( slugs : string [ ] ) : string {
36+ return slugs . length === 0 ? "/docs" : `/docs/${ slugs . join ( "/" ) } ` ;
37+ }
38+
39+ /**
40+ * 把所有 page 按 canonical URL 分组,每组按优先级挑一个最适合当前 locale 的变体:
41+ * 1. 显式 .{locale}.mdx(例 foo.en.mdx 匹配 locale=en)
42+ * 2. frontmatter.lang 与 locale 相同的原文(没后缀但 lang 字段对得上)
43+ * 3. 无 locale 标记的原文(兜底,保证每组至少保留一个)
44+ *
45+ * 返回 rawUrl(page 原始 URL,含 .en/.zh 后缀)→ canonicalUrl 的映射。
46+ * 只有被选中的变体会出现在 Map 里;其它变体在 sidebar 里要被剪掉。
47+ */
48+ function pickVariantsByLocale (
49+ pages : SourcePage [ ] ,
50+ locale : Locale ,
51+ ) : Map < string , string > {
52+ const groups = new Map < string , SourcePage [ ] > ( ) ;
53+ for ( const p of pages ) {
54+ const { canonical } = stripLocaleSuffix ( p . slugs ) ;
55+ const key = slugsToUrl ( canonical ) ;
56+ const arr = groups . get ( key ) ;
57+ if ( arr ) arr . push ( p ) ;
58+ else groups . set ( key , [ p ] ) ;
59+ }
60+
61+ const result = new Map < string , string > ( ) ;
62+ for ( const [ canonicalUrl , variants ] of groups ) {
63+ const chosen = chooseVariant ( variants , locale ) ;
64+ result . set ( chosen . url , canonicalUrl ) ;
65+ }
66+ return result ;
67+ }
68+
69+ function chooseVariant ( variants : SourcePage [ ] , locale : Locale ) : SourcePage {
70+ // 1. 显式 .{locale} 后缀的翻译版
71+ const explicit = variants . find (
72+ ( p ) => stripLocaleSuffix ( p . slugs ) . suffix === locale ,
73+ ) ;
74+ if ( explicit ) return explicit ;
75+
76+ // 2. 原文(无后缀)且 frontmatter.lang === locale
77+ const originalMatching = variants . find ( ( p ) => {
78+ const { suffix } = stripLocaleSuffix ( p . slugs ) ;
79+ if ( suffix !== null ) return false ;
80+ const lang = ( p . data as { lang ?: string } ) . lang ;
81+ return lang === locale ;
82+ } ) ;
83+ if ( originalMatching ) return originalMatching ;
84+
85+ // 3. 原文兜底(显示原语言,避免某篇文档因为没翻译就从 sidebar 消失)
86+ const original = variants . find (
87+ ( p ) => stripLocaleSuffix ( p . slugs ) . suffix === null ,
88+ ) ;
89+ if ( original ) return original ;
90+
91+ // 4. 实在没原文(理论上不会到这,除非两个 .en/.zh 并存没原文),随便返回一个
92+ return variants [ 0 ] ;
93+ }
94+
95+ /**
96+ * 按选中的变体集合剪 PageTree:
97+ * - 页节点:URL 不在 chosen 里 → 删除;在 chosen 里 → 改写 url 成 canonical
98+ * - 文件夹:递归处理子节点,子节点全空就连同文件夹一起删
99+ * - index 页同样判定 + 改写
100+ */
101+ function filterTreeByLocale (
102+ root : PageTree . Root ,
103+ chosen : Map < string , string > ,
104+ ) : PageTree . Root {
105+ const transformIndex = (
106+ index : PageTree . Folder [ "index" ] ,
107+ ) : PageTree . Folder [ "index" ] => {
108+ if ( ! index ) return undefined ;
109+ const canonicalUrl = chosen . get ( index . url ) ;
110+ if ( canonicalUrl === undefined ) return undefined ;
111+ return { ...index , url : canonicalUrl } ;
112+ } ;
113+
114+ const transformNode = ( node : PageTree . Node ) : PageTree . Node | null => {
115+ if ( node . type === "folder" ) {
116+ const children = node . children
117+ . map ( transformNode )
118+ . filter ( ( c ) : c is PageTree . Node => c !== null ) ;
119+ const index = transformIndex ( node . index ) ;
120+ if ( ! index && children . length === 0 ) return null ;
121+ return { ...node , index, children } ;
122+ }
123+ if ( node . type === "separator" ) return { ...node } ;
124+ // page
125+ const canonicalUrl = chosen . get ( node . url ) ;
126+ if ( canonicalUrl === undefined ) return null ;
127+ return { ...node , url : canonicalUrl } ;
128+ } ;
129+
130+ const transformRoot = ( r : PageTree . Root ) : PageTree . Root => {
131+ const children = r . children
132+ . map ( transformNode )
133+ . filter ( ( c ) : c is PageTree . Node => c !== null ) ;
134+ return {
135+ ...r ,
136+ children,
137+ fallback : r . fallback ? transformRoot ( r . fallback ) : undefined ,
138+ } ;
139+ } ;
140+
141+ return transformRoot ( root ) ;
142+ }
9143
10144function pruneEmptyFolders ( root : PageTree . Root ) : PageTree . Root {
11145 const transformNode = ( node : PageTree . Node ) : PageTree . Node | null => {
@@ -65,7 +199,15 @@ function pruneEmptyFolders(root: PageTree.Root): PageTree.Root {
65199}
66200
67201export default async function Layout ( { children } : { children : ReactNode } ) {
68- const tree = pruneEmptyFolders ( source . pageTree ) ;
202+ // 根据 locale cookie 为 sidebar 挑选文档变体;与根 layout.tsx 的 locale 读取方式保持一致
203+ const cookieStore = await cookies ( ) ;
204+ const locale : Locale =
205+ cookieStore . get ( "locale" ) ?. value === "en" ? "en" : "zh" ;
206+
207+ // 先按 locale 筛掉重复的翻译变体,剩下每组只留一个;再剪空文件夹
208+ const chosen = pickVariantsByLocale ( source . getPages ( ) , locale ) ;
209+ const localizedTree = filterTreeByLocale ( source . pageTree , chosen ) ;
210+ const tree = pruneEmptyFolders ( localizedTree ) ;
69211 const options = await baseOptions ( ) ;
70212 return (
71213 < >
0 commit comments