手搓一个零依赖的 Markdown 静态站点生成器,我学到了什么

手搓一个零依赖的 Markdown 静态站点生成器,我学到了什么

不用框架,不用构建工具链,只用 Node.js 标准库和一个 marked.js。

为什么不用现成方案

Hexo 太重------npm install 能装 200+ 个包。Hugo 要装二进制。Gatsby 要学 GraphQL。我想要的是:

  • 零依赖(除了一个 Markdown 解析器)
  • 完全控制构建流程
  • 能部署到任意静态托管
  • 子路径自动适应

所以我决定自己写一个。不是为了替代它们,是为了完全理解构建流程的每一步。

一、Markdown 解析:选 marked 但 vendor 本地化

解析 Markdown 有两个选择:自己写 parser,或者用现成库。

自己写 parser 能做到零依赖,但 Markdown 语法比想象的复杂------GFM 表格、围栏代码块、任务列表、自动链接,每种都要处理边界情况。投入产出比不高。

我选了 marked.js(~35KB),但不通过 npm install。直接把 marked.min.js 放到 vendor/ 目录,用 require() 加载:

javascript 复制代码
const { marked } = require('./vendor/marked.min.js');

function renderMarkdown(body) {
  return marked(body, { gfm: true, breaks: true });
}

好处:构建时零网络依赖,不进 node_modules,不污染全局。

一个细节:breaks: true 让单个换行变成 <br>,对中文写作更友好(中文习惯每段一句一行)。

二、目录扫描:glob + frontmatter 解析

文章放在 content/posts/<分类>/ 目录下。构建时扫描所有 .md 文件,解析 YAML frontmatter 提取元数据。

frontmatter 解析不引入 js-yaml------自己写了一个简单的行扫描器:

javascript 复制代码
function parseFrontMatter(text) {
  const match = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  if (!match) return { meta: {}, body: text };
  const meta = parseYaml(match[1]); // 自写的 YAML 解析器,约 80 行
  return { meta, body: match[2] };
}

YAML 解析器只支持子集:标量、数组、嵌套对象、块标量(|)。不支持流映射、锚点、别名。约 80 行代码,够用就行。

增量构建的判断逻辑:比较文件的 mtime 和上次构建的 manifest。mtime 没变就跳过。简单但有效。

三、HTML 生成:不用模板引擎

我试过 ejs、pug、handlebars,最后决定不用任何模板引擎

原因:模板引擎引入的复杂度(语法、转义、作用域)远超它带来的好处。HTML 模板本身就是可读的,直接用字符串替换就够了:

javascript 复制代码
function renderPage(template, data) {
  let html = template;
  html = html.replace('{{title}}', escapeHtml(data.title));
  html = html.replace('{{content}}', data.content);
  return html;
}

布局嵌套用文件包含:base.html 包含 post.htmlpost.html 包含文章内容。构建时按顺序拼接。

一个坑:字符串替换要注意转义。用户输入的 <script> 标签如果没转义,构建产物就有 XSS 风险。我用了 escapeHtml() 函数处理所有动态内容。

四、CSS Token 系统:不用预处理器

不用 Sass、Less、PostCSS。直接用 CSS 自定义属性(--variable)。

css 复制代码
:root {
  --bg-primary: #0b0d12;
  --text-primary: #f5f7fb;
  --accent: #6f7cff;
  --font-display: 'Inter', sans-serif;
  --layout-width: 1120px;
}

主题切换就是覆盖这些变量:

css 复制代码
/* paper 主题 */
:root {
  --bg-primary: #faf6f0;
  --text-primary: #2c2420;
  --accent: #2563eb;
  --font-display: 'Caveat', cursive;
}

好处:

  • 浏览器原生支持,无需编译
  • 运行时可切换(document.documentElement.style.setProperty
  • 主题作者只需覆盖变量,不需要理解组件结构
  • 不用 CSS-in-JS 的原因更简单:运行时注入 <style> 标签违背零依赖目标,且 SSR 场景下会有闪屏问题

一个设计决策:所有组件样式用 var() 引用变量,硬编码值只出现在 :root 中。这样主题只需要覆盖 :root 就能改变全局视觉。

五、搜索索引:lunr.js + CJK 分词

搜索有两种方案:服务端搜索(需要后端)和客户端搜索(纯静态)。

我选了客户端搜索,用 lunr.js(~29KB)。问题是 lunr.js 不支持中文分词------它按空格分词,中文会变成一整块。

解决方案:构建时预分词。把中文文本按字拆分,用空格连接,让 lunr.js 能索引:

javascript 复制代码
function tokenizeCJK(text) {
  return text.replace(/([\u4e00-\u9fff])/g, '$1 ');
}

按字拆分的代价是精确率下降("中国人"搜"中国"匹配不到),但召回率 100%,且 50KB 的索引文件对 100 篇文章来说,宁可多匹配也不要漏匹配。

构建时生成 search-index.json,前端加载后用 lunr.js 搜索。索引文件约 50KB/100 篇文章,首次加载后缓存。

为什么不用向量搜索?因为这是客户端方案,不需要服务端。向量搜索需要 Embedding API,引入了外部依赖。

六、踩过的坑

坑 1:Windows 路径

path.join() 在 Windows 上用反斜杠。但 HTML 中的 URL 必须用正斜杠。解决:所有输出路径统一用 .replace(/\\/g, '/')

坑 2:Mermaid 渲染时序

Mermaid 图表需要在 DOM 加载后才能渲染。但 Markdown 构建时已经把代码块转成了 <pre><code>。解决:构建时保留原始代码,前端用 mermaid.render() 异步渲染。

坑 3:KaTeX 渲染时机

数学公式 $E=mc^2$ 在 Markdown 中可能和普通 $ 符号冲突。解决:构建时用正则提取公式,存入 data-math-inline 属性,前端用 KaTeX 渲染。

坑 4:热重载 SSE 实现

开发时需要文件变化后自动刷新。我用了 Server-Sent Events(SSE):

javascript 复制代码
// 服务端
res.writeHead(200, {
  'Content-Type': 'text/event-stream',
  'Cache-Control': 'no-cache',
  'Connection': 'keep-alive'
});
res.write('data: reload\n\n');

// 客户端
const es = new EventSource('/__reload');
es.onmessage = () => location.reload();

一个坑:SSE 连接在页面刷新时会断开,需要自动重连。EventSource 浏览器原生支持自动重连,但要设置 retry 间隔。

结尾

轮子造完了,能用。但我不建议每个人都造------除非你和我一样,想完全理解构建流程的每一步,或者你的需求确实特殊到现成方案满足不了。

如果你的目标只是写博客,Hugo 仍然是最高效的选择。如果你需要完全控制构建流程、或者像我一样想理解每一步发生了什么,手搓是你的选项。

相关推荐
X档案库2 天前
【开源】我做了一套可以 AI 托管的 Markdown 博客与知识库
rust·博客·markdown·marksharex
DeMinds4 天前
内容没有丢,我为什么总在重新整理?|DeMinds 如何让工作接着继续
ios·github·markdown
acheding5 天前
File System Access API 实战:让网页真正读写本地文件
前端·javascript·vue.js·编辑器·markdown
acheding5 天前
把 CodeMirror 6 调教成 Markdown 编辑器:扩展、装饰与门面
javascript·vue.js·编辑器·markdown
卷无止境7 天前
Quarkdown:赋予 Markdown 超能力的现代排版系统
前端·markdown
DeMinds8 天前
这篇文章,真的有“结构”吗?
markdown
特立独行的猫a9 天前
Markmap 入门到精通:从一段 Markdown 到一张可交互思维导图
markdown·工具·思维导图·markmap
秋天的一阵风10 天前
✨ 原来文本转换可以这么丝滑!UnifiedJS 实战指南来了
前端·github·markdown
梦想不只是梦与想12 天前
超级记事本:markdown的使用
markdown