散篇适合「一篇讲清一个点」。架构导读、逐文件剖析这类必须循序阅读的长内容,硬塞进 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 | 页面文件 | 页面职责 |
|---|---|---|
/series | app/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* 一段。
作者怎么加一册新书§
- 新建
content/series/{路径名}/ - 写入
series.json - 按序添加
01-….mdx、02-….mdx… - 构建后自动出现在
/series与导航「系列」下——不必改路由代码
下一章回到共用的 MDX 编译链:散篇与系列章节都走同一个 MdxContent。