本篇是《手写一个内容型技术博客站》系列第 4 篇。
系列第 1 篇讲了为什么弃用 MDX 自研渲染器。这一篇深入 src/lib/markdown.ts——一个 755 行的渲染器,全站文章、教程、静态页、相册说明共用,是站点的"心脏"。
一、整体管线
processMarkdown(入口)的处理顺序有讲究:
processMarkdown(markdown)
├─ 1. 剥离 frontmatter(兼容 CRLF)
├─ 2. renderMath 先把公式渲染成占位符
├─ 3. 逐行遍历(mermaid / 代码块 / 标题 / 段落 / 列表 / 表格)
├─ 4. renderInline 处理行内格式(粗体/斜体/代码/链接)
└─ 5. restoreMath 把占位符换回 KaTeX HTML为什么公式要先占位? 因为 $$...$$ 或 $...$ 里的内容可能含 *、_、反引号等 markdown 语法字符,如果先走一遍行内解析,公式会被破坏。先渲染成占位符(@@MATH_0@@),其他语法处理完再换回来,互不干扰。
二、XSS 转义的正确范式
这是全文最核心的安全点。最初的实现有个漏洞:renderInline 只 replace 匹配正则的部分,未匹配的残余文本原样透传——文章里出现 <script> 或 <img onerror> 会直接打进 HTML。
修复范式,第一行就整体转义:
const escapeHtml = (text: string): string =>
text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const renderInline = (textValue: string): string => {
// 先整体转义,堵住未匹配模式的残余文本 XSS 透传;
// escapeHtml 不触碰 markdown 语法字符(* _ ` [] ()),后续正则匹配不受影响。
let out = escapeHtml(textValue);
// 然后才是行内正则:代码、粗体、斜体、链接……
return out;
};关键设计:escapeHtml 只转 & < > " ' 五个字符,不碰 markdown 语法字符 * _ [] ()——所以转义后正则照常匹配。链接回调内不再二次转义(输入已安全),避免 url?a=1&b=2 的 & 被 & → &` 双重转义。
这个范式的另一个好处是幂等自收敛:二次调用不会破坏已有实体(< 不会继续恶化)。
三、行内渲染:顺序即正确性
renderInline 的 replace 顺序不是随便排的,每一步都有关键理由:
// 1. 行内代码先处理(避免 ** 等被误判成粗体)
out = out.replace(/``([^`]*?)``/g, '<code ...>$1</code>'); // 双反引号优先,内容可含单反引号
out = out.replace(/`([^`]+)`/g, '<code ...>$1</code>');
// 2. 粗体;3. 斜体(仅单词两端,避免 * 被当列表标记)
// 4. 链接
out = out.replace(/\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g,
(_m, text, url) => `<a href="${url}" target="_blank" rel="noopener noreferrer" ...>${text}</a>`);为什么先处理行内代码? 代码段里如果有 not bold 或 ` code `,必须先把它抽出来,否则后面的粗体/斜体正则会在代码内容上错误匹配。这也是"双反引号优先于单反引号"的原因——双反引号段里的单个反引号不该被单反引号正则截断。
链接只匹配 https?:// 开头的绝对 URL,相对路径和 javascript: 协议直接被忽略,天然堵住 [x](javascript:alert(1)) 这类注入。
四、代码块与 Mermaid:fence 状态机
代码块不靠正则匹配靠状态机——逐行扫描 inCodeBlock 布尔切换:
// processMarkdown 内部:逐行遍历
if (/^```/.test(line)) {
if (!inCodeBlock) {
inCodeBlock = true;
currentCodeLang = line.slice(3).trim();
currentCodeLines = [];
} else {
inCodeBlock = false; // 输出整个代码块
}
continue;
}为什么不用正则? 正则处理"多行代码块里含 ```"这种嵌套/边界情况会出错,且无法精确定位语言标签。状态机能处理任意长度的代码块,还能和"标题提取"共用同一套状态(见下节 TOC)。
Mermaid 块单独处理:识别 language-mermaid,输出 <pre class="mermaid-block" data-mermaid="true"><code class="language-mermaid">,客户端组件渲染成图。
五、TOC 一致性的坑:buildHeadingIds 必须跳过代码块
站点的目录(TOC)和高亮滚动,依赖 extractHeadings 提取标题。曾有个很隐蔽的 bug:
buildHeadingIds 逐行匹配 # 但没跟踪代码块状态,把代码块里的 # 注释 也当成标题了。
# 参考资料 ← 真标题这是注释 ← 被误当标题!
x = 1
结果 TOC 里出现一堆"python 注释",50 项里 39 项是假的,而且标题 ID 序号错位,正文锚点与 TOC 对不上。
修法:buildHeadingIds 与 processMarkdown 使用完全一致的 inCodeBlock 状态机逻辑——两处必须同步,否则 headingIndex 序号错位。
这是"内容站里看起来无关的两块代码其实强耦合"的典型:TOC 提取和正文渲染,必须共享同一套"什么是标题"的判定。
六、正文 XSS / CRLF / 实体二次转义:三个合规坑
坑1:CRLF 导致 frontmatter 未剥离
剥离 frontmatter 的正则 ^---\n... 只匹配 LF。Windows 编辑保存的 CRLF 文件匹配不到 → 整个 frontmatter 渲染成正文。修法:^---\r?\n[\s\S]*?\r?\n---\r?\n?。详情页与列表页行为不一致(列表页走 gray-matter 不受影响),这个"不一致"本身就是排查信号。
坑2:源文件写死 HTML 实体 → 二次转义
部分迁移来的文章源文件直接写 < >(比如讲 HTML 实体的文章本身)。escapeHtml 又把它们转一次 → < 变 <,浏览器显示字面量 <。
修法两层:渲染器层在 escapeHtml 开头先 decodePreEscaped(<→<、>→>、&→&,顺序先 </> 最后 & 防 < 被拆坏),再整体转义;内容层把非代码块的 < 改回真符号。
Mermaid 块内实体不要清理——strict 下裸 < 有语法风险,且 mermaid 读 textContent 自动还原实体,本就正确。
坑3:$ 裸变量被当成行内公式
行内公式正则 $...$ 会把 $0 $1(awk 变量)、$10(美元金额)、${x}(模板串)误当公式——当两个 $ 之间夹一整段中文时尤其灾难,KaTeX 吞内容渲染异常。
修法双层:① 开头把行内代码/代码块替换成占位符,公式处理完再还原(代码里的 $ 永久免疫)② 行内 $...$ 内容含 CJK 且无 LaTeX 命令特征(\xxx)时原样保留文本(k = \text{参与混币的数量} 这类真公式仍放行)。
七、性能与测试
- 测试:
node:test跑核心用例(XSS/粗斜体/锚点/链接 noopener/slugify/TOC 净化),npm test全绿。渲染器是纯函数,是项目里最好测的部分。 - signature 缓存:
categories.ts的内容树有签名缓存(mtime+size),内容不变时列表页不重建树,性能有保障。
合规度检验:一个渲染器写完,至少要能回答三个问题——① 任意输入不产生 XSS ② TOC 与正文锚点一一对应 ③ 公式/代码块/frontmatter 互不污染。jiusi-notes 的 755 行,就是围绕这三条不断"打补丁"磨出来的。
给普通人的一句话:自己写渲染器,是为了把"每个字符怎么渲染"的决定权握在自己手里——代价是你要对所有边界负责。
参考
- remark / rehype 生态:https://github.com/remarkjs
- KaTeX:https://katex.org
评论
0评论加载中…