OneKuma

OneKuma's Blog

One Lonely Kuma.
github
bilibili
twitter

mdxを再発明する —— 信頼されていないMarkdownのレンダリング

物語はタイトルの後半部分から始まります —— クライアントレンダリングコンポーネント化された信頼されていないmarkdown。

私たちはそのようなクライアントレンダリング markdown のソリューションを探求したいと考えています。しかし、Vue コミュニティでは、通常のやり方は直接v-htmlを設定することで、これがカスタムコンポーネントを渡すのが難しいという結果を招きます。そしてこの記事の主役であるmdxは、markdown 文書の AST を取得し、それを JSX でレンダリングすることができ、これは自然にコンポーネント化をサポートします。結局のところ、JSX なのですから。

しかし、問題はmdx自身が自らをプログラミング言語と位置付けていることです。一般的には、バンドラーのコンパイル時にプリレンダリングが行われます。また、ES モジュールJSXを使用するためにはJavaScript コードを動的に実行する必要があります。これらの理由により、信頼されていない markdown ファイルをレンダリングすることができません

そこで、物語はmdxを再発明することに続きます ——

mdx とは?#

mdxは、Markdown 文書に JSX コードをシームレスに挿入できる書き込み形式です。インタラクティブなチャートやポップアップなどのコンポーネントをインポート(import)し、書いている内容に埋め込むこともできます。これにより、コンポーネントを利用して長いコンテンツを書くことが革命的になりました。

# こんにちは、世界!

<div className="note">
  > ブロック引用の中の注目すべきこと!
</div>
import { year } from './data.js'

export const name = 'world'

# こんにちは {name.toUpperCase()}

現在の年は {year} です 

これらの 2 つのコードスニペットは、mdxの特性をよく示しています。簡単にまとめると、markdown に以下の機能が追加されました:

  • ESM モジュールimport構文:他の場所からコンポーネントやデータを引き入れることをサポート
  • ESM モジュールexport構文:mdx ファイル内にいくつかのコードを書き、コンポーネントやデータを定義することをサポート
  • JSX のXML タグ<div>...</div>
  • JSX の{name.toUpperCase()}

もちろん、他にもいくつかの markdown 拡張構文があります:

なぜそれらのソリューションを再発明(魔改)しないのでしょうか?

  • 主な問題は、基本的に人気がないことです。使用している人はあまり多くないようです;
  • もう一つの問題は、これらの 2 つが markdown や HTML 以外の構文を導入していることです。

例えば、彼らはおそらく 2 つのコロンでalertコンポーネントブロックを包むでしょう:

::alert{:type="type"}
あなたの警告
::

もちろん、ここで私たちはどちらの構文がより良いかなどの話題で争うつもりはありません。多くの場合、これは使用者の習慣嗜好などの要因によるものです。

しかし、否定できないのは、JSX は所謂JavaScript + XMLであり、その XML の構文は HTML と同じように見えます。Web の経験者にとっては、それは普段使っている HTML そのものであり、React ユーザーにとっては JSX はさらに馴染み深いものです。また、単純に文書を編集する文字作業者にとっては、彼らは HTML や XML の構文を書くことを初めて学ぶかもしれませんが、HTML にはより豊富なコミュニティコンテンツがあり、学ぶための多くのチュートリアルや、変換なしで使用できる多くのコードスニペットがあります。一方、XML も潜在的な汎用スキルになる可能性があり、一度学べば他の分野に移行するのが容易です。

要するに、私はmarkdown + JSXのような構文ソリューションを直接使用する方が好ましいと考えています。ユーザーの習得コストが低く、スキルもより汎用的です。

mdx の問題は?#

mdxは自らをプログラミング言語と位置付けています。彼らは文書で次のように述べています:

MDX はプログラミング言語であることを忘れないでください。もしあなたがユーザーを信じるなら、すべてはうまくいくでしょう。しかし、ユーザーが入力する内容には注意してください。誰にも MDX コンテンツをアップロードさせないでください。もし必要があるなら、<iframe>sandboxを同時に使用してください。ただし、セキュリティは難しく、100% の保証はありません。Node の場合、vm2を試してみる価値があります。しかし、あなたは Docker のようなツールを使ってオペレーティングシステム全体をサンドボックス化し、実行頻度を制限し、プロセスが過剰な実行時間を占有した場合にはそれを終了させるべきです。

もちろん、このような設計には何の問題もありません。あなたは様々な文書、SSGなどのシーンで自由に使用でき、バンドラーに mdx コードをパッケージ化またはプリレンダリングさせることができます。あなたの mdx コードは開発者が制御可能です。

しかし、もし私たちがクライアントコンポーネント化された信頼されていないmarkdown をレンダリングしたい場合はどうでしょうか?

その場合、mdxのようなソリューションには多くの問題があります:

  • 誰もが知っているように、信頼されていないコードを実行することは危険なことです。文書や設定ファイルなどのシーンで任意のコードを実行できる機能を提供することは良くありません。特に、これらの形式の内容が信頼されていない可能性がある場合は特にそうです;
  • 次に、ESM の import と export はこのようなシーンでは合理的ではありません;
  • 最後に、mdx は JS コンパイラに依存しています。このものをクライアントにパッケージ化するのはあまり良い感じがしません。

mdx のサブセットを再発明する#

そこで、ホイールを作りました —— mdio

目標は mdx の JSX の動的構文を削除することです。開発者が渡すことができる動的パラメータカスタムコンポーネントを除いて、他のすべてのものは静的に推論でき、コード実行機能はありません。使用できるのは渡された情報のみです。その特性は次のようになります:

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

# こんにちは世界

<TagList />

1. リスト 1
2. リスト 2

テキスト形式、**太字**。タイトルは {frontmatter.title} です。

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

<div>
  生のHTMLはOK
</div>

おおよそ markdown にJSONX、つまりJSON + XML(誤解)を追加したようなものです。

  • XML タグ<div>...</div>
  • JSON 式:大括弧で囲まれた有効な JSON 式、例えば:{1}{"text"}{{"key":"value"}}{null}
  • アクセスパス式:大括弧で囲まれたfrontmatterまたは渡された環境変数へのアクセス式、例えば:{frontmatter.title}{env.abc.def[0].ghi}(現在は静的に決定されるフィールドと配列インデックスのアクセスのみをサポートする予定)

それ以外にも、いくつかの関連する周辺施設を提供する必要があります。クライアントレンダリングのコンポーネント(Vue の例):

  • mdio 構文を AST に解析し、その後Vue JSXを使用して対応するVNodeをユーザーにレンダリングします;
  • props から Vue コンポーネントを受け入れ(何の変更もせずに、動的インポートを介して非同期コンポーネントをサポートできます)、解析された AST の中で実際の Vue コンポーネントに置き換えます;
  • mdio の frontmatter 内の YAML 形式データを解析し、すべての環境をカスタムコンポーネントに自動的に渡すことができます;
  • Composable 関数:文書の frontmatter や AST などの情報を文書内の深くネストされたカスタムコンポーネントに直接提供します。

上記のコードを使用する際の感覚は次のようになります:

<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 をレンダリングすることをサポートします。次に、私たちはmdioが望む機能をサポートするために mdx のコンパイラを魔改造する必要があります。

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プロジェクトは、markdown を AST に解析し、AST をさまざまな形式に変換するなど、markdown に関連する機能を提供するためのunifiedプラグインまたはパーサーです。

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 つのプラグインが追加されています。

1 つはmicromark プラグインで、micromarkは markdown のパーサーであり、remark とは独立して使用できます。remark-parseの markdown 解析機能は実際にはmicromarkによって提供されているため、micromarkのプラグインも remark で使用できます。コアの解析ロジックはすべてmicromark内にあります。

もう 1 つはfromMarkdown プラグインです。ここではコンパイラ原理の基本知識が関与しています。おおよそ、ソースコードは字句解析器を通じてトークンストリームに変換され、その後構文解析器を通じて中間表現、通常はASTを得ます。ここでmicromarkmarkdown を構文構造を持つトークンストリームに解析するだけです(ソースコード内ではEventsと呼ばれています)。つまり、字句解析と構文解析は一緒に行われています(LL1 の再帰下降パーサーを通じて)。その後、fromMarkdown プラグインはこの構文構造を持つトークンストリームをAST を生成します(ここでは markdown を解析しているため、mdastです)。


次に、mdast-util-from-markdownに移ると、確かにmicromarkが提供するコンパイルメカニズムを直接使用していることがわかります。

image

image

その後、mdast-util-from-markdownパッケージ内には多くのものが書かれています。具体的な役割は、上記で述べたように、micromarkが解析したEventsストリームをmdast という抽象構文木構造に変換することです。
ここまで見ておけば大体のことがわかります。micromarkが実際に markdown を解析するパッケージであり、内部にはLL1 のパーサーが実装されています。具体的には詳しく見ませんが、簡単に言うと、mdast を hast に変換し、hast を HTML または JSX にシリアライズする可能性のある後続のプロセスです。

現在までに何が行われたかをまとめると:

mdx 関連のプラグイン#

remark-mdxパッケージに戻ると、上記のソースコード分析から、私たちは容易に知ることができます:

  • micromark-extension-mdxjsパッケージは、コード内のmdxjsに対応し、元の markdown 構文を拡張するためのmicromarkプラグインです;
  • 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 つの段階に対応しています。そしてこれら 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 行目でアクセスパスの分割を試みます。ここでは単純なバージョンを書いており、.で分割しています(配列インデックスを書くのは面倒です)。

mdast 変換の拡張#

前の部分で、mdioの mdast を生成しました。次に、mdio 内の mdast ノードを実際の hast ノードに変換する必要があります。具体的には、remark-rehypeライブラリにmdioノードに対応する処理関数を渡す必要があります。おおよそ以下のコードの感じです。

const mdioHandlers: ToHastHandlers = {
  MdioTextElement(state, node: MdioTextElement | MdioFlowElement) {
    // フラグメントを処理します
    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)) {
            // カスタムコンポーネントの場合、私たちは生のJSONデータを直接使用します
            properties[attr.name] = attr.value.data?.json;
          } else {
            // ビルトインDOMの場合、JSON文字列を解析します
            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 ノードを組み立てるかを指導します。実際に実行してデバッグしないと何をしているのか分からないため、具体的には説明しません。


さらに、アクセスパスを使用してオブジェクトのフィールドにアクセスすることをサポートするために、mdast を走査して実際の結果に置き換える必要があります。同様に、タイプ定義が複雑であるため、デバッグしないと理解できません。以下のrewrite関数は、アクセスパスを使用して実際のフィールドを取得するために使用されます。

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: 他のケースを処理する
      return cur.toString();
    } else {
      return '';
    }
  }
}

一部のコーナーケースの処理#

1 つは、JSX に変わると元の HTML コメント構文<!-- -->がサポートされなくなります。XML タグ構文の解析を拡張することを検討するか、単に正規表現で一度通過させることもできます。しかし、このサポートを追加すると、mdx の厳密なサブセットではなくなります。

もう 1 つは、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>
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。