第 04 / 11 章

系列小册:内容模型与版块设计

content/series 怎么组织、lib/series 与 series-path 如何分工、进度键如何避免与散篇冲突,以及章节页三栏布局的组件拆分。

⏱ 3 分钟1087 字

散篇适合「一篇讲清一个点」。架构导读、逐文件剖析这类必须循序阅读的长内容,硬塞进 content/posts/ 会把首页时间线撑爆,也不利于「上一章 / 下一章」导航。

系列版块解决的就是这件事:一册多章、独立路由、独立进度键,同时复用同一套 MDX 编译与阅读进度基础设施。

内容目录约定§

一册一个文件夹:

content/series/
└── inkstack-source/           # 小册路径名 = URL 中的 [series]
    ├── series.json            # 小册元数据(不是 MDX)
    ├── 01-orientation.mdx     # 章文件名排序即阅读顺序
    ├── 02-architecture-and-dirs.mdx
    └── …

series.json§

{
  "title": "墨栈源码详解",
  "subtitle": "从目录到实现的系列小册",
  "description": "……",
  "date": "2026-10-01",
  "tags": ["架构", "Next.js", "MDX"],
  "draft": false
}

缺 title / description / date,或 draft: true,整册不发布。

章节 .mdx§

文首至少要有 title、description;可用 draft: true 隐藏单章。日期写在小册级 series.json 即可,章文件不必再带 date(与散篇 frontmatter 略有不同)。

文件名建议 01-…、02-…:lib/series.ts 用英文 locale 的字符串排序决定顺序,再赋连续的 order(1、2、3…)。

为何拆成 lib/series.ts + lib/series-path.ts§

文件能否进客户端包职责
lib/series.ts否(读盘)扫 content/series、解析 JSON/MDX、缓存、章间导航
lib/series-path.ts能只拼 URL:/series/{册}、/series/{册}/{章}

窄屏横向章节条是客户端组件,若直接 import "@/lib/series",会把 node:fs 拖进浏览器打包并在 Turbopack 下炸掉。因此路径函数单独放在无 Node 依赖的小模块里;series.ts 再 export { chapterHref, seriesHref } from "./series-path",服务端代码仍可从 @/lib/series 一处引用。

数据模型§

type SeriesMeta = {
  slug: string;
  title: string;
  subtitle: string;
  description: string;
  date: string;
  tags: string[];
  chapterCount: number;
  words: number;      // 各章合计
  minutes: number;    // 各章分钟粗加
};

type ChapterMeta = {
  slug: string;           // 如 01-orientation
  progressKey: string;    // series/{seriesSlug}/{chapterSlug}
  seriesSlug: string;
  title: string;
  description: string;
  order: number;
  minutes: number;
  words: number;
};

type Chapter = ChapterMeta & { content: string; toc: TocItem[] };
type Series = SeriesMeta & { chapters: Chapter[] };

progressKey 刻意带 series/ 前缀,避免与散篇路径名撞车——两边共用同一个 Zustand history 字典。

字数与章内目录(TOC)不另写算法:直接调用 lib/posts.ts 导出的 readingStats / extractToc,保证侧栏锚点与 rehype-slug 行为一致。

主要 API§

函数用途
getAllSeries全部小册(新→旧);production 模块级缓存,dev 每次重读
getSeriesBySlug小册首页 / 元数据
getChapter(series, chapter)章节页取 { series, chapter }
getChapterSurroundings上一章 / 下一章(按册内顺序,不是按日期)
toSeriesMeta丢掉 chapters,列表页用
chapterHref / seriesHref纯字符串路径

路由与页面文件§

URL页面文件页面职责
/seriesapp/series/page.tsx全部小册卡片
/series/[series]app/series/[series]/page.tsx小册封面、从第一章开始、完整章目录
/series/[series]/[chapter]…/[chapter]/page.tsx三栏阅读台:本册目录 · 正文 · 本章目录

章节页 generateStaticParams:

export function generateStaticParams() {
  return getAllSeries().flatMap((s) =>
    s.chapters.map((c) => ({ series: s.slug, chapter: c.slug }))
  );
}

仍设 dynamicParams = false:未预生成的册/章直接 404。

章节页:组件怎么拆§

大屏是三栏阅读台(约 ≥1080px):

栏组件职责
左SeriesChapterRail本册目录、册内进度条、回小册首页
中页头 + MdxContent + ChapterPager正文与章间翻页
右ReadingPathRail本章标题目录(与散篇同一套)

窄屏:

  • SeriesChapterStrip(客户端)横向滑动切章,并把当前章滚入视野
  • ReadingPathFab 承接本章目录

其它相关组件:

  • components/series/series-card.tsx——首页 / /series 列表
  • components/series/series-toc.tsx——小册首页的大目录(嵌 ReadingChip)
  • components/reading/reading-chip.tsx——按 progressKey 显示已读百分比

sticky 侧栏为何这样写§

章节页 Grid 保持默认拉伸(align-items: stretch),让左右 aside 与正文同高;position: sticky 放在 aside 内部的 .series-chapter-sticky 上。

若对 Grid 使用 align-items: start,aside 高度塌成「仅目录那么高」,内部 sticky 没有吸附行程,看起来就像「没吸住」。这与散篇文章页「侧栏拉高 + 内部 sticky top-24」是同一套路。

样式集中在 app/styles/site.css 的 .series-chapter* / .series-rail* / .series-strip* 一段。

作者怎么加一册新书§

  1. 新建 content/series/{路径名}/
  2. 写入 series.json
  3. 按序添加 01-….mdx、02-….mdx…
  4. 构建后自动出现在 /series 与导航「系列」下——不必改路由代码

下一章回到共用的 MDX 编译链:散篇与系列章节都走同一个 MdxContent。