
如果你也受够了动辄上百兆 node_modules、每次升级依赖都要顺手修一遍代码的博客系统这篇文章大概率对你有用。我最近给个人博客换了一套新的发布后台名字叫 caveman翻译过来就是穴居人。它不是框架不是 npm 包也不是哪个在线平台的后台而是一整套“返祖”式的静态站方案文章以 Markdown 纯文本保存在本地构建时用系统自带的命令行工具渲染成静态 HTML最后通过 rsync 同步到任意一台普通服务器上。caveman 适合谁适合那些和我一样希望博客能跑十年、不想被框架升级牵着走、又希望完全掌控发布流程的人。它不追求“开箱即用”的华丽后台而是追求“文件在站点就在”的确定性。如果你只想五分钟搭一个漂亮的博客Hugo、Astro 仍然是更好的选择。但如果你想理解一个网站到底是怎么从零到一跑起来的或者想摆脱依赖树的束缚那 caveman 的思路值得参考。这篇内容会从设计初衷、目录结构、最小实现、功能补齐、上线踩坑几个面展开全程不需要复杂的构建链一套 Linux 或 macOS 环境就够。1. 受够 node_modules 之后我决定做一套叫 caveman 的博客1.1 压倒我的最后一根稻草我有一个写了七八年的个人博客中间换过 WordPress、Jekyll、Hugo后来跟着潮流试过一段 Astro。每个工具都不差但它们都有一个共同点你一旦离开一段时间再回去维护时整个世界都变了。印象最深的是一个深夜。我打开一个三个月没碰的项目执行了npm install屏幕上滚过去几百行输出。升级完再跑npm run build报错提示某个包的 API 变了。我去查 changelog又发现这个包依赖的另一个包有了 breaking change而这个 breaking change 又牵着第三个包……那个晚上我什么都没写光是在处理依赖关系了。我数了一下项目里的 node_modules文件数量超过四万个体积接近 120MB。一个只有几十篇文章的个人博客凭什么要管理四万个文件很多人会说你又不需要天天升级锁好版本不就行了。但问题是安全漏洞、Node 版本更新、开发机迁移都会逼着你重新面对这堆东西。我想要的博客不只是“现在能跑”而是“五年后我还能轻松地往里写一篇文章”。那时候我突然意识到也许问题不在某个框架选错了而是我把一件本该简单的事情搞得太工程化了。1.2 静态博客的本质就是“文本转 HTML”回看互联网早期一个个人网站就是一堆 HTML 文件放在 FTP 目录里谁来谁看。没有数据库没有服务端脚本当然也没有构建链。它的运行逻辑极其简单浏览器请求某个路径服务器把对应文件返回仅此而已。现在的静态博客生成器本质上也是“文本转 HTML”只是在外面套了一层又一层JavaScript 运行时、组件化、热更新、插件系统……这些对大型应用确实有用但对一个写作者来说90% 的能力都用不到。我要的只是Markdown 能渲染成干净、可读的 HTML文章列表、标签页、归档页能自动生成本地预览方便发布到服务器方便将来某天换电脑克隆一个 Git 仓库就能恢复全部工作流这些需求一套简单的脚本就能覆盖不需要引入一整套现代工程体系。caveman 就是在这个判断下诞生的。1.3 “穴居人哲学”的三条原则给项目起名 caveman多少带点自嘲但也有很实际的原因。我希望这套系统像穴居人使用石器一样不追求精巧的构造只追求三个词小、少、死不了。第一条尺寸小。整个发布系统的源码只有几个脚本加一个模板目录压缩下来也就几十 KB。相比动辄占满硬盘的依赖树它轻得像不存在。第二条依赖少。caveman 不强迫你安装任何编程语言的包管理产物。服务器上只要有普通的 Web 服务软件本地有 rsync 和基本命令整个流程就能跑起来。即使以后这些东西都被人忘记了只要 HTML 还在发布目录里站点依然能访问。第三条死不了。因为所有内容都是纯文本所有生成逻辑都可读、可修改所以这套系统几乎没有“失传”的风险。哪怕某个脚本因为环境变化不能用了你也只需要半天时间重写它而不是和某个陌生框架搏斗一星期。这三条原则听起来简单真正落地时会有很多细节要抠。下面讲讲我是怎么把文件、脚本和发布链路组织在一起的。2. 三个不装依赖的构建套装caveman 的目录与流水线2.1 内容层所有文章都是普通文件caveman 的文章存放在src/posts目录下每篇是一个带简单头信息的 Markdown 文件。我没有引入复杂的 Front Matter 规范只用了最朴素的键值对形式大致是这样--- title: caveman 项目笔记 date: 2025-01-12 09:30:00 08:00 tags: [博客, 极简] --- 这里开始写正文纯 Markdown。文件命名我用的是英文短横线拼接的 slug比如caveman-project-notes.md。这样 URL 稳定、英文搜索引擎友好也不会有中文文件名导致的编码问题。图片、附件等静态资源统一放在src/static里构建时原样复制到站点根目录。内容层的使命非常纯粹负责存文字。我不需要登录后台不需要在线编辑器在 Vim、VS Code、Typora 里打开就能写。文字存在本地磁盘和 Git 仓库里这比任何“云保存”都可靠——云端账号可能被封服务器可能跑路但一个.md文件永远是一个.md文件。2.2 生成层Makefile Bash Python 的分工很多人觉得“不装依赖”就等于“不写代码”这完全是误解。caveman 只是不依赖外部生态它自己当然有代码而且用得很开心。我的分工是这样的Makefile 负责入口和流程控制。它是 Linux/macOS 系统命令不需要安装而且语法简单地写在最前面。命令无非是make build、make serve、make publish三条谁都能看懂。Bash 脚本负责文件遍历、目录创建、批量复制这些“体活”。比如把src/static复制到public/把文章按年份归好类把 Markdown 文件名统一改成 HTML 文件名。Python 3 负责需要逻辑判断的事情解析日期、处理 URL 编码、生成 RSS/XML、生成全文搜索索引。为什么用 Python 而不是继续 Bash因为 Bash 处理字符串真是噩梦而 Python 3 在绝大多数机器上都是自带的。用它至少不需要额外安装 Node.js、Ruby 或 PHP。这层分工的核心思想是每个工具只做自己最擅长的事。Makefile 不该写复杂的业务逻辑Bash 不该去解析 XMLPython 不需要控制整个构建流程。2.3 发布层rsync 同步到任意服务器caveman 最终生成的public/就是一个完整的静态网站。所谓“发布”就是把这整个目录同步到服务器上的网站根目录我用的是 rsync 一行命令rsync -avz --delete public/ useryour-server:/var/www/caveman/-a保留文件属性-v显示过程-z压缩传输--delete确保本地删除的文件在远端也被清理掉。这里必须提醒一件事情--delete是一把双刃剑。如果本地public/因为某种原因只生成了半截同步过去就会把服务器上的好文件删掉。我的习惯是先把构建脚本跑通过再手动执行一次rsync --dry-run看看差异确认无误后才真正发布。如果你更谨慎可以在发布前加一个检查脚本统计 HTML 文件数量少于阈值就中止#!/usr/bin/env python3 import sys, glob html_files glob.glob(public/**/*.html, recursiveTrue) if len(html_files) 10: print(构建结果异常中止发布) sys.exit(1)把这一步挂在make publish之前相当于给发布加了一道保险。这种事就是典型的“踩过一次坑才会写”后面我会细说。3. 一步一步拿 Shell 和 Python 攒出一个能跑的静态站3.1 初始化目录和第一个 Makefile创建项目目录时我通常会得到这样一个骨架caveman/ ├── Makefile ├── scripts/ │ ├── build.sh │ ├── render.py │ └── index.py ├── templates/ │ ├── article.html │ ├── list.html │ └── base.html ├── src/ │ ├── posts/ │ └── static/ └── public/public/是构建输出目录可以随时删除重建不纳入 Git 管理。Makefile 最简单的时候长这样.PHONY: build serve publish build: bash scripts/build.sh serve: cd public python3 -m http.server 8000 publish: python3 scripts/check_build.py rsync -avz --delete public/ useryour-server:/var/www/caveman/build.sh的逻辑也不复杂清空public、建目录、复制静态文件、渲染文章页、生成列表页。我不会让单个脚本超过 150 行一旦要变复杂就拆成多个脚本。3.2 模板渲染不用模板引擎做静态站必然要面对“怎么把内容填进 HTML”的问题。有人会引入 Jinja2 或 EJS但 caveman 的原则是不装依赖所以我用 Python 标准库里的string.Template它能把字符串里的$变量替换成实际内容。一个最简单的基础模板长这样!DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title$title/title link relstylesheet href/css/style.css /head body main classcontainer $content /main /body /htmlPython 渲染代码也很短from string import Template import pathlib template_path pathlib.Path(templates/article.html) page_tpl Template(template_path.read_text(encodingutf-8)) html page_tpl.safe_substitute(title这篇文章的标题, contentp渲染后的正文/p)为什么用safe_substitute而不是substitute因为如果模板里某个变量名写错substitute会直接抛异常而safe_substitute只会保留原样定位问题时更容易看到是哪个变量没被替换。模板引擎这件事上我的体会是一个 50 篇博文的个人站真的不需要分支判断、循环嵌套、组件继承这些高级能力。一个$content位就够了。手写几个页面怎么拼装远比学会一门模板语言更重要。3.3 从单篇渲染到全站生成单篇文章搞定后要解决的是批量化。写一个 Python 脚本遍历src/posts下所有 Markdown 文件调用外部渲染器转成 HTML再填进文章模板最后抽取出标题、日期、标签生成首页的文章列表。渲染那一步我选的是 Pandoc不是系统的什么都不选。Pandoc 是一个独立的二进制程序不需要装依赖树只要服务器或本机有这个命令就行。它的好处是转出来的 HTML 干净对 Markdown 的支持非常全面代码块、表格、脚注都能处理好。调用方式很简单pandoc article.md -f markdown -t html --highlight-stylepygments -o article_raw.html用--highlight-stylepygments生成带语法高亮 class 的代码块再配合自己的 CSS观感完全够用。如果纯粹追求“零外部命令”也可以用 Python-Markdown 这个库但那就需要 pip 安装一次。这个度怎么把握我的判断标准是对日常维护“几乎不会失效”的东西可以接受。Pandoc 不是 Node 依赖不会出现“半年后升级坏掉”的问题。第一版 caveman 在我电脑上跑通的时刻站点是这样的首页能列出所有文章标题和日期点进去能看到正文图片路径正确手机浏览器打开排版尚可。没有分类页没有 RSS没有搜索。但这已经是一个完整的博客了。剩下的都是边用边补。4. 没有框架的网站功能模块靠什么补齐4.1 SEO 三件套meta、sitemap、RSS没有框架的网站意味着没有插件市场。SEO 这类事情过去靠插件现在只能在脚本里手动补齐。我的做法分三步第一步每篇文章的模板 head 区都生成meta description、meta keywords、canonical和 Open Graph 基础标签。这些数据从哪里来从 Markdown 的头部信息里读。如果文章没写摘要就截取正文的前 100 个字符做成 description。第二步构建时生成sitemap.xml。写一个 Python 函数扫描public里的全部 HTML 文件把路径按YYYY-MM-DD格式排序后输出的 XML 里。注意 URL 里的中文路径要做百分号编码否则搜索引擎可能不认。第三步生成rss.xml。只需要取最新 20 篇文章的标题、链接、日期、摘要拼成标准的 RSS 2.0 格式。这里我对日期格式特别小心必须符合 RFC 822否则很多阅读器会报错。最稳妥的方式是用 Python 的email.utils.format_datetime方法。这三样东西加起来不到 200 行代码却能让博客被搜索引擎正常收录、被 RSS 阅读器正常订阅。很多看起来很复杂的功能拆开看就是一个数据连接和字符串拼接的活儿。4.2 代码高亮与 Markdown 渲染的分工写技术博客的人没有代码高亮是没法活的。用 Pandoc 渲染时可以直接生成带高亮 class 的 HTML。比如一段 Python 代码渲染后会变成span classkwdef/span这种形式。然后我只要在自己的 CSS 文件里定义几种颜色.kw { color: #d73a49; } .dt { color: #6f42c1; } .st { color: #032f62; }这就是最简单的代码高亮方案不需要引入 JavaScript不需要联网加载 JS 文件页面还非常轻快。要说缺点也有如果某天你想换主题CSS 变量得自己重新调。但我个人认为博客的主要价值是内容不是配色。我理解的“极简”不是什么都不要而是每个引入项都要能说出它存在的必要性。Pandoc 负责文本渲染、CSS 负责视觉表现两者都是确定性的、可维护的不像前端生态那样牵一发动全身。4.3 标签、归档和分页在无数据库时代怎么做标签和归档本质上是同一件事把文章按某个维度聚合。caveman 的做法是构建完成后写一个小脚本扫描所有已经生成的文章页提取带tags字段的数据累积到一个内存字典里。然后按标签名生成tags/tag/index.html里面列出所有包含该标签的文章链接。归档页更简单按年份分组就行了。每篇文章的文件路径里有日期字段所以生成一个archive/index.html把文章按年份倒序排列年份下面挂标题和链接。这种页面不需要数据库不需要分页查询只是一个循环遍历的结果。分页这件事我反而建议直接不做。个人博客一周写两篇一年也就一百篇首页展示最近二十篇其余靠归档页访问完全够用。如果非要做传统分页思路是按每页若干篇把文章列表切成多段分别生成page/1/index.html、page/2/index.html这类产物。但你会发现维护成本和收益完全不成比例。5. 从本地上线到长期维护caveman 后来踩过的那些坑5.1 中文文件名与 URL 编码带来的诡异问题第一次把站部署到服务器后我遇到了一个诡异现象部分文章页面可以访问部分则偶尔 404浏览器地址栏里还会出现一长串百分号编码的路径。查了半天才发现是有几篇 Markdown 文件用了中文标题作为文件名比如我的2024年终总结.md。构建脚本虽然生成了中文路径的 HTML但本地预览服务器和 Nginx 对非 ASCII URL 的处理方式不一样某些场景下就会找不到文件。解决方式是从根上断掉所有文件名一律使用英文 slug。中英文标题都写在头部信息里URL 只认英文短横线。这个方法坚持下来后线上再也没出现过 404。标签页里如果出现中文路径则用urllib.parse.quote手动编码一次确保生成的链接是正确的在线编码形式。这个坑给我的教训是本地跑通不等于线上能跑通文件系统、Web 服务器、浏览器三者对中文字符的容忍度完全不同。最稳的方案就是路径里只出现 ASCII 字符。5.2 时区和日期把文章搞“消失”了上线后有一段时间新写的文章总是隔了一天才能在博客上出现。起先我以为是缓存问题后来发现是时区问题。我的开发机上用的北京时间而服务器用的 UTC。构建脚本里取了本地时间作为发布时间生成的 HTML 显示的是北京时间但 RSS 和 Sitemap 里的日期被 Python 解析成 UTC 后往前推了八个小时。更糟的是如果文章设置了“未来发布时间”自己本机测试一切正常但服务器判定的“现在”还没到那个时间所以首页列表宁愿不显示这篇文章。解决方案是在每篇文章的头部信息里写完整时区date: 2025-01-12 09:30:00 08:00构建脚本读取时忽略本地时间直接用这个带时区的时间做格式化。RSS、Sitemap、列表排序都以它为准。这之后“文章在零点消失、在第二天出现”之类的怪事再也没有发生过。5.3 移动端适配的问题用脚本生成网站时很容易只顾着桌面端浏览器的体验而忽视了手机端。第一版 caveman 上线后我拿手机打开自己的博客整页字体大得出奇代码块横向溢出图片比例也乱。原因是我只写了文章内容和样式却没有在模板里加一行 viewport meta 标签。补上基础视口标签后又把 CSS 里的max-width设置为 720px正文区域居中代码块设置了overflow-x: auto图片设置了max-width: 100%。这三个基础规则解决掉了 90% 的手机端阅读问题。这类坑在大型框架里往往已经被默认主题解决了但自己写脚本时就是一个一个踩出来的。也正因为踩过现在我对“默认主题帮你做了多少事”有了更真实的理解。5.4 全文搜索从零开始博客写多了之后文章多了没有搜索功能非常难受。我试过集成外部的搜索服务但那种方案要么把数据放在别人的服务器上要么在内页里引入一个大库。最后决定采取一个笨办法构建时把每篇文章的标题、摘要、正文前一段文字和 URL 导出成search.json前端做一个不超过 60 行的原生 JavaScript 搜索框用户在搜索框中输入关键词脚本在 JSON 数据里模糊匹配列出结果。这个方案对几百篇文章的博客来说完全够用秒级响应并且不需要一个后端服务。缺点也很明显搜索只能做关键词匹配不支持复杂的语法。可它毕竟没有引入新的运行时工具也没有超出“纯静态”的边界在极简与可用之间找到一个合适的平衡。如果你也想采用这个思路我建议在search.json里预留一个“纯文本正文”字段不要把整篇文章序列化进去否则文件会涨得很快。取前一两段文字索引体积能控制在几十 KB。5.5 评论、互动与读者来访没有后端数据库评论是最麻烦的事。我最初的想法是集成某个第三方评论区但第三方服务多多少少都有广告、不可控或者必须加载外部脚本的问题。后来看到不少人的做法是开放 GitHub Issues 作为评论但它要求访客必须有 GitHub 账号对很多普通读者不友好。最终我选择了一个最原始的方式文章末尾放一个邮箱链接欢迎读者直接写信交流。这很“穴居人”但它没有隐私问题、没有维护负担也真的收到过几封很认真的读者来信体验反而比我预期中好。如果未来一定要做评论区我大概率会选一份基于 Webmention 的方案或者继续沿用“邮箱传统”。对一个个人博客来说没有评论并不会让内容失去价值但维护一个糟糕的评论系统一定会消耗大量精力。6. 除博客之外caveman 式极简还能延伸到哪6.1 从博客到个人工具站caveman 的整体思路不只适用于博客。后来我用同样的方式搭了一个“个人工具导航”站页面更少功能更纯粹一组静态 HTML 页面记录自己常用的工具链接和简要说明。整个过程不到半小时没有框架初始化、没有脚手架完全就是复制博客的模板目录改一改。这件事让我意识到人对自己动手能力的信心往往建立在“我知道每一块拼图在哪”的基础上。6.2 把极简迁移到写作工具链用 caveman 管理博客之后我的写作场景也改变了。以前总觉得写作需要一整套“后台管理”——文章列表、富文本编辑器、自动保存、发布按钮。现在反而回归到最朴素的方式写内容用 Markdown 编辑器版本管理用 Git发布是一个make build make publish命令。这个变化无关什么“仪式感”纯粹是为了减少决策成本。打开编辑器、找到文件、写、保存然后发布每一步都很直白。没有弹窗提醒你“有新版本可升级”没有点击按钮后漫长的加载。6.3 一种值得保留的“返祖”心态我不建议所有人都彻底抛弃框架也不觉得使用现代工具就是错。caveman 这条路线真正教会我的是在选择一个工具前先问它是否值得持续供奉。维护成本不取决于初始安装多大而取决于你离开一年后还能不能重新捡起来。HTML 能存三十年Markdown 也不会突然消失但某个热门构建工具五年后还在不在谁都说不准。所以如果你最近也为个人站的维护疲惫不堪可以试试用三十个文件、三个脚本和一个模板目录搭建一套自己的发布系统。不必叫它 caveman但你可以保留同样的原则内容归内容工具归工具别让工具绑架了写作本身。