開発記録

Appli Parkの自作CMS設計記録|公開版を残す下書きと安全な表示

Appli Park専用CMSの実装を、記事データ、公開版と下書きの状態遷移、共通表示部品、画像再生成の流れに分けて紹介します。実際のコードと管理画面から確認できる設計記録です。

CONTENTSこの記事の目次5項目

Appli Parkでは、記事を継続して追加・修正するために専用CMSを実装しています。この記事では一般的なCMSの説明を繰り返さず、リポジトリで確認できるデータ構造、公開事故を防ぐ状態遷移、表示部品と画像処理の判断に絞ります。

1. 記事をHTMLではなく構造化データで持つ

  • articles:記事本体。タイトルや公開状態と、本文ブロックをまとめたcontent_blocks(JSONB)を持つ
  • article_drafts:公開記事のIDに対応する編集下書き。下書き側にもcontent_blocks(JSONB)を持つ
  • 本文ブロック:専用の別テーブルではなく、各content_blocks内のJSON配列として保存する。配列の順序が表示順になる
  • タグと特集:本文とは別の関連データとして管理する

本文は、大見出し、テキスト、画像、FAQ、出典、コード、比較表などのブロックを並べたJSON配列です。各ブロックには種類と内容が入り、画像ならURL・代替テキスト・キャプションなどを持ちます。公開版と編集下書きは、それぞれのcontent_blocks列にこの配列を保存します。 任意HTMLをそのまま保存すると、閉じ忘れたタグや意図しないスクリプトまで扱う検証が必要です。Appli Parkでは入力できる種類を絞り、ブロックごとに検証して共通部品へ渡します。表現を増やすときは新しいブロック型と表示処理が必要ですが、記事ごとにHTMLの違いを抱えずに済みます。

2. 公開中の記事を直接上書きしない

  • 公開APIは公開版の記事だけを返す
  • 「下書きを保存」は編集下書きだけを更新する
  • CMSプレビューは保存済み下書きを優先する
  • 「変更を公開」で初めて公開版へ反映する
  • 記事一覧では「下書きあり」を表示し、公開・編集状態を区別する

公開中の記事を編集すると、CMSは記事本体のarticlesを直接上書きせず、その記事IDに対応する編集下書きをarticle_draftsへ保存します。本文も、下書き側のcontent_blocksだけが更新されます。公開APIは公開条件を満たすarticlesの内容を返し、CMSプレビューは保存済みの下書きを優先します。 「変更を公開」を実行したときに、下書きの内容を記事本体へ反映し、反映後の下書きを削除します。編集用と公開用のデータを分け、それぞれを読み出す処理も分けることで、保存途中の内容を読者へ出さない構成です。

図のAはarticlesにある公開版、Bはarticle_draftsにある編集下書きです。本文はそれぞれのcontent_blocks(JSONB)に保存され、「変更を公開」で初めて公開版へ反映されます。

3. プレビューは公開画面と同じ部品で表示する

ArticlePresentationがタイトル、概要、カテゴリー、タグなど記事全体を組み立て、ArticleRendererが本文ブロックを種類別に表示します。その場のプレビュー、保存済み下書きのプレビュー、公開記事はいずれもこの組み合わせを使います。プレビュー専用の似たHTMLを別に持つと、公開画面だけ余白やリンク処理が違う事故が起きるためです。公開画面にだけ著者欄や関連記事などを後から追加します。

4. 画像を登録するときも、持ち込む情報を減らす

  1. JPEGまたはPNG、5MB以内、縦横4000px以内に制限する
  2. ImageIOで画像を読み込み、表示用の画像データへデコードする
  3. 透過PNGはPNG、それ以外はRGBのJPEGとして再生成する
  4. 再生成したバイト列だけを画像ストレージへ保存する
  5. 記事側では管理画像URL、代替テキスト、任意のキャプションを保存する

自作にすると、記事のブロックや保存・公開の手順を、このサイトの運営に合わせて変更できます。例えば、出典を普通の本文と分けて入力する欄や、公開版を残す下書きは、そのために用意した機能です。 その代わり、入力検証、認証、画像の処理、保存の失敗表示、公開画面との整合性まで自分たちで保守します。自由な装飾や多人数の承認フローを増やす前に、まず一人の管理人が記事を安全に更新できる範囲を整える。今のCMSは、その優先順位で育てています。

リポジトリで確認した「変更を公開」の処理

CODEArticleService.ktkotlin
val draft = draftRepository.findById(id)
    .orElseThrow { ArticleNotFoundException("保存済みの下書きが見つかりません。") }
val request = draft.toUpsertRequest(
    status = ArticleStatus.PUBLISHED,
    publishedAt = article.publishedAt ?: OffsetDateTime.now(),
)
validateRequest(request)
if (repository.existsBySlugAndIdNot(request.slug, id)) {
    throw ArticleConflictException("このslugは既に使用されています。")
}
applyRequest(article, request)
val saved = repository.save(article)
draftRepository.delete(draft)
ArticleService.ktのpublishDraftから、下書き取得〜削除までを抜粋。前段の記事取得・公開対象確認と、末尾のレスポンス変換は省略しています。メソッド全体は@Transactionalの対象です。実装確認日:2026年9月19日。

applyRequestは、下書きから読み出した本文ブロックをJSONへ変換し、記事本体のcontent_blocksへ設定します。URLの重複や本文の妥当性を確認し、記事の保存と下書きの削除を同じトランザクション内で行います。 反映が完了すると、次にCMSを開いたときは公開版が編集元になります。下書き保存だけではこの公開処理に入らないので、読者に見える内容は変わりません。

READ MORE初心者向けの「CMSとは?」へ戻るCMSの基本的な役割と、下書き・公開版の違いから読みたい方はこちらです。