资讯动态

从零实现一个极简静态站点生成器:Go语言、Markdown与零依赖的博客构建实践

发布时间:2026/10/29 12:22:29 来源:尧图企业网站定制
caveman 这个项目是我给自己写的一个静态站点生成器。名字起得很随便因为当时的想法也很原始给我一个 Markdown 目录给我一条命令输出一个干净的静态站点其他什么都不要。两个周末写完至今跑了三个多月中间没有升级过依赖没有修过安全漏洞也没有为了“更好的体验”加过哪怕一个前端框架。这篇文章就把这个项目的完整思路、关键实现、以及我在实际使用中踩到的坑一次说清楚。如果你和我一样受够了那些“开箱即用但用不起来”的重型建站工具或者只是想找一个极简方式维护自己的博客、文档站、项目说明页那 caveman 这份折腾记录应该对你有用。它不是最优雅的方案但它是真正能让我在凌晨三点写完一篇文章后还能一条命令发出去的工具。1. 为什么我会忍不住写一个叫 caveman 的静态站点生成器1.1 我原本的博客发布流程到底哪里难受先交代背景。我之前用的是一套比较流行的建站框架功能多到我可以写一整篇目录标签、分类、草稿、多语言、主题市场、插件系统、实时预览、增量构建。第一次配置完我觉得自己拥有了全世界三个月后再打开那个项目我已经忘了主题的颜色变量在哪个文件里定义。更麻烦的是升级。每隔一段时间就有新版本安全更新通知一封接一封。我不更新怕出问题更新怕出更大的问题。最讽刺的是我只想写文章结果我把大量周末时间花在“维护一个用来写文章的框架”上这完全背离了我最初建站的初衷。我当时的真实需求其实特别简单写文章文件就是 Markdown放在一个固定目录里跑一条命令旁边就生成一个静态站点部署也就是把生成的目录扔到服务器上不联网也能构建离线也能用生成的页面必须能稳定存活十年而不是依赖某个云服务。把需求列完我意识到自己需要的不是更多功能而是一个“原始人也会用”的工具。于是 caveman 就这么立项了。1.2 “原始人力气大”立项时我给自己定的四条规矩给项目起名 caveman不是为了好玩是为了把设计边界钉死。我一开始就把四条原则写进了 README 的第一行运行时零依赖构建出来的东西是单个可执行文件部署机器上不需要装 Node、Python、Ruby 或其他任何解释器。单一职责只做“Markdown 到静态页面”的转换不做评论、搜索、后台管理、在线编辑。约定大于配置默认目录结构固定能不写配置就不写配置。慢即是快不搞实时热更新不搞增量编译缓存每次构建都老老实实全量扫描、全量生成。这四条规矩最大的作用是防止我脑子一热加功能。比如某天我想给 caveman 加一个“管理后台”第四条直接把它否了。某天我想加“插件市场”第一条和第二条例直接把它否了。写工具的人最容易犯的错就是在一个小工具上不断堆功能最后把工具堆成另一个大系统。caveman 不想变成那样。1.3 为什么选 Go而不是 Python 或 Node其实我一开始用的是 Python因为写起来快。但很快发现一个问题部署到服务器上我需要确认服务器有 Python 环境还需要处理虚拟环境而一旦服务器被折腾过依赖版本经常乱七八糟。然后我考虑过 Node但“node_modules 黑洞”对我来说是另一个灾难。我只是想生成几个 HTML 文件为了这我需要把一个动辄几十 MB 的依赖目录搬到服务器上或者用一个打包工具再去处理一次这已经不符合“原始”的原则了。最后我选择了 Go原因非常实际考察点PythonNodeGo构建产物脚本文件依赖解释器脚本文件依赖 Node 运行时单个二进制文件部署依赖virtualenv / 系统包node_modules / bunlder无启动速度慢慢很快标准库能力够用但部署麻烦够用但东西太多HTML 模板、文件遍历、网络全都有学习成本低中需要一点时间适应用 Go 写出来的 caveman最终编译完只有一个文件在我服务器的 Linux 上跑起来占用内存不到 15 MB。这个体量让我觉得很安心。2. caveman 的核心模型把 Markdown 目录当作唯一事实来源2.1 目录结构约定caveman 不搞数据库不搞配置文件驱动的复杂路由所有内容都靠文件系统的目录结构表达。一个典型站点的目录长这样my-site/ content/ _index.md posts/ hello-world.md second-post.md about.md templates/ page.html post.html list.html static/ css/ style.css js/ main.js caveman.yml这里有几个固定约定content/放所有 Markdown 文件其中_index.md是首页内容子目录决定页面层级content/posts/hello-world.md会生成public/posts/hello-world/index.htmltemplates/里放 HTML 模板static/里放的任何文件都会原样复制到输出目录caveman.yml是可选站点配置不写也能构建只会用默认值。这套约定最大的好处是不需要解释。任何人打开项目看三秒目录结构就知道页面在哪、模板在哪、资源在哪。我后来用 caveman 给一个朋友的个人站点做重建他不懂技术但我跟他说“把文章按年月日那样放进去就行”他立刻明白了。2.2 文章元信息Front Matter 只留三个核心字段写文章的时候我只需要在文件开头加一小段元信息--- title: Hello Caveman date: 2025-02-01 10:30:00 tags: [markdown, blog] ---字段只有三个title是标题date是发布时间tags是可选标签。除此之外我不再支持分类、摘要、封面图、置顶、权重这些花哨字段。为什么这么抠因为我发现文字工作者真正需要的元信息就是标题和时间其他都是“编辑器侧”想出来的需求。如果非要有摘要就用 Markdown 正文的第一段如果非要有封面就在正文里放一张图。约定比功能更简单。2.3 我不写 YAML 解析器只写一个极简的 Front Matter 解析Go 标准库里没有 YAML 库引入第三方 YAML 库对一个小工具来说有点重。我的做法是写一个只能识别冒号键值对的极简解析器够用就行。func parseFrontMatter(src []byte) (map[string]string, []byte) { result : make(map[string]string) if !bytes.HasPrefix(src, []byte(---\n)) { return result, src } endIdx : bytes.Index(src[4:], []byte(\n---)) if endIdx 0 { return result, src } block : src[4 : 4endIdx] for _, line : range strings.Split(string(block), \n) { line strings.TrimSpace(line) if line { continue } pos : strings.Index(line, :) if pos 0 { continue } key : strings.TrimSpace(line[:pos]) val : strings.TrimSpace(line[pos1:]) result[key] val } rest : src[4endIdx4:] return result, rest }这段代码不处理 YAML 的嵌套对象、数组、多行字符串但它对我需要的三个字段完全够用。tags我直接在读取时按逗号拆一下就行if rawTags, ok : fm[tags]; ok { rawTags strings.TrimSpace(strings.Trim(rawTags, [])) for _, t : range strings.Split(rawTags, ,) { post.Tags append(post.Tags, strings.TrimSpace(t)) } }我在最初实现时花 20 分钟写了这个解析器后面三个多月里一次没改过。这就是“原始”带来的好处。3. 从零实现解析、渲染、静态资源三条主线的关键细节3.1 Markdown 解析器自己写还是用依赖Go 标准库不提供 Markdown 转 HTML 的能力所以这里面临一个选择自己写一个 Markdown 解析器还是引入第三方库。我一开始真的自己写了一个简陋解析器支持标题、段落、列表、粗体、斜体、代码块。结果刚写完就发现真正的文章里有太多边缘情况嵌套列表、链接、图片、行内代码、引用块、表格。这些东西自己维护起来是个无底洞。最后我的选择是引入一个够用的 Markdown 解析库。目前用github.com/gomarkdown/markdown它在构建期依赖里但编译进二进制后运行时依然零依赖。这也是一个取舍我不需要从零造 Markdown 解析器但我也不愿意因为这个把一堆 node_modules 塞进部署环境。调用它的核心代码很短func renderMarkdown(src []byte) ([]byte, error) { out : markdown.ToHTML(src, nil, nil) return out, nil }3.2 页面装配用标准库模板不引入任何模板引擎caveman 的模板部分直接用 Go 标准库的html/template。这样做的原因很朴素标准库模板能力足够渲染快而且构建产物不会再有额外的依赖。我的模板设计分成三类list.html用来渲染首页和目录页会循环展示文章列表post.html用来渲染单篇文章page.html用来渲染普通页面比如 about。数据流是先从content/目录里扫描所有 Markdown 文件解析完 Front Matter 后生成一个文章列表然后把这个列表注入模板。例如首页模板的核心循环长这样{{ range .Pages }} article h2a href{{ .URL }}{{ .Title }}/a/h2 time datetime{{ .Date }}{{ .DateDisplay }}/time div classexcerpt{{ .Excerpt }}/div /article {{ end }}在 Go 代码里模板装载没有绕弯子直接读取模板目录里的文件tmpl, err : template.ParseFiles( filepath.Join(cfg.TemplateDir, page.html), filepath.Join(cfg.TemplateDir, post.html), filepath.Join(cfg.TemplateDir, list.html), )如果模板文件不存在caveman 会在构建时直接报错而不是生成一个空白页面。这个设计让我很省心因为“模板写错了”通常在渲染器里只是静默失败最终线上看到一个残缺页面排查半天才发现是模板少了个结束标签。3.3 静态资源不处理、不压缩、不打包静态资源这一块是 caveman 最“原始”的部分直接把static/目录原封不动复制到public/static/。没有任何 CSS 压缩、JS 打包、图片压缩、指纹文件名。为什么不优化因为我的个人站点全部静态资源加起来不到 1.8 MB其中大部分是图片。为这 1.8 MB 引入一套资源构建流水线完全不划算。如果你真的在乎性能最有效的优化是先把图片压缩一遍而不是在生成器里加一堆“工程化功能”。复制代码也非常简单func copyStatic(srcDir, destDir string) error { return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { if err ! nil { return err } rel, _ : filepath.Rel(srcDir, path) dest : filepath.Join(destDir, rel) if d.IsDir() { return os.MkdirAll(dest, 0o755) } data, err : os.ReadFile(path) if err ! nil { return err } return os.WriteFile(dest, data, 0o644) }) }这个函数 20 行不到但解决了整个站点“样式和脚本从哪来”的问题。3.4 构建顺序全量清理后生成caveman 的每一次构建都不是增量构建而是先删除旧输出目录再全量生成。这是故意为之。原因有两个。第一增量构建可以快但需要记录文件指纹和缓存状态复杂度会指数上升第二删除旧目录可以保证线上不会出现“上一篇博客已经删了但 public 里还残留旧页面”的问题。所以构建主流程是这样的读取站点配置 - 扫描 content 目录 - 解析每篇文章 - 按日期排序 - 渲染所有页面 - 复制静态资源 - 全量写入 public因为 Markdown 文件数量通常不超过几千个这个全量流程在我的机器上大概 0.3 秒完成。一个 0.3 秒能跑完的流程我完全不需要增量构建。4. 我在真实博客站点上跑了三个月后踩到的坑4.1 中英文混排的换行和空格问题caveman 用的 Markdown 渲染库本身没问题但我在真实文章里发现一个很致命的排版问题中文和英文混排时如果英文单词紧贴中文字符渲染出来会显得拥挤。比如“这是一个 caveman 项目”和“这是一个caveman项目”看起来完全是两种排版。我发现问题后不是在 Markdown 解析器里改而是在 HTML 输出后加了一层后处理在所有亚洲文字和 ASCII 字母的边界处插入一个细的间隔。这里有一个大坑一定不能在全文本上做后处理否则代码块里的代码会被插入空格。我的方案是先把 HTML 里pre和code部分保护起来替换成占位符处理完再还原回来。这个处理逻辑大概 40 行但解决了一个很实际的问题。4.2 绝对链接与相对链接的取舍静态站点部署到服务器子路径时链接问题很容易炸。caveman 一开始默认生成的链接是相对链接比如/posts/hello-world/。但我的博客实际部署在https://example.com/blog/这个子路径下面直接导致所有绝对链接都指向了站点根目录页面全部 404。这个问题让我明白了一个道理静态生成器不能替用户猜链接必须把“站点根路径”交给用户配置。我在caveman.yml里加了一个baseUrl字段模板里所有链接都通过它拼出来。func href(raw string) string { if strings.HasPrefix(raw, http://) || strings.HasPrefix(raw, https://) { return raw } return cfg.BaseURL / strings.TrimPrefix(raw, /) }我最初以为自己是聪明人可以在生成时自动判断后来发现判断逻辑永远在边缘条件上出错。最后干脆退回到最原始的方式用户在配置里写清楚根路径程序只负责拼接。这又是一个“越原始越可靠”的例子。4.3 日期显示绝对时间排序本地时间渲染写文章的人经常会忽视一个问题date字段用什么时区。我用2025-02-01 10:30:00这种格式在上海时区是上午十点半但如果服务器上时区设置成 UTC发布时间排序和显示都会错位。我的方案是解析时把这个时间当成 UTC 时间存在结构体里用于排序渲染显示时再按照站点配置里的timezone转成需要的时区。两套逻辑分开绝不混用。post.SortTime t.UTC() post.DisplayTime t.In(cfg.Timezone).Format(2006-01-02)这个坑在本地开发时根本发现不了因为开发机器和服务器通常时区不一样。我是在发布第三篇文章时发现服务器首页显示的文章日期比实际日期晚了一天才排查清楚。4.4 Git Hook 配合部署一条命令发布全站部署流程我简化到极致只用了一个 shell 脚本#!/usr/bin/env bash set -euo pipefail caveman build rsync -az --delete public/ deployserver:/var/www/html/ curl -fsS https://example.com/ping /dev/null echo deploy donersync的--delete参数会把我本地删除的文件同步在服务器上删除保证线上永远是 public 目录的完全镜像。这里有第二个隐藏坑如果你在服务器上手工放了一些文件比如临时维护页、下载包那么一次--delete就会把它们全删掉。我的解决办法很简单凡是需要手工保留的文件统一放在站点根目录外比如/var/www/manual/永远不放进发布目录。这样--delete就永远不误伤。这个部署流程没有任何 CI CD、没有 Docker、没有 Kubernetes、没有 Webhook。三个多月里它一次也没有让我失望过。真正的“稳”有时候就是把流程变短让每一步都能被一个普通人类理解。5. 如果一开始就这么设计我会省下哪些事5.1 主题系统应该早点用一个 embed.FS 纳入二进制caveman 的第一个版本模板文件是“坐在 public 旁边”的目录每次部署都要手动把模板目录同步到服务器或者留一份在服务器上。后来我发现这个设计很别扭如果我在一台新电脑上克隆仓库重新构建模板少了一个文件整个生成器就罢工。正确做法应该是在 Go 里用embed.FS把默认模板直接嵌入到二进制文件里。这样发布时只有一个caveman文件模板不会丢。我后来改成了这样//go:embed templates/* var templateFS embed.FS用户自定义模板仍然可以从外部目录加载但也允许“不携带任何文件就能构建”。这个改动大幅提升了命令行工具的便携性。5.2 不要把站点配置和构建参数混在一起caveman 早期只有命令行参数比如--output public --source content。后来又加了配置文件caveman.yml。两个来源一旦冲突处理逻辑就开始混乱。我现在定了一条死规则配置文件只放站点元信息和输出路径命令行参数只用来覆盖“这次构建”的临时行为绝不新增“只在命令行里存在”的配置项。如果你发现自己有 10 个以上的命令行参数说明你还没想清楚工具的边界。5.3 给工具定一个“停止加功能”的规则这是我踩了无数次坑总结出来的。每个人的个人项目都容易陷入“再快一点、再多一点”的修修补补。我给 caveman 定的停止规则是任何新功能如果不能让项目删除至少 50 行旧代码并且不能让部署流程变得更短就拒绝它。这条规则后来帮我挡掉了三分之二的“灵感”。比如有人建议给 caveman 加一个内置 HTTP 预览服务器我对照规则后拒绝了因为脚本外面已经可以用python3 -m http.server实现不值得内嵌。有人建议加文章搜索功能我也拒绝了因为当前文章量用浏览器自带的页面内查找就够用了。5.4 后续可以扩展但我不打算做的方向依然有几个方向我很肯定会有用只是它们的复杂度会破坏 caveman 的“原始感”RSS 自动生成这个还算轻量未来大概率会加站点地图自动生成也有价值但还需要考虑 filtering 规则文章内搜索索引生成会让输出目录多一套 JSON我觉得不划算可视化编辑器完全违背 caveman 的初衷绝对不做。个人项目最难的从来不是“实现功能”而是知道哪些功能不该实现。caveman 现在保持在一个很小的状态我维护它需要做的事情几乎只有两件写文章跑caveman build。最后再分享一个小技巧如果你也想写一个自己的静态站点生成器别急着在开头把目录结构、模板系统、配置体系都设计完。先把“一篇 Markdown 变成一页 HTML”这条最小路径跑通然后立刻部署到你服务器上用真实的写作去找问题。caveman 之所以没烂尾就是因为它从第一个可用版本开始就在我喜欢用的真实场景里跑着而不是躺在某个待办清单上被无限重构。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑