🔖

Astro + StarlightでMermaidダイアグラムをクライアントサイド描画する方法

に公開

この記事について

この記事では、MarkDown文書に埋め込まれたMarmaidコードをドキュメントサイトで表示するための方法の検討の経緯とクライアントサイドで処理する方法について記述します。

前提・制約

ソースコードであるMarkdown文書は、Astro + Starlightで構築するドキュメントサイト以外の環境(BacklogのwikiやGitHubなど)でも表示・編集される可能性があります。そのため、元のMarkdown文書は変更せずに、ビルド時の変換処理のみでMermaidダイアグラムを表示できる仕組みが必要です。

Starlightの仕様

Starlightではcontent/docs配下にマークダウン文書を配置するだけでドキュメントサイトでその文書を記事として公開することができます。しかしそのマークダウン文書内に書かれたMermaidコードをダイアグラムとして表示することはできず、それを意図通り表示するためにはMermaidコードに対して何らかの処理が必要になります。

サーバーサイドで処理するか?クライアントサイドで処理するか?

Mermaidコードをダイアグラムとして表示するための処理方法としては次の2つの選択肢があります。

1. サーバーサイドで処理する方法

Mermaid公式のmermaid.jsというライブラリを使用してAstro + Starlightのビルド時にそのライブラリを実行してMermaidコードをSVG画像に変換します。画像はサーバーサイドに保存され、マークダウン文書にはその画像のパスを指す<img>タグを埋め込みます。

2. クライアントサイドで処理する方法

Astro + Starlightのビルド時にMermaid公式のmermaid.jsを使用するHTMLタグとJavaScriptタグをマークダウン文書に埋め込みます。ビルド結果であるHTMLをクライアントブラウザで表示する際にmermaid.jsを使ってMermaidコードをダイアグラムに変換して表示します。

クライアントサイドで処理する方法を採用

当初は、サーバーサイドで処理する方法で実装していたのですが、いくつか課題が発生したため最終的にはクライアントサイドで処理する方法を採用することとしました。

課題1:@mermaid-js/mermaid-cliを使った複雑なビルド時変換が環境依存で不安定
課題2:ビルドするためにはヘッドレスchrome関連のライブラリをインストールする必要がある
課題3:ビルド時間が長い
課題4:WindowsやWSL環境でのファイルロック(EBUSY)エラーの頻発

クライアントサイドで処理する方法を採用することにより、いずれの課題も解消できると判断しました。
そもそも最初にサーバーサイドでの処理を採用したのは、クライアントサイドの環境(IEなどの古いブラウザではMermaidの描画できない可能性が高い)に依存しないようにしたいというのが理由でした。しかし今回構築するサイトのユーザーの環境ではその問題に直面する可能性は極めて低いと判断したため、メリットの多いクライアントサイドでの処理を採用することとしました。

クライアントサイドでの処理の具体的な実装例

では、具体的にどのように実装するのかを例示します。

前提

この事例では、Astro + Starlightで構築したドキュメントサイトで扱うMarkdown文書に含まれるMermaidコードを処理します。ビルドするタイミングでMermaidコードを変換し、ソースコードであるMarkdown文書はそのまま残したいので、今回はRemark plugin機構を使用して、npm run devnpm run buildなどのビルド実行時に変換スクリプトが起動するようにしました。

Remarkとは、簡単にいうと、MarkdownをHTMLや他の形式に変換したり、変換過程でカスタム処理を追加できるツールです。プラグインとして使えるので、Astroをはじめ他の実行環境にも移植することが可能です。
https://github.com/remarkjs/remark

Remark Pluginの導入

Remark Pluginで今回の変換スクリプトを起動できるようにするために以下のようにastro.config.mjsに設定をします。(今回の説明に直接的に関連しない行は割愛しています)

astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import remarkMermaidClient from './src/plugins/remark-mermaid-client.js';

export default defineConfig({
	markdown: {
		syntaxHighlight: {
		type: 'shiki',
		excludeLangs: ['mermaid', 'math'],
		},
		remarkPlugins: [
			[remarkMermaidClient, {
				theme: 'default'
			}]
		],
  	},
	
});

Astro設定でexcludeLangs: ['mermaid']を指定し、Shikiシンタックスハイライターがmermaidコードブロックを処理しないようにする必要があります。これにより、remarkプラグインが適切にmermaidコードを検出・変換できます。

変換スクリプトの説明

変換スクリプト(remark-mermaid-client.js)の全文
import { visit } from 'unist-util-visit';

// HTMLエスケープ機能
function escapeHtml(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

export default function remarkMermaidClient(options = {}) {
  const { 
    theme = 'default'
  } = options;

  return function transformer(tree, file) {
    const mermaidNodes = [];
    
    // mermaidコードブロックを検索
    visit(tree, 'code', (node, index, parent) => {
      if (node.lang === 'mermaid') {
        mermaidNodes.push({ node, index, parent });
      }
    });

    if (mermaidNodes.length === 0) {
      return;
    }

    // 最初のmermaidブロックの前にJavaScript機能を追加(1回のみ)
    let jsAdded = false;

    // mermaidブロックを順次処理
    for (const { node, index, parent } of mermaidNodes) {
      const mermaidCode = node.value;
      
      // 一意のIDを生成
      const diagramId = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

      // JavaScriptを一度だけ追加(コピー機能なし)
      const jsScript = !jsAdded ? `
        <script type="module">
          if (!window.mermaidInitialized) {
            window.mermaidInitialized = true;
            
            // Mermaidの初期化
            const mermaid = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');
            mermaid.default.initialize({
              startOnLoad: false,
              theme: '${theme}',
              themeVariables: {
                primaryColor: '#ff6b6b',
                primaryTextColor: '#333',
                primaryBorderColor: '#ff6b6b',
                lineColor: '#666',
                sectionBkgColor: '#f9f9f9',
                altSectionBkgColor: '#e9e9e9',
                gridColor: '#ddd',
                secondaryColor: '#006100',
                tertiaryColor: '#fff'
              },
              securityLevel: 'loose',
              fontFamily: '"Helvetica Neue", Arial, sans-serif'
            });

            // 全てのMermaidダイアグラムをレンダリング
            window.renderMermaidDiagrams = async function() {
              const diagrams = document.querySelectorAll('.mermaid-diagram[data-processed="false"]');
              
              for (const diagram of diagrams) {
                try {
                  const code = diagram.textContent.trim();
                  const id = diagram.id;
                  
                  const { svg } = await mermaid.default.render(id + '_svg', code);
                  diagram.innerHTML = svg;
                  diagram.setAttribute('data-processed', 'true');
                } catch (error) {
                  console.error('Mermaid rendering failed:', error);
                  diagram.innerHTML = '<p style="color: red;">⚠️ ダイアグラムの描画に失敗しました</p>';
                  diagram.setAttribute('data-processed', 'error');
                }
              }
            };

            // ページ読み込み完了後にレンダリング
            if (document.readyState === 'loading') {
              document.addEventListener('DOMContentLoaded', window.renderMermaidDiagrams);
            } else {
              window.renderMermaidDiagrams();
            }
          } else {
            // 既に初期化済みの場合は即座にレンダリング
            if (window.renderMermaidDiagrams) {
              window.renderMermaidDiagrams();
            }
          }
        </script>` : '';

      // クライアントサイドレンダリング用のHTML(コピーボタンなし)
      const mermaidNode = {
        type: 'html',
        value: `${jsScript}<div class="mermaid-container">
          <div class="mermaid-diagram" id="${diagramId}" data-processed="false">
${mermaidCode}
          </div>
          <details class="mermaid-source">
            <summary class="mermaid-toggle">
              <span class="toggle-icon">▼</span>
              <span class="toggle-text">Mermaidソースコードを表示</span>
            </summary>
            <div class="mermaid-code">
              <pre><code class="language-mermaid">${escapeHtml(mermaidCode)}</code></pre>
            </div>
          </details>
        </div>`
      };

      parent.children[index] = mermaidNode;
      jsAdded = true; // JavaScript追加フラグを設定
    }
  };
}

詳細解説

以下解説です。

1. インポート部分

import { visit } from 'unist-util-visit';

unist-util-visitはRemark/Unistエコシステムの一部で、ASTノードを走査するためのユーティリティです。

2. プラグイン関数の構造

export default function remarkMermaidClient(options = {}) {
  return function transformer(tree, file) {
    // ...
  };
}

これはRemarkプラグインの標準的な構造で、それぞれ次のことを意味しています。

  • transformer関数がRemarkから呼び出される
  • tree: RemarkがMarkdownを解析したAST
  • file: Remarkが処理中のファイル情報

3. AST走査とノード検出部分
AST(Abstract Syntax Tree:抽象構文木)とは、ソースコードの構造を木構造で表現したデータです。Remarkでは、Markdownの各要素(見出し、段落、コードブロックなど)をノードとして管理し、プラグインがこのASTを操作することで変換処理を行います。

visit(tree, 'code', (node, index, parent) => {
  if (node.lang === 'mermaid') {
    mermaidNodes.push({ node, index, parent });
  }
});
  • tree: RemarkのAST構造
  • 'code': Remarkが定義するノードタイプ
  • node.lang: Remarkのコードブロックノードの言語プロパティ
  • index, parent: ASTにおけるノードの位置情報

4. ノード置換部分

const mermaidNode = {
  type: 'html',  // ← Remarkのノードタイプ
  value: `${jsScript}<div class="mermaid-container">...`
};

parent.children[index] = mermaidNode;  // ← AST操作
  • type: 'html': RemarkのHTMLノードタイプ
  • parent.children[index]: ASTの直接操作
    AST走査とノード検出部分で作成したmermaidNodeをノードに置換します。

5. Mermaid変換するJavaScriptの生成

const jsScript = !jsAdded ? `
        <script type="module">
          if (!window.mermaidInitialized) {
            window.mermaidInitialized = true;
            
            // Mermaidの初期化
            const mermaid = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');
            mermaid.default.initialize({
              startOnLoad: false,
              theme: '${theme}',
              themeVariables: {
                primaryColor: '#ff6b6b',
                primaryTextColor: '#333',
                primaryBorderColor: '#ff6b6b',
                lineColor: '#666',
                sectionBkgColor: '#f9f9f9',
                altSectionBkgColor: '#e9e9e9',
                gridColor: '#ddd',
                secondaryColor: '#006100',
                tertiaryColor: '#fff'
              },
              securityLevel: 'loose',
              fontFamily: '"Helvetica Neue", Arial, sans-serif'
            });

            // 全てのMermaidダイアグラムをレンダリング
            window.renderMermaidDiagrams = async function() {
              const diagrams = document.querySelectorAll('.mermaid-diagram[data-processed="false"]');
              
              for (const diagram of diagrams) {
                try {
                  const code = diagram.textContent.trim();
                  const id = diagram.id;
                  
                  const { svg } = await mermaid.default.render(id + '_svg', code);
                  diagram.innerHTML = svg;
                  diagram.setAttribute('data-processed', 'true');
                } catch (error) {
                  console.error('Mermaid rendering failed:', error);
                  diagram.innerHTML = '<p style="color: red;">⚠️ ダイアグラムの描画に失敗しました</p>';
                  diagram.setAttribute('data-processed', 'error');
                }
              }
            };

            // ページ読み込み完了後にレンダリング
            if (document.readyState === 'loading') {
              document.addEventListener('DOMContentLoaded', window.renderMermaidDiagrams);
            } else {
              window.renderMermaidDiagrams();
            }
          } else {
            // 既に初期化済みの場合は即座にレンダリング
            if (window.renderMermaidDiagrams) {
              window.renderMermaidDiagrams();
            }
          }
        </script>` : '';

当記事で本来伝えたかった、Mermaidコードをブラウザ上でレンダリングするJavaScript部分です。
この部分では以下の機能を実装しています。
1. Mermaidライブラリの初期化とレンダリング

  • CDNからのmermaid.js@11の動的インポート
  • テーマとスタイルの設定
  • ダイアグラムの自動レンダリング

2. ソースコード表示機能
こちらはデバッグも兼ねた便利機能的な位置づけです。

  • <details>要素による折りたたみ表示
  • HTMLエスケープされたMermaidコードの表示

Remarkプラグインでの処理に対する説明が多くなってしまいましたが整理すると、以下の表の通りとなります。

機能 Remark依存度 説明
AST走査 完全依存 visit()とAST構造
ノード検出 完全依存 node.lang === 'mermaid'
ノード置換 完全依存 AST操作
Mermaid変換するJavaScriptの生成 非依存 純粋な文字列処理

まとめ

Mermaidダイアグラムをクライアントサイドでレンダリングする処理を採用したことにより、Mermaidダイアグラムの描画が環境に依存しない安定したシステムとなりました。開発・ビルド・デプロイのすべての段階でエラーが解消され、保守性も大幅に向上しました。

また、後半のクライアントサイドでの処理部分に関しては、Mermaidダイアグラムのレンダリング処理以外にも、Remarkプラグインを使った処理の解説も行いました。

この記事が読者の皆さんの参考になれば幸いです。

Discussion