docs: add OG image generation workflow + Astro template

New workflow guide covering Satori + resvg pattern for dynamic social
preview images. Includes production template, gotchas (font format,
static file shadowing), design variants, and testing approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Florian BRUNIAUX 2026-03-11 11:53:50 +01:00
parent fe28f89574
commit 2f83320bc7
4 changed files with 471 additions and 0 deletions

View file

@ -0,0 +1,259 @@
/**
* Dynamic OG Image Generator for Astro 5
*
* Generates a 1200x630 PNG at build time via Satori + resvg.
* Place this file at: src/pages/og-image.png.ts
*
* Dependencies:
* pnpm add satori @resvg/resvg-js @fontsource/inter
*
* Usage:
* The file is auto-served at /og-image.png
* Reference it from your Layout:
* <meta property="og:image" content="/og-image.png" />
*
* IMPORTANT: Delete any existing public/og-image.png
* Static files in public/ take priority over API routes in dev mode.
*/
import type { APIRoute } from 'astro'
import satori from 'satori'
import { Resvg } from '@resvg/resvg-js'
import { readdirSync, readFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
// ---------------------------------------------------------------------------
// Optional: count dynamic stats from your content at build time
// Remove if you don't need this
// ---------------------------------------------------------------------------
function countContentFiles(contentDir: string, extension = '.md'): number {
try {
let total = 0
const entries = readdirSync(contentDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const files = readdirSync(resolve(contentDir, entry.name))
total += files.filter(f => f.endsWith(extension)).length
} else if (entry.name.endsWith(extension)) {
total++
}
}
return total
} catch {
return 0
}
}
// ---------------------------------------------------------------------------
// Stats to display — edit these to match your project
// ---------------------------------------------------------------------------
function getStats() {
// Example: count quiz questions dynamically
const questionCount = countContentFiles(
resolve(__dirname, '../content/questions')
)
return [
{ value: questionCount > 0 ? `${questionCount}` : '200+', label: 'QUIZ QUESTIONS' },
{ value: '50+', label: 'TEMPLATES' },
{ value: '10k+', label: 'LINES OF DOCS' },
{ value: '500+', label: 'GITHUB STARS' },
]
}
// ---------------------------------------------------------------------------
// Stat card component (reused for each stat)
// ---------------------------------------------------------------------------
function statCard(value: string, label: string) {
return {
type: 'div',
props: {
style: {
background: '#161b22',
border: '1px solid #30363d',
borderRadius: '10px',
padding: '16px 24px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: '200px',
},
children: [
{
type: 'span',
props: {
style: { fontSize: '36px', fontWeight: 800, color: '#f0883e', lineHeight: 1.2 },
children: value,
},
},
{
type: 'span',
props: {
style: { fontSize: '13px', color: '#8b949e', letterSpacing: '0.1em', fontWeight: 500, marginTop: '4px' },
children: label,
},
},
],
},
}
}
// ---------------------------------------------------------------------------
// Main route handler
// ---------------------------------------------------------------------------
export const GET: APIRoute = () => {
const stats = getStats()
// Font: use local woff (not woff2) from @fontsource — satori requires woff1 or TTF
// woff2 and remote CDN URLs will fail silently or throw "Unsupported OpenType signature"
const fontPath = resolve(
__dirname,
'../../node_modules/@fontsource/inter/files/inter-latin-400-normal.woff'
)
const fontData: ArrayBuffer = readFileSync(fontPath).buffer as ArrayBuffer
const svg = satori(
{
type: 'div',
props: {
style: {
width: '1200px',
height: '630px',
background: 'linear-gradient(135deg, #0d1117 0%, #161b22 50%, #0d1117 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'Inter, sans-serif',
position: 'relative',
padding: '60px',
},
children: [
// Subtle dot grid overlay
{
type: 'div',
props: {
style: {
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
backgroundImage: 'radial-gradient(circle, #ffffff08 1px, transparent 1px)',
backgroundSize: '40px 40px',
},
},
},
// Top badge pill — edit label to match your project type
{
type: 'div',
props: {
style: {
background: '#21262d',
border: '1px solid #30363d',
borderRadius: '20px',
padding: '6px 16px',
color: '#e6edf3',
fontSize: '14px',
letterSpacing: '0.08em',
fontWeight: 600,
marginBottom: '24px',
},
children: 'FREE & OPEN SOURCE', // edit this
},
},
// Title block — two lines, first white, second orange
{
type: 'div',
props: {
style: { display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '12px' },
children: [
{
type: 'span',
props: {
style: { fontSize: '72px', fontWeight: 800, color: '#e6edf3', lineHeight: 1.1 },
children: 'Your Project', // edit this
},
},
{
type: 'span',
props: {
style: { fontSize: '72px', fontWeight: 800, color: '#f0883e', lineHeight: 1.1 },
children: 'Name Here', // edit this
},
},
],
},
},
// Subtitle
{
type: 'div',
props: {
style: { fontSize: '20px', color: '#8b949e', marginBottom: '40px', textAlign: 'center' },
children: 'Your project tagline goes here', // edit this
},
},
// Stats row
{
type: 'div',
props: {
style: { display: 'flex', gap: '16px', marginBottom: '40px' },
children: stats.map(s => statCard(s.value, s.label)),
},
},
// Author signature — bottom left
// Remove this block if you don't want it
{
type: 'div',
props: {
style: {
position: 'absolute',
bottom: '36px',
left: '48px',
display: 'flex',
alignItems: 'center',
gap: '14px',
},
children: [
{
type: 'span',
props: {
style: { fontSize: '28px', fontWeight: 800, color: '#c0522a', letterSpacing: '-0.02em' },
children: 'FB.', // your initials
},
},
{
type: 'span',
props: {
style: { fontSize: '16px', color: '#8b949e', fontWeight: 400 },
children: 'your-domain.com', // your domain
},
},
],
},
},
],
},
},
{
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: fontData, weight: 400, style: 'normal' }],
}
)
const resvg = new Resvg(svg as unknown as string, { fitTo: { mode: 'width', value: 1200 } })
const png = resvg.render().asPng()
return new Response(png.buffer as ArrayBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
},
})
}