blog.yuzu441.com

og画像を生成できるようにした

毎回og画像を設定するのが面倒だったので特別にog画像の設定をしていない記事には、zennやはてなブログのようにタイトルのog画像を生成するようにした

astroのogp生成では @vercel/og 使っていたり、pupetterなどで生成してる記事があったが 今回は@vercel/ogの内部でも使われているsatoriを直接使うことにする

ざっくりやること

Reactで画面を作る容量でog画像っぽいUIを作り、satoriと@resvg/resvg-jsを使用して作ったogっぽいUIをsvgに変換 その後og画像はsvg対応していないのでsharpを使ってそのsvgをpngに変換する

インストール

必要なものをインストールする。このブログのパッケージ管理はpnpmを使っているのでpnpmで書く

pnpm add -D satori sharp @resvg/resvg-js

# reactで作るのでreactも入れる
pnpm astro add react

作っていく

コンポーネントを作る。とりあえずはタイトルとブログタイトルが出るところを目指す。なので完成形はこんな感じ。

og image

ogimageを出すためのコンポーネント

// OgImage.tsx
import satori from 'satori'
import sharp from 'sharp'
import { SITE_TITLE } from '../consts'

const fontFamily = 'Noto Sans JP'
const convertFontFamily = fontFamily.replaceAll(' ', '+')

export async function getOgImage(text: string) {
  const fontData = await getFontData(SITE_TITLE, 400)
  const boldFontData = await getFontData(text, 700)
  const svg = await satori(
    <div
      style={{
        height: '100%',
        width: '100%',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'flex-start',
        backgroundColor: '#fff',
        padding: '24px',
        overflow: 'hidden',
      }}
    >
      <div
        style={{
          display: 'flex',
          flexGrow: '1',
          fontWeight: 700,
          fontSize: '48px',
          width: '100%',
          overflow: 'hidden',
        }}
      >
        {text}
      </div>
      <div
        style={{
          display: 'flex',
          justifyContent: 'flex-end',
          width: '100%',
          fontSize: '24px',
        }}
      >
        blog.yuzu441.com
      </div>
    </div>,
    {
      width: 800,
      height: 400,
      fonts: [
        {
          name: fontFamily,
          data: fontData,
          weight: 400,
          style: 'normal',
        },
        {
          name: fontFamily,
          data: boldFontData,
          weight: 700,
          style: 'normal',
        },
      ],
    },
  )

  return await sharp(Buffer.from(svg)).png().toBuffer()
}

async function getFontData(text: string, wght: number) {
  const API = `https://fonts.googleapis.com/css2?family=${convertFontFamily}:wght@${wght}&text=${encodeURIComponent(
    text,
  )}`

  const css = await (
    await fetch(API, {
      headers: {
        'User-Agent':
          'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1',
      },
    })
  ).text()

  const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/)

  if (!resource) {
    throw new Error('Failed to fetch font')
  }

  const res = await fetch(resource[1])

  return res.arrayBuffer()
}

ogを返すEndpointを作る。画像が別で設定されている記事は生成404を返すことで生成しないようにしている。

// src/pages/og/[slug].png.ts
import type { APIContext, GetStaticPathsResult } from 'astro'
import { getCollection } from 'astro:content'
import { getOgImage } from '../../components/OgImage'

export async function getStaticPaths(): Promise<GetStaticPathsResult> {
  const posts = await getCollection('posts')

  return posts.map((entry) => ({
    params: { slug: entry.slug },
    props: { title: entry.data.title, isGenerate: entry.data.image?.url === undefined },
  }))
}

export async function GET({ props: { title, isGenerate } }: APIContext) {
  if (!isGenerate) {
    return new Response(undefined, {
      status: 404,
    })
  }

  const body = await getOgImage(title)
  return new Response(body, {
    headers: {
      'content-type': 'image/png',
    },
  })
}

これでastro buildするとが画像が生成される

これ作っててAstroのエンドポイントに対してgetStaticPaths効くの知った

tips

ogのUIを作っていく時に毎回ビルドしたり、ブラウザで開いて作るの面倒だなと思っていたらvercelが Vercel OG Image Playground というプレビュー見ながら作れるのでこれで作ってコンポーネントファイルにコピペして作ると効率が良さそう

作っている時に記事のタイトルなのでそんなに長くなることは無いだろうけどtext-overflow: elipsisで長かったら省略して欲しかったんだけど、うまく動かなくて断念したのでできた人はXか何かで教えてほしい

参考