最近对博客进行了一次技术栈迁移,其中对 Markdown 的解析渲染支持也从 Markdown-it 系列迁移至 Unifiedjs 系列,在 Unified 的工作流程里,又包含了处理 Markdown 的 Remarkjs 系列以及处理 HTML 的 Rehypejs 系列。
在博客里, Markdown Parser 的整个工作流程都是自己管理的,包括不同结果的输出,例如:提供给 RSS 订阅用的 HTML ,提供给列表和搜索用的 Metadata ,以及提供给详情页作为 React 组件渲染内容用的 JSX ,这些过程并不算复杂,事实上进展确实是很顺利,但是在我以为即将大功告成之际,突然发现渲染出来的内容少了一个东西:我的视频呢?
改版前的表现
改版之前是使用 Markdown-it 作为技术栈, Markdown 代码与 HTML 代码的相处非常和谐,对于没有 Markdown 原生语法支持的 HTML 标签,都可以直接编写 HTML 代码进行渲染,内嵌视频最初就是这样子实现的。
像这样,在 Markdown 里直接编写 HTML 代码,即可直接输出 HTML 。
但改版后,原本应该渲染为视频的地方,都只剩下一个 <p></p> 标签,很明显是在 Markdown 代码转换过程中被过滤了。
理解 Unified 的工作流程
像我这种 Parser 流程比较长,中间处理环节还是动态变化的情况,很怕这些奇怪的问题,但还好 Unified 的设计非常清晰,先了解一下实现原理,更方便找到问题的原因。上面提到了 Unified 包含了 Remark 和 Rehype 两个系列的工作流,因为在 Unified 生态的工作过程中,都是基于 AST 语法树工作,可以简单地理解为:
- 先由 Remark 负责把 Markdown 文件的内容转为 MDAST ,在这个过程中所有代码都是围绕 Markdown 工作
- 再通过中间插件 remark-rehype ,把 MDAST 转为 HAST
- 最终由 Rehype 处理 HAST ,自此阶段开始,所有工作都是围绕 HTML 展开,最终输出什么样的结果也是在这个阶段处理
以上流程可以反过来,也就是先处理 HTML 再还原为 Markdown ,如果是这种流程,中间插件需要更换为 rehype-remark 。
名词解释:
MDAST —— Markdown Abstract Syntax Tree , Markdown 抽象语法树
HAST —— Hypertext Abstract Syntax Tree ,超文本抽象语法树
问题的排查
了解了工作流程,就可以分三个阶段排查问题了,要么就是在 Remark 环节把 HTML 代码屏蔽了,要么就是 Rehype 环节有问题,要么就是中间的 AST 转换抛弃了这部分代码。
此时 Parser 里的处理器插件是这么启用的:
这里的每一个 Plugins 变量都是一个数组,会根据我的构建场景动态启用插件(相关源码见:core/parser ),例如:
因此先仅启用 remarkPlugins ,发现一切正常,继续启用 remarkRehypePlugins ,就出问题了, Markdown 里的 <video /> 标签被过滤了。
所以我在 remark-rehype 的文档里找到了关于 HTML 标签过滤的说明:
因为在 markdown 中支持 HTML 是一项繁重的任务(性能和包大小) ,而且并不总是需要的,要同时使用两者,您还必须配置
allowDangerousHtml: true选项。 —— 详见 When should I use this?
解决方案
原因被定位到了就很好解决,目前是找到了这些解决方案,可以根据需要处理。
开启 allowDangerousHtml
根据 remark-rehype 的文档,仅需开启该选项即可支持将 Markdown 里的 HTML 代码作为半标准节点嵌入 HAST 中 raw 。
注意:除了该插件需要开启该选项之外,像我的博客还使用了 rehype-stringify 插件,它也需要一起开启该选项。
由于我还使用了 rehype-sanitize 用于对 HTML 内容的清理,因此仅开启该选项在我的博客里并不能直接达到目的,还要在 Sanitize 进行放行,并且平时写 React 组件的习惯上,我对 dangerouslySetInnerHTML 的使用非常克制,有一些代码洁癖让我不喜欢这个方案,因此我放弃了它。
将 Raw HTML 转为 HAST
在 remark-rehype 的文档里,描述 allowDangerousHtml 部分提及到了另外一个插件: rehype-raw 。
这个插件很适合希望支持渲染嵌入在 Markdown 里的 HTML(需要传递 allowDangerousHtml: true 给 remark-rehype ),它可以获取 Markdown 里的 HTML 字符串并将它们作为实际节点包含到 HAST 中。
在开启 allowDangerousHtml 选项时, Markdown 里的 HTML 代码仅作为半标准节点嵌入 HAST 中 raw 属性,但配合这个插件,可以将原始的 HTML 字符串解析为标准的 HAST 节点。
处理过程需要依赖一个完整的 HTML 解析器(详见 parse5 ),它将完全按照浏览器解析的方式重新创建抽象语法树,同时保持原始数据和位置信息完好无损。
注意在使用过程中的插件顺序:
这个方案处理过程比较繁重,但这是支持不受信任内容的唯一方法,除非类似那种内容完全由用户提交的场景,否则在内容可控的场景下,都不推荐使用这个方案。
使用 Markdown 图片语法
这是一个最轻巧的解决方案,几乎没有多余的处理成本。
因为我的博客文章详情页最终是通过 JSX 进行渲染(可参考 markup/renderer ),因此完整的处理过程是:Markdown > MDAST > HAST > HTML > JSX ,在最后一个环节使用 rehype-react 的时候,可以将 HTML 代码转换为 React 组件需要的 JSX 代码。
所以我想到了一个方案,使用 Markdown 内置的图片语法,将视频链接放在原本需要放图片链接的位置,然后在转 JSX 的过程中,判断 URL 结尾的扩展名将视频 URL 分配给 Video 组件。
事实上在文章详情里实现很完美,但我考虑到了 RSS 订阅源里的 HTML 代码并没有得到解决,并且这种方式无法配置视频的 poster 属性,所以这个方案也被我放弃了。
使用 Markdown 自定义指令
这个方案是在 GitHub Remarkjs Discussions 里搜索时找到了几个讨论,提供了很棒的灵感!
- Embed videos in markdown? #1043
- displaying video by link in react-markdown #957
- What can remark actually do / how to display videos in markdown? #557
- How to type a custom plugin on project? #747
Remark 提供了这方面的插件支持,仅需安装 remark-directive 插件,这是对 Markdown 指令语法提案 的实现(这个提案很有意思,值得阅读!),可以使用和 Markdown 十分接近的语法实现一些自定义功能。
看到这里的读者应该不会陌生,很多知名的静态生成器项目都支持自定义指令,例如:
最终实现方案
简单说一下实现方案,最终是通过编写一个 Remark 插件实现自定义指令,以 :::video 的语法,在 Markdown 内容里配置视频的 src 、 poster 、 title 属性。
源码现在维护在 @blackwork/machine/remark-video ,这里贴的代码在未来可能会有变化。
所需的依赖
先安装依赖,由于不需要在运行时使用,所以统一安装到 devDependencies 里。
这些插件的作用:
| 插件 | 作用 | 写本文时使用的版本号 |
|---|---|---|
| remark-directive | 添加对通用指令的支持 | ^3.0.0 |
| unist-util-visit | 遍历 AST 语法树节点,导出了一个 visit 方法 | ^5.0.0 |
| @types/mdast | 为 TypeScript 提供插件主要参数的类型 | ^4.0.4 |
设计时的想法
考虑到需要配置的参数如 src 和 poster 的 URL 都比较长,用 leafDirective 语法会比较难维护,因此选择了 containerDirective 语法,按照约定,从上往下分为三行内容,分别是 src 、 poster 以及 title :
其他的视频播放器属性,由指令插件统一管理,因此不需要在 Markdown 里自定义配置。
编写指令插件
按照 README 的例子,很快就能编写一个自定义插件了,这里就不赘述具体的过程,看代码和注释即可。
启用插件
在我的博客项目里,是在 core/parser 里启用插件(也就是最终提供给 unified().use() 使用 ),在使用的过程中,如果启用了另外一个 rehype-sanitize 插件,还需要在该插件的选项里配置 tagNames 和 attributes 的白名单列表。
最终结果
这就是这段 Markdown 指令渲染出来的效果(当然,不包括下面的标题展示,那是我另外包裹了一层 figure 标签,详见 parser/components ,在转换为 JSX 的时候处理的)。


















