Updated

MarkdownからCloudflareへ——このブログを作った理由と仕組み

Astro、Cloudflare Workers、R2、D1を組み合わせ、Markdownを正本にした個人ブログの設計思想・独自実装・公開フローを実画面とともに紹介します。

Author

kinn00kinn新しい記事を見逃さない

ブログを作った。

ただ記事を表示するだけなら、既製のサービスを使うほうが速い。それでも自分で作ったのは、文章と画像を自分の手元に残しながら、書くことと公開することの距離を短くしたかったからだ。

この記事では、完成した画面だけでなく、なぜこの構成にしたのか、どこを独自実装したのか、一般的なAstroブログと何が違うのかまでまとめる。

先に全体像

このブログは、ローカルのMarkdownと画像を正本にする。

flowchart TD A["VS CodeでMarkdownを書く"] --> B["Git repository<br/>本文・元画像の正本"] B -->|npm run publish| C["Publish script"] C --> V["Validation / Review"] V --> R2["Cloudflare R2<br/>公開画像"] V --> D1["Cloudflare D1<br/>記事・タグ・画像メタデータ"] V --> BUILD["Astro build<br/>静的HTML・最適化画像"] R2 --> WORKER["Cloudflare Workers"] D1 --> WORKER BUILD --> ASSETS["Static Assets"] ASSETS --> WORKER WORKER --> BROWSER["Browser / RSS reader / SNS crawler"]

実行するコマンドは一つだけだ。

npm run publish

内部では、検証、画像同期、データベース反映、ビルド、デプロイまでを順番に行う。

背景——管理画面ではなく、ファイルとして書きたい

既存のブログサービスには、エディタ、画像管理、公開ボタン、分析機能まで揃っている。一方で、自分が欲しかった執筆環境はもっと単純だった。

  • 普段使っているエディタでMarkdownを書きたい
  • 記事と画像をGitで履歴管理したい
  • サービス固有の形式に本文を閉じ込めたくない
  • データベースやストレージを失っても再構築できるようにしたい
  • 表示速度と運用コストは抑えたい

公開環境を正本にしない。手元のMarkdownから、公開環境を何度でも作り直せるようにする。

これが設計の中心にある。

目的

目指したのは「機能が多いCMS」ではなく、次の条件を満たす小さな出版システムだった。

  1. 執筆はMarkdownだけで完結する
  2. 公開操作は一つのコマンドにまとめる
  3. 静的配信を基本にして高速に表示する
  4. 画像は長期キャッシュできるURLで配信する
  5. 将来、ホスティング先を変更しても記事を持ち出せる

管理画面、認証、オンライン編集、コメント機能は最初から作らないと決めた。必要になるか分からないものを先に持つと、書くためのシステムが、システムを保守するためのシステムになってしまう。

技術スタック

レイヤー採用技術役割
UI / SSGAstro 7Markdownの読み込み、静的HTML生成、画像最適化
配信Cloudflare Workers + Static AssetsHTML・CSS・フォントの配信、R2画像のルーティング
画像Cloudflare R2公開用画像オブジェクト
データCloudflare D1記事、タグ、画像メタデータと関連
執筆Markdown + Astro Content Collectionsfrontmatterの型検証と本文管理
自動化Node.js + Wrangler検証、同期、ビルド、デプロイ
品質Node Test Runnerコンテンツ境界、生成HTML、性能予算のテスト

Astroを選んだ理由

記事ページは、アクセスのたびにサーバーで組み立てる必要がない。AstroならMarkdownから静的HTMLを生成でき、クライアントへ送るJavaScriptも必要な場所だけに限定できる。

Content Collectionsのschemaで、slugやタグの形式、日付、画像参照をビルド時に検証できる点も大きい。

z.object({
  title: z.string(),
  slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
  publishedAt: z.coerce.date(),
  draft: z.boolean().default(false),
  tags: z.array(
    z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
  ),
  heroImage: z.optional(image()),
});

Cloudflareを選んだ理由

静的アセット、Worker、オブジェクトストレージ、SQLデータベースを同じ基盤上で扱える。現在の規模では大掛かりなサーバーを持つ必要がなく、配信経路も短い。

通常のページはStatic Assetsから返し、/images/*だけをWorkerがR2へ解決する。

if (url.pathname.startsWith('/images/')) {
  const object = await env.BLOG_ASSETS.get(key);
  return new Response(object.body, { headers });
}

return env.ASSETS.fetch(request);

独自実装したところ

スターターを見た目だけ変更したのではなく、公開までの経路をブログ向けに組み直している。

1. Markdownを変更しない画像パイプライン

記事から画像を参照するときは、普通の相対パスを書く。

![Home画面](../assets/first-post-home-desktop.png)

publish時に画像のSHA-256を計算し、次の形式でR2へ保存する。

images/{post-slug}/{sha256}.{ext}

HTMLを生成するときだけ公開URLへ置換するため、MarkdownにCloudflare固有のURLは残らない。画像を変更すればhashも変わり、古いキャッシュとの衝突を避けられる。

2. 再構築できるD1

D1には記事本文を変換したHTML、タグ、画像メタデータ、関連情報を保存する。ただし、D1を唯一の原本にはしない。

Markdownと元画像がrepositoryに残っているため、D1とR2は再生成できる。バックアップ戦略を複雑にする代わりに、派生データを作り直せる設計にした。

3. 公開前レビューと境界テスト

publish時には、単にアップロードするだけでなく、次の問題を検出する。

  • slugやタグの不正な形式
  • slugの重複
  • 存在しないローカル画像
  • descriptionの不足や過不足
  • 似たタグ、似た記事
  • 壊れた内部リンク
  • HTMLサイズとビルド時間の性能予算

エラーにすべき問題と、確認を促すレビューを分けている。descriptionが短いだけで公開を止める必要はないが、気づかず公開するのも避けたいからだ。

4. URLだけで展開するリンクカード

Markdown内にURLを単独行で置くと、remark pluginがリンクカードへ変換する。

内部記事ではローカルのfrontmatterと画像を読むため、ネットワーク接続なしでもビルドできる。

外部URLではOGメタデータを読み、取得できない場合にも通常リンクへ安全にフォールバックする。

5. SNS共有用画像

記事のhero imageから1200×630のJPEGを生成し、Open GraphとX Cardへ設定する。og:imageだけでなく、MIME type、幅、高さ、代替テキストも明示した。

これにより、ブラウザ上の画像形式に依存せず、SNSクローラーが扱いやすいカードを生成できる。

6. Light / Dark theme

テーマは次の優先順位で決める。

  1. ユーザーが以前選んだテーマ(localStorage
  2. OS/ブラウザのprefers-color-scheme
  3. OSが指定しない場合はlight

初期描画前に<html>へテーマを設定し、ページを開いた瞬間に色が反転するちらつきを抑えている。

一般的なAstro Blog Starterとの違い

項目一般的なスターターこのブログ
記事の正本MarkdownMarkdown
画像ビルド成果物へ同梱元画像はrepo、公開画像はhash付きでR2
メタデータfrontmatterのみfrontmatterからD1も再構築
公開Astro buildvalidate → review → R2 → D1 → build → deploy
検索未実装または外部サービス静的HTML上のクライアント検索
リンク通常のアンカー内部・外部リンクカードをremarkで生成
SNS画像固定画像または元画像記事ごとに1200×630 JPEG生成
テーマ固定OS連動+選択保存
品質保証ビルド確認中心コンテンツ、リンク、HTML、性能を自動テスト

違いは、D1やR2を使っていること自体ではない。ローカルの原稿を中心に、公開用データを一方向に生成する境界を明確にしたことが一番の違いだ。

実際にできた画面

Homeは、説明、最新記事、件数、タグを一画面で把握できるBento型にした。記事数が少ない段階でも不自然な空白や重複が出ないよう、要素数に応じてレイアウトを変えている。

blog-kinnのHome画面。概要、記事カード、統計、タグをBentoレイアウトで表示している

Blog、Snaps、Searchは同じグリッド寸法を共有しつつ、中身の優先順位を変えた。

  • Blogはタイトル、説明、タグを重視
  • Snapsは画像を入口にする
  • Searchは入力結果と一致件数を即時更新
  • Tagsは分類と記事数を一覧化

ダークモードでも、単純な白黒反転ではなく、背景、カード、境界線、補助文字のコントラストを変数で管理する。

ダークモードのSearch画面。検索フォームと共通寸法の記事カードを表示している

公開フロー

npm run publishの中では、次の順序を崩さない。

1. frontmatterと参照ファイルをvalidate
2. 公開前レビューを表示
3. 画像hashを計算してR2へupload
4. MarkdownをHTMLへ変換
5. D1へposts / tags / assetsを反映
6. Astroで静的ページをbuild
7. WranglerでWorkerとassetsをdeploy

途中で失敗した場合、後続処理は実行しない。同じ入力でもう一度実行できるよう、同期処理は冪等にしている。

作って分かったこと

「便利な管理画面がない」は欠点とは限らない

一人で使うブログなら、Git、エディタ、Markdownがすでに管理画面の役割を果たしている。認証やフォームを追加しないことで、攻撃面と保守対象を減らせた。

データベースを使っても、データベース中心にしなくてよい

D1は一覧や関連を扱うには便利だが、文章の編集履歴はGitのほうが自然だ。役割ごとに正本を決めれば、両者を無理なく併用できる。

小さなブログにもテストは効く

記事が増えるほど、壊れた画像、slugの重複、タグの表記揺れは人間の確認だけでは漏れる。表示のテストだけでなく、コンテンツそのものの境界をテストする価値がある。

これから

現時点で、書く・探す・共有する・公開するための基本機能は揃った。次に追加するなら、機能数を増やすより、次の部分を育てたい。

  • 記事が増えたときの検索インデックス
  • 画像ごとの適切なresponsive size
  • アクセシビリティの継続的な自動検査
  • publish処理の差分同期
  • 実記事を増やしながら情報設計を調整

このブログ自体が、完成品であると同時に実験場所でもある。仕組みを複雑にしすぎず、実際に書く中で必要になったものだけを追加していく。

この記事を書いた人

kinn00kinnSoftware / Research / Writing