Emacs で Markdown を書いていると、見出しやコードブロックだけでなく、Mermaid のような図もあわせて確認したくなります。そこで Emacs から Markdown を HTML へ変換し、ブラウザーでプレビューする仕組みを追加しました。
方針は単純です。Markdown バッファーの内容を一時的な HTML へ変換し、その HTML をブラウザーで開きます。Emacs の中で完結させるのではなく、描画はブラウザーへ任せます。これにより、Mermaid の描画も同じプレビューの流れへ載せられます。
使い方
Markdown バッファーで C-c C-p を実行すると、HTML を生成してブラウザーで開きます。いったんプレビューを開いた後は、保存のたびに同じ HTML を更新します。文書を修正して保存し、ブラウザー側を見れば結果を確認できます。
実装方針
HTML 側では marked.js で Markdown を描画し、mermaid コードブロックだけを Mermaid.js で図へ変換します。通常の Markdown はそのまま HTML になり、図が必要な部分だけ追加の処理を入れる構成です。
Markdown の本文をそのまま JavaScript に埋め込むと、バッククォート、-->、日本語を含む場合に壊れます。そこで Emacs 側では本文を UTF-8 へ変換してから base64 化し、ブラウザー側で復元して marked.parse へ渡します。この形なら、Markdown や Mermaid の記法をそのまま扱えます。
制約
この実装は CDN から marked.js と mermaid.js を読み込みます。そのため、ネットワークのない環境ではプレビューが動きません。また、ASCII アートのような等幅レイアウトは Markdown の段落やコードブロックの扱いに影響されるため、今回の実装で特別な補正は入れていません。図にできるものは Mermaid で書くほうが安定します。
実装
~/.emacs.d/init/40-markdown-mode.el へ追加した主要部分は次のとおりです。
(defvar-local my-markdown-mermaid-preview-file nil)
(defun my-markdown-mermaid-preview-output-file ()
"Return the HTML output path for the current Markdown buffer."
(let* ((source (or (buffer-file-name) (buffer-name)))
(base (file-name-base source))
(dir (expand-file-name "markdown-preview" temporary-file-directory)))
(make-directory dir t)
(expand-file-name (concat base ".preview.html") dir)))
(defun my-markdown-mermaid-preview-render ()
"Render the current Markdown buffer to an HTML file with Mermaid support."
(interactive)
(unless (derived-mode-p 'markdown-mode)
(user-error "Not in markdown-mode"))
(let* ((output-file (my-markdown-mermaid-preview-output-file))
(markdown-text (buffer-substring-no-properties (point-min) (point-max)))
(markdown-base64
(base64-encode-string
(encode-coding-string markdown-text 'utf-8)
t))
(html (format "<!doctype html>
<html lang=\"ja\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>%s</title>
<style>
body {
margin: 0;
background: #f6f7fb;
color: #1f2937;
font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;
}
main {
max-width: 960px;
margin: 0 auto;
padding: 40px 24px 64px;
background: #ffffff;
min-height: 100vh;
box-sizing: border-box;
}
pre {
overflow-x: auto;
padding: 12px 16px;
border-radius: 10px;
background: #111827;
color: #f9fafb;
white-space: pre;
overflow-wrap: normal;
word-break: normal;
tab-size: 2;
}
code {
font-family: \"SFMono-Regular\", Consolas, monospace;
}
img {
max-width: 100%%;
}
.mermaid {
overflow-x: auto;
padding: 8px 0;
}
</style>
</head>
<body>
<main id=\"preview\"></main>
<script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>
<script type=\"module\">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
const sourceBytes = Uint8Array.from(atob('%s'), c => c.charCodeAt(0));
const source = new TextDecoder().decode(sourceBytes);
const preview = document.getElementById('preview');
preview.innerHTML = marked.parse(source, { breaks: true, gfm: true });
mermaid.initialize({ startOnLoad: false });
const blocks = Array.from(preview.querySelectorAll('pre code.language-mermaid'));
for (const block of blocks) {
const pre = block.parentElement;
const container = document.createElement('div');
container.className = 'mermaid';
container.textContent = block.textContent;
pre.replaceWith(container);
}
await mermaid.run({ querySelector: '.mermaid' });
</script>
</body>
</html>"
(file-name-nondirectory (or (buffer-file-name) (buffer-name)))
markdown-base64)))
(with-temp-file output-file
(insert html))
(setq my-markdown-mermaid-preview-file output-file)
output-file))
(defun my-markdown-mermaid-preview-open ()
"Render the current Markdown buffer and open the preview in a browser."
(interactive)
(browse-url-of-file (my-markdown-mermaid-preview-render)))
(defun my-markdown-mermaid-preview-refresh ()
"Refresh the Markdown preview HTML after saving."
(when (and my-markdown-mermaid-preview-file
(derived-mode-p 'markdown-mode))
(my-markdown-mermaid-preview-render)))
(add-hook 'markdown-mode-hook
(lambda ()
(define-key markdown-mode-map (kbd "C-c C-p") #'my-markdown-mermaid-preview-open)
(add-hook 'after-save-hook #'my-markdown-mermaid-preview-refresh nil t)))
まとめ
大がかりな実装ではありませんでした。ただし、Markdown の本文を安全にブラウザーへ渡す方法と、Mermaid ブロックだけを取り出す方法に工夫が要りました。Emacs だけで完結させるより、HTML を生成してブラウザーで描画する方式が扱いやすくなります。
Mermaid を含む Markdown を軽く確認する用途なら、この構成で実用になります。