ota2000
2 min read

Astro ブログに記事タイトル入りの OGP 画像を自動生成する

ブログ記事を X でシェアしたとき、OGP 画像が全記事共通のサイトロゴだった。記事タイトルが画像に入っていた方がクリックされやすいので、記事ごとに OGP 画像を自動生成するようにした。

仕組み

Astro の静的エンドポイントで、各記事に対応する /blog/{id}/og.png を生成する。

  1. satori — React 風の JSX オブジェクトから SVG を生成
  2. @resvg/resvg-js — SVG を PNG に変換

ビルド時にすべての記事分の PNG が生成される。ランタイムの処理は不要。

セットアップ

pnpm add satori @resvg/resvg-js

画像生成エンドポイント

src/pages/blog/[id]/og.png.ts を作成。既存の [id].astro[id]/index.astro に移動する。

import type { APIRoute, GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

export const getStaticPaths = (async () => {
  const posts = await getCollection('blog');
  return posts
    .filter((post) => !post.data.draft)
    .map((post) => ({
      params: { id: post.id },
      props: { title: post.data.title, date: post.data.date },
    }));
}) satisfies GetStaticPaths;

// ビルド時に1回だけ取得
const fontResponse = await fetch(
  'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@latest/japanese-700-normal.woff'
);
const fontData = Buffer.from(await fontResponse.arrayBuffer());

export const GET: APIRoute = async ({ props }) => {
  const { title, date } = props;

  const svg = await satori(
    {
      type: 'div',
      props: {
        style: { /* レイアウト定義 */ },
        children: [
          { type: 'div', props: { children: title } },
          // アイコン、著者名、日付 ...
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [{ name: 'Noto Sans JP', data: fontData, weight: 700 }],
    },
  );

  const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } });
  const png = resvg.render().asPng();
  return new Response(Buffer.from(png), {
    headers: { 'Content-Type': 'image/png' },
  });
};

日本語フォントの読み込み

satori は SVG を生成するためにフォントデータが必要。ローカルにフォントファイルを置く方法だと、Astro のビルド時に process.cwd() がビルド出力ディレクトリを指すためパスが壊れる。

CDN から fetch で取得する方式にした。トップレベル await で書けばビルド時に1回だけ取得される。

const fontResponse = await fetch(
  'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@latest/japanese-700-normal.woff'
);
const fontData = Buffer.from(await fontResponse.arrayBuffer());

OGP meta タグの設定

Layout に ogImage prop を追加し、ブログ記事ページでは動的 URL を渡す。

<!-- Layout.astro -->
<meta property="og:image" content={ogImage || 'https://ota2000.com/og-image.png'} />
<meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
<!-- [id]/index.astro -->
<Layout ogImage={`https://ota2000.com/blog/${post.id}/og.png`}>

summary_large_image にすると X で大きな画像カードとして表示される。

アイコンを埋め込む

サイトのアバター画像を OGP に含めたかったので、apple-touch-icon.png を base64 エンコードして satori に渡している。

const iconBase64 = `data:image/png;base64,${
  fs.readFileSync('public/apple-touch-icon.png').toString('base64')
}`;

// satori のレイアウト内で
{ type: 'img', props: { src: iconBase64, width: 48, height: 48 } }

結果

SNS でシェアしたときに記事タイトル + アイコンが大きく表示されるようになった。ビルド時間は 13 記事で約 7 秒増。記事を追加するたびに自動で OGP 画像が生成される。