这套文档系统是 Caviar Lab 个人网站中的内容层。它使用 Next.js App Router 渲染普通 Markdown,并允许生产环境从发布目录之外的独立内容仓库读取文稿。目标是在保留主站视觉与组件复用的同时,让内容更新摆脱整站重新构建。
设计目标
| 目标 | 实现方式 |
|---|---|
| 文稿可移植 | 内容使用标准 Markdown 与 YAML Front Matter |
| 更新反馈快 | 运行时读取文件,索引默认每秒重新扫描 |
| 避免重复开销 | 单篇文档按 mtimeMs 和文件大小缓存解析结果 |
| 保持视觉一致 | 复用主站布局、导航、页脚、字体与响应式规则 |
| 内容独立发布 | 通过 CAVIARLAB_CONTENT_DIR 读取外部 Git 仓库 |
| 限制文件风险 | 路径校验、类型白名单、文件大小上限与资源响应头 |
这套系统不提供在线编辑、多人协作和数据库版本管理。写作、审阅和历史记录继续由 Markdown 编辑器与 Git 负责。
内容目录
系统只识别 logs 和 projects 两类一级目录:
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.tsx | GFM、Shiki、链接与图片地址转换 |
components/DocumentOutline.tsx | 生成目录并根据滚动位置高亮当前章节 |
components/CodeBlock.tsx | 代码块复制交互与无剪贴板 API 时的降级方案 |
app/docs-assets/[...path]/route.ts | 读取相对资源并返回缓存与安全响应头 |
app/api/docs/revalidate/route.ts | 验证 Bearer Secret,清理内容缓存并刷新文档路由 |
请求与解析流程
一次 /docs/<slug> 请求的处理顺序如下:
getDocuments()检查索引缓存是否仍在有效期内;- 缓存失效后递归扫描内容目录中的
.md文件; - 忽略隐藏目录、符号链接和内容根目录之外的真实路径;
gray-matter解析 Front Matter,Markdown AST 提取标题、简介和目录;- 根据相对路径判断文档种类并生成 slug;
- 页面使用 React Markdown 渲染正文,Shiki 在服务端完成代码高亮;
- 目录组件在浏览器中监听滚动与窗口尺寸变化。
入口文件名 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=true。listed: false 可以让文档保留直接访问路由,但不出现在文档主页列表中。
链接与资源处理
Markdown 中的相对地址以当前文档目录为基准:
- 指向
.md的相对链接会转换为/docs/...文章路由; - 图片、音频、视频和 PDF 会转换为
/docs-assets/...; - HTTP(S) 外链在新标签页打开,并带有
noopener noreferrer; - 外链使用 Lucide
Link图标,文章内链使用FilePenLine图标; #heading页内锚点保持原样,不添加文章内链图标。
资源路由使用扩展名白名单确定 Content-Type,当前支持常见图片、音视频与 PDF。响应包含 ETag、Last-Modified、nosniff 和沙箱 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;
- 图片尺寸接近实际展示需求,不保留未使用的原图或重复副本;
- 文稿没有重复的一级标题或重复分割线;
- 提交前检查缺失资源、孤立文件、内容目录总体积和最大文件。
关于这套系统从选型到正式内容导入的过程,可以继续阅读开发日志。