|
| 1 | +import { source } from "@/lib/source"; |
| 2 | +import Link from "next/link"; |
| 3 | + |
| 4 | +/** |
| 5 | + * CommunityShare 自动目录索引(Server Component,渲染在 /docs/CommunityShare/index.mdx 内)。 |
| 6 | + * |
| 7 | + * 为什么需要这个组件: |
| 8 | + * 原先 index.mdx 里的分类列表是人工维护的 Markdown,每次新增文章 / 新增分类都要顺手改索引, |
| 9 | + * 实际上经常漏(main 上 index.mdx 少列了 Amazing-AI-Tools / Language / Leetcode / Life / |
| 10 | + * Personal-Study-Notes 五个分类,还留着"身体健康"这种完全空的占位分类)。 |
| 11 | + * 改成 server 组件从 fumadocs `source.getPages()` 实时读目录树 → 文档增删后索引自动同步, |
| 12 | + * 不再需要 #110 那种靠脚本定期重新生成 MDX 的方案(也就不需要引入 glob / gray-matter 依赖)。 |
| 13 | + * |
| 14 | + * 设计: |
| 15 | + * - 顶级分类 = CommunityShare 下的直属一级目录 |
| 16 | + * - 每个分类的标题:优先读该分类 index.mdx 的 frontmatter.title;没 index 就用目录名兜底 |
| 17 | + * - 分类内的文章按 title 字母排序,排除分类自己的 index.mdx(避免"点击进入自己"的死循环) |
| 18 | + * - 翻译版(lang === "en" 或文件名以 .en 结尾)不出现在列表,统一走原文 URL,locale 由 cookie 切 |
| 19 | + * - 分类条目超过 INLINE_LIMIT (12) 时折叠显示:"共 N 篇 → 进入分类" 单行链接, |
| 20 | + * 避免 Leetcode 这种几十上百篇的分类把页面顶爆 |
| 21 | + * - 完全不指向 "/docs/CommunityShare/<dir>" 硬拼 URL,全部走 page.url(fumadocs 已做 slug 规范化 |
| 22 | + * 和拼音转换,硬拼会漏掉 lib/source.ts 里 Leetcode 目录的 pinyin slug transform) |
| 23 | + */ |
| 24 | + |
| 25 | +type PageLike = ReturnType<typeof source.getPages>[number]; |
| 26 | + |
| 27 | +const ROOT = "CommunityShare"; |
| 28 | +const INLINE_LIMIT = 12; |
| 29 | + |
| 30 | +/** 判定一个页面是不是英文翻译版(不应出现在索引里) */ |
| 31 | +function isEnglishVariant(page: PageLike): boolean { |
| 32 | + const data = page.data as { lang?: string }; |
| 33 | + if (data.lang === "en") return true; |
| 34 | + // 兜底:历史上有未加 lang frontmatter 的 .en.mdx 文件,靠文件名识别 |
| 35 | + return page.file.name.endsWith(".en"); |
| 36 | +} |
| 37 | + |
| 38 | +/** 取页面 file.path 相对 ROOT 的第一级目录名,如 "CommunityShare/Geek/foo" → "Geek" */ |
| 39 | +function firstSegmentUnderRoot(filePath: string): string | null { |
| 40 | + const prefix = `${ROOT}/`; |
| 41 | + if (!filePath.startsWith(prefix)) return null; |
| 42 | + const rest = filePath.slice(prefix.length); |
| 43 | + const slashIdx = rest.indexOf("/"); |
| 44 | + return slashIdx === -1 ? null : rest.slice(0, slashIdx); |
| 45 | +} |
| 46 | + |
| 47 | +export function CommunityShareIndex() { |
| 48 | + const all = source.getPages(); |
| 49 | + |
| 50 | + // 第一步:筛出 CommunityShare 下的全部非英文版页面 |
| 51 | + const pages = all.filter( |
| 52 | + (p) => p.file.path.startsWith(`${ROOT}/`) && !isEnglishVariant(p), |
| 53 | + ); |
| 54 | + |
| 55 | + // 第二步:按第一级子目录分组(根目录的 index.mdx 本身 category=null,跳过) |
| 56 | + const byCategory = new Map<string, PageLike[]>(); |
| 57 | + for (const page of pages) { |
| 58 | + const category = firstSegmentUnderRoot(page.file.path); |
| 59 | + if (!category) continue; |
| 60 | + const bucket = byCategory.get(category) ?? []; |
| 61 | + bucket.push(page); |
| 62 | + byCategory.set(category, bucket); |
| 63 | + } |
| 64 | + |
| 65 | + // 第三步:构造渲染所需的 view-model,并按分类名排序 |
| 66 | + const categories = [...byCategory.entries()] |
| 67 | + .map(([dirName, catPages]) => { |
| 68 | + // 分类自己的 index.mdx(若存在) |
| 69 | + const categoryIndex = catPages.find( |
| 70 | + (p) => |
| 71 | + p.file.dirname === `${ROOT}/${dirName}` && p.file.name === "index", |
| 72 | + ); |
| 73 | + const displayTitle = categoryIndex?.data.title ?? dirName; |
| 74 | + const categoryUrl = categoryIndex?.url ?? `/docs/${ROOT}/${dirName}`; |
| 75 | + |
| 76 | + // 内容条目 = 排除分类 index 本身 |
| 77 | + const entries = catPages |
| 78 | + .filter((p) => p !== categoryIndex) |
| 79 | + .sort((a, b) => a.data.title.localeCompare(b.data.title, "zh-Hans-CN")); |
| 80 | + |
| 81 | + return { |
| 82 | + dirName, |
| 83 | + displayTitle, |
| 84 | + categoryUrl, |
| 85 | + entries, |
| 86 | + }; |
| 87 | + }) |
| 88 | + .sort((a, b) => a.displayTitle.localeCompare(b.displayTitle, "zh-Hans-CN")); |
| 89 | + |
| 90 | + if (categories.length === 0) { |
| 91 | + // 兜底:理论上不会走到,但避免开发期目录清空时整个页面报错 |
| 92 | + return ( |
| 93 | + <p className="text-sm text-neutral-500">暂无分享内容,期待你的投稿!</p> |
| 94 | + ); |
| 95 | + } |
| 96 | + |
| 97 | + return ( |
| 98 | + <div className="flex flex-col gap-8"> |
| 99 | + {categories.map((cat) => ( |
| 100 | + <section key={cat.dirName}> |
| 101 | + <h2 className="text-xl font-bold mb-3"> |
| 102 | + <Link href={cat.categoryUrl} className="hover:underline"> |
| 103 | + {cat.displayTitle} |
| 104 | + </Link> |
| 105 | + <span className="ml-2 text-xs font-normal text-neutral-500"> |
| 106 | + ({cat.entries.length} 篇) |
| 107 | + </span> |
| 108 | + </h2> |
| 109 | + {cat.entries.length > INLINE_LIMIT ? ( |
| 110 | + // 超过阈值:折叠显示,避免 Leetcode 这种分类把页面顶爆 |
| 111 | + <p className="text-sm text-neutral-600 dark:text-neutral-400"> |
| 112 | + <Link |
| 113 | + href={cat.categoryUrl} |
| 114 | + className="text-[var(--color-fd-primary)] hover:underline" |
| 115 | + > |
| 116 | + 查看全部 {cat.entries.length} 篇 → |
| 117 | + </Link> |
| 118 | + </p> |
| 119 | + ) : ( |
| 120 | + <ul className="list-disc pl-6 space-y-1"> |
| 121 | + {cat.entries.map((p) => ( |
| 122 | + <li key={p.url}> |
| 123 | + <Link href={p.url} className="hover:underline"> |
| 124 | + {p.data.title} |
| 125 | + </Link> |
| 126 | + </li> |
| 127 | + ))} |
| 128 | + </ul> |
| 129 | + )} |
| 130 | + </section> |
| 131 | + ))} |
| 132 | + </div> |
| 133 | + ); |
| 134 | +} |
0 commit comments