第 02 / 11 章

架构分层与目录设计

单向依赖怎么切、构建期与浏览器各干什么、散篇与系列的调用顺序,以及 content/posts 与 content/series 为何并列。

⏱ 5 分钟1443 字

三层 + 一条编译链§

从依赖方向看,仓库是单向的:

content/posts/*.mdx
content/series/**/*.{json,mdx}
        ↓  只被读取,不依赖任何业务代码
      lib/          读盘、算元数据、编译 MDX、高亮
        ↓
      app/          路由页面,组装数据与布局
        ↓
  components/       展示与交互(可被 app 与 MDX 映射引用)

约束很简单:

  1. content/ 不 import 代码——稿件是纯文本资源。
  2. lib/ 尽量不依赖 React 组件(唯一例外是 lib/mdx.tsx 要挂上 MDX 用的组件表)。
  3. app/ 负责「这一页要什么数据」,不在页面文件里堆 AST 遍历逻辑。
  4. "use client" 尽量下沉到叶子组件,避免整页变客户端。
  5. 会读盘的模块不能被客户端组件直接 import——系列路径字符串因此拆到 lib/series-path.ts。

构建期与运行期各干什么§

打开一篇散文或小册章节时,时间上其实有两段。

构建期(或服务端渲染期)

  1. lib/posts.ts / lib/series.ts 读出文稿,解析元数据,算出字数、目录等
  2. lib/mdx.tsx 用 remark/rehype 插件链处理正文,Prism 高亮代码
  3. 页面组件输出 HTML;generateStaticParams 事先列出所有路径,整站按静态页生成

浏览器里(仅限交互)

  1. 主题切换、顶栏高亮当前导航(含「系列」)
  2. 代码块的 Tab、复制、沙箱预览
  3. 滚动进度、目录当前节、本地保存的「读到哪了」
  4. 系列窄屏下的横向章节条

正文段落本身不依赖这些脚本是否加载成功。

读一篇散文章时的调用顺序§

以 /posts/001-mdx-code-blocks 为例:

  1. Next 匹配 app/posts/[slug]/page.tsx
  2. getPostBySlug(slug) → 内部若无缓存则 getAllPosts() 扫盘
  3. generateMetadata 用同一篇文章的 title / description
  4. 页面渲染:ProgressBar、ArticleShell、页头元信息、<MdxContent />、侧栏本章目录
  5. MDX 里的 <pre> 被映射成 CodeBlock(客户端);标题被映射成带锚点的标题组件
  6. 挂载后:滚动监听、阻尼进度条、定时把进度写入 Zustand(键为文章路径名)

读一章系列时的调用顺序§

以 /series/inkstack-source/02-architecture-and-dirs 为例:

  1. Next 匹配 app/series/[series]/[chapter]/page.tsx
  2. getChapter(series, chapter) → 必要时 getAllSeries() 扫 content/series
  3. ArticleShell 的进度键为 series/inkstack-source/02-architecture-and-dirs
  4. 大屏三栏:SeriesChapterRail(本册)· 正文 · ReadingPathRail(本章)
  5. 窄屏:SeriesChapterStrip 切章 + ReadingPathFab 打开本章目录
  6. 正文仍走同一个 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.tsfrontmatter / 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 dev
  • build / start:生产构建与预览
  • typecheck / check:TypeScript 检查;check = typecheck + build

仓库目标仍是「装好依赖 → 写 MDX → 构建」,并加上 CI 质量门禁。

下一章先看散篇索引 lib/posts.ts,再看系列索引与版块设计。