• #playwright
  • #ogp
  • #vue
  • #nuxt
開発完了

PlaywrightでVueコンポーネントをOGP画像に変換する

Vueコンポーネントで作成したUIを、そのままOGP画像として出力する方法を解説する。Cloudflare Workersでの動的生成ではなく、Playwrightでスクリーンショットを撮影して静的画像として配信するアプローチを採用した。

結論

Playwrightで開発サーバー上のプレビューページをスクリーンショットし、静的OGP画像として保存する方式が最適だった。

  • ビルド時間に影響しない(手動実行のスクリプト)
  • Vueコンポーネントの見た目をそのままOGPに反映できる
  • 更新が必要な時だけスクリプトを実行すればよい

生成されたOGP画像の例

背景:OG Workerの限界

当初、Cloudflare WorkersでOGP画像を動的に生成していた。Satori + Resvgを使ってSVGを描画し、PNGに変換する方式だ。

しかし、デザイン原則ページのように複雑なUIをOGPにしたい場合、Workerで一から描画するのは現実的ではなかった。

  • Vueコンポーネントで既に良いUIがある
  • それをWorkerで再実装するのは二度手間
  • Satoriの制約(対応CSSが限られる)もある

解決策:Playwrightでスクリーンショット

アーキテクチャ

[Vueコンポーネント]
    ↓
[OGプレビューページ] ← 1200x630固定サイズでレンダリング
    ↓
[Playwright] ← スクリーンショット撮影
    ↓
[静的PNG画像] → public/og/pages/design-principles/{category}/{no}.png

OGプレビューページの作成

OGP用のプレビューページを pages/og-preview/design-principles/[category]/[no].vue に作成した。

<template>
  <div class="og-container" id="og-capture">
    <!-- ヘッダー -->
    <div class="og-header">
      <span class="og-breadcrumb">デザイン原則 / {{ categoryName }}</span>
    </div>

    <!-- コンポーネント -->
    <div class="og-content">
      <component :is="PrincipleComponent" v-if="PrincipleComponent" />
    </div>

    <!-- フッター -->
    <div class="og-footer">
      <span class="og-url">log.eurekapu.com</span>
    </div>
  </div>
</template>

<style scoped>
.og-container {
  width: 1200px;
  height: 630px;
  /* ... */
}
</style>

ポイント:

  • #og-capture をPlaywrightのセレクタとして使用
  • 1200x630ピクセルに固定(OGP標準サイズ)
  • layout: false でNuxtのレイアウトを無効化

Playwrightスクリプト

scripts/generate-design-principles-og.ts でスクリーンショットを撮影する。

import { chromium } from 'playwright'

const browser = await chromium.launch({ headless: true })
const context = await browser.newContext({
  viewport: { width: 1200, height: 800 },
  deviceScaleFactor: 2, // 高解像度
})
const page = await context.newPage()

await page.goto(url, { waitUntil: 'networkidle' })

// スタイルシートの読み込みを待機(重要)
await page.waitForFunction(() => {
  const sheets = document.styleSheets
  for (let i = 0; i < sheets.length; i++) {
    try { sheets[i].cssRules } catch (e) { return false }
  }
  return true
}, { timeout: 10000 })

const container = await page.waitForSelector('#og-capture')
await container.screenshot({ path: outputPath, type: 'png' })

注意点: waitForFunction でスタイルシートの読み込みを待つのが重要。これがないと、CSSが適用される前にスクリーンショットが撮られてしまう。

実行方法

# 開発サーバーを起動
pnpm dev

# 別ターミナルでスクリプト実行
npx tsx scripts/generate-design-principles-og.ts

静的画像の参照

生成した画像はNuxtページから参照する。

// pages/design-principles/[category]/[no].vue
const ogImageUrl = computed(() => {
  return `https://log.eurekapu.com/og/pages/design-principles/${category}/${no}.png`
})

useSeoMeta({
  ogImage: ogImageUrl.value,
})

OG Workerの削除

静的画像に移行したため、OG Workerのdesign-principlesハンドラーは削除した。

// apps/workers/og/src/index.ts
// 以下のルートを削除
// if (url.pathname.startsWith('/og/pages/design-principles/')) {
//   return handleDesignPrinciplesOgImage(url, env)
// }

まとめ

項目OG Worker(動的)Playwright(静的)
実装コスト高(UIを再実装)低(既存コンポーネント流用)
更新方法自動手動スクリプト
ビルド時間影響なし影響なし
柔軟性Satoriの制約ありVueの全機能が使える

Vueコンポーネントで既にUIを持っている場合、Playwrightでスクリーンショットを撮る方式が効率的だ。ビルド時間への影響もなく、必要な時だけ手動で更新できる。