feat(seo): site meta composable + Open Graph image generation

Adds `useSiteMeta()` composable that wraps useSeoMeta with site defaults
(title template, canonical URL, og/twitter image, og:site_name) and
absolute-URL handling via runtimeConfig.public.appUrl.

Adds /og/events/[slug].png route that renders per-event OG images via
satori + @resvg/resvg-js, cached on disk by slug + updatedAt. Bundles
Brygada 1918 + Commit Mono fonts as server assets, ships a fallback
default.png, and patches @shuding/opentype.js via patch-package.

Converts ~25 pages from useHead to useSiteMeta and adds noindex on
private/auth/admin pages.
This commit is contained in:
Jennie Robinson Faber 2026-05-21 17:50:34 +01:00
parent 877ef1a220
commit 31144617d7
36 changed files with 1182 additions and 53 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,29 @@
// GET /og/events/[slug].png — generated Open Graph image for an event.
// Cached on disk by slug + event.updatedAt so any admin edit busts the cache.
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing slug' })
}
// .png suffix is part of the route filename, but slugs in DB don't include it.
const cleanSlug = slug.replace(/\.png$/, '')
const eventDoc = await loadPublicEvent(event, cleanSlug, { lean: true })
const key = eventCacheKey(eventDoc)
let png = await getCachedOG(key)
if (!png) {
png = await renderEventOG(eventDoc)
await setCachedOG(key, png)
}
setHeader(event, 'Content-Type', 'image/png')
setHeader(
event,
'Cache-Control',
'public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400'
)
return png
})

37
server/utils/og-cache.js Normal file
View file

@ -0,0 +1,37 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const CACHE_DIR = resolve(__dirname, '../../.cache/og')
let ensured = false
async function ensureCacheDir() {
if (ensured) return
await mkdir(CACHE_DIR, { recursive: true })
ensured = true
}
// Returns cached PNG buffer or null. Key must be safe for a filename.
export async function getCachedOG(key) {
await ensureCacheDir()
try {
return await readFile(resolve(CACHE_DIR, `${key}.png`))
} catch {
return null
}
}
export async function setCachedOG(key, buffer) {
await ensureCacheDir()
await writeFile(resolve(CACHE_DIR, `${key}.png`), buffer)
}
// Build a safe, deterministic cache key from event slug + updatedAt timestamp.
// Slug is already URL-safe; updatedAt busts the cache on any edit.
export function eventCacheKey(event) {
const slug = String(event.slug || event._id).replace(/[^a-zA-Z0-9_-]/g, '_')
const stamp = event.updatedAt ? new Date(event.updatedAt).getTime() : 0
return `event-${slug}-${stamp}`
}

239
server/utils/og-render.js Normal file
View file

@ -0,0 +1,239 @@
import satori from 'satori'
import { Resvg } from '@resvg/resvg-js'
let fontCache
async function loadFonts() {
if (fontCache) return fontCache
const storage = useStorage('assets:server')
const [brygadaRegular, brygadaSemiBold, commitRegular, commitBold] =
await Promise.all([
storage.getItemRaw('fonts/Brygada1918-Regular.ttf'),
storage.getItemRaw('fonts/Brygada1918-SemiBold.ttf'),
storage.getItemRaw('fonts/commit-mono-400.woff'),
storage.getItemRaw('fonts/commit-mono-600.woff')
])
fontCache = [
{ name: 'Brygada 1918', data: brygadaRegular, weight: 400, style: 'normal' },
{ name: 'Brygada 1918', data: brygadaSemiBold, weight: 600, style: 'normal' },
{ name: 'Commit Mono', data: commitRegular, weight: 400, style: 'normal' },
{ name: 'Commit Mono', data: commitBold, weight: 600, style: 'normal' }
]
return fontCache
}
// Insert Cloudinary transform params (resize, quality) into the URL so we
// embed a smaller image and keep the resulting PNG under a reasonable size.
function withCloudinaryTransform(url) {
if (typeof url !== 'string') return url
return url.replace(
/\/image\/upload\/(?!w_|h_|c_|q_|f_)/,
'/image/upload/w_1200,h_340,c_fill,q_auto,f_jpg/'
)
}
async function fetchImageAsDataUrl(url) {
if (!url) return null
try {
const transformed = withCloudinaryTransform(url)
const res = await fetch(transformed)
if (!res.ok) return null
const buf = Buffer.from(await res.arrayBuffer())
const contentType = res.headers.get('content-type') || 'image/jpeg'
return `data:${contentType};base64,${buf.toString('base64')}`
} catch {
return null
}
}
function formatEventDate(startDate, timeZone) {
const date = new Date(startDate)
const dateFmt = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
timeZone
}).format(date)
const timeFmt = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
timeZone
}).format(date)
return `${dateFmt} · ${timeFmt}`
}
// Design tokens lifted from app/assets/css/main.css (light palette).
// Hardcoded here because satori doesn't resolve CSS variables.
const COLOR = {
bg: '#f4efe4',
textBright: '#1a1008',
textDim: '#5a5040',
candle: '#7a5a10',
border: '#b8a880',
parch: '#2a2015',
parchText: '#ede4d0',
parchAccent: '#c4a448'
}
function buildTemplate({ title, dateLabel, imageDataUrl }) {
const hasImage = Boolean(imageDataUrl)
const imageBlock = hasImage
? {
type: 'div',
props: {
style: {
display: 'flex',
width: '100%',
height: 340,
overflow: 'hidden',
borderBottom: `2px dashed ${COLOR.border}`
},
children: {
type: 'img',
props: {
src: imageDataUrl,
width: 1200,
height: 340,
style: { width: '100%', height: '100%', objectFit: 'cover' }
}
}
}
}
: {
type: 'div',
props: {
style: {
display: 'flex',
width: '100%',
height: 340,
background: COLOR.parch,
alignItems: 'center',
justifyContent: 'center',
borderBottom: `2px dashed ${COLOR.border}`
},
children: {
type: 'div',
props: {
style: {
fontFamily: 'Brygada 1918',
fontSize: 64,
fontWeight: 600,
color: COLOR.parchText,
letterSpacing: '-0.01em'
},
children: 'GHOST GUILD'
}
}
}
}
return {
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
width: 1200,
height: 630,
background: COLOR.bg,
fontFamily: 'Commit Mono'
},
children: [
imageBlock,
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
padding: '36px 60px 48px 60px',
flex: 1,
justifyContent: 'space-between'
},
children: [
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
gap: 18
},
children: [
{
type: 'div',
props: {
style: {
fontFamily: 'Commit Mono',
fontSize: 18,
fontWeight: 600,
color: COLOR.candle,
letterSpacing: '0.12em',
textTransform: 'uppercase'
},
children: 'Ghost Guild · Event'
}
},
{
type: 'div',
props: {
style: {
fontFamily: 'Brygada 1918',
fontSize: 56,
fontWeight: 600,
color: COLOR.textBright,
lineHeight: 1.1,
letterSpacing: '-0.01em',
maxHeight: 140,
overflow: 'hidden'
},
children: title
}
}
]
}
},
{
type: 'div',
props: {
style: {
fontFamily: 'Commit Mono',
fontSize: 22,
color: COLOR.textDim
},
children: dateLabel
}
}
]
}
}
]
}
}
}
export async function renderEventOG(event) {
const fonts = await loadFonts()
const imageDataUrl = event.featureImage?.url
? await fetchImageAsDataUrl(event.featureImage.url)
: null
const dateLabel = formatEventDate(
event.startDate,
event.displayTimezone || 'America/Toronto'
)
const svg = await satori(
buildTemplate({ title: event.title, dateLabel, imageDataUrl }),
{ width: 1200, height: 630, fonts }
)
const png = new Resvg(svg, {
fitTo: { mode: 'width', value: 1200 }
})
.render()
.asPng()
return png
}