自作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 State | D1へ保存した文書モデル |
| 状態の更新 | エディタ内部で反映 | プラグインと公開APIを通して反映 |
| 表示 | 同じ画面のDOM | Webが別のタイミングで作る記事のDOM |
WYSIWYGエディタでは、編集操作、状態の更新、DOMへの反映が一つのランタイム内で進みます。 自作CMSでは、同じような流れがObsidian、プラグイン、API、DB、Webへ分かれています。
APIを挟んだ大きなエディタに見えた話
二つが同じものだという話ではありません。
LexicalのEditor Stateには、ノード木や選択状態など、その場で編集するための情報があります。 自作CMSの文書モデルが持つのは、公開状態、検索、内部リンク、埋め込みに必要な情報です。
更新の時間も違います。 WYSIWYGエディタはキー入力へすぐ反応しますが、自作CMSは公開操作を境に状態を永続化し、Webからのリクエストに応じて表示します。
それでも抽象的に見ると、どちらも次の構造を持っています。
入力を受け取る
表示とは別の文書状態へ反映する
文書状態から表示を作る
最初から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を通って表示されるまでを一度ちゃんと考えてみたくて、自分の用途に合わせて設計を詰めてみた感じです。