Satori + SvelteKit で OGP 画像を自動生成する
Satori とは Vercel が公開している OGP 画像生成ライブラリです。OGP 画像を表示したい場合、記事ごとに対応する OGP 画像が必要になるわけで、新しい記事を投稿するたびに新たな画像を生成しなければいけません。都度画像を生成する手間は取れないわけで、このOGP 画像を生成する工程を自動化する仕組みが必要となります。Satori は記事のタイトルなどをもとに動的 OGP 画像を生成するユースケースのために使用できます。
Satori とは Vercel が公開している OGP 画像生成ライブラリです。
OGP 画像とは、Twitter などの SNS などでシェアされる際に表示されるサムネイル画像のことです。例えば、ブログなどの場合には以下のようにタイトルを画像として表示しているのを見かけたことがあるのではないでしょうか?
このような OGP 画像を表示したい場合、記事ごとに対応する OGP 画像が必要になるわけで、新しい記事を投稿するたびに新たな画像を生成しなければいけません。都度画像を生成する手間は取れないわけで、この OGP 画像を生成する工程を自動化する仕組みが必要となります。
Satori はこのように記事のタイトルなどをもとに動的に OGP 画像を生成するユースケースのために使用できます。OGP 画像を生成する場合、画像を生成する API を用意していおいてクエリパラメータでタイトルなどを指定して動的に生成する方法がよく上げられています。
例えば、Vercel の提供する Open Graph Image as a Service が該当するでしょう。https://og-image.vercel.app/azukiazusa
のように URL パスで文字列を指定して画像を生成できます。
この記事では SSG であらかじめ静的に生成された HTML を表示するブログ向けに SvelteKit を用いてビルド時に静的に OGP 画像を生成する方法を紹介します。
satori のインストール
この記事では SSG のためのフレームワークとして SvelteKit を利用していますが、その他のフレームワークでも同じように動かせると思うので、あまり詳細の内容までは踏み込みません。まずは冒頭で紹介した OGP 画像を生成するためのツールである satori をインストールします。
npm install satori
satori の特徴は JSX 構文により HTML と CSS を用いて直感的に画像を生成できるところにあります。JSX を使用せずにオブジェクト形式で記述することもできますが、よほど特別な事情がない限り JSX 形式で書くのがわかりやすいでしょう。JSX を使用するためには React をインストールする必要があります。
npm install react @types/react
さらに、tsconfig.json
の compilerOptions
に "jsx": "react-jsx"
を追加します。
{
"compilerOptions": {
"jsx": "react-jsx"
}
}
Satori は SVG 形式で画像を生成するのですが、OGP 画像は SVG をサポートしていません。そのため、satori により生成された画像を別の形式に変換する必要があります。今回は SVG から PNG に変換するために Node.js 上で使える sharp というライブラリを使用します。
npm install sharp
最後に、Satori を使用するためにはフォントデータが必要です。Google Fonts などでダウンロードしておいてローカルに配置しておきましょう。今回は Noto Sans Japanese をダウンロードして /fonts/NotoSansJP-Regular.otf
に配置しておきます。
Satori で画像を生成する
まずは Satori を使用して画像を生成する関数を作成しましょう。src/lib/generateOgpImage.tsx
ファイルを作成します。
import React from 'react'
import satori from 'satori'
import sharp from 'sharp'
import fs from 'fs'
export const generateOgpImage = async (title: string): Buffer => {
// フォントデータを読み込む
const font = fs.readFileSync('./fonts/NotoSansJP-Regular.otf')
// JSX から画像を生成する
const svg = await satori(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
justifyContent: 'space-between',
flexDirection: 'column',
backgroundColor: 'rgb(55,65,81)',
fontWeight: 600,
padding: 48,
border: '48px solid rgb(31,41,55)',
}}
>
<div style={{ color: '#fff', fontSize: 64, maxWidth: 1000 }}>{title}</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ color: '#d1d5db', fontSize: 48, display: 'flex', alignItems: 'center' }}>
<img
src="https://avatars.githubusercontent.com/u/59350345?s=400&u=9248ba88eab0723c214e002bea66ca1079ef89d8&v=4"
width={48}
height={48}
style={{ borderRadius: 9999, marginRight: 24 }}
/>
azukiazusa
</div>
</div>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: 'Noto Sans JP',
data: font,
style: 'normal',
},
],
},
)
// SVG から PNG 形式に変換する
const png = await sharp(Buffer.from(svg)).png().toBuffer()
return png
}
はじめに fs.readFileSync('./fonts/NotoSansJP-Regular.otf')
でフォントデータを読み込みます。これは後ほど satori
関数のオプションとして渡します。
続いて satori
関数を呼び出しここで画像を生成します。前述したとおり、JSX により HTML と CSS を用いて画像を生成できます。Satori においてサポートされている HTML と CSS はいくつかの制限が存在します。通常静的で目に見える要素のみが実装されています。例えば、HTML の <input>
要素や CSS の cursor
プロパティはサポートされていません。
サポートされている要素は以下から確認できます。
また実験的な機能ではありますが。TailwindCSS 形式のプロパティを使用することもできます。
<div tw="bg-gray-50 flex w-full">
<div tw="flex flex-col md:flex-row w-full py-12 px-4 md:items-center justify-between p-8">
...
実際にどのように描画されるかどうか確認するには、Playground で確認するのがよいでしょう。
satori
関数の第 2 引数には画像を生成する際のオプションを渡します。画像のサイズや使用するフォントの情報などです。OGP 画像のサイズは 1200×630px が一般的[要出典]ですのでそのとおりに width
と height
プロパティを渡しています。
最後に sharp
により PNG 形式に変換して完了です。
const png = sharp(Buffer.from(svg)).png().toBuffer()
画像を生成するエンドポイントを作成する
さきほど作成した generateOgpImage
関数を使用して生成した画像を返却するエンドポイントを作成しましょう。SvelteKit のルーティングはファイルベースのルーティングを採用しており、ディレクトリの構造と同じパスでルートを作成します。例えば src/routes/blog/[slug]
のようなディレクトリは /blog/foo
や /blog/bar/
のようなルートを作成します。
routes
のディレクトリにはそれぞれ 1 つ以上のルートファイルが存在します。ルートファイルは +page.svelte
のように接頭辞として +
がついているのでそれで判別可能です。
Next.js の API Routes のようにサーバー側で動作するエンドポイントを作成するためには、ルートファイルとして +server.ts
ファイルを作成します。今回は src/routes/blog/ogp/[title].png/+server.ts
ファイルを作成して /blog/ogp/記事のタイトル.png
のようなパスで画像を配置できるようにします。
import { generateOgpImage } from '$lib/generateOgpImage'
import type { RequestHandler } from '@sveltejs/kit'
export const prerender = true
export const GET: RequestHandler = async ({ params }) => {
const { title } = params
const png = await generateOgpImage(title)
return new Response(png, {
headers: {
'Content-Type': 'image/png',
},
})
}
+server.ts
では GET
,POST
,PATCH
,PUT
,DELETE
のような HTTP メソッドに対応する名前の関数を export
します。この関数は RequestEvent を引数にとり Response オブジェクトを返却します。
さらに SvelteKit の面白い点として、サーバー側ルートであっても export const prerender = true
を宣言することでプレレンダリングできます。これにより、ビルド時に静的アセットとして OGP 画像を配置することが可能です。
記事詳細画面で OGP 画像を設定する
OGP 画像を生成する仕組みが整ったので、記事詳細画面から OGP 画像を設定するようにしましょう。src/routes/blog/[slug]/+page.svelte
ファイルが記事の詳細画面のコンポーネントだとします。
<script lang="ts">
import ArticleCard from '../../../components/ArticleCard.svelte'
import type { PageData } from './$types'
export let data: PageData
const baseUrl = process.env.BASE_URL
</script>
<svelte:head>
<title>{post.title}</title>
<meta name="description" content={post.about} />
<<meta property="og:title" content={title} />
<meta property="og:image" content={`baseUrl/blog/ogp/${encodeURIComponent(post.title)}.png`} />
<meta property="og:description" content={description} />
</svelte:head>
<ArticleCard title={post.title} contents={post.contents} />
<!-- svelte-ignore a11y-missing-content -->
<a href={`/blog/ogp/${encodeURIComponent(post.title)}.png`} />
export let data: PageData
はページのレンダリングが開始する前にサーバーで取得されたデータです。Next.js のおける getStaticProps
に相当するものだと考えればよいでしょう。型注釈に使用している PageData
は ./$types
から import していますが、これは SvelteKit により自動で生成されるもので、サーバー側の関数の返り値の型(+page.server.ts
の load
関数)が自動で割り当てられます。
OGP の情報の設定は <head>
タグ内に記述します。SvelteKit では <svelte:head>
という特殊なタグを使用することで <head>
タグ内に要素を挿入できます。OGP 画像は <meta property="og:image" />
というタグで設定します。property
の値にはさきほど作成した OGP 画像を生成するパスを指定します。ここでは相対パスではなく絶対パスで指定する必要があるところに注意しましょう。
最後にとある事情で空の <a>
タグで OGP 画像へのパスを指定します。これは、SvelteKit がプレレンダリング可能なページを各ページの <a>
タグをクローリングして見つけるためです。この仕様により、動的なルートにおいて Next.js の getStaticPaths
のような関数を用意する必要はないのですが、<meta>
タグの property
属性に指定したパスはクローリングしてくれないためハック的な方法で空の <a>
タグを置いています。
ここまでの作業が完了したら、OGP 画像を生成できるはずです。npm run build
コマンドでアプリケーションをビルドして成果物に OGP 画像が含まれていることを確認できます。このブログでも同様のことを行って OGP 画像を生成しているので、参考にしてみてください。