|
| 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 "{root}" 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 | +} |
0 commit comments