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:
parent
877ef1a220
commit
31144617d7
36 changed files with 1182 additions and 53 deletions
239
server/utils/og-render.js
Normal file
239
server/utils/og-render.js
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue