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.
199 lines
4.8 KiB
TypeScript
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);
|
|
}
|