@@ -3,54 +3,48 @@ import { Card, Cards } from "fumadocs-ui/components/card";
33import type { PageTree } from "fumadocs-core/server" ;
44
55/**
6- * ============================================================================
7- * <SectionIndex> — 文档分区的"子节点卡片索引"
8- * ============================================================================
6+ * SectionIndex — 文档分区的子节点卡片索引。
97 *
10- * 这个组件做一件事:** 给定一个文档目录,把它的直接子节点(子文件夹 + 文件)渲染成 Cards** 。
8+ * 这个组件做一件事:给定一个文档目录,把它的直接子节点(子文件夹 + 文件)渲染成 Cards。
119 *
1210 * 三处使用场景:
13- * 1. /docs landing → < SectionIndex /> 列出顶层分区(ai / cs / 群友分享 ... )
14- * 2. CommunityShare 首页 → < SectionIndex root=" CommunityShare" /> 列出 Geek / Leetcode / RAG 等子分类
15- * 3. Leetcode 首页 → < SectionIndex root=" CommunityShare/Leetcode" /> 列出 49 篇题解
11+ * 1. /docs landing SectionIndex 不传参 列出顶层分区(ai / cs / 群友分享 等 )
12+ * 2. CommunityShare 首页 SectionIndex root=CommunityShare 列出 Geek / Leetcode / RAG 等子分类
13+ * 3. Leetcode 首页 SectionIndex root=CommunityShare/Leetcode 列出全部 Leetcode 题解
1614 *
1715 * ----------------------------------------------------------------------------
1816 * 为什么不直接用 fumadocs 自带的?
19- * fumadocs 确实有 getPageTreePeers() 和 < DocsCategory> (deprecated 但能用),
20- * 但它们**只返回 type=" page" 的兄弟节点,文件夹直接过滤掉** 。
21- * → 场景 1 和 2 的子节点大多是文件夹,内置 API 在这俩场景下返回空 。
22- * → 场景 3(Leetcode 下面全是 page)倒是可以直接用 < DocsCategory> 。
23- * 为了三处共用一个视觉,这里自己走一遍 pageTree。
17+ * fumadocs 有 getPageTreePeers() 和 DocsCategory(deprecated 但能用),但它们只返回
18+ * type=page 的兄弟节点,文件夹直接过滤掉。
19+ * - 场景 1 和 2 的子节点大多是文件夹,内置 API 返回空 。
20+ * - 场景 3(Leetcode 下面全是 page)倒是可以直接用 DocsCategory。
21+ * 为了三处共用一个视觉,这里自己走一遍 pageTree。
2422 *
2523 * ----------------------------------------------------------------------------
26- * source.pageTree 长什么样 (心智模型)
24+ * source.pageTree 的结构 (心智模型)
2725 *
28- * Root {
29- * children: [
30- * Folder {
31- * name: "AI 知识库",
32- * index: Page { url: "/docs/ai", name: "AI 知识库" }, // 有 index.mdx
33- * children: [ Page, Folder, ... ]
34- * },
35- * Folder {
36- * name: "All projects",
37- * index: undefined, // 没 index.mdx
38- * children: [ ... ]
39- * },
26+ * Root
27+ * children:
28+ * Folder
29+ * name = AI 知识库
30+ * index = Page(url=/docs/ai, name=AI 知识库) // 有 index.mdx
31+ * children: [Page, Folder, ...]
32+ * Folder
33+ * name = All projects
34+ * index = undefined // 没 index.mdx
35+ * children: [...]
4036 * ...
41- * ]
42- * }
4337 *
44- * 关键:Folder 可能**没有** index(对应目录下没 index.mdx),这种情况下:
45- * - fumadocs 不会给它生成 /docs/<folder> 路由 → 硬拼这个 URL 会 404
38+ * 关键:Folder 可能没有 index(目录下没 index.mdx),这种情况下:
39+ * - fumadocs 不会给它生成 /docs/<folder> 路由, 硬拼这个 URL 会 404
4640 * - 所以要 fallback 到子树第一个 page 的 url(见 findFirstPageUrl)
4741 *
4842 * ----------------------------------------------------------------------------
4943 * 几条不改的约束:
50- * - URL 永不硬拼:只用 tree 节点自带的 .url,规避 " /docs/<没 index 的目录>" 死链
51- * - 英文翻译版(URL 末段 .en)过滤掉,由 [...slug] 的 cookie locale fallback 负责切语言
52- * - 渲染用 fumadocs <Cards>/<Card>,三处保持视觉一致
53- * ============================================================================
44+ * - URL 永不硬拼:只用 tree 节点自带的 .url,规避 /docs/<没 index 的目录> 死链
45+ * - locale 翻译版( 末段 .en 或 .zh 且存在对应 canonical)过滤掉;孤儿(只有翻译版
46+ * 没 canonical)保留,否则 35 篇只有 .en.md 的英文题解会从索引消失
47+ * - 渲染用 fumadocs Cards / Card,三处保持视觉一致
5448 */
5549
5650// fumadocs PageTree 节点是 discriminated union,先抽出两个具体类型方便写类型注解
@@ -59,7 +53,7 @@ type FolderNode = Extract<PageTree.Node, { type: "folder" }>;
5953
6054interface SectionIndexProps {
6155 /**
62- * 从 pageTree 根往下走的目录路径,段之间用 "/" ,例如 " CommunityShare/Leetcode" 。
56+ * 从 pageTree 根往下走的目录路径,段之间用 / 分隔 ,例如 CommunityShare/Leetcode。
6357 * 不传 = 直接用 pageTree 根节点本身(用于 /docs landing)。
6458 */
6559 root ?: string ;
@@ -73,12 +67,12 @@ interface CardEntry {
7367}
7468
7569/**
76- * 从 pageTree 根一路"钻"到 root 指定的目录节点。
70+ * 从 pageTree 根一路钻到 root 指定的目录节点。
7771 *
78- * 举例:root = " CommunityShare/Leetcode"
79- * ① 根的 children 里找 segmentName === " CommunityShare" 的 folder
80- * ② 再在这个 folder 的 children 里找 segmentName === " Leetcode" 的 folder
81- * ③ 返回这个 folder 节点
72+ * 举例:root = CommunityShare/Leetcode
73+ * 1) 根的 children 里找 segmentName = CommunityShare 的 folder
74+ * 2) 再在这个 folder 的 children 里找 segmentName = Leetcode 的 folder
75+ * 3) 返回这个 folder 节点
8276 *
8377 * 任一段找不到就返回 null(组件会渲染一个明显的错误提示,而不是静默空页)。
8478 */
@@ -102,14 +96,14 @@ function findFolderByPath(
10296}
10397
10498/**
105- * 取 folder 对应的"目录名" (用来跟 root 参数里的段做匹配)。
99+ * 取 folder 对应的目录名 (用来跟 root 参数里的段做匹配)。
106100 *
107- * 为什么不直接用 ` folder.name`?
108- * fumadocs 的 FolderNode.name 是 ** ReactNode**( string | 复杂 JSX 都可能 ),
101+ * 为什么不直接用 folder.name:
102+ * fumadocs 的 FolderNode.name 是 ReactNode 类型(可能是 string,也可能是 JSX),
109103 * 直接字符串比较会在极端情况踩坑。更可靠的办法是从 folder.index.url 反推——
110- * 比如 " /docs/CommunityShare/Geek" 末段 " Geek" 就是目录名。
104+ * 比如 /docs/CommunityShare/Geek 最后一段 Geek 就是目录名。
111105 *
112- * 没 index 时只能退回 name.toString()。目前仓库里这种情况目录名都是纯字符串,
106+ * 没 index 时退回 name.toString()。目前仓库里这种情况目录名都是纯字符串,
113107 * 所以兜底够用。
114108 */
115109function folderSegmentName ( folder : FolderNode ) : string {
@@ -121,44 +115,70 @@ function folderSegmentName(folder: FolderNode): string {
121115}
122116
123117/**
124- * 这个 page 是不是英文翻译版?是的话不进索引列表。
118+ * 这个 URL 是不是可以隐藏的翻译版?
125119 *
126- * 站点里有两种翻译版文件:
127- * - 早期:`xxx.en.mdx`(靠 frontmatter.lang === "en" 标记)
128- * - 新: `xxx.en.md` 带 translatedFrom frontmatter
129- * PageTree 节点层面看不到 frontmatter,只能靠 URL 末段后缀 `.en` 兜底识别。
130- * 不影响展示正确性——翻译切换由 [...slug]/page.tsx 的 cookie locale fallback 做。
120+ * 站点里同一篇文档最多有三种文件形态:
121+ * - 无后缀的 canonical:xxx.mdx 或 xxx.md 原文,作者写什么语言就是什么语言
122+ * - .en.md / .en.mdx 英文翻译或英文原文
123+ * - .zh.md / .zh.mdx 中文翻译(原文是英文时才出现)
124+ *
125+ * 策略:只有当 .en / .zh 后缀的 URL 同时存在对应的 canonical(无后缀)版本时,才把它
126+ * 当翻译版隐藏;否则它就是这篇文档的唯一形态,必须保留——否则 35 篇只有 .en.md 的英文
127+ * 题解 + 7 篇只有 .zh.md 的中文翻译会从索引里一起消失。
128+ *
129+ * canonicals 传入预构建的"所有非 locale 后缀 URL"集合,避免每次判断都全表扫 getPages()。
131130 */
132- function isEnglishVariant ( page : PageNode ) : boolean {
133- const urlSlug = page . url . split ( "/" ) . pop ( ) ?? "" ;
134- return urlSlug . endsWith ( ".en" ) ;
131+ function isHideableLocaleVariant (
132+ url : string ,
133+ canonicals : Set < string > ,
134+ ) : boolean {
135+ const m = url . match ( / ^ ( .+ ) \. ( e n | z h ) $ / ) ;
136+ if ( ! m ) return false ;
137+ return canonicals . has ( m [ 1 ] ) ;
138+ }
139+
140+ /** 预构建 canonical URL 集合:所有 URL 末段不带 .en / .zh 的 page。单次 render 只算一次。 */
141+ function buildCanonicalUrlSet ( ) : Set < string > {
142+ const set = new Set < string > ( ) ;
143+ for ( const page of source . getPages ( ) ) {
144+ if ( ! / \. (?: e n | z h ) $ / . test ( page . url ) ) {
145+ set . add ( page . url ) ;
146+ }
147+ }
148+ return set ;
135149}
136150
137151/**
138152 * 深度优先找子树里第一个可链接的 page url。
139153 *
140- * 用途:folder 没有自己的 index.mdx 时,不能硬拼 /docs/<folder> 做卡片链接
141- * (Next 路由里没这条,会 404)。所以往里走一层,找到第一个 page 文件的 url
142- * 拿来做兜底链接。比如:
154+ * 用途:folder 没有自己的 index.mdx 时,不能硬拼 /docs/<folder> 做卡片链接(Next 路由
155+ * 里没这条,会 404)。所以往里走一层,找到第一个 page 文件的 url 拿来做兜底链接。比如:
143156 *
144- * CommunityShare/Language/ ← 没 index.mdx
145- * pte-intro.mdx ← 用这篇的 url 做兜底
157+ * CommunityShare/Language/ 没 index.mdx
158+ * pte-intro.mdx 用这篇的 url 做兜底
146159 *
147160 * 点击卡片会进到 /docs/CommunityShare/Language/pte-intro,不会 404。
148161 */
149- function findFirstPageUrl ( nodes : PageTree . Node [ ] ) : string | null {
162+ function findFirstPageUrl (
163+ nodes : PageTree . Node [ ] ,
164+ canonicals : Set < string > ,
165+ ) : string | null {
150166 for ( const node of nodes ) {
151167 if ( node . type === "separator" ) continue ;
152168 if ( node . type === "page" ) {
153- if ( isEnglishVariant ( node as PageNode ) ) continue ;
154- return ( node as PageNode ) . url ;
169+ const page = node as PageNode ;
170+ if ( isHideableLocaleVariant ( page . url , canonicals ) ) continue ;
171+ return page . url ;
155172 }
156173 if ( node . type === "folder" ) {
157174 const folder = node as FolderNode ;
158- if ( folder . index && ! isEnglishVariant ( folder . index ) ) {
175+ if (
176+ folder . index &&
177+ ! isHideableLocaleVariant ( folder . index . url , canonicals )
178+ ) {
159179 return folder . index . url ;
160180 }
161- const nested = findFirstPageUrl ( folder . children ) ;
181+ const nested = findFirstPageUrl ( folder . children , canonicals ) ;
162182 if ( nested ) return nested ;
163183 }
164184 }
@@ -168,20 +188,23 @@ function findFirstPageUrl(nodes: PageTree.Node[]): string | null {
168188/**
169189 * 把一个 pageTree 节点归一成 Card 数据。
170190 *
171- * - separator 节点(sidebar 分隔条)→ 跳过
172- * - page 节点 → 直接用 name + url + description
173- * - folder 节点 →
174- * · 有 index: 用 index 的 name / url / description(最直观的形态)
175- * · 没 index:用 folder.name 做标题,href 兜底到 findFirstPageUrl
176- * · 整个子树连一个可链接的 page 都没有:返回 null 跳过(不生成死链)
177- * - 英文翻译版 → 返回 null 跳过
191+ * - separator 节点(sidebar 分隔条): 跳过
192+ * - page 节点: 直接用 name + url + description;是可隐藏的 locale 翻译版则跳过
193+ * - folder 节点:
194+ * 有 index 且 index 不是翻译版 用 index 的 name / url / description
195+ * 有 index 但 index 本身是翻译版 当作没 index 走 fallback(规避暴露翻译 URL)
196+ * 没 index 用 folder.name 做标题,href 兜底到 findFirstPageUrl
197+ * 整个子树都没可链接的 page 返回 null 跳过(不生成死链)
178198 */
179- function nodeToCard ( node : PageTree . Node ) : CardEntry | null {
199+ function nodeToCard (
200+ node : PageTree . Node ,
201+ canonicals : Set < string > ,
202+ ) : CardEntry | null {
180203 if ( node . type === "separator" ) return null ;
181204
182205 if ( node . type === "page" ) {
183206 const page = node as PageNode ;
184- if ( isEnglishVariant ( page ) ) return null ;
207+ if ( isHideableLocaleVariant ( page . url , canonicals ) ) return null ;
185208 return {
186209 title : asPlainText ( page . name ) ,
187210 href : page . url ,
@@ -190,8 +213,13 @@ function nodeToCard(node: PageTree.Node): CardEntry | null {
190213 }
191214
192215 const folder = node as FolderNode ;
193- const idxUrl = folder . index ?. url ;
194- const fallbackUrl = idxUrl ?? findFirstPageUrl ( folder . children ) ;
216+ // folder.index 如果本身是翻译版(index.en.mdx / index.zh.mdx),不能直接当卡片 href,
217+ // 否则会把非 canonical URL 暴露出去。退回 findFirstPageUrl 兜底。
218+ const idxUrl =
219+ folder . index && ! isHideableLocaleVariant ( folder . index . url , canonicals )
220+ ? folder . index . url
221+ : undefined ;
222+ const fallbackUrl = idxUrl ?? findFirstPageUrl ( folder . children , canonicals ) ;
195223 if ( ! fallbackUrl ) return null ;
196224 return {
197225 title : folder . index
@@ -205,7 +233,7 @@ function nodeToCard(node: PageTree.Node): CardEntry | null {
205233}
206234
207235/**
208- * PageTree 里 name/ description 类型是 ReactNode,这里强行要一个 string 做卡片标题。
236+ * PageTree 里 name 和 description 类型是 ReactNode,这里强行要一个 string 做卡片标题。
209237 * 实际上仓库里所有 frontmatter 都是 string,不会走到 String(value) 的分支。
210238 */
211239function asPlainText ( value : unknown ) : string {
@@ -229,13 +257,16 @@ export function SectionIndex({ root }: SectionIndexProps) {
229257 // 但类型定义上 Root 没有 index 字段,所以下面要区分一下。
230258 const children = "children" in node ? node . children : [ ] ;
231259
232- // 第 3 步:过滤 + 转成 Card 数据。
233- // - 排除根自己的 index URL(folder 的 index 会和 folder 本身同 url,
234- // 不过滤的话"点进自己"会导致"Geek → Geek"这种死循环展示)
260+ // 第 3 步:预构建 canonical URL 集合,供 locale 翻译版判定复用
261+ const canonicals = buildCanonicalUrlSet ( ) ;
262+
263+ // 第 4 步:过滤 + 转成 Card 数据。
264+ // - 排除根自己的 index URL(folder 的 index 会和 folder 本身同 url,不过滤的话
265+ // "点进自己"会导致 Geek -> Geek 这种死循环展示)
235266 // - 按 title 中文排序,保证每次渲染顺序稳定(不然 file system order 会跟 OS 走)
236267 const rootIndexUrl = "index" in node ? node . index ?. url : undefined ;
237268 const cards = children
238- . map ( nodeToCard )
269+ . map ( ( n ) => nodeToCard ( n , canonicals ) )
239270 . filter ( ( c ) : c is CardEntry => c !== null && c . href !== rootIndexUrl )
240271 . sort ( ( a , b ) => a . title . localeCompare ( b . title , "zh-Hans-CN" ) ) ;
241272
@@ -247,7 +278,7 @@ export function SectionIndex({ root }: SectionIndexProps) {
247278 ) ;
248279 }
249280
250- // 第 4 步:fumadocs 的 Cards / Card 组件负责视觉
281+ // 第 5 步:fumadocs 的 Cards / Card 组件负责视觉
251282 return (
252283 < Cards >
253284 { cards . map ( ( c ) => (
0 commit comments