Skip to content

Commit fdaff81

Browse files
longsizhuoLynPtl
andcommitted
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>
1 parent 404e357 commit fdaff81

4 files changed

Lines changed: 236 additions & 42 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
* 通用分区索引(Server Component),替代原本散落的三份各自实现:
7+
* - `/docs/page.tsx` 的 pageTree.children Cards(PR #290 的 draft)
8+
* - `app/components/CommunityShareIndex.tsx` 的分组列表(PR #288 的 draft)
9+
* - `app/docs/CommunityShare/Leetcode/index.mdx` 里的内联 `source.getPages().filter().map(<Card>)`
10+
*
11+
* 合并的动机:
12+
* 1. drift 维护:改一处行为(比如过滤翻译版、排序规则)要改 3 处,容易忘
13+
* 2. 其中一处还有 404 bug:`/docs/CommunityShare/<没 index 的目录>` 硬拼 URL 在 Next 路由里不存在
14+
* —— 和 PR #290 修 `/docs` 404 是同一个根因,即 Next `[...slug]` 不匹配空 slug,folder 没 index.mdx
15+
* 就意味着 `/docs/X` 没有任何 route
16+
*
17+
* 设计思路:
18+
* - 走 `source.pageTree`(而不是 `getPages()`):fumadocs 已经把"folder + 其可选 index"的关系
19+
* 建好了,我们不用自己从扁平 page 列表里反推
20+
* - `root` 参数接受形如 `"CommunityShare"` / `"CommunityShare/Leetcode"` 的目录相对路径。
21+
* undefined 表示从 pageTree 根开始(用于 `/docs` landing)
22+
* - 渲染策略:统一用 fumadocs `<Cards>` / `<Card>`,三处视觉语言一致
23+
* - URL 永不硬拼:folder 有 index → 走 index.url;没 index → 递归找子树第一个 page 的 url
24+
* 作为 fallback(保证不点空)
25+
* - 翻译版(`lang === "en"` 或文件名 `.en.mdx`)不出现在列表。语言切换仍由 `[...slug]/page.tsx`
26+
* 的 cookie fallback 处理,这里不重复
27+
*/
28+
29+
type PageNode = Extract<PageTree.Node, { type: "page" }>;
30+
type FolderNode = Extract<PageTree.Node, { type: "folder" }>;
31+
32+
interface SectionIndexProps {
33+
/** 相对 `/docs` 的目录路径,如 "CommunityShare";不传则从顶层开始 */
34+
root?: string;
35+
}
36+
37+
interface CardEntry {
38+
title: string;
39+
href: string;
40+
description?: string;
41+
}
42+
43+
/** 从 pageTree 根出发,按 "a/b/c" 逐段下钻找到目标 folder 节点 */
44+
function findFolderByPath(
45+
tree: PageTree.Root,
46+
root: string | undefined,
47+
): PageTree.Root | FolderNode | null {
48+
if (!root) return tree;
49+
const segments = root.split("/").filter(Boolean);
50+
let current: PageTree.Root | FolderNode = tree;
51+
for (const seg of segments) {
52+
const children: PageTree.Node[] = current.children;
53+
const next: FolderNode | undefined = children.find(
54+
(c): c is FolderNode =>
55+
c.type === "folder" && folderSegmentName(c) === seg,
56+
);
57+
if (!next) return null;
58+
current = next;
59+
}
60+
return current;
61+
}
62+
63+
/**
64+
* fumadocs 的 FolderNode.name 是 ReactNode(可能是字符串,也可能是 JSX),
65+
* 单靠 name 匹配不稳定。这里优先用 index 页的 slug 倒数第二段反推目录名,
66+
* 没 index 时退回 name.toString()。
67+
*/
68+
function folderSegmentName(folder: FolderNode): string {
69+
// folder.index.url 长这样:"/docs/CommunityShare/Geek" → 末段 "Geek" 即目录名
70+
if (folder.index) {
71+
const parts = folder.index.url.split("/").filter(Boolean);
72+
return parts[parts.length - 1] ?? "";
73+
}
74+
// 没 index:从 name 兜底(通常是 string)
75+
return typeof folder.name === "string" ? folder.name : String(folder.name);
76+
}
77+
78+
/** 判定页面是英文翻译版(不应出现在索引里) */
79+
function isEnglishVariant(page: PageNode): boolean {
80+
// PageTree 节点 name 可能是 string | ReactNode;英文变体的 frontmatter.lang === "en"
81+
// 但 pageTree 级别看不到 frontmatter,只能靠 URL 末段后缀兜底
82+
const urlSlug = page.url.split("/").pop() ?? "";
83+
return urlSlug.endsWith(".en");
84+
}
85+
86+
/** 深度优先找出子树第一个 page 的 url(folder 没 index 时用来兜底,保证不点空) */
87+
function findFirstPageUrl(nodes: PageTree.Node[]): string | null {
88+
for (const node of nodes) {
89+
if (node.type === "separator") continue;
90+
if (node.type === "page") {
91+
if (isEnglishVariant(node as PageNode)) continue;
92+
return (node as PageNode).url;
93+
}
94+
if (node.type === "folder") {
95+
const folder = node as FolderNode;
96+
if (folder.index && !isEnglishVariant(folder.index)) {
97+
return folder.index.url;
98+
}
99+
const nested = findFirstPageUrl(folder.children);
100+
if (nested) return nested;
101+
}
102+
}
103+
return null;
104+
}
105+
106+
function nodeToCard(node: PageTree.Node): CardEntry | null {
107+
if (node.type === "separator") return null;
108+
if (node.type === "page") {
109+
const page = node as PageNode;
110+
if (isEnglishVariant(page)) return null;
111+
return {
112+
title: asPlainText(page.name),
113+
href: page.url,
114+
description: page.description ? asPlainText(page.description) : undefined,
115+
};
116+
}
117+
// folder
118+
const folder = node as FolderNode;
119+
const idxUrl = folder.index?.url;
120+
const fallbackUrl = idxUrl ?? findFirstPageUrl(folder.children);
121+
if (!fallbackUrl) return null; // 整个子树都没可链接的 page,跳过(不生成死链)
122+
return {
123+
title: folder.index
124+
? asPlainText(folder.index.name)
125+
: asPlainText(folder.name),
126+
href: fallbackUrl,
127+
description: folder.index?.description
128+
? asPlainText(folder.index.description)
129+
: undefined,
130+
};
131+
}
132+
133+
function asPlainText(value: unknown): string {
134+
if (typeof value === "string") return value;
135+
if (value == null) return "";
136+
return String(value);
137+
}
138+
139+
export function SectionIndex({ root }: SectionIndexProps) {
140+
const node = findFolderByPath(source.pageTree, root);
141+
if (!node) {
142+
// 路径写错了(比如打错目录名),给个明显的渲染提示而不是静默空页
143+
return (
144+
<p className="text-sm text-red-600">
145+
SectionIndex: root path &quot;{root}&quot; not found in pageTree
146+
</p>
147+
);
148+
}
149+
150+
// Root node 和 FolderNode 都有 children;Root 没 index 概念(自身就是 /docs)
151+
const children = "children" in node ? node.children : [];
152+
153+
// 过滤:排除根自己的 index(避免"点进自己")
154+
const rootIndexUrl = "index" in node ? node.index?.url : undefined;
155+
const cards = children
156+
.map(nodeToCard)
157+
.filter((c): c is CardEntry => c !== null && c.href !== rootIndexUrl)
158+
// 按 title 中文排序,给读者稳定的浏览顺序
159+
.sort((a, b) => a.title.localeCompare(b.title, "zh-Hans-CN"));
160+
161+
if (cards.length === 0) {
162+
return (
163+
<p className="text-sm text-fd-muted-foreground">
164+
暂无内容,期待你的投稿!
165+
</p>
166+
);
167+
}
168+
169+
return (
170+
<Cards>
171+
{cards.map((c) => (
172+
<Card
173+
key={c.href}
174+
title={c.title}
175+
href={c.href}
176+
description={c.description}
177+
/>
178+
))}
179+
</Cards>
180+
);
181+
}

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" />

app/docs/page.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { DocsPage, DocsBody } from "fumadocs-ui/page";
2+
import type { Metadata } from "next";
3+
import { cookies } from "next/headers";
4+
import { SectionIndex } from "@/app/components/docs/SectionIndex";
5+
6+
/**
7+
* /docs 根路由的 landing。Header 导航的 "文档 / Docs" 直接指向 /docs,但原本
8+
* app/docs/ 下只有 layout.tsx + [...slug]/page.tsx(catch-all 不匹配空 slug),
9+
* 所以 /docs 本身 404。这个文件提供兜底 landing,复用已挂好的 DocsLayout。
10+
*
11+
* 内容交给 `<SectionIndex />`(root 不传 → 渲染 pageTree 顶层分区)。所有渲染
12+
* 逻辑和 CommunityShare / Leetcode 两处共用同一个组件,避免 drift。
13+
*/
14+
15+
async function getLocaleFromCookie(): Promise<"zh" | "en"> {
16+
const cookieStore = await cookies();
17+
return cookieStore.get("locale")?.value === "en" ? "en" : "zh";
18+
}
19+
20+
export default async function DocsRootPage() {
21+
const locale = await getLocaleFromCookie();
22+
const heading = locale === "en" ? "Knowledge Base" : "文档总览";
23+
const intro =
24+
locale === "en"
25+
? "Pick a section to dive in. Everything here is community-contributed and Git-based — edits flow through pull requests."
26+
: "从下面任意一个分区进入。所有内容都来自社区贡献,基于 Git 管理,修改走 Pull Request 流程。";
27+
28+
return (
29+
<DocsPage>
30+
<DocsBody>
31+
<h1 className="text-3xl font-extrabold tracking-tight md:text-4xl mb-4">
32+
{heading}
33+
</h1>
34+
<p className="text-base text-fd-muted-foreground mb-8">{intro}</p>
35+
<SectionIndex />
36+
</DocsBody>
37+
</DocsPage>
38+
);
39+
}
40+
41+
export async function generateMetadata(): Promise<Metadata> {
42+
const locale = await getLocaleFromCookie();
43+
return {
44+
title: locale === "en" ? "Docs" : "文档",
45+
description:
46+
locale === "en"
47+
? "Involution Hell community knowledge base — AI, CS, jobs, community shares."
48+
: "Involution Hell 社区知识库 — AI、计算机基础、求职、群友分享等分区总览。",
49+
};
50+
}

0 commit comments

Comments
 (0)