Hexo博客Mermaid支持开发实录:从主题定制到开源PR

背景

我的 Hexo 博客使用 Cactus 主题,写技术文章时经常需要用 Mermaid 画架构图和流程图。但 Cactus 主题不支持 Mermaid 渲染,而另一个主题 NexT 有现成实现。本文记录如何参考 NexT 的方案为 Cactus 开发 Mermaid 支持,以及如何通过 Fork + PR 的方式贡献给开源社区。

Hexo 渲染原理:从 Markdown 到博客页面

整体流程

graph LR
    A["Markdown 源文件"] --> B["Hexo 渲染管线"]
    B --> C["HTML 静态文件"]
    C --> D["部署到 Web 服务器"]
    D --> E["浏览器访问"]

Hexo 渲染管线详解

Hexo 将 Markdown 转换为 HTML 页面的过程经过多个阶段,每个阶段都有对应的 Hook 可以插入自定义逻辑:

graph TB
    subgraph "Hexo Generate 流程"
        A["source/_posts/*.md"] --> B["before_post_render Filter"]
        B --> C["hexo-renderer-marked"]
        C --> D["highlight.js 代码高亮"]
        D --> E["after_post_render Filter"]
        E --> F["EJS 模板引擎"]
        F --> G["public/*.html"]
    end

关键点:

阶段 作用 Hook 类型
before_post_render 在 Markdown 渲染前拦截内容 Filter (priority)
hexo-renderer-marked Markdown → HTML Renderer
highlight.js 代码块语法高亮 内置于 renderer
after_post_render HTML 渲染后处理 Filter
EJS 模板 注入 head/scripts/layout Template Engine

Mermaid 文本如何变成流程图

sequenceDiagram
    participant MD as Markdown 源文件
    participant Filter as before_post_render
    participant Marked as hexo-renderer-marked
    participant Browser as 浏览器
    participant MermaidJS as mermaid.js

    MD->>Filter: mermaid 代码围栏
    Filter->>Marked: pre.mermaid + HTML转义内容
    Marked->>Browser: 原样透传 HTML block
    Browser->>MermaidJS: 检测 .mermaid 元素, 懒加载 CDN
    MermaidJS->>Browser: 解析文本, 渲染为 SVG 图形

整个过程分为服务端和客户端两个阶段:

  • 服务端(hexo generate 时):filter 将 mermaid 代码围栏替换为 HTML 容器,绕过 highlight.js
  • 客户端(浏览器加载时):JavaScript 检测页面中的 .mermaid 元素,动态加载 mermaid.js 库并渲染为 SVG

实现方案:参考 NexT 主题开发 Cactus Mermaid 支持

NexT 主题的 Mermaid 实现

NexT 使用 Swig 模板引擎,其 Mermaid 方案由三部分组成:

  • scripts/tags/mermaid.js:注册 {% mermaid %} 标签插件
  • layout/_third-party/tags/mermaid.swig:客户端加载 mermaid@8 并初始化
  • _config.ymlmermaid.enablemermaid.theme 配置项

Cactus 主题的架构差异

维度 NexT Cactus
模板引擎 Swig EJS
代码围栏支持 {% mermaid %} 标签 标准 ```mermaid + 标签
CDN 管理 vendors 配置 cdn helper 函数
主题适配 手动指定 自动检测 colorscheme

整体架构设计

graph TB
    subgraph "Cactus Theme Mermaid 架构"
        subgraph "服务端 - hexo generate"
            A["scripts/mermaid.js"] --> B["before_post_render filter\n(priority 5)"]
            A --> C["tag plugin\n{% mermaid %}"]
            B --> D["正则匹配 ```mermaid 围栏"]
            D --> E["HTML 转义 + 包裹\n<pre class=mermaid>"]
            C --> E
        end

        subgraph "配置层"
            F["_config.yml"] --> G["mermaid.enable"]
            F --> H["mermaid.theme: auto"]
            F --> I["cdn.mermaid: jsdelivr URL"]
        end

        subgraph "客户端 - 浏览器"
            J["scripts.ejs"] --> K["检测 .mermaid 元素"]
            K --> L["动态创建 script 标签"]
            L --> M["CDN 加载 mermaid.js"]
            M --> N["mermaid.initialize + run"]
            N --> O["SVG 渲染"]
        end

        subgraph "样式层"
            P["style.styl"] --> Q["pre.mermaid 覆盖默认 pre 样式"]
        end
    end

核心实现详解

1. 服务端 Filter:拦截 Mermaid 围栏

核心挑战:Hexo 的 highlight.js 会将 ```mermaid 代码块渲染为 <figure class="highlight plaintext"> 结构,破坏 mermaid 语法。必须在 highlight.js 处理之前拦截。

1
2
3
4
5
6
7
8
9
10
11
12
13
// scripts/mermaid.js
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

hexo.extend.filter.register('before_post_render', function(data) {
if (!mermaidEnabled()) return data;
const mermaidRegex = /^```mermaid\s*\n([\s\S]*?)^```\s*$/gm;
data.content = data.content.replace(mermaidRegex, (match, content) => {
return `\n<pre class="mermaid">\n${escapeHtml(content.trim())}\n</pre>\n`;
});
return data;
}, 5); // priority 5: 在 markdown renderer (priority 10) 之前执行

这里有两个关键设计决策:

为什么用 <pre> 而不是 <div>

CommonMark 规范中,<div> 类型的 HTML block 遇到空行会被截断,后续内容被当作 Markdown 处理。而 <pre> 是 Type 1 HTML block,直到遇到 </pre> 才结束,不受空行影响。Mermaid 的 subgraph 之间通常有空行分隔,<div> 会导致内容被截断。

为什么需要 HTML 转义?

即使在 <pre> 标签内,浏览器 HTML 解析器仍然会解析 <> 字符。例如 classDiagram 中的 <<abstract>> 注解,浏览器会将 <abstract> 解释为 HTML 标签并吞掉它。转义后 mermaid.js 通过 element.textContent 读取内容时,浏览器会自动反转义回原始字符。

2. 客户端懒加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// layout/_partial/scripts.ejs
(function() {
var mermaidElements = document.querySelectorAll('.mermaid');
if (!mermaidElements.length) return; // 无 mermaid 元素则不加载

var mermaidTheme = '<%= theme.mermaid.theme || "auto" %>';
if (mermaidTheme === 'auto') {
var colorscheme = '<%= theme.colorscheme || "dark" %>';
mermaidTheme = (colorscheme === 'dark' || colorscheme === 'classic') ? 'dark' : 'default';
}

function initMermaid() {
mermaid.initialize({ startOnLoad: false, theme: mermaidTheme });
mermaid.run({ nodes: mermaidElements });
}

var script = document.createElement('script');
script.src = '<%= theme.cdn.mermaid %>';
script.onload = initMermaid;
document.head.appendChild(script);
})();

设计要点:

  • 按需加载:先检测页面是否存在 .mermaid 元素,不存在则跳过,避免无谓的网络请求
  • 主题自动适配:根据 Cactus 的 colorscheme 配置自动选择 mermaid 的 dark/default 主题
  • 动态 script 注入:通过 createElement 而非静态 <script> 标签,实现真正的懒加载

3. 配置项

1
2
3
4
5
6
7
# _config.yml
mermaid:
enable: true
theme: auto # auto | dark | default | forest | neutral

cdn:
mermaid: https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js

开发过程中踩的坑

问题 现象 根因 解决方案
CDN 404 页面显示纯文本,不渲染 cdnjs 上 mermaid 11.4.1 版本不存在 切换到 jsdelivr 的 mermaid@10
HTML block 截断 第一个 subgraph 正常,后续变为 Markdown <div> 遇空行终止 HTML block 改用 <pre> 标签
特殊字符被吞 classDiagram 的 <<abstract>> 报语法错误 浏览器将 <abstract> 解析为 HTML 标签 服务端 HTML 转义内容

开源贡献:Fork 与 Pull Request 流程

流程概览

graph LR
    A["upstream\nprobberechts/hexo-theme-cactus"] -->|Fork| B["origin\ncursorhu/hexo-theme-cactus"]
    B -->|clone / push| C["本地仓库"]
    C -->|feature branch| D["feature/mermaid-support"]
    D -->|push| B
    B -->|Pull Request| A

具体步骤

Step 1: Fork 原仓库

在 GitHub 上 fork probberechts/hexo-theme-cactus 到自己的账号 cursorhu

Step 2: 设置 Remote

1
2
3
4
5
6
# 将 origin 指向自己的 fork
git remote set-url origin https://github.com/cursorhu/hexo-theme-cactus.git

# 添加 upstream 指向原作者
git remote add upstream https://github.com/probberechts/hexo-theme-cactus.git
git fetch upstream

此时的 remote 结构:

1
2
origin    → cursorhu/hexo-theme-cactus (自己的 fork,可读写)
upstream → probberechts/hexo-theme-cactus (原仓库,只读)

Step 3: 推送个人工作分支

1
2
# 推送包含个人配置 + mermaid 功能的分支
git push -u origin cursorhu

Step 4: 创建干净的 Feature Branch

PR 给原作者不能包含个人配置改动,需要从 upstream/master 创建干净分支:

1
2
3
4
5
6
7
8
9
# 从原作者的最新 master 创建 feature branch
git checkout -b feature/mermaid-support upstream/master

# 只添加 mermaid 相关的 4 个文件改动
git add scripts/mermaid.js _config.yml layout/_partial/scripts.ejs source/css/style.styl
git commit -m "feat: add Mermaid diagram support"

# 推送到自己的 fork
git push origin feature/mermaid-support

Step 5: 创建 Pull Request

访问:https://github.com/probberechts/hexo-theme-cactus/compare/master...cursorhu:hexo-theme-cactus:feature/mermaid-support

PR 的关键要素:

  • Title:简洁描述功能,如 feat: add Mermaid diagram support
  • Body:说明实现方式、使用方法、修改的文件列表
  • Scope:只包含功能相关改动,不混入个人配置

分支管理策略

gitGraph
    commit id: "upstream/master"
    branch cursorhu
    commit id: "personal config"
    commit id: "mermaid (mixed)"
    checkout main
    branch feature/mermaid-support
    commit id: "feat: mermaid (clean)"

两条分支各有用途:

  • cursorhu:自己日常使用,包含个人配置 + 所有功能
  • feature/mermaid-support:提交给社区的干净功能分支,基于 upstream/master

总结

整个开发过程的核心经验:

  1. 理解渲染管线:Hexo 的 filter 机制提供了 before_post_render 这个关键 Hook,让我们能在 highlight.js 之前拦截 mermaid 内容
  2. 尊重浏览器规则:HTML 解析器无处不在,即使 <pre> 标签内的内容也需要正确转义
  3. 分离关注点:服务端负责内容保护(防止被其他处理器破坏),客户端负责渲染(懒加载 + 按需初始化)
  4. 开源贡献要干净:个人使用的分支和提交给社区的分支必须分开,PR 只包含功能本身