概要
以前から構想していた「テキスト中心の個人ブログ」を実装した。開発ブログ(技術記録)とは別に、随筆や日記を気軽に書ける場所が欲しかった。
固有の UI を避けてオリジナルで設計した。設計書を先に書き、それを Astro + Cloudflare D1 環境に移植する形で開発した。
デザインシステムの設計
まず CSS カラートークンを定義した。既存サイトの変数と分離するため、ブログ専用のスコープで管理している。
--paper: #FAFAF8; /* 目に優しいオフホワイト */
--ink: #1F1F1D; /* 本文・見出し */
--ink-soft: #4F4D48; /* サブテキスト */
--ink-mute: #8A8780; /* メタ情報 */
--line: #E4E2DA; /* 罫線 */
--accent: #6B5B3E; /* 暖色アクセント */
フォントは Zen Kaku Gothic New(Google Fonts)を 300/400/500/700 の 4 ウェイトで読み込んでいる。
本文サイズは 17px・line-height 2.0 と、読み物として余裕を持たせた。
データ設計
既存のブログ(開発ブログ)とテーブルを分離し、専用の 3 テーブルを作成した。
- 投稿テーブル: スラグ、タイトル、本文(エディタ内部形式)、パース済みブロック JSON、日付、カテゴリ、公開状態
- カテゴリテーブル: スラグ、名前、説明、カラーコード
- 設定テーブル: キー・バリュー形式(bio、プロフィールリンクなど)
テーブルは初回アクセス時に CREATE TABLE IF NOT EXISTS で自動作成される。ローカル開発でもマイグレーションを手動で走らせる必要がない。
本文のパースとレンダリング
エディタ内部では Markdown に近い plain text を使い、保存時にブロック配列 JSON に変換する。
## 見出し → { type: "h2", text }
> 引用 → { type: "quote", text }
--- → { type: "hr" }
 → { type: "image", src, caption }
@youtube(ID) → { type: "embed", kind: "youtube", id }
@card(U|T|D|S)→ { type: "card", url, title, description, siteName }
パース済み JSON はサーバーサイドで HTML に変換して set:html で出力する。XSS 対策として、すべてのテキストフィールドはエスケープしてから出力している。外部 URL は http:// または https:// のみ許可し、それ以外は無視する。
全画面エディタ
管理画面の記事編集は、公開ページとは切り離した全画面エディタで行う。
- 上部バー: 左に「← もどる」、中央に保存状態(下書き / 編集中… / 保存済み)、右に字数カウンターと「公開する」ボタン
- 本文 textarea: 入力に応じて自動リサイズ(scrollHeight に追従)
- フローティング書式ツールバー: 見出し・引用・太字・斜体・区切り・写真の 6 種
- キーボードショートカット:
Ctrl+Sで下書き保存、ESCで一覧に戻る
800ms デバウンスで入力するたびに自動保存するようにした。これにより、「公開する」だけでなく書いている途中の状態も失われない。
URL 検出と埋め込みプロンプト
本文の現在行に URL が含まれる場合、画面下に黒いプロンプトを表示する。
- YouTube URL → 「YouTube動画を検出しました」+ 「埋め込む」ボタン
- X (Twitter) URL → 「X(Twitter)の投稿を検出しました」+ 「埋め込む」ボタン
- その他 URL → 「URLを検出しました」+ 「カードで埋め込む」ボタン
「カードで埋め込む」を選ぶと、サーバーサイドで OGP を取得し、タイトル・説明・サイト名を自動入力する。
カテゴリ管理 UI
カテゴリ管理画面で工夫した点:
- インライン編集: テーブルセルをクリックするとそのまま編集できる。変更から 600ms 後に自動保存される
- カラーパレット: swatch をクリックすると 11 色のパレットがポップアップし、選択すると即時反映・保存される
- 2 段階削除確認: 削除ボタン押下後、紐づく記事数を表示した上で「N件もろとも削除」を求める確認ステップを挟む
- 新規追加フロー: テーブル先頭にハイライトされた入力行が現れ、Enter で確定・ESC でキャンセルできる
公開ページの設計
一覧ページは年ごとのセクション分けと、「新しい順 / 古い順」トグルを実装した。
記事行の構成は 日付(78px 固定)| 点線フィラー | タイトル(最大60%) のレイアウト。点線フィラーは flex: 1 で余白を埋めるシンプルな実装だが、スマートフォンでは非表示にしてタイトルを折り返す。
カテゴリフィルタータブは URL パラメータ(?category=slug)で状態を管理する。アクティブタブの下線と行罫線が同じ Y 座標で重なる仕様は、position: relative + ::before で bottom: -1px に 1px の線を引いて実現した。
リアクションボタン
「いいね」的な機能として、絵文字なしのリアクションボタンを実装した。押下で状態が反転し(背景が墨色になる)、localStorage でリアクション済み状態を保持する。カウントはサーバーへ非同期で送信して更新する楽観的更新方式。
セキュリティ上の工夫
- スラグのバリデーション:
/^[a-zA-Z0-9_\-.]{1,120}$/でのみ受け付ける - 外部 URL:
isSafeUrl()でhttp://https:のみ許可し、javascript: や data: を弾く - YouTube 動画 ID:
/^[a-zA-Z0-9_-]+$/のみ許可し iframe の src に使う - OGP 取得: セッション検証済みのエンドポイントで行い、レスポンスは最大文字数でスライスする
- XSS 対策: set:html で出力する HTML はサーバー側でエスケープ済みのみ
振り返り
デザインシステムの設計書(SPEC)を先に書いた上で実装に入ったのが効率的だった。カラートークン・タイポグラフィ・レイアウト定数が固まっていると、実装中に迷いが生じない。
エディタの「自動保存 + 手動公開」の分離は書き心地を高める上で重要だと改めて感じた。