私のブログと、私が参加している別のオープンソースプロジェクトは、最近どちらも Mermaid を Obsidian チームがオープンソースで公開した pele に乗り換えました。まずは同じ構文から描いた図を見てください。左が Mermaid 公式パッケージ、右が pele です。

見た目が良いだけではありません。同じ 136 枚の図で、ブラウザ上で Mermaid 公式パッケージと比べるとこうなります。

1.1ms
1 枚あたりの再描画
Mermaid は 28 ms
67KB
ダウンロードするレンダラー
gzip 後。Mermaid は 224 KB
4.8KB
SVG のサイズ
Mermaid は 27.5 KB
0回の再描画
テーマ切り替え
Mermaid は毎回すべて再描画

詳しいデータと測り方は最後の節にまとめています。

私のブログでは Mermaid で図をよく描きます。フロー、アーキテクチャ、シーケンス、どれも Mermaid 頼みです。現在サイトには 44 本の記事があり、5 つの言語を合わせると図は 136 枚になります。これらの図をきれいに見せつつページを重くしないために、レンダラーはこれまでに 3 回変えてきました。最初は Mermaid 公式パッケージ、8 月に beautiful-mermaid に変え、先週のサイト全面リニューアルで Obsidian チームがオープンソースで公開した pele に変えました。

pele に変えたあと、サイトの 136 枚の図で実測してみました。すべてレンダリングに成功し、合計でわずか 75 ミリ秒ほど、beautiful-mermaid より約 9 倍速く、出力される SVG も 4 割小さくなりました。さらに重要なのは、DOM にまったく触れず、依存パッケージも一切なく、配色はすべて CSS 変数に任せている点です。おかげで「サーバー側で SVG を描いておき、ブラウザではレンダリング用のコードを 1 行も読み込まない」という構成がちょうど実現できました。

この記事は次の順番で書いています。pele とは何か、なぜ私がそこに辿たどり着いたのか、実測データと描画の違い、TanStack Start への組み込み方(サーバー側で図を出力する、CSS だけでテーマを切り替える、画像をダウンロードするときに色を書き戻す)、そして最後に、別のプロジェクトがブラウザ側で Mermaid 公式パッケージを置き換えた前後の比較です。コードもすべて載せているので、Mermaid を使っているブログならそのまま参考になるはずです。

pele とは#

pele は Obsidian チームがオープンソースで公開している Mermaid レンダラーで、MIT ライセンスです。ソースコードは GitHub の obsidianmd/pele にあり、npm での公開者は kepano、lishid といった Obsidian のメンバーで、GitHub Actions 経由で公開されています。やることはひとつだけで、Mermaid の構文を SVG 文字列に変換します。

特徴をいくつかにまとめました。

  • 同期で、依存パッケージなし:render(text) がそのまま { svg } を返します。ブラウザも jsdom も要りません。
  • Mermaid 12.1 に組み込まれているすべての図の種類に対応:各図のパーサーは Mermaid 自身のテスト仕様を通しており、Mermaid のパーサーを使ったファジングも行われています。
  • 配色は CSS 変数だけ:出力される SVG は var(--pele-fg)、var(--pele-surface) のような変数で色が付くので、テーマを変えても再描画は不要です。
  • 安全:出力される SVG には script もイベントハンドラーも HTML も含まれず、図の中のコードが実行されることもありません。
  • 必要な図の種類だけ読み込める:pele/core に単一の図モジュールを組み合わせるか、pele/lazy でオンデマンドに読み込めます。

レイアウトと見た目のスタイルは pele 独自のものなので、同じ構文でも GitHub や Mermaid Live Editor で見るものとは違う図になります。冒頭の比較画像を見ればわかると思います。

WARNING

npm 上の pele 1.0.0 は 2020 年の別プロジェクト(LavaMoat の実験)で、この pele とは無関係です。Obsidian の pele は 2026 年 10 月 6 日にようやく 1.x の公開を始め、3 週間で 1.12.0 まで進みました。ほぼ毎日新しいバージョンが出ています。私のサイトは現在 1.3.0 に固定しています。

なぜ pele にたどり着いたのか#

Mermaid 公式パッケージ#

最初は Mermaid 公式パッケージに ELK レイアウトを組み合わせ、ブラウザ側でレンダリングしていました。描けることは描けるのですが、ずっと悩まされていた点がいくつかありました。

  • 重すぎる:レンダラーと ELK を合わせると lazy chunk はおよそ 1.5 MB。記事に図が 1 枚でもあれば、読者はこの塊を読み込まなければなりません。
  • 図が背景と同じ色になる:デフォルトのノードの塗りは半透明の紫で、サイトの暗い星空の上に重なるとほとんど見えません。自分で記事を読むときでさえ、ツールバーでテーマを切り替えていました。
  • ガードを大量に書く必要がある:ELK はレイアウト時にオブジェクトをシリアライズするため、React fiber にぶつかって circular JSON が発生します。そのために 35 行の JSON.stringify ガードを書きました。

beautiful-mermaid#

8 月に beautiful-mermaid に変えました。同期で DOM に触れず、出力される SVG も CSS 変数で色が付くので、レンダリングを丸ごとサーバー側に移せました。図はサーバーで描かれ、ブラウザは SVG 文字列を受け取るだけです。client 側の JS は 20.18 MB から 18.80 MB に減りました。

問題はレイアウトでした。内部はやはり ELK で、少し複雑な図になると線が交差し、エッジのラベルが小さな枠ひとつひとつに収められて、窮屈きゅうくつに見えます。加えてサイズも小さくなく、minify 後でも 1.5 MB ありました。

pele#

10 月のサイトリニューアルでは、サイト全体から紫をなくすことになり、図のブロックも作り直す必要があったので、ついでに pele に変えました。pele は、レンダリングをサーバーに移せた beautiful-mermaid の特性、つまり同期で DOM に触れず配色を CSS 変数に任せる点をそのまま備えつつ、サイズはずっと小さく、描かれる図もずっとすっきりしています。

実測:beautiful-mermaid との比較#

サイトの 44 本の記事、5 つの言語にある 136 枚の Mermaid 図をすべて抜き出し(flowchart 107 枚、graph 19 枚、sequenceDiagram 10 枚)、同じマシン上で Node を使って pele 1.3.0 と beautiful-mermaid 1.1.3 をそれぞれ 3 回ずつ走らせました。

pele 1.3.0beautiful-mermaid 1.1.3
成功136 / 136136 / 136
全体の所要時間72–91 ms657–693 ms
1 枚あたりの中央値0.4 ms3.7 ms
1 枚あたりの p951.4 ms8.2–11.3 ms
最も遅い 1 枚7–10 ms94–102 ms
SVG の平均サイズ4.8 KB7.7 KB

サイズについては、esbuild でそれぞれ minify 済みの browser bundle を作りました。

バンドル内容minify 後gzip
pele すべての図の種類626 KB231 KB
pele flowchart のみ登録112 KB43 KB
beautiful-mermaid1,522 KB457 KB

私のサイトはサーバー側でレンダリングしているので、これらのサイズが読者のブラウザに届くことはありません。client 側で使うつもりなら、pele で必要な図の種類だけを登録すると大きく違います。flowchart だけなら gzip 後で 43 KB です。

描画の違い#

下は CrowdSec の記事にある同じ図です。旧サイトはレンダラーを変える前の状態で凍結しているので、beautiful-mermaid で描かれたままです。

pele に変えたあと:

同じ左から右へのフローチャートですが、pele は一直線に並べ、往復する 2 本の線も平行に整え、ラベルは線の上に直接置いていて、交差はひとつもありません。この図には元々 classDef で緑と黄色に指定したノードがありましたが、新版ではその 2 色が見当たりません。これはサイト側で別途処理しているためで、後ほど説明します。

サイトへの組み込み方#

私のサイトは TanStack Start を使っています。全体の流れは、サーバー側で記事内の Mermaid ブロックをすべて SVG に描き、記事データと一緒にブラウザへ送り、ブラウザは SVG をページに差し込んでズームとツールバーを担当するだけ、というものです。

サーバー側で図を出力する#

レンダリングは 1 行だけで、配色はレンダラーに一切持ち込みません。

ts
// src/lib/mdx/mermaidSvg.ts
import { render } from 'pele';

export function renderMermaidSvg(body: string): string {
  return render(body).svg;
}

これを server function で包み、記事内の図を 1 枚ずつ描いて「図の内容の hash → SVG」の対応表を作ります。

ts
// src/lib/mdx/mermaidRender.ts
export const renderMermaidSvgs = createServerFn({ method: 'POST' })
  .validator((content: string) => content)
  .handler(async ({ data: content }) => {
    const fences = extractMermaidFences(content);
    if (fences.length === 0) return {};
    // 必ず handler の中で動的 import する。モジュールのトップレベルに静的 import を置いてはいけない
    const { renderMermaidSvg } = await import('./mermaidSvg');
    const out: Record<string, string> = {};
    for (const body of fences) {
      const key = mermaidKey(body);
      if (out[key]) continue;
      try {
        out[key] = renderMermaidSvg(body);
      } catch {
        // 1 枚の図の構文が壊れていても、記事全体を失敗させるべきではない
      }
    }
    return out;
  });

この対応表は記事データを読み込むときに取得し、query と一緒にブラウザへ送られ、図のコンポーネントはそれを引くだけです。SVG はすでにサーバー側で描かれているので SSR の HTML にも含まれ、読者はページを開いた瞬間に図を見られます。JS を待つ必要はありません。

必ず避けなければならない罠わながひとつあります。レンダラーは動的 import しかできないということです。最初に試したとき、SSR 側では静的 import、ブラウザ側では動的 import にしていました。すると同じモジュールが 2 通りの方法で参照され、rollup がそれを分割できずにメイン chunk へそのまま統合してしまい、記事ページのルート chunk が 127 KB から 1,613 KB に膨れ上がりました。すべてを server function 内の動的 import に変えたあとは、client 側からレンダラーのコードが完全に消えました。

テーマ切り替えは CSS 変数を変えるだけ#

pele が出力する SVG は --pele-* 変数で色が付き、その値は外側の class が与えます。私のサイトはテーマを 2 つだけ残しています。夜(サイトの配色)と紙(明るい配色で、画像としてダウンロードしてほかの場所に貼りやすいもの)です。

css
.mm-theme-ink {
  --pele-bg: transparent;
  --pele-fg: var(--paper);
  --pele-muted: var(--paper-3);
  --pele-line: var(--paper-2);
  --pele-surface: rgba(var(--paper-rgb), 0.05);
  --pele-surface-alt: rgba(var(--paper-rgb), 0.1);
  --pele-border: var(--paper-3);
  --pele-accent: var(--amber);
  --pele-font: var(--cjk-font);
  --pele-font-mono: var(--mono-font);
  --pele-radius: 2px;
}

読者がツールバーで「紙」に切り替えても、mm-theme-ink を mm-theme-light に変えるだけです。図を再描画する必要はなく、ブラウザ側にレンダラーも要りません。

画像をダウンロードするときは色を書き戻す#

このやり方には副作用がひとつあります。SVG そのものには色がなく、色は外側の CSS が与えています。読者が「SVG をダウンロード」で手に入れたファイルはページを離れると変数に値がなくなり、pele デフォルトの白地に黒文字に戻ってしまいます。そこでダウンロードの前に、その時点で計算された変数の値を SVG 自体に書き込んでいます。

ts
const PELE_VARS = ['--pele-bg', '--pele-fg', '--pele-muted', '--pele-line', '--pele-surface',
  '--pele-surface-alt', '--pele-border', '--pele-accent', '--pele-font', '--pele-font-mono', '--pele-radius'];

function standaloneSvg(svg: SVGSVGElement): SVGSVGElement {
  const clone = svg.cloneNode(true) as SVGSVGElement;
  const cs = getComputedStyle(svg);
  for (const name of PELE_VARS) {
    const value = cs.getPropertyValue(name).trim();
    if (value) clone.style.setProperty(name, value);
  }
  clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  return clone;
}

PNG のダウンロードも、まずこの SVG を canvas に描き、背景色はテーマに合わせます。夜ならサイトの黒、紙なら白です。

図の中で私が付けた色#

古い記事では、classDef でノードに色を付けることがよくありました。薄い緑は成立、薄い赤は除外、淡い黄色は要点を表し、全部で 11 本の記事で使っています。リニューアル後はサイト全体を黒と白、それに琥珀色こはくいろひとつだけにしたので、これらの色をそのまま入れるとかなり浮いてしまいます。とはいえ内容の一部なので、単純に取り除くわけにもいきません。

そこでサーバー側で図を出力するときに、ひと手間加えました。各ノードに私が当初付けた色を読み取り、色相からそれが図の中で何を表しているかを判断して 4 つの役割に振り分け、元の色を外して対応する class を付け、描き方はサイトの CSS に任せます。つまり、色がノードの見た目を直接決めるのではなく、まず「このノードの意味」に翻訳されるわけです。

元の色役割描き方
緑成立いちばん明るい枠、ごく薄い背景
黄、橙要点同上、左上に琥珀色の点をひとつ追加、1 枚の図に 1 つだけ
青、紫副次枠と文字を一段落とす
赤除外点線の枠、背景なし、文字をさらに一段落とす

グレースケールや認識できない書き方はデフォルトどおりに描きます。先ほどの図で Jarvis bot の左上にある琥珀色の点は、こうして付いたものです。記事本文は 1 文字も変える必要がなく、画像をダウンロードするときもこれらのスタイルが一緒に書き込まれます。

Mermaid 公式との真っ向勝負:ブラウザ側で 20 倍速い#

私は友人と一緒に tessera というオープンソースプロジェクトもメンテナンスしています。セルフホストのファイルとメモの共有サービスで、共有したものは時間が来ると自動で消えます。まだ開発中です。メモには Mermaid を書くことができ、元々は Mermaid 公式パッケージを使っていました。ブログを乗り換えたあと、一緒に開発している仲間に tessera も pele に変えてもらい、PR #148 が 10 月 28 日にマージされました。

tessera の状況はブログとちょうど逆です。メモはユーザーがブラウザで打ちながらプレビューするものなので、図は client 側でしか描けず、しかも内容は他人が貼り付けたもので信頼できません。1 文字変えるたびに図を再描画する必要があり、他人が共有したメモを開くときも、ブラウザはまずレンダラーをダウンロードしなければなりません。だからここでは、レンダラーのサイズと速度がそのまま体感に表れます。

乗り換えた効果#

PR ではサイズを測っていなかったので、自分で一度測ってみました。tessera の乗り換え前と乗り換え後の書き方(同じバージョン、同じ設定)に合わせて esbuild でオンデマンド読み込みのモジュールにバンドルし、Edge でサイトの 136 枚の図を順番に描き、それぞれ 3 回ずつ走らせました。

Mermaid 11.17.2(乗り換え前)pele 1.12.0(乗り換え後)
最初の図を描くのにダウンロードする JS821 KB(gzip 224 KB)160 KB(gzip 67 KB)
最初の図が描かれるまで(読み込み込み)110–132 ms29–35 ms
136 枚すべて描き終わるまで4.1–4.5 秒0.19–0.24 秒
1 枚あたりの中央値28 ms1.1–1.4 ms
SVG の平均サイズ27.5 KB4.8 KB
ライト/ダークモードの切り替えすべての図を再描画再描画不要

図のあるメモを開くとき、ダウンロードするレンダラーは元の 3 割ほどになりました。打ちながらプレビューするときは、再描画 1 回が 30 ミリ秒近くから 1 ミリ秒ちょっとになり、約 20 倍速くなっています。これらの時間はローカルで読み込み、帯域制限なしの状態で測ったもので、主に JS の解析と実行のコストを反映しています。実際にネットワーク越しに読み込む場合は、Mermaid が余分にダウンロードする 150 KB あまりを別途考慮する必要があります。136 枚の図はどちらも全部描画に成功しており、乗り換えで壊れた図はひとつもありません。

組み込み方#

組み込みには pele のもうひとつの使い方、ブラウザ側でのオンデマンド読み込みを使います。

ts
// 描く図があるときだけレンダラーを読み込み、メイン bundle には入れない
const { renderAsync } = await import('pele/lazy');
// 貼り付けられた内容は信頼できない:出力にリンクを含めず、画像リクエストも発行しない
const rendered = await renderAsync(trimmed, {
  images: false,
  links: false,
});

配色も同じく CSS 変数に任せます。tessera には元々デザイントークンが揃っているので、:root で --pele-* を直接紐付けました。ライト/ダークモードを切り替えたりアクセントカラーを変えたりすると、図も追従し、再描画は要りません。

css
:root {
  --pele-bg: var(--card);
  --pele-fg: var(--foreground);
  --pele-surface: var(--muted);
  --pele-border: var(--border);
  --pele-accent: var(--primary);
  /* 残りの変数も同じように既存の token に対応させる */
}

削除したコード#

速くなっただけでなく、メンテナンスの煩わずらわしさも大きく減りました。レンダリング用の hook は 66 行削除して 12 行追加しただけで、削除したのはほとんどが Mermaid を回避するために書いたものです。

  • レンダリングのたびに重複しない id を生成する必要があった。
  • Mermaid はレンダリングに失敗すると、サイズ計測用のコンテナを body に残したままにし、どんどん溜まっていく(mermaid-js/mermaid#4730)ので、自分で片付ける必要があった。
  • Mermaid が出力する SVG は幅が 100% になっていて、本当のサイズは inline style に隠れているため、正規表現で width、height に書き換えないとズーム用のウィンドウで測れなかった。pele の SVG は最初から幅と高さを持っている。
  • テーマを変えるたびに図全体を再レンダリングする必要があった。

lockfile からも d3、cytoscape、KaTeX、DOMPurify など、Mermaid が持ち込んでいた 80 あまりのパッケージが消え、追加されたのは pele ひとつだけです。pele 自体には依存が一切ありません。同じレンダラーを、ブログはサーバー側で、tessera はブラウザ側で使っていて、どちらの組み込み方もとてもシンプルです。

注意点#

  • バージョンは固定する:pele は現在とても頻繁に更新されていて、3 週間で十数バージョンが出ています。バージョンを固定し、アップグレード前に変更内容を確認して、自分の図で一通り試すことをおすすめします。
  • 見た目は Mermaid と違う:pele が約束しているのは構文の互換性だけで、レイアウトと見た目は独自のものです。読者が GitHub 上のレンダリング結果と図を見比べるようなら、その覚悟はしておいたほうがいいでしょう。
  • エラーメッセージはソースを引用する:render() は構文エラーや未対応の種類に出会うと PeleError を投げ、メッセージには図の原文が含まれます。表示するときはプレーンテキストとして扱い、HTML にそのまま入れないでください。
  • ブラウザ側では mount() が使える:CSS からフォントを読み取り、コンテナの幅に合わせてレイアウトを調整してくれます。私のサイトはサーバー側で図を出力しているので使っていません。

乗り換えてから今まで、サイトの 136 枚の図はすべて正常で、記事ページはもうレンダラーを一切抱える必要がありません。tessera のほうも、ダウンロードするレンダラーは元の僅わずか 3 割になりました。普段から Obsidian でメモを取っている私ですが、まさかブログの図まで同じチームが作ったものを使うことになるとは思っていませんでした。

參考連結