概要
自宅のRaspberry Pi 5で動かしているWordPressサイトに対して、「VSCodeでMarkdownを書き、GitでバージョンするだけでブログがpublishできるCLIツール」を自作しました。Qiita CLIのような体験を、自前のWordPressで実現するのが目的です。
このツール(リポジトリ名 ssmbar-blog)は、設計と実装の大半をClaude(チャット版)とClaude Codeに手伝ってもらいながら組み上げました。隠さずに書くと、アーキテクチャの相談から実コードの生成まで、かなりの部分をAIと進めています。本記事ではその経緯も含めて、淡々と記録を残します。
この記事は設計編です。実装中にハマった点は別記事(つまづき編)にまとめました。
なぜ作ったか
私が運用しているのは、レンタルブログではなく完全な自前WordPressです。構成はおおよそ次の通りです。
- ハードウェア: Raspberry Pi 5
- Webサーバ: nginx
- PHP 8.2 / MariaDB / WordPress
- テーマ: Cocoon(子テーマ運用)
- 前段にCDN(コンテンツ配信ネットワーク。世界中のサーバにキャッシュを置いて配信を速くする仕組み)としてCloudflare
この環境で記事を書く場合、標準だとWordPressの管理画面(ブロックエディタ)を使うことになります。ただ、私は普段の文章もコードもVSCodeで書いているので、
- ブラウザの管理画面で長文を書くのがやりにくい
- 下書きをGitで管理したい(履歴を残したい、複数端末で書きたい)
- 画像の貼り付けやリンクカードの挿入を、毎回手作業でやりたくない
といった不満がありました。Qiitaには公式の「Qiita CLI」があって、ローカルのMarkdownをそのまま投稿できます。あれと同じことを自前WordPress相手にやりたい、というのが出発点です。
WordPressには REST API(プログラムから記事や画像を読み書きするためのHTTPインターフェース)が標準で備わっているので、これを叩けば外部から投稿・更新ができます。そこをCLIから操作する形にしました。
全体像
ツールはNode.js + TypeScriptで書いています。Markdown → HTMLの変換には、unified / remark / rehype というエコシステムを使いました。これはMarkdownやHTMLを構文木(AST: 抽象構文木。文章やコードを木構造のデータとして扱う表現)として扱い、プラグインを差し込んで変換できるライブラリ群です。
ディレクトリ構成はこうなっています。
ssmbar-blog/
├── posts/ # 記事本体
│ └── YYYY-MM-DD-XXXX/ # 1記事1ディレクトリ(XXXXは4桁通し番号)
│ ├── index.md # 本文(frontmatter付き)
│ └── images/ # この記事で使う画像
├── .meta/ # ローカルとWPの対応表(git管理する)
│ ├── post_map.json # ディレクトリ ↔ WP投稿ID
│ └── media_map.json # 画像 ↔ WPメディアID
├── scripts/
│ ├── new.ts # 記事の雛形作成
│ ├── push.ts # WPへ投稿/更新
│ ├── pull.ts # WPから取得
│ └── lib/ # 共通ロジック
│ ├── config.ts
│ ├── wp-client.ts # REST APIクライアント
│ ├── frontmatter.ts
│ ├── markdown.ts # MD ↔ HTML 変換
│ ├── categories.ts # 親子カテゴリ解決
│ ├── media.ts # 画像アップロード
│ └── meta-store.ts # JSONファイル管理
├── .env.example
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md
データの流れは「ローカルのMarkdown → 変換 → REST APIでWordPressへ」の一方向が基本です。
[ローカル] <!--SC_0-->
posts/.../index.md
│ npm run push
▼
frontmatter解析 + MD→HTML変換
│
├─ 画像を /wp/v2/media へアップロード
▼
記事を /wp/v2/posts へPOST(新規) / PUT(更新)
│
▼
.meta/*.json を更新
│
▼
自動で git add / commit / push
主な依存ライブラリは次の通りです(package.json から抜粋。バージョンは作成当時のもの)。
{
"dependencies": {
"chalk": "^5.3.0",
"dotenv": "^16.4.5",
"gray-matter": "^4.0.3",
"mime-types": "^2.1.35",
"rehype-parse": "^9.0.1",
"rehype-raw": "^7.0.0",
"rehype-remark": "^10.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"remark-stringify": "^11.0.0"
}
}
gray-matter: Markdown冒頭のfrontmatter(後述)を解析するdotenv: 認証情報などを.envファイルから読むchalk: ターミナル出力に色を付ける
使い方
コマンドは3つです。
記事を作る
npm run new -- "記事タイトル"
posts/YYYY-MM-DD-XXXX/index.md という形でテンプレートが生成されます。XXXX は4桁ゼロ埋めの通し番号です(最初は日付ベースのslugにしていましたが、後述の事情で通し番号に変えました)。
-- の後にタイトルを書くのは、npmの仕様で、それ以降の引数をスクリプトに渡すためです。
publish / 更新する
npm run push -- 2026-04-30-0001
.meta/post_map.json にそのディレクトリのWP投稿IDが記録されていなければ新規投稿(POST)、記録されていれば更新(PUT)になります。つまり同じコマンドで初回も2回目以降も扱えます。
全記事をまとめてpushしたい場合は、ディレクトリの代わりに . を渡します。
npm run push -- .
WPから取得する(※運用では使っていません)
npm run pull
設計当初はWordPress側の記事をMarkdownに引き戻す機能も用意しました。ただ、運用していく中で命名規則の食い違いなどで素直に動かない場面があり、最終的には使わない運用に落ち着きました。詳細はつまづき編に書きます。今は git pull(端末間の同期)と npm run push(公開)の2つだけで回しています。
frontmatter の仕様
各記事の index.md は、先頭にYAML形式のメタデータ(frontmatter)を持ちます。
---
title: "記事タイトル" # 必須
slug: "post-slug" # URL末尾。省略可
status: "draft" # draft / publish / private
post_type: "post" # 投稿タイプ。デフォルトは post
categories:
- "プログラミング"
tags:
- "WordPress"
- "Git"
date: "2026-04-30T12:00:00+09:00"
excerpt: "抜粋文(省略可)"
featured_image: "images/cover.jpg" # アイキャッチ(記事ディレクトリからの相対パス)
---
いくつか補足します。
statusはdraft(下書き) /publish(公開) /private(非公開)に対応しています。WordPressには他にpendingやfutureもありますが、このツールでは扱っていません。categoriesは当初「親/子」のパス記法(例:"技術/Linux")で階層を自動生成できるようにしていました。実際には、運用しているカテゴリ数が少ないので、いまはフラットに使っています。post_typeは通常の投稿ならpostです。私のサイトには限定公開用の独自投稿タイプもあり、そちらに出したいときは手動で値を変えています(このCPTの話は本記事の範囲外なので省略します。CPT = カスタム投稿タイプで、WordPress標準の「投稿」「固定ページ」以外に自分で定義する投稿の種類のことです)。
設計上の決め事
本文は「丸ごとHTMLブロック1つ」にする
WordPressの現行エディタ(ブロックエディタ)は、本文を複数の「ブロック」という単位で持ちます。このツールでは、変換後のHTMLを <!-- wp:html --> という単一のHTMLブロックにまるごと入れて投稿しています。
<!-- wp:html -->
(ここに変換後のHTML全文)
<!-- /wp:html -->
こうする理由は、MarkdownとHTMLを行き来したときに情報が落ちにくいからです。代わりに、管理画面でのブロック単位の編集はできなくなります。なので「記事はローカルのMarkdownが正本(source of truth)。管理画面では編集しない」という運用に割り切っています。
ローカルとWordPressの対応はJSONで持つ
「このローカルディレクトリは、WordPressのどの投稿IDか」を .meta/post_map.json に記録しています。画像も同様に .meta/media_map.json で対応を持ちます。
このファイルは必ずgit管理に含めます。消えたり同期されなかったりすると、ツールが「対応IDなし=新規投稿」と判断して、既存記事の複製を作ってしまうからです(実際にこれで事故りました。つまづき編に書きます)。
画像はハッシュで重複排除する
本文中の  を全部拾い、ファイル内容のSHA-256ハッシュ(ファイルの中身から計算する一意の指紋のような値)を取って、media_map.json と突き合わせます。同じ内容の画像が既にアップロード済みなら再アップロードせず、記録済みのURLに差し替えます。同じ画像を1記事内で何度参照しても、アップロードは1回だけです。
認証はApplication Passwordを使う
REST APIへの書き込みには認証が要ります。WordPressの通常ログインパスワードではなく、Application Password(REST API専用に発行できる別パスワード。WordPress 5.6以降の標準機能)を使っています。万一漏れても、そのApplication Passwordだけ失効させれば被害を限定できます。値は .env に置き、gitには含めません。
push成功後に自動でgit commit / push する
npm run push が成功すると、続けて自動で git add . && git commit && git push まで走ります。複数端末(私の場合はWindowsとMac)で書くので、「公開したのにメタ情報が他端末に伝わらず、複製事故が起きる」のを防ぐためです。
端末間の同期は、Raspberry Pi上に置いたGitのbareリポジトリ(作業ファイルを持たず、共有・同期のためだけに使うリポジトリ)を経由します。外出先からでも届くように、後からTailscale(端末同士を仮想的に同じネットワークへ繋ぐVPNサービス)を導入しました。リモートの指定はプライバシーのため伏せますが、形式としては次のようになります。
[ユーザー名]@[ホスト名]:git-repos/ssmbar-blog.git
ClaudeとClaude Codeでどう作ったか
このツールは、次のような役割分担で作りました。
- チャット版Claude: 設計の相談、アーキテクチャ決め、コードの提示、Claude Codeに渡す「指示書」の作成まで
- Claude Code: 実際のファイル作成・修正の実行
チャットで方針とコードの骨子を固め、それを指示書の形に落として、ファイル操作はClaude Codeに任せる、という流れです。Claude Codeへの指示書は「指示通りに実行する/迷ったら勝手に判断せず質問する/指示外のリファクタはしない/ただし検証やダブルチェックは歓迎」という方針で書いています。
すべてがすんなり通ったわけではありません。たとえば push.ts の自動git部分で、定義していない変数 dirName を参照してしまうコードが生成され、適用前にVSCodeのエラー表示で気づいて直した、ということもありました。AIが書いたものをそのまま信用せず、手元で確認する工程は必要です。
次回
設計編は以上です。実際に運用に乗せるまでには、ショートコードが壊れる、改行が消える、複製記事ができる、といった具体的なトラブルをいくつも踏みました。それらと解決策は、つまづき編にまとめます。
(メモ: 本記事中のコード断片は手元の記録から再構成したものです。実際のリポジトリと細部が異なる場合は、リポジトリの内容を正としてください。公開時には実コードへ差し替え・追記してください。)


コメント