% cd ..

Front Matter CMS と Keystatic を共用すると設計思想の違いが見えてきた

Front Matter CMS と Keystatic を共用すると設計思想の違いが見えてきた

このブログは Markdown ファイルを Git で管理する構成で、執筆環境として最初に Front Matter CMS(VS Code 拡張)を導入しました(導入記事はこちら)。VS Code の中で完結するので、Vim キーバインドで本文をガッツリ書ける点が気に入っています。

ただ、用途によってはブラウザベースの CMS の方が便利な場面もあります。例えば画像中心の軽い記事や、外出先でちょっと書きたいとき。 そこで Keystatic を併用しようと思い立ちました。「Git-based の WordPress 風管理画面」という触れ込みで、ローカル CMS の選択肢としても有力候補です。

役割分担はざっくりこんなイメージで考えていました。

  • Front Matter CMS(VS Code + Vim) → テック記事、長文、コードブロック多め
  • Keystatic → 軽めの記事、ビジュアル中心、画像メイン

両 CMS とも Git ベースで、同じ Markdown ファイルを編集対象にできるはず——と、軽い気持ちでスタートしたのですが、実際には簡単には共存できないなぁ、というのが今回の学びでした。 Keystatic 自体のセットアップは ローカル編Vercel Preview デプロイ編 にまとめていますが、ここでは「Keystatic を Front Matter CMS と併用したときに発生した問題」に絞った内容で、その顛末を記します。

ハマりポイント 1: Markdoc テーブル問題

Keystatic の content フィールドのネイティブ形式は Markdoc(Stripe が作った構造化ドキュメント言語)です。Markdown のスーパーセット的な立ち位置ですが、設計思想がかなり違います。

Markdown (CommonMark)Markdoc
設計思想寛容
テキストっぽく書けば何でもパースする
厳格
スキーマで許可したノードのみ受け付け
未知の HTMLそのまま通す文字列扱い
用途自由な執筆構造を保証したい CMS / ドキュメントサイト

Keystatic で options を全部盛ると、CommonMark + GFM の記述はそこそこ維持されます(詳細は ローカル編)。 ただし、それを超える構文や一部の GFM 記法は依然として通りません。

記法Keystatic で開いて保存Keystatic で新規入力
GFM テーブル(パイプテーブル){% table %} に変換される — NGMarkdoc 記法で出力
HTML コメント <!-- -->パススルー(維持される) — OK-- が en dash に自動変換され入力不可 — NG
生 HTML(<iframe> 等)パススルー — OKOK
画像属性 {width=400px align=right}テキストとしてパススルー — OK
正規化系(_italic_*italic* 等)記法が統一される(意味は変わらない)統一形式で出力

唯一の破壊的変換は GFM テーブルです。Keystatic は GFM テーブルを読み込んでエディタ上に正しく表示しますが、保存すると Markdoc 独自の {% table %} 記法に変換されます。ブログのレンダラーは remark-gfm ベースなので {% table %} を解釈できず、テーブルがリスト表示に崩れます。

fields.mdx() に切り替えれば GFM テーブルで保存されますが、今度は HTML コメント(<!-- -->)や <iframe> 等の生 HTML が MDX の JSX パーサーに引っかかります(MDX は < を JSX として解析するため)。このブログは記事内でビデオ埋め込みに <iframe> を使い、テーブル内の改行に <br> を使っているため、MDX 切替は既存記事を軒並み壊します。Keystatic には fields.gfm() のような GFM 専用フィールドは存在せず、markdoc / mdx / document(deprecated) の 3 択しかありません。

Keystatic 側の設定で解決できないか探した結果

結論を出す前に一度、Keystatic 側に「テーブルを GFM 形式で保存する」逃げ道がないかを確認しました。

  • MarkdocEditorOptions.table: boolean のみ(オン/オフ)。シリアライズ形式を切り替えるスイッチはない
  • format.data / format.contentField: frontmatter を YAML/JSON のどちらで保存するかや、frontmatter と本文を分離するかどうかの制御のみ。本文のシリアライズ形式には関与しない
  • createMarkdocConfigrender オプション: Markdoc → React のレンダリング用で、保存時のシリアライズには関与しない
  • GitHub Issues: markdoc → GFM table の要望 issue は見つからず
  • ContentFormFieldvalidate: 存在するが、fields.markdoc() で生成されるフィールドをオーバーライドする口はない

結論として、Keystatic 側の設定だけで解決する方法は現状存在しません。となると、どこか別のレイヤで手当てするしかない。

Vercel ビルド側で preprocessor を挟む

最終的に採用したのは、ブログのビルド時に {% table %} を GFM パイプテーブル(パイプ記号 | で書くテーブル記法)に変換する preprocessor を挟む方針です。src/lib/markdoc-table-to-gfm.ts として実装しました。

コードはシンプルで、やっていることは 3 段階です。

  1. コードフェンス(...)の中は触らないように本文を分割
  2. {% table %}...{% /table %} ブロックを正規表現で抽出
  3. 中身(* 箇条書き+ --- 区切り)をパースして | ... | 形式に変換

セル内のパイプは \| にエスケープし、複数行は <br> で接続、列数が揃わない行は空セルでパディング、という現実的なエッジケースを拾っています。属性付き({% table align="left" %})にも対応。

src/lib/posts.tsmarkdownProcessor().process(content) の直前にこの関数を噛ませるだけで、あとは既存の remark-gfm が普通にパイプテーブルとして処理してくれます。レンダラーの構造自体は一切いじっていません

HTML ソースを綺麗にする

preprocessor を当てたあとに HTML ソースを覗いてみると、<p><table> の間に空行が 35 行ほど入っているのに気がつきました。 最初は preprocessor のせいかと疑いましたが、切り分けると rehype-raw が raw HTML の再パース過程で生む既存挙動で、preprocessor 関係なく GFM パイプテーブルを書けば前から発生していた HTML ソースのとっ散らかりでした。 ブラウザ表示には影響なしとはいえ、エンジニアの性(さが)で汚いソースは許せません笑。

どうせ綺麗にするなら、ということで rehype 公式の整形プラグイン rehype-format をパイプラインに追加。 結果、HTML がインデント込みで読める状態に整いました。

<p>テーブル表示のテスト</p>
<table>
  <thead>
    <tr>
      <th>Pattern</th>
      ...

<pre> / <code> / <textarea> / <script> / <style> の中身は保護されるのでコードブロックが崩れる心配はありません。 gzip/brotli 圧縮後はサイズ差もほぼ消えるので、エンジニアブログとしては View Source した時に読める方が気持ちいい、という判断です。

廃案: pre-commit hook で GFM に逆変換する

最初に検討したものの、採用しなかったアイデアもあります。参考までになぜ採用しなかったのか記しておきます。

readonly フィールド問題を pre-commit hook で解決した実績(別記事参照)があるので、同じ発想で「コミット時に {% table %} を GFM パイプテーブルに自動変換する hook」を書くこともできます。Markdoc のテーブル記法は構造が規則的なので、パース自体はそこまで難しくありません。
ただ、この方向は採用しませんでした。

  • Keystatic を Vercel に載せて GitHub API 経由で commit するフローだと、ローカルの git hook(husky など)は Keystatic 経由の保存をカバーできない
  • GitHub Actions で push 後に自動変換 commit する方法もあるが、(1) 一瞬だけど汚れた状態のコミットが履歴に入る (2) 無駄な Vercel ビルドが走る (3) ループ防止の仕組みが必要 (4) 「Keystatic を使うためのインフラ」がどんどん膨らむ

結局、ソースファイル自体は {% table %} のまま残し、ビルド時だけ GFM として解釈する preprocessor 方式が一番筋が良いと判断しました。ソースファイルに手を入れないので、Keystatic で開き直してもエディタ上のテーブル表示が崩れない(Keystatic は {% table %} をネイティブ表示できる)という副次的な利点もあります。

運用ルールの変更

この対応により、元々敷いていた「テーブルを含む記事は Keystatic で本文を保存しない」という運用ルールは撤廃できました。Keystatic でテーブルを編集・保存しても、ビルド時に preprocessor が GFM に戻すので表示が崩れません。

画像属性の記法 {width=400px align=right} も相変わらずパススルーされるので問題ありません。

Keystatic に「保存前に content を検査して警告する」hook API があればもっと綺麗ですが、無くても今回のように「ビルド層で吸収する」パターンで実用上は問題なくなります。

ハマりポイント 2: Keystatic のドラフトが外部変更を握りつぶす

CMS 併用で最も厄介だった問題がこれです。

Keystatic はエディタで記事を開くと、編集状態をブラウザの IndexedDB に自動保存します。次に同じ記事を開くと、ファイルシステム上の最新版ではなく IndexedDB のドラフトを優先して復元する。「Restored draft from 5 minutes ago.」という Toast が一瞬表示されて、8 秒で消えます。

これが何を意味するかというと:

  1. Keystatic で記事を開く(IndexedDB にドラフトが保存される)
  2. Front Matter CMS やパイプラインで同じファイルを編集・保存する
  3. Keystatic で再び開く → 外部変更が無視され、古いドラフトで上書きされる

Toast には "You may want to discard the draft changes." というテキストが出ますが、Discard する手段がありません。ツールバーの「Reset changes」ボタンも、ドラフト状態を「初期状態」として扱うため無効化されています。DevTools で IndexedDB を手動クリアするしかない。

なぜこうなるのか

Keystatic は idb-keyval ライブラリを使って IndexedDB にドラフトを保存しています。記事を開くと getDraft() で IndexedDB を先にチェックし、データがあればファイルよりドラフトを優先。外部変更の検知は localTreeKey !== draft.treeKey で行っていますが、SPA のためページ遷移ではツリーキャッシュが更新されず、検知が効かないケースもあります。

patch-package でパッチを当てた

Keystatic の config にドラフト無効化オプションはないので、patch-package でビルド済み JS にパッチを当てました。

変更内容:

  • Toast に [Discard draft] ボタンを追加: Keystatic の Toast API が actionLabel / onAction をネイティブにサポートしていたので、それを使用
  • 外部変更検知時はタイムアウトなし: hasChangedSince が true のとき Toast が消えずに残り、ユーザーの判断を待つ
  • エントリ固有のドラフトだけ削除: 他の記事のドラフトには影響しない
  • ドラフト削除後にページリロード: dist JS パッチの制約で setState と Toast の close が競合するため、reload() で確実にファイルから再読み込み
patches/@keystatic+core+0.5.50.patch

npm install のたびに postinstall で自動適用されます。

PR は保留

このパッチは local mode で外部エディタと併用するユーザー全般に刺さる問題を解決しているので、Keystatic の GitHub に PR として提案する価値はあります。ただし、ハマりポイント 3(SPA キャッシュ)も含めて Keystatic の外部編集との相性問題は根が深く、当ブログとしては Keystatic の利用を縮小する方向になりつつあるため、Issue/PR 対応は保留としています。パッチは patch-package で自分の環境に自動適用されるので、当面はこのまま運用します。

ハマりポイント 3: SPA キャッシュが外部変更を無視する

ハマりポイント 2 の IndexedDB ドラフト問題はパッチで対処しましたが、もう一つ別の経路で外部変更が無視されることがわかりました。

再現手順

  1. Keystatic で記事を作成・保存する
  2. 記事一覧に戻る
  3. 外部エディタ(VS Code、vim 等)でそのファイルを編集・保存する
  4. Keystatic の一覧からその記事をクリックして開く

結果: 外部編集前の古い内容が表示される。Toast も Discard ボタンも出ない。

ブラウザをリロード(F5)してから開き直すと、外部編集後の内容が正しく読み込まれます。

原因: ファイルツリーのメモリキャッシュ

これは IndexedDB ドラフトとは無関係の問題です。Keystatic の local モードはページロード時にファイルツリーを一度メモリに読み込み、SPA 内の画面遷移ではディスクを再読み込みしません。ファイル監視(fs.watch 等)も実装されていないため、外部ツールがファイルを変更してもキャッシュは更新されません。

なぜ根が深いか

ハマりポイント 2(IndexedDB ドラフト)と合わせて見えてくるのは、Keystatic は「自分以外がファイルを書き換えること」を根本的に想定していない設計 だということです。

  • ドラフト保存: 自分だけが編集する前提
  • ファイル読み込み: ページロード時に一度だけ
  • ファイル監視: なし

「Keystatic が唯一のコンテンツ編集手段」であれば問題にならないので、これは設計上のバグではなく思想の違いです。ただし、Front Matter CMS や翻訳パイプラインと併用する環境では致命的な落とし穴になります。

回避策

外部でファイルを編集した後は、Keystatic をブラウザリロードしてから記事を開くこと。 SPA 内の画面遷移(一覧→記事)だけではファイルの再読み込みが行われません。

Front Matter CMS 側の設定

ハマりポイントは Keystatic に集中していますが、Front Matter CMS 側もフィールドの hidden 設定や日本語タイトルの slug 生成問題など、多少のチューニングが必要でした。詳細は Front Matter CMS の導入記事にまとめています。

執筆ルール: Markdoc の正規化に合わせて書く

検証の結果、Keystatic で開いて保存すると Markdown が Markdoc 流儀に正規化されることがわかりました。Front Matter CMS 側で最初からこのルールに沿って書いておけば、両 CMS を行き来しても差分ゼロで相互に編集・保存できます。自分自身の備忘も兼ねて、ルールを表にまとめておきます。

項目OKNG備考
改行\ + 改行行末半角空白 2 つ\ の方が可視で事故が少ない。多くのエディタは行末空白を自動削除する
斜体*italic*_italic_Keystatic を通すと * 形式に正規化される
太字**bold**__bold__同上
順序なしリスト-* / +- で統一推奨
リンク[text](url)参照形式 [text][ref]インライン形式が無難
段落内の改行物理的に 1 行で書く改行だけ入れる(soft break)Keystatic は段落内の soft break を hard break に正規化することがある。意図せず改行が入る可能性あり
画像属性 {width=...}そのまま使ってOKMarkdoc は未知の構文をテキストとしてパススルーするので原型維持される
生 HTML(<h2> 等)使わない<h2>見出し</h2>HTML として固定され、Markdown 構文に戻らなくなる

特に「段落内の改行が hard break に正規化される」挙動は地味にハマります。before/after を比べていて気づいたんですが、\ を付けていない素の改行が、保存後には \+改行 に置き換わっていました。書き手としては「改行したら改行されてほしい」ことが多いので親切な正規化ではありますが、意図的に soft break を使っていた場合は注意が必要です。

ちなみにこのルールは、.editorconfig や Prettier 設定で一部強制できそうです(行末空白の trim、強調記号の統一など)。ツール側で守れるところはツールに任せると、執筆時に意識する項目が減って楽になります。

結論: Keystatic は Markdown ファイルを自由に書きたい用途には向かない

ここまでやってわかったのは、Keystatic は「既に自由な Markdown を持っているプロジェクト」とは相性が悪い ということです。新規プロジェクトで Keystatic schema 前提でコンテンツを作るならハマりも少ないと思いますが、このブログのように:

  • 記事は VS Code や Obsidian でも書ける状態にしておきたい
  • 翻訳パイプラインやタグ付けスクリプトが Markdown ファイルを直接読み書きする
  • 画像属性記法や生 HTML を必要に応じて使う

という前提だと、Keystatic の Markdoc 流儀がいちいち引っかかります。「Git-based の WordPress 風 CMS」という期待で導入すると、想像より制約が多くて驚くことになると思います。

さらに、ハマりポイント 2・3 で明らかになったように、外部ツールとの併用は Keystatic の設計思想と根本的に衝突します。ドラフトの握りつぶし(パッチで対処済み)に加えて、SPA キャッシュによる外部変更の無視は dist レベルのパッチでは対応が難しく、運用上の注意(リロードの徹底)で回避するしかありません。

じゃあ Keystatic 不要か?

実際のところ、現在のこのブログの運用では Front Matter CMS 一本でほぼ完結しています。

当初は Keystatic にブラウザ UI としての役割を期待していましたが、実際に Front Matter CMS を使い込んでみると、ダッシュボードの記事一覧、サイドパネルでの status 切替、カバー画像の設定など、日常的な操作は VS Code 内で十分こなせることがわかりました。Keystatic のために Route Groups の分離、patch-package によるドラフト握りつぶし対策、執筆ルールの整備と、かなりのインフラを積み上げましたが、そのコストに見合うほど Keystatic を使うシーンがなかったのが正直なところです。

Keystatic を完全に外す判断はまだしていませんが、積極的に使う理由も今のところありません。将来的に非エンジニアの寄稿者を入れるなど、ブラウザ CMS が必要になったタイミングで改めて検討する、という位置づけです。

CMS 選定で同じ落とし穴にハマる人の参考になれば幸いです。

あとがき: 両立できない根本原因——シリアライザの往復変換(round-trip)問題

ここまで色々な問題を「Keystatic の設計思想の壁」としてきましたが、これをもう少し掘り下げると、問題の根っこは シリアライザの round-trip 特性 にありそうです。

Keystatic は内部で Markdoc のパーサーとシリアライザを使っています。記事を開くと「Markdown → Markdoc AST → エディタ内部モデル」に変換され、保存すると「内部モデル → Markdoc AST → Markdown」に戻される。この往復で GFM テーブルが {% table %} になったりする(フィールドの順序が変わる程度なら実害はないが、構文自体が変換されるのは破壊的)。Keystatic の設計判断というより、採用しているライブラリのシリアライザがそう出力するから、そうなっているわけです。

これはある意味合理的なトレードオフです。 リッチな WYSIWYG エディタを提供するには入力をパースして内部モデルに変換する必要があり、保存時にはその内部モデルからファイルを再生成する。 ファイル再生成のためのシリアライザを自前で書くのはコストに見合わないので、既存ライブラリを使う。
「車輪を再発明しない」という鉄則に従う妥当な判断です。

一方、Front Matter CMS が「Markdown フォーマットに手を加えない」のは、そもそもパース→再シリアライズをしないから。 frontmatter だけを YAML として読み書きし、本文には触らない。リッチエディタを諦めた代わりに、round-trip 問題が原理的に発生しません。

つまり、2 つの CMS の違いは思想の違いであると同時に、シリアライザを挟むか挟まないかというアーキテクチャの違いでもあります。

理論的には「リッチな編集 UI」と「ファイルフォーマットの完全保持」は両立できるはずです。 AST レベルで差分編集し、触っていない部分はバイト単位で保持すればいい。
ただ、現時点でそこまでやっている Git-based CMS は見つかりませんでした。

フォーマットにこだわって CMS を選ぶひとは、編集 UI の使い勝手だけでなく、保存時にシリアライザが何をするかを確認しておくと、この手の地雷を踏まずに済むと思います。

最後にもうひとつ。
ソフトウェア開発には「車輪を再発明しない」という格言がありますが、既存のライブラリを使うなら車輪を交換可能にすべきです。
交換できない車輪を取り付けたら、その車輪の制約がそのままその車の制約になります。それにパンクしたら終わり、みたいなリスクもあります。
今回の件でいえば、Keystatic のシリアライザがプラグインとして差し替え可能であれば、コミュニティが GFM シリアライザを作って問題を解決できたかもしれません。

オープンソースの世界であればことさら、「車輪を再発明しない」と「車輪を交換可能にする」が設計原則としてセットになったとき、拡がっていく風景(コミュニティ)があるように思います。