Skip to content

Commit d08e1bf

Browse files
committed
fix(docs): /docs 根路由 404 — 加 landing page 列出所有分区
Header 导航栏 "文档 / Docs" 点进去是 /docs,但路由目录下只有 app/docs/[...slug]/page.tsx(catch-all 不匹配空 slug)和 layout.tsx, 没有东西匹配 /docs 本身,所以生产直接 404(x-matched-path: /_not-found)。 修法:新增 app/docs/page.tsx(server component),复用已有的 docs layout, 读 fumadocs source.pageTree.children 顶层分区渲染成 Cards 列表: - 不硬编码分区名,新增顶层目录自动出现在 landing - 卡片标题/描述读各分区 index.mdx frontmatter,没 index 的分区(jobs / all-projects)降级用目录名 + 兜底到子树第一个 page 的 url,保证不点空 - locale 从 cookie 读(zh/en),只切 H1/intro 两句话;卡片内容本身 已由 [...slug] 的 locale fallback 负责,这里不重复处理
1 parent 3845146 commit d08e1bf

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

app/docs/page.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { source } from "@/lib/source";
2+
import { DocsPage, DocsBody } from "fumadocs-ui/page";
3+
import { Card, Cards } from "fumadocs-ui/components/card";
4+
import type { PageTree } from "fumadocs-core/server";
5+
import type { Metadata } from "next";
6+
import { cookies } from "next/headers";
7+
8+
/**
9+
* /docs 根路由的 landing 页。
10+
*
11+
* 为什么需要这个文件:
12+
* - Header 导航栏 "文档 / Docs" 直接指向 /docs
13+
* - 但路由目录下只有 app/docs/[...slug]/page.tsx(catch-all,不匹配空 slug)和 layout.tsx,
14+
* 没有任何东西匹配 /docs 本身 → 用户点导航就 404
15+
* - 修法:加这个 server component,复用 app/docs/layout.tsx 里已经挂好的 DocsLayout
16+
* (侧边栏 + copy tracking + view tracking 都继承下来),自己只负责渲染中间内容区
17+
*
18+
* 内容策略:
19+
* - 不硬编码 ai / computer-science / ... 这些分区,直接读 source.pageTree 顶层 children
20+
* → 以后新增分区(比如又搞一个 "research-logs" 目录)landing 自动带上
21+
* - 卡片标题 / 描述用分区 index.mdx 的 frontmatter.title / description
22+
* 没 index 的分区降级用目录名 + 空描述(jobs / all-projects 目前就是这个情况)
23+
* - 仅从 cookie 读 locale 用于 H1/intro 的中英切换;卡片内容来自 frontmatter 本身,
24+
* 所以已经由 [...slug] 的 locale fallback 负责,这里不重复处理
25+
*/
26+
27+
type FolderNode = Extract<PageTree.Node, { type: "folder" }>;
28+
type PageNode = Extract<PageTree.Node, { type: "page" }>;
29+
30+
interface SectionCard {
31+
title: string;
32+
description?: string;
33+
href: string;
34+
}
35+
36+
async function getLocaleFromCookie(): Promise<"zh" | "en"> {
37+
const cookieStore = await cookies();
38+
const val = cookieStore.get("locale")?.value;
39+
return val === "en" ? "en" : "zh";
40+
}
41+
42+
/** 把 pageTree 顶层 node 映射成 landing 卡片;遇到 separator / 孤立 page 就跳过 */
43+
function toSectionCard(node: PageTree.Node): SectionCard | null {
44+
if (node.type === "separator") return null;
45+
if (node.type === "page") {
46+
// 顶层直接挂的文件(比如目录只有一个文件被 pruneEmptyFolders 提出来了)
47+
const pageNode = node as PageNode;
48+
return {
49+
title: asPlainText(pageNode.name),
50+
description: pageNode.description
51+
? asPlainText(pageNode.description)
52+
: undefined,
53+
href: pageNode.url,
54+
};
55+
}
56+
// folder 分支
57+
const folder = node as FolderNode;
58+
const indexUrl = folder.index?.url;
59+
// folder 没 index 时指向它第一个 page 后代,保证 landing 上点击不落空
60+
const fallbackUrl =
61+
indexUrl ?? findFirstPageUrl(folder.children) ?? undefined;
62+
if (!fallbackUrl) return null;
63+
return {
64+
title: asPlainText(folder.name),
65+
description: folder.index?.description
66+
? asPlainText(folder.index.description)
67+
: undefined,
68+
href: fallbackUrl,
69+
};
70+
}
71+
72+
/** 深度优先找出子树中第一个 page 的 url,folder 没 index 时用来兜底 */
73+
function findFirstPageUrl(children: PageTree.Node[]): string | null {
74+
for (const child of children) {
75+
if (child.type === "page") return (child as PageNode).url;
76+
if (child.type === "folder") {
77+
const folder = child as FolderNode;
78+
if (folder.index) return folder.index.url;
79+
const nested = findFirstPageUrl(folder.children);
80+
if (nested) return nested;
81+
}
82+
}
83+
return null;
84+
}
85+
86+
/** PageTree 里 name / description 可能是 string 或 ReactNode,这里只取纯文本兜底 */
87+
function asPlainText(value: unknown): string {
88+
if (typeof value === "string") return value;
89+
if (value == null) return "";
90+
// ReactNode 情况:回退成占位,实际项目里所有 frontmatter 都是 string
91+
return String(value);
92+
}
93+
94+
export default async function DocsRootPage() {
95+
const locale = await getLocaleFromCookie();
96+
const tree = source.pageTree;
97+
98+
const cards = tree.children
99+
.map(toSectionCard)
100+
.filter((c): c is SectionCard => c !== null);
101+
102+
// 文案双语:和其它翻译组件不同的是,这里内容极少,直接内联 literal 比接 next-intl 轻
103+
const heading = locale === "en" ? "Knowledge Base" : "文档总览";
104+
const intro =
105+
locale === "en"
106+
? "Pick a section to dive in. Everything here is community-contributed and Git-based — edits flow through pull requests."
107+
: "从下面任意一个分区进入。所有内容都来自社区贡献,基于 Git 管理,修改走 Pull Request 流程。";
108+
109+
return (
110+
<DocsPage>
111+
<DocsBody>
112+
<h1 className="text-3xl font-extrabold tracking-tight md:text-4xl mb-4">
113+
{heading}
114+
</h1>
115+
<p className="text-base text-fd-muted-foreground mb-8">{intro}</p>
116+
<Cards>
117+
{cards.map((c) => (
118+
<Card
119+
key={c.href}
120+
title={c.title}
121+
href={c.href}
122+
description={c.description}
123+
/>
124+
))}
125+
</Cards>
126+
</DocsBody>
127+
</DocsPage>
128+
);
129+
}
130+
131+
export async function generateMetadata(): Promise<Metadata> {
132+
const locale = await getLocaleFromCookie();
133+
return {
134+
title: locale === "en" ? "Docs" : "文档",
135+
description:
136+
locale === "en"
137+
? "Involution Hell community knowledge base — AI, CS, jobs, community shares."
138+
: "Involution Hell 社区知识库 — AI、计算机基础、求职、群友分享等分区总览。",
139+
};
140+
}

0 commit comments

Comments
 (0)