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.
239 lines
6.6 KiB
JavaScript
239 lines
6.6 KiB
JavaScript
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
|
|
}
|