私のブログと、私が参加している別のオープンソースプロジェクトは、最近どちらも Mermaid を Obsidian チームがオープンソースで公開した pele に乗り換えました。まずは同じ構文から描いた図を見てください。左が Mermaid 公式パッケージ、右が pele です。
見た目が良いだけではありません。同じ 136 枚の図で、ブラウザ上で 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.0 | beautiful-mermaid 1.1.3 | |
|---|---|---|
| 成功 | 136 / 136 | 136 / 136 |
| 全体の所要時間 | 72–91 ms | 657–693 ms |
| 1 枚あたりの中央値 | 0.4 ms | 3.7 ms |
| 1 枚あたりの p95 | 1.4 ms | 8.2–11.3 ms |
| 最も遅い 1 枚 | 7–10 ms | 94–102 ms |
| SVG の平均サイズ | 4.8 KB | 7.7 KB |
サイズについては、esbuild でそれぞれ minify 済みの browser bundle を作りました。
| バンドル内容 | minify 後 | gzip |
|---|---|---|
| pele すべての図の種類 | 626 KB | 231 KB |
| pele flowchart のみ登録 | 112 KB | 43 KB |
| beautiful-mermaid | 1,522 KB | 457 KB |
私のサイトはサーバー側でレンダリングしているので、これらのサイズが読者のブラウザに届くことはありません。client 側で使うつもりなら、pele で必要な図の種類だけを登録すると大きく違います。flowchart だけなら gzip 後で 43 KB です。
描画の違い#
下は CrowdSec の記事にある同じ図です。旧サイトはレンダラーを変える前の状態で凍結しているので、beautiful-mermaid で描かれたままです。
pele に変えたあと:
同じ左から右へのフローチャートですが、pele は一直線に並べ、往復する 2 本の線も平行に整え、ラベルは線の上に直接置いていて、交差はひとつもありません。この図には元々 classDef で緑と黄色に指定したノードがありましたが、新版ではその 2 色が見当たりません。これはサイト側で別途処理しているためで、後ほど説明します。
サイトへの組み込み方#
私のサイトは TanStack Start を使っています。全体の流れは、サーバー側で記事内の Mermaid ブロックをすべて SVG に描き、記事データと一緒にブラウザへ送り、ブラウザは SVG をページに差し込んでズームとツールバーを担当するだけ、というものです。
サーバー側で図を出力する#
レンダリングは 1 行だけで、配色はレンダラーに一切持ち込みません。
// src/lib/mdx/mermaidSvg.ts
import { render } from 'pele';
export function renderMermaidSvg(body: string): string {
return render(body).svg;
}これを server function で包み、記事内の図を 1 枚ずつ描いて「図の内容の hash → SVG」の対応表を作ります。
// 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 つだけ残しています。夜(サイトの配色)と紙(明るい配色で、画像としてダウンロードしてほかの場所に貼りやすいもの)です。
.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 自体に書き込んでいます。
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(乗り換え後) | |
|---|---|---|
| 最初の図を描くのにダウンロードする JS | 821 KB(gzip 224 KB) | 160 KB(gzip 67 KB) |
| 最初の図が描かれるまで(読み込み込み) | 110–132 ms | 29–35 ms |
| 136 枚すべて描き終わるまで | 4.1–4.5 秒 | 0.19–0.24 秒 |
| 1 枚あたりの中央値 | 28 ms | 1.1–1.4 ms |
| SVG の平均サイズ | 27.5 KB | 4.8 KB |
| ライト/ダークモードの切り替え | すべての図を再描画 | 再描画不要 |
図のあるメモを開くとき、ダウンロードするレンダラーは元の 3 割ほどになりました。打ちながらプレビューするときは、再描画 1 回が 30 ミリ秒近くから 1 ミリ秒ちょっとになり、約 20 倍速くなっています。これらの時間はローカルで読み込み、帯域制限なしの状態で測ったもので、主に JS の解析と実行のコストを反映しています。実際にネットワーク越しに読み込む場合は、Mermaid が余分にダウンロードする 150 KB あまりを別途考慮する必要があります。136 枚の図はどちらも全部描画に成功しており、乗り換えで壊れた図はひとつもありません。
組み込み方#
組み込みには pele のもうひとつの使い方、ブラウザ側でのオンデマンド読み込みを使います。
// 描く図があるときだけレンダラーを読み込み、メイン bundle には入れない
const { renderAsync } = await import('pele/lazy');
// 貼り付けられた内容は信頼できない:出力にリンクを含めず、画像リクエストも発行しない
const rendered = await renderAsync(trimmed, {
images: false,
links: false,
});配色も同じく CSS 変数に任せます。tessera には元々デザイントークンが揃っているので、:root で --pele-* を直接紐付けました。ライト/ダークモードを切り替えたりアクセントカラーを変えたりすると、図も追従し、再描画は要りません。
: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 でメモを取っている私ですが、まさかブログの図まで同じチームが作ったものを使うことになるとは思っていませんでした。
- pele 公式サイトとドキュメントpele.run
- pele のテーマ変数pele.run
- pele のソースコードGitHub
- tessera を pele に乗り換えた PRLimatura/tessera#148
- Mermaidmermaid.js.org