spicebook/frontend/src/lib/og-renderer.tsx
Ryan Malloy c9116c5d86 Add SEO meta tags, OG image generation, and astro-icon integration
Wire astro-seo-meta for OG, Twitter Card, and canonical tags on all
pages. Add Satori + resvg dynamic OG image endpoints at /og/[id].png
with branded dark-theme cards. Replace inline SVGs with zero-JS
astro-icon rendering. SSR fetches use 5s AbortController timeout and
shared ID validation across all dynamic routes.
2026-02-14 13:15:52 -07:00

199 lines
4.8 KiB
TypeScript

import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
interface OgImageProps {
title: string;
description?: string;
engine?: string;
}
function loadFont(): ArrayBuffer {
// In prod: dist/client/fonts/ In dev: public/fonts/
const candidates = [
join(process.cwd(), 'dist', 'client', 'fonts', 'Inter-SemiBold.ttf'),
join(process.cwd(), 'public', 'fonts', 'Inter-SemiBold.ttf'),
];
for (const path of candidates) {
try {
return readFileSync(path).buffer as ArrayBuffer;
} catch {
// try next
}
}
throw new Error('Inter-SemiBold.ttf not found');
}
let fontData: ArrayBuffer | null = null;
function getFont(): ArrayBuffer {
if (!fontData) {
try {
fontData = loadFont();
} catch (err) {
console.error('[og-renderer] Font load failed:', err);
throw err;
}
}
return fontData;
}
// Small waveform logo — a sine-like path rendered as SVG text
function WaveformLogo() {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="40" height="40" rx="8" fill="#1e3a5f" />
<path
d="M6 20 Q10 8, 14 20 Q18 32, 22 20 Q26 8, 30 20 Q34 32, 38 20"
stroke="#60a5fa"
strokeWidth="2.5"
fill="none"
/>
</svg>
<span style={{ fontSize: '24px', color: '#94a3b8', fontWeight: 600 }}>
SpiceBook
</span>
</div>
);
}
export async function renderOgImage({
title,
description,
engine,
}: OgImageProps): Promise<Uint8Array> {
const svg = await satori(
<div
style={{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '60px',
backgroundColor: '#020617', // slate-950
fontFamily: 'Inter',
}}
>
{/* Top: branding */}
<WaveformLogo />
{/* Center: title + description */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div
style={{
fontSize: title.length > 40 ? '42px' : '52px',
fontWeight: 600,
color: '#f1f5f9', // slate-100
lineHeight: 1.2,
maxWidth: '1000px',
}}
>
{title}
</div>
{description && (
<div
style={{
fontSize: '24px',
color: '#94a3b8', // slate-400
lineHeight: 1.4,
maxWidth: '900px',
}}
>
{description.length > 120
? description.slice(0, 117) + '...'
: description}
</div>
)}
</div>
{/* Bottom: engine badge + accent bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{engine ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '18px',
color: '#64748b', // slate-500
}}
>
<div
style={{
display: 'flex',
padding: '6px 14px',
borderRadius: '6px',
backgroundColor: '#0f172a', // slate-900
border: '1px solid #1e293b', // slate-800
color: '#60a5fa', // blue-400
fontSize: '16px',
fontWeight: 600,
}}
>
{engine}
</div>
<span>circuit simulation</span>
</div>
) : (
<div style={{ display: 'flex' }} />
)}
<div
style={{
display: 'flex',
fontSize: '16px',
color: '#475569', // slate-600
}}
>
spicebook.warehack.ing
</div>
</div>
{/* Accent bar at very bottom */}
<div
style={{
display: 'flex',
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
height: '4px',
background: 'linear-gradient(90deg, #2563eb, #60a5fa, #2563eb)',
}}
/>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: getFont(),
weight: 600,
style: 'normal',
},
],
}
);
const resvg = new Resvg(svg, {
fitTo: { mode: 'width', value: 1200 },
});
const pngBuffer = resvg.render().asPng();
return new Uint8Array(pngBuffer);
}