三层 + 一条编译链§
从依赖方向看,仓库是单向的:
content/posts/*.mdx
content/series/**/*.{json,mdx}
↓ 只被读取,不依赖任何业务代码
lib/ 读盘、算元数据、编译 MDX、高亮
↓
app/ 路由页面,组装数据与布局
↓
components/ 展示与交互(可被 app 与 MDX 映射引用)
约束很简单:
content/不 import 代码——稿件是纯文本资源。lib/尽量不依赖 React 组件(唯一例外是lib/mdx.tsx要挂上 MDX 用的组件表)。app/负责「这一页要什么数据」,不在页面文件里堆 AST 遍历逻辑。"use client"尽量下沉到叶子组件,避免整页变客户端。- 会读盘的模块不能被客户端组件直接 import——系列路径字符串因此拆到
lib/series-path.ts。
构建期与运行期各干什么§
打开一篇散文或小册章节时,时间上其实有两段。
构建期(或服务端渲染期)
lib/posts.ts/lib/series.ts读出文稿,解析元数据,算出字数、目录等lib/mdx.tsx用 remark/rehype 插件链处理正文,Prism 高亮代码- 页面组件输出 HTML;
generateStaticParams事先列出所有路径,整站按静态页生成
浏览器里(仅限交互)
- 主题切换、顶栏高亮当前导航(含「系列」)
- 代码块的 Tab、复制、沙箱预览
- 滚动进度、目录当前节、本地保存的「读到哪了」
- 系列窄屏下的横向章节条
正文段落本身不依赖这些脚本是否加载成功。
读一篇散文章时的调用顺序§
以 /posts/001-mdx-code-blocks 为例:
- Next 匹配
app/posts/[slug]/page.tsx getPostBySlug(slug)→ 内部若无缓存则getAllPosts()扫盘generateMetadata用同一篇文章的title/description- 页面渲染:
ProgressBar、ArticleShell、页头元信息、<MdxContent />、侧栏本章目录 - MDX 里的
<pre>被映射成CodeBlock(客户端);标题被映射成带锚点的标题组件 - 挂载后:滚动监听、阻尼进度条、定时把进度写入 Zustand(键为文章路径名)
读一章系列时的调用顺序§
以 /series/inkstack-source/02-architecture-and-dirs 为例:
- Next 匹配
app/series/[series]/[chapter]/page.tsx getChapter(series, chapter)→ 必要时getAllSeries()扫content/seriesArticleShell的进度键为series/inkstack-source/02-architecture-and-dirs- 大屏三栏:
SeriesChapterRail(本册)· 正文 ·ReadingPathRail(本章) - 窄屏:
SeriesChapterStrip切章 +ReadingPathFab打开本章目录 - 正文仍走同一个
MdxContent
细节见后文「系列小册」专章。
为什么不把文章塞进 app/§
Next App Router 里,app 下的文件名会变成路由。若把长文直接放进 app/posts/.../page.mdx,路由、布局和文稿会缠在一起,也不利于「扫一个文件夹就得到全部文章」。
因此:
- 散篇放在
content/posts/,文件名去掉.mdx即路径名 - 小册放在
content/series/{小册名}/,同目录下的series.json描述小册本身,各章仍是.mdx
两套内容并列,而不是把系列塞进 posts 的 frontmatter 字段——这样扫盘、缓存、静态参数和 URL 语义都更干净。
为什么有 lib/ 而不是全写在页面里§
页面文件应当薄:取数、排版、串联组件。扫盘、算阅读时长、合并代码围栏、调用 Prism——这些属于可复用的领域逻辑,集中在 lib/:
| 文件 | 职责 |
|---|---|
lib/posts.ts | 散篇文章索引、字数、目录、上一篇/下一篇 |
lib/series.ts | 系列小册与章节索引、章间导航(读盘) |
lib/series-path.ts | 系列 URL 拼接(可被客户端引用) |
lib/tags.ts | 标签聚合(posts + series)与 tagHref |
lib/content-validate.ts | frontmatter / JSON 必填校验 |
lib/content-cache.ts | 生产态模块缓存、开发态每次重读 |
lib/mdx.tsx | 把 MDX 字符串编译成 React 树(仅服务端) |
lib/rehype.ts | 代码围栏合并 + Prism 写入 HTML AST |
lib/prism.ts | 按语言加载 Prism 语法并高亮 |
lib/code-lang.ts | 语言别名、哪些语言允许沙箱预览 |
lib/site.ts | 站点名、标语、日期格式化、站点 URL |
为什么交互组件放 components/§
layout.tsx 需要包一层 ThemeProvider,这是整站一次的运行时上下文,放在 app/providers.tsx。具体 UI 是可替换的积木,按域放在 components/:
| 目录 | 内容 |
|---|---|
components/mdx/ | MDX 标签映射、代码块 |
components/reading/ | 进度条、折线目录、ArticleShell、ReadingChip、继续阅读 |
components/series/ | 小册卡片、目录、章导航 / 导轨 / 窄屏横条 |
components/photo/ | 摄影发现、灯箱、影集 |
components/posts/ | 散篇卡片 |
components/site/ | 顶栏、页脚、主题切换 |
完整源码树(不含依赖与构建产物)§
inkstack/
├── app/
│ ├── fonts/
│ ├── globals.css # 聚合导入 app/styles/*
│ ├── styles/ # tokens / site / prose / code / reading / photos
│ ├── layout.tsx / providers.tsx / page.tsx
│ ├── sitemap.ts / robots.ts
│ ├── posts/[slug]/page.tsx
│ ├── photos/…
│ ├── tags/…
│ └── series/
│ ├── page.tsx # /series
│ ├── [series]/page.tsx # 小册首页
│ └── [series]/[chapter]/page.tsx
├── components/
│ ├── mdx/
│ ├── reading/
│ ├── series/
│ ├── photo/
│ ├── posts/
│ └── site/
├── content/
│ ├── posts/ # 散篇笔记
│ ├── series/ # 系列小册(一目录一册)
│ ├── photos/ # 摄影元数据
│ └── albums/ # 影集
├── lib/
│ ├── posts.ts / series.ts / tags.ts / …
│ ├── content-validate.ts / content-cache.ts
│ ├── mdx.tsx / rehype.ts / prism.ts / …
├── scripts/
├── types/
├── next.config.ts
└── package.json
路径别名:tsconfig.json 里 "@/*": ["./*"],所以 @/lib/posts 即仓库根下的 lib/posts。
配置层两个关键开关§
next.config.ts§
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["next-mdx-remote"],
reactCompiler: true,
};
export default nextConfig;Next 16 默认用 Turbopack 打包。next-mdx-remote 的依赖里有 ESM/CJS 混用的老包,不声明 transpilePackages 时,构建期经常出现模块解析错误。这不是业务逻辑,却是本仓库能跑起来的前提。
package.json 脚本§
dev:显式NODE_ENV=development next devbuild/start:生产构建与预览typecheck/check:TypeScript 检查;check= typecheck + build
仓库目标仍是「装好依赖 → 写 MDX → 构建」,并加上 CI 质量门禁。
下一章先看散篇索引 lib/posts.ts,再看系列索引与版块设计。