Blogchikina.net

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

HTMLを保存する案への違和感から、自作CMSの中間モデルをLexicalの設計を参考に組み立てた話です

自作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と同じではありませんが、表示のためのマークアップへ本文を詰めて保存する案が、それに似て見えたのだと思います。

ただ、この連想は半分ほど的外れに見えました。

このブログはReact RouterのSSRで動いています。 初期表示ではサーバーが記事を含むHTMLを返すため、ブラウザが別のAPIを待ってから本文を組み立てる構成ではありません。 ここだけを見れば、変換済みのHTMLを使う案には合理性があります。

しかし、React Routerではloaderが返したデータをhydrationのためにブラウザへ直列化します。 公式ドキュメントにも、loader dataは自動的に直列化されるとあります。 最初のコードなら、post.html は表示するHTMLを作るために使われ、同じ内容がloader dataにも入ります。

別のHTTPリクエストが増えるわけではありませんが、SSRだからHTML文字列をデータとして運ばない、とも言えませんでした。 とはいえ、転送量だけで設計を却下するつもりもありません。 自分の違和感は、まだ別のところにありました。

XSSの問題

dangerouslySetInnerHTML と聞いて最初に問題になるのはXSSです。 この危険はもともと知っていましたし、React公式の説明にも、信頼できてサニタイズ済みのデータだけを渡すよう明記されています。

このCMSへ入る本文は、自分がObsidianで書いたMarkdownだけです。 管理側もCloudflare Accessで制限していますが、入力者が自分だけであることを理由に検証を省かず、ゼロトラストを意識した境界にしたいと考えていました。

サニタイズを挟めば終わり、とも考えられませんでした。 任意HTMLを受け入れるなら、許可する要素、属性、URLと、サニタイズ後の処理まで安全性を保ち続ける必要があります。 OWASPも、サニタイズ後の加工で防御が崩れることや、新しい回避手法へ対応するためライブラリの更新が必要になることを挙げています。

もちろん、JSONへ変えれば自動的に安全になるわけではありません。 URLのスキームやノードの種類など、値ごとの検証は残ります。 それでも、任意HTMLを安全にするより、受け入れる表現を型で限定するほうが、自分の作りたいCMSには合っていました。

ReactとTypeScriptの問題

XSSは最初から知っていた問題です。 それだけでは説明できない違和感が残っていたので、dangerouslySetInnerHTML がReactの中でどう扱われているかも調べました。

このブログで使っているReact 19.2.8のクライアント実装では、値が { __html: ... } の形であることと、children を同時に渡していないことを確認したあと、最後は次の代入にたどり着きます。

domElement.innerHTML = key;

SSR側では、__html の値をサーバーのHTML出力へ加えます。

Reactを通さずに innerHTML を直接変更するコードと、dangerouslySetInnerHTML は同じではありません。 外側の要素と、その要素へ渡した値はReactが管理しています。

しかし、HTML文字列の内側にある要素はReact要素ではありません。 そこにはコンポーネントの境界もなく、内部の要素を一つずつReactの差分更新へ載せることもできません。 Reactが管理するのはHTML文字列を受け取る境界までであり、文字列の内側にある意味までは管理しません。

TypeScriptから見ても同じです。

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

htmlstring であることは確認できます。 しかし、その中に見出しがあるのか、内部リンクがあるのか、別の記事を埋め込んでいるのかは型からわかりません。

自分は、まさにその意味を型で表現したいはずでした。 それを最後に一つの string へ閉じ込めていたわけです。

HTMLは最終的な表示形式として必要です。 ただ、CMSがDBへ保存する段階で string にしてしまうと、表示する前に使いたかった情報までHTMLの中へ入ってしまいます。 自分が嫌だったのは、この部分でした。

Markdownだけを保存する案

それなら、ObsidianのMarkdownだけを保存すればよいようにも思えます。

実際、執筆上の正本はいまもMarkdownです。 復旧や差分確認のため、公開した記事リビジョンにもMarkdown原文を残しています。

記事を表示するだけなら、MarkdownからHTMLやReact要素を作れます。 ただ、自分がCMSへ持たせたかったのは描画情報だけではありませんでした。

最初に欲しい機能を書き出した時点で、検索、内部リンク、ノートの埋め込みが入っていました。

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

具体的なUIは決まっていなくても、リンクや埋め込みが作る関係は失いたくありませんでした。

Markdownには [[記事名]] と書けます。 しかし、CMSではその文字列を表示するだけでなく、リンク先の記事が改名されたあとも同じ記事を指し、公開状態に応じて表示を変える必要があります。 表示名を現在の記事名へ追従させるリンクと、書き手が指定した文言に固定するリンクも区別したくなりました。

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

たとえば、内部リンクは実際には次のような型で保持しています。

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

リンク先の識別子と表示名の扱いが、HTML文字列の中ではなく型として残ります。 検索では同じ文書から表示用の文字列を取り出せますし、埋め込みは元ノートとの境界を保ったノードとして表現できます。

Markdown原文も捨ててはいません。 執筆と復旧にはMarkdownを使い、公開側は同じ記事リビジョンに保存した型付きJSONを読みます。 HTMLはWeb側で型付きJSONから作る形になりました。

Lexicalを思い出した

型付きJSONの構造は、機能を増やすにつれてかなり大きくなりました。

段落、見出し、コード、数式、画像、内部リンク、埋め込みと、表示に必要な情報をノードへ分けていきます。 Web側では、そのノードを読みながらReact要素へ変換します。

型を書き足し、描画のために別の形へ組み替えているうちに、どこかで同じことをした感覚が出てきました。

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

LexicalはMetaが開発するテキストエディタ向けのフレームワークです。

公式ドキュメントでは、DOMではなくLexicalが管理する状態モデルを正本とし、Editor Stateがノード木と選択状態を持つと説明されています。 Editor StateはJSONへ直列化でき、独自ノードも追加できます。

LexicalがHTMLを状態として使わない理由も、自分がCMSで感じた違和感に近いものでした。 HTMLは同じ見た目を複数の構造で表せるほど柔軟であり、編集対象の標準形として扱うには自由度が高すぎます。 Lexicalは文書の構造と装飾を状態モデルへ持たせ、そこからDOMを更新します。

CMSとWYSIWYGエディタの共通点

自分のCMSも、MarkdownとHTMLを直接つなぐのをやめ、意味を持つノードの木をあいだへ置いていました。

Mermaid原文
flowchart LR
  subgraph CMS["自作CMS"]
    Markdown["ObsidianのMarkdown"] --> Document["型付きJSON"]
    Document --> ReactView["Reactによる記事表示"]
    Document --> Search["検索"]
    Document --> Relations["記事間の関係"]
  end
Mermaid原文
flowchart LR
  subgraph Editor["Lexical"]
    Input["編集操作"] --> EditorState["Editor State"]
    EditorState --> DOM["エディタ上のDOM"]
  end

二つが同じものだという話ではありません。 LexicalのEditor Stateには編集時の選択状態があり、更新や履歴を扱うための仕組みもあります。 一方、自作CMSの型付きJSONが持つのは、公開、検索、内部リンクの解決に使う情報です。

編集環境にはすでにObsidianがあるため、CMSへLexicalの編集ランタイムを入れる理由もありませんでした。 Obsidianプラグイン、公開API、DB、Webのあいだでブログ固有の型を共有したかったので、Lexicalの保存形式をそのまま使うとかえって扱いにくくなります。

実際に参考にしたのは、Editor StateのJSONをそのまま保存する方法ではありません。 表示とは別にノードの状態を持ち、必要な表示へ変換する構成です。 自分が中間モデルに興味を持つようになった理由の一つも、ここにあります。

最初からLexicalのような構成を作ろうとしていたわけではありません。 HTMLを保存したくなくて型を増やし、描画処理を作っているうちに、Lexicalと似た場所へ来ていました。

今後作りたいもの

記事ページには、一般的なブログと同じように見出しを使ったサイドバーを付ける予定です。 そこへ内部リンクや埋め込みが作る関係も出せれば、記事同士のつながりをポートフォリオの一部として見せられるかもしれません。

まだUIは決まっていませんし、今の型で情報が足りるかもわかりません。 実際に作ってみて足りなければ、またノードの型が増えると思います。