Notionに1行書くだけでSVG図解を出す — ヘッドレスCMSブログの図解パイプライン

この記事でわかること
- PNG / Mermaid / 生 HTML / 独自記法の 4 つを比べて、ダークモード追従を決め手に選んだ理由
- Notion の本文に置いた 1 行を、プレースホルダー経由で SVG に差し替える実装(3 ファイル・依存ゼロ)
- SVG に色を直書きせず、CSS 変数と currentColor でライト/ダークに追従させる方法
- 記法自体を記事で紹介できなくなる罠など、実際に踏んだハマりどころ 4 つ
Next.js(App Router)で Notion をヘッドレスCMSにしていること。本文を Markdown に変換して markdown-it で HTML 化する構成を想定していますが、「本文に印を置いて後から差し替える」考え方は他の CMS でも同じです。
記事に図解を入れたくなりました。文章だけでは伝わらない「ものの仕組み」を説明するとき、図が1枚あると読者の理解が一気に進みます。
ただ、このブログはNotionをヘッドレスCMSにしています。本文はNotion→Markdown→HTMLと変換されてから表示されるので、「図を入れる」と一口に言っても置き場所に困ります。結論から言うと、記事側には1行だけ書き、SVG本体はリポジトリで管理する形に落ち着きました。
この記事でやること
- Notionの本文に
:::figureの1行を書くと、そこにSVG図解が入る仕組みを作る - 図の色はCSS変数に任せ、ブログのライト/ダーク切り替えにそのまま追従させる
- 実装は3ファイル。新しい依存はゼロ
なぜ画像ではなくSVGなのか
最初に検討したのは、図をPNGで作ってR2に置き、Notionに画像として貼る方法です。アイキャッチで既にやっているので追加実装がいりません。でも、これには決定的な問題がありました。
| 方法 | テーマ切替 | 修正のしやすさ | 導入コスト |
|---|---|---|---|
| PNGをR2に置く | ✕ 白背景が浮く | ✕ 作り直し→再アップ | ◎ 既存の仕組み |
| Mermaid(導入済み) | △ テーマ設定が別体系 | ◎ テキストで直せる | ◎ 導入済み |
| 本文に生HTML | ◎ currentColorが使える | ✕ Notionに長大なSVGを貼るのは苦痛 | ◎ 不要 |
| 独自記法(採用) | ◎ CSS変数で完全追従 | ◎ コードとしてGit管理 | △ 実装が少しいる |
決め手はダークモードでした。このブログはnext-themesでライト/ダークを切り替えられますが、PNGの図を貼るとダーク背景の中でそこだけ真っ白に光ります。図を二枚作るのも、後でラベルを一文字直すたびに作り直すのも、現実的ではありません。
インラインSVGなら currentColor やCSS変数が使えるので、テーマ追従はタダです。ならばSVGをどこに置くかだけが問題です。
なお、このブログにはすでにMermaidが入っています。フロー図やシーケンス図のように型のある図はMermaidで書くほうが速いので、そこは住み分けです。今回作るのは、二つの方式を並べて差を見せるとか、矢印の横に注釈を置くとか、Mermaidの文法に乗らない図のための逃げ道です。
基本の形
本文にSVGを直接書かず、印だけを置いてあとで差し替えるという形にしました。Notionにはこう書きます。
:::figure{id="my-diagram"}この1行だけです。SVG本体は src/figures/index.ts に置きます。
なぜ直接書かないのか。このブログはmarkdown-itを html: true で使っているので、理屈の上では生SVGも通ります。でも実際には、① Notionのエディタで数十行のSVGを扱うのが苦痛、② Markdownの整形で壊れるリスク、③ 図の差分がGitに残らない、と三拍子揃っていません。
処理の流れはこうなります。
実装
1. 図の定義(src/figures/index.ts)
idで引ける辞書にするだけです。Edge Runtimeで動く必要があるので、ファイル読み込みではなくモジュールに直書きします。
export type Figure = {
/** 図の下に出る説明。 */
caption: string;
/** SVG 本体。読み上げ用の説明は svg の aria-label に書く。 */
svg: string;
};
export const figures: Record<string, Figure> = {
'my-diagram': {
caption: '図の下に出る説明文。',
svg: `<svg viewBox="0 0 720 300" role="img" aria-label="...">...</svg>`,
},
};
export const FIGURE_PATTERN = /^:::figure\{id="([^"]+)"\}[ \t]*$/gm;
export function renderFigure(id: string): string | null {
const fig = figures[id];
if (!fig) return null;
return `<figure class="figure"><div class="figure-scroll">${fig.svg}</div><figcaption>${fig.caption}</figcaption></figure>`;
}2. 本文に差し込む(blogs/[blogId]/page.tsx)
このブログにはもともと :::callout の独自記法があったので、まったく同じ手口に揃えました。ポイントはSVGをmarkdown-itに通さないことです。
// 図解マーカーをプレースホルダーに退避
const figureMap = new Map<string, string>();
let figureIndex = 0;
const bodyWithFigures = bodyPreprocessed.replace(
FIGURE_PATTERN,
(marker: string, id: string) => {
const figureHtml = renderFigure(id);
if (!figureHtml) return marker; // 未定義のidはマーカーを残す
const placeholder = `FIGURE_PLACEHOLDER_${figureIndex++}`;
figureMap.set(placeholder, figureHtml);
return placeholder;
}
);
const html = md.render(bodyWithFigures);
// レンダリング後にSVGを差し戻す
let processedHtml = html;
figureMap.forEach((figureHtml, placeholder) => {
processedHtml = processedHtml.replace(
new RegExp(`<p>${placeholder}</p>|${placeholder}`, 'g'),
figureHtml
);
});<p>PLACEHOLDER</p> と裸の両方を見ているのは、markdown-itが単独行を段落で包むためです。<figure> を <p> の中に入れると不正なHTMLになるので、段落ごと置き換えます。
3. 色を外に出す(styles/markdown.css)
ここが一番のポイントです。SVGの中に色を直書きしない。意味を持つ色はクラスにして、値はCSS変数で与えます。
.znc .figure {
--fig-allow: #0b6355;
--fig-deny: #9b3226;
--fig-accent: #9a5d06;
}
.dark .znc .figure {
--fig-allow: #52bfa9;
--fig-deny: #e08578;
--fig-accent: #dfa455;
}
.znc .figure .fig-t-allow { fill: var(--fig-allow); }
.znc .figure .fig-s-allow { stroke: var(--fig-allow); }
/* 狭い画面では縮めず横スクロール(文字が潰れないように) */
.znc .figure-scroll { overflow-x: auto; }
.znc .figure svg { width: 100%; min-width: 540px; height: auto; }図の本体は fill="currentColor" で書いておけば、本文の文字色をそのまま継承します。意味を持つ色(危険・安全・強調)だけをクラスにすれば、テーマ切替は自動で付いてきます。
ハマったところ
Notionでは波括弧をエスケープする
Notion-flavored Markdownでは {...} がブロックの属性指定に使われます。MCP経由でページを作るときは :::figure\{id="..."\} のようにバックスラッシュで逃がします。Notion上では普通のテキストとして保存されるので、ブログ側はそのまま受け取れます。
記法自体を記事で紹介できなくなる
最初は /:::figure\{id="([^"]+)"\}/g で書いていましたが、これだとこの記事自体が書けません。説明のために書いた記法が全部図に化けてしまうからです。
行頭・行末アンカー付き(^...$ と m フラグ)にして独立した1行だけを対象にし、さらに未定義のidはマーカーをそのまま残すようにしました。おかげで文中の引用は無事ですし、idをタイポしたときに黙って図が消える事故も防げます。
meta descriptionにマーカーが混ざる
generateMetadata で本文を平文化しているので、そちらでもマーカーを除去しておきます。忘れるとOGPの説明文に figure{id=...} が出ます。
スマホで潰れる
viewBoxで縮めると図中の11pxの文字が5px相当になり、読めません。min-width を与えて横スクロールに逃がすのが現実的でした(既存のテーブルも同じ思想で処理しています)。
使い心地
記事を書くときの手順はこうなりました。
src/figures/index.tsに図を追加する(idとcaptionとSVG)- Notionの本文の入れたい位置に
:::figureの1行を置く - 終わり
図の修正はコードの修正なので、差分がレビューできます。ラベルを一文字直したいときに画像を作り直す必要はありません。
一方で、図を追加するのにデプロイが必要になりました。記事だけをNotionで完結させたい人には向きません。そこはトレードオフで、図を「コンテンツ」ではなく「コンポーネント」として扱うと決めた結果です。
次は、よく使う形(比較・フロー・階層)をヘルパー化して、SVGを手書きしないで済むようにしたいところです。
参考リンク
同じ「Notion の本文に独自記法を置いて差し替える」手口は、callout でも使っています。土台となる Notion → Next.js の仕組み自体はこちら。


