ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

极简静态站点生成器caveman:用纯shell脚本摆脱前端工具链

极简静态站点生成器caveman:用纯shell脚本摆脱前端工具链 做了这么多年前端和内容站我一直有个毛病见到一个项目总想先把框架选好、依赖装齐、管线配齐然后才肯动手写第一行代码。结果往往是框架升级循环、依赖冲突排查、构建脚本修修补补真正花在内容本身上的时间反而没多少。这个叫“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 21; 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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{{TITLE}}/title meta namedescription content{{DESCRIPTION}} link relstylesheet 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 litime$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 部署流程简单到什么程度部署流程是这样的本地写文章运行./build.sh生成public/目录用 rsync 把public/同步到服务器上rsync -avz --delete public/ userhost:/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 最核心的成果不是那堆能跑的脚本而是找到了一个最合适的问题边界。在我看来这其实是比写代码本身更值得养成的一种习惯先找到那个真正值得做的东西再用最小的成本把它做出来剩下的留给时间去检验。如果你也厌倦了被工具追着跑不妨试着给自己造一个“穴居人”级别的方案拿最原始的工具做最顺手的活儿。可能你会回来感谢这个简单的决定的。
返回列表