ShareDan CMS は、お知らせ本文・FAQの回答・固定コンテンツの本文をHTMLではなく ProseMirror(Tiptap)のJSONで配信します。{ type: 'doc', content: [...] }という、ノードの木構造です。それを公開サイト側でHTMLへ変換するのが SDK のrenderProseMirror()です。この記事では、なぜHTMLで返さないのか、そして「安全にHTMLへ変換する」ためにレンダラをどう設計したかを説明します。
なぜHTMLではなくJSONで返すのか
本文をサーバー側でHTML文字列にしてしまうと、出力先の都合に関係なく1つの形に固定されます。実際には、ReactにdangerouslySetInnerHTMLで流し込みたいこともあれば、静的サイトジェネレータでビルド時に埋め込みたい、ネイティブアプリで独自に描画したい、と出力先はさまざまです。
JSONで返せば、エスケープすべき文脈を出力先ごとに選べます。プラットフォーム側が「誰かの環境では危険かもしれないHTML」を先回りで組み立てて配ることもありません。安全性の責任分界点を、実際に描画する側の文脈へそろえられる — これがJSONで返す一番の理由です。
const post = await cms.posts.get('news', slug);
const html = renderProseMirror(post.body);
// React なら
// <article dangerouslySetInnerHTML={{ __html: html }} />設計のかなめ:許可リスト方式
renderProseMirror()は、あらかじめ決めたノード・マークの集合だけをHTMLへ変換します。ノードのtypeは「どのタグを出すか」を選ぶキーにすぎず、JSON中の文字列がそのままタグになることはありません。だからtypeにどんな文字列が入っていても、許可した以外のタグが出力へ混ざることは原理的に起きません。
function renderNode(n) {
switch (n.type) {
case 'paragraph': return `<p>${renderChildren(n)}</p>`;
case 'heading': return `<h${level(n)}>${renderChildren(n)}</h${level(n)}>`;
case 'bulletList': return `<ul>${renderChildren(n)}</ul>`;
case 'text': return renderText(n); // ← テキストは必ずエスケープ
// …許可したノードだけを列挙
default: return n.content ? renderChildren(n) : ''; // 未知は無視
}
}もう一つの柱がエスケープです。テキストノードの中身は必ず&<>"をエスケープしてから出力します。属性値(リンクのhref、画像のsrcやaltなど)も同じようにエスケープするので、値の中に"を仕込んで属性の外へ抜け出す、といったこともできません。
マークは内側から巻いていく
太字やリンクなどの装飾(マーク)は、エスケープ済みのテキストを内側から順にタグで包んでいきます。ここでもswitchで許可したマークだけを処理し、知らないマークは素通しします。
function renderText(n) {
let html = escapeHtml(n.text ?? '');
for (const mark of n.marks ?? []) {
switch (mark.type) {
case 'bold': html = `<strong>${html}</strong>`; break;
case 'code': html = `<code>${html}</code>`; break;
case 'link':
html = `<a href="${escapeHtml(mark.attrs.href)}">${html}</a>`;
break;
// …許可したマークだけ
}
}
return html;
}未知のノード・マークは無視する
許可リストのdefaultは、エラーにせず無視する設計です。未知のノードは(子があれば)中身だけを描き、未知のマークは素通しします。これは前方互換のためです。将来、表や目次のような新しいブロックを本文に追加しても、まだ更新していない古いSDKはその部分を黙って飛ばすだけで、例外や真っ白な画面にはなりません。新しい表現はSDKを上げたときに描けるようになります。
裏を返すと、新しいノードに対応するには「SDKのバージョンを上げる」必要があります。破壊的変更を避けつつ機能を足していけるのは、この“知らないものは飛ばす”方針があるからです。
リンクの target / rel を選べる
リンクのtargetとrelはオプションで指定できます。既定は_blank+noopener noreferrer。同一タブで開きたい、あるいはrelを自分で管理したい場合は上書きしてください。
const html = renderProseMirror(post.body, {
linkTarget: '_blank',
linkRel: 'noopener noreferrer',
});集合外を扱う:カスタムブロックという拡張点
「許可した集合だけ」を厳密に守ると、当然それ以外を出したいケースが出てきます。そのための拡張点がカスタムブロックです。テナントが本文へ挿入したカスタムブロックは、中身を描いたうえで種別と表示名をdata属性として付けた<div>になります。
<div data-custom-block data-block-id="cta" data-name="お問い合わせ導線">
…ブロックの中身…
</div>公開サイト側は[data-custom-block]を拾って、種別(data-block-id)ごとに装飾したり、独自コンポーネントへ差し替えたり(hydrate)できます。集合外を扱う道は3つ —①返却HTMLを後処理する、②カスタムブロックで包んでサイト側で描く、③prosemirror モジュールを参考に独自レンダラを書く。
“信頼できない入力”を描くときは
本文は、認証済みのテナント編集者が管理画面で作るコンテンツが前提です。許可リストとエスケープにより、ドキュメントから許可外のタグや属性が出力へ混ざることはありません。一方で、もっと信頼度の低い入力(第三者が自由に投稿できるデータなど)を同じ経路で描くなら、多層防御としてrenderProseMirror()の出力を DOMPurify のようなサニタイザへもう一度通すことをおすすめします。安全性は「どこから来たデータか」で決まります — レンダラは出力の“形”を保証しますが、入力の信頼性までは肩代わりしません。
まとめ
設計の要点は3つです。HTMLではなくJSONで配ることで出力先ごとに安全な文脈を選べること、許可リスト+エスケープで想定外のタグが混ざらないこと、未知は無視で前方互換を保つこと。型やメソッドの詳細はAPIリファレンスを、リンクオプションの追加はSDK 0.4を参照してください。