CPQ

程沛权

使用 remark-directive 为 Unifiedjs 提供 Markdown 视频语法的解析

程沛权2024/11/10 01:34:05

最近对博客进行了一次技术栈迁移,其中对 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 。

<video 
  src="https://cdn.chengpeiquan.com/video/my-cats-in-mountain-view-room.mp4" 
  poster="https://cdn.chengpeiquan.com/img/2022/12/20221231235941.jpg?x-oss-process=image/interlace,1"
  title="山景房里的三只猫"
  controls
  preload="auto"
/>

但改版后,原本应该渲染为视频的地方,都只剩下一个 <p></p> 标签,很明显是在 Markdown 代码转换过程中被过滤了。

理解 Unified 的工作流程

像我这种 Parser 流程比较长,中间处理环节还是动态变化的情况,很怕这些奇怪的问题,但还好 Unified 的设计非常清晰,先了解一下实现原理,更方便找到问题的原因。上面提到了 Unified 包含了 Remark 和 Rehype 两个系列的工作流,因为在 Unified 生态的工作过程中,都是基于 AST 语法树工作,可以简单地理解为:

  1. 先由 Remark 负责把 Markdown 文件的内容转为 MDAST ,在这个过程中所有代码都是围绕 Markdown 工作
  2. 再通过中间插件 remark-rehype ,把 MDAST 转为 HAST
  3. 最终由 Rehype 处理 HAST ,自此阶段开始,所有工作都是围绕 HTML 展开,最终输出什么样的结果也是在这个阶段处理

以上流程可以反过来,也就是先处理 HTML 再还原为 Markdown ,如果是这种流程,中间插件需要更换为 rehype-remark

名词解释:

MDAST —— Markdown Abstract Syntax Tree , Markdown 抽象语法树

HAST —— Hypertext Abstract Syntax Tree ,超文本抽象语法树

问题的排查

了解了工作流程,就可以分三个阶段排查问题了,要么就是在 Remark 环节把 HTML 代码屏蔽了,要么就是 Rehype 环节有问题,要么就是中间的 AST 转换抛弃了这部分代码。

此时 Parser 里的处理器插件是这么启用的:

const processor = unified()
  .use(remarkPlugins) // Markdown to MDAST
  .use(remarkRehypePlugins) // MDAST to HAST
  .use(rehypePlugins) // HAST to HTML
  .use(reactPlugins) // HTML to JSX

const file = await processor.process(markdown)

这里的每一个 Plugins 变量都是一个数组,会根据我的构建场景动态启用插件(相关源码见:core/parser ),例如:

import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkStringify from 'remark-stringify'
import { type PluggableList } from 'unified'

const remarkPlugins: PluggableList = [
  [remarkParse], // e.g. [plugin, pluginOptions]
  [remarkGfm],
  [remarkStringify],
]

因此先仅启用 remarkPlugins ,发现一切正常,继续启用 remarkRehypePlugins ,就出问题了, Markdown 里的 <video /> 标签被过滤了。

所以我在 remark-rehype 的文档里找到了关于 HTML 标签过滤的说明:

因为在 markdown 中支持 HTML 是一项繁重的任务(性能和包大小) ,而且并不总是需要的,要同时使用两者,您还必须配置 allowDangerousHtml: true 选项。 —— 详见 When should I use this?

解决方案

原因被定位到了就很好解决,目前是找到了这些解决方案,可以根据需要处理。

开启 allowDangerousHtml

根据 remark-rehype 的文档,仅需开启该选项即可支持将 Markdown 里的 HTML 代码作为半标准节点嵌入 HAST 中 raw

import remarkRehype from 'remark-rehype'
import { type PluggableList } from 'unified'

const remarkRehypePlugins: PluggableList = [
  [remarkRehype, { allowDangerousHtml: true }],
]

注意:除了该插件需要开启该选项之外,像我的博客还使用了 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 ),它将完全按照浏览器解析的方式重新创建抽象语法树,同时保持原始数据和位置信息完好无损。

注意在使用过程中的插件顺序:

import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rehypeRaw from 'rehype-raw'
import { type PluggableList } from 'unified'

const remarkRehypePlugins: PluggableList = [
  [remarkRehype, { allowDangerousHtml: true }],
]

const rehypePlugins: PluggableList = [
  [rehypeStringify, { allowDangerousHtml: true }],
  [rehypeRaw],
]

这个方案处理过程比较繁重,但这是支持不受信任内容的唯一方法,除非类似那种内容完全由用户提交的场景,否则在内容可控的场景下,都不推荐使用这个方案。

使用 Markdown 图片语法

这是一个最轻巧的解决方案,几乎没有多余的处理成本。

因为我的博客文章详情页最终是通过 JSX 进行渲染(可参考 markup/renderer ),因此完整的处理过程是:Markdown > MDAST > HAST > HTML > JSX ,在最后一个环节使用 rehype-react 的时候,可以将 HTML 代码转换为 React 组件需要的 JSX 代码。

import rehypeReact, { type Options as RehypeReactOptions } from 'rehype-react'
import { a, img } from './components'
import { Fragment, jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime'
import { type PluggableList } from 'unified'

const components = {
  a, // e.g. `<a />` --> Next.js `<Link />`
  img, // e.g. `<img />` --> Next.js `<Image />`
} as unknown as RehypeReactOptions['components']

const rehypeReactOptions = {
  Fragment,
  components, // e.g. Record<tagName, componentName>
  ignoreInvalidStyle: true,
  jsx,
  jsxs,
  passKeys: true,
  passNode: true,
  development: false,
} satisfies RehypeReactOptions

const reactPlugins: PluggableList = [[rehypeReact, rehypeReactOptions]]

所以我想到了一个方案,使用 Markdown 内置的图片语法,将视频链接放在原本需要放图片链接的位置,然后在转 JSX 的过程中,判断 URL 结尾的扩展名将视频 URL 分配给 Video 组件。

![山景房里的三只猫](https://cdn.chengpeiquan.com/video/my-cats-in-mountain-view-room.mp4)
// components.tsx

const video = async (props: React.VideoHTMLAttributes<HTMLVideoElement>) => {
  // 组件里的其它逻辑
  // ...

  return <video {...props} />
}

export const img = async (props: React.ImgHTMLAttributes<HTMLImageElement>) => {
  // 判断常用的视频文件扩展名,将其转发给视频组件渲染
  if (props?.src?.endsWith('.mp4')) {
    return <video {...props} />
  }

  // 组件里的其它逻辑
  // ...

  return <img {...props} />
}

事实上在文章详情里实现很完美,但我考虑到了 RSS 订阅源里的 HTML 代码并没有得到解决,并且这种方式无法配置视频的 poster 属性,所以这个方案也被我放弃了。

使用 Markdown 自定义指令

这个方案是在 GitHub Remarkjs Discussions 里搜索时找到了几个讨论,提供了很棒的灵感!

Remark 提供了这方面的插件支持,仅需安装 remark-directive 插件,这是对 Markdown 指令语法提案 的实现(这个提案很有意思,值得阅读!),可以使用和 Markdown 十分接近的语法实现一些自定义功能。

看到这里的读者应该不会陌生,很多知名的静态生成器项目都支持自定义指令,例如:

最终实现方案

简单说一下实现方案,最终是通过编写一个 Remark 插件实现自定义指令,以 :::video 的语法,在 Markdown 内容里配置视频的 srcpostertitle 属性。

源码在 plugins/remark-video ,这里贴的代码在未来可能会有变化。

所需的依赖

先安装依赖,由于不需要在运行时使用,所以统一安装到 devDependencies 里。

pnpm add -D remark-directive unist-util-visit @types/mdast

这些插件的作用:

插件作用写本文时使用的版本号
remark-directive添加对通用指令的支持^3.0.0
unist-util-visit遍历 AST 语法树节点,导出了一个 visit 方法^5.0.0
@types/mdast为 TypeScript 提供插件主要参数的类型^4.0.4

设计时的想法

考虑到需要配置的参数如 srcposter 的 URL 都比较长,用 leafDirective 语法会比较难维护,因此选择了 containerDirective 语法,按照约定,从上往下分为三行内容,分别是 srcposter 以及 title

:::video
https://example.com/video-src.mp4
https://example.com/video-poster.jpg
A video title
:::

其他的视频播放器属性,由指令插件统一管理,因此不需要在 Markdown 里自定义配置。

编写指令插件

按照 README 的例子,很快就能编写一个自定义插件了,这里就不赘述具体的过程,看代码和注释即可。

import { type Root } from 'mdast'
import { visit } from 'unist-util-visit'
import { isArray, isObject, isString } from '@bassist/utils'

// For the `src` and `poster` attributes
interface LinkNode {
  type: 'link'
  url: string
  children?: unknown[]
  [key: string]: unknown
}

// For the `title` attribute
interface TextNode {
  type: 'text'
  value: string
  [key: string]: unknown
}

interface VideoDirectiveNodeChildren {
  children: (TextNode | LinkNode | unknown)[]
}

interface VideoDirectiveNode {
  type: 'containerDirective'
  name: 'video'
  children: VideoDirectiveNodeChildren[]
  [key: string]: unknown
}

interface HyperScriptData {
  hName?: string
  hProperties?: {
    [key: string]: unknown
  }
  [key: string]: unknown
}

/**
 * With container directive
 *
 * @example
 *
 * ```md
 * :::video
 * src
 * poster
 * title
 * :::
 * ```
 */
const isVideoNode = (i: unknown): i is VideoDirectiveNode => {
  if (!isObject(i)) return false
  const children = i?.children?.[0]?.children
  return (
    i.type === 'containerDirective' &&
    i.name === 'video' &&
    isArray(children) &&
    children.length > 0
  )
}

const isLinkNode = (i: unknown): i is LinkNode => {
  return isObject(i) && i.type === 'link' && isString(i.url) && !!i.url
}

const isTextNode = (i: unknown): i is TextNode => {
  return isObject(i) && i.type === 'text' && isString(i.value) && !!i.value
}

const isValidChildNode = (i: unknown) => isLinkNode(i) || isTextNode(i)

/**
 *
 * @description
 *
 * I have customized a compilation process in Markdown Parser,
 * so not all HTML codes are allowed to be rendered.
 *
 * When Markdown is being converted to AST,
 * many HTML tags will be discarded, and the same is true for Video.
 *
 * In order to uniformly implement custom rendering content,
 * this plugin implements the ability of `video` directive.
 *
 * One more important thing, since rehype-sanitize is enabled,
 * remember to configure the options to allow
 * rendering of video tags and attributes.
 *
 * @example
 *
 * Enter the following into the markdown file:
 *
 * ```md
 * :::video
 * https://example.com/video-src.mp4
 * https://example.com/video-poster.jpg
 * A video title
 * :::
 * ```
 *
 * Compile and output a Video tag:
 *
 * ```html
 * <video
 *  src="https://example.com/video-src.mp4"
 *  poster="https://example.com/video-poster.jpg"
 *  title="Hello World"
 * />
 * ```
 *
 * @returns Transformer
 */
const remarkVideo = () => {
  return (tree: Root) => {
    // Prevents the following judgment from being inferred as never
    visit(tree, (node: unknown) => {
      if (!isVideoNode(node)) return

      const [srcNode, posterNode, titleNode] = node.children[0].children
        .map((i) => {
          if (isLinkNode(i)) return i
          if (isTextNode(i)) {
            i.value = i.value.replace(/\n/g, '').trim()
            if (i.value) return i
          }
          return undefined
        })
        .filter(isValidChildNode)

      const src = srcNode.url
      const poster = posterNode.url
      const title = titleNode.value

      const data = (node.data || (node.data = {})) as HyperScriptData
      data.hName = 'video'
      data.hProperties = {
        src,
        poster,
        title,
        controls: true,
        preload: 'auto',
        className: 'w-full aspect-video rounded-lg',
      }
    })
  }
}

export default remarkVideo

启用插件

在我的博客项目里,是在 core/parser 里启用插件(也就是最终提供给 unified().use() 使用 ),在使用的过程中,如果启用了另外一个 rehype-sanitize 插件,还需要在该插件的选项里配置 tagNamesattributes 的白名单列表。

import remarkDirective from 'remark-directive'
import remarkVideo from './plugins/remark-video'
import { type PluggableList } from 'unified'

const remarkPlugins: PluggableList = [
  [remarkParse],
  [remarkDirective],
  [remarkVideo],
]

const rehypePlugins: PluggableList = [
  // ...
  [
    rehypeSanitize,
    {
      // No need `user-content-` prefix
      clobberPrefix: '',
      // https://github.com/syntax-tree/hast-util-sanitize#tagnames
      tagNames: [...toArray(defaultSchema.tagNames), 'video'],
      // https://github.com/syntax-tree/hast-util-sanitize#attributes
      attributes: {
        ...(defaultSchema.attributes || {}),
        video: ['src', 'poster', 'controls', 'preload', 'className'],
      },
    },
  ],
  // ...
]

// ...

最终结果

这就是这段 Markdown 指令渲染出来的效果(当然,不包括下面的标题展示,那是我另外包裹了一层 figure 标签,详见 parser/components ,在转换为 JSX 的时候处理的)。

:::video
https://cdn.chengpeiquan.com/video/my-cats-in-mountain-view-room.mp4
https://cdn.chengpeiquan.com/img/2022/12/20221231235941.jpg?x-oss-process=image/interlace,1
山景房里的三只猫
:::
山景房里的三只猫