第 03 / 11 章

文章索引:lib/posts.ts

散篇笔记如何扫盘、过滤、估读、抽目录、编刊号,以及列表页为何只拿元数据不拿正文。

⏱ 3 分钟826 字

这是散篇内容层的入口。首页、标签页、文章页几乎都通过它拿数据,而不是自己 fs.readFile。

系列小册走平行的 lib/series.ts(见本册其它章与源码),但字数估算与目录抽取与散篇共用同一套函数,保证行为一致。

类型:列表薄、详情厚§

export type TocItem = { id: string; text: string; depth: 2 | 3 | 4 };

export type PostMeta = {
  slug: string;       // 路径名:文件名去掉 .mdx
  title: string;
  description: string;
  date: string;
  tags: string[];
  minutes: number;    // 估读分钟
  words: number;      // 字词量(中文按字、英文按词)
  issue: string;      // 刊号字符串,如 "004"
};

export type Post = PostMeta & { content: string; toc: TocItem[] };

首页、标签页只需要 PostMeta;文章页才需要 content 和 toc。toMeta 会显式丢掉正文,避免把整篇 MDX 当 props 传进列表。标签聚合(含系列)见 lib/tags.ts。

阅读时长怎么估§

export function readingStats(source: string) {
  const cjk = source.match(/[\u3400-\u9fff\uf900-\ufaff]/g)?.length ?? 0;
  const latin = source.match(/[A-Za-z0-9]+/g)?.length ?? 0;
  const words = cjk + latin;
  const minutes = Math.max(1, Math.round(cjk / 400 + latin / 200));
  return { words, minutes };
}

中文按「字」、拉丁按「连续单词」计数;阅读速度按约 400 汉字/分、200 英文词/分估算,至少显示 1 分钟。这是启发式,不是精确计时。

文章目录为何单独抽一遍 AST§

侧栏目录需要 id,正文标题也需要同一个 id(由 rehype-slug 写入)。两边必须用同一套 slug 算法,否则点击目录会跳错。

extractToc 用 unified + remark-parse + remark-gfm 解析正文,再 visit 所有 2~4 级标题,用 GithubSlugger 生成 id。这与 GitHub / rehype-slug 的常见行为对齐。

注意:这里只为了抽目录,并不做完整 MDX 编译;真正的组件化渲染在 MdxContent。

getAllPosts:扫盘、过滤、排序、编号§

核心流程:

  1. 读 content/posts 下所有 .mdx
  2. gray-matter 拆开文首 YAML 与正文
  3. draft: true → 跳过;非草稿若缺 title / date / description → 构建失败(lib/content-validate.ts)
  4. 按 date 新文在前排序
  5. 给每篇赋 issue:最旧的一篇是 001,最新的是最大号——实现上是 posts.length - i 再 padStart(3, "0")
  6. 结果放进模块级 cache;仅 production 复用(lib/content-cache.ts)。开发态每次请求重新扫盘,改 MDX 立即生效

周边 API§

函数用途
getPostBySlug文章页取单篇
getPostSurroundings上一篇 / 下一篇。数组是新→旧,所以 next 是更靠前的新文,prev 是更旧的一篇
toMetaPost → PostMeta

getPostSurroundings 的命名容易和「时间上一篇」搞混:这里的「上一篇 / 下一篇」是按站点列表顺序(新文在前)做的阅读导航,不是按写作日历的「更早一天」。

作者侧约定(content/posts/)§

文首必须类似:

title: "标题"
description: "列表与 SEO 用的一句话"
date: "2026-10-01"
tags: [架构, Next.js]
draft: false

文件名里的 001- 只是命名习惯,真正的路径名是完整文件名去掉后缀;刊号 issue 由日期排序计算,不读文件名里的数字。

readingStats 与 extractToc 已导出,供 lib/series.ts 直接复用——系列章节的估读与侧栏目录算法与散篇完全一致。

下一章专门讲系列小册的内容模型、series-path 拆分,以及章节页三栏如何组装。之后再进入共用的 MDX 编译链。