はじめに
ブログ記事を SNS でシェアしたとき、OGP 画像の有無でクリック率は大きく変わります。
Astro なら satori を使ってビルド時に自動生成するのが定番ですが、
Cloudflare Pages にデプロイする日本語ブログではいくつかの罠があります。
このブログで実際に踏んだ 2 つの問題と、その解決策を紹介します。
sharpが Cloudflare のランタイムで動かない- 日本語タイトルが文字単位で折返され、文節の途中で改行される
構成の全体像
最終的に採用したパイプラインは以下の通りです。
flowchart LR
A["getStaticPaths()"] --> B["satori<br/>HTML → SVG"]
B --> C["resvg<br/>SVG → PNG"]
C --> D["dist/og/slug.png"]
ポイントは export const prerender = true を宣言し、
ビルド時に全投稿分の OGP を静的ファイルとして出力する点です。ランタイムでは一切画像生成をしません。
export const prerender = true;
export async function getStaticPaths() { const posts = await getCollection("posts"); return posts.map((post) => ({ params: { slug: post.slug }, props: { post }, }));}記事ページ側では、<meta property="og:image"> に /og/${slug}.png を指定しておけば、
ビルドで生成された静的ファイルがそのまま配信されます。
なぜ sharp ではダメだったのか
Astro の OGP 画像生成で検索すると、多くの記事が satori + sharp の組み合わせを紹介しています。
僕も最初はこの構成でした。
ところが Cloudflare Pages では sharp がランタイムで動きません。
sharp は C++ の native binding に依存しており、Cloudflare の V8 Isolate 環境では実行できないからです。
prerender: true を付ければビルド時(= Node.js 環境)で実行されるため sharp でも動きますが、
この宣言を忘れると Astro は SSR ルート扱いにします。
結果として本番デプロイ後に OGP エンドポイントが 5xx を返し、SNS のクローラーが画像を取得できなくなります。
解決策は sharp を @resvg/resvg-js に置き換えることです。
import { Resvg } from "@resvg/resvg-js";
// satori が返した SVG 文字列を PNG に変換const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 },});const png = resvg.render().asPng();@resvg/resvg-js も native Node binding ですが、
prerender: true と組み合わせることでビルド時にしか実行されないため、ランタイム非対応の問題を完全に回避できます。
なお、native binding を含むパッケージは Astro の server bundle に巻き込まれるとビルドが失敗します。
astro.config.mjs で明示的に除外する必要があります。
vite: { ssr: { external: ["@resvg/resvg-js", "satori", "satori-html"], },},日本語タイトルの改行問題
ここまでで OGP 画像は生成できるようになりましたが、日本語ブログ特有の問題が残ります。
satori のテキストレイアウトエンジンは 文字単位 で折返しを判定します。
英語なら単語境界(スペース)で自然に切れますが、日本語にはスペースによる単語区切りがないため、
「渡す」が「渡」と「す」に分断されるような不自然な改行が発生しました。
ZWSP は使えない
最初に試したのは zero-width space(U+200B)を文節境界に挿入して word-break: keep-all と組み合わせる方法でした。
CSS の仕様上これが正攻法ですが、
satori は ZWSP をグリフとして描画してしまい、□(tofu)が表示されるという問題がありました。
budoux で解決
最終的に採用したのは、Google 製の日本語改行ライブラリ budoux です。
budoux は学習済みモデルで日本語の文節境界を推定し、自然な位置で分割してくれます。
アプローチは以下の通りです。
budouxでタイトルを文節に分割する- フォントサイズとコンテナ幅から 1 行あたりの最大文字幅を計算する
- 文節を貪欲法で行に詰め、上限を超えたら次の行へ送る
- 各行を個別の
<div>として satori に渡す
import { loadDefaultJapaneseParser } from "budoux";
const jpParser = loadDefaultJapaneseParser();
function estimateWidth(text: string): number { let w = 0; for (const ch of text) { w += ch.charCodeAt(0) > 0x7f ? 1 : 0.55; } return w;}
function splitTitleIntoLines(title: string, fontSize: number): string[] { const maxUnits = (CONTENT_WIDTH / fontSize) * 0.95; const phrases = jpParser.parse(title); const lines: string[] = []; let current = "";
for (const phrase of phrases) { const next = current + phrase; if (estimateWidth(next) > maxUnits && current.length > 0) { lines.push(current); current = phrase; } else { current = next; } } if (current) lines.push(current);
return lines;}estimateWidth では CJK 文字を幅 1.0、ASCII 文字を 0.55 として近似しています。
正確なグリフ幅測定ではないものの、実用上十分な精度で行折返しの判定ができます。
Before / After
| 記事タイトル | Before | After |
|---|---|---|
| Claude Code にプロジェクト文脈を渡す 4 層構造 | ...文脈を渡 / す 4 層構造 | ...プロジェクト文脈を / 渡す 4 層構造 |
| SubCutter - ブラウザ完結の固定費管理PWAを作った | ...完結の固定費 / 管理PWAを作った | ...ブラウザ完結の / 固定費管理PWAを作った |
はまりポイントまとめ
-
prerender: trueを忘れると本番で壊れる
Cloudflare Pages + Astro の組み合わせでは、native binding を使うエンドポイントには必ずprerender: trueを付ける。付け忘れると開発環境では動くのに本番で 5xx になる。 -
satori は全要素に
display: flexが必要
satori のレイアウトエンジンは Yoga ベースで、すべての要素が Flexbox として評価される。display: flexを省略すると予期しないレイアウト崩れが起きる。 -
satori-htmlの tagged template は値を HTML エスケープする
html`...${dynamicHtml}...`と書くとdynamicHtml内の<div>がエスケープされてしまう。動的に生成した HTML を挿入するには、raw 文字列として渡すヘルパーが必要。
まとめ
- Astro + Cloudflare Pages で OGP 画像を自動生成するなら、
prerender: true+ satori + @resvg/resvg-js がビルド時完結で安定する - 日本語ブログでは budoux を入れるだけで改行品質が劇的に改善 される。約 30 KB の依存を追加するだけで、全記事の OGP 画像が自然な文節区切りになる
- satori 特有の制約(display: flex 必須、ZWSP の tofu 化、satori-html のエスケープ)は事前に知っておくとデバッグ時間を大幅に節約できる
まずは自分のブログの OGP エンドポイントに export const prerender = true が付いているか確認するところから始めるのがおすすめです。