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

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
}