OneKuma

OneKuma's Blog

One Lonely Kuma.
github
bilibili
twitter

重新发明 mdx —— 渲染不受信任的 markdown

故事开始于标题的后半段 —— 客户端渲染组件化的不受信任的 markdown。

我们希望探索如此的一个客户端渲染 markdown 的方案。但是在 Vue 社区,通常做法是直接设置 v-html,这就导致 难以传入自定义组件 进去。而本文的主角 mdx 可以拿到 markdown 文档的 AST,将其使用 JSX 进行渲染,这天然就支持组件化,毕竟是 JSX。

但是,问题是 mdx 自己对自己的定位是一个编程语言,一般其都是在 bundler 的编译期进行预渲染;又因为其使用 ES ModuleJSX 需要动态执行 JavaScript 代码。这些原因导致其不能渲染不受信任的 markdown 文件

于是,故事继续于重新发明 mdx ——

什么是 mdx?#

mdx 是一种书写格式,允许你在 Markdown 文档中无缝地插入 JSX 代码。 你还可以导入(import)组件,例如交互式图表或弹框,并将它们嵌入到你所书写的内容当中。 这让利用组件来编写较长的内容成为了一场革命。

# Hello, world!

<div className="note">
  > Some notable things in a block quote!
</div>
import { year } from './data.js'

export const name = 'world'

# Hello {name.toUpperCase()}

The current year is {year} 

这两个代码片段很能展现 mdx 的特性。快速总结一下,大概就是给 markdown 加上了:

  • ESM Moduleimport 语法:支持从别的地方引入组件、数据等以供使用
  • ESM Moduleexport 语法:支持在 mdx 文件里写一些代码,定义组件、数据等以供使用
  • JSX 的 XML 标签<div>...</div>
  • JSX 的 表达式{name.toUpperCase()}

当然也有一些其他 markdown 扩展语法:

为什么不选择重新发明(魔改)它们的方案?

  • 最主要的问题,基本就是不够流行,似乎使用的人不是很多;
  • 另一个问题是,这两者引入了一些 markdown 和 HTML 外的语法。

比如他们大概会用两个冒号包了一个 alert 组件块出来:

::alert{:type="type"}
Your warning
::

当然,这里我们不争执,哪种语法更好等等类似的话题。往往这也吵不出个所以然来,多半还是看使用者的习惯taste 等等方面的因素。

但是不可否认的是,JSX 也就是所谓的 JavaScript + XML,其中的 XML 的语法看起来是和 HTML 是一样的。无论是对于 Web 的有经验者,那直接就是平时使用的 HTML,而且对于 React 用户来说那 JSX 更加熟悉不过了;还有单纯编辑文档的文字工作者,他们可能是第一次学习写 HTML 或者 XML 语法的东西,但是考虑到 HTML 有更加丰富的社区内容,有非常多的教程可以学习,有非常多的代码片段可以拿过来不加转换的使用,同时 XML 也会可能成为一种潜在的通用技能,方便学习了一次之后迁移到其他的领域。

总而言之,我更偏向于直接使用 markdown + JSX 这样的语法方案,对用户的上手成本更低,技能也更加通用

mdx 的问题?#

mdx 对自己的定位其实是一个编程语言,它在文档里这么描述:

请牢记,MDX 是一门编程语言。 如果你相信你的用户,那就岁月静好。 但是一定要当心用户输入的内容,不要让任何人 上传 MDX 内容。 如果您确有需要,请同时使用 <iframe>sandbox,不过 security is hard, and that doesn’t seem to be 100%. 对于 Node,vm2 值得一试。 但是你还是应该使用 Docker 之类的工具对整个操作系统做沙箱化处理、 以及限制执行频率、以及在进程占用太多执行时间时能够将其 终止。

当然,这样的设计没有任何问题,你可以在各种文档、SSG 之类的场景里随意使用,交给 bundler 打包或者预渲染你的 mdx 代码,你的 mdx 代码是开发者可控的

但是,如果我们希望在客户端能够渲染组件化的不受信任的 markdown 呢?

那么,mdx 这样的方案就会有很多问题了:

  • 众所周知,运行不受信任的代码是一件危险的事情,在渲染文档、配置文件等等场景里面提供可以运行任意代码的功能是不好的,尤其是这些格式内容可能来源不受信任;
  • 其次,它的 ESM 的 import 和 export 在这样的场景里也不合理;
  • 最后,mdx 依赖一个 JS 编译器,即 acron,打包一个这个东西给客户端感觉不太行。

重新发明 mdx 的一个子集#

于是,造了个轮子 —— mdio

目标是移除 mdx 里 JSX 的动态语法。除了开发者能够传入的动态参数自定义组件,其他东西都可以静态推导出来,没有任何代码执行的功能,只能使用传入的信息。它的特性看起来是这样的:

---
title: 123123123
tags: [t1, t2, t3]
---

# Hello World

<TagList />

1. list 1
2. list 2

Some text format, **bold**. The title is {frontmatter.title}.

<InfoBox name={"hello"} info={{"key":"value"}} list={[1,2,3]} box={null} />

<div>
  Raw html is ok
</div>

大概就是 markdown 加上了 JSONXJSON + XML(误)。

  • XML 标签<div>...</div>
  • JSON 表达式:大括号包裹一个合法的 JSON 表达式,例如:{1}{"text"}{{"key":"value"}}{null}
  • Access Path 表达式:大括号包裹一个引用 frontmatter 或者传入的环境变量的访问表达式,例如:{frontmatter.title}{env.abc.def[0].ghi}(目前仅计划支持静态确定的 field 和数组下标访问)

除此以外还需要提供一些相应的外围设施,客户端渲染的组件(以 Vue 为例):

  • 解析 mdio 语法到 AST,然后使用 Vue JSX 转换成对应的 VNode 渲染给用户;
  • 从 props 里接受 Vue 组件(甚至无需做任何修改,就可以支持通过 dynamic import 导入异步组件),将解析出来的 AST 中替换真实的 Vue 组件;
  • 解析 mdio 中的 frontmatter 里的 YAML 格式数据,可以自动将所有环境传入自定义组件;
  • Composable 函数:将文档的 frontmatter 和 AST 等信息直接 provide 给文档内深层嵌套的自定义组件。

对于上述代码,使用起来的感觉就是:

<script setup lang="ts">
import { Markdown } from '@breadio/vue';

const content = `... mdio 语法的文档字符串`;

// 定义了一些 dynamic import 的异步组件
const components = {
  InfoBox: defineAsyncComponent(() => import('~/components/InfoBox.vue')),
  TagList: defineAsyncComponent(() => import('~/components/TagList.vue')),
};
</script>

<template>
  <Markdown :content="kuma" :components="components"></Markdown>
</template>
<script setup lang="ts">
// TagList.vue
// 自动传递了 frontmatter props, 可以直接使用
const props = defineProps<{ frontmatter?: { tags?: string[] } }>();

// 也可以使用 composable 函数获取信息
// import { useWikiContent } from '@breadio/vue'
// const { frontmatter } = useWikiContent();
</script>

<template>
  <p class="tag-list space-x-2">
    <span class="font-bold">标签:</span>
    <span
      v-for="t in props.frontmatter?.tags ?? []"
      :key="t"
      class="rounded py-1 px-2 bg-gray-100"
      >{{ t }}</span
    >
  </p>
</template>

这就是 mdio,一个 mdx 的子集,移除了 mdx 的动态语法,用于支持在客户端内渲染组件化的不受信任的 markdown。接下来,我们就需要魔改 mdx 的编译器,来支持 mdio 想要的功能。

魔改 mdx 的过程#

由于 mdx 依赖的 unified / remark 生态比较复杂,当中涉及非常非常多的包。

unified 是一个通用的解析文本的框架,它的插件生态系统中有很多东西,其中一个就是 remark,用于解析 markdown。mdx 就是在这个生态系统的基础上构建的。

为了知道我们如果修改到我们想要的结果,下面首先对 mdx 解析的主要流程和依赖进行一个源码的分析。

实际上,你自己跟着它的 import 点一遍也能看个大概,但是由于真的真的涉及太多包了,搜的非常累,所以我贴心的帮你贴了链接和图片,你可以直接跟着看。

mdx 源码分析#

@mdx-js/mdx 包是 mdx 项目整体的入口,暴露了一堆 compileevaluate 之类的核心接口,核心解析 mdx 的函数在 src/core.js 这个文件内,大概意思就是创建了一个 unified 实例。

image

可以发现里面加了一堆插件,我也没细看具体每个都是干什么的,总之可以发现他引用了一个 remark-mdx 插件,就是我们想要看的。


remark-mdx 插件位于同一个 monorepo 里,用于解析 mdx 语法。它做的事情非常简单,包装了另外几个插件。

image

remark#

至此,需要具体解释一下 remark 项目的结构,然后再说这几个看起来和 mdx 相关的插件。

remark 项目是一个 unified 插件或者说解析器,用于将 markdown 解析为 AST,将 AST 转换到各种格式上等等和 markdown 有关的功能。

remark 这个包是对 unified 的一个包装,内部创建一个 unified 实例,并添加 markdown 解析相关的插件。

image

其中包括 2 个插件 remark-parseremark-stringify,这里我们只关注 remark-parse,也就是一个 markdown 编译器插件。


可以发现 remark-parse 又套了一层,是对 mdast-util-from-markdown 插件的封装。

image

这里涉及到了一些东西,解释一下:mdast 是 markdown AST 的缩写,也就是 markdown 的抽象语法树表示。@types/mdast 包含了抽象语法树的定义,其它的 mdast-util- 开头的东西是各种 mdast 相关的包。另外,后面可能会见到 hast,是 HTML AST 的缩写,是 HTML 的抽象语法树表示。

除此以外,可以看到他这里添加了 2 种插件。

一是 micromark 插件micromark 是一个 markdown 的解析器,可以独立于 remark 使用。remark-parse 的 markdown 解析功能实际上就是由 micromark 提供,因此 micromark 的插件也可以拿过来给 remark 进行使用,核心的解析逻辑都在 micromark 里。

二是 fromMarkdown 插件。这里涉及到编译原理的一些基础知识,大概就是源码,经过词法分析器,变成 Token 流,在经过语法分析器,得到一个中间表示,通常就是 AST。他这里的 micromark 只会将 markdown 解析成某种带有语法结构的 Token 流(源码里称为 Events),也就是它这个词法分析和语法分析是做在一起的(经过一个 LL1 的递归下降解析器)。然后 fromMarkdown 插件会将这个带有语法结构的 Token 流,生成一棵 AST(这里因为是解析 markdown,所以是 mdast)。


然后,点到 mdast-util-from-markdown 里,可以看到他确实是直接使用了 micromark 提供的编译机制。

image

image

然后,mdast-util-from-markdown 这个包底下还写了很多东西。具体作用就是上文说的,将 micromark 解析出来的 Events 流,转换成 mdast 这种抽象语法树结构
我们先大概看到这里就差不多了,micromark 就是真正负责解析 markdown 的包了,里面实现了一个 LL1 的 parser,具体就不细看了。简单说一种可能的后续流程,mdast 转成 hast,然后 hast 序列化到 HTML 或者用 JSX 渲染出来。

总结一下目前经过了什么东西:

mdx 相关的插件#

回到 remark-mdx 这个包来,根据上面的源码分析,我们容易知道:

  • micromark-extension-mdxjs 这个包,对应代码里的 mdxjs,是一个 micromark 插件用于扩展原有的 markdown 语法;
  • mdast-util-mdx 这个包,对应代码里的 mdxFromMarkdownmdxToMarkdown 也就是用于从 Events 流构造 mdast 和从 mdast 序列化到 markdown 文本。

image


那么,我们首先看 micromark-extension-mdxjs

image

经典操作来了,还是包了好几个插件。每个插件基本就是具体的逻辑了,因此就看到这里,此处就分别介绍一下功能:

  1. micromark-extension-mdxjs-esm:ESM import /export 语法支持
  2. micromark-extension-mdx-expression:JSX 的大括号 { ... } 表达式
  3. micromark-extension-mdx-jsx:JSX 里的 XML 标签语法
  4. micromark-extension-mdx-md:关闭一些 markdown 功能

然后,我们回头看 mdast-util-mdx

image

依旧是经典的插件套娃。可以发现插件名字还是那几种,只不过变成 mdast 的插件,功能也从解析 markdown 变成生成 mdast 了,具体添加的特性支持同上。

总结一下 mdx 相关的插件,remark-mdx -> micromark-extension-mdxjsmdast-util-mdx,分别对应 micromark 和 mdast 两个阶段。然后这 2 个包,又因为可以选择支持 mdx 的特性,主要分为了 3 块:

  1. ESM import /export 语法支持
  2. JSX 的大括号 { ... } 表达式
  3. JSX 里的 XML 标签语法

修改编译 JSX 到编译 JSONX#

至此,我们如何魔改 mdx 的思路逐渐清晰,对应上面 3 块特性就是:

  1. 移除 ESM import /export 的支持,只需要不引入插件就行了
  2. 魔改对于 JSX 的大括号 { ... } 表达式的支持
    • 直接进行 JSON.parse
    • 手写一个支持 a.b.c.d[0].e.f 的编译器
  3. XML 标签语法无需修改(也可以移除 <div {...obj}></div> 等的支持,不赘述)

那么,我们其实只需要修改对 JS 编译方法即可,具体位于 micromark-extension-mdx-expression 里的 micromark-util-events-to-acorn 内。当然,我们省略了其它的一些修改:

  • 将原有的代码的 JSDoc 转换成 TypeScript(感谢 GPT4 的辅助)
  • 选项的传参,JSONX 相关数据对应的传参方法等等
  • 移除 JSX 的编译
  • 修改各种 AST 节点的名字的定义

micromark-util-events-to-acorn 这个包的编译 JS 的核心代码,差不多就是这一块:

image

换成我们想要的,大概就是这种感觉:

image

在第 145 行,我们直接进行一个 JSON.parse,获得里面的 JSON 数据。

如果 JSON.parse 报错,那么在第 152 到 156 行,尝试对其进行一个 access path 的分割,这里只写了一个简单版本,按照 . 进行分割(懒得写数组下标了)。

扩展 mdast 转换#

在上一部分,我们已经把 mdio 的 mdast 给搞出来了,接下来还需要实现 mdio 里的 mdast 节点转换到真实的 hast 节点。具体的就是,给 remark-rehype 这个库传递 mdio 节点对应的处理函数,大概是下面代码的感觉。

const mdioHandlers: ToHastHandlers = {
  MdioTextElement(state, node: MdioTextElement | MdioFlowElement) {
    // Handle Fragment
    if (!node.name) {
      if (node.children.length > 0) {
        return state.all(node);
      } else {
        return undefined;
      }
    }

    const properties: Record<
      string,
      boolean | number | string | null | undefined | Array<string | number>
    > = {};

    for (const attr of node.attributes) {
      if (attr.type === 'MdioAttribute') {
        if (attr.value === null || attr.value === undefined || typeof attr.value === 'string') {
          properties[attr.name] = attr.value ?? '';
        } else if (attr.value.type === 'MdioAttributeValueExpression') {
          if (/^[A-Z]/.test(node.name)) {
            // For custom components, we directly use raw json data
            properties[attr.name] = attr.value.data?.json;
          } else {
            // For builtin dom, parse JSON string
            try {
              properties[attr.name] = JSON.stringify(attr.value.data?.json);
            } catch (_error) {
              properties[attr.name] = '';
            }
          }
        }
      }
    }

    return {
      type: 'element',
      tagName: node.name,
      properties,
      position: node.position,
      children: state.all(node)
    };
  },
  MdioTextExpression(state, node: MdioTextExpression) {
    if (node.data?.json) {
      const json = node.data.json;
      if (typeof json === 'string' || typeof json === 'number' || typeof json === 'bigint') {
        return {
          type: 'text',
          position: node.position,
          value: '' + json
        };
      } else if (typeof json === 'object') {
        try {
          return {
            type: 'text',
            position: node.position,
            value: JSON.stringify(json)
          };
        } catch (error) {}
      }
    }
    return undefined;
  }
};
  
  const processor = unified()
    .use(remarkParse)
    .use(mdio)
    .use(remarkGfm)
    .use(remarkRehype, {
      handlers: mdioHandlers,
      passThrough: [
        'MdioFlowExpression',
        'MdioFlowElement',
        'MdioTextElement',
        'MdioTextExpression',
      ]
    })

因为,AST 节点的结构基本都很复杂,所以差不多就是按照类型定义,指导我们如何把 hast 节点拼出来。实际运行起来 debug 才能知道在干什么,因此不具体描述。


除此以外,为了支持 access path 访问对象的 field,还需要遍历一下 mdast,将其替换为真实的结果。同上,因为类型定义复杂,需要 debug 才能明白,不具体解释。底下的 rewrite 函数用于通过 access path 获取到真实的 field。

function rewriteVariables(root: MdastRoot, env: Record<string, any>) {
  visit(root, function (node: MdastNodes) {
    if (node.type === 'MdioFlowExpression' || node.type === 'MdioTextExpression') {
      if (node.data?.path) {
        const real = rewrite(node.data.path);
        if (node.type === 'MdioTextExpression') {
          // @ts-expect-error ts2322
          node.type = 'text';
          node.value = real;
        }
      }
    } else if (node.type === 'MdioFlowElement' || node.type === 'MdioTextElement') {
      for (const attr of node.attributes) {
        if (attr.type === 'MdioExpressionAttribute' && attr.data?.path) {
          const real = rewrite(attr.data.path);
          attr.value = real;
        } else if (
          attr.type === 'MdioAttribute' &&
          attr.value &&
          typeof attr.value !== 'string' &&
          attr.value.data?.path
        ) {
          attr.value = rewrite(attr.value.data.path);
        }
      }
    }
  });

  function rewrite(path?: AccessPath) {
    if (!Array.isArray(path) || path.length === 0) return undefined;
    let cur: any = env;
    try {
      for (const p of path ?? []) {
        if (p in cur) {
          cur = cur[p];
        } else {
          cur = undefined;
          break;
        }
      }
    } catch (_error) {
      cur = undefined;
    }
    if (cur) {
      // TODO: handle more cases
      return cur.toString();
    } else {
      return '';
    }
  }
}

一些 corner case 的处理#

一是,变成了 JSX 之后就不支持原本的 HTML 注释语法了 <!-- -->,可以考虑扩展一下 XML 标签语法的解析,也可以考虑干脆先拿正则过一遍都可以。但是添加这个支持,就不是 mdx 的严格子集了。

二是,在 JSX 里写原本的 markdown 语法时,可能会导致一些元素被 p 标签额外包裹。看下面这个例子:

<table>
    <thead>
        <tr class="header">
            <th><p>播放地區</p></th>
            <th><p>播放平台</p></th>
            <th><p>播放日期</p></th>
            <th><p>播放時間(<a href="UTC+8" title="wikilink">UTC+8</a></p></th>
            <th><p>字幕語言</p></th>
            <th><p>備註</p></th>
        </tr>
    </thead>
    <tbody></tbody>
</table>

经过 mdx 的编译,会生成类似下面的结果:

table
- thead
  - p  // <---
    - tr
      - th1
      - th2
      - ...
- tbody

注意到 thead 这个 XML 元素会被意外的多包裹一层。在 mdx 里有这个插件 remark-mark-and-unravel,用于将只有包裹一个孩子节点的节点,也就是这个不必要的二度节点进行消除。

其它还有一些 mdx 里手写的插件 的作用还没看,等遇到问题再说。

Vue 组件#

最后,用 Vue 组件包装一下,额外接收一个 components 参数,用于将 hast 转换为 JSX 时,将自定义组件替换为对应的 Vue 组件构造器。

import type { VNode } from '@vue/runtime-dom';
import type { Root as HastRoot } from 'hast';

import { visit } from 'unist-util-visit';
import { Fragment, jsx } from 'vue/jsx-runtime';
import { type DefineComponent, computed, defineComponent, h } from 'vue';

import { ParseResult, createParser, toJsxRuntime } from '@breadio/markdown';

export const Markdown = defineComponent({
  name: 'Markdown',
  inheritAttrs: true,
  props: {
    parsed: {
      type: Object,
      required: false
    },
    content: {
      type: String,
      required: false
    },
    components: {
      type: Object,
      required: false
    }
  },
  setup(props, attrs) {
    const parser = createParser();

    const parsed = computed(() => {
      if (props.parsed) {
        return props.parsed as ParseResult<any>;
      } else {
        try {
          const result = parser.parseSync(props.content ?? '');
          return result;
        } catch (error) {
          return undefined;
        }
      }
    });
    const hast = computed(() => {
      if (parsed.value?.hast) {
        const comps = unifyVueHast(parsed.value.hast, {
          frontmatter: parsed.value?.frontmatter
        });
      }
      return parsed.value?.hast;
    });
    const frontmatter = computed(() => parsed.value?.frontmatter);

    return () => {
      const header = attrs.slots.header?.({ frontmatter: frontmatter.value });
      const children = hast.value
        ? (toJsxRuntime(hast.value, {
            components: props.components,
            Fragment,
            // @ts-expect-error ts2322
            jsx,
            // @ts-expect-error ts2322
            jsxs: jsx,
            elementAttributeNameCase: 'html'
          }) as VNode)
        : null;
      const footer = attrs.slots.footer?.({ frontmatter: frontmatter.value });

      return h('div', null, [header, children, footer]);
    };
  }
});

function unifyVueHast(root: HastRoot, env: Record<string, any>) {
  const components = new Set<string>();
  visit(root, function (node) {
    if (node.type === 'element' && /^[A-Z]/.test(node.tagName)) {
      node.properties = { ...node.properties, ...env };
      components.add(node.tagName);
    }
  });
  return components;
}

于是,我们就能这样使用 mdio 了。

<script setup lang="ts">
import { Markdown } from '@breadio/vue';

const content = `... mdio 语法的文档字符串`;

// 定义了一些 dynamic import 的异步组件
const components = {
  InfoBox: defineAsyncComponent(() => import('~/components/InfoBox.vue')),
  TagList: defineAsyncComponent(() => import('~/components/TagList.vue')),
};
</script>

<template>
  <Markdown :content="kuma" :components="components"></Markdown>
</template>
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.