🖼️
概念 #OGP #Satori #Astro #SNS 📚 ブログサイト構築

ブログサイト構築 — 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

パッケージ役割
satoriJSX ライクな要素ツリーを SVG に変換
@resvg/resvg-jsSVG → 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.jswoff2 フォーマット未対応。 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-AgentGoogle 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)に応じたバイナリを自動選択する。

関連