Blogchikina.net

CMSを作っていたらWYSIWYGエディタに似てきた話

Obsidian、API、DB、WebをつなぐCMSを作っていたら、全体がWYSIWYGエディタを引き延ばしたような構造になった話です

共有Xで共有

自作CMSの構成を考えていたとき、AIから次のコードを提案されました。

export default function BlogPost({ loaderData }: Route.ComponentProps) {
  return (
    <article>
      <h1>{loaderData.post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: loaderData.post.html }} />
    </article>
  );
}

Obsidianで書いたMarkdownを公開時にHTMLへ変換し、そのHTMLをDBへ保存する構成です。 表示時には変換済みのHTMLを流し込むだけなので、いかにも手軽です。

それでも、HTMLを保存するという部分がなんか嫌でした。

最初に思い出したのは、IP、TCP、UDPをXMLで表現するRFC 3252です。 2002年4月1日に公開されたジョークRFCで、名前はBinary Lexical Octet Ad-hoc Transport、略してBLOATとなっています。

何でもXMLへ包めばよいわけではない、という冗談です。 HTMLはXMLと同じではありませんが、表示用のマークアップへ本文を詰め、そのまま保存する案が少し似て見えました。

もちろん、RFCを思い出しただけでは設計を変える理由になりません。 何が嫌なのかを考えてみることにしました。

HTMLを保存するのは何が嫌なのか

dangerouslySetInnerHTMLという名前を見ると、まずXSSが気になります。 任意のHTMLを受け入れるなら、許可する要素、属性、URLを決め、表示する前にサニタイズしなければなりません。

ただ、JSONへ変えれば安全になるわけでもありません。 内部リンクにjavascript:のようなURLを許可しないことや、未知のノードを拒否することは、どの保存形式でも必要です。

XSSはHTMLを扱ううえでの問題ですが、自分がHTMLを保存したくない理由とは少し違いました。

Reactとの相性も考えました。 dangerouslySetInnerHTMLへ渡したHTMLは、最終的には一つの文字列として扱われます。

type Post = {
  title: string;
  html: string;
};

TypeScriptが分かるのは、htmlがstringであることまでです。 その中に見出しがあるのか、内部リンクがあるのか、別の記事を埋め込んでいるのかは型に現れません。

ここで、ようやく引っかかっていたものが見えてきました。

HTMLへ変換すると、ブログ固有の意味が一つのstringへ閉じ込められます。

HTMLから見出しやリンクを取り出すことはできます。 しかし、そのたびに、いったん捨てた意味をHTMLから推測して戻すことになります。 最初からその意味を持っているのに、わざわざ表示形式へ潰してから拾い直すのは変です。

独自属性や命名規則を決めれば、HTMLの中にブログ固有の情報を残すこともできます。 ただ、それはTypeScriptが型で表現できる違いを、HTML上の規約と解析処理で作り直すことでもあります。 せっかくTypeScriptで作っているのに、コンパイラが追えない独自ルールを増やすのは、あまり好みではありませんでした。

本文にさせたい仕事が多すぎる

記事を表示するだけなら、HTMLを保存しても困らなかったと思います。 自分のCMSでは、本文を表示以外にも使う予定でした。

  • 見出しと本文を検索対象にする

  • 内部リンクの参照先を解決する

  • ノートを別の記事へ埋め込む

  • 記事同士の関係をグラフとして取り出す

もともとグラフ理論が好きで、記事同士の関係をforce-directed graphのような形でポートフォリオに入れたいと考えていました。 Obsidianのグラフ表示が好きなのも、その延長です。

特に面倒なのが内部リンクです。 Obsidianでは[[記事名]]と書けますが、CMSでは単に<a>へ変換すれば終わりではありません。

  • 記事が改名されても同じ記事を指す

  • リンク先の公開状態に応じて表示を変える

  • 表示名を現在の記事名へ追従させる

  • 書き手が指定した表示名は固定する

同じ内部リンクでも、参照先と表示名には別々の状態があります。 これをHTMLのhrefとテキストへ変換した時点で、CMSから見たリンクの意味はかなり薄くなります。

そこで、Markdownをブログ固有の型付きJSONへ変換し、そのJSONをD1へ保存することにしました。

実際の内部リンクは、次のような型で持っています。

type InternalLinkNode = {
  type: "internal-link";
  version: 1;
  targetNoteKey: NoteKey;
  targetArticleRef?: ArticleId;
  selector?: NoteSelector;
  label:
    | { mode: "dynamic"; fallback: string }
    | { mode: "fixed"; value: string };
};

ブログのリンク一つにしては、だいぶ大げさです。 ただ、リンク先の識別子と表示名の決め方が型に残るので、Web側がHTMLを解析して推測する必要はありません。

labelも、真偽値とoptionalな値を並べるのではなく、dynamicとfixedのUnionにしています。 現在の記事名へ追従する状態と、書き手が表示名を固定した状態を分ければ、処理側もmodeに応じて分岐できます。 文字列の約束事を増やすより、言語がもともと持っている型の絞り込みを使うほうが素直です。

Markdownだけでは足りないのか

ここまで書くと、型付きJSONを増やさず、Markdownだけを保存すればよいようにも見えます。

実際、記事を書くときに扱うのはいまもMarkdownです。 公開したリビジョンにも、復旧と差分確認のためにMarkdown原文を残しています。

ただし、Markdownにある[[記事名]]は、書き手が入力した参照です。 どの記事を指すのか、その記事が公開されているのか、表示名を追従させるのかは、公開時にCMSが解決します。

Markdownと型付きJSONは、どちらか一方を選ぶ関係ではありませんでした。

形式担当するもの
Markdown執筆、差分確認、復旧
型付きJSON表示、検索、参照解決、埋め込み
HTMLブラウザへ返す最終的な表示

記事はMarkdownで書き、公開時に型付きJSONへ変換します。 HTMLは保存する状態ではなく、その文書モデルから作る出力になりました。

型付きJSONを公開APIへ渡す

ObsidianプラグインはMarkdownから型付きJSONを作り、公開APIへ送ります。 公開APIは受け取った内容を検証して内部リンクの参照先などを解決し、D1へ保存します。 Web側の表示、検索、記事間の関係を作る処理も、このJSONを使います。

内部リンクの場合、プラグインが送るのはhrefを含んだHTMLではなく、リンク先や表示名の扱いを持つ内部リンクノードです。 公開APIはそのノードから参照先を解決でき、Web側は内部リンクとして表示できます。 それぞれの処理がhrefやclass名を手がかりに、HTMLから内部リンクかどうかを判定する必要はありません。

APIの境界では、Hono RPCで入出力の型をObsidianプラグイン側と共有し、受信した値をZodで検証しています。 TypeScriptの型は実行時には消えますが、Zodの検証を通したあとは、公開APIでも内部リンクをUnionの一つとして扱えます。

コードブロックや埋め込みでも、Obsidianプラグイン、公開API、Webが同じノードの定義を使います。

そこでLexicalを思い出した

段落、見出し、コード、数式、画像、内部リンク、埋め込み。 必要なものを型付きノードへ分け、文書モデルから表示を作っているうちに、何かに似ている気がしてきました。

そこで思い出したのが、Lexicalです。

LexicalはMetaが開発するテキストエディタ向けのフレームワークです。 公式ドキュメントでは、DOMとは別にEditor Stateを持ち、その中にノード木と選択状態を保持すると説明されています。 Editor StateはJSONへ直列化でき、独自ノードも追加できます。

Lexicalは、編集内容をDOMそのものに持たせるのではなく、Editor Stateとして保持します。 編集操作によってEditor Stateを更新し、そこからエディタ上のDOMを作ります。

Mermaid原文
flowchart LR
  Input["編集操作"] --> State["Editor State"]
  State --> Render["描画処理"]
  Render --> DOM["エディタ上のDOM"]

自分のCMSも、入力を直接HTMLへ変換するのをやめ、その間に文書の状態を置いていました。 違うのは、そのやり取りが一つのエディタ内部では終わらないことです。

WYSIWYGエディタ自作CMS
入力エディタ上の編集操作Obsidianで書いたMarkdownと公開操作
状態Editor StateD1へ保存した文書モデル
状態の更新エディタ内部で反映プラグインと公開APIを通して反映
表示同じ画面のDOMWebが別のタイミングで作る記事のDOM

WYSIWYGエディタでは、編集操作、状態の更新、DOMへの反映が一つのランタイム内で進みます。 自作CMSでは、同じような流れがObsidian、プラグイン、API、DB、Webへ分かれています。

APIを挟んだ大きなエディタに見えた話

二つが同じものだという話ではありません。

LexicalのEditor Stateには、ノード木や選択状態など、その場で編集するための情報があります。 自作CMSの文書モデルが持つのは、公開状態、検索、内部リンク、埋め込みに必要な情報です。

更新の時間も違います。 WYSIWYGエディタはキー入力へすぐ反応しますが、自作CMSは公開操作を境に状態を永続化し、Webからのリクエストに応じて表示します。

それでも抽象的に見ると、どちらも次の構造を持っています。

  1. 入力を受け取る

  2. 表示とは別の文書状態へ反映する

  3. 文書状態から表示を作る

最初からLexicalの構造を参考にして作ったわけではありません。 HTMLを保存することへの違和感を追っていったら、入力と表示の間に文書の状態を置く形になっていました。 作っている途中でWYSIWYGエディタに似ていると思ったのは、このあたりでした。

まとめ

最初に考えていたのは、MarkdownをHTMLへ変換して、そのままDBへ保存する構成でした。 そこから内部リンクや埋め込みをどう扱うか考えていき、Markdownを型付きJSONへ変換し、その型をプラグイン、API、DB、Webで使う形になりました。 結果として、WYSIWYGエディタの中で行われている入力、状態の更新、表示までの流れを、APIとDBを挟んで大きくしたような構成になっています。

自作CMSについてネットを漁ると、記事をJSONのような形で保存している例はほかにもあります。 ただ、残したい情報やJSONを使う範囲が自分のCMSとは違い、そのまま採用できない部分が多くありました。

AIに「CMSの記事はJSONとHTMLのどちらで保存するのがおすすめか」と聞けば、JSONを勧められること自体は珍しくありません。 ただ、その理由はDBでの扱いやすさあたりで終わることが多い印象でした。 今回は保存形式だけを選ぶのではなく、書いた記事がプラグイン、API、DB、Webを通って表示されるまでを一度ちゃんと考えてみたくて、自分の用途に合わせて設計を詰めてみた感じです。