VS CodeでMarkdownを⌘⌥ZするとZennプレビューで見られる執筆環境を作ってみた

VS CodeでMarkdownを⌘⌥ZするとZennプレビューで見られる執筆環境を作ってみた

任意の場所にあるMarkdownを⌘⌥Z一発でZennプレビューできる環境を作りました。Zenn CLIの固定slugプレビュー機能と自作VS Code拡張を組み合わせることで、記事ファイルを動かさずに公開時と同じ見た目をその場で確認できます。
2026.07.07

こんにちは、けーまです。

VS CodeでMarkdownを書くとき、標準のプレビュー機能だとレイアウトやフォントが少し味気なく、執筆中の見栄えが気になったことはありませんか?

どうせなら、我々クラスメソッドグループが提供している「Zenn」のあの美しくて見やすいレイアウトで確認しながら書きたいものです。

通常、Zenn CLIのプレビュー機能は特定のディレクトリ内しか対応していませんが、「どこにあるMarkdownファイルでも、⌘⌥Z を押すだけでZennのプレビューを横にサッと開きたい!」と思い立ち、今回の環境を作ってみました。

快適なMarkdownプレビュー環境を作りたい方の参考になれば幸いです。

0. 前提環境

項目
OS macOS 26.5.1
Node.js v25.9.0
VS Code 1.117.0
zenn-cli 0.5.2

1. 仕組みの全体像

登場するのは次の3つです。

⌘⌥Z (VS Code キーバインド)


ミニVS Code拡張 (zennPreview.open コマンド)
  │ 開いているファイルを保存 → 同期スクリプトを実行 → Simple Browserを開く

同期スクリプト zenn-preview.sh
  │ ① Markdownを ~/zenn-content/articles/preview-current.md に変換コピー
  │ ② zenn preview サーバー (localhost:8000) を起動
  │ ③ 常駐デーモンがファイル保存を監視して同期し続ける

Zenn CLI プレビューサーバー
  └ http://localhost:8000/articles/preview-current

ポイントは、記事ファイル自体は動かさず、固定slugのプレビュー用ファイルに変換コピーし続けることです。Zenn CLIは~/zenn-content/articles/配下しか見ませんが、この方式ならどこにある記事でもプレビューできます。

2. Zenn CLIのセットアップ

プレビュー用の作業ディレクトリを作り、Zenn CLIを入れます。

mkdir ~/zenn-content && cd ~/zenn-content
npm init -y
npm install zenn-cli
npx zenn init

npx zenn init~/zenn-content/articles/~/zenn-content/books/ができます。動作確認します。

npx zenn preview
👀 Preview: http://localhost:8000

表示されたURLをブラウザで開ければ成功です。確認したら一旦 Ctrl+C で止めます。

3. 同期スクリプトの作成

~/zenn-content/zenn-preview.sh として作成します(パスは自分の環境に合わせて変更してください)。

このスクリプトの仕事は「任意の場所にある記事を、Zennがプレビューできる形に変換して~/zenn-contentの中へ届け続ける」ことです。関係するファイルの配置はこうなります。

~/blog/my-article/              ← 記事はどこに置いてもよい
├── my-article.md               ← 執筆中の記事(元ファイル。一切変更されない)
└── screenshot.png              ← 記事から相対パスで参照する画像

~/zenn-content/                 ← Zenn CLIのプレビュー用ディレクトリ
├── zenn-preview.sh             ← 本スクリプト
├── articles/
│   └── preview-current.md      ← 記事の変換コピー(毎回上書きされる)
└── images/
    └── preview → ~/blog/my-article/   ← 記事フォルダへのsymlink

/tmp/
├── zenn-preview.pid            ← 常駐デーモンのプロセスID
└── zenn-preview.target         ← いま追従中の記事のパス

Zennのプレビューサーバーは~/zenn-contentの外を一切見られないため、記事は~/zenn-content/articles/preview-current.mdへ固定slugで変換コピーし、画像は記事フォルダごとsymlinkで~/zenn-content/images/preview/として見せる、という構成です。

やっていることは4つです。

  1. frontmatterの変換: 手元の記事のfrontmatter(title/tags)をZenn形式(title/emoji/type/topics/published)に変換して、~/zenn-content/articles/preview-current.mdに書き出す
  2. ローカル画像の表示: 記事のあるディレクトリを~/zenn-content/images/previewにsymlinkし、本文中の相対パス画像参照を/images/preview/...に書き換える(元ファイルは変更しない)
  3. サーバーの起動と自動復旧: localhost:8000 が応答しなければnpx zenn previewをバックグラウンド起動。デーモンが15秒ごとに死活確認して落ちていれば再起動
  4. 保存の監視: 常駐デーモンが1秒ごとに指定ファイルの更新を確認し、保存されるたびに同期する

設定はスクリプト冒頭の変数にまとまっています。ZENN_DIRは2章で作ったZenn CLIのディレクトリを指します。別の場所に作った場合はここを変更してください。

~/zenn-content/zenn-preview.sh:

#!/bin/bash
# 配置場所: ~/zenn-content/zenn-preview.sh
# 指定した Markdown を Zenn プレビュー用フォルダ(~/zenn-content)に同期し続ける
# 使い方: zenn-preview.sh <mdファイルのパス>
set -u

ZENN_DIR="$HOME/zenn-content"
SLUG="preview-current"
DEST="$ZENN_DIR/articles/$SLUG.md"
PIDFILE="/tmp/zenn-preview.pid"
TARGETFILE="/tmp/zenn-preview.target"
URL="http://localhost:8000/articles/$SLUG"

# frontmatter を Zenn 形式に変換して同期する
sync_file() {
  python3 - "$SRC" "$DEST" <<'PYEOF'
import json, os, re, sys

src, dest = sys.argv[1], sys.argv[2]
text = open(src, encoding="utf-8").read()

# ローカル画像をプレビューで表示できるようにする:
# 記事のあるディレクトリを ~/zenn-content/images/preview に symlink し、
# 相対パスの画像参照を /images/preview/... に書き換える(元ファイルは変更しない)
src_dir = os.path.dirname(os.path.abspath(src))
img_root = os.path.join(os.path.expanduser("~"), "zenn-content", "images")
os.makedirs(img_root, exist_ok=True)
link = os.path.join(img_root, "preview")
if os.path.islink(link):
    os.remove(link)
if not os.path.exists(link):
    os.symlink(src_dir, link)

def _rewrite_img(m):
    alt, path = m.group(1), m.group(2)
    if re.match(r"^(https?://|/|data:)", path):
        return m.group(0)
    return f"![{alt}](/images/preview/{path})"

# frontmatter をパース
meta, body = {}, text
if text.startswith("---"):
    parts = text.split("---", 2)
    if len(parts) == 3:
        body = parts[2].lstrip("\n")
        for line in parts[1].strip().splitlines():
            if ":" not in line:
                continue
            k, v = line.split(":", 1)
            k, v = k.strip(), v.strip()
            try:
                meta[k] = json.loads(v)
            except Exception:
                meta[k] = v.strip("\"'")

body = re.sub(r"!\[([^\]]*)\]\(([^)\s]+?)(?:\s+\"[^\"]*\")?\)", _rewrite_img, body)

title = meta.get("title")
if not title:
    m = re.search(r"^#{1,2} (.+)$", body, re.M)
    title = (m.group(1) if m else "無題")[:80]
emoji = str(meta.get("emoji") or "📝")
topics = []
for t in meta.get("tags") or []:
    t = re.sub(r"[^a-z0-9]", "", str(t).lower())
    if t:
        topics.append(t)
topics = topics[:5]
if not topics:
    # topics が空だと Zenn プレビューが注意表示を出すためプレースホルダを入れる
    topics = ["preview"]

fm = (
    "---\n"
    f"title: {json.dumps(title, ensure_ascii=False)}\n"
    f"emoji: \"{emoji}\"\n"
    "type: \"tech\"\n"
    f"topics: {json.dumps(topics)}\n"
    "published: false\n"
    "---\n\n"
)
open(dest, "w", encoding="utf-8").write(fm + body)
PYEOF
}

# プレビューサーバー(localhost:8000)が起動していなければ起動して待つ
ensure_server() {
  if ! curl -s -o /dev/null --max-time 2 http://localhost:8000; then
    (cd "$ZENN_DIR" && nohup npx zenn preview >/tmp/zenn-preview.log 2>&1 &)
    for _ in $(seq 1 20); do
      curl -s -o /dev/null --max-time 1 http://localhost:8000 && break
      sleep 0.5
    done
  fi
}

# --daemon: 監視デーモン本体(指定されたファイルの保存を監視して同期し続ける)
if [ "${1:-}" = "--daemon" ]; then
  trap '' HUP
  if [ -f "$PIDFILE" ]; then
    old=$(cat "$PIDFILE")
    [ "$old" != "$$" ] && kill "$old" 2>/dev/null
  fi
  echo $$ > "$PIDFILE"
  ensure_server
  last_key=""
  tick=0
  while true; do
    sleep 1
    # 15秒ごとにサーバーの死活を確認し、落ちていれば自動復旧する
    tick=$(( (tick + 1) % 15 ))
    [ "$tick" = "0" ] && ensure_server
    # 自分が正規のデーモンでなくなったら退場する(二重起動の自己解消)
    [ "$(cat "$PIDFILE" 2>/dev/null)" = "$$" ] || exit 0
    # 追従対象: 最後に明示指定されたファイル
    f=$(cat "$TARGETFILE" 2>/dev/null)
    [ -n "$f" ] && [ -f "$f" ] || continue
    key="$f:$(stat -f %m "$f" 2>/dev/null)"
    if [ "$key" != "$last_key" ]; then
      last_key="$key"
      SRC="$f" sync_file
    fi
  done
fi

# 通常起動: 指定ファイルを同期して、デーモンが不在なら立ち上げる
SRC="${1:-}"
ensure_server
if [ -z "$SRC" ] || [ ! -f "$SRC" ]; then
  SRC=$(cat "$TARGETFILE" 2>/dev/null)
fi
if [ -n "$SRC" ] && [ -f "$SRC" ]; then
  echo "$SRC" > "$TARGETFILE"
  sync_file
fi
# デーモンが生きていなければ起動する
if [ ! -f "$PIDFILE" ] || ! kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
  nohup "$0" --daemon >/dev/null 2>&1 &
fi
echo "Preview: $URL"

実行権限を付けて、単体で動作確認します。

chmod +x ~/zenn-content/zenn-preview.sh
~/zenn-content/zenn-preview.sh ~/path/to/your-article.md
Preview: http://localhost:8000/articles/preview-current

このURLをブラウザで開き、指定した記事が表示されれば成功です。以降はデーモンが常駐しているので、記事を保存するたびに自動で同期されます。

4. VS Code拡張の作成

⌘⌥Zで「保存 → スクリプト実行 → Simple Browserで表示」までを1発でやるために、小さな自作拡張を作ります。Marketplaceに公開せず、ローカルにフォルダを置くだけです。

~/.vscode/extensions/local.markdown-zenn-preview-1.0.0/ を作り、2ファイルを置きます。

~/.vscode/extensions/local.markdown-zenn-preview-1.0.0/package.json:

{
    "name": "markdown-zenn-preview",
    "displayName": "Markdown Zenn Preview",
    "description": "開いているMarkdownをZennプレビューでSimple Browserに表示する",
    "publisher": "local",
    "version": "1.0.0",
    "engines": {
        "vscode": "^1.80.0"
    },
    "capabilities": {
        "untrustedWorkspaces": {
            "supported": true
        }
    },
    "main": "./extension.js",
    "contributes": {
        "commands": [
            {
                "command": "zennPreview.open",
                "title": "Markdown: Zennプレビューを開く"
            }
        ]
    }
}

~/.vscode/extensions/local.markdown-zenn-preview-1.0.0/extension.js:

const vscode = require("vscode");
const cp = require("child_process");
const util = require("util");

const execFile = util.promisify(cp.execFile);
const SCRIPT = `${process.env.HOME}/zenn-content/zenn-preview.sh`;
const PREVIEW_URL = "http://localhost:8000/articles/preview-current";

function activate(context) {
    context.subscriptions.push(
        vscode.commands.registerCommand("zennPreview.open", async () => {
            const editor = vscode.window.activeTextEditor;
            let file = "";
            if (editor && editor.document.uri.scheme === "file") {
                // 未保存の変更があれば保存してからプレビュー対象にする
                if (editor.document.isDirty) {
                    await editor.document.save();
                }
                file = editor.document.uri.fsPath;
            }
            // スクリプトはサーバー起動完了まで待ってから終了するので、
            // 完了を待ってからブラウザを開く(接続拒否エラーの防止)
            try {
                await execFile("/bin/bash", file ? [SCRIPT, file] : [SCRIPT], { timeout: 30000 });
            } catch (err) {
                vscode.window.showErrorMessage(`Zennプレビュー同期に失敗: ${err.message}`);
            }
            await vscode.commands.executeCommand(
                "simpleBrowser.api.open",
                vscode.Uri.parse(PREVIEW_URL),
                { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }
            );
        })
    );
}

function deactivate() {}

module.exports = { activate, deactivate };

~/.vscode/extensions/直下にpublisher.name-version形式のフォルダを置くと、VS Codeが起動時に拡張として読み込みます。ビルドやvsixのパッケージングは不要です。

5. キーバインドの設定

コマンドパレットから「Preferences: Open Keyboard Shortcuts (JSON)」を開き、keybindings.jsonに追加します。

[
    {
        "key": "cmd+alt+z",
        "command": "zennPreview.open"
    }
]

VS Codeを再起動(またはコマンドパレットで「Developer: Reload Window」)して拡張を読み込みます。

6. 動作確認

任意の場所にあるMarkdownをVS Codeで開いて ⌘⌥Z を押します。完成するとこうなります(左がMarkdown、右がZennプレビュー)。

VS CodeでMarkdownとZennプレビューを並べて表示した完成例

✅ 次の動きになれば成功です。

  • エディタの横にSimple Browserが開き、Zennの見た目で記事が表示される
  • 記事を編集して保存すると、1〜2秒でプレビューに反映される(Simple Browser側のリロードは不要。zenn previewがホットリロードします)
  • 別の記事ファイルで ⌘⌥Z すると、プレビューがそのファイルに切り替わる

7. まとめ

Zenn CLI・同期スクリプト・自作VS Code拡張の3点で、任意の場所のMarkdownを⌘⌥Z一発でZennプレビューできる環境を作りました。「固定slugのプレビュー用ファイルに変換コピーする」方式なので、記事の管理場所やfrontmatterの形式が違っても、変換部分を差し替えれば応用できます。

参考

この記事をシェアする

関連記事