🧯

腹をくくって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の間で、tagを別要素として認識させる必要があります。
そのために今回はいったんparagraphとして解釈されたものををタグとparagraphに分けます。

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

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

そして
2のmdastをrehypeにする際にただしく変換(今回の場合は<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が認識されていないプロパティのためエラーが出ているようですが、これを登録しないとカスタムハンドラを登録できません。

ですが、私が使っているReactMarkdownコンポーネントのremarkRehypeOptions propsでは問題なく追加できるためこのエラーについてはいったんスルーすることにしました。

エラーが出ていない例

ですがreact-markdownのバージョンを^8.0.7から9.0.1に上げたら型エラーが発生してしまいました。
おそらく内部で参照しているremarkRehypeが昔のバージョンではanyにしていたところに余計な型チェックが入ったみたいな感じだと思います。
ですがこうしないとrehypeのプラグインが使えなかったので仕方なくこのまま放置です・・・

非同期プラグインを使う

ここから先はこの主題とはあまり関係ないのですが、非同期プラグインはReactMarkdownでは使えなく、rehypePrettyCodeというrehypeのプラグインは使えません。
ということでこの記事を参考に移行

最終成果物

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自体かなり古くからある都合上、新しいプロダクトと比べて肩を導入したときのメリットが薄いからか、Unified周りの型の調査はあまりコスパがよいように感じなかったので、anyしちゃっています。
正直7割ぐらい自分の使い方に問題があるかなと思っているので、もしわかる方いらっしゃいましたら教えてください。

Discussion