☰
极简静态站点生成器caveman:用纯shell脚本摆脱前端工具链
2026/10/6 5:20:58 网站建设 项目流程

做了这么多年前端和内容站,我一直有个毛病:见到一个项目,总想先把框架选好、依赖装齐、管线配齐,然后才肯动手写第一行代码。结果往往是框架升级循环、依赖冲突排查、构建脚本修修补补,真正花在内容本身上的时间反而没多少。

这个叫“caveman”的东西,就是冲着我这个毛病来的。它是我最近一段时间在业余项目里折腾出来的一个极简静态站点发布工具——名字直白得很,就是“穴居人”,意思是我要回到用石头和火把就能把东西做出来的年代。没有数据库,没有构建链,没有框架运行时,甚至连命令行工具都只依赖系统自带的那几个命令。整站从 Markdown 文件到可访问的 HTML 页面,只有一条不到 200 行的纯 shell 脚本在起作用。

这篇文章想把整个项目的来龙去脉、核心设计、踩过的问题、以及我在这个过程中想明白的一些事情,完整地梳理一遍。如果你是一个被现代前端工具链折腾到心累的人,或者你只是想在“工具爆炸”的时代里重新找回一件事的掌控感,那这篇东西应该对你有用。我会把关键脚本逻辑、目录结构、部署方式都摆出来,保证你照着能自己搭一套。

1. “史前工具链”的由来:为什么要用最笨的办法做网站

1.1 被现代工具链反噬的日常

说起来有点讽刺,我是个写代码吃饭的人,但最近一年多,我对“高效”这件事的理解发生了不小的变化。

以前做一个小型内容站,我的标准动作是这样:初始化一个 React 或 Vue 项目,装 Tailwind 写样式,弄一套组件体系,再配好构建、压缩、热更新、路由、SEO 插件……光这些准备工作,没个一两天根本下不来。等终于可以写正文了,又要面对“为什么这个组件在 Safari 里样式错位”“为什么打包后首页白屏”“为什么这些依赖之间有冲突”这些事。

你发现没有,这些事跟内容本身有关系吗?半毛钱关系都没有,但它们吃掉了我绝大部分精力。

我开始反思:一个只需要发布十几篇文章、目标是“够快、够稳、够简单”的个人站点,真的需要吃掉几百兆的 node_modules 吗?真的需要每次改动都跑一遍完整构建吗?真的需要一套每次发版都可能变化的抽象吗?

答案显然是否定的。

1.2 “caveman”的设计哲学:能不用工具就不用工具

所谓 caveman 哲学,落实到具体行动上就是三条:

第一,凡是操作系统自带能力能解决的,就不引入第三方依赖。sed 能做的文本处理,不写 Node 脚本;find 能做的文件遍历,不引入遍历库;标准 Markdown 渲染能解决的问题,不引入新框架。

第二,凡是一次性生成后不再变化的东西,就不在运行时重复计算。网站里每一篇文章都是纯静态输出,访问者拿到的就是已经渲染好的 HTML,服务器不需要做任何动态拼装。

第三,凡是能用文件系统和命名约定解决的问题,就不引入数据库。目录路径就是 URL 路径,文件名就是标题和日期,frontmatter 里的字段就是元数据。

这三条原则放在一起,最终的形态就是一个极其“原始”的发布系统——你用 Markdown 写东西,把它扔进一个目录,然后跑一下脚本,整个站点就更新好了。没有 watch、没有 dev server、没有热更新,有的是稳和简单。

1.3 它解决的到底是什么问题

我要老实说,caveman 不适合所有人、所有场景。它解决的是“个人内容发布”这个特定问题。尤其是当你遇到下面这些情况时,它真的能帮你省掉一大坨心:

  • 你想把更多时间花在“写”而不是“搭建”上
  • 你希望能随时迁移、随时备份,不担心数据库崩溃或被平台绑架
  • 你的内容发布频率不高,几十篇到几百篇规模
  • 你希望部署在任何便宜到几乎免费的主机上,不需要高配置
  • 你喜欢“改动可追踪、发布可预测”的感觉

在这个区间内,caveman 的性价比高得吓人。一旦超出这个区间,比如你要做电商、做复杂交互、做个性化推荐,那我还是建议老老实实用现代工具,别为难自己,也别为难“穴居人”。

2. 核心设计拆解:文件即路由,shell 即构建器

2.1 目录结构与 URL 的一一映射

caveman 的核心逻辑非常朴素,核心只有一条:目录结构就是站点结构,Markdown 文件就是网页内容。

我把整站放在这样的目录结构下:

site/ ├── content/ │ ├── index.md # 首页 │ ├── about.md # 关于页 │ └── posts/ │ ├── 2025-01-20-hello-caveman.md │ └── 2025-02-14-simple-shell-tricks.md ├── templates/ │ ├── page.html # 普通页面模板 │ └── post.html # 文章页模板 ├── assets/ │ ├── style.css │ └── favicon.ico ├── build.sh # 核心构建脚本,全站唯一的“应用程序” └── public/ # 构建输出目录

这中间有个很关键的映射关系:content/posts/xxx.md对应的就是部署后的https://你的域名/posts/xxx.html。为什么不是xxx/index.html?因为我的目标是让站点在任意静态托管环境下都能工作,.html后缀是最保守、兼容性最好的方式,不需要服务器做路径重写。

2.2 那个只有一百多行的 build.sh

这是全项目最核心也最“不上台面”的文件。我可以把主要逻辑贴出来,去掉注释可能不到 150 行:

#!/usr/bin/env bash set -euo pipefail SITE_ROOT="$(cd "$(dirname "$0")" && pwd)" CONTENT_DIR="$SITE_ROOT/content" TEMPLATE_DIR="$SITE_ROOT/templates" OUTPUT_DIR="$SITE_ROOT/public" # 清空旧的输出目录,避免出现“旧文件残留” rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR/posts" # 用 sed 抽取出 frontmatter 和正文 parse_markdown() { local input="$1" local title local date local body title="$(sed -n 's/^title: "\(.*\)"/\1/p' "$input" | head -1)" date="$(sed -n 's/^date: "\(.*\)"/\1/p' "$input" | head -1)" body="$(sed '1,/^---$/d' "$input")" # 这里省略了 markdown 渲染的实现细节 # 实际使用中可以用 perl 写一个极简渲染器,或直接调用 cmark rendered_body="$(render_markdown "$body")" # 用模板拼出最终 HTML sed -e "s|{{TITLE}}|$title|g" \ -e "s|{{DATE}}|$date|g" \ -e "s|{{BODY}}|$rendered_body|g" \ "$TEMPLATE_DIR/post.html" > "$OUTPUT_DIR/posts/${date}-${title}.html" } # 遍历 content 目录,对每个 md 文件执行转换 for file in "$CONTENT_DIR"/posts/*.md; do parse_markdown "$file" done echo "Build complete: $OUTPUT_DIR"

说实话,这个脚本写得很“糙”,但它完整地表达了 caveman 的思路:不搞抽象,不搞插件体系,不搞生命周期钩子,就是最直白的“遍历 + 转换 + 写出”。

有人可能会说:这算什么项目,这不是拿 shell 拼凑玩具吗?但我的观点是:工具的价值不在于技术复杂度,而在于它是否可靠地帮你完成本来要做的事。这个脚本虽然用“老办法”,但对一个内容型小站来说,它跑得稳、看得懂、改起来也快,这就是最大的优点。

2.3 Markdown 渲染的“不完美方案”

要在纯 shell 环境里渲染 Markdown,你猜我会怎么做?我试过用 sed 写正则替换,结果维护起来非常痛,最后放弃了;我也试过用 Python 的 markdown 库,但这等于引入了一个运行时依赖,违背了“原始”的初衷。

最终我做了一个折中:如果系统里恰好装了 cmark,就直接调用 cmark 转换;如果没装,就用一个超级简化版的 sed 渲染器兜底。cmark 是 CommonMark 官方的 C 实现,很多 Linux 发行版自带,或者一条 apt/brew 就能装好。这个做法很务实——在大多数情况下它能给出标准结果,而在极端环境下我也不是完全没法用。

render_markdown() { if command -v cmark >/dev/null 2>&1; then echo "$1" | cmark --hardbreaks else # 极简 fallback:只处理标题、粗体、代码块、链接 echo "$1" \ | sed 's/^# \(.*\)/<h1>\1<\/h1>/' \ | sed 's/\*\*\(.*\)\*\*/<strong>\1<\/strong>/g' \ | sed 's/\[\(.*\)\](\(.*\))/<a href="\2">\1<\/a>/g' fi }

这套“双保险”策略后来成了我在整个项目里最喜欢的一个点。它让我意识到:做工程不是非得选一个“标准答案”,根据场景在“标准方案”和“兜底方案”之间动态切换,往往是比任何单一选择都更可靠的做法。

3. 从 bare HTML 到完整站点:模板、样式与导航的演进

只有脚本还不能算站点,你还得有模板、样式和页面骨架。这块我花了不少时间打磨,因为很多极简工具做出来后“能跑但丑得没法看”,我不想让 caveman 落到这个下场。

3.1 用一个模板撑起全部页面

全站只有一个页面模板,就是templates/page.html。首页、关于页、文章页全都由它生成,区别只在于替换进去的内容不同。

我的做法是用占位符 + sed 替换。模板里写得清清楚楚:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{{TITLE}}</title> <meta name="description" content="{{DESCRIPTION}}"> <link rel="stylesheet" href="/assets/style.css"> </head> <body> <header> <nav> <a href="/">首页</a> <a href="/about.html">关于</a> <a href="/posts/">文章</a> </nav> </header> <main> {{BODY}} </main> <footer> <p>由 caveman 强力驱动,无需数据库,无需框架。</p> </footer> </body> </html>

配合脚本里对应增加的DESCRIPTION解析,每个页面就有了独立的 meta 描述。这一步看着小,但对搜索引擎的抓取和展示效果影响很大,强烈建议不要省。

3.2 样式上做减法,但保留“可读性底线”

既然叫 caveman,样式我也刻意做了减法。没有 CSS 框架,没有预处理器,没有设计系统,就一个手写的style.css。但“做减法”不代表“放弃好看”,我给自己定的目标是:页面在任何设备上打开,都清晰、稳定、不突兀。

我给正文设置了舒服的阅读宽度和行高,所有间距都用相对单位,让它能自适应不同屏幕:

body { margin: 0 auto; max-width: 42rem; padding: 1.5rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.75; color: #2d2d2d; background: #fff; } pre { overflow-x: auto; padding: 1rem; background: #f6f6f6; border-radius: 6px; } img { max-width: 100%; height: auto; }

这个样式表我控制在了 50 行以内,没有任何花活。它要的就是一个效果:你打开页面,可以舒舒服服地把一篇短文读完,不被布局分心,不被复杂的视觉干扰。

3.3 文章列表页的生成:又一个“用约定胜过配置”的例子

一个博客必须有文章列表页,否则读者找到文章后就没法继续逛了。

在 caveman 里,列表页不是手工维护的,而是构建时自动生成的。我写了一个小循环,把所有文章的标题和日期提出来,按文件名里的日期倒序排,再拼出一个索引页:

# 生成文章列表 { echo "<ul>" for file in "$CONTENT_DIR"/posts/*.md; do title="$(sed -n 's/^title: "\(.*\)"/\1/p' "$file" | head -1)" date="$(sed -n 's/^date: "\(.*\)"/\1/p' "$file" | head -1)" filename="$(basename "$file" .md)" echo "<li><time>$date</time> <a href=\"/posts/$filename.html\">$title</a></li>" done echo "</ul>" } | sort -r > "$OUTPUT_DIR/posts/index.html"

这里有个小陷阱:basename得到的文件名带2025-01-20-hello-caveman这种前缀,我们的链接用的是.html而不是.md,所以要手动拼一下后缀。我知道这个处理很基础,但它确实是静态站点生成器里一个最容易被忽略的细节。

4. 踩坑实录:没有实时预览之后,我是怎么调试的

这部分我想多说几句。因为网上讲静态站点的文章,几乎都在讲“怎么做出来”,很少讲“做出来之后怎么维护、怎么调试”。而工具越简单,调试方式就越不直观,这恰恰是我在实际使用中最有感触的地方。

4.1 没有 dev server,改样式只能靠“盲改 + 刷新”

第一次跑起来后,我想调一下文章的排版间距。放在以前,dev server 会把我对 CSS 的修改实时显示在浏览器里;但 caveman 没有 watch 功能,我改完 CSS 后必须先重新运行一次./build.sh,再手动刷新浏览器页面。

一开始我很不习惯,甚至想过要不要加一个watch参数。后来想了想,我们的原始哲学就是不引入额外进程,那就不加。我给自己定了一个流程:

  • 改样式 → 跑构建 → 刷新页面 → 看效果 → 再改
  • 如果只是微调间距,把浏览器窗口和编辑器并排放着,其实速度也不慢

这个流程虽然“原始”,但有一个意想不到的好处:每次刷新看到的都是最终产物,不会是 dev server 里缓存过的半成品。现场环境什么样,线上就是什么样,调试结果不会被开发环境“骗”了。

4.2 中文文件名和空格:一个让 sed 脚本原地爆炸的坑

刚开始我把文章命名为我的第一篇博客.md,文件名带着中文和空格。结果构建脚本直接跑飞了。我在find和for循环里被空格问题挂了好几次。

排查过程并不复杂:

  • 第一步,在终端里手动跑for file in content/posts/*.md; do echo "$file"; done,发现输出正常,说明文件遍历没问题
  • 第二步,把parse_markdown里的echo "$input"加上引号,错误消失
  • 第三步,把所有命令里的参数都套上双引号,全站构建恢复正常

根源就是 shell 脚本里最常见的“词分割”问题:没有引号时,"我的 第一篇 博客.md"会被拆成三个参数,后面的逻辑全部错乱。这个坑对有经验的 shell 使用者来说不值一提,但对从脚本开始做站的人来说,极具代表性。

我的修复方式很简单,把所有变量引用都改成带双引号:

parse_markdown() { local input="$1" local output="$2" # 传输中不要把空格拆掉 cp "$input" "$output" # ... 后续处理 }

后来我干脆立了一条规矩:所有变量引用一律加双引号,哪怕看起来不需要。这条规矩帮我节省了大量排查时间。

4.3 URL 里的中文和空格:另一个维度的问题

文件名解决之后,紧接着又冒出新问题:如果文件名里有空格,生成的 URL 里也会有空格,浏览器打开时会自动编码成%20,虽然不是不能访问,但既不美观也不利于分享。

我的解决方式是在 build.sh 里加了一个清理步骤,把文件名里的空格替换成连字符:

safe_filename="$(echo "$filename" | sed 's/ /-/g')" safe_title="$(echo "$title" | sed 's/ /-/g')" output_path="$OUTPUT_DIR/posts/$safe_filename.html"

这样一来,URL 里全是干净的小写英文连字符风格,复制出去给任何人用都不会出问题。我在网上看过不少极简生成器的实现,很多人没考虑到这一步,导致站内有些链接是编码过的地址。这些小细节其实很影响体验。

4.4 调试工具就只有 curl 吗?也不是

调试期间我还干过一些“原始”但有效的事。比如验证生成页面是否正确,我就用curl直接抓取本地静态文件的 HTTP 响应;想看渲染后的 HTML 结构,就用sed/grep管道把关键节点筛出来。这个过程不复杂,却帮我养成了“先看产物再改代码”的好习惯。

有次我发现某篇文章的正文里出现了未闭合的<b>标签,排查了半天发现是 Markdown 原文中写了个**但忘闭合了。这种问题在有 preview 的编辑器里一眼就能看到,但纯 shell 构建里只能通过输出检查发现。于是我又在脚本里加了一句:

# 检查是否有未闭合的粗体标记 if echo "$rendered_body" | grep -q '\*\*'; then echo "Warning: unclosed bold marker in $input" fi

这不算完美方案,但至少能在构建时把明显的格式问题暴露出来。我觉得这种“小工具小保障”的思路,特别符合 caveman 的气质——不需要重型 lint,只给关键节点设提醒。

5. 部署与性能实测:一台最便宜的 VPS 的极限在哪里

工具装进背包了,得拉出去遛遛。我把一个临时博客站点部署到了市面上能找到的最低配 VPS 上:单核 CPU、512M 内存,服务用系统自带的 nginx 托管静态文件。

5.1 部署流程简单到什么程度

部署流程是这样的:

  1. 本地写文章,运行./build.sh生成public/目录
  2. 用 rsync 把public/同步到服务器上
rsync -avz --delete public/ user@host:/var/www/caveman/

就这么两步。没有 CI/CD 流水线,没有容器镜像,没有服务编排。因为我从没想过要把发布做成“自动的”,手动 rsync 这个动作用了十几年了,依然简单可靠。部署时间一般在 1 到 2 秒,整个站点在服务器上占用的磁盘有几百 KB。

5.2 性能:单核小机实测数据

我在这个低配 VPS 上跑了基本的压测,用 ab 模拟并发请求,效果可以参考下表:

并发数单请求平均耗时错误率CPU 占用
12ms0%几乎为零
505ms0%约 8%
1009ms0%约 15%
30040ms0%约 40%

要知道这是单核 512M 的小机器,托管的全是静态文件,nginx 能轻松扛住几百并发。作为一个个人内容站,这个性能余量已经非常充足了。

5.3 需要明确说清楚的边界

夸完了也得泼冷水。这个工具依然有明确的能力边界:

  • 如果你需要用户在页面上实时发表评论,需要单独的评论服务支撑,caveman 自己无能为力
  • 如果你想做复杂的交互组件,比如嵌入式图表、地图、实时聊天,建议用专门的平台
  • 如果你想把站点嵌入到一个大型系统里,作为模块而非独立网页存在,那还是走现有系统的模板方案更现实

我觉得“知道工具的天花板在哪”永远是使用工具的一部分,这也是我这两天做完 caveman 之后最大的体会之一。

6. 复盘:这个“原始”项目真正教会我的几件事

项目跑起来后,我做了一番梳理,发现它对我的影响其实超出了“工具本身”的范畴。

6.1 约束反而给了我更大的自由

以前用重型框架时,我被各种抽象束缚着,不自觉地去想“最佳实践”“架构模式”“可扩展性”,内容产出反而滞后。而 caveman 几乎没有任何约束需要遵守,它也根本不会进化成一个大系统,因此我可以把所有注意力放到文章本身,放到要传达的信息上。这是我没想到的收获:限制怎么写,反而解放了写什么。

6.2 “能做”和“应该做”是两回事

我知道有人会想,为什么不用现成的静态站点生成器,比如 Hugo、Pentadactyl,随便挑一个都比自己写 shell 脚本强。这话完全对。但如果我只是用现成框架,就不会真正理解“文件到 URL 的映射”“模板与内容如何拼装”“纯静态托管带来的性能优势”这些底层原理。

我亲手重造了一个轮子,不是为了证明轮子应该长这样,而是为了搞明白轮子为什么能转。这些理解,是用现成工具很难得到的。

6.3 不是所有技术难题都值得解决

这个项目之所以能快速落地,很大原因是我不停地做减法:不做后台编辑器、不做多主题、不做多语言、不做缓存策略。那些没做的功能,每一项都让我免于背负新的复杂度。

理性地判断“什么不该做”,有时候比“怎么做得更好”更能决定一个项目的成败。caveman 最核心的成果不是那堆能跑的脚本,而是找到了一个最合适的问题边界。

在我看来,这其实是比写代码本身更值得养成的一种习惯:先找到那个真正值得做的东西,再用最小的成本把它做出来,剩下的,留给时间去检验。

如果你也厌倦了被工具追着跑,不妨试着给自己造一个“穴居人”级别的方案,拿最原始的工具,做最顺手的活儿。可能你会回来感谢这个简单的决定的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询