ブログサイト構築 — OGP 画像の自動生成
Satori と @resvg/resvg-js を使い、記事タイトル入りの OGP 画像をビルド時に自動生成する実装。フォント取得の woff2 問題と解決策を含む
OGP 画像とは
SNS でリンクをシェアしたときに表示されるサムネイル画像。
<meta property="og:image"> タグで指定する。
<meta property="og:image" content="https://octomblog.com/ogp/blog/cache.png" />
課題
ブログ記事ごとに個別の画像を用意するのは手間がかかる。 Zenn・はてなブログのように、タイトルが入った画像をビルド時に自動生成したい。
解決策:Satori + @resvg/resvg-js
| パッケージ | 役割 |
|---|---|
satori | JSX ライクな要素ツリーを SVG に変換 |
@resvg/resvg-js | SVG → PNG に変換(Node.js ネイティブアドオン) |
{ type: 'div', props: { style: {...}, children: [...] } }
↓ satori
<svg>...</svg>
↓ @resvg/resvg-js
*.png(1200 × 630 px)
実装アーキテクチャ
Astro 静的エンドポイント
src/pages/ogp/
├── blog/[slug].png.ts ← Blog 記事ごとの OGP PNG
└── docs/[slug].png.ts ← Docs 記事ごとの OGP PNG
Astro の静的エンドポイントは getStaticPaths + GET の組み合わせで、ビルド時に PNG ファイルを生成できる。
// src/pages/ogp/blog/[slug].png.ts
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
};
export const GET: APIRoute = async ({ props }) => {
const { title, description, tags } = props.post.data;
const png = await renderOgpPng({ title, description, tags });
return new Response(png, { headers: { 'Content-Type': 'image/png' } });
};
ビルド後、dist/ogp/blog/<記事ID>.png に PNG が生成される。
OGP デザイン
1200 × 630 px
┌──────────────────────────────────────────────────────────┐
│ octomblog [カテゴリバッジ] │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ← 紺線 │
│ │
│ シリーズ名(docs のみ) │
│ タイトル(白・48px・bold) │
│ │
│ description(グレー・20px) │
│ │
│ #tag1 #tag2 octomblog.com │
└──────────────────────────────────────────────────────────┘
背景: #0f172a タイトル: #f8fafc ライン: #6366f1
説明: #94a3b8 タグ: #818cf8 URL: #475569
共通ユーティリティ(src/lib/ogp.ts)
export async function renderOgpPng(opts: OgpOptions): Promise<Uint8Array> {
const fonts = await loadFonts();
const svg = await satori(buildElement(opts), {
width: 1200,
height: 630,
fonts,
});
return new Resvg(svg).render().asPng();
}
フォント取得の問題と解決
問題:woff2 未サポート
Satori の内部ライブラリ @shuding/opentype.js は woff2 フォーマット未対応。
woff2 の解析には Brotli 圧縮の展開が必要で、実装されていない。
Google Fonts は Chrome 等のモダンブラウザの UA 文字列には woff2 を返す。 これをそのまま渡すとビルドエラーになる。
Error: Unsupported OpenType signature wOF2
解決策:古い UA で woff を取得
古いブラウザの User-Agent を使うと、Google Fonts は woff(サポート済み)を返す。
const css = await fetch(
'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@700',
{
headers: {
// Firefox 30 は woff2 未対応 → Google Fonts が woff を返す
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; rv:30.0) Gecko/20100101 Firefox/30.0',
},
}
).then((res) => res.text());
| User-Agent | Google Fonts が返す形式 |
|---|---|
| Chrome 120 (モダン) | woff2 → satori でエラー |
| Firefox 30 (古い) | woff → satori で動作 |
フォントキャッシュ
ビルド中に同じフォントを何度も fetch しないよう、モジュールスコープの変数でキャッシュする。
let fontsCache: SatoriFont[] | null = null;
async function loadFonts(): Promise<SatoriFont[]> {
if (fontsCache) return fontsCache;
// ... fetch ...
fontsCache = fontDataArray.map(/* ... */);
return fontsCache;
}
Vite の設定
@resvg/resvg-js は Node.js ネイティブアドオン(.node バイナリ)のため、
Vite の SSR バンドルの対象外にする必要がある。
// astro.config.mjs
vite: {
ssr: {
external: ['@resvg/resvg-js'],
},
},
レイアウトでの参照
BlogPostLayout.astro
// image フィールドがあればそれを使い、なければ自動生成 OGP を参照
const resolvedImagePath = image
? (image.startsWith('/') ? image : `/${image}`)
: `/ogp/blog/${post.id}.png`;
DocsLayout.astro
<BaseLayout image={`/ogp/docs/${doc.id}.png`}>
パッケージ導入
pnpm add satori @resvg/resvg-js
@resvg/resvg-js はプラットフォームごとにバイナリが異なる(resvg-js-<platform>.node)。
pnpm がビルド環境(darwin / linux)に応じたバイナリを自動選択する。
関連
- 1. 🏗️このサイトのアーキテクチャ
- 2. 🏗️ブログサイト構築 — 全体アーキテクチャ
- 3. 🚀ブログサイト構築 — Astro SSG と MPA 設計
- 4. ☁️ブログサイト構築 — Cloudflare ホスティング
- 5. 🖼️ブログサイト構築 — OGP 画像の自動生成
- 6. 🎨ブログサイト構築 — Satori で絵文字を描画する
- 7. 🗄️ブログサイト構築 — Cloudflare D1 入門