monolithic kernel

Astro のブログに Mermaid を導入した

Gatsby 時代にメンテナンスできなくて撤去していた Mermaid を導入した。Gatsby だろうと Astro だろうと Remark のプラグインで Markdown 中の Mermaid のコードブロックを探してきて、そこに対して変換を掛けるという流れは同じなのだが、mermaid-cli を使って実装している方を見つけて、自分でも簡単にできそうだったので参考にして自作してみた。

# mermaid-cli も Puppeteer を使っているので当時と本質的には違いはないように思うのだが、何故か今回はうまくいってくれた。前回挫折した gatsby-remark-mermaid (のバックエンドである remark-mermaidjs) は Puppeteer から Playwright ベースに切り替えたようだった。

ソースコードを公開することを考えると Remark のプラグインとして完結するように実装することが望ましいのだが、Astro のコンポーネントにすることでレンダリング部分の柔軟性やメンテナンス性が大きく変わってくるため、コードブロックのときと同様に、Remark プラグインとしてはカスタムコンポーネントへの置換のみを行い、カスタムコンポーネント側で Mermaid のレンダリングを行った。

Remark プラグイン

ここは特に変わったことはなく、Mermaid で書かれたコードブロックを独自の mermaid 要素に置換しているのみ。

src/remark/remark-mermaid.ts
import type { Code, Paragraph } from "mdast"
import type { Root } from "mdast"
import type { Plugin } from "unified"
import type { Node } from "unist"
import { visit, type Visitor } from "unist-util-visit"
const remarkMermaid: Plugin<[], Root> = () => {
return async (tree: Node) => {
const visitor: Visitor<Code> = (node, index, parent) => {
if (!parent || index === undefined) {
return
}
const { lang, meta, value } = node
const title = meta
if (lang !== "mermaid") {
return
}
parent.children.splice(index, 1, {
type: "paragraph",
data: {
hName: "mermaid",
hProperties: {
// Workaround for preventing loss of line breaks in mermaid.
// Need to decode the value in the mermaid component.
code: encodeURIComponent(value),
...(title ? { title } : {}),
},
},
children: [],
} as Paragraph)
}
visit(tree, "code", visitor)
}
}
export default remarkMermaid

カスタムコンポーネント

code 属性として encodeURIComponent を通した Mermaid のコードを渡すようにしているので、受け取って SVG にした上でレンダリングしている。mermaid-cli はコマンドとして実行する以外にも API も提供していたため、そちらを使うことにした (ただし、API は Semantic Versioning で互換性を保証する対象外とのことなので注意が必要)。

astro.config.ts
import mdx from "@astrojs/mdx"
import { defineConfig } from "astro/config"
import remarkMermaid from "./src/remark/remark-mermaid"
export default defineConfig({
integrations: [
mdx(
optimize: {
customComponentNames: ["mermaid"],
},
),
],
markdown: {
remarkPlugins: [
remarkMermaid,
],
},
})
src/components/Mermaid.astro
---
import { run } from "@mermaid-js/mermaid-cli"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
type Props = {
title?: string
code: string
}
const renderSVG = async (mermaidContent: string): Promise<string> => {
const prefix = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "mermaid-")), "diagram")
await fs.writeFile(`${prefix}.mmd`, mermaidContent)
await run(`${prefix}.mmd`, `${prefix}.svg`, {
outputFormat: "svg",
quiet: true,
puppeteerConfig: {
headless: "new",
},
})
return await fs.readFile(`${prefix}.svg`, "utf-8")
}
const { title, code } = Astro.props
const svg = await renderSVG(decodeURIComponent(code))
---
<figure>
{title && <figcaption>{title}</figcaption>}
<Fragment set:html={svg} />
</figure>
src/components/BlogEntry.astro
---
import Mermaid from "@components/Mermaid.astro"
const { Content } await entry.render()
---
<Content
components={{
"mermaid": Mermaid,
}}
/>

文字が欠ける問題

ここまでで Mermaid の図を描画して表示することはできたのだが、文字が若干欠ける問題があった。調べてみると、どうやらページの親要素で設定している line-height の値が原因のようで、とりあえず line-height: 1 で上書きしたところ直った。

ただ、親要素の影響にアドホックに対処するというのはどうにも気持ち悪く、根本的には親要素の影響を受けない形で SVG をレンダリングしたい。そのために結構いろいろ試行錯誤したので、メモとして残しておく。

img 要素で表示する

まず、img 要素の src 属性にデータ URL スキームとして SVG を指定することで、広い意味での Shadow DOM に SVG を置くことができるようだったのでやってみた。幅と高さを得るのに雑に正規表現で引っ張ってきているが、Mermaid の出力した SVG を扱う前提なのでまあ。

結果としては描画自体は問題なかったものの、画像になることで SVG 中の文字列をブラウザ上で選択したりコピーしたりといった操作ができなくなるため採用しなかった。

# 今どきは OS に組み込まれた AI がなんとかしてくれるという話はあるかもしれない。

src/components/Mermaid.astro
---
// ...
const svg = await renderSVG(decodeURIComponent(code))
const [, , width, height] = (svg.match(/viewBox="([^"]+)"/)?.[1] ?? "").split(" ")
---
<figure>
<img width={width} height={height} src={`data:image/svg+xml,${encodeURIComponent(svg)}`} />
</figure>

CSS をリセットする

CSS に all: initial というプロパティがあって、これを使うことで親要素の影響を排除できるようだったのでやってみたところ、これでもうまく描画できた。

しかし、SVG の中で #my-svg という ID を使っているため、SVG をページ内に複数配置すると (挙動的には実害はなさそうだったものの) ページ内で ID が重複することに気づいてやめた。SVG の文字列を加工してあげればよさそうかなとも考えたものの、SVG 内部の CSS で参照されていたので加工にもそれなりに手間が掛かりそうだった。

src/components/Mermaid.astro
---
// ...
const svg = await renderSVG(decodeURIComponent(code))
---
<figure><div set:html={svg} /></figure>
<style>
div {
all: initial;
}
</style>

iframe の中に入れる

別の文書になっていれば ID の重複は関係ないし、CSS の影響もなくなるだろうということで、iframe に入れてみた。

確かに親要素から変にプロパティが継承されることはないものの、これはこれで余計な margin が発生したりしてアドホックな調整を強いられるので、モヤモヤが残ってやめた。

src/components/Mermaid.astro
---
// ...
const svg = await renderSVG(decodeURIComponent(code))
const [, , width, height] = (svg.match(/viewBox="([^"]+)"/)?.[1] ?? "").split(" ")
---
<figure>
<iframe
width={width}
height={height}
sandbox=""
srcdoc={`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin:0;overflow:hidden">${svg}</body></html>`}
></iframe>
</figure>

Shadow DOM の中に入れる

Shadow DOM の中に入れてしまえば ID の重複は気にしなくてよいし、CSS の all: initial を組み合わせれば親要素の影響もシンプルに排除できるということで、ここが Shadow DOM を使うべき状況かというとよくわからなかったものの使ってみた。実際のところ興味本位というのが大きい。

Lit という Web Components を構築するためのライブラリが Astro との Integration もあってよさそうだったので、それで作ってみた。これだけのために Lit を入れるほどかという新たなモヤモヤにはひとまず目をつむりつつ、記述自体はスッキリしたしこれまでの問題点は一通り解決した。

src/components/Mermaid.astro
---
// ...
const svg = await renderSVG(decodeURIComponent(code))
---
<figure>
<MermaidDiagram set:html={svg} />
</figure>
src/components/MermaidDiagram.ts
import { css, html, LitElement } from "lit"
export class MermaidDiagram extends LitElement {
static styles = css`
:host {
all: initial;
}
`
render() {
return html`<slot></slot>`
}
}
customElements.define("mermaid-diagram", MermaidDiagram)

Lit では、上記の例のように書くスタイルの他にデコレータを使ってよりスッキリ記述できるスタイルも提供されているのだが、そちらは動作しなかった。Astro との組み合わせでも Vite の設定を追加すれば動くという報告も見かけたものの、それも手元ではうまく行かず。Astro Integration の公式ドキュメントでは明示的には言及していないものの、コード例がデコレータを使わない記述になっているので、今のところそういうものなのかなと思って合わせている。

View Transitions (SPA mode) との組み合わせでも Lit はうまく動かないようなので、そこも注意が必要そう。

動作確認

最後に正しく描画されていることを確認しておく。

index.md
```mermaid
graph TD;
A-->B;
A-->C;
B-->D;
C-->D;
```
A
B
C
D

この図が実は Shadow DOM の中でレンダリングされていると思うと、それだけでちょっと楽しい。Developer Tool で見てみると、(この記事を書いた時点では) mermaid-diagram という独自の要素があって、その中に Shadow DOM がぶら下がっているので、ぜひ見てみてほしい。


Related articles