Engineering

AstroでMermaidを使う — ビルド時変換をやめてクライアントサイドにした理由

  • Astro
  • Mermaid
  • React
  • ダイアグラム
  • Markdown
  • 動的インポート

AstroMermaidダイアグラムを表示するにあたり、クライアントサイドレンダリングを採用しました。rehype-mermaidのデフォルト戦略(inline-svg)ではPlaywrightでヘッドレスブラウザーを起動してSVGを生成します。Playwright不要の pre-mermaid 戦略もありますが、これはクライアントサイドレンダリングに委ねる方式で、ビルド時にSVGを静的出力する目的には使えません。CI環境の複雑化を避けたかったことが最大の理由です。加えて、Mermaidを使うページが全体のごく一部であることから、該当ページでだけライブラリを動的インポートする方式が最もシンプルだと判断しました。

Labee Dev Toolboxの用語集では、SPFの認証フローやDNSルックアップの流れをMermaidのシーケンス図やフローチャートで説明しています。Astroには公式のMermaid対応がないため、表示方法を自前で実装しています。

labee.devSPF(Sender Policy Framework) | 用語解説 | Labee Dev Toolboxメール送信元の IP アドレスがドメイン所有者に許可されているかを検証する仕組み。DMARC の前提条件の一つ。

ビルド時変換を選ばなかった理由

MermaidのAstro対応としてまず検討したのは、rehype-mermaid のようなビルド時変換プラグインです。このプラグインはMarkdownのコードブロックをビルド時にSVGへ変換し、静的HTMLとして出力します。

rehype-mermaid の内部では、MermaidのJavaScript APIを実行してSVGを生成する必要があります。MermaidはブラウザーのDOM APIに依存しているため、Node.js上でそのまま実行することはできません。そこで rehype-mermaid はPlaywrightを使ってヘッドレスブラウザーを起動し、その中でMermaidのレンダリングを行います。具体的には、ヘッドレスChromiumのページ上でMermaidコードを渡し、レンダリング結果のSVGを取得してMarkdownの出力に埋め込むという流れです。

この方式を採用しなかった理由は2つあります。

1つ目は、ビルド時間です。ヘッドレスブラウザーを動かすにはChromiumのバイナリが必要で、ビルドのたびにPlaywrightのセットアップとブラウザー起動のコストがかかります。Mermaidを使うページが一部しかないのに、全ビルドでこのコストを払うのは割に合いません。

2つ目は、バンドルサイズの制御です。Mermaidのコアチャンクだけで約600KB、ダイアグラム関連を含めると数MBになります。静的インポートにするとメインのJSバンドルに含まれ、Mermaidを使わないページでも読み込まれてしまいます。動的インポートで該当ページだけに限定することで、不要なページへの影響をゼロにしています。

Shikiのシンタックスハイライト除外

最初の準備として、Mermaidのコードブロックをシンタックスハイライトの対象から外します。

// astro.config.mjs
export default defineConfig({
  markdown: {
    syntaxHighlight: {
      type: 'shiki',
      excludeLangs: ['mermaid', 'math'],
    },
  },
})

この設定がないと、Shikiが ```mermaid ブロックをコードとしてハイライトし、<span> タグで装飾されたHTMLが出力されます。Mermaidライブラリは素のテキストを受け取る必要があるため、ハイライトを除外して <code class="language-mermaid"> をそのまま出力させます。

excludeLangs のデフォルト値は ['math'] です。Astroは数式用の math 言語をデフォルトで除外しています。mermaid を追加する際にこのデフォルト値を上書きすることになるため、math を配列から落とすとKaTeX等の数式レンダリングが壊れます。明示的に ['mermaid', 'math'] と両方を指定する必要があります。

MermaidRendererコンポーネント

Reactコンポーネントとして実装しました。Astroの client:load ディレクティブでハイドレーションします。

// src/client/components/jp/MermaidRenderer.tsx
import { useEffect } from 'react'

export function MermaidRenderer() {
  useEffect(() => {
    const blocks = document.querySelectorAll('code.language-mermaid')
    if (blocks.length === 0) return

    let cancelled = false

    async function render() {
      const { default: mermaid } = await import('mermaid')
      if (cancelled) return

      mermaid.initialize({
        startOnLoad: false,
        theme: 'default',
        sequence: { width: 250, actorMargin: 80 },
      })

      const nodes: HTMLElement[] = []
      for (const code of blocks) {
        const pre = code.parentElement
        if (!(pre instanceof HTMLElement) || pre.tagName !== 'PRE') continue
        const source = code.textContent ?? ''
        pre.classList.add('mermaid')
        pre.textContent = source
        nodes.push(pre)
      }

      if (nodes.length > 0) {
        await mermaid.run({ nodes })
      }
    }

    render()
    return () => { cancelled = true }
  }, [])

  return null
}

import('mermaid') による動的インポートがこの設計の核です。Mermaidのコアチャンクだけで約600KB、ダイアグラム関連を含めると合計で数MBになります。静的インポートだとすべてのページのバンドルに含まれてしまいますが、動的インポートならMermaidブロックを含むページでのみダウンロードされます。

mermaid.initialize() の各オプション

startOnLoad: false は、Mermaidのデフォルトの自動レンダリングを無効にする設定です。Mermaidは通常、ページ読み込み時に .mermaid クラスを持つ要素を自動検出してレンダリングします。しかしこの実装では、<pre> 要素のテキスト差し替えとクラス付与を手動で行ったうえで mermaid.run() を明示的に呼び出しています。自動検出とタイミングが競合するのを避けるためstartOnLoadfalse にして手動制御に統一しています。

theme: 'default' は、Mermaidの組み込みテーマの中からデフォルトのカラーパレットを選択しています。darkforest など他のテーマもありますが、サイトの配色と干渉しないデフォルトを選んでいます。

sequence オプションはシーケンス図専用のレイアウト設定です。width: 250 はアクター(参加者)ボックスの幅をピクセル単位で指定しています。デフォルトでは150pxですが、日本語のラベルは英語より幅を取るため、余裕を持たせています。actorMargin: 80 はアクター間の水平方向の余白です。ボックス幅を広げた分、余白も調整してダイアグラム全体のバランスを取っています。

キャンセル処理

cancelled フラグはメモリリーク防止のためです。動的インポートは非同期処理なので、レンダリング完了前にページ遷移が起きた場合にDOM操作をスキップします。useEffect のクリーンアップ関数でフラグを true にし、import() の完了後にフラグを確認してから処理を続行します。

ダイアグラムがあるページだけ読み込む

レイアウトコンポーネントで hasMermaid プロパティを受け取り、MermaidRendererの読み込みを制御します。

---
// src/layouts/JpContentLayout.astro
const { hasMermaid } = Astro.props
---

{hasMermaid && <MermaidRenderer client:load />}

ページ側では記事本文にMermaidブロックが含まれるかを正規表現でチェックします。

---
const hasMermaid = /```mermaid/.test(post.body ?? '')
---

この判定はビルド時に行われるため、Mermaidを使わないページのHTMLにはReactコンポーネント自体が含まれません。バンドルサイズへの影響はゼロです。

CSSでコードブロックのスタイルをリセット

Mermaidが生成するSVGは、通常のコードブロックとは異なるスタイルが必要です。暗い背景色をリセットし、ダイアグラムを中央配置にします。

.jp-content-body pre.mermaid {
  background: var(--jp-surface);
  padding: var(--jp-space-6);
  font-size: 1rem;
  text-align: center;
  overflow-x: auto;
}

.jp-content-body pre.mermaid svg {
  max-width: 100%;
  height: auto;
}

overflow-x: auto により、モバイルで横幅に収まらないダイアグラムはスクロールで確認できます。

Markdownでの記述

記事の中ではコードブロックと同じ感覚で書けます。Labee Dev Toolboxの用語集では、SPFの認証フローをシーケンス図で、DNSルックアップの上限をフローチャートで説明しています。

```mermaid
sequenceDiagram
    participant S as 送信サーバー
    participant R as 受信サーバー
    participant D as DNS

    S->>R: メール送信(MAIL FROM: user@example.com)
    R->>D: example.com TXT を問い合わせ
    D-->>R: SPF レコードを返却
    Note over R: 送信元 IP と許可リストを照合
```

ビルド後のページでは、コードブロックの代わりにSVGのシーケンス図が表示されます。

テスト

テストでは、MermaidRendererの動作を3つの観点からカバーしています。

1つ目は、正常系のレンダリングです。code.language-mermaid を持つ要素がDOMに存在する状態でコンポーネントをマウントし、mermaid.initialize()mermaid.run() が呼ばれることを確認します。渡される nodes 配列に対象の <pre> 要素が含まれていること、<pre> 要素に mermaid クラスが付与されていること、テキストコンテンツが元のMermaidソースコードに差し替わっていることも検証対象です。

2つ目は、Mermaidブロックが存在しないページでの振る舞いです。code.language-mermaid に該当する要素がない場合、import('mermaid') が呼ばれずに処理が早期リターンすることを確認します。不要なページでライブラリのダウンロードが発生しないことを保証するテストです。

3つ目は、アンマウント時のキャンセル処理です。コンポーネントがマウントされた直後にアンマウントし、動的インポートの完了後にDOM操作がスキップされることを確認します。ページ遷移時のメモリリークやエラーを防ぐための検証です。

数百KBのライブラリを使わないページで読み込まないこと、使うページでは確実にレンダリングすること。この2点がこの実装の判断基準でした。