Astro ブログに記事タイトル入りの OGP 画像を自動生成する
ブログ記事を X でシェアしたとき、OGP 画像が全記事共通のサイトロゴだった。記事タイトルが画像に入っていた方がクリックされやすいので、記事ごとに OGP 画像を自動生成するようにした。
仕組み
Astro の静的エンドポイントで、各記事に対応する /blog/{id}/og.png を生成する。
- satori — React 風の JSX オブジェクトから SVG を生成
- @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 画像が生成される。