用 200 行代码写一个静态站点生成器
Hexo、Hugo、Astro 都很好用,但它们都有同一个问题:你只想要 20% 的功能,却要接受 100% 的抽象。主题改不动、插件看不懂、升级还会崩。
这个博客的生成器干脆自己写。核心逻辑不到 200 行,用到的依赖只有三个:
markdown-it—— Markdown 渲染highlight.js—— 代码高亮gray-matter—— 解析 front matter
第一步:读文章
文章存在 posts/ 目录里,一个 .md 文件一篇。front matter 用 --- 包起来:
import matter from 'gray-matter'
import fs from 'node:fs'
function readPosts(dir) {
return fs.readdirSync(dir)
.filter((f) => f.endsWith('.md'))
.map((file) => {
const raw = fs.readFileSync(`${dir}/${file}`, 'utf8')
const { data, content } = matter(raw)
return {
title: data.title,
date: String(data.date).slice(0, 10),
tags: data.tags ?? [],
content
}
})
.sort((a, b) => (a.date < b.date ? 1 : -1)) // 新的在前
}
gray-matter 会把 --- 之间的 YAML 解析成对象,剩下的正文原样返回。
第二步:渲染 Markdown
markdown-it 默认不做代码高亮,需要手动挂一个 highlight 钩子:
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
const md = new MarkdownIt({
html: true,
linkify: true,
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
const html = hljs.highlight(str, {
language: lang,
ignoreIllegals: true
}).value
return `<pre class="code-block" data-lang="${lang}"><code class="hljs">${html}</code></pre>`
}
return `<pre class="code-block"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
})
data-lang 是留给 CSS 的,用来在代码块右上角显示语言名。ignoreIllegals: true 很重要——高亮器遇到不认识的语法会直接抛错,加上这个参数就会容忍过去。
第三步:给标题加锚点
想支持「复制标题链接」,得给每个标题生成 id。这里用一个轻量的插件实现,顺便把目录也收集出来:
md.core.ruler.push('anchors_and_toc', (state) => {
const used = new Map()
const toc = []
for (let i = 0; i < state.tokens.length; i++) {
const token = state.tokens[i]
if (token.type !== 'heading_open') continue
const text = state.tokens[i + 1].content
let slug = slugify(text)
// 同名标题加后缀,避免 id 冲突
const n = used.get(slug)
if (n) { used.set(slug, n + 1); slug = `${slug}-${n + 1}` }
else used.set(slug, 1)
token.attrSet('id', slug)
const level = Number(token.tag.slice(1))
if (level === 2 || level === 3) toc.push({ level, text, slug })
}
state.env.__toc = toc
})
slugify 要能正确处理中文,所以不能简单地用 replace(/[^\w]/g, ''):
const slugify = (s) => s.trim().toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}_-]+/gu, '') // \p{L} 保留所有语言的字母
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '')
\p{L} 配合 u 标志能匹配任何语言的字母,中文标题也就不会被清空。
第四步:套模板输出
模板就是返回字符串的函数,没什么魔法:
function layout({ title, content, nav }) {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head><title>${esc(title)}</title></head>
<body>
<nav>${nav.map((n) => `<a href="${n.url}">${esc(n.text)}</a>`).join('')}</nav>
<main>${content}</main>
</body>
</html>`
}
写文件之前记得建目录:
function emit(relPath, html) {
const target = path.join(OUT_DIR, relPath)
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, html)
}
顺手做掉的三件事
生成器写出来之后,加功能其实都很便宜:
分页——首页只放最近 8 篇,剩下的切到 /page/2/:
for (let i = 1; i <= totalPages; i++) {
const slice = posts.slice((i - 1) * size, i * size)
emit(i === 1 ? 'index.html' : `page/${i}/index.html`, renderHome(slice))
}
标签——遍历所有文章聚合出一个 Map,一个标签一个页面:
const tagMap = new Map()
for (const p of posts) {
for (const t of p.tags) {
if (!tagMap.has(t)) tagMap.set(t, [])
tagMap.get(t).push(p)
}
}
RSS——本质就是拼 XML 字符串,注意转义就行:
const items = posts.slice(0, 20).map((p) => `
<item>
<title>${esc(p.title)}</title>
<link>${esc(baseUrl + p.url)}</link>
<pubDate>${new Date(p.date).toUTCString()}</pubDate>
<description>${esc(p.summary)}</description>
</item>`).join('')
关于阅读时长
中文的阅读速度跟英文完全不是一回事。按"字数"算中文、按"单词数"算英文,得到的估算才勉强靠谱:
function readingTime(text) {
const cjk = (text.match(/[\u4e00-\u9fa5]/g) || []).length
const words = (text.replace(/[\u4e00-\u9fa5]/g, ' ')
.match(/[A-Za-z0-9_']+/g) || []).length
return Math.max(1, Math.round(cjk / 400 + words / 220))
}
大概 400 字/分钟中文、220 词/分钟英文,取整后至少显示 1 分钟。
值不值得自己写
如果你的需求是「Markdown 进、静态站出」,自己写一个生成器可能是最省心的选择:
- 完全可控——想改哪里改哪里,没有黑盒
- 依赖极少——三个包,升级风险几乎为零
- 构建极快——几十篇文章不到一秒
- 调试简单——出问题直接
console.log,不用翻框架源码
代价是得自己维护。但代码量摆在那儿,一晚上读完,之后基本不用动。