Hexo、Hugo、Astro 都很好用,但它们都有同一个问题:你只想要 20% 的功能,却要接受 100% 的抽象。主题改不动、插件看不懂、升级还会崩。

这个博客的生成器干脆自己写。核心逻辑不到 200 行,用到的依赖只有三个:

  • markdown-it —— Markdown 渲染
  • highlight.js —— 代码高亮
  • gray-matter —— 解析 front matter

第一步:读文章

文章存在 posts/ 目录里,一个 .md 文件一篇。front matter 用 --- 包起来:

js
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 钩子:

js
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。这里用一个轻量的插件实现,顺便把目录也收集出来:

js
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, ''):

js
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 标志能匹配任何语言的字母,中文标题也就不会被清空。

第四步:套模板输出

模板就是返回字符串的函数,没什么魔法:

js
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>`
}

写文件之前记得建目录:

js
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/:

js
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,一个标签一个页面:

js
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 字符串,注意转义就行:

js
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('')

关于阅读时长

中文的阅读速度跟英文完全不是一回事。按"字数"算中文、按"单词数"算英文,得到的估算才勉强靠谱:

js
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,不用翻框架源码

代价是得自己维护。但代码量摆在那儿,一晚上读完,之后基本不用动。