From fdaff814e4605c5f22c428e571e70c549ff87a4f Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Thu, 16 Apr 2026 19:17:57 +0000 Subject: [PATCH 1/3] =?UTF-8?q?refactor(docs):=20=E7=BB=9F=E4=B8=80=20/doc?= =?UTF-8?q?s=E3=80=81CommunityShare=E3=80=81Leetcode=20=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E4=B8=BA=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 #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/ 会 404 (Copilot CR 指出),和 PR #290 修 /docs 404 是同一个根因:Next [...slug] 不匹配空 slug,folder 没 index 就意味着 /docs/X 没 route。 ## - 走 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 / 视觉 ## 本地验证 - /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> --- app/components/docs/SectionIndex.tsx | 181 +++++++++++++++++++++ app/docs/CommunityShare/Leetcode/index.mdx | 20 +-- app/docs/CommunityShare/index.mdx | 27 +-- app/docs/page.tsx | 50 ++++++ 4 files changed, 236 insertions(+), 42 deletions(-) create mode 100644 app/components/docs/SectionIndex.tsx create mode 100644 app/docs/page.tsx diff --git a/app/components/docs/SectionIndex.tsx b/app/components/docs/SectionIndex.tsx new file mode 100644 index 00000000..b71fcac9 --- /dev/null +++ b/app/components/docs/SectionIndex.tsx @@ -0,0 +1,181 @@ +import { source } from "@/lib/source"; +import { Card, Cards } from "fumadocs-ui/components/card"; +import type { PageTree } from "fumadocs-core/server"; + +/** + * 通用分区索引(Server Component),替代原本散落的三份各自实现: + * - `/docs/page.tsx` 的 pageTree.children Cards(PR #290 的 draft) + * - `app/components/CommunityShareIndex.tsx` 的分组列表(PR #288 的 draft) + * - `app/docs/CommunityShare/Leetcode/index.mdx` 里的内联 `source.getPages().filter().map()` + * + * 合并的动机: + * 1. drift 维护:改一处行为(比如过滤翻译版、排序规则)要改 3 处,容易忘 + * 2. 其中一处还有 404 bug:`/docs/CommunityShare/<没 index 的目录>` 硬拼 URL 在 Next 路由里不存在 + * —— 和 PR #290 修 `/docs` 404 是同一个根因,即 Next `[...slug]` 不匹配空 slug,folder 没 index.mdx + * 就意味着 `/docs/X` 没有任何 route + * + * 设计思路: + * - 走 `source.pageTree`(而不是 `getPages()`):fumadocs 已经把"folder + 其可选 index"的关系 + * 建好了,我们不用自己从扁平 page 列表里反推 + * - `root` 参数接受形如 `"CommunityShare"` / `"CommunityShare/Leetcode"` 的目录相对路径。 + * undefined 表示从 pageTree 根开始(用于 `/docs` landing) + * - 渲染策略:统一用 fumadocs `` / ``,三处视觉语言一致 + * - URL 永不硬拼:folder 有 index → 走 index.url;没 index → 递归找子树第一个 page 的 url + * 作为 fallback(保证不点空) + * - 翻译版(`lang === "en"` 或文件名 `.en.mdx`)不出现在列表。语言切换仍由 `[...slug]/page.tsx` + * 的 cookie fallback 处理,这里不重复 + */ + +type PageNode = Extract; +type FolderNode = Extract; + +interface SectionIndexProps { + /** 相对 `/docs` 的目录路径,如 "CommunityShare";不传则从顶层开始 */ + root?: string; +} + +interface CardEntry { + title: string; + href: string; + description?: string; +} + +/** 从 pageTree 根出发,按 "a/b/c" 逐段下钻找到目标 folder 节点 */ +function findFolderByPath( + tree: PageTree.Root, + root: string | undefined, +): PageTree.Root | FolderNode | null { + if (!root) return tree; + const segments = root.split("/").filter(Boolean); + let current: PageTree.Root | FolderNode = tree; + for (const seg of segments) { + const children: PageTree.Node[] = current.children; + const next: FolderNode | undefined = children.find( + (c): c is FolderNode => + c.type === "folder" && folderSegmentName(c) === seg, + ); + if (!next) return null; + current = next; + } + return current; +} + +/** + * fumadocs 的 FolderNode.name 是 ReactNode(可能是字符串,也可能是 JSX), + * 单靠 name 匹配不稳定。这里优先用 index 页的 slug 倒数第二段反推目录名, + * 没 index 时退回 name.toString()。 + */ +function folderSegmentName(folder: FolderNode): string { + // folder.index.url 长这样:"/docs/CommunityShare/Geek" → 末段 "Geek" 即目录名 + if (folder.index) { + const parts = folder.index.url.split("/").filter(Boolean); + return parts[parts.length - 1] ?? ""; + } + // 没 index:从 name 兜底(通常是 string) + return typeof folder.name === "string" ? folder.name : String(folder.name); +} + +/** 判定页面是英文翻译版(不应出现在索引里) */ +function isEnglishVariant(page: PageNode): boolean { + // PageTree 节点 name 可能是 string | ReactNode;英文变体的 frontmatter.lang === "en" + // 但 pageTree 级别看不到 frontmatter,只能靠 URL 末段后缀兜底 + const urlSlug = page.url.split("/").pop() ?? ""; + return urlSlug.endsWith(".en"); +} + +/** 深度优先找出子树第一个 page 的 url(folder 没 index 时用来兜底,保证不点空) */ +function findFirstPageUrl(nodes: PageTree.Node[]): string | null { + for (const node of nodes) { + if (node.type === "separator") continue; + if (node.type === "page") { + if (isEnglishVariant(node as PageNode)) continue; + return (node as PageNode).url; + } + if (node.type === "folder") { + const folder = node as FolderNode; + if (folder.index && !isEnglishVariant(folder.index)) { + return folder.index.url; + } + const nested = findFirstPageUrl(folder.children); + if (nested) return nested; + } + } + return null; +} + +function nodeToCard(node: PageTree.Node): CardEntry | null { + if (node.type === "separator") return null; + if (node.type === "page") { + const page = node as PageNode; + if (isEnglishVariant(page)) return null; + return { + title: asPlainText(page.name), + href: page.url, + description: page.description ? asPlainText(page.description) : undefined, + }; + } + // folder + const folder = node as FolderNode; + const idxUrl = folder.index?.url; + const fallbackUrl = idxUrl ?? findFirstPageUrl(folder.children); + if (!fallbackUrl) return null; // 整个子树都没可链接的 page,跳过(不生成死链) + return { + title: folder.index + ? asPlainText(folder.index.name) + : asPlainText(folder.name), + href: fallbackUrl, + description: folder.index?.description + ? asPlainText(folder.index.description) + : undefined, + }; +} + +function asPlainText(value: unknown): string { + if (typeof value === "string") return value; + if (value == null) return ""; + return String(value); +} + +export function SectionIndex({ root }: SectionIndexProps) { + const node = findFolderByPath(source.pageTree, root); + if (!node) { + // 路径写错了(比如打错目录名),给个明显的渲染提示而不是静默空页 + return ( +

+ SectionIndex: root path "{root}" not found in pageTree +

+ ); + } + + // Root node 和 FolderNode 都有 children;Root 没 index 概念(自身就是 /docs) + const children = "children" in node ? node.children : []; + + // 过滤:排除根自己的 index(避免"点进自己") + const rootIndexUrl = "index" in node ? node.index?.url : undefined; + const cards = children + .map(nodeToCard) + .filter((c): c is CardEntry => c !== null && c.href !== rootIndexUrl) + // 按 title 中文排序,给读者稳定的浏览顺序 + .sort((a, b) => a.title.localeCompare(b.title, "zh-Hans-CN")); + + if (cards.length === 0) { + return ( +

+ 暂无内容,期待你的投稿! +

+ ); + } + + return ( + + {cards.map((c) => ( + + ))} + + ); +} diff --git a/app/docs/CommunityShare/Leetcode/index.mdx b/app/docs/CommunityShare/Leetcode/index.mdx index 0cb0d13e..ce3a2590 100644 --- a/app/docs/CommunityShare/Leetcode/index.mdx +++ b/app/docs/CommunityShare/Leetcode/index.mdx @@ -4,26 +4,10 @@ description: 这里收集了社区分享的所有 Leetcode 刷题笔记和题解 docId: aslw60tfyzxqga598pt4ociu --- -import { source } from "@/lib/source"; +import { SectionIndex } from "@/app/components/docs/SectionIndex"; # Leetcode 题解 欢迎查阅 Leetcode 相关的分享内容。 - - {source - .getPages() - .filter( - (page) => - page.file.dirname === "CommunityShare/Leetcode" && - page.file.name !== "index", - ) - .map((page) => ( - - ))} - + diff --git a/app/docs/CommunityShare/index.mdx b/app/docs/CommunityShare/index.mdx index 19ade43f..cbcd868c 100644 --- a/app/docs/CommunityShare/index.mdx +++ b/app/docs/CommunityShare/index.mdx @@ -4,33 +4,12 @@ date: "2025-09-18" docId: sfzt30mtx0jsuv6esnpm3w8y --- +import { SectionIndex } from "@/app/components/docs/SectionIndex"; + 欢迎来到群友分享板块!无论你是技术极客,还是热爱生活,都欢迎积极投稿! 一篇微不足道的文章或许可以帮助一个迷茫的陌生人~ > 转载文章请先联系原作者获取授权,谢谢! -## 技术分享 - -- [常用Markdown语法](/docs/CommunityShare/Geek/CommonUsedMarkdown) - -- [Git入门操作指南-程序员必会的git小技巧](/docs/CommunityShare/Geek/git101) - -- [用闲置树莓派搭建一个Minecraft服务器](/docs/CommunityShare/Geek/raspberry-guide) - -- [常用Katex语法](/docs/CommunityShare/Geek/Katex/index) - -## 心理健康 - -- [程序员 Burnout 自救指南](/docs/CommunityShare/MentalHealth/burnout-guide) - 识别和应对职业倦怠 - -## RAG - -- [RAG toy demo](/docs/CommunityShare/RAG/rag) - -## 身体健康 - -- 久坐办公的解决方案 -- 程序员健身指南 -- 饮食与营养建议 -- 睡眠质量改善 + diff --git a/app/docs/page.tsx b/app/docs/page.tsx new file mode 100644 index 00000000..794d0729 --- /dev/null +++ b/app/docs/page.tsx @@ -0,0 +1,50 @@ +import { DocsPage, DocsBody } from "fumadocs-ui/page"; +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { SectionIndex } from "@/app/components/docs/SectionIndex"; + +/** + * /docs 根路由的 landing。Header 导航的 "文档 / Docs" 直接指向 /docs,但原本 + * app/docs/ 下只有 layout.tsx + [...slug]/page.tsx(catch-all 不匹配空 slug), + * 所以 /docs 本身 404。这个文件提供兜底 landing,复用已挂好的 DocsLayout。 + * + * 内容交给 ``(root 不传 → 渲染 pageTree 顶层分区)。所有渲染 + * 逻辑和 CommunityShare / Leetcode 两处共用同一个组件,避免 drift。 + */ + +async function getLocaleFromCookie(): Promise<"zh" | "en"> { + const cookieStore = await cookies(); + return cookieStore.get("locale")?.value === "en" ? "en" : "zh"; +} + +export default async function DocsRootPage() { + const locale = await getLocaleFromCookie(); + const heading = locale === "en" ? "Knowledge Base" : "文档总览"; + const intro = + locale === "en" + ? "Pick a section to dive in. Everything here is community-contributed and Git-based — edits flow through pull requests." + : "从下面任意一个分区进入。所有内容都来自社区贡献,基于 Git 管理,修改走 Pull Request 流程。"; + + return ( + + +

+ {heading} +

+

{intro}

+ +
+
+ ); +} + +export async function generateMetadata(): Promise { + const locale = await getLocaleFromCookie(); + return { + title: locale === "en" ? "Docs" : "文档", + description: + locale === "en" + ? "Involution Hell community knowledge base — AI, CS, jobs, community shares." + : "Involution Hell 社区知识库 — AI、计算机基础、求职、群友分享等分区总览。", + }; +} From f12b2cc6869ccf51c1583d3b88cf33c924895c42 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Thu, 16 Apr 2026 19:28:53 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs(SectionIndex):=20=E5=8A=A0=E5=A4=A7?= =?UTF-8?q?=E9=87=8F=E4=B8=AD=E6=96=87=E6=B3=A8=E9=87=8A=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=20pageTree=20=E5=BF=83=E6=99=BA=E6=A8=A1=E5=9E=8B=20+=20?= =?UTF-8?q?=E6=AF=8F=E6=AD=A5=E5=81=9A=E4=BB=80=E4=B9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/docs/SectionIndex.tsx | 158 ++++++++++++++++++++------- 1 file changed, 120 insertions(+), 38 deletions(-) diff --git a/app/components/docs/SectionIndex.tsx b/app/components/docs/SectionIndex.tsx index b71fcac9..d4780152 100644 --- a/app/components/docs/SectionIndex.tsx +++ b/app/components/docs/SectionIndex.tsx @@ -3,44 +3,85 @@ import { Card, Cards } from "fumadocs-ui/components/card"; import type { PageTree } from "fumadocs-core/server"; /** - * 通用分区索引(Server Component),替代原本散落的三份各自实现: - * - `/docs/page.tsx` 的 pageTree.children Cards(PR #290 的 draft) - * - `app/components/CommunityShareIndex.tsx` 的分组列表(PR #288 的 draft) - * - `app/docs/CommunityShare/Leetcode/index.mdx` 里的内联 `source.getPages().filter().map()` - * - * 合并的动机: - * 1. drift 维护:改一处行为(比如过滤翻译版、排序规则)要改 3 处,容易忘 - * 2. 其中一处还有 404 bug:`/docs/CommunityShare/<没 index 的目录>` 硬拼 URL 在 Next 路由里不存在 - * —— 和 PR #290 修 `/docs` 404 是同一个根因,即 Next `[...slug]` 不匹配空 slug,folder 没 index.mdx - * 就意味着 `/docs/X` 没有任何 route - * - * 设计思路: - * - 走 `source.pageTree`(而不是 `getPages()`):fumadocs 已经把"folder + 其可选 index"的关系 - * 建好了,我们不用自己从扁平 page 列表里反推 - * - `root` 参数接受形如 `"CommunityShare"` / `"CommunityShare/Leetcode"` 的目录相对路径。 - * undefined 表示从 pageTree 根开始(用于 `/docs` landing) - * - 渲染策略:统一用 fumadocs `` / ``,三处视觉语言一致 - * - URL 永不硬拼:folder 有 index → 走 index.url;没 index → 递归找子树第一个 page 的 url - * 作为 fallback(保证不点空) - * - 翻译版(`lang === "en"` 或文件名 `.en.mdx`)不出现在列表。语言切换仍由 `[...slug]/page.tsx` - * 的 cookie fallback 处理,这里不重复 + * ============================================================================ + * — 文档分区的"子节点卡片索引" + * ============================================================================ + * + * 这个组件做一件事:**给定一个文档目录,把它的直接子节点(子文件夹 + 文件)渲染成 Cards**。 + * + * 三处使用场景: + * 1. /docs landing → 列出顶层分区(ai / cs / 群友分享 ...) + * 2. CommunityShare 首页 → 列出 Geek / Leetcode / RAG 等子分类 + * 3. Leetcode 首页 → 列出 49 篇题解 + * + * ---------------------------------------------------------------------------- + * 为什么不直接用 fumadocs 自带的? + * fumadocs 确实有 getPageTreePeers() 和 (deprecated 但能用), + * 但它们**只返回 type="page" 的兄弟节点,文件夹直接过滤掉**。 + * → 场景 1 和 2 的子节点大多是文件夹,内置 API 在这俩场景下返回空。 + * → 场景 3(Leetcode 下面全是 page)倒是可以直接用 。 + * 为了三处共用一个视觉,这里自己走一遍 pageTree。 + * + * ---------------------------------------------------------------------------- + * source.pageTree 长什么样(心智模型) + * + * Root { + * children: [ + * Folder { + * name: "AI 知识库", + * index: Page { url: "/docs/ai", name: "AI 知识库" }, // 有 index.mdx + * children: [ Page, Folder, ... ] + * }, + * Folder { + * name: "All projects", + * index: undefined, // 没 index.mdx + * children: [ ... ] + * }, + * ... + * ] + * } + * + * 关键:Folder 可能**没有** index(对应目录下没 index.mdx),这种情况下: + * - fumadocs 不会给它生成 /docs/ 路由 → 硬拼这个 URL 会 404 + * - 所以要 fallback 到子树第一个 page 的 url(见 findFirstPageUrl) + * + * ---------------------------------------------------------------------------- + * 几条不改的约束: + * - URL 永不硬拼:只用 tree 节点自带的 .url,规避 "/docs/<没 index 的目录>" 死链 + * - 英文翻译版(URL 末段 .en)过滤掉,由 [...slug] 的 cookie locale fallback 负责切语言 + * - 渲染用 fumadocs /,三处保持视觉一致 + * ============================================================================ */ +// fumadocs PageTree 节点是 discriminated union,先抽出两个具体类型方便写类型注解 type PageNode = Extract; type FolderNode = Extract; interface SectionIndexProps { - /** 相对 `/docs` 的目录路径,如 "CommunityShare";不传则从顶层开始 */ + /** + * 从 pageTree 根往下走的目录路径,段之间用 "/",例如 "CommunityShare/Leetcode"。 + * 不传 = 直接用 pageTree 根节点本身(用于 /docs landing)。 + */ root?: string; } +// 一张 Card 需要的最小数据。渲染前把各种节点(page / folder)归一成这个结构 interface CardEntry { title: string; href: string; description?: string; } -/** 从 pageTree 根出发,按 "a/b/c" 逐段下钻找到目标 folder 节点 */ +/** + * 从 pageTree 根一路"钻"到 root 指定的目录节点。 + * + * 举例:root = "CommunityShare/Leetcode" + * ① 根的 children 里找 segmentName === "CommunityShare" 的 folder + * ② 再在这个 folder 的 children 里找 segmentName === "Leetcode" 的 folder + * ③ 返回这个 folder 节点 + * + * 任一段找不到就返回 null(组件会渲染一个明显的错误提示,而不是静默空页)。 + */ function findFolderByPath( tree: PageTree.Root, root: string | undefined, @@ -61,29 +102,50 @@ function findFolderByPath( } /** - * fumadocs 的 FolderNode.name 是 ReactNode(可能是字符串,也可能是 JSX), - * 单靠 name 匹配不稳定。这里优先用 index 页的 slug 倒数第二段反推目录名, - * 没 index 时退回 name.toString()。 + * 取 folder 对应的"目录名"(用来跟 root 参数里的段做匹配)。 + * + * 为什么不直接用 `folder.name`? + * fumadocs 的 FolderNode.name 是 **ReactNode**(string | 复杂 JSX 都可能), + * 直接字符串比较会在极端情况踩坑。更可靠的办法是从 folder.index.url 反推—— + * 比如 "/docs/CommunityShare/Geek" 末段 "Geek" 就是目录名。 + * + * 没 index 时只能退回 name.toString()。目前仓库里这种情况目录名都是纯字符串, + * 所以兜底够用。 */ function folderSegmentName(folder: FolderNode): string { - // folder.index.url 长这样:"/docs/CommunityShare/Geek" → 末段 "Geek" 即目录名 if (folder.index) { const parts = folder.index.url.split("/").filter(Boolean); return parts[parts.length - 1] ?? ""; } - // 没 index:从 name 兜底(通常是 string) return typeof folder.name === "string" ? folder.name : String(folder.name); } -/** 判定页面是英文翻译版(不应出现在索引里) */ +/** + * 这个 page 是不是英文翻译版?是的话不进索引列表。 + * + * 站点里有两种翻译版文件: + * - 早期:`xxx.en.mdx`(靠 frontmatter.lang === "en" 标记) + * - 新: `xxx.en.md` 带 translatedFrom frontmatter + * PageTree 节点层面看不到 frontmatter,只能靠 URL 末段后缀 `.en` 兜底识别。 + * 不影响展示正确性——翻译切换由 [...slug]/page.tsx 的 cookie locale fallback 做。 + */ function isEnglishVariant(page: PageNode): boolean { - // PageTree 节点 name 可能是 string | ReactNode;英文变体的 frontmatter.lang === "en" - // 但 pageTree 级别看不到 frontmatter,只能靠 URL 末段后缀兜底 const urlSlug = page.url.split("/").pop() ?? ""; return urlSlug.endsWith(".en"); } -/** 深度优先找出子树第一个 page 的 url(folder 没 index 时用来兜底,保证不点空) */ +/** + * 深度优先找子树里第一个可链接的 page url。 + * + * 用途:folder 没有自己的 index.mdx 时,不能硬拼 /docs/ 做卡片链接 + * (Next 路由里没这条,会 404)。所以往里走一层,找到第一个 page 文件的 url + * 拿来做兜底链接。比如: + * + * CommunityShare/Language/ ← 没 index.mdx + * pte-intro.mdx ← 用这篇的 url 做兜底 + * + * 点击卡片会进到 /docs/CommunityShare/Language/pte-intro,不会 404。 + */ function findFirstPageUrl(nodes: PageTree.Node[]): string | null { for (const node of nodes) { if (node.type === "separator") continue; @@ -103,8 +165,20 @@ function findFirstPageUrl(nodes: PageTree.Node[]): string | null { return null; } +/** + * 把一个 pageTree 节点归一成 Card 数据。 + * + * - separator 节点(sidebar 分隔条)→ 跳过 + * - page 节点 → 直接用 name + url + description + * - folder 节点 → + * · 有 index:用 index 的 name / url / description(最直观的形态) + * · 没 index:用 folder.name 做标题,href 兜底到 findFirstPageUrl + * · 整个子树连一个可链接的 page 都没有:返回 null 跳过(不生成死链) + * - 英文翻译版 → 返回 null 跳过 + */ function nodeToCard(node: PageTree.Node): CardEntry | null { if (node.type === "separator") return null; + if (node.type === "page") { const page = node as PageNode; if (isEnglishVariant(page)) return null; @@ -114,11 +188,11 @@ function nodeToCard(node: PageTree.Node): CardEntry | null { description: page.description ? asPlainText(page.description) : undefined, }; } - // folder + const folder = node as FolderNode; const idxUrl = folder.index?.url; const fallbackUrl = idxUrl ?? findFirstPageUrl(folder.children); - if (!fallbackUrl) return null; // 整个子树都没可链接的 page,跳过(不生成死链) + if (!fallbackUrl) return null; return { title: folder.index ? asPlainText(folder.index.name) @@ -130,6 +204,10 @@ function nodeToCard(node: PageTree.Node): CardEntry | null { }; } +/** + * PageTree 里 name/description 类型是 ReactNode,这里强行要一个 string 做卡片标题。 + * 实际上仓库里所有 frontmatter 都是 string,不会走到 String(value) 的分支。 + */ function asPlainText(value: unknown): string { if (typeof value === "string") return value; if (value == null) return ""; @@ -137,9 +215,9 @@ function asPlainText(value: unknown): string { } export function SectionIndex({ root }: SectionIndexProps) { + // 第 1 步:定位目标节点(pageTree 根 or 某个 folder) const node = findFolderByPath(source.pageTree, root); if (!node) { - // 路径写错了(比如打错目录名),给个明显的渲染提示而不是静默空页 return (

SectionIndex: root path "{root}" not found in pageTree @@ -147,15 +225,18 @@ export function SectionIndex({ root }: SectionIndexProps) { ); } - // Root node 和 FolderNode 都有 children;Root 没 index 概念(自身就是 /docs) + // 第 2 步:拿它的直接子节点。PageTree.Root 和 FolderNode 都有 children 字段, + // 但类型定义上 Root 没有 index 字段,所以下面要区分一下。 const children = "children" in node ? node.children : []; - // 过滤:排除根自己的 index(避免"点进自己") + // 第 3 步:过滤 + 转成 Card 数据。 + // - 排除根自己的 index URL(folder 的 index 会和 folder 本身同 url, + // 不过滤的话"点进自己"会导致"Geek → Geek"这种死循环展示) + // - 按 title 中文排序,保证每次渲染顺序稳定(不然 file system order 会跟 OS 走) const rootIndexUrl = "index" in node ? node.index?.url : undefined; const cards = children .map(nodeToCard) .filter((c): c is CardEntry => c !== null && c.href !== rootIndexUrl) - // 按 title 中文排序,给读者稳定的浏览顺序 .sort((a, b) => a.title.localeCompare(b.title, "zh-Hans-CN")); if (cards.length === 0) { @@ -166,6 +247,7 @@ export function SectionIndex({ root }: SectionIndexProps) { ); } + // 第 4 步:fumadocs 的 Cards / Card 组件负责视觉 return ( {cards.map((c) => ( From 6079fa7c1da61a42d527e588678e62d5fc46bdf0 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Thu, 16 Apr 2026 19:41:45 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(SectionIndex):=20=E6=8C=89=20CR=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20locale=20=E5=8F=98=E4=BD=93=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=20+=20=E5=8E=BB=E6=8E=89=E6=B3=A8=E9=87=8A=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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**、反引号等),代码注释又不会被渲染。 --- app/components/docs/SectionIndex.tsx | 191 ++++++++++++++++----------- 1 file changed, 111 insertions(+), 80 deletions(-) diff --git a/app/components/docs/SectionIndex.tsx b/app/components/docs/SectionIndex.tsx index d4780152..b6750ce7 100644 --- a/app/components/docs/SectionIndex.tsx +++ b/app/components/docs/SectionIndex.tsx @@ -3,54 +3,48 @@ import { Card, Cards } from "fumadocs-ui/components/card"; import type { PageTree } from "fumadocs-core/server"; /** - * ============================================================================ - * — 文档分区的"子节点卡片索引" - * ============================================================================ + * SectionIndex — 文档分区的子节点卡片索引。 * - * 这个组件做一件事:**给定一个文档目录,把它的直接子节点(子文件夹 + 文件)渲染成 Cards**。 + * 这个组件做一件事:给定一个文档目录,把它的直接子节点(子文件夹 + 文件)渲染成 Cards。 * * 三处使用场景: - * 1. /docs landing → 列出顶层分区(ai / cs / 群友分享 ...) - * 2. CommunityShare 首页 → 列出 Geek / Leetcode / RAG 等子分类 - * 3. Leetcode 首页 → 列出 49 篇题解 + * 1. /docs landing SectionIndex 不传参 列出顶层分区(ai / cs / 群友分享 等) + * 2. CommunityShare 首页 SectionIndex root=CommunityShare 列出 Geek / Leetcode / RAG 等子分类 + * 3. Leetcode 首页 SectionIndex root=CommunityShare/Leetcode 列出全部 Leetcode 题解 * * ---------------------------------------------------------------------------- * 为什么不直接用 fumadocs 自带的? - * fumadocs 确实有 getPageTreePeers() 和 (deprecated 但能用), - * 但它们**只返回 type="page" 的兄弟节点,文件夹直接过滤掉**。 - * → 场景 1 和 2 的子节点大多是文件夹,内置 API 在这俩场景下返回空。 - * → 场景 3(Leetcode 下面全是 page)倒是可以直接用 。 - * 为了三处共用一个视觉,这里自己走一遍 pageTree。 + * fumadocs 有 getPageTreePeers() 和 DocsCategory(deprecated 但能用),但它们只返回 + * type=page 的兄弟节点,文件夹直接过滤掉。 + * - 场景 1 和 2 的子节点大多是文件夹,内置 API 返回空。 + * - 场景 3(Leetcode 下面全是 page)倒是可以直接用 DocsCategory。 + * 为了三处共用一个视觉,这里自己走一遍 pageTree。 * * ---------------------------------------------------------------------------- - * source.pageTree 长什么样(心智模型) + * source.pageTree 的结构(心智模型) * - * Root { - * children: [ - * Folder { - * name: "AI 知识库", - * index: Page { url: "/docs/ai", name: "AI 知识库" }, // 有 index.mdx - * children: [ Page, Folder, ... ] - * }, - * Folder { - * name: "All projects", - * index: undefined, // 没 index.mdx - * children: [ ... ] - * }, + * Root + * children: + * Folder + * name = AI 知识库 + * index = Page(url=/docs/ai, name=AI 知识库) // 有 index.mdx + * children: [Page, Folder, ...] + * Folder + * name = All projects + * index = undefined // 没 index.mdx + * children: [...] * ... - * ] - * } * - * 关键:Folder 可能**没有** index(对应目录下没 index.mdx),这种情况下: - * - fumadocs 不会给它生成 /docs/ 路由 → 硬拼这个 URL 会 404 + * 关键:Folder 可能没有 index(目录下没 index.mdx),这种情况下: + * - fumadocs 不会给它生成 /docs/ 路由,硬拼这个 URL 会 404 * - 所以要 fallback 到子树第一个 page 的 url(见 findFirstPageUrl) * * ---------------------------------------------------------------------------- * 几条不改的约束: - * - URL 永不硬拼:只用 tree 节点自带的 .url,规避 "/docs/<没 index 的目录>" 死链 - * - 英文翻译版(URL 末段 .en)过滤掉,由 [...slug] 的 cookie locale fallback 负责切语言 - * - 渲染用 fumadocs /,三处保持视觉一致 - * ============================================================================ + * - URL 永不硬拼:只用 tree 节点自带的 .url,规避 /docs/<没 index 的目录> 死链 + * - locale 翻译版(末段 .en 或 .zh 且存在对应 canonical)过滤掉;孤儿(只有翻译版 + * 没 canonical)保留,否则 35 篇只有 .en.md 的英文题解会从索引消失 + * - 渲染用 fumadocs Cards / Card,三处保持视觉一致 */ // fumadocs PageTree 节点是 discriminated union,先抽出两个具体类型方便写类型注解 @@ -59,7 +53,7 @@ type FolderNode = Extract; interface SectionIndexProps { /** - * 从 pageTree 根往下走的目录路径,段之间用 "/",例如 "CommunityShare/Leetcode"。 + * 从 pageTree 根往下走的目录路径,段之间用 / 分隔,例如 CommunityShare/Leetcode。 * 不传 = 直接用 pageTree 根节点本身(用于 /docs landing)。 */ root?: string; @@ -73,12 +67,12 @@ interface CardEntry { } /** - * 从 pageTree 根一路"钻"到 root 指定的目录节点。 + * 从 pageTree 根一路钻到 root 指定的目录节点。 * - * 举例:root = "CommunityShare/Leetcode" - * ① 根的 children 里找 segmentName === "CommunityShare" 的 folder - * ② 再在这个 folder 的 children 里找 segmentName === "Leetcode" 的 folder - * ③ 返回这个 folder 节点 + * 举例:root = CommunityShare/Leetcode + * 1) 根的 children 里找 segmentName = CommunityShare 的 folder + * 2) 再在这个 folder 的 children 里找 segmentName = Leetcode 的 folder + * 3) 返回这个 folder 节点 * * 任一段找不到就返回 null(组件会渲染一个明显的错误提示,而不是静默空页)。 */ @@ -102,14 +96,14 @@ function findFolderByPath( } /** - * 取 folder 对应的"目录名"(用来跟 root 参数里的段做匹配)。 + * 取 folder 对应的目录名(用来跟 root 参数里的段做匹配)。 * - * 为什么不直接用 `folder.name`? - * fumadocs 的 FolderNode.name 是 **ReactNode**(string | 复杂 JSX 都可能), + * 为什么不直接用 folder.name: + * fumadocs 的 FolderNode.name 是 ReactNode 类型(可能是 string,也可能是 JSX), * 直接字符串比较会在极端情况踩坑。更可靠的办法是从 folder.index.url 反推—— - * 比如 "/docs/CommunityShare/Geek" 末段 "Geek" 就是目录名。 + * 比如 /docs/CommunityShare/Geek 最后一段 Geek 就是目录名。 * - * 没 index 时只能退回 name.toString()。目前仓库里这种情况目录名都是纯字符串, + * 没 index 时退回 name.toString()。目前仓库里这种情况目录名都是纯字符串, * 所以兜底够用。 */ function folderSegmentName(folder: FolderNode): string { @@ -121,44 +115,70 @@ function folderSegmentName(folder: FolderNode): string { } /** - * 这个 page 是不是英文翻译版?是的话不进索引列表。 + * 这个 URL 是不是可以隐藏的翻译版? * - * 站点里有两种翻译版文件: - * - 早期:`xxx.en.mdx`(靠 frontmatter.lang === "en" 标记) - * - 新: `xxx.en.md` 带 translatedFrom frontmatter - * PageTree 节点层面看不到 frontmatter,只能靠 URL 末段后缀 `.en` 兜底识别。 - * 不影响展示正确性——翻译切换由 [...slug]/page.tsx 的 cookie locale fallback 做。 + * 站点里同一篇文档最多有三种文件形态: + * - 无后缀的 canonical:xxx.mdx 或 xxx.md 原文,作者写什么语言就是什么语言 + * - .en.md / .en.mdx 英文翻译或英文原文 + * - .zh.md / .zh.mdx 中文翻译(原文是英文时才出现) + * + * 策略:只有当 .en / .zh 后缀的 URL 同时存在对应的 canonical(无后缀)版本时,才把它 + * 当翻译版隐藏;否则它就是这篇文档的唯一形态,必须保留——否则 35 篇只有 .en.md 的英文 + * 题解 + 7 篇只有 .zh.md 的中文翻译会从索引里一起消失。 + * + * canonicals 传入预构建的"所有非 locale 后缀 URL"集合,避免每次判断都全表扫 getPages()。 */ -function isEnglishVariant(page: PageNode): boolean { - const urlSlug = page.url.split("/").pop() ?? ""; - return urlSlug.endsWith(".en"); +function isHideableLocaleVariant( + url: string, + canonicals: Set, +): boolean { + const m = url.match(/^(.+)\.(en|zh)$/); + if (!m) return false; + return canonicals.has(m[1]); +} + +/** 预构建 canonical URL 集合:所有 URL 末段不带 .en / .zh 的 page。单次 render 只算一次。 */ +function buildCanonicalUrlSet(): Set { + const set = new Set(); + for (const page of source.getPages()) { + if (!/\.(?:en|zh)$/.test(page.url)) { + set.add(page.url); + } + } + return set; } /** * 深度优先找子树里第一个可链接的 page url。 * - * 用途:folder 没有自己的 index.mdx 时,不能硬拼 /docs/ 做卡片链接 - * (Next 路由里没这条,会 404)。所以往里走一层,找到第一个 page 文件的 url - * 拿来做兜底链接。比如: + * 用途:folder 没有自己的 index.mdx 时,不能硬拼 /docs/ 做卡片链接(Next 路由 + * 里没这条,会 404)。所以往里走一层,找到第一个 page 文件的 url 拿来做兜底链接。比如: * - * CommunityShare/Language/ ← 没 index.mdx - * pte-intro.mdx ← 用这篇的 url 做兜底 + * CommunityShare/Language/ 没 index.mdx + * pte-intro.mdx 用这篇的 url 做兜底 * * 点击卡片会进到 /docs/CommunityShare/Language/pte-intro,不会 404。 */ -function findFirstPageUrl(nodes: PageTree.Node[]): string | null { +function findFirstPageUrl( + nodes: PageTree.Node[], + canonicals: Set, +): string | null { for (const node of nodes) { if (node.type === "separator") continue; if (node.type === "page") { - if (isEnglishVariant(node as PageNode)) continue; - return (node as PageNode).url; + const page = node as PageNode; + if (isHideableLocaleVariant(page.url, canonicals)) continue; + return page.url; } if (node.type === "folder") { const folder = node as FolderNode; - if (folder.index && !isEnglishVariant(folder.index)) { + if ( + folder.index && + !isHideableLocaleVariant(folder.index.url, canonicals) + ) { return folder.index.url; } - const nested = findFirstPageUrl(folder.children); + const nested = findFirstPageUrl(folder.children, canonicals); if (nested) return nested; } } @@ -168,20 +188,23 @@ function findFirstPageUrl(nodes: PageTree.Node[]): string | null { /** * 把一个 pageTree 节点归一成 Card 数据。 * - * - separator 节点(sidebar 分隔条)→ 跳过 - * - page 节点 → 直接用 name + url + description - * - folder 节点 → - * · 有 index:用 index 的 name / url / description(最直观的形态) - * · 没 index:用 folder.name 做标题,href 兜底到 findFirstPageUrl - * · 整个子树连一个可链接的 page 都没有:返回 null 跳过(不生成死链) - * - 英文翻译版 → 返回 null 跳过 + * - separator 节点(sidebar 分隔条):跳过 + * - page 节点:直接用 name + url + description;是可隐藏的 locale 翻译版则跳过 + * - folder 节点: + * 有 index 且 index 不是翻译版 用 index 的 name / url / description + * 有 index 但 index 本身是翻译版 当作没 index 走 fallback(规避暴露翻译 URL) + * 没 index 用 folder.name 做标题,href 兜底到 findFirstPageUrl + * 整个子树都没可链接的 page 返回 null 跳过(不生成死链) */ -function nodeToCard(node: PageTree.Node): CardEntry | null { +function nodeToCard( + node: PageTree.Node, + canonicals: Set, +): CardEntry | null { if (node.type === "separator") return null; if (node.type === "page") { const page = node as PageNode; - if (isEnglishVariant(page)) return null; + if (isHideableLocaleVariant(page.url, canonicals)) return null; return { title: asPlainText(page.name), href: page.url, @@ -190,8 +213,13 @@ function nodeToCard(node: PageTree.Node): CardEntry | null { } const folder = node as FolderNode; - const idxUrl = folder.index?.url; - const fallbackUrl = idxUrl ?? findFirstPageUrl(folder.children); + // folder.index 如果本身是翻译版(index.en.mdx / index.zh.mdx),不能直接当卡片 href, + // 否则会把非 canonical URL 暴露出去。退回 findFirstPageUrl 兜底。 + const idxUrl = + folder.index && !isHideableLocaleVariant(folder.index.url, canonicals) + ? folder.index.url + : undefined; + const fallbackUrl = idxUrl ?? findFirstPageUrl(folder.children, canonicals); if (!fallbackUrl) return null; return { title: folder.index @@ -205,7 +233,7 @@ function nodeToCard(node: PageTree.Node): CardEntry | null { } /** - * PageTree 里 name/description 类型是 ReactNode,这里强行要一个 string 做卡片标题。 + * PageTree 里 name 和 description 类型是 ReactNode,这里强行要一个 string 做卡片标题。 * 实际上仓库里所有 frontmatter 都是 string,不会走到 String(value) 的分支。 */ function asPlainText(value: unknown): string { @@ -229,13 +257,16 @@ export function SectionIndex({ root }: SectionIndexProps) { // 但类型定义上 Root 没有 index 字段,所以下面要区分一下。 const children = "children" in node ? node.children : []; - // 第 3 步:过滤 + 转成 Card 数据。 - // - 排除根自己的 index URL(folder 的 index 会和 folder 本身同 url, - // 不过滤的话"点进自己"会导致"Geek → Geek"这种死循环展示) + // 第 3 步:预构建 canonical URL 集合,供 locale 翻译版判定复用 + const canonicals = buildCanonicalUrlSet(); + + // 第 4 步:过滤 + 转成 Card 数据。 + // - 排除根自己的 index URL(folder 的 index 会和 folder 本身同 url,不过滤的话 + // "点进自己"会导致 Geek -> Geek 这种死循环展示) // - 按 title 中文排序,保证每次渲染顺序稳定(不然 file system order 会跟 OS 走) const rootIndexUrl = "index" in node ? node.index?.url : undefined; const cards = children - .map(nodeToCard) + .map((n) => nodeToCard(n, canonicals)) .filter((c): c is CardEntry => c !== null && c.href !== rootIndexUrl) .sort((a, b) => a.title.localeCompare(b.title, "zh-Hans-CN")); @@ -247,7 +278,7 @@ export function SectionIndex({ root }: SectionIndexProps) { ); } - // 第 4 步:fumadocs 的 Cards / Card 组件负责视觉 + // 第 5 步:fumadocs 的 Cards / Card 组件负责视觉 return ( {cards.map((c) => (