OG Worker拡張計画: Vueページ対応
結論(実績)
ビルド時間を5分22秒から3分31秒に短縮(1分51秒、約35%改善)
| 項目 | Before | After | 改善 |
|---|---|---|---|
| 総ビルド時間 | 5分22秒 | 3分31秒 | -1分51秒 |
| Generate static site | 175秒 | 150秒 | -25秒 |
| og-image prerender | 262件 | 0件 | -262件 |
実施内容
- OG Workerを拡張: Vueページ(coding-standards, jleague等)のOGP画像を動的生成
- defineOgImage削除: 全44ファイルからnuxt-og-imageのprerender呼び出しを削除
- usePageOgSignature導入: サーバサイドで署名付きOG Worker URLを生成
これにより、ビルド時のOGP画像生成が不要となり、初回アクセス時にWorkerが動的生成→R2キャッシュする方式に変更された。
背景・課題
現状のビルド時間内訳(5分22秒)
| フェーズ | 時間 |
|---|---|
| Install dependencies | 23秒 |
| Content processing | 18秒 |
| Vite Client build | 37秒 |
| Vite Server build | 53秒 |
| Prerendering | 175秒(約3分) |
Prerender内訳(1228ページ)
| カテゴリ | 数 | 説明 |
|---|---|---|
| blog-article | 392 | 記事HTML + _payload.json |
| og-image | 262 | OGP画像生成(削減対象) |
| coding-standards | 160 | 現時点で158ルール + ページ2件(index, viewer/index) |
| financial-quiz | 137 | クイズ + J.League + payload |
| その他 | 277 | docs, blog一覧, payload等 |
nuxt-og-imageが262件のOGP画像をビルド時にprerenderしている。
目標
- __og-image__のprerenderを0にする(262件削減)
- ビルド時間を4分以下に短縮
既存ファイルの場所
Worker関連
- Worker本体:
apps/workers/og/src/index.ts - wrangler.toml:
apps/workers/og/wrangler.toml
Nuxt側
- 既存のOG署名composable:
apps/web/app/composables/useOgSignature.ts - nuxt.config.ts:
apps/web/nuxt.config.ts
defineOgImageを使用しているファイル(44件)
以下のファイルからdefineOgImageを削除し、useOgSignatureに置き換える:
coding-standards(3件)
app/pages/coding-standards/index.vueapp/pages/coding-standards/viewer/index.vueapp/pages/coding-standards/viewer/[ruleId].vue
financial-quiz(13件)
app/pages/financial-quiz/index.vueapp/pages/financial-quiz/actual-consensus.vueapp/pages/financial-quiz/analyst-conservatism.vueapp/pages/financial-quiz/balance-sheet-test.vueapp/pages/financial-quiz/eps-per-scatter.vueapp/pages/financial-quiz/micron-eps-forecast.vueapp/pages/financial-quiz/nvidia-eps-forecast.vueapp/pages/financial-quiz/proportional-animation.vueapp/pages/financial-quiz/proportional-comparison.vueapp/pages/financial-quiz/quiz.vueapp/pages/financial-quiz/semiconductor-revenue.vueapp/pages/financial-quiz/waterfall-test.vueapp/pages/financial-quiz/jleague/index.vueapp/pages/financial-quiz/jleague/club/[club].vue
financial-data(4件)
app/pages/financial-data/index.vueapp/pages/financial-data/annual-revenue-chart.vueapp/pages/financial-data/hyperscale-capex.vueapp/pages/financial-data/nvidia-financial-chart.vue
blog(14件)
app/pages/blog/index.vueapp/pages/blog/ai-native-future-scenarios.vueapp/pages/blog/blood-medicine-world.vueapp/pages/blog/blood-type-basics.vueapp/pages/blog/brownian_motion_infographic.vueapp/pages/blog/company_chart_absolute.vueapp/pages/blog/data-value-breakthrough-analysis.vueapp/pages/blog/drawio-viewer-test.vueapp/pages/blog/investment-critical-point.vueapp/pages/blog/nvidia-moat.vueapp/pages/blog/openai-vs-google-considerations.vueapp/pages/blog/scenario4-tipping-point.vueapp/pages/blog/search_market_analysis.vueapp/pages/blog/tax-accountant-inquiry.vueapp/pages/blog/tax-consultation-form.vue
その他(10件)
app/pages/index.vueapp/pages/about.vueapp/pages/search.vueapp/pages/excel-viewer.vueapp/pages/animation-demo.vueapp/pages/animation-demo-1.vueapp/pages/animation-demo-2.vueapp/pages/flowchart/customer-type-identification.vue
解決策
OG Workerを拡張してVueページのOGP画像も生成する。
対象ページ
- coding-standards(158ルール)
- URL: /coding-standards/viewer/rule-X-X
- パラメータ: title
- jleague(62クラブ)
- URL: /financial-quiz/jleague/club/clubId
- パラメータ: clubName, clubColor
- その他Vueページ(44ページ)
- 各ページで個別にOG Worker URLを設定
アーキテクチャ
Before(現状)
Vueページ
└─ defineOgImage(component: OgImageTemplate, ...)
└─ nuxt-og-imageがビルド時にprerender(262件)
After(改善後)
Vueページ OG Worker
│ │
│ og:image URL を Worker に向ける │
│ /og/pages/coding-standards/... │
│ /og/pages/jleague/... │
└────────────────────────────────────────│
│
▼
┌─────────────────┐
│ R2キャッシュ確認 │
└────────┬────────┘
│
┌──────────────────┴──────────────────┐
│ │
▼ ▼
[HIT] R2から返す [MISS] 生成→R2保存→返す
設計詳細
キャッシュ無効化戦略
キャッシュキーに署名から算出したバージョンを含め、パラメータが変わった場合に自動的に新しい画像を生成する。
キャッシュキー形式:
pages/coding-standards/{ruleId}/{sig.slice(0,8)}.png
pages/jleague/{clubId}/{sig.slice(0,8)}.png
バージョンはWorker側で署名の先頭8文字から計算。URLにvパラメータは不要(署名から一意に決まる)。パラメータ(title, name, color)が変わればハッシュも変わるため、自動的にキャッシュが無効化される。
署名検証ルール
既存のブログ記事用署名と同じロジックを使用。
正規化ルール(Worker側・Nuxt側で同一実装):
function normalizeText(text: string): string {
return text.trim().replace(/\s+/g, ' ')
}
署名生成:
- ペイロード: type|id|title(またはtype|id|name|color)をパイプ区切り
- HMAC-SHA256でハッシュ化、先頭16文字を使用
サニタイズ(Worker側のみ、画像生成時):
- HTMLエスケープ(&, <, >, ", ')
- 長さ制限(title: 80文字、clubName: 40文字)
clubColorバリデーション(Worker側):
function isValidHexColor(color: string): boolean {
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color)
}
不正な形式の場合はデフォルト色(#1a1a2e)を使用。
nuxt-og-image無効化方針
defineOgImageを使用している全てのVueページから削除し、useHeadでOG Worker URLを設定する。nuxt-og-imageモジュール自体は残すが、prerenderされるページがなくなるため__og-image__は0件になる。
対象ファイル(44ファイル):
- coding-standards: 3ファイル(index, viewer/index, viewer/ruleId)
- financial-quiz: 約15ファイル(各クイズページ + jleague関連)
- その他: 約26ファイル(about, search, blog関連等)
OG Worker エンドポイント設計
1. coding-standards用
GET /og/pages/coding-standards/{ruleId}.png
?title=ルールのタイトル
&sig=署名(16文字)
デザイン: ピンク→青グラデーション、白カード、タイトル中央配置(既存OgImageTemplateと同じ)
2. jleague用
GET /og/pages/jleague/{clubId}.png
?name=クラブ名
&color=クラブカラー(hex、#RGB or #RRGGBB形式)
&sig=署名(16文字)
デザイン: クラブカラー→暗い青グラデーション、盾アイコン、クラブ名(既存JLeagueOgImageと同じ)
3. 汎用ページ用
GET /og/pages/general/{pageId}.png
?title=ページタイトル
&sig=署名(16文字)
デザイン: OgImageTemplateと同じ
実装コード
Worker (src/index.ts) 追加部分
// 正規化関数(既存と共通、Nuxt側と同一実装)
function normalizeText(text: string): string {
return text.trim().replace(/\s+/g, ' ')
}
// サニタイズ関数(既存と共通)
function sanitize(text: string, maxLen: number): string {
const escaped = text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
return escaped.length > maxLen ? escaped.slice(0, maxLen - 3) + '...' : escaped
}
// clubColorバリデーション(CSS注入防止)
function isValidHexColor(color: string): boolean {
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color)
}
// ページ用署名検証
async function verifyPageSignature(
params: { type: string; id: string; [key: string]: string },
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder()
// type別にペイロード構築
let payload: string
if (params.type === 'coding-standards' || params.type === 'general') {
payload = [params.type, params.id, normalizeText(params.title || '')].join('|')
} else if (params.type === 'jleague') {
payload = [params.type, params.id, normalizeText(params.name || ''), params.color || ''].join('|')
} else {
return false
}
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
const expected = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
return expected === signature
}
// 既存のルーティングに追加
if (url.pathname.startsWith('/og/pages/coding-standards/')) {
return handleCodingStandardsOgImage(url, env)
}
if (url.pathname.startsWith('/og/pages/jleague/')) {
return handleJLeagueOgImage(url, env)
}
if (url.pathname.startsWith('/og/pages/general/')) {
return handleGeneralPageOgImage(url, env)
}
// coding-standards OGP画像生成
async function handleCodingStandardsOgImage(url: URL, env: Env): Promise<Response> {
const match = url.pathname.match(/^\/og\/pages\/coding-standards\/(.+)\.png$/)
if (!match) return new Response('Invalid path', { status: 400 })
const ruleId = match[1]
const title = url.searchParams.get('title') || ruleId
const signature = url.searchParams.get('sig')
// 署名検証
if (!signature || !(await verifyPageSignature(
{ type: 'coding-standards', id: ruleId, title },
signature,
env.OG_SECRET
))) {
return new Response('Forbidden', { status: 403 })
}
// バージョンは署名から算出(URLパラメータに依存しない)
const version = signature.slice(0, 8)
// R2キャッシュ確認
const cacheKey = `pages/coding-standards/${ruleId}/${version}.png`
const cached = await env.OG_IMAGES.get(cacheKey)
if (cached) {
return new Response(cached.body, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-OG-Cache': 'HIT'
}
})
}
// 生成
const imageResponse = await generateOgImageTemplate(sanitize(title, 80))
const imageBuffer = await imageResponse.arrayBuffer()
// R2保存
await env.OG_IMAGES.put(cacheKey, imageBuffer, {
httpMetadata: { contentType: 'image/png' }
})
return new Response(imageBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-OG-Cache': 'MISS'
}
})
}
// JLeague OGP画像生成
async function handleJLeagueOgImage(url: URL, env: Env): Promise<Response> {
const match = url.pathname.match(/^\/og\/pages\/jleague\/(.+)\.png$/)
if (!match) return new Response('Invalid path', { status: 400 })
const clubId = match[1]
const clubName = url.searchParams.get('name') || clubId
const rawColor = url.searchParams.get('color') || '#1a1a2e'
const signature = url.searchParams.get('sig')
// clubColorバリデーション(CSS注入防止)
const clubColor = isValidHexColor(rawColor) ? rawColor : '#1a1a2e'
// 署名検証
if (!signature || !(await verifyPageSignature(
{ type: 'jleague', id: clubId, name: clubName, color: rawColor },
signature,
env.OG_SECRET
))) {
return new Response('Forbidden', { status: 403 })
}
// バージョンは署名から算出(URLパラメータに依存しない)
const version = signature.slice(0, 8)
// R2キャッシュ確認
const cacheKey = `pages/jleague/${clubId}/${version}.png`
const cached = await env.OG_IMAGES.get(cacheKey)
if (cached) {
return new Response(cached.body, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-OG-Cache': 'HIT'
}
})
}
// 生成(バリデーション済みclubColorを使用)
const imageResponse = await generateJLeagueOgImage(sanitize(clubName, 40), clubColor)
const imageBuffer = await imageResponse.arrayBuffer()
// R2保存
await env.OG_IMAGES.put(cacheKey, imageBuffer, {
httpMetadata: { contentType: 'image/png' }
})
return new Response(imageBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-OG-Cache': 'MISS'
}
})
}
// OgImageTemplate相当のデザイン
async function generateOgImageTemplate(title: string): Promise<ImageResponse> {
const fontData = await loadGoogleFont({
family: 'Noto Sans JP',
weight: 700,
text: title + 'Eurekapu.com'
})
const html = `
<div style="display: flex; width: 1200px; height: 630px; justify-content: center; align-items: center; padding: 40px; background: linear-gradient(135deg, #ff0080 0%, #0066ff 100%); font-family: Noto Sans JP, sans-serif;">
<div style="display: flex; flex-direction: column; justify-content: space-between; width: 1100px; height: 530px; background: white; border-radius: 24px; padding: 50px 60px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);">
<div style="display: flex; flex: 1; align-items: center; justify-content: center; font-size: 52px; font-weight: 700; color: #1a1a1a; line-height: 1.4; text-align: center;">
${title}
</div>
<div style="display: flex; justify-content: flex-end; align-items: center;">
<div style="display: flex; font-size: 24px; color: #888; font-weight: 500;">
Eurekapu.com
</div>
</div>
</div>
</div>
`
return new ImageResponse(html, {
width: 1200,
height: 630,
fonts: [{ name: 'Noto Sans JP', data: fontData, weight: 700, style: 'normal' }]
})
}
// JLeagueOgImage相当のデザイン
async function generateJLeagueOgImage(clubName: string, clubColor: string): Promise<ImageResponse> {
const fontData = await loadGoogleFont({
family: 'Noto Sans JP',
weight: 700,
text: clubName + '財務データ分析J.LEAGUElog.eurekapu.com'
})
const html = `
<div style="display: flex; width: 1200px; height: 630px; flex-direction: column; justify-content: center; align-items: center; padding: 60px; position: relative; font-family: Noto Sans JP, sans-serif; background: linear-gradient(135deg, ${clubColor} 0%, #1a1a2e 100%);">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="width: 120px; height: 120px; margin-bottom: 32px;">
<path d="M50 5 L90 20 L90 50 Q90 80 50 95 Q10 80 10 50 L10 20 Z" fill="${clubColor}" stroke="white" stroke-width="3"/>
<circle cx="50" cy="50" r="20" fill="white"/>
<path d="M50 30 L55 42 L68 42 L58 50 L62 63 L50 55 L38 63 L42 50 L32 42 L45 42 Z" fill="${clubColor}"/>
</svg>
<div style="display: flex; font-size: 64px; font-weight: 700; color: white; text-align: center; line-height: 1.3; max-width: 1000px; text-shadow: 2px 2px 8px rgba(0,0,0,0.5);">
${clubName}
</div>
<div style="display: flex; font-size: 32px; font-weight: 500; color: rgba(255, 255, 255, 0.9); margin-top: 24px; text-align: center;">
財務データ分析
</div>
<div style="display: flex; position: absolute; bottom: 40px; right: 60px; font-size: 24px; color: rgba(255, 255, 255, 0.8); font-weight: 500;">
log.eurekapu.com
</div>
<div style="display: flex; position: absolute; top: 40px; left: 60px; font-size: 20px; color: rgba(255, 255, 255, 0.7); font-weight: 600; letter-spacing: 2px;">
J.LEAGUE
</div>
</div>
`
return new ImageResponse(html, {
width: 1200,
height: 630,
fonts: [{ name: 'Noto Sans JP', data: fontData, weight: 700, style: 'normal' }]
})
}
wrangler.toml 追加
routes = [
{ pattern = "log.eurekapu.com/share/*", zone_name = "eurekapu.com" },
{ pattern = "log.eurekapu.com/og/blog/*", zone_name = "eurekapu.com" },
{ pattern = "log.eurekapu.com/og/pages/*", zone_name = "eurekapu.com" }
]
Nuxt側 composable追加 (app/composables/useOgSignature.server.ts)
重要: このプロジェクトはcloudflare-pages-static(静的サイト)前提です。静的ビルドでは動的APIは動作しないため、.server.tsサフィックスを使用してビルド時(SSG)にサーバサイドで署名を生成します。
.server.tsサフィックスにより、このファイルはサーバサイドでのみバンドルされる- SSGビルド時にサーバサイドで実行され、結果がHTMLに埋め込まれる
- クライアントバンドルには含まれない(シークレットの漏洩防止)
// app/composables/useOgSignature.server.ts
// .server.ts サフィックスにより、このファイルはサーバサイドでのみバンドルされる
import { createHmac } from 'node:crypto'
// 正規化関数(Worker側と同一実装)
function normalizeText(text: string): string {
return text.trim().replace(/\s+/g, ' ')
}
// 署名生成(サーバサイド専用)
function generatePageSignature(params: {
type: string
id: string
title?: string
name?: string
color?: string
}): string {
// useRuntimeConfigはサーバサイドでのみOG_SECRETを返す
const config = useRuntimeConfig()
const secret = config.ogSecret
if (!secret) throw new Error('OG_SECRET is not configured in runtimeConfig')
let payload: string
if (params.type === 'coding-standards' || params.type === 'general') {
payload = [params.type, params.id, normalizeText(params.title || '')].join('|')
} else if (params.type === 'jleague') {
payload = [params.type, params.id, normalizeText(params.name || ''), params.color || ''].join('|')
} else {
throw new Error('Unknown type')
}
return createHmac('sha256', secret).update(payload).digest('hex').slice(0, 16)
}
// ページ用OGP画像URL生成(サーバサイド専用)
export function useOgSignature() {
return {
generatePageOgImageUrl(params: {
type: 'coding-standards' | 'jleague' | 'general'
id: string
title?: string
clubName?: string
clubColor?: string
}): string | null {
// 必須パラメータのバリデーション
if (params.type === 'coding-standards' || params.type === 'general') {
if (!params.title) return null
} else if (params.type === 'jleague') {
if (!params.clubName || !params.clubColor) return null
}
const baseUrl = 'https://log.eurekapu.com/og/pages'
if (params.type === 'coding-standards') {
const sig = generatePageSignature({ type: 'coding-standards', id: params.id, title: params.title })
return `${baseUrl}/coding-standards/${params.id}.png?title=${encodeURIComponent(params.title!)}&sig=${sig}`
}
if (params.type === 'jleague') {
const sig = generatePageSignature({ type: 'jleague', id: params.id, name: params.clubName, color: params.clubColor })
return `${baseUrl}/jleague/${params.id}.png?name=${encodeURIComponent(params.clubName!)}&color=${encodeURIComponent(params.clubColor!)}&sig=${sig}`
}
// general
const sig = generatePageSignature({ type: 'general', id: params.id, title: params.title })
return `${baseUrl}/general/${params.id}.png?title=${encodeURIComponent(params.title!)}&sig=${sig}`
}
}
}
nuxt.config.tsにruntimeConfig追加:
export default defineNuxtConfig({
runtimeConfig: {
ogSecret: process.env.OG_SECRET, // サーバサイドのみ
}
})
Vueページ側の変更例
注意: useOgSignature()は.server.tsファイルのためサーバサイド専用。クライアント側で呼ばれるとビルドエラーになるため、useAsyncDataのserver: trueオプションで包み、サーバサイドでのみ実行されることを保証する。
ruleId.vue
// Before
defineOgImage({
component: 'OgImageTemplate',
title: computed(() => currentRule.value?.title || 'JavaScriptコーディング規約')
})
// After
const route = useRoute()
const ruleIdValue = String(route.params.ruleId)
// サーバサイドでのみ実行(SSGビルド時に署名生成、結果はHTMLに埋め込まれる)
const { data: ogImageUrl } = await useAsyncData(
`og-coding-standards-${ruleIdValue}`,
() => {
const { generatePageOgImageUrl } = useOgSignature()
if (!currentRule.value) return null
return generatePageOgImageUrl({
type: 'coding-standards',
id: ruleIdValue,
title: currentRule.value.title
})
},
{ server: true, lazy: false }
)
useHead({
meta: computed(() => ogImageUrl.value ? [
{ property: 'og:image', content: ogImageUrl.value },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:image', content: ogImageUrl.value },
] : [])
})
club.vue
// Before
defineOgImage({
component: 'JLeagueOgImage',
clubName: selectedClub.value?.name ?? 'Jリーグ',
clubColor: clubColor.value,
})
// After
const route = useRoute()
const clubIdValue = String(route.params.club)
// サーバサイドでのみ実行(SSGビルド時に署名生成、結果はHTMLに埋め込まれる)
const { data: ogImageUrl } = await useAsyncData(
`og-jleague-${clubIdValue}`,
() => {
const { generatePageOgImageUrl } = useOgSignature()
if (!selectedClub.value) return null
return generatePageOgImageUrl({
type: 'jleague',
id: clubIdValue,
clubName: selectedClub.value.name,
clubColor: clubColor.value
})
},
{ server: true, lazy: false }
)
useHead({
meta: computed(() => ogImageUrl.value ? [
{ property: 'og:image', content: ogImageUrl.value },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:image', content: ogImageUrl.value },
] : [])
})
補足: SSR対応プロジェクトの場合
SSR対応プロジェクト(cloudflare-pagesなど動的サーバーを使用する場合)では、以下の方法も可能です:
server/api/og-signature.get.tsを作成し、API経由で署名を取得- Vueページ側で
useFetch('/api/og-signature', ...)を使用
ただし、本プロジェクトは静的サイト前提のため、上記の.server.ts方式を採用しています。
実装手順
- Worker実装
- normalizeText, sanitize, isValidHexColor関数追加
- verifyPageSignature関数追加
- /og/pages/coding-standards/ エンドポイント追加
- /og/pages/jleague/ エンドポイント追加
- /og/pages/general/ エンドポイント追加
- R2キャッシュ処理(署名からバージョン算出)
- wrangler.toml更新(ルート追加)
- ローカルテスト(wrangler dev --remote)
- Workerデプロイ
- Nuxt側実装
- nuxt.config.tsにruntimeConfig.ogSecret追加(既に設定済み)
- app/composables/usePageOgSignature.ts作成(動的インポートで署名生成)
- ruleId.vue修正(defineOgImage削除、usePageOgSignature + useHead追加)
- club.vue修正(defineOgImage削除、usePageOgSignature + useHead追加)
- その他Vueページ修正(42ファイル)
- 全てのdefineOgImage呼び出しを削除
- ビルドテスト
- 本番デプロイ・動作確認(2026-01-01 完了)
期待効果 → 実績
| 項目 | Before | 期待 | 実績 |
|---|---|---|---|
| og-image prerender | 262件 | 0件 | 0件 |
| Generate static site | 175秒 | 約100秒 | 150秒 |
| 総ビルド時間 | 5分22秒 | 約4分 | 3分31秒 |
期待を上回る結果となった。OGP画像のprerender削除に加え、全体的なビルド最適化が効いている。
リスク・注意点
- 初回アクセス遅延: キャッシュなし初回は1-2秒かかる(SNSクローラーには影響なし)
- 44ファイルの修正: Vueページの修正が多い(一括置換で対応可能)
- デザイン一致: Worker側のHTML/CSSがVueコンポーネントと完全一致する必要あり
設計判断
Q&A
Q: OGPのキャッシュ無効化はupdatedAtなどのバージョンパラメータで行う想定ですか?
A: はい。署名のハッシュ値(8文字)をバージョンとして使用。パラメータ(title, name, color)が変わればハッシュも変わるため、自動的にキャッシュが無効化される。
Q: ruleId/clubIdの値は常に固定で、将来的に改名・統合されない前提ですか?
A: 基本固定。改名する場合はキャッシュキーが変わるだけで問題なし。統合の場合は古いIDのキャッシュは放置(自然に期限切れ or 手動削除)。
Q: nuxt-og-imageは残す前提ですか?
A: モジュール自体は残すが、defineOgImageを全て削除するため__og-image__のprerenderは0件になる。将来的にモジュール削除も検討可能。