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>
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。