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