EmDashブログにMermaidで図解を入れられるようにしました

きっかけ

Zennで slack-utils-soracomの記事 を読んでいて、Mermaidで書かれたシーケンス図が目に留まりました。
文章で説明されるより図があるとわかりやすくて良いなあと思い、この自分のブログ(EmDashで動いている)にも導入してみました。

Mermaidとは

Mermaidは、文章のように書いたものを図に変換してくれるツールです。たとえばこんなふうに書くと、

flowchart LR
    A --> B
    B --> C

このように矢印でつながった図になります。

flowchart LR
    A --> B
    B --> C

画像ではなく、テキストを書くだけなので、

  • テキストだけで図が作れる
  • あとから修正しやすい(「AからCに変えたい」なら1文字書き換えるだけ)
  • 他の人に共有するのもテキストだけで済む

というのが便利です。
フローチャート、シーケンス図(時系列でのやりとりを表す図)、ガントチャートなどいろいろな種類の図に対応していて、GitHubやNotionなどでは標準で使えます。


ゴール

記事本文で ```mermaid のコードブロックを書くと、公開ページで図として表示されるようにしたい。
EmDash本体には手を入れない。(アップデートで毎回追随が必要になるため)

最初の調査: EmDashは内部でMarkdownを使っていない

最初は rehype-mermaidastro-mermaid というプラグインを入れれば終わる話だと思っていました。
これらはMarkdownをHTMLに変換する途中で、Mermaidのコードを図に変換してくれる仕組みです。

ところが、EmDashの記事本文はMarkdownではなく、Portable Text というJSON構造でDBに保存されていました。
Portable Textは、段落・見出し・コードブロックなどを構造化されたデータとして持つフォーマットで、「これは段落」「これはコード」と型で区別されます。記事にMermaidのコードブロックを書くと、こんな感じのJSONがDBに入ります。

[
  { "_type": "block", "children": [{ "_type": "span", "text": "見出し" }] },
  {
    "_type": "code",
    "language": "mermaid",
    "code": "sequenceDiagram\n..."
  }
]

レンダリングの経路はこうなっています。

src/pages/posts/[slug].astro
    │  <PortableText value={post.data.content} />
    ▼
node_modules/emdash/src/components/PortableText.astro
    │  astro-portabletext の BasePortableText に委譲
    ▼
node_modules/emdash/src/components/Code.astro  ← コードブロックはここ

この中で、コードブロックを最終的にHTMLにしているのが Code.astro というファイルです。
中身は60行程度で、やっていることは単純そうでした。

---
const { code, language, filename } = node;
const languageClass = language ? `language-${language}` : "";
---

<div class="emdash-code">
  {filename && <div class="emdash-code-filename">{filename}</div>}
  <pre class={languageClass}><code class={languageClass}>{code}</code></pre>
</div>

やっていることは「記事のコード部分をそのまま <pre><code> というHTMLタグで囲って出すだけ」で、
ごらんの通りシンタックスハイライト(コードをカラフルに色付けする処理)も行っていません。

ここで2つのことが分かりました。

  • rehype-mermaid はMarkdown→HTMLの変換途中に差し込むプラグインなので、Markdownを通らないEmDashでは使えない
  • Code.astro が生テキストを出力しているだけなので、ページが表示された後にブラウザ側でHTMLを書き換える(DOM置換する)方針が良さそう

結論として、ビルド時(記事を配信用に変換するタイミング)での変換ではなく、ブラウザ側でレンダリングする方式にしました。EmDashはCloudflare Workersという、サーバー側で重い処理を走らせにくい環境で動かしているので、ブラウザ側に処理を寄せる判断とも相性が良いです。

実装

Base.astro というブログ全体で使われる共通テンプレートに、以下のスクリプトを追加します。
posts配下の記事ページに限定せず全ページに置いていますが(用語集などの独自ページもあるため)、
Mermaidブロックがないページではすぐに処理が終わるので、重くはならないハズです。

<script>
  async function renderMermaid() {
    const blocks = document.querySelectorAll<HTMLElement>(
      "pre > code.language-mermaid"
    );
    if (blocks.length === 0) return;

    const { default: mermaid } = await import(
      /* @vite-ignore */
      // @ts-ignore - remote ESM, resolved at runtime from CDN
      "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
    );

    const isDark = document.documentElement.classList.contains("dark");
    mermaid.initialize({
      startOnLoad: false,
      theme: isDark ? "dark" : "default",
      fontFamily:
        "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
    });

    const items = Array.from(blocks);
    for (let i = 0; i < items.length; i++) {
      const code = items[i];
      const pre = code.closest("pre");
      if (!pre) continue;
      const target =
        pre.parentElement?.classList.contains("emdash-code")
          ? pre.parentElement
          : pre;
      const source = code.textContent ?? "";
      const wrapper = document.createElement("div");
      wrapper.className = "mermaid-rendered";
      const id = `mermaid-${Date.now()}-${i}`;
      try {
        const { svg } = await mermaid.render(id, source);
        wrapper.innerHTML = svg;
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        console.error("Mermaid render error:", err);
        wrapper.innerHTML = `<pre class="mermaid-error">Mermaid error: ${msg.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)}</pre>`;
      }
      target.replaceWith(wrapper);
    }
  }

  renderMermaid();
</script>

置換の単位だけ少し考えました。
EmDashの Code.astro<div class="emdash-code"><pre><code> という入れ子構造でHTMLを出力するので、一番内側の <pre> だけ置き換えると、外側の空の <div> が残ってしまいます。なので、親要素が .emdash-code なら親ごと置換、そうでなければpre単位で置換という分岐を入れました。

CSSはこんな感じです(Base.astro内に記載)。図のはみ出し対策とエラーメッセージの見た目だけ。

.mermaid-rendered {
  margin: 2em 0;
  text-align: center;
  overflow-x: auto;
}
.mermaid-rendered svg {
  max-width: 100%;
  height: auto;
}
.mermaid-error {
  margin: 2em 0;
  padding: var(--spacing-4);
  background: color-mix(in srgb, #d00 10%, var(--color-bg));
  border: 1px solid color-mix(in srgb, #d00 30%, var(--color-border));
  border-radius: var(--radius);
  color: #c00;
  font-family: var(--font-mono);
  font-size: var(--font-size-sm);
  white-space: pre-wrap;
}


注意点: innerHTMLとXSS

初稿では、エラー時に wrapper.innerHTML = err.message と書いて、エラーメッセージをそのままHTMLとして埋め込んでいました。
ただ、innerHTML という仕組みは、渡した文字列の中に <script> タグなどが含まれていると、それをそのまま実行してしまう危険があります。
※これを利用した攻撃を XSS(クロスサイトスクリプティング) と呼びます。


今回は管理者(自分)しか記事を書けないブログの管理画面なので実害は考えにくいのですが、Mermaidのエラーメッセージには書いたコードがそのまま含まれることがあるため、記事本文に <script> を含むMermaidコードを書けばXSSの原因になりえます。
習慣としても避けておきたいので、HTMLとして危険な記号(< > &)を安全な表記に置換する処理を挟みました。

動作確認

せっかくなので、Mermaidを使ってレンダリングの流れを描いてみます

sequenceDiagram
    participant B as ブラウザ
    participant P as 記事ページ
    participant S as renderMermaid()
    participant C as jsdelivr CDN
 
    B->>P: GET /posts/...
    P-->>B: HTML (pre > code.language-mermaid を含む)
    B->>S: script実行
    S->>S: querySelectorAll で検出
    alt 0件
        S-->>B: 何もしない
    else 1件以上
        S->>C: mermaid.esm.min.mjs を動的import
        C-->>S: モジュール
        S->>S: mermaid.render() で各ブロックをSVG化
        S-->>B: DOM置換
    end

なお現状、EmDashの編集画面UIからはコードブロックの言語を指定する項目が見当たらなかったので、Markdownショートカット(キーボードの特定の組み合わせで書式を変える機能)で入力する必要があります。

空行で ``` (バッククォート3つ) + mermaid + 半角スペース と打つと、言語指定付きのコードブロックに変換されますので、その状態で下記を入力すると上記の図が表示されます。

```mermaid
sequenceDiagram
    participant B as ブラウザ
    participant P as 記事ページ
    participant S as renderMermaid()
    participant C as jsdelivr CDN

    B->>P: GET /posts/...
    P-->>B: HTML (pre > code.language-mermaid を含む)
    B->>S: script実行
    S->>S: querySelectorAll で検出
    alt 0件
        S-->>B: 何もしない
    else 1件以上
        S->>C: mermaid.esm.min.mjs を動的import
        C-->>S: モジュール
        S->>S: mermaid.render() で各ブロックをSVG化
        S-->>B: DOM置換
    end
```

できなかったこと

  • ブログ編集画面でのライブプレビュー(EmDashプラグイン開発が必要)
  • テーマ切替時の自動再描画(Mermaidは初期化時のテーマで図を生成するため、ライト/ダーク切替後はリロードで対応)
  • ビルド時SVG事前生成(記事を配信用に変換するタイミングで図もSVGにしておく方式)
  • カスタムテーマ設定UI

特に編集画面プレビューは欲しいのですが、EmDash自体がまだbeta previewなので、本体が安定するまで様子見にします。

No comments yet