如果你也受够了动辄上百兆 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 不该去解析 XML,Python 不需要控制整个构建流程。
2.3 发布层:rsync 同步到任意服务器
caveman 最终生成的public/就是一个完整的静态网站。所谓“发布”,就是把这整个目录同步到服务器上的网站根目录,我用的是 rsync 一行命令:
rsync -avz --delete public/ user@your-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', recursive=True) 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/ user@your-server:/var/www/caveman/build.sh的逻辑也不复杂:清空public、建目录、复制静态文件、渲染文章页、生成列表页。我不会让单个脚本超过 150 行,一旦要变复杂,就拆成多个脚本。
3.2 模板渲染不用模板引擎
做静态站必然要面对“怎么把内容填进 HTML”的问题。有人会引入 Jinja2 或 EJS,但 caveman 的原则是不装依赖,所以我用 Python 标准库里的string.Template,它能把字符串里的$变量替换成实际内容。
一个最简单的基础模板长这样:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>$title</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <main class="container"> $content </main> </body> </html>Python 渲染代码也很短:
from string import Template import pathlib template_path = pathlib.Path('templates/article.html') page_tpl = Template(template_path.read_text(encoding='utf-8')) html = page_tpl.safe_substitute(title='这篇文章的标题', content='<p>渲染后的正文</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-style=pygments -o article_raw.html用--highlight-style=pygments生成带语法高亮 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 class="kw">def</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,但你可以保留同样的原则:内容归内容,工具归工具,别让工具绑架了写作本身。