Skip to content

Commit 479b566

Browse files
longsizhuoLynPtl
andauthored
refactor(docs): 统一 /docs、CommunityShare、Leetcode 三处索引为 <SectionIndex>(取代 #288 #290) (#292)
* refactor(docs): 统一 /docs、CommunityShare、Leetcode 三处索引为 <SectionIndex> 合并 #288 + #290 + app/docs/CommunityShare/Leetcode/index.mdx 里原先三份 各自实现的"列目录子节点"逻辑,改成一个 server component。 ## 为什么要合并 原本三处各自实现: - /docs 根路由(PR #290 draft)—— 读 pageTree.children - CommunityShare/index.mdx(PR #288 draft)—— 读 getPages() 过滤 path - CommunityShare/Leetcode/index.mdx —— 内联 MDX 里 source.getPages().filter().map() drift 问题:排序、英文过滤、fallback URL 三份逻辑各走各的;更严重的是 PR #288 里对"没 index.mdx 的子目录"硬拼 /docs/CommunityShare/<dir> 会 404 (Copilot CR 指出),和 PR #290 修 /docs 404 是同一个根因:Next [...slug] 不匹配空 slug,folder 没 index 就意味着 /docs/X 没 route。 ## <SectionIndex root?> - 走 source.pageTree(不是 getPages),fumadocs 已经把 folder+index 关系建好了,不用自己从扁平 page 列表反推 - root 接 "CommunityShare" / "CommunityShare/Leetcode" 这种相对路径, 不传就是从 pageTree 根开始(给 /docs landing 用) - URL 永不硬拼:folder 有 index 走 index.url;没 index 递归找子树第一个 page 的 url 作为 fallback(直接修掉 CR 那个 404 bug) - 英文翻译版(URL 末段 .en)过滤不进列表;语言切换仍由 [...slug] 的 cookie fallback 负责 - 统一 fumadocs <Cards>/<Card> 视觉 ## 本地验证 - /docs → 5 张卡片,全部 200 - /docs/CommunityShare → 8 张卡片,全部 200(包括原先会 404 的 Language/ Life/Personal-Study-Notes/RAG 四个没 index 的分类,现在点进去是子目录里 第一篇 page,不再死链) - /docs/CommunityShare/Leetcode → 49 张卡片,0 个 .en 泄漏 ## 取代关系 - 关闭 PR #288(CommunityShareIndex 专用实现,有 404 bug) - 关闭 PR #290(/docs landing 单独实现) - 本 PR 一并覆盖,继续承担解决 #110 的责任 Co-authored-by: LynPtl <194795025+LynPtl@users.noreply.github.com> * docs(SectionIndex): 加大量中文注释说明 pageTree 心智模型 + 每步做什么 * 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**、反引号等),代码注释又不会被渲染。 --------- Co-authored-by: LynPtl <194795025+LynPtl@users.noreply.github.com>
1 parent 1f92a42 commit 479b566

4 files changed

Lines changed: 349 additions & 42 deletions

File tree

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { source } from "@/lib/source";
2+
import { Card, Cards } from "fumadocs-ui/components/card";
3+
import type { PageTree } from "fumadocs-core/server";
4+
5+
/**
6+
* SectionIndex — 文档分区的子节点卡片索引。
7+
*
8+
* 这个组件做一件事:给定一个文档目录,把它的直接子节点(子文件夹 + 文件)渲染成 Cards。
9+
*
10+
* 三处使用场景:
11+
* 1. /docs landing SectionIndex 不传参 列出顶层分区(ai / cs / 群友分享 等)
12+
* 2. CommunityShare 首页 SectionIndex root=CommunityShare 列出 Geek / Leetcode / RAG 等子分类
13+
* 3. Leetcode 首页 SectionIndex root=CommunityShare/Leetcode 列出全部 Leetcode 题解
14+
*
15+
* ----------------------------------------------------------------------------
16+
* 为什么不直接用 fumadocs 自带的?
17+
* fumadocs 有 getPageTreePeers() 和 DocsCategory(deprecated 但能用),但它们只返回
18+
* type=page 的兄弟节点,文件夹直接过滤掉。
19+
* - 场景 1 和 2 的子节点大多是文件夹,内置 API 返回空。
20+
* - 场景 3(Leetcode 下面全是 page)倒是可以直接用 DocsCategory。
21+
* 为了三处共用一个视觉,这里自己走一遍 pageTree。
22+
*
23+
* ----------------------------------------------------------------------------
24+
* source.pageTree 的结构(心智模型)
25+
*
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: [...]
36+
* ...
37+
*
38+
* 关键:Folder 可能没有 index(目录下没 index.mdx),这种情况下:
39+
* - fumadocs 不会给它生成 /docs/<folder> 路由,硬拼这个 URL 会 404
40+
* - 所以要 fallback 到子树第一个 page 的 url(见 findFirstPageUrl)
41+
*
42+
* ----------------------------------------------------------------------------
43+
* 几条不改的约束:
44+
* - URL 永不硬拼:只用 tree 节点自带的 .url,规避 /docs/<没 index 的目录> 死链
45+
* - locale 翻译版(末段 .en 或 .zh 且存在对应 canonical)过滤掉;孤儿(只有翻译版
46+
* 没 canonical)保留,否则 35 篇只有 .en.md 的英文题解会从索引消失
47+
* - 渲染用 fumadocs Cards / Card,三处保持视觉一致
48+
*/
49+
50+
// fumadocs PageTree 节点是 discriminated union,先抽出两个具体类型方便写类型注解
51+
type PageNode = Extract<PageTree.Node, { type: "page" }>;
52+
type FolderNode = Extract<PageTree.Node, { type: "folder" }>;
53+
54+
interface SectionIndexProps {
55+
/**
56+
* 从 pageTree 根往下走的目录路径,段之间用 / 分隔,例如 CommunityShare/Leetcode。
57+
* 不传 = 直接用 pageTree 根节点本身(用于 /docs landing)。
58+
*/
59+
root?: string;
60+
}
61+
62+
// 一张 Card 需要的最小数据。渲染前把各种节点(page / folder)归一成这个结构
63+
interface CardEntry {
64+
title: string;
65+
href: string;
66+
description?: string;
67+
}
68+
69+
/**
70+
* 从 pageTree 根一路钻到 root 指定的目录节点。
71+
*
72+
* 举例:root = CommunityShare/Leetcode
73+
* 1) 根的 children 里找 segmentName = CommunityShare 的 folder
74+
* 2) 再在这个 folder 的 children 里找 segmentName = Leetcode 的 folder
75+
* 3) 返回这个 folder 节点
76+
*
77+
* 任一段找不到就返回 null(组件会渲染一个明显的错误提示,而不是静默空页)。
78+
*/
79+
function findFolderByPath(
80+
tree: PageTree.Root,
81+
root: string | undefined,
82+
): PageTree.Root | FolderNode | null {
83+
if (!root) return tree;
84+
const segments = root.split("/").filter(Boolean);
85+
let current: PageTree.Root | FolderNode = tree;
86+
for (const seg of segments) {
87+
const children: PageTree.Node[] = current.children;
88+
const next: FolderNode | undefined = children.find(
89+
(c): c is FolderNode =>
90+
c.type === "folder" && folderSegmentName(c) === seg,
91+
);
92+
if (!next) return null;
93+
current = next;
94+
}
95+
return current;
96+
}
97+
98+
/**
99+
* 取 folder 对应的目录名(用来跟 root 参数里的段做匹配)。
100+
*
101+
* 为什么不直接用 folder.name:
102+
* fumadocs 的 FolderNode.name 是 ReactNode 类型(可能是 string,也可能是 JSX),
103+
* 直接字符串比较会在极端情况踩坑。更可靠的办法是从 folder.index.url 反推——
104+
* 比如 /docs/CommunityShare/Geek 最后一段 Geek 就是目录名。
105+
*
106+
* 没 index 时退回 name.toString()。目前仓库里这种情况目录名都是纯字符串,
107+
* 所以兜底够用。
108+
*/
109+
function folderSegmentName(folder: FolderNode): string {
110+
if (folder.index) {
111+
const parts = folder.index.url.split("/").filter(Boolean);
112+
return parts[parts.length - 1] ?? "";
113+
}
114+
return typeof folder.name === "string" ? folder.name : String(folder.name);
115+
}
116+
117+
/**
118+
* 这个 URL 是不是可以隐藏的翻译版?
119+
*
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()。
130+
*/
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;
149+
}
150+
151+
/**
152+
* 深度优先找子树里第一个可链接的 page url。
153+
*
154+
* 用途:folder 没有自己的 index.mdx 时,不能硬拼 /docs/<folder> 做卡片链接(Next 路由
155+
* 里没这条,会 404)。所以往里走一层,找到第一个 page 文件的 url 拿来做兜底链接。比如:
156+
*
157+
* CommunityShare/Language/ 没 index.mdx
158+
* pte-intro.mdx 用这篇的 url 做兜底
159+
*
160+
* 点击卡片会进到 /docs/CommunityShare/Language/pte-intro,不会 404。
161+
*/
162+
function findFirstPageUrl(
163+
nodes: PageTree.Node[],
164+
canonicals: Set<string>,
165+
): string | null {
166+
for (const node of nodes) {
167+
if (node.type === "separator") continue;
168+
if (node.type === "page") {
169+
const page = node as PageNode;
170+
if (isHideableLocaleVariant(page.url, canonicals)) continue;
171+
return page.url;
172+
}
173+
if (node.type === "folder") {
174+
const folder = node as FolderNode;
175+
if (
176+
folder.index &&
177+
!isHideableLocaleVariant(folder.index.url, canonicals)
178+
) {
179+
return folder.index.url;
180+
}
181+
const nested = findFirstPageUrl(folder.children, canonicals);
182+
if (nested) return nested;
183+
}
184+
}
185+
return null;
186+
}
187+
188+
/**
189+
* 把一个 pageTree 节点归一成 Card 数据。
190+
*
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 跳过(不生成死链)
198+
*/
199+
function nodeToCard(
200+
node: PageTree.Node,
201+
canonicals: Set<string>,
202+
): CardEntry | null {
203+
if (node.type === "separator") return null;
204+
205+
if (node.type === "page") {
206+
const page = node as PageNode;
207+
if (isHideableLocaleVariant(page.url, canonicals)) return null;
208+
return {
209+
title: asPlainText(page.name),
210+
href: page.url,
211+
description: page.description ? asPlainText(page.description) : undefined,
212+
};
213+
}
214+
215+
const folder = node as FolderNode;
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);
223+
if (!fallbackUrl) return null;
224+
return {
225+
title: folder.index
226+
? asPlainText(folder.index.name)
227+
: asPlainText(folder.name),
228+
href: fallbackUrl,
229+
description: folder.index?.description
230+
? asPlainText(folder.index.description)
231+
: undefined,
232+
};
233+
}
234+
235+
/**
236+
* PageTree 里 name 和 description 类型是 ReactNode,这里强行要一个 string 做卡片标题。
237+
* 实际上仓库里所有 frontmatter 都是 string,不会走到 String(value) 的分支。
238+
*/
239+
function asPlainText(value: unknown): string {
240+
if (typeof value === "string") return value;
241+
if (value == null) return "";
242+
return String(value);
243+
}
244+
245+
export function SectionIndex({ root }: SectionIndexProps) {
246+
// 第 1 步:定位目标节点(pageTree 根 or 某个 folder)
247+
const node = findFolderByPath(source.pageTree, root);
248+
if (!node) {
249+
return (
250+
<p className="text-sm text-red-600">
251+
SectionIndex: root path &quot;{root}&quot; not found in pageTree
252+
</p>
253+
);
254+
}
255+
256+
// 第 2 步:拿它的直接子节点。PageTree.Root 和 FolderNode 都有 children 字段,
257+
// 但类型定义上 Root 没有 index 字段,所以下面要区分一下。
258+
const children = "children" in node ? node.children : [];
259+
260+
// 第 3 步:预构建 canonical URL 集合,供 locale 翻译版判定复用
261+
const canonicals = buildCanonicalUrlSet();
262+
263+
// 第 4 步:过滤 + 转成 Card 数据。
264+
// - 排除根自己的 index URL(folder 的 index 会和 folder 本身同 url,不过滤的话
265+
// "点进自己"会导致 Geek -> Geek 这种死循环展示)
266+
// - 按 title 中文排序,保证每次渲染顺序稳定(不然 file system order 会跟 OS 走)
267+
const rootIndexUrl = "index" in node ? node.index?.url : undefined;
268+
const cards = children
269+
.map((n) => nodeToCard(n, canonicals))
270+
.filter((c): c is CardEntry => c !== null && c.href !== rootIndexUrl)
271+
.sort((a, b) => a.title.localeCompare(b.title, "zh-Hans-CN"));
272+
273+
if (cards.length === 0) {
274+
return (
275+
<p className="text-sm text-fd-muted-foreground">
276+
暂无内容,期待你的投稿!
277+
</p>
278+
);
279+
}
280+
281+
// 第 5 步:fumadocs 的 Cards / Card 组件负责视觉
282+
return (
283+
<Cards>
284+
{cards.map((c) => (
285+
<Card
286+
key={c.href}
287+
title={c.title}
288+
href={c.href}
289+
description={c.description}
290+
/>
291+
))}
292+
</Cards>
293+
);
294+
}

app/docs/CommunityShare/Leetcode/index.mdx

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,10 @@ description: 这里收集了社区分享的所有 Leetcode 刷题笔记和题解
44
docId: aslw60tfyzxqga598pt4ociu
55
---
66

7-
import { source } from "@/lib/source";
7+
import { SectionIndex } from "@/app/components/docs/SectionIndex";
88

99
# Leetcode 题解
1010

1111
欢迎查阅 Leetcode 相关的分享内容。
1212

13-
<Cards>
14-
{source
15-
.getPages()
16-
.filter(
17-
(page) =>
18-
page.file.dirname === "CommunityShare/Leetcode" &&
19-
page.file.name !== "index",
20-
)
21-
.map((page) => (
22-
<Card
23-
key={page.url}
24-
title={page.data.title}
25-
href={page.url}
26-
description={page.data.description}
27-
/>
28-
))}
29-
</Cards>
13+
<SectionIndex root="CommunityShare/Leetcode" />

app/docs/CommunityShare/index.mdx

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,12 @@ date: "2025-09-18"
44
docId: sfzt30mtx0jsuv6esnpm3w8y
55
---
66

7+
import { SectionIndex } from "@/app/components/docs/SectionIndex";
8+
79
欢迎来到群友分享板块!无论你是技术极客,还是热爱生活,都欢迎积极投稿!
810

911
一篇微不足道的文章或许可以帮助一个迷茫的陌生人~
1012

1113
> 转载文章请先联系原作者获取授权,谢谢!
1214
13-
## 技术分享
14-
15-
- [常用Markdown语法](/docs/CommunityShare/Geek/CommonUsedMarkdown)
16-
17-
- [Git入门操作指南-程序员必会的git小技巧](/docs/CommunityShare/Geek/git101)
18-
19-
- [用闲置树莓派搭建一个Minecraft服务器](/docs/CommunityShare/Geek/raspberry-guide)
20-
21-
- [常用Katex语法](/docs/CommunityShare/Geek/Katex/index)
22-
23-
## 心理健康
24-
25-
- [程序员 Burnout 自救指南](/docs/CommunityShare/MentalHealth/burnout-guide) - 识别和应对职业倦怠
26-
27-
## RAG
28-
29-
- [RAG toy demo](/docs/CommunityShare/RAG/rag)
30-
31-
## 身体健康
32-
33-
- 久坐办公的解决方案
34-
- 程序员健身指南
35-
- 饮食与营养建议
36-
- 睡眠质量改善
15+
<SectionIndex root="CommunityShare" />

0 commit comments

Comments
 (0)