
3 分钟#tooling #node
零依赖:把 Markdown 编译成博客
不用 Hugo,不用 Astro,不上任何构建框架。一个 400 行的 Node 脚本,把 content/posts/*.md 变成一整个静态站。
我的博客以前是 Hugo 的。主题很漂亮,构建很快,但每次想改一处布局,都要先在 Go template 的 {{ }} 里绕十分钟。
后来我把整套东西删了,只留一个文件:build.mjs。
需要的其实只有三件事
一个静态博客生成器要做的事,拆开看只有三件:
- 读 frontmatter,拿到标题、日期、标签
- 把 Markdown 变成 HTML
- 套上模板,写到
dist/
Node 内置的 fs 和 path 就够了。不需要 remark,不需要 unified,不需要插件生态——因为我写的 Markdown 是我自己写的,我不需要兼容整个 CommonMark。
Markdown 子集就够用了
我实现的是这样一个子集:
- 标题、段落、粗体斜体、行内代码
- 围栏代码块(带语言 class)
- 有序/无序列表、引用、分割线
- 表格、图片、链接
- 任务列表
加起来不到 200 行。剩下的都是我不写的语法,所以它永远不会因为"不支持"而咬我。
有个小细节值得一提:中文软换行。
const CJK = /[\u2e80-\u9fff\u3000-\u303f\uff00-\uffef]/;
function joinSoft(text) {
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
let out = '';
for (const l of lines) {
if (!out) { out = l; continue; }
const a = out.slice(-1), b = l[0];
out += CJK.test(a) && CJK.test(b) ? l : ' ' + l;
}
return out;
}
Markdown 里换行不产生空格,但中文被换行拆开时如果补一个空格,就成了"中 文"。所以判断两端是不是中日韩字符,是就贴着,不是就补空格。
图片尺寸在构建时补上
CLS 最容易被图片搞坏。但我又不想在每篇文章里手写 width / height。
解法很简单:构建时读一遍本地图片的头部,把真实尺寸塞进 <img>。
// WebP 的三种容器格式,尺寸藏在不同的位置
if (fmt === 'VP8X') return { w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3) };
if (fmt === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 }; }
写完之后, 出来就是 <img width="1200" height="675" loading="lazy">,布局一次都不会跳。
用断言代替肉眼
发布之前跑一遍 node scripts/verify.mjs,它检查:
| 检查 | 为什么 |
|---|---|
每页只有一个 h1 |
结构正确性 |
| 每个链接都能落到文件 | 死链是静态站最常见的病 |
没有残留的 **、# 标题或三反引号围栏 |
渲染漏了会很难看 |
feed.xml 的链接都在站点域名下 |
防止站点搬家后 RSS 失效 |
| JS 总体积 ≤ 4KB(gzip) | 性能预算 |
死链检查最值钱。有一次我把 posts/ 改成 post/,本地看着一切正常,校验脚本直接报出 17 条死链。
代价是什么
说点实话。自己写生成器的代价是:
- 没有热更新,改完要重跑
node build.mjs - 语法支持靠自己加,想用脚注就得自己写
- 没有插件生态,搜索、评论、图床都得自己接
但收益也很直接:整站只有两种语言,HTML 和 CSS。任何一处不满意,我都能在十分钟内改到满意,不用查文档,不用跟抽象层搏斗。
工具的价值不在于它能做多少,而在于它不挡路。
现在这个站的构建时间
$ time node build.mjs
✓ 生成 5 篇文章 · 6 个标签 · 21 条 sitemap -> dist/
real 0m0.09s
九十毫秒。改完一篇文章,敲一个命令,刷新浏览器就看到了。
这大概就是我愿意继续手写它的原因。