astro-notion-blogで動的なOGPを実装してみる

このブログは astro-notion-blog ベースで、デフォルトではOGP画像が設定されていなかった。記事を共有したとき何も表示されないのが地味に気になっていたので、satori + sharp で動的に生成するよう実装した。

Image in a image block
figmaで作成した初期案のOGP

ライブラリ選定

候補はいくつかあった。

  • @vercel/og — Next.js向けでEdgeRuntime前提、Astroのビルド時処理には合わない
  • satori — JSXライクなオブジェクトツリーからSVGを生成。素のAstroでも使いやすい

結局 satori + sharp の組み合わせにした。satorが SVG を吐き、sharp で PNG に変換してグレースケールをかける。

実装方針

既存の astro-notion-blog には Cloudflare 向けのファイルダウンロード処理がインテグレーションとして実装されている。同じパターンで astro:build:start フックにOGP生成処理をまとめた。pnpm dev でも動くよう astro:server:start も使う。

生成した画像は public/og/{slug}.png に置く。ビルド済みのものは再生成しないようスキップ処理を入れた。

デザイン

デザインはこんな感じ。左カラムにサイト名とタイトル、右カラムにNotionのアイコンを7度回転させて配置、全体グレースケール。

satori は Flexbox 前提で、全ての divdisplay: 'flex' が必要。これを忘れると Expected <div> to have explicit "display: flex" で落ちる。

フォントはサイトで使っている Space Grotesk と IBM Plex Sans JP をそのまま流用した。

絵文字の処理

Notionのアイコンは絵文字か画像URLのどちらかを取り得る。絵文字はそのままだとsatoriで表示できないので、TwemojiのSVGに変換する。

CDNに毎回リクエストするのは嫌だったので @iconify-json/twemoji をローカルインストール。コードポイント → アイコン名 → SVGボディ という流れで解決する。

function emojiToSvgDataUrl(emoji: string): string | null {
  const cp = emoji.codePointAt(0)!.toString(16)
  const name = twChars[cp]          // chars.json: コードポイント → 名前
  if (!name) return null
  const icon = twIcons.icons[name]  // icons.json: 名前 → SVGボディ
  if (!icon) return null
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${twIcons.width} ${twIcons.height}">${icon.body}</svg>`
  return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}

OGPの優先順位

NotionのFeaturedImageが設定されていればそれを使い、なければ今回の自動生成画像、最終フォールバックはデフォルト画像。

Notion FeaturedImage → /og/{slug}.png → default-og-image.png

所感

NotionのアイコンがそのままOGPのビジュアルになるのがいい感じで、絵文字を設定するだけでそれなりに見栄えのするカードが自動生成される。