前几年我发现自己陷入一个怪圈为了高效记录我先后折腾过 Notion 的数据库、Obsidian 的插件市场、还有各种带看板和时间线的笔记应用。折腾的结果是——我花在调整工具上的时间比真正写东西的时间还多。后来一个偶然的机会我读到一个观点工具越复杂表达越廉价。这句话直接击中了我。于是我花了几个晚上写了一款叫caveman的小工具它不是笔记软件也不是什么大平台它就是一个能在命令行里运行的极简静态博客构建器把 Markdown 文件变成干净的 HTML 页面。caveman的核心思路是砍掉一切可以砍掉的功能只保留写 Markdown、生成页面、发布上线这条最短路径。没有 JavaScript 框架没有后台数据库没有主题市场。整条链路就是你写.md文件我帮你渲染成.html然后把整个文件夹丢到任意静态托管平台上就完事。这套工具特别适合三类人一是受够了复杂笔记软件、想回归纯文本写作的人二是想拥有一个完全可控、不用交年费的个人博客的人三是像我一样喜欢拆东西、想让所有代码在自己眼皮底下跑起来的技术爱好者。这篇文章我不打算只给你看成品我想把整个设计取舍的过程、核心模块的实现思路、以及我在实际使用中踩过的真实坑都摊开讲。适不适合你也用caveman看完你应该自己就能判断。1. 为什么叫它 caveman把复杂问题打回原形的取舍逻辑最开始这个项目的代号其实是 amnesia因为我想做个得了失忆症的系统——每次启动都忘了上次的复杂配置只管你当下写的那一篇文章。但写了两天发现这个方向过于暧昧于是改成了caveman。原因很直接原始人解决温饱问题的思路简单到粗暴——饿了就追猎物冷了就披兽皮所有工具都是一物一用。我想做的这个博客工具也该是这个气质一个 Markdown 文件进来一个带样式的 HTML 页面出去中间尽量不做任何多余的思考。1.1 先分清必要复杂度和自找麻烦市面上的博客系统不管是动态的还是静态的都会给你一套默认的复杂度清单分类、标签、归档、分页、搜索、评论、主题引擎、多作者权限、草稿盒子……这些功能单独看都有道理但它们组合在一起就成了一个吞噬注意力的黑洞。caveman 在设计之初就在笔记本上写了两行字必要复杂度把 Markdown 正确渲染成 HTML并且让样式可读。自找麻烦上面清单之外的绝大多数东西。所谓必要的复杂度在技术上其实并不轻——Markdown 的语法变体多得吓人表格、脚注、任务列表、代码高亮每一种都需要测试。但这是内容创作者的实际需求逃不掉。而标签系统这种很多人习以为常的东西我直接用文件夹目录结构代替了你想给文章分个类建个子文件夹就行URL 自然就是/travel/xxx.html。想打多个标签用 front matter 里的关键词字段生成页面的时候在头部显示几个链接就好但我不为它单独做一套聚合页。注意这种文件夹即分类的做法在内容量到几百篇之后会变得难维护所以实际上我在实现里留了一个 tag 扫描脚本的扩展点但默认不跑。这正是 caveman 的哲学默认极简但不封死你往上加需求的路径。1.2 逆向思考什么功能是我刻意不做出来的很多自称极简的工具其实只是把功能藏到了二级菜单里。caveman 不开这个玩笑——它干脆没有二级菜单因为根本没有 UI。我刻意不做的东西包括编辑器写 Markdown 是你自己的事用 VS Code、Vim、甚至是系统自带备忘录都行。caveman 只认文件不绑定你的编辑习惯。草稿系统没写完就是.md文件没写完写完再运行构建命令。想写一半存档那是 Git 的事直接提交一个WIP分支就行。主题系统传统的主题引擎意味着模板继承、变量注入、皮肤配置三件事。caveman 只提供一套默认的干净样式你要改就改全站唯一的那个 CSS 文件。插件市场如果有无法满足的需求你有两个选择给我提 issue或者自己 fork 一份改源码。这反而逼着用户去理解核心代码去贡献去成为社区的一部分。这种刻意不做的选取标准我总结成一句话凡是与把 Markdown 变成 HTML这条主链路无关的功能都不做。但凡是主链路上的缺失比如代码高亮就要立刻补上。这一步取舍决定了整个项目的体量也让后续的所有编码工作都有了清晰的边界。2. 核心渲染模块拆解从 Markdown 文本到 HTML 页面的最短路径caveman 的实现语言我选了 Node.js倒不是因为它比其他语言适合静态站点生成纯粹是因为生态里围绕 Markdown 的解析工具最成熟而且我自己日常写脚本最顺手。整个项目核心就三个文件build.js构建逻辑、template.js页面骨架、style.css全站样式。没错就这么少。2.1 全链路代码主流程拼图我把构建过程抽象成了一条流水线每一步只做一件事。// build.js 核心逻辑简化版 const fs require(fs); const path require(path); const { marked } require(marked); const { JSDOM } require(jsdom); const createTemplate require(./template); const SRC_DIR path.join(__dirname, src); const OUT_DIR path.join(__dirname, public); // 1. 递归读取 src 下所有 .md 文件 function collectMarkdownFiles(dir) { const results []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...collectMarkdownFiles(fullPath)); } else if (entry.name.endsWith(.md)) { results.push(fullPath); } } return results; } // 2. 解析 front matter标题、日期、关键词 function parseFrontMatter(raw) { const match raw.match(/^---\n([\s\S]*?)\n---\n?/); if (!match) return { meta: {}, content: raw }; const meta {}; for (const line of match[1].split(\n)) { const idx line.indexOf(:); if (idx -1) { const key line.slice(0, idx).trim(); const value line.slice(idx 1).trim(); meta[key] value; } } return { meta, content: raw.slice(match[0].length) }; } // 3. Markdown 转 HTML并做简单净化与抽取标题 function renderMarkdown(content) { const rawHtml marked.parse(content); const dom new JSDOM(main${rawHtml}/main); const doc dom.window.document; const firstH1 doc.querySelector(h1); const title firstH1 ? firstH1.textContent : 未命名文章; // 自动为所有 h2/h3 生成锚点链接 doc.querySelectorAll(h2, h3).forEach((heading, index) { const id sec-${index}; heading.setAttribute(id, id); }); return { html: doc.querySelector(main).innerHTML, title }; } // 4. 组合模板并写入 public 目录 function buildSite() { if (!fs.existsSync(OUT_DIR)) { fs.mkdirSync(OUT_DIR, { recursive: true }); } const files collectMarkdownFiles(SRC_DIR); const pages files.map((file) { const raw fs.readFileSync(file, utf-8); const { meta, content } parseFrontMatter(raw); const { html, title } renderMarkdown(content); const outputPath path.join(OUT_DIR, path.relative(SRC_DIR, file).replace(/\.md$/, .html)); const fullHtml createTemplate({ meta, title, content: html }); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, fullHtml); return { meta, title, outputPath }; }); // 顺便生成一份简单的 index.html文章列表 generateIndex(pages); } buildSite();这段代码的巧妙之处在于它始终保持了同步阻塞式的执行逻辑。你可能觉得同步 IO 性能差但请注意两点第一caveman 的定位是个人博客文章总量撑死几百篇第二同步代码的调试成本极低出错时堆栈信息一目了然这对一个要长期维护的小项目来说是巨大的优势。2.2 Markdown 渲染与 HTML 结构设计之间的隐性契约大多数 Markdown 渲染库默认输出的 HTML 是裸的结构语义很单薄。如果你只是把h1、p、blockquote直接塞进页面那跟复制粘贴纯文本没区别。所以我在渲染管线里加了一步 HTML 后处理使用jsdom对生成的 DOM 做三件小事把文章内第一个h1的文本提取出来作为页面标题和导航栏标题。给所有h2和h3自动生成id锚点这样读者可以复制带#sec-1的链接来精确引用某一段。把没有alt属性的图片补一个默认描述提升可访问性通常补成文件名本身。这步后处理非常重要。我见过很多教程只会告诉你用 marked 把 md 转 html但如果不做这一步你的页面会缺少语义层对 SEO 和屏幕阅读器都不友好。caveman 虽然标榜极简但极简不是粗糙该有的语义标签和锚点链接一个都不能少。2.3 页头元信息与正文分离的轻量方案front matter 我用了最常见的 YAML 格式就是 Markdown 文件顶部的三根横线包裹的区域。caveman 不做校验、不做类型推断所有字段一律按字符串处理然后直接注入模板。这意味着你甚至可以自定义字段比如加一个tags或summary模板里怎么显示完全由你改template.js决定。举个实际的例子我常用的 front matter 长这样--- title: 用 caveman 重建我的个人博客 date: 2025-11-20 keywords: [极简, static-site, markdown] summary: 分享我如何用不到 200 行代码构建一个适合自己的静态博客工具。 --- 正文从这里开始……解析逻辑非常简单读到第一对---之间的内容按行切分每行用冒号分割 key 和 value然后去掉首尾空格。如果你想要嵌套结构或数组我会直接告诉你去装一个小型 YAML 解析库而不是在自研解析器里去兼容。这种能省则省实在不能省再引入依赖的思路贯穿了整个项目。3. 本地开发与站点复用我的目录结构设计与日常发布流一个静态博客生成器光有核心渲染逻辑还不够你还得告诉它东西放哪。目录结构的合理与否直接决定你用了三个月之后还愿不愿意继续用下去。3.1 文件夹约定与命名规范caveman 默认按下面的目录约定工作caveman/ ├── build.js ├── template.js ├── style.css ├── src/ │ ├── index.md # 用来自动生成首页列表 │ ├── posts/ │ │ ├── hello-caveman.md │ │ └── 2025/ │ │ └── retro-review.md │ └── about.md # 单页内容 └── public/ # 每次构建自动清空、重新生成src/index.md只是一个入口文件内容可以基本为空只需要 front matter 里的title字段构建时会用它生成首页标题。所有 Markdown 文件按文件夹自动映射成 URL 路径src/posts/2025/retro-review.md就会生成public/posts/2025/retro-review.html。图片、附件这类的静态资源放到src/assets/下构建时原样拷贝到public/assets/。3.2 日常发布三步走我用 caveman 发一篇文章的流程已经简短到几乎形成肌肉记忆在src/posts/下新建.md文件写完正文。在终端执行node build.js。把public/目录下的所有改动git add git commit git push到托管仓库。整个过程 3 到 5 秒。没有后台进程没有数据库迁移没有缓存清理。正因为全流程跑得极快我才愿意频繁更新博客。如果发布一篇文章要等一分钟构建加两分钟构建日志分析我写字的频率肯定会直线下降。3.3 为什么一定要把内容与渲染彻底分离如果你把 Markdown 文件和构建脚本放在同一个仓库可能会遇到一个尴尬问题改文章和改代码的提交混在一起很难回溯。所以我建议你做一个更彻底的分离——其实caveman的build.js也允许你通过环境变量指定源目录和输出目录比如CAVEMAN_SRC/path/to/your/content node /path/to/caveman/build.js也就是说内容仓库和工具仓库可以分开。内容仓库是一堆.md文件和assets文件夹它的历史记录就是你的写作史工具仓库则是一套稳定的构建逻辑你不想折腾它的时候完全可以把它的版本锁定一两年不升级也没问题。这种内容与逻辑解耦的做法让博客的生命力不再依赖某个特定工具的维护积极性。4. 我在实际使用中踩过的真实坑与排查链路说到实操必须进入踩坑部分。caveman 的代码量不大但正因为小出问题时往往更难用传统方式排查——没有日志系统没有错误追踪一切靠人脑。4.1 路径分隔符引起的跨平台构建失败项目最开始是在 macOS 上写的一切正常。后来我把仓库同步到一台 Windows 机器一执行node build.js就报错提示找不到public/posts/2025/retro-review.html这个路径下的目录。排查过程是这样的先怀疑marked渲染出错单独跑了一个测试文件渲染逻辑完好。然后用console.log打印outputPath才发现打印出来的是public\posts\2025\retro-review.html反斜杠Windows 的path.join用的是\而后续给fs.mkdirSync传的路径自然没问题但如果你有一层映射或者拼接字符串的操作很容易混入硬编码的/导致明明看到文件生成在正确位置却打不开的局面。修复方案很简单用path.join拼路径而不是字符串拼用path.relative计算相对路径。另外统一在入口处加一句process.env.PATH_SEPARATOR path.sep也没啥必要只要全程使用path模块的 API 就不会有问题。插一句实际体会在所有跨平台 bug 里路径分隔符是最经典的一种。也正因为它太常见反而容易被忽略。如果你在做一个跨平台小工具建议在 CI 里同时跑 Linux 和 Windows 两个任务哪怕只有一条最基本的构建测试。4.2 front matter 解析的边角情况我前面说 front matter 解析非常直接但当你的文章量上来之后边角情况会来敲门。比如某个值里带着英文冒号title: 爬虫笔记百度与Google的收录差异这是一个完全合法的 YAML 值但我的简易解析器是按line.indexOf(:)来切割的它不会考虑引号包裹于是会把title切成了爬虫笔记和百度与Google的收录差异两段解析出来的标题直接变成半个页面标题就错乱了。排查思路第一反应是 Markdown 渲染问题但换了别的渲染库依然如此。打日志发现 front matter 解析出来的meta.title不完整于是定位到问题在解析层。上网搜了一下 YAML 规范发现字符串里包含冒号需要引号包裹。我的解析器没有遵循这个规范。最后我没有直接引入 YAML 库而是用了一个更简单的做法如果这一行里冒号之后还有冒号就先把整行按第一个冒号切分然后把 value 用引号包住让它在页面上正常展示。但为了保险起见我在 README 里加了一条建议标题里少用英文冒号多用全角冒号这样既避免解析歧义也符合中文排版习惯。4.3 锚点链接在列表页失效的问题生成文章页时我给每个 H2/H3 都加了idsec-N这个没问题。但文章列表页只显示标题和摘要并不包括正文内容。我在列表页里写的摘要直接用了 front matter 里的summary字符串但有的文章没写summary于是列表页出现空白条目。排查逻辑先在generateIndex(pages)里打日志发现meta.summary是undefined。一开始打算在模板里用|| 兜底但这会生成一个空卡片视觉上很糟糕。最后采用了一个简单的降级策略如果没有写summary就用正文渲染后的纯文本前 120 个字符作为摘要补上stripHtml和截断两个小函数。这样列表页永远不会空白反而会促使我尽量给每篇文章写一句带钩子的简介。4.4 大量图片导致的构建内存峰值有一段时间我写摄影评测文章一篇文章塞了 40 多张原图。caveman 的构建过程不处理图片压缩这是刻意选择它只是把文件拷贝到public/。问题出在 HTML 后处理那一步jsdom会把整篇文章的 HTML 解析成一个真实 DOM40 多张base64缩略图加上大图路径的字符串内存直接冲高。很明显问题不在渲染库而在于我用jsdom做后处理这个方案的固有成本。基于不同类型的项目就得用不同方案的原则我做了两个调整默认不再用jsdom解析整个 DOM而是改为用正则和字符串截取的方式提取第一个h1和给标题加id这一层开销瞬间降为常量级。图片资源保持文件路径引用不要在 Markdown 里直接贴 base64否则页面体积和构建内存双双爆炸。如果你也打算基于 Node 做类似工具我可以认真提醒你能用正则解决的就别引入 DOM 模拟环境。正则可能不够优雅但在这个量级上它是性价比之王。5. 扩展方向与假装有插件的边界我把能力边界画在哪里caveman 的定位是极简但它不自闭。正因为有了硬边界扩展起来反而清晰。我不会乱加功能但有三个方向是我在实际使用中觉得值得去做的。5.1 基于现场生成的“相关文章”推荐静态博客没法在服务端做实时推荐但你可以在构建时计算相似度。caveman 扫描每篇文章的关键词字段keywords构建时生成一个倒排索引然后在每篇文章底部输出一个相关文章小列表链接到共享关键词最多的另外两篇文章。这个功能大概 40 行代码就能实现效果却很像样。核心逻辑function buildRelatedPages(pages) { const index {}; pages.forEach((page) { (page.meta.keywords || []).forEach((kw) { index[kw] index[kw] || []; index[kw].push(page); }); }); pages.forEach((page) { const candidates []; (page.meta.keywords || []).forEach((kw) { (index[kw] || []).forEach((c) { if (c ! page !candidates.includes(c)) candidates.push(c); }); }); // 取前 2 篇作为相关文章 page.related candidates.slice(0, 2); }); }5.2 渐进增强的本地搜索很多人觉得静态博客没法做站内搜索非要上第三方评论系统或脚本服务。caveman 提供一个很朴素的方案构建时把每篇文章的纯文本内容生成一个 JSON 索引文件然后前端只用一个文本框加一段 30 行的 JS 在内存里做模糊匹配。不需要搜索服务不需要后端隐私还很好。真实体验上几百篇文章的索引文件大概在 200KB 以内完全可以接受。5.3 假装有插件体系用脚本注入而非运行时架构我不打算让 caveman 像 VS Code 那样搞一个插件加载器。更符合它气质的方式是暴露构建期事件钩子比如beforeBuild、afterRender、afterBuild。你如果觉得默认行为不够用在build.js里手动触发钩子回调写业务逻辑即可。不用注册中心、依赖注入这些重型概念。这让我想到一个更宏观的问题小工具最重要的不是什么都能做而是边界清晰扩展有路。caveman 刻意不提供运行时插件机制因为它会带来版本兼容的噩梦但暴露几个构建期的回调既不增加架构复杂度又能让你在需要时自由发挥。6. 实际效果与最后的个人体会如果只用一句话总结 caveman 的价值我会说它让我重新找回了写完即发布的爽快感。文章数据在本地是一堆纯文本永远不担心平台倒闭页面构建到本地是一个纯静态目录想放哪里放哪里整个项目的源码和内容分开管理想换工具我随时能把内容搬走。它就像一个原始人的石斧朴素到没有第二层机关但你握在手里就知道它该往哪用力。如果你也在寻找这样的状态——不怎么折腾能流畅地写点东西自由地发布到网上又不想被任何平台的规则绑架——我建议你试试这个思路不一定要用 caveman哪怕是用 shell 脚本加pandoc也能搭出同样的流程。关键是你想清楚自己的写作主链路是什么然后把所有会分散注意力的选项全部关掉让工具回归能用这个朴素的起点。