Markdown 转 HTML 源码:内嵌 HTML 的安全处理、GFM 差异与标题 id 锚点
把 Markdown 转成 HTML 源码本身不难,麻烦出在转完要粘贴到别的地方:CMS 的正文框、网页模板、邮件正文。这时候真正要问的是:Markdown 里已经写好的 HTML 被怎么处理了?转换器按哪种 Markdown 规则解析?页面里的锚点还能不能跳转?
Markdown 里夹带的 HTML:三种处理方式
Markdown 允许直接写 HTML,转换器必须决定拿它怎么办。本站”Markdown 转 HTML”工具把这个决定做成了一个选项:
| 选项 | <b onclick="x()">hi</b> 的结果 | 适用场景 |
|---|---|---|
| 显示为文本(默认) | 页面上直接看到 <b onclick=...> | Markdown 来源不可信,或者你正在写讲解 HTML 的文章 |
| 保留为 HTML | 原样复制,onclick 也在 | Markdown 是你自己写的 |
| 删除 | 什么都不输出 | 只想要纯 Markdown 生成的内容 |
“保留”就是原样复制:凡是写成原始 HTML 的内容,都会连同 script 和事件属性一起进入输出,选了这一项,工具会在下方显示一条提醒。预览面板不受影响,因为它在禁用脚本的沙箱 iframe 里渲染;但这只保护了预览,保护不了你复制出去的那份 HTML。
Markdown 转换不等于过滤 XSS
很多人以为”转成 Markdown 就安全了”。Markdown 解析器的职责是翻译语法,大多数解析器(包括本工具使用的 marked)出于设计考虑,会把原始 HTML 直接放行。安全是另一个步骤,应该放在哪里,取决于文本是谁写的。
无论选哪种模式,工具都有一项固定的保护:用 Markdown 语法写的链接和图片,如果协议是 javascript: 之类的不安全协议,会被去除。[点我](javascript:alert(1)) 输出的只是纯文字,状态栏会告诉你去掉了几个。网页链接、mailto:、tel:、锚点和相对路径可以通过,图片还可以使用常见格式的 data:image/ 地址。但这个检查只针对 Markdown 语法的链接。你直接写的 <a href="javascript:..."> 属于原始 HTML:默认模式会把它转义成文字,“保留”模式下则原样输出。
如果你要在自己的站点上渲染不可信的 Markdown,不要依赖转换器的”保留”选项。应该把最终的 HTML 交给专门的过滤库处理,比如浏览器里的 DOMPurify,或者 Node 服务端的 sanitize-html,并且用白名单限定允许的标签和属性。过滤要放在 HTML 被插入页面的那一刻,因为只有那个位置才知道上下文。
为什么 List 不见了
在正文里写了 List<String>,输出里却只剩一个 List。这是 Markdown 的陷阱,不是转换器的 bug。按 CommonMark 的规则,<String> 是合法的 HTML 开始标签,所以会被当成原始 HTML。默认模式下,转换器把它显示成文字,什么都不会丢;但在”保留”模式下,浏览器会把它当成一个未知元素,不显示出来。<T>、<div> 写在句子里也是一样的结果。
写代码相关的内容时,请用反引号包起来(`List<String>`),或者放进围栏代码块,这样里面的 <、> 会被自动转义。
还有一个相关的坑:HTML 块里面的 Markdown 不会被解析,除非前后用空行隔开。
<div>
*hello*
</div>
在 CommonMark 里,div 里面保持字面文本 *hello*;而在 *hello* 这一行前后各加一个空行,它才会变成 <em>。如果你的文档把 HTML 包装和 Markdown 内容混在一起,请按这条规则检查转换结果。
GFM 还是纯 CommonMark
工具默认按 GitHub 风格 Markdown(GFM)解析:表格、任务列表(- [x])、~~删除线~~,以及把裸露的网址自动变成链接。把第一个选项关掉,就是纯 CommonMark,这时表格会变成用竖线隔开的普通段落。当目标环境比较严格时,这一点很重要:如果你要粘贴的地方会重新解析 Markdown 而不是接受 HTML,那只有 GFM 才有的表格就会丢失。
输出是普通的语义化 HTML,没有 class,也没有内联样式,唯一的例外是围栏代码块里 <code> 上的 language-xxx 类,highlight.js、Prism 这类代码高亮库就是靠它识别语言。最终样式由粘贴后所在页面的 CSS 决定。邮件客户端是例外:它们对 <style> 的支持参差不齐,可靠的做法是把样式写成内联,所以需要你自己补上。
换行:一个回车还是两个空格
在 CommonMark 里,段落中间的单个换行是”软换行”,渲染出来是一个空格。要强制换行,需要在行尾加两个空格,或者在换行前加一个反斜杠。不同渲染器对默认值的看法不同:有的把每个换行都当成 <br>,所以同一段文字在聊天框、评论框和 README 之间复制,形状可能不一样。选项”把单个换行当作 <br>”就是打开这种行为。写文档时建议关闭,让手工折行的源码行连成一段;记笔记或整理聊天记录时,每行末尾都敲了回车,就应该打开。
标题 id 与锚点失效(含中文标题)
page.html#what-changed 这样的链接,取决于渲染器给标题生成的 id。勾选”给标题添加 id 属性”后,工具会把标题文字转成小写、去掉标点、用连字符连接单词,遇到重复的标题再追加 -1、-2。## What changed 得到 what-changed,## Q&A: Setup 得到 qa-setup,第二个 ## Setup 得到 setup-1。任何语言的文字都会保留,所以 ## 本次变化 的 id 就是 本次变化。
别的生成器有各自的规则,有些会直接丢掉所有非 ASCII 字符,导致中文标题没有可用的锚点。按某一个生成器的 id 写好的目录或交叉引用,换一个生成器可能就失效。转换完成后,请在预览里点一下,或者直接看源码里的 id=。如果某个链接绝对不能断,就在自己的模板里显式写死 id,而不是依赖从标题推导。
中文标题的 id 本身是合法的,现代浏览器也能跳转;但把链接复制出来时会变成一串 %E6%9C%AC...,这就是前面 URL 编码那一类问题,不是 id 出了错。
片段还是完整文档
默认输出的是一个片段,适合粘贴进 CMS 的正文字段。勾选”包装成完整的 HTML 文档”后,会加上 doctype、viewport 元标签,以及取自第一个 h1 的 <title>;再加上那段小样式表,单独保存的文件就能直接阅读。“网页链接在新标签页打开”会给网页链接加上 target="_blank" 和 rel="noopener noreferrer",只作用于 http 和 https 链接。“压缩输出”会去掉标签之间的空白,但保留 <pre> 里的内容。
转换在页面里完成,粘贴内部文档也不会被上传。
如果你要的是 PDF 或图片,而不是 HTML 源码,应该用本站的 Markdown 查看与导出工具,那里才有页面尺寸、字体和分页的设置。