背景
我的 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.yml:mermaid.enable和mermaid.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 | // scripts/mermaid.js |
这里有两个关键设计决策:
为什么用 <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 | // layout/_partial/scripts.ejs |
设计要点:
- 按需加载:先检测页面是否存在
.mermaid元素,不存在则跳过,避免无谓的网络请求 - 主题自动适配:根据 Cactus 的
colorscheme配置自动选择 mermaid 的 dark/default 主题 - 动态 script 注入:通过
createElement而非静态<script>标签,实现真正的懒加载
3. 配置项
1 | # _config.yml |
开发过程中踩的坑
| 问题 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 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 | # 将 origin 指向自己的 fork |
此时的 remote 结构:
1 | origin → cursorhu/hexo-theme-cactus (自己的 fork,可读写) |
Step 3: 推送个人工作分支
1 | # 推送包含个人配置 + mermaid 功能的分支 |
Step 4: 创建干净的 Feature Branch
PR 给原作者不能包含个人配置改动,需要从 upstream/master 创建干净分支:
1 | # 从原作者的最新 master 创建 feature branch |
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
总结
整个开发过程的核心经验:
- 理解渲染管线:Hexo 的 filter 机制提供了
before_post_render这个关键 Hook,让我们能在 highlight.js 之前拦截 mermaid 内容 - 尊重浏览器规则:HTML 解析器无处不在,即使
<pre>标签内的内容也需要正确转义 - 分离关注点:服务端负责内容保护(防止被其他处理器破坏),客户端负责渲染(懒加载 + 按需初始化)
- 开源贡献要干净:个人使用的分支和提交给社区的分支必须分开,PR 只包含功能本身