💭

react-markdownでクリックできるhashtagを実現する

2022/01/29に公開

やりたいこと

react-markdownで下記のようなハッシュタグ付きのマークダウンをレンダリングしたときに、
ハッシュタグ専用のページにリンクさせたい。

## 材料

- 材料 1
- 材料 2
- 材料 3

## 作り方

- 手順 1: 手順 1 を書きます
- 手順 2: 手順 2 を書きます
- 手順 3: 手順 3 を書きます

#recipe #idea

実現する上での課題

結論から言うと、デフォルトの挙動では簡単に実現できないので、プラグインを作成し react-markdown のオプションとして渡す必要がある。

react-markdown では components という prop をつかってタグごとに使う React コンポーネントをカスタマイズすることができる。
しかし、デフォルトでは一覧に載っているタグしかコンポーネントのカスタマイズができない。

この問題を解決するための課題が 2 つある。

  • 課題 1. ハッシュタグを解析するルールがそもそもデフォルトでは定義されていない (カスタムなルールなので)

  • 課題 2. 解析した結果得られた mdast (マークダウンの AST)のハッシュタグに対応する部分を hast (HTML の AST)にどう変換するかが定義されていない
    react-markdown が内部的には remark-rehype という mdast から hast へ変換するライブラリを使っているのだが、このライブラリがデフォルトではこれら一覧に載っているもの以外は div だったり p だったりに強制的に変換するようになっている。[1]

方針

  • カスタムな type の生成
    mdast を再帰的に traverse してハッシュタグを抽出しもとの mdast を変更する。
    ハッシュタグは"hashtag"というカスタムな type でノードを生成する。

  • カスタムな type の変換
    mdast から hast に変換する際にデフォルトの一覧に載っているもの以外は強制的に div などに変換されてしまうという話は上述の通り。なので、カスタムな type をどのように hast に変換するかを教えてあげる必要がある。
    これは remark-rehype が AST を変換するのに内部的に mdast-util-to-hast を使っており、このライブラリの handler オプションを使うことでカスタムな type に対する変換ルールを定義することができる。

カスタムな type の生成

下記では mdast のツリーを再帰的に traverse して正規表現でハッシュタグを抽出し mdast を変更する remark の
プラグインを定義している。
mdast の traverse には visit というライブラリを使っいる。再帰的に処理関数 (下記例では visitor)を対象 type のノード (下記例では paragraph)に適用してくれる。

import { is } from "unist-util-is";
import { visit } from "unist-util-visit";

function matchAll(regExp, text) {
  const matches = [];

  let match;
  while ((match = regExp.exec(text))) {
    matches.push(match);
  }

  return matches;
}

function remarkHashtagPlugin() {
  return tree => visit(tree, "paragraph", visitor);

  function visitor(node) {
    const { children } = node;
    node.children = [];

    children.forEach(function (child) {
      if (!is(child, "text")) {
        node.children.push(child);
        return;
      }

      const matches = matchAll(/(#(\w)+)/gi, child.value);

      if (matches.length === 0) {
        node.children.push(child);
        return true;
      }

      if (matches[0].index > 0) {
        node.children.push({
          type: "text",
          value: child.value.substr(0, matches[0].index)
        });
      }

      matches.forEach((match, index) => {
        node.children.push({
          type: "hashtag",
          children: [{ type: "text", value: match[0] }]
        });

        if (matches.length > index + 1) {
          const startAt = match.index + match[0].length;
          node.children.push({
            type: "text",
            value: child.value.substr(
              startAt,
              matches[index + 1].index - startAt
            )
          });
        }
      });

      const lastMatch = matches[matches.length - 1];

      if (lastMatch.index + lastMatch[0].length < child.value.length) {
        node.children.push({
          type: "text",
          value: child.value.substr(lastMatch.index + lastMatch[0].length)
        });
      }
    });
  }
}

カスタムな type の変換

プラグインを作成することで hashtag というカスタムなタイプを抽出することができるようになった。
次にこのタイプを hast に変換するときのルール (handler)を定義する必要がある。

import { all } from "mdast-util-to-hast";

const hashtagHandler = (h, node) => {
  return h(node, "hashtag", all(h, node));
};

react-markdown に適用

最後に react-markdown の props にプラグインと handler を渡せば完成。
handler で hashtag なタイプは hashtag というタグに変換されるようになっているので、components で hashtag というカスタムなタグに対してカスタムなコンポーネントを定義することができるようになっている。

import React from "react";
import ReactMarkdown from "react-markdown";

const myMarkdown = `
## 材料

- 材料 1
- 材料 2
- 材料 3

## 作り方

- 手順 1: 手順 1 を書きます
- 手順 2: 手順 2 を書きます
- 手順 3: 手順 3 を書きます

#recipe #idea
`;

export default function App() {
  return (
    <div>
      <div>
        <ReactMarkdown
          components={{
            hashtag: value => {
              return (
                <a href="https://google.com">
                  <strong>{value.children}</strong>
                </a>
              );
            }
          }}
          remarkPlugins={[remarkHashtagPlugin]}
          remarkRehypeOptions={{
            handlers: hashtagHandler
          }}
        >
          {myMarkdown}
        </ReactMarkdown>
      </div>
    </div>
  );
}
脚注
  1. さらに詳しく言うと remark-rehype が内部的に使っている mdast-util-to-hast というライブラリのunknownHandler オプションのデフォルトの挙動が div だったり p に変換する。 ↩︎

Discussion