AstroでMermaidダイアグラムを表示するにあたり、クライアントサイドレンダリングを採用しました。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() を明示的に呼び出しています。自動検出とタイミングが競合するのを避けるため、startOnLoad を false にして手動制御に統一しています。
theme: 'default' は、Mermaidの組み込みテーマの中からデフォルトのカラーパレットを選択しています。dark や forest など他のテーマもありますが、サイトの配色と干渉しないデフォルトを選んでいます。
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点がこの実装の判断基準でした。
