全部文章

3 分钟#tooling #node

零依赖:把 Markdown 编译成博客

不用 Hugo,不用 Astro,不上任何构建框架。一个 400 行的 Node 脚本,把 content/posts/*.md 变成一整个静态站。

我的博客以前是 Hugo 的。主题很漂亮,构建很快,但每次想改一处布局,都要先在 Go template 的 {{ }} 里绕十分钟。

后来我把整套东西删了,只留一个文件:build.mjs。

需要的其实只有三件事

一个静态博客生成器要做的事,拆开看只有三件:

  1. 读 frontmatter,拿到标题、日期、标签
  2. 把 Markdown 变成 HTML
  3. 套上模板,写到 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 }; }

写完之后,![封面](cover-1.webp) 出来就是 <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

九十毫秒。改完一篇文章,敲一个命令,刷新浏览器就看到了。

这大概就是我愿意继续手写它的原因。

回到顶部