🍄

Markdown, MermaidをReactアプリ内に表示したい

2024/12/01に公開

やりたいこと

React で GUI アプリケーションを作成していますが、API もユーザーに公開しています。
ユーザーへ公開するためのドキュメントは別管理していましたが、アプリと合わせて更新できた方が更新忘れもなくリリース対象も減るので、API リファレンスもアプリケーション内に掲載するようにしました。API リファレンスの掲載についてはこちらでできました。
https://zenn.dev/chocolat0w0/articles/0e89c30e3073ae

このとき、API 利用方法などを記載したドキュメントも公開したくなりました。文章による説明に加え、シーケンス図も掲載すると理解しやすそうです。
普通に HTML で文章を書いて、図は画像で埋め込むことでも実現できますが、更新が面倒そうなので Markdown + Mermaid で書いたドキュメントをそのまま表示したいです。

動作確認環境

パッケージ バージョン
node 22.11.0
yarn 1.22.17
React 18.3.1
vite 6.0.1
Typescript 5.6.2

手順

React をセットアップ

React + Vite +Typescript でプロジェクトを作成します。

プラグインをインストール

今回使うプラグインは4つです。
Mermaid が不要であれば上3つで良いです。

プラグイン名 用途
react-markdown Markdown 記法を解釈して HTML 出力する
remark-gfm GFM (github flavored markdown) で解釈します。これがないと表が使えません
react-syntax-highlighter コードハイライト
mermaid Mermaid.js の記述を解釈します
$ yarn add react-markdown remark-gfm react-syntax-highlighter
info Direct dependencies
├─ mermaid@11.4.1
├─ react-markdown@9.0.1
├─ react-syntax-highlighter@15.6.1
└─ remark-gfm@4.0.0

Typescript 用に型定義もインストールします。

$ yarn add -D @types/react-syntax-highlighter
info Direct dependencies
└─ @types/react-syntax-highlighter@15.5.13

本筋ではないですが、ルーティング用に react-router-dom もインストールします。

$ yarn add react-router-dom
info Direct dependencies
└─ react-router-dom@7.0.1

実装

ドキュメントは .md ファイルとして作成します。
このファイルを読み込んでアプリケーション内の1ページに表示します。

できあがった全体を示します。
完成画面

ファイル構成

├── eslint.config.js
├── index.html
├── package.json
├── public
│   └── api
│       └── guide.md  // 追加
├── src
│   ├── index.css
│   ├── main.tsx  // 変更
│   ├── pages
│   │   └── ApiGuide
│   │       ├── Mermaid.tsx  // 追加
│   │       └── index.tsx  // 追加
│   └── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock

public/api/guide.md

いつも通り Markdown で書いたファイルを配置します。
(この記事中に Markdown コードを入れるにはどうしたら良いのだろう)
Markdownファイル

src/main.tsx

今回作ったページへのルーティングを追加。

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
+ import { BrowserRouter, Route, Routes } from 'react-router-dom'
+ import ApiGuide from './pages/ApiGuide/index.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
+      <BrowserRouter>
+      <Routes>
+        <Route path="/api-guide" element={<ApiGuide />} />
+      </Routes>
+    </BrowserRouter>
  </StrictMode>,
)

src/pages/ApiGuide/index.tsx

src/pages/ApiGuide/index.tsx
import mermaid from "mermaid";
import React from "react";
import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import remarkGfm from "remark-gfm";

import { Mermaid } from "./Mermaid";

const ApiGuide: React.FC = () => {
  const [markdown, setMarkdown] = React.useState("");

  // ① 表示したい Markdown ファイルを読み込む
  React.useEffect(() => {
    (async () => {
      const file = await fetch("/api/guide.md");
      setMarkdown(await file.text());
      mermaid.initialize({ startOnLoad: false });
    })();
  }, []);

  return (
    <Markdown
      remarkPlugins={[remarkGfm]}
      components={{
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        code({ node, className, children, ref, ...props }) {
          if (
            // ② Mermaid のコードブロックの場合は Mermaid コンポーネントで表示
            className === "language-mermaid" &&
            node?.children[0].type === "text"
          ) {
            return <Mermaid code={node?.children[0].value} />;
          } else {
            // ② Mermaid でないコードブロックの場合は react-syntax-highlighter で表示
            const match = /language-(\w+)/.exec(className || "");

            return match ? (
              <SyntaxHighlighter
                // ③ style はお好みで
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                style={vscDarkPlus as any}
                language={match[1]}
                // ④ デフォルトだと code > code と二重タグになる
                PreTag="div"
                {...props}
              >
                {String(children).replace(/\n$/, "")}
              </SyntaxHighlighter>
            ) : (
              <code className={className}>{children}</code>
            );
          }
        },
      }}
    >
      {markdown}
    </Markdown>
  );
};

export default ApiGuide;

② の部分でコードブロックを判別し、Mermaid 記法が指定されていたら次の Mermaid コンポーネントが利用されるようにしています。

③ スタイルはお好みで変えてください。指定できるスタイルはこちら

src/pages/ApiGuide/Mermaid.tsx

src/pages/ApiGuide/Mermaid.tsx
import mermaid from "mermaid";
import React from "react";

type Props = {
  code: string;
};

export const Mermaid: React.FC<Props> = (props) => {
  const { code } = props;
  const outputRef = React.useRef<HTMLDivElement>(null);

  const render = React.useCallback(async () => {
    if (outputRef.current && code) {
      try {
        // ① 一意な ID を指定する必要あり
        const { svg } = await mermaid.render(`m${crypto.randomUUID()}`, code);
        outputRef.current.innerHTML = svg;
      } catch (error) {
        console.error(error);
        outputRef.current.innerHTML = "Invalid syntax";
      }
    }
  }, [code]);

  React.useEffect(() => {
    render();
  }, [render]);

  return code ? (
    <div style={{ backgroundColor: "#fff" }}>
      <div ref={outputRef} />
    </div>
  ) : null;
};

Mermaid コードブロックの際に表示されるコンポーネントです。

① の部分でレンダリングに ID を指定する必要があるのですが、ページに複数の Mermaid コードブロックが存在する場合、ID が重複していると正常に表示されません。このため、ユニーク ID を指定しています。
randomUUID では数字はじまりの ID も生成されますが、HTML のルールで英字から始まる必要があり、数字はじまりの ID だと表示も正常にできませんでした。このため、先頭に英字を追加しています。

そのほか

remarkGfm により表の記法は解釈されて Table タグで出力されますが、スタイルが当たらないため見づらいです。
スタイルは別途指定する必要があるので、プロダクトのスタイル定義方法に合わせてページに書くなりグローバルに設定するなりが必要です。
Zenn っぽくするとこんな感じのスタイルを当てれば良いです。

table: {
  borderCollapse: "collapse",
},
th: {
  padding: ".5rem",
  fontWeight: 700,
  border: "1px solid #d6e3ed",
  backgroundColor: "#edf2f7",
},
td: {
  padding: ".5rem",
  border: "1px solid #d6e3ed",
  backgroundColor: "#fff",
}

まとめ

若干はまりどころもありましたが、少し手を加えて表示できました。
図をスクショしてページ内に表示・・・とかしなくて良くなったので管理が楽です。

今回はシーケンス図しか使いませんでしたが、Mermaid で記述できるものはなんでも表示できるはずです。

Discussion