Skip to content

Commit 6079fa7

Browse files
committed
fix(SectionIndex): 按 CR 补齐 locale 变体过滤 + 去掉注释里的 markdown
Copilot 在 #292 提了 3 条要修的: 1) isEnglishVariant 只过滤 .en,没管 .zh —— 站点实际有 .zh.md(原文是 en 时的中文翻译), 重复链接会在索引里暴露。改成 isHideableLocaleVariant(url, canonicals):只有对应 canonical 存在时才隐藏,孤儿(只有 .en 或 .zh 单一形态的文档,共 35 + 7 篇)保留。 2) folder.index 如果本身是翻译版(理论上会有 index.en.mdx / index.zh.mdx),不能直接 当卡片 href,会暴露非 canonical URL。nodeToCard 里给 idxUrl 加同样的过滤,不合规时 退回 findFirstPageUrl。 3) folderSegmentName 注释写的"倒数第二段"但代码取的是最后一段,改掉注释。 另外按用户反馈清掉注释里的 markdown(**bold**、反引号等),代码注释又不会被渲染。
1 parent f12b2cc commit 6079fa7

1 file changed

Lines changed: 111 additions & 80 deletions

File tree

app/components/docs/SectionIndex.tsx

Lines changed: 111 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,54 +3,48 @@ import { Card, Cards } from "fumadocs-ui/components/card";
33
import 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

6054
interface 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
*/
115109
function 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(/^(.+)\.(en|zh)$/);
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 (!/\.(?:en|zh)$/.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 里 namedescription 类型是 ReactNode,这里强行要一个 string 做卡片标题。
209237
* 实际上仓库里所有 frontmatter 都是 string,不会走到 String(value) 的分支。
210238
*/
211239
function 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

Comments
 (0)