自前WordPressをMarkdownで書くツールを作って踏んだ落とし穴(つまづき編)

壊れた部屋管理話

設計編では、自宅Raspberry Pi 5上のWordPressに対してMarkdown + Gitで投稿できるCLI(ssmbar-blog)の構成を書きました。この記事では、実際に運用へ乗せるまでに踏んだ具体的なトラブルと、その解決策を並べます。

環境は設計編と同じく、Raspberry Pi 5 / nginx / WordPress / Cocoonテーマ / 前段にCloudflareです。実装はClaude(チャット版)とClaude Codeに手伝ってもらっています。

似たことを自前WordPressでやろうとしている人の役に立てば幸いです。


1. 複数端末で書いたら記事が複製された

最初に踏んだのがこれです。WindowsとMacの2台で書いていたところ、同じ記事がWordPress側に2つできてしまいました。

原因は、ローカルとWordPressの対応表である .meta/post_map.json が、端末間で同期される前にpushされたことでした。一方の端末では「この記事はWP投稿ID = ◯◯」と記録されているのに、もう一方の端末にはその記録が届いておらず、「対応IDなし=新規投稿」と判断して複製を作ってしまう、という流れです。

対策は2つです。

  • .meta/ を必ずgit管理に含める(.gitignore で除外しない)
  • npm run push の成功後に、自動で git add . && git commit && git push まで実行する

これで「公開したのにメタ情報だけ他端末に伝わっていない」状態を作りにくくしました。さらに外出先からも同期できるよう、後からTailscale(端末同士を仮想的に同じネットワークに繋ぐVPN)を入れ、Gitリモートを自宅LAN内の固定IPからTailscaleのホストネームへ切り替えました。固定IP直指定だと、自宅の外からは届かなかったためです。

教訓として、「対応表」をどう同期するかは、この手のツールの設計で最初に固めるべきでした。ここが緩いと静かに複製が増えます。


2. WordPressがショートコードのクォートを書き換えていた

Cocoonには <!--SC_2-->(URLをカード状リンクに展開する)や <!--SC_3-->(目次を生成する)といったショートコード(角括弧の短い記法で機能を呼び出すWordPressの仕組み)があります。これをMarkdownにそのまま書いてpushしたところ、<!--SC_4--> がうまく展開されない現象に出くわしました。

調べていく過程で見つかった一因が、WordPressの wptexturize という処理です。これは本文中の引用符などを「見栄えの良い記号」に自動変換する標準フィルタで、ここで url="..." のクォートが別の文字(いわゆるカーリークォート)に置き換わっていました。

子テーマの functions.php で、この処理を本文に対して無効化しました。

remove_filter( 'the_content', 'wptexturize' );

ただし後で分かったのですが、<!--SC_5--> が壊れていた本当の主因はこれではありませんでした(次の項目)。とはいえクォートをそのまま保つこと自体は理にかなっているので、この除去は残しています。


3. URLの自動リンク化がショートコードを壊していた

<!--SC_6--> は問題なく展開されるのに <!--SC_7--> だけ壊れる、という非対称が手がかりでした。違いはURLの有無です。

WordPress側で実際に保存されている本文を確認したところ、こうなっていました。

<p><!--SC_8--></p>

url= の中のURLが <a href="...">...</a>オートリンク化されていました。これではショートコードの属性解析が破綻します。

原因は remark-gfm の「URLを自動でリンクにする」機能でした。Markdown → HTML変換の段階で、ショートコード内のURLまで巻き込んで <a> タグに変えていたわけです。<!--SC_9--> にはURLが無いので被害を受けず、無事に展開されていた、と非対称の理由もこれで説明がつきました。

対策として、markdown.ts に「ショートコードを変換の前後で退避・復元する」処理を入れました。変換前にショートコードらしき部分を <!--SC_0--> のようなプレースホルダーに置き換え、remark / rehypeに本文を渡し、変換後に元へ戻す方式です。これでショートコードの中身に変換器が触れなくなります。

骨子はこういう形です(手元の記録から再構成したものなので、実コードはリポジトリを正としてください)。

// remark-gfm のURL自動リンク化から WP ショートコードを守る。
// ペアタグ <!--SC_0--> → 単独タグ <!--SC_10--> の順で退避する。
function hideShortcodes(md: string): { escaped: string; map: string[] } {
  const map: string[] = [];

  // ペアのショートコードを先に退避(例: <!--SC_1-->)
  const afterPairs = md.replace(
    /\[(<!--SC_11-->+)([^\]]*)\]([\s\S]*?)\[\/\1\]/g,
    (match) => {
      const idx = map.length;
      map.push(match);
      return `<!--SC_${idx}-->`;
    }
  );

  // 残りの単独ショートコード(例: <!--SC_12-->)
  // Markdownリンク [text](url) や参照 [text]<!--SC_13--> との混同を避けるため、
  // ] の直後が ( や [ でないものだけを対象にする。
  const afterAll = afterPairs.replace(
    /\[<!--SC_14-->+(?:\s[^\]]*)?\](?![(\[])/g,
    (match) => {
      const idx = map.length;
      map.push(match);
      return `<!--SC_${idx}-->`;
    }
  );

  return { escaped: afterAll, map };
}

// 変換後にプレースホルダーを元のショートコードへ戻す
function restoreShortcodes(html: string, map: string[]): string {
  return html.replace(/<!--SC_(\d+)-->/g, (_, n) => map[Number(n)]);
}

正規表現の (?![(\[]) は「直後が ([ でない」ことを意味する否定先読みです。Markdownの通常のリンク記法 [テキスト](URL) を誤ってショートコード扱いしないための条件です。


4. [ふきだし] や <!--SC_15--> はショートコードではなかった

ショートコードを直そうとして気づいたのですが、Cocoonの [ふきだし]<!--SC_16--> は、そもそも登録されたショートコードではありません。これらはビジュアルエディタ(管理画面の見た目を保った編集画面)のボタンで挿入される装飾機能で、Markdownにベタ書きしても展開されません。

本物のショートコードとして機能するのは <!--SC_17--><!--SC_18--> です。[ふきだし] 系はMarkdownからは使えないものとして、いったん諦めました。「全部ショートコードだろう」と思い込んでいたのが回り道の原因でした。装飾を入れたい場合は、HTMLを直接書くか、別の手段を考える必要があります。


5. 1つの改行が消える問題(remark-breaks)

標準的なMarkdownでは、空行を挟まない単なる改行は無視され、前後の行が半角スペースで連結されて1段落になります。たとえば次のように書くと、

今日は晴れです。
気温は20度くらいです。

レンダリング後は「今日は晴れです。 気温は20度くらいです。」と1行に繋がります。これはブログを書く感覚と合いませんでした。エディタで改行したら、表示でも改行されてほしいわけです。

対策に remark-breaks を導入しました。これは段落内の単一改行を <br> に変換するremarkプラグインです。変換パイプラインの remark-gfm の直後に差し込みます。

import remarkBreaks from 'remark-breaks';

// ...
  .use(remarkGfm)
  .use(remarkBreaks)        // ← 追加
  .use(remarkRehype, { allowDangerousHtml: true })
// ...

導入時に確認したのは、項目3のショートコード退避用プレースホルダー(<!--SC_N-->)の前後に余計な <br> が入らないか、という点でした。コードブロックや表、リスト項目には影響しないので、副作用は限定的です。これで「改行 = 改行」「空行 = 段落の区切り」という直感どおりの書き味になりました。


6. アイキャッチ自動生成のチェックボックスが効いて見えない(未解決)

Cocoonには、アイキャッチ画像を指定しない記事に対してタイトルから画像を自動生成する機能があります。REST API経由で作った記事だと、この機能のオン/オフを切り替える管理画面上のチェックボックスが、保存してもオフのまま表示されることがありました。

WP-CLI(コマンドラインからWordPressを操作するツール)で、正常な記事とAPI作成記事の投稿メタ(記事に紐づく付加情報)を比較してみました。

wp post meta list <投稿ID> --fields=meta_key --format=csv

差分はキャッシュ系のキーくらいで、アイキャッチ生成に直接効くようなメタの違いは見当たりませんでした。そして、記事を実際にpublishすると自動生成自体は正常に走ることも確認できています。

つまり、機能は動くがチェックボックスの表示状態だけが噛み合っていない、という見た目だけの問題と判断し、いったん保留にしています。推測ですが、Cocoonが管理画面の保存時(save_post フック)に行うメタ初期化が、REST API経由の作成では走っていないのが原因ではないかと見ています。実害が出たら再調査する予定です。


7. pull(WPから引き戻す)は使わなくなった

設計時には、WordPress側の記事をMarkdownに引き戻す npm run pull も用意していました。理屈の上では「管理画面で直した内容をローカルへ取り込む」ために便利なはずでした。

ところが運用してみると、ディレクトリの命名規則の違いなどで素直に動かない場面があり、無理に使うとローカルの状態を壊しかねないと分かりました。そもそも「本文の正本はローカルのMarkdown、管理画面では編集しない」という方針なので、引き戻す必要性自体が薄かったのです。

現在は次の2つだけで運用しています。

  • git pull: 端末間でローカルの中身を同期する
  • npm run push: WordPressへ公開する

機能を作ったものの使わない、という判断に落ち着いた例です。


8. 既存タグの再投稿で 400 term_exists が出た

記事を再pushしたときに、タグの登録で term_exists の400エラー(HTTPで「リクエストが不正」を表すステータス)が出ることがありました。

原因は、既にWordPressに存在するタグを「探して再利用する」前に「新規作成しようとして」いたことです。同名タグは作れないので、APIが「もうある」と弾いていました。

修正方針は、タグを作る前に既存タグを検索し、見つかればそのIDを使い、無ければ作成する、という順序にすることです。カテゴリの親子解決でも同じ考え方を使っています。この修正はClaude Code向けの指示書にまとめて実行してもらいました。


まとめ

並べてみると、トラブルは大きく3種類に分かれます。

  • 同期・状態管理の問題(項目1, 7, 8): 「どのローカルがどのWP記事か」「既にあるものをどう再利用するか」をきちんと持たないと、複製やエラーが起きる。
  • WordPress / Cocoon側の挙動(項目2, 4, 6): wptexturize、ショートコードの正体、save_post フック前提の機能など、WP側の前提を知らないとハマる。
  • Markdown変換パイプラインの副作用(項目3, 5): remark / rehypeのプラグインが、こちらの意図しない箇所まで手を加える。

自前WordPressをMarkdownで運用する構成は、組んでしまえば書き味は快適です。ただ、ここに挙げたような「変換器とWordPressの境界」で起きる問題は、実際に記事を流してみないと表に出てきませんでした。同じ構成を試す人は、いきなり本番記事ではなく、ショートコード・画像・改行・タグを盛り込んだテスト記事を1本作って通すのをおすすめします。

コメント

タイトルとURLをコピーしました