マークダウンの中でJSXを使えるmarkdown-it-safe-jsxを作った
早速ですがマークダウンの中でJSXを使えるようにするmarkdown-itのプラグインを作りました。
下の画像のようにマークダウンの中でJSXを使えるようになります。
そうすると、下の画像のように記事の中に下のようにインタラクティブに動くコンポーネントを埋め込むことができます。
この実行結果は mosya.dev という私が開発したプログラミング学習サービスのブログで確認することができます👇
JSXを書けるこの仕組みは個人の技術ブログ用途にはぴったりなのではないかと思います!
なぜ作ったか
海外のこの記事を見て、技術記事は文字と画像だけでなくインタラクティブ性が必要だと実感しました。
触っててとても楽しいし、記事の続きが読みたくなったのです。
私はこんな読んで楽しい、触って楽しい記事を書かないといけない!!
そこでマークダウンの中でReactを埋め込めないかと考えました。
最初は表示されたマークダウンに対してあとからReactコンポーネントをマウントする方法を考えました。
ただこれだとガクッと表示されてしまってパフォーマンスの指標の一つであるCLSの指標で不利になってしまいます。
そこで、サーバーサイドで出力しておいたHTMLとして普通に出力して、フロント側でhydrate
してインタラクティブに動かすような仕組みを頑張ってつくりました。
パフォーマンスも意識したmarkdown-itのプラグインが作れて筆者はとても満足です笑
使い方
使い方はとても簡単です!
下のコードのようにmarkdown-itで使えるコンポーネントを登録できるようにしています。あらかじめ登録しておいたコンポーネントのみマークダウンで使えるので安全です!
import MarkdownIt from "markdown-it";
import { safeJsx } from "markdown-it-safe-jsx";
const md = new MarkdownIt();
md.use(safeJsx, {
MyComponent: ({ text }) => <MyComponent text={text} />,
});
const html = md.render(`
# Hello
<MyComponent text="Hello" />
`);
コンポーネントのhydrate
ただ、これだけだと、マークダウンの中で書かれたJSXが文字列として出力されるだけです!
そこで、useHydrate
というフックスタイルのAPIを提供しています。
useHydrate
を使うと、マークダウンの中で書かれたJSXを実際に動くコンポーネントに変換してくれます!
import MarkdownIt from 'markdown-it'
import { safeJsx, useHydrateJsx } from 'markdown-it-safe-jsx'
import { TestComponent } from './TestComponent'
const components = {
TestComponent,
}
function Markdown({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
useHydrateJsx({
components,
ref,
unsafeHydrateFunction: true,
}, []);
const result = useMemo(() => {
const md = markdownIt({
breaks: true,
linkify: true,
html: true,
});
md.use(safeJsx, components, {
unsafeRenderFunction: true,
})
return md.render(text);
}, [text]);
return (
<div dangerouslySetInnerHTML={{ __html: result }} ref={ref}></div>
)
}
他のアプローチとの違い
MDXとの違い
マークダウンの中でJSXをかけるようにするアプローチはすでにいくつかあります。
例えば有名なライブラリだとMDXがあります。
これはマークダウンファイルを一つのWebpackのモジュールとして扱い、コンパイル時にJSXに変換するアプローチです。
これだと、たとえばマークダウンのデータがAPIから取得されるような場合には使えません。
私は mosya.dev というプログラムの学習サービスを作っているのですが、このサービスの教材に必要なマークダウンのデータはAPIから取得しています。
なので、バンドル前提のMDXは少し使いづらくて採用を見送りました。
追記👇
こちらの記事にある方法で、バンドル前提じゃなくてもマークダウン内でJSXが使えるそうです!
next-mdx-remoteとの違い
そこで、next-mdx-remoteというAPIから取得したマークダウンをJSXに変換するライブラリも検討しました。
ただ、私はマークダウンのカスタマイズはこのZennにも使われているmarkdown-it
をベースにするのに慣れていて、なんとかこのmarkdown-it
のプラグインとしてJSXを使えるようにしたいと思っていました笑
ちょこっとだけZennの開発も手伝わせてもらってた時期もあるのでmarkdown-it
のプラグインを作るのには慣れていました。
さらにmarkdown-it
はその拡張性から多くのプラグインが存在していて、それらのプラグインとも組み合わせて使えるようにしたかったので、next-mdx-remote
は使わないことにしました。
どうやって実現しているのか
最初はTypeScript
のパーサーを使おうと思ったのですが、TypeScript
のパーサーを使うのは流石にバンドルサイズが大きくなりすぎるのでやめました。
そこで今回はacorn
というJavaScriptのパーサーを採用しました!
acornは私の尊敬するmarijnh氏という人が作ったパーサーでこの方はCodeMirrorというエディターの作者でもあります。
acornはJavaScriptのコードからそのコードをASTというデータ構造に変換してくれます。
このデータ構造を使えば、JSXのコードにどんなpropsが使われているのかを取得することができます。
TypeScriptと比べると118KBとかなり軽量なので、ブラウザーでも気軽に使えるのがとても便利です。
acornを使ってJSXのpropsを取得する
まず、acorn
でJSXを解析できるようにするためにacorn-jsx
というライブラリを使います。
import { Node, Parser } from "acorn";
import jsx from "acorn-jsx";
const JSXParser = Parser.extend(jsx()); // JSXを解析できるようにする
次にacorn-walk
というライブラリを使います。これはASTを再帰的に探索するためのライブラリです。
import { extend } from "acorn-jsx-walk";
import { base, simple } from "acorn-walk";
extend(base);
マークダウンの中で正規表現で引っ掛けたJSXのコードをacorn
で解析します。
simple
という関数を使うと再起的にASTを探索してくれるので、JSXOpeningElement
というノードを見つけたらその中のpropsを取得します。
function extractPropsFromJSX(jsxString: string) {
const JSXParser = Parser.extend(jsx());
const ast = JSXParser.parse(jsxString, {
ecmaVersion: 2020,
sourceType: "module",
});
const props: Record<string, unknown> = {};
simple(ast, {
JSXOpeningElement: (node) => {
node.attributes.forEach((attr) => {
if (attr.type === "JSXAttribute" && attr.value) {
const propName = attr.name.name;
let propValue;
if (attr.value.type === "JSXExpressionContainer") {
propValue = evaluateExpression(attr.value.expression, jsxString);
} else {
propValue = evaluateExpression(attr.value, jsxString);
}
props[propName] = propValue;
}
});
},
});
return props;
}
evaluateExpression
という関数をつくってJSXの中のpropsの内容を取得するようにします。
function evaluateExpression(node: Node, source: string) {
if (node.type === "Literal") {
return node.value;
}
if (node.type === "ArrayExpression") {
return node.elements.map((element) =>
evaluateExpression(element, source)
);
}
if (node.type === "ObjectExpression") {
const obj: Record<string, unknown> = {};
node.properties.forEach((prop) => {
if (prop.type === "Property") {
obj[prop.key.value] = evaluateExpression(prop.value, source);
}
});
return obj;
}
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
const functionString = getSourceCode(node, source);
const func = new Function(`return ${functionString}`)();
return func;
}
if (node.type === "TemplateLiteral") {
let templateValue = "";
node.quasis.forEach((part, index) => {
templateValue += part.value.raw;
if (index < node.expressions.length) {
const exprValue = evaluateExpression(node.expressions[index], source);
templateValue += exprValue;
}
});
return templateValue;
}
return undefined;
}
これで大体の型のpropsを取得することができます。
この結果を元にrenderToString
して結果を文字列として出力します。
const props = extractPropsFromJSX(allMatch);
const renderedComponent = ReactDOMServer.renderToString(
<Component {...props} />
);
hydrateをつかってフロントエンドで命を吹き込む
サーバーサイドからJSXを文字列として出力することができました。
さらにフロントエンドでは実際にこのJSXが動くようにしたいです。
ただ、今のままだとただの結果のみが文字列として出力されるだけなので何を頼りにJSXを動かせばいいのかわかりません。
そこであらかじめmarkdown-it側でヒントをマークダウンの中に埋め込んでおくことにしました。
const renderedComponent = ReactDOMServer.renderToString(
<div
data-component={componentName}
data-props={JSON.stringify(props)}
>
<Component {...props} />
</div>
);
これで、フロントエンドではdata-component
とdata-props
を参照すればどのコンポーネントがどのpropsで呼ばれていたのかを知ることができます。
この情報をもとにhydrateRoot
関数を使って、実際にJSXを動かすことができました。
import { hydrateRoot } from "react-dom/client";
export function useHydrateJsx(
options: {
components: Record<string, React.ComponentType<any>>;
ref: React.RefObject<HTMLElement>;
unsafeHydrateFunction?: boolean;
},
deps: React.DependencyList
) {
const { components, ref, unsafeHydrateFunction } = options;
useEffect(() => {
if (!ref.current) return;
const elements = ref.current.querySelectorAll("[data-component]");
elements.forEach((element) => {
const componentName = element.getAttribute("data-component");
if (!componentName) return;
const propsString = element.getAttribute("data-props");
const Component = components[componentName];
const props = propsString
? JSON.parse(propsString)
: {};
if (Component) {
hydrateRoot(element, <Component {...props} />);
}
});
}, deps);
}
hydrateRoot
関数はReactDOM.render
と同じように使えますが、ReactDOM.render
と違って、既にDOMが存在し、そのDOMを再利用することができます。
さらにそのDOMが、再びレンダリングしようとしているコンポーネントのHTMLと完全一致させる必要があります。
はじめて使いましたがCLS
を意識してるので、あらかじめサーバーサイドで出力したHTMLを再利用することができるのはとても便利でした。
制約事項
まだ以下のような機能が実装されていません。
- セルフクロージングタグのサポート
- ネストされたJSXのサポート
- 壊れたJSXのエラーハンドリングはない。。。
セルフクロージングタグのサポート
以下のような感じのJSXはマークダウンの中で使えません。
<MyComponent />
代わりに以下のように書く必要があります。
<MyComponent></MyComponent>
ネストされたJSXのサポート
まだ、以下のようにJSXをネストすることはできません。
<MyComponent>
<MyComponent2 />
</MyComponent>
これはASTを再起的に探索させることになってちょっとめんどくさかったので、今後の課題として残しておきます。
壊れたJSXのエラーハンドリングはない。。。
壊れたJSXをマークダウンの中で書いてしまうと、そのマークダウンを読み込んだときにエラーが発生してしまうのでそこは自己責任でお願いします。
個人用のブログなどで使うのが一番ちょうどいいかもしれません。
まとめ
今回はマークダウンの中でJSXを使えるようにするmarkdown-itのプラグインを作りました。
JavaScriptを解析できるacorn
がとても便利でした。
このようにASTを扱えるライブラリが使えると今回のようなこと以外にも選択肢が広がると思うので、ぜひ使ってみてください!
まだテストも書けてなく、型も不完全なので、もし興味があればPRを送ってもらえると嬉しいです!
Discussion
誤解を生みそうなので一応コメントしておくと、next-mdx-remoteも不要でMDX自体がこの機能を提供しています。なのでMDXのみで可能ではあります。
(ただ、最終的にGodaiさん的なモチベーションがmarkdown-itのプラグインを作りたい、だったので否定するわけではないです🙆♂)
おぉそうだったのですね!教えていただいてありがとうございます!
追記しました!