🧯

腹をくくってUnifiedのプラグインを書くときの備忘録

2024/06/20に公開

unified は、Markdown と HTML の相互変換を中心とした大規模なエコシステムです。本稿では、自作構文を Markdown に対応させるパーサーの開発方法について解説します。

今回作るもの

マークダウンにハッシュタグを認識させる

# header1
paragraph
#tag <--- これ!

参考文献

用語

  • ast: 抽象構文木(Abstract Syntax Tree) - プログラムの構文構造を木構造で表現したもの。言語の意味に関わる情報のみを抽出したものです。(Wikipedia参照)
  • mdast: markdownのast
  • hast: htmlのast
  • remark: markdownをmdastに変換するプラグイン
  • remarkRehype: mdastをhastに変換するプラグイン
  • rehypeStringify: hastをhtmlに変換するプラグイン

制作の流れ

ハッシュタグを認識しない標準的な変換フローは以下の通りです。

  1. markdown -(remark)-> mdast
  2. mdast -(remarkRehype)-> rehype
  3. rehype -(rehypeStringify)-> html

ハッシュタグを認識させるためには、このフローの1と2の間でタグを別要素として認識する必要があります。
今回紹介する方法は、paragraphとして解釈されたものをタグparagraphに分割することで実現します。

例:これはタグ#tagですの分割例

  • これはタグ <- paragraph
  • #tag <- tag
  • です<- paragraph

その後、2の MDAST を HAST に変換する際に、<span class="tag">{tagValue}</span> に正しく変換します。

トランスフォーマの作成

まず、mdastからtagを抽出し、tagノードをmdastに追加します。

エラー解決

記事を参考に「文節型のノードがconsoleに出力され」るサンプルを入れたのですが、いきなり型エラーが出ている。

※型チェックを聞かせるために直接arrow関数で書いています。

なんかいろいろごちゃごちゃpackageリストを整理したらエラーは消えました

pnpm add unist-util-inspect
rm -rf node_modules
pnpm install
pnpm add @types/unist
pnpm add unist-util-visit vfile

dependencies一部抜粋

package.json
"dependencies": {
    "@types/unist": "^3.0.2",
    "rehype-stringify": "^10.0.0",
    "remark-parse": "^11.0.0",
    "remark-rehype": "^11.1.0",
    "unified": "^10.0.4",
    "unist-util-inspect": "^8.0.0",
    "unist-util-visit": "^5.0.0",
    "vfile": "^6.0.1",
  }

最終成果物

    return await unified()
        .use(remarkParse)
        .use(() => (tree) => {
            visit(
                tree,
                "paragraph",
                (node: Paragraph, _index, parent: Parent) => {
                    //remove this node
                    parent.children = parent.children.filter((n) => n !== node);

                    const child = node.children[0];
                    if (child && child.type !== "text") return;
                    // add nodes
                    const texts = parseTextToTagP(child.value);

                    for (const text of texts) {
                        if (text.startsWith("#"))
                            parent.children.push(u("tag", text));
                        else
                            parent.children.push(
                                u("paragraph", [u("text", text)]),
                            );
                    }
                },
            );
        })

if (child && child.type !== "text") return;で型を絞り込まないと、エディタ上でプロパティが正しく表示されません。

remarkRehypeのハンドラを書く

これまで、mdast(MarkdownのAST)をカスタマイズすることはできました。次に、これを hast(HTMLのAST)に変換する際に、正しく変換する必要があります。

ハンドラの例

export const tagHandler = (_h: unknown, node: Node) => {
    return {
        type: "element" as const,
        tagName: "span",
        properties: {
            className: "tag",
        },
        children: [u("text", node.type)],
    };
};

記事にあるように、このハンドラをunified().use(remarkRehype,{handlers:{}})に追加すると、エラーが発生します。これは、handlers.tagが認識されていないプロパティのためです。

エラーが発生している例の

このエラーの原因は、handlers.tag が無効なプロパティであるためと考えられます。しかし、このプロパティを使わないとカスタムハンドラを登録できないという問題があります。

エラーが発生していない例

私が使用している React Markdown コンポーネントの remarkRehypeOptions props では、このエラーが発生せずに handlers.tag を追加することができました。そのため、今回はこのエラーを無視することにする予定でした。

しかし、React Markdown のバージョンを 8.0.7 から 9.0.1 に上げると、型エラーが発生します。これは、内部で参照している remarkRehype が過去のバージョンではany型だった箇所に対して、新しいバージョンで型チェックが強化されたことが原因と考えられます。
ですがこうしないとrehypeのプラグインが使えなかったので仕方なくこのまま@ts-ignoreします

非同期プラグインを使う

この部分は、本記事の主題とは直接関係ありませんが、念のために記述しておきます。

現時点では、非同期プラグインは React Markdown で使用することができず、rehype のプラグインである rehypePrettyCode も利用できません。

そこで、こちらの記事を参考に移行作業を進めていきます。

最終成果物

import { type FC, useEffect, useState } from "react";

import { tagHandler as tag } from "@/lib/tagHandler";
import { tagParse } from "@/lib/tagParsePlugin";
import * as prod from "react/jsx-runtime";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeReact from "rehype-react";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";

const production = {
    Fragment: prod.Fragment,
    jsx: prod.jsx,
    jsxs: prod.jsxs,
};

type Props = { text: string };

export const MarkdownView: FC<Props> = ({ text }) => {
    const [content, setContent] = useState(<div>loading...</div>);
    useEffect(() => {
        const f = async () => {
            const file = await unified()
                .use(remarkParse)
                .use(tagParse)
                .use(remarkRehype, {
                    // remarkRehypeの型システム上、mdastの独自ノードにたいしてハンドラを追加すると、そんなノードは知らないとエラーが出てしまう。js時代からある巨大なエコシステムなためこういった部分で型がおろそかなのはしょうがない。
                    // biome-ignore lint/suspicious/noExplicitAny: <explanation>
                    handlers: { tag } as any,
                })
                .use(rehypePrettyCode)
                // 動いでしまうのでとりあえずanyでごまかす
                // biome-ignore lint/suspicious/noExplicitAny: <explanation>
                .use(rehypeReact, production as any)
                .process(text);

            setContent(file.result);
        };
        f();
    });
    return content;
};

useEffectをカスタムフックに切り出すことを検討しましたが、単体でテストする意義を見出せなかったこと、およびこのファイルに記述しても視認性や可読性が大きく低下しないと判断したため、そのまま記述することにしました。

まず、rehypeReact を使用して GitHub の例通りのコードを書いたところ、型エラーが発生しました。Unified 自体は古くから存在するライブラリであり、新しいプロダクトと比較すると TypeScript の型定義が不十分である可能性があります。そのため、調査コストに見合うと考えられず、any 型を使用しています。

現状、私の使い方に問題がある可能性が 7 割程度あると認識しています。解決策をご存知の方がいらっしゃいましたら、ご教示いただけますと幸いです。

Discussion