最近在维护一个叫 Caveman 的个人项目名字取自“穴居人”意思是主动退回最原始的工具状态去写东西。做这个项目的起因很朴素我只是想写点技术笔记和日常记录却发现自己被困在一堆前端工具链里出不来了。装 Node.js、拉依赖、配打包器、调热更新一整套流水线下来正文一个字还没写环境先折腾了两小时。更荒诞的是工具的升级速度比我的写作速度还快每次打开项目总有几个依赖在催我更新。所以我写了 Caveman一个把静态博客生成器剥离到只剩骨架的命令行小工具核心就一个 Python 脚本不依赖任何第三方库不吃 Node.js不碰打包器。它的工作方式跟穴居人差不多——拾柴、生火、烤肉直接、原始、可靠。你只需要一堆 Markdown 文件跑一条命令就能得到一个完整的静态站点。它解决的是内容创作者的“工具绑架”问题当写作环境比写作本身还复杂时人就会失去表达的欲望。Caveman 想把表达的门槛降到接近于零。这篇文章不是项目文档的搬运而是把整个项目的来龙去脉、设计取舍、核心代码逻辑、实测踩坑全部摊开。适合两类人看一类是像我一样觉得 Hexo、VuePress 太重的个人博客维护者另一类是刚接触静态站点生成想搞清楚 Markdown 到 HTML 到底发生了什么的新手。读完你不仅能复刻一个极简版博客系统还能理解那些大而全框架到底在替你做什么——以及哪些事情其实你根本不需要它们替你做。1. 为什么想做这个“返祖”项目1.1 现代前端工具链的“过度文明”我做过几年业务系统对 JavaScript 生态不陌生。但当我想给自己搭一个个人网站时那种“杀鸡用牛刀”的荒诞感特别强烈。一个小博客内容无非是标题、段落、代码块、图片、链接解析逻辑几百行顶天。可主流的做法是什么初始化一个 Hexo 站点node_modules 体积轻松超过 200MB依赖树拉出来几十层。再走一遍 VuePress 或 Hugo 的流程虽然 Hugo 是编译型二进制体感稍好但主题定制又逼着你学 Go template 的语法。不是说这些工具不好而是它们面向的是完全不同的人群。它们假设你的站点需要插件系统、多语言、SEO 自动优化、搜索、评论、统计、CDN 加速但我的真实需求只是把我的想法变成几张页面能在手机和电脑上顺畅阅读加载足够快不需要我反复修依赖。我试过删掉那些框架回归手写 HTML写了两篇就发现一个问题当页面结构一有调整所有文章都得跟着改内容与表现完全没有分离维护成本同样不可接受。我需要的是介于“裸手写 HTML”和“重型站点生成器”之间的东西用 Markdown 写内容用最简单的方式把它变成页面。1.2 Caveman 的设计哲学能不用就不用“Caveman”这个名字起初有点自嘲后来我越用越觉得贴切。穴居人没有复杂的工具链但他们活下来了我的博客也不需要复杂的框架但它得稳定。整个项目我只给自己定了三条规矩。第一条单文件可分发。整个生成器就是一个caveman.py你可以把它当作文书处理工具一样复制到任何一台装有 Python 的机器上不需要pip install不需要配置虚拟环境。这里的底气来自一个事实Python 标准库足够吃下整个需求——pathlib处理文件路径re做正则解析datetime处理日期html模块负责转义。没有哪一步需要第三方介入。第二条零运行时依赖。生成的每一个 HTML 文件都是纯静态的CSS 内联或用相对路径引用不加载任何 CDN 上的 JavaScript不引入在线字体不嵌入跟踪脚本。这样做的好处是站点部署到任何环境——Nginx、GitHub Pages、一个树莓派——都不会出现资源加载失败。你打开页面不依赖任何第三方服务的存活状态。第三条纯静态输出。不用数据库不用服务端渲染接口所有内容在构建那一刻就固化成文件。这带来一个隐藏价值可移植性。整个站点就是一个文件夹拿到哪都能跑。哪天我不想要 Caveman 了Markdown 源文件还在换工具分分钟迁移这也提醒我永远不要把内容格式和工具深度绑定。这三条原则背后其实是一句话工具的存在感越弱内容的存在感越强。当一个写作工具复杂到需要你学习如何使用它时它就已经从帮手变成了门槛。2. 工具选型像穴居人挑石头一样挑工具2.1 为什么不用主流的静态站点生成器做这个项目前我把主流方案都过了一遍。Hexo 和 VuePress 直接出局原因上面说过Node 依赖太重。如果你只是偶尔写几篇随笔每次写完npm install都要面对几百 MB 的磁盘占用这种维护成本容易消磨写作热情。Hugo 单二进制很优雅性能也极佳构建 1000 篇文章几乎瞬间完成但它的模板语法学习曲线对你来说未必值得。Jekyll 是 GitHub Pages 的亲儿子部署很顺但 Ruby 环境在国内网络条件下的安装体验我只能说谁试谁知道。你可能会说这些工具的差距无非是习惯问题用熟了也一样。是的它们都能完成工作但有一个问题被它们的设计目标遮蔽了这些工具为了覆盖足够多的用户都内置了大量你可能一辈子用不上的功能。配置文件的层级越来越深主题系统的变量命名越来越抽象。每次想加一个小功能你要么翻文档找到那个被神奇地命名成xxx_custom_xxx的配置项要么就得写插件去挂钩子。这一切对“专注写作”来说是噪音。而有趣的是Caveman 的代码规模不到 500 行却已经满足了我在写博客这件事上百分之九十的需求。2.2 最终技术栈与目录结构我最终选择的技术栈老实说像“石器时代”的配置Python 标准库加不超过一百行的自写 Markdown 子集解析器。为什么不用现成的 Python-Markdown 库因为我想保持“零依赖”这条底线同时想向读者证明一个够用的 Markdown 解析器本身没那么神秘。当然我也在项目 README 里写了如果你的文章结构特别复杂表格、脚注、数学公式都要那你应该去用真正的 Markdown 库或换主流的生成器。Caveman 的目标是极简不是万能。项目目录结构长这样caveman/ ├── caveman.py ├── content/ │ ├── hello-caveman.md │ └── second-post.md ├── templates/ │ └── default.html └── output/ ├── index.html ├── hello-caveman.html └── second-post.htmlcontent/是写作区放 Markdown 文件文件顶部用简单的key: value格式写 Frontmatter标题、日期、分类解析器逐行读取后把元数据和正文拆开。templates/只有一个 HTML 模板所有页面共用通过占位符注入不同内容。output/是产品目录每次构建先清空再生成。2.3 关于“内容组织方式”的思考为什么文章一定要先写元数据因为静态站点生成器的本质是“将无序的内容整理成有序的索引”。没有标题和日期首页就不知道如何排列文章列表文件系统的时间戳又不可靠。但我不想引入 YAML 解析库Frontmatter 本身并不复杂在---分隔线之间用:切分键值就够了。对个人博客来说标题、日期这五个字段的元数据基本满足索引和归档需求没必要上完整的 TOML/YAML 配置体系。这种“少即精确”的思路贯穿了整个项目设计也为后面解析碰到了若干边界情况埋下了伏笔这些坑我会在第五节专门列出。3. 核心实现从 Markdown 到静态页面3.1 入口脚本设计与命令Caveman 的入口做得跟任何命令行工具一样简单两个子命令build和serve。执行python caveman.py build会扫描content/目录所有.md文件逐一解析后生成 HTML 到output/并额外生成首页的文章列表。python caveman.py serve则是在本机启动一个 8000 端口的 HTTP 服务方便预览这一功能几乎是白送的Python 标准库的http.server模块五行代码就能搞定。入口部分的参数解析我没有用argparse吗用了。完整的参数包括--input指定内容目录、--output指定输出目录、--serve-port指定端口号但默认值在大多数情况下就够了。核心的流程用伪代码描述就是读取 Frontmatter解析正文到 HTML渲染模板写文件。这里有个细节值得新手注意所有的页面生成顺序不能乱先处理所有文章得到完整元数据再生成首页的文章列表否则首页不知道最新文章是哪几篇。3.2 最小可用的 Markdown 子集解析器这是整个项目里最敏感、也最有趣的模块。我明确在代码注释里写了“我实现的只是 Markdown 的一个子集不要试图让它支持一切。”解析器的核心是逐行扫描根据行首字符决定当前块级元素的类型。支持的元素包括以#开头的一到六级标题以-或*开头的无序列表以1.开头的有序列表以开头的引用块以三个反引号包裹的代码块普通段落内联元素支持加粗、行内代码以及[文本](链接)形式的链接。解析器的执行分两步第一步先扫描整个行序列把孩子块拆出来代码块优先处理避免内部的#和被误判第二步对每个文本块调用内联解析用正则替换加粗、代码和链接。下面这段代码是代码块识别和处理的简化版本def parse_blocks(lines): blocks [] i 0 while i len(lines): line lines[i].rstrip(\n) if line.strip().startswith(): code_lines [] i 1 while i len(lines) and not lines[i].strip().startswith(): code_lines.append(lines[i].rstrip(\n)) i 1 blocks.append((precode, escape_html(\n.join(code_lines)) /code/pre)) i 1 continue if line.startswith(## ): # 注意先判断二级标题避免被一级截胡 blocks.append((h2, inline_parse(line[3:]) /h2)) elif line.startswith(# ): blocks.append((h1, inline_parse(line[2:]) /h1)) elif line.startswith(- ): blocks.append((ulli, inline_parse(line[2:]) /li/ul)) # 篇幅原因省略其他块级分支 else: blocks.append((p, inline_parse(line) /p)) i 1 return blocks注意代码块里的escape_html这个细节非常重要。我踩过一个坑最初没对代码内容做 HTML 转义结果文章里写了一个div标签示例渲染出来直接破坏了整个页面的结构。Markdown 解析器在把文本变成 HTML 之前必须把转成lt;把转成gt;否则你写的内容会被浏览器当成真实的标签去解析。这是一条安全底线也是我建议所有自制解析器默认开启的行为。3.3 模板渲染与页面生成模板文件default.html的写法非常直白!DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title{title}/title link relstylesheet hrefstyle.css /head body headera hrefindex.htmlCaveman/a/header article{content}/article footer{date} · {category}/footer /body /html渲染的过程就是字符串替换用str.replace把{title}、{content}、{date}、{category}这四个占位符一一替换成具体内容然后把替换完的字符串写入output/下的独立文件。这种方式效率不是最高的但胜在零学习成本模板里出现任何变量名你一眼就能看穿它的逻辑。这里提醒一个容易出错的地方占位符最好不要和 HTML 中的花括号冲突。如果你的文章正文里出现了{title}这四个字符在替换完内容之后你再做一次全局替换就会把文章里的字符串也换掉。保险的做法是先替换正文中的内容再替换元数据字段或者在用content替换时先把这部分的占位符换成一个冷门标记最后再还原。首页的列表生成相对简单构造一个ul遍历所有文章的元数据按日期倒序排列把标题和日期渲染成带链接的列表项。写到这里我发现日期比较在纯字符串环境下是可行的只要你在 Frontmatter 里统一用YYYY-MM-DD格式字符串的字典序就是时间顺序这条经验在处理任何轻量项目时都成立。3.4 路径处理与中文文件名的坑文章源文件名我建议直接用英文 slug比如hello-caveman.md而不是第一篇文章.md。你也许觉得中文文件名直观但生成的 HTML 文件名和链接里的 URL 编码会特别难看而且某些老旧的 Web 服务器对非 ASCII 文件名的处理姿势千奇百怪。我在项目里做了兼容处理如果源文件名为中文生成的 HTML 会以统一格式重命名。但后来发现这种重命名会带来另一个麻烦——目录列表里显示的是新名字找源文件还得扫一遍。最终我放弃了这个兼容倒逼自己用英文 slug。写作时标题在 Frontmatter 里存中文文件名用英文两边各司其职体验反而干净。这个决策也让我重新思考了工具的取舍。Caveman 的解析器应该保持简单复杂的情况交给用户调整写作习惯。工具毕竟是工具不应为了适应特殊的输入而让自己变得臃肿。4. 实战记录用 Caveman 搭一个个人站4.1 初始化项目结构拿我自己的站点举例。我先创建了 Caveman 的目录复制caveman.py进去再手动建好content、templates、output三个文件夹。templates/default.html是上节那张模板另外我加了一个style.css也放在根目录构建时直接复制到output/下。这一步在生成器里对应一行shutil.copy2它和构建逻辑无关但很多人第一次搭建时会忘记然后发现页面 HTML 好了却没有任何样式。初始化完成后我执行了python caveman.py build看看空目录的构建结果。预期是output/里只有一个首页但实际测试下来我却发现自己的代码在处理“没有任何文章”的状态时报了除零错误——因为文章列表为空时日期排序代码访问了空列表的第一个元素。这是我写代码时没考虑到的边界情况后来加了一个判断若文章列表为空首页就显示“还没有文章”。这种空状态处理虽是小事但对一个面向写作者的工具体验影响很大。4.2 写第一篇文章并观察生成效果我在content/下新建welcome.mdFrontmatter 如下--- title: 欢迎来到穴居人的世界 date: 2026-01-01 category: 随笔 ---正文写了几个段落包含一个二级标题、一个列表和一段代码块。然后跑python caveman.py build。构建日志打印出每个文件的生成路径终于看到output/welcome.html出现时那种感觉和第一次用烤箱烤出面包类似工具虽然简单产出却是完整可用的。我用浏览器打开output/welcome.html页面布局正常标题、日期、正文都在预期位置代码块有独立背景色。此时离我动手写下第一行代码大约只过了一个小时。这个流程让我直观感受到了静态站点生成器的核心价值内容与展示分离但构建产物是纯文件。我写这篇博文的 Markdown 时脑子里完全不需要去想页面长什么样因为模板已经定死了视觉。而读者在浏览器里看到的是干净的 HTML他们也不会关心你用什么工具生成因为打开速度足够快加载时间接近零。4.3 本地预览与部署上线本地预览直接用python caveman.py serve它会调用http.server绑定 8000 端口默认在/output目录下提供静态文件服务。打开浏览器访问http://localhost:8000就能看到站点。这里的细节是serve命令应该阻塞不能立即退出所以实现上要在启动 HTTP 服务后调用serve_forever()并在命令行提示用户按CtrlC停止。部署上线我选了最简单的方案——Nginx 静态站点。把output/整个目录上传到服务器Nginx 配置里指一个root /var/www/caveman;一个location / {}块就完了。整个过程不需要任何后端进程也不需要担心 Node 服务挂了要守护进程重启。如果你不想买服务器把output/目录推到 GitHub Pages 仓库里一样能跑本质上这些平台都是静态文件托管对输出文件没有任何额外要求。4.4 执行效果与生成的文件一次完整构建后output/下通常有这些文件index.html文章列表、每个文章对应的 HTML、style.css。我数过三篇文章的事件站点全部体积加起来不到 30KB而同等内容的 Hexo 站点光 node_modules 就不止 300MB。这不是贬低重型框架而是当你真正回归到“发布一个网页只需要 30KB”这个事实时会重新审视之前那些被默认接受的开销是否真的必要。我更想强调的是可迁移性。每次构建完的output/都是一个自洽的站点文件夹我可以把它压缩成一个 zip 发给你你解压后用浏览器打开index.html就能阅读。没有数据库要导入没有环境变量要配置没有 Session Token 要刷。这种搬运的便利性在工具链复杂的项目里几乎不可能实现。5. 常见问题与排查技巧实录5.1 Frontmatter 解析的边界情况我最早实现 Frontmatter 解析时用了一个“如果文件前两行是---就把前两块分隔线之间的内容当作元数据”的逻辑。这个逻辑在正常情况下没问题但有一个隐蔽的坑如果文章正文一开始就用了---作为横线分隔且它恰好出现在文件前几行Frontmatter 就会把半个正文吞进去。排查的方式是打印预处理后的元数据一旦发现title字段里出现了大段文字基本就是这里出了问题。另一个问题是字段值的冒号。Frontmatter 的标准格式是key: value但如果你写的值是带冒号的字符串比如category: 日常: 工作日志简单的line.split(:)会把后面的部分也切开。我的处理是在切分时指定split(:, 1)只切第一个冒号。这个技巧在处理任何键值对格式时都通用你可以直接抄。5.2 代码块嵌套与正则失灵的排查最让我头疼的一类问题出在内联解析的正则上。我最初用了一个正则同时识别加粗和链接结果在文本[点击这里](https://example.com)里的括号也被加粗匹配干扰了。这是我调试最久的一个坑最后发现问题的根源是正则在匹配之前没有把代码块内容排除掉。修正之后我改变了处理顺序先整体扫描代码块把其中内容用标记替换成占位符等所有内联处理完之后再换回来。这样一来代码块内部不管出现什么特殊符号都不会被误解析。如果你在自建解析器时遇到类似问题放弃一个万能正则先做块级隔离是更健康的思路。5.3 部署后样式丢失的问题我把站点部署到子路径时遇到过样式完全丢失的情况。原因是我在模板里写了link relstylesheet hrefstyle.css使用的是相对路径。首页index.html在根目录没问题但文章页如果放在posts/子目录下style.css的路径就变成了posts/style.css自然找不到。解决方案有两个要么所有页面统一放到根目录不做子目录要么在生成时根据当前文档层级自动计算相对路径。我在 Caveman 里选择的是后者用一个depth变量页面在子目录时自动加上../前缀。5.4 常见问题速查表问题现象可能原因处理方法首页没有文章列表文章解析失败检查 Frontmatter 是否闭合无---结尾时正文未被识别代码块内的符号被转义错解析顺序错误先隔离代码块再解析内联格式生成的 HTML 中文乱码编码未指定读写文件时统一加encodingutf-8样式在某些页面丢失相对路径层级错误根据页面深度计算../前缀日期排序混乱日期格式不统一强制使用YYYY-MM-DD格式特殊字符渲染异常未做 HTML 转义使用html.escape处理所有文本5.5 一些写在源码里的经验最后分享两个写工具时学到的通用经验。第一不要相信用户会按照你的预设提供输入。日期格式不统一、Frontmatter 缺字段、文件名带空格这些情况你早晚会遇到。与其在文档里苦口婆心劝用户规范输入不如在解析时多写几个条件判断并在遇错时打印明确的错误信息告诉用户哪一行导致了什么问题。第二小工具也要写测试。我给 Caveman 添加了一个简单的测试脚本用几组预置的 Markdown 文件跑一遍比对输出 HTML 是否符合预期。没有这套测试后续的修改几乎必然引入回归而你会毫无察觉。结语的一点个人体会这个项目让我悟出一个道理技术选择经常不是在“好”与“坏”之间做取舍而是在“复杂”与“合适”之间做取舍。Jekyll、Hugo、Hexo 都是优秀的工具但 Caveman 的定位从来不是替代它们而是替代我内心对“社交化写作”和“平台绑定”的不适应。我现在每写一篇文章只需要打开 Markdown 文件、敲字、保存、跑一条命令一个干净的页面就躺在output/里等我了。如果你也想试试这条路我建议你复刻一个阉割版的 Caveman就实现标题、段落、代码块、链接四个解析已经能满足九成写作场景。在做这个项目的过程中你对解析器的认识会从“黑盒咒语”变成“几张正则和循环”这种感觉非常上瘾。事实上我正是在实现过程中意识到很多时候工具的复杂度不是因为需求复杂而是因为没有人停下来问一句我真的需要这么复杂吗