← 返回文档
项目文档 ·

Caviar Lab 动态文档系统架构

运行时 Markdown、文件修改时间缓存、受限资源路由与独立内容仓库的设计说明。

这套文档系统是 Caviar Lab 个人网站中的内容层。它使用 Next.js App Router 渲染普通 Markdown,并允许生产环境从发布目录之外的独立内容仓库读取文稿。目标是在保留主站视觉与组件复用的同时,让内容更新摆脱整站重新构建。

设计目标

目标实现方式
文稿可移植内容使用标准 Markdown 与 YAML Front Matter
更新反馈快运行时读取文件,索引默认每秒重新扫描
避免重复开销单篇文档按 mtimeMs 和文件大小缓存解析结果
保持视觉一致复用主站布局、导航、页脚、字体与响应式规则
内容独立发布通过 CAVIARLAB_CONTENT_DIR 读取外部 Git 仓库
限制文件风险路径校验、类型白名单、文件大小上限与资源响应头

这套系统不提供在线编辑、多人协作和数据库版本管理。写作、审阅和历史记录继续由 Markdown 编辑器与 Git 负责。

内容目录

系统只识别 logsprojects 两类一级目录:

content/
├── logs/
│   └── runtime-docs-launch.md
└── projects/
    ├── dynamic-docs/
    │   ├── index.md
    │   └── images/
    │       └── pipeline.svg
    └── ai-zero-course/
        ├── index.md
        ├── preparation.md
        └── chapter-*.md

日志使用 logs/<slug>.md。一个项目的入口文档推荐使用 projects/<slug>/index.md,同目录中的其他 Markdown 可以作为章节。解析器仍兼容 README.md,但内容站中的入口文件优先命名为 index.md,避免把仓库首页说明和网站文章概念混在一起。

模块职责

模块职责
lib/documents.ts扫描内容目录、解析元信息、提取标题、维护索引与单篇缓存
app/docs/page.tsx获取摘要并按日志、项目文档分组展示
app/docs/[...slug]/page.tsx动态文档路由、页面元信息与文章布局
components/MarkdownDocument.tsxGFM、Shiki、链接与图片地址转换
components/DocumentOutline.tsx生成目录并根据滚动位置高亮当前章节
components/CodeBlock.tsx代码块复制交互与无剪贴板 API 时的降级方案
app/docs-assets/[...path]/route.ts读取相对资源并返回缓存与安全响应头
app/api/docs/revalidate/route.ts验证 Bearer Secret,清理内容缓存并刷新文档路由

请求与解析流程

一次 /docs/<slug> 请求的处理顺序如下:

  1. getDocuments() 检查索引缓存是否仍在有效期内;
  2. 缓存失效后递归扫描内容目录中的 .md 文件;
  3. 忽略隐藏目录、符号链接和内容根目录之外的真实路径;
  4. gray-matter 解析 Front Matter,Markdown AST 提取标题、简介和目录;
  5. 根据相对路径判断文档种类并生成 slug;
  6. 页面使用 React Markdown 渲染正文,Shiki 在服务端完成代码高亮;
  7. 目录组件在浏览器中监听滚动与窗口尺寸变化。

入口文件名 index.md 和兼容文件名 README.md 不会出现在 URL 中。例如:

projects/dynamic-docs/index.md
→ /docs/projects/dynamic-docs

projects/ai-zero-course/chapter-2.md
→ /docs/projects/ai-zero-course/chapter-2

元信息约定

推荐每篇文档显式提供标题、简介和日期:

---
title: 文档标题
description: 用于文档卡片和页面 SEO 的一句话简介
date: 2026-08-12
updated: 2026-08-13
tags:
  - Next.js
draft: false
repoUrl: https://github.com/example/project
---

页面主标题以 Front Matter 的 title 为准。正文中的第一个一级标题用于缺省标题推导,渲染时会被移除,避免页面头部与正文重复显示同一个标题。二至四级标题会进入“在本页”目录。

生产环境默认不展示 draft: true 的文档;需要预览时可配置 CAVIARLAB_INCLUDE_DRAFTS=truelisted: false 可以让文档保留直接访问路由,但不出现在文档主页列表中。

链接与资源处理

Markdown 中的相对地址以当前文档目录为基准:

  • 指向 .md 的相对链接会转换为 /docs/... 文章路由;
  • 图片、音频、视频和 PDF 会转换为 /docs-assets/...
  • HTTP(S) 外链在新标签页打开,并带有 noopener noreferrer
  • 外链使用 Lucide Link 图标,文章内链使用 FilePenLine 图标;
  • #heading 页内锚点保持原样,不添加文章内链图标。

资源路由使用扩展名白名单确定 Content-Type,当前支持常见图片、音视频与 PDF。响应包含 ETagLast-Modifiednosniff 和沙箱 CSP,浏览器再次请求未变化资源时可以得到 304 Not Modified

两级缓存

文档系统没有用长期页面缓存掩盖内容变化,而是把缓存放在读取和解析层。

索引缓存

文档列表默认缓存 1,000 毫秒,由 CAVIARLAB_DOCS_INDEX_TTL_MS 控制。缓存到期后重新扫描文件列表,因此新增、删除和重命名文章最迟约一秒可以被发现。

单篇缓存

每篇文档记录绝对路径、mtimeMs 和文件大小。两个值没有变化时直接复用已经解析的文档对象;变化时只重新读取这一篇文章。

if (cached?.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) {
  return cached.document;
}

这种策略适合文档规模较小、读取频繁而修改相对较少的个人网站。它避免每次请求都重新构建 Markdown AST,同时不要求数据库或常驻文件监听器。

安全边界

内容仓库被视为需要约束的输入,而不是可以任意执行的程序:

  • Markdown 单文件上限为 1 MiB,资源单文件上限为 10 MiB;
  • 原始 HTML 不进入渲染结果,Markdown 不能注入脚本;
  • 内容扫描不跟随符号链接,并用 realpath 校验文件仍在内容根目录内;
  • 资源读取再次进行路径边界与文件类型校验;
  • 刷新接口只负责清理缓存,不在 Next.js 进程中执行 Git 命令;
  • Secret 使用恒定时间比较,未配置时接口返回 503
  • 网站进程只需要内容目录读取权限,拉取仓库由独立部署流程完成。

生产部署

程序发布目录与内容仓库保持独立:

/var/www/caviarlab-site-current/  # Next.js standalone 程序
/var/www/caviarlab-content/       # Markdown 内容 Git 仓库

服务需要以下环境变量:

变量用途
CAVIARLAB_CONTENT_DIR内容仓库的绝对路径
CAVIARLAB_DOCS_INDEX_TTL_MS文档索引缓存时间,默认 1000
CAVIARLAB_INCLUDE_DRAFTS是否在生产环境显示草稿
CAVIARLAB_REVALIDATE_SECRET缓存刷新接口的 Bearer Secret

内容更新由 scripts/sync-docs-content.sh 完成。脚本在内容仓库执行 git pull --ff-only,成功后调用 /api/docs/revalidate。因此修改 Markdown 只需要同步内容;修改 React 组件、CSS、依赖或解析逻辑才需要重新构建 Next.js。

维护检查清单

新增或导入文档时需要确认:

  • Front Matter 的标题、简介和日期真实有效;
  • 文章内链使用相对 .md 路径,资源链接指向实际存在的文件;
  • 虚构地址使用普通文字,不伪装成可点击链接;
  • 图片使用可读的英文文件名,并优先转换为 WebP 或 AVIF;
  • 图片尺寸接近实际展示需求,不保留未使用的原图或重复副本;
  • 文稿没有重复的一级标题或重复分割线;
  • 提交前检查缺失资源、孤立文件、内容目录总体积和最大文件。

关于这套系统从选型到正式内容导入的过程,可以继续阅读开发日志