Mermaidを導入するところまではすぐできたけど、ライトモードとダークモードの切り替えに追従させようとして若干手間取ったので覚え書き。
↓ こういう図
テーマ切り替えのために以下の2つのアプローチを試した。
- クライアントサイドで図を都度生成(テーマ切り替え時に再生成)
- ビルド時にインラインSVGとして埋め込み、CSS変数を経由してテーマ切り替え
結局1番目のアプローチを採用した。(最初は2番目の方法を試してたけど、めんどくさすぎて断念)
コードの全体像はPR参照。
目次
パッケージのインストール
必要なパッケージをインストールする。mermaidはクライアントサイドへ配布する。
pnpm add rehype-mermaid playwright mermaidrehype-mermaidの設定
astro.config.mjsでrehype-mermaidを設定する。strategyをpre-mermaidに設定するのがポイント。
// ...
import rehypeMermaid from "rehype-mermaid";
export default defineConfig({
// ...
markdown: {
rehypePlugins: [[rehypeMermaid, { strategy: "pre-mermaid" }]],
// ...
},
});astro.config.mjsstrategyをpre-mermaidに設定すると、ビルド時にMermaidのコードブロックがSVGに変換されず、以下のようにclass="mermaid"を持つ<pre>タグとしてHTMLに出力される。 この<pre>タグ内のコードを、クライアントサイドのjsで図に変換する。
<pre class="mermaid">
graph TD
A[Client] --> B[Load Balancer]
B --> C[Server 1]
B --> D[Server 2]
</pre>出力されるHTMLのイメージクライアントサイドでのレンダリング
次に、クライアントサイドでMermaidの図をレンダリングする。 mermaid.initialize()で初期化して、ページ遷移時にmermaid.run()を実行したら<pre class="mermaid">がSVGに変換される。テーマの切り替えをしないならこれで終わり。
import mermaid from "mermaid";
// デフォルトのテーマを指定して初期化
mermaid.initialize({ theme: "dark", startOnLoad: false });
// ページが読み込まれるたびにMermaidのレンダリングを実行
document.addEventListener("astro:page-load", async () => {
await mermaid.run();
});クライアントサイドスクリプトテーマ切り替えの実装
ここからがテーマ切り替え。最初はもう一度mermaid.initialize({ theme })を呼び出せばいいと思ったけど、うまくいかなかった。 理由は、一度mermaid.run()で描画された図の<pre>要素からは、元のMermaidコードが削除されてしまうから。
描画後のHTMLを見てみると、以下のようにdata-processed="true"という属性がついて、<pre>タグの中身がSVGに置き換わっていることがわかる。
<pre class="mermaid">
graph TD
A[Client] --> B[Load Balancer]
B --> C[Server 1]
B --> D[Server 2]
</pre>
<pre class="mermaid" data-processed="true">
<svg id="mermaid-1762685428736">...</svg>
</pre>描画前後のHTMLの比較というわけでワークアラウンド気味だが、Issueコメントを参考に次のような処理フローで実装。
初回描画時の処理
<pre>タグ内にある元のMermaidコードを、data-original-code属性にバックアップとして退避- 現在のテーマで
mermaid.initialize({ theme })を実行 mermaid.run()を実行して図を描画
テーマ切り替え時の処理
data-processed属性を持つ図を検索し、data-original-code属性に退避させておいたMermaidコードを<pre>タグ内に復元- 新しいテーマで
mermaid.initialize({ theme })を再実行 mermaid.run()を実行して、復元したコードを元に新しいテーマで図を再描画
実装
実装したコードは以下
import type { MermaidConfig } from "mermaid";
// ページ内のMermaid要素をすべて取得する
const getMermaidElements = (): NodeListOf<HTMLElement> =>
document.querySelectorAll(".mermaid");
/**
* 指定されたテーマでMermaidの図をレンダリングする
*/
export const renderDiagrams = async (
theme: MermaidConfig["theme"],
dark: boolean
) => {
const elements = getMermaidElements();
if (elements.length === 0) {
return;
}
// 動的にmermaidをインポート
const mermaid = (await import("mermaid")).default;
mermaid.initialize({ theme, startOnLoad: false, darkMode: dark });
await mermaid.run({ nodes: elements });
};
/**
* レンダリング前のMermaidコードをdata-original-code属性に退避させる
*/
export const saveMermaidCodes = () => {
const elements = getMermaidElements();
for (const element of elements) {
if (!element.hasAttribute("data-original-code")) {
element.setAttribute("data-original-code", element.innerHTML);
}
}
};
/**
* 描画済みのMermaid図をレンダリング前の状態(Mermaidコード)に戻す
*/
export const resetProcessedDiagrams = () => {
const elements = getMermaidElements();
for (const element of elements) {
// data-processed属性がなければ未処理なのでスキップ
if (!element.hasAttribute("data-processed")) {
continue;
}
const originalCode = element.getAttribute("data-original-code");
if (originalCode != null) {
element.removeAttribute("data-processed");
element.innerHTML = originalCode;
}
}
};src/utils/mermaid.ts...
<!-- mermaid diagram support -->
<script>
import {
resetProcessedDiagrams,
saveMermaidCodes,
renderDiagrams,
} from "@/utils/mermaid";
const loadMermaidDiagrams = async (theme: "light" | "dark") => {
saveMermaidCodes();
resetProcessedDiagrams();
const dark = theme === "dark";
await renderDiagrams(dark ? "dark" : "default", dark);
};
document.addEventListener("theme-changed", async ev => {
const theme = ev.detail.theme;
await loadMermaidDiagrams(theme);
});
document.addEventListener("astro:page-load", async () => {
const storedTheme = localStorage.getItem("theme");
if (storedTheme) {
await loadMermaidDiagrams(storedTheme == "dark" ? "dark" : "light");
} else {
const preferedTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
await loadMermaidDiagrams(preferedTheme);
}
});
</script>src/layouts/PostDetails.astro(テーマ切り替え部分抜粋)まとめ
Astroブログにmermaid.jsを導入し、クライアントサイドでレンダリングすることで動的なテーマ切り替えを実装した。
ただし、mermaid.jsはgzip圧縮後でも140kB程度と比較的サイズが大きいため、ページの表示速度が落ちる可能性あり
補足(没案): インラインSVGとCSS変数でテーマ変更
rehype-mermaidのstrategyをinline-svgにし、themeCSSオプションでCSS変数を埋め込むやり方。このやり方だとクライアントサイドにmermaidが不要。
具体的には、themeCSSでSVGの各要素(ノードやテキストなど)の色をCSS変数で定義し、<html>タグのクラス(例: .dark)に応じてCSS側で変数の値を切り替える。
Mermaidが生成するSVGのセレクタは、図の種類ごとに定義されてるので、それらすべてにCSS変数を当てていくとうまくいく。 ただめちゃめちゃめんどくさい。Claude Codeに書かせてたけど破綻したので途中で断念……
// SVG内の各要素に適用するスタイルをCSS変数で定義
const mermaidThemeCSS = `
.node rect,
.node circle,
.node ellipse,
.node polygon,
.node path {
fill: var(--mermaid-node-fill);
stroke: var(--mermaid-node-stroke);
}
.label,
.nodeLabel {
color: var(--mermaid-text-color);
}
/* ... SVGを構成する他の全セレクタに対しても同様の定義が必要 ... */
`
export default defineConfig({
markdown: {
rehypePlugins: [
[
rehypeMermaid,
{
strategy: "inline-svg",
mermaidConfig: {
// themeをnullに設定し、themeCSSを有効化
theme: "null",
themeCSS: mermaidThemeCSS,
},
},
],
// ...
});astro.config.mjs(イメージ):root {
--mermaid-node-fill: #f0f0f0;
--mermaid-node-stroke: #333;
--mermaid-text-color: #000;
}
:root.dark {
--mermaid-node-fill: #333;
--mermaid-node-stroke: #f0f0f0;
--mermaid-text-color: #fff;
}global.css(イメージ)