Skip to content

Commit 2f83320

Browse files
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>
1 parent fe28f89 commit 2f83320

File tree

4 files changed

+471
-0
lines changed

4 files changed

+471
-0
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

77
## [Unreleased]
88

9+
## [3.34.1] - 2026-03-11
10+
11+
### Added
12+
13+
- **`guide/workflows/og-image-generation.md`** — New workflow guide for generating dynamic OG images at build time using Satori and resvg in Astro 5. Covers setup, font format requirements (woff1 only), static file shadowing gotcha, dynamic stat counting from content directories, testing with opengraph.xyz / LinkedIn Post Inspector, and three design variants (stats grid, personal branding, terminal badge). Includes CI size check pattern.
14+
15+
- **`examples/scripts/og-image-astro.ts`** — Production-ready template for `src/pages/og-image.png.ts`. Drop into any Astro 5 project. Auto-serves at `/og-image.png`, counts content files dynamically, includes stat card component, author signature, and inline comments on every gotcha.
16+
917
## [3.34.0] - 2026-03-11
1018

1119
### Added

examples/scripts/og-image-astro.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* Dynamic OG Image Generator for Astro 5
3+
*
4+
* Generates a 1200x630 PNG at build time via Satori + resvg.
5+
* Place this file at: src/pages/og-image.png.ts
6+
*
7+
* Dependencies:
8+
* pnpm add satori @resvg/resvg-js @fontsource/inter
9+
*
10+
* Usage:
11+
* The file is auto-served at /og-image.png
12+
* Reference it from your Layout:
13+
* <meta property="og:image" content="/og-image.png" />
14+
*
15+
* IMPORTANT: Delete any existing public/og-image.png
16+
* Static files in public/ take priority over API routes in dev mode.
17+
*/
18+
19+
import type { APIRoute } from 'astro'
20+
import satori from 'satori'
21+
import { Resvg } from '@resvg/resvg-js'
22+
import { readdirSync, readFileSync } from 'fs'
23+
import { resolve, dirname } from 'path'
24+
import { fileURLToPath } from 'url'
25+
26+
const __dirname = dirname(fileURLToPath(import.meta.url))
27+
28+
// ---------------------------------------------------------------------------
29+
// Optional: count dynamic stats from your content at build time
30+
// Remove if you don't need this
31+
// ---------------------------------------------------------------------------
32+
function countContentFiles(contentDir: string, extension = '.md'): number {
33+
try {
34+
let total = 0
35+
const entries = readdirSync(contentDir, { withFileTypes: true })
36+
for (const entry of entries) {
37+
if (entry.isDirectory()) {
38+
const files = readdirSync(resolve(contentDir, entry.name))
39+
total += files.filter(f => f.endsWith(extension)).length
40+
} else if (entry.name.endsWith(extension)) {
41+
total++
42+
}
43+
}
44+
return total
45+
} catch {
46+
return 0
47+
}
48+
}
49+
50+
// ---------------------------------------------------------------------------
51+
// Stats to display — edit these to match your project
52+
// ---------------------------------------------------------------------------
53+
function getStats() {
54+
// Example: count quiz questions dynamically
55+
const questionCount = countContentFiles(
56+
resolve(__dirname, '../content/questions')
57+
)
58+
59+
return [
60+
{ value: questionCount > 0 ? `${questionCount}` : '200+', label: 'QUIZ QUESTIONS' },
61+
{ value: '50+', label: 'TEMPLATES' },
62+
{ value: '10k+', label: 'LINES OF DOCS' },
63+
{ value: '500+', label: 'GITHUB STARS' },
64+
]
65+
}
66+
67+
// ---------------------------------------------------------------------------
68+
// Stat card component (reused for each stat)
69+
// ---------------------------------------------------------------------------
70+
function statCard(value: string, label: string) {
71+
return {
72+
type: 'div',
73+
props: {
74+
style: {
75+
background: '#161b22',
76+
border: '1px solid #30363d',
77+
borderRadius: '10px',
78+
padding: '16px 24px',
79+
display: 'flex',
80+
flexDirection: 'column',
81+
alignItems: 'center',
82+
minWidth: '200px',
83+
},
84+
children: [
85+
{
86+
type: 'span',
87+
props: {
88+
style: { fontSize: '36px', fontWeight: 800, color: '#f0883e', lineHeight: 1.2 },
89+
children: value,
90+
},
91+
},
92+
{
93+
type: 'span',
94+
props: {
95+
style: { fontSize: '13px', color: '#8b949e', letterSpacing: '0.1em', fontWeight: 500, marginTop: '4px' },
96+
children: label,
97+
},
98+
},
99+
],
100+
},
101+
}
102+
}
103+
104+
// ---------------------------------------------------------------------------
105+
// Main route handler
106+
// ---------------------------------------------------------------------------
107+
export const GET: APIRoute = () => {
108+
const stats = getStats()
109+
110+
// Font: use local woff (not woff2) from @fontsource — satori requires woff1 or TTF
111+
// woff2 and remote CDN URLs will fail silently or throw "Unsupported OpenType signature"
112+
const fontPath = resolve(
113+
__dirname,
114+
'../../node_modules/@fontsource/inter/files/inter-latin-400-normal.woff'
115+
)
116+
const fontData: ArrayBuffer = readFileSync(fontPath).buffer as ArrayBuffer
117+
118+
const svg = satori(
119+
{
120+
type: 'div',
121+
props: {
122+
style: {
123+
width: '1200px',
124+
height: '630px',
125+
background: 'linear-gradient(135deg, #0d1117 0%, #161b22 50%, #0d1117 100%)',
126+
display: 'flex',
127+
flexDirection: 'column',
128+
alignItems: 'center',
129+
justifyContent: 'center',
130+
fontFamily: 'Inter, sans-serif',
131+
position: 'relative',
132+
padding: '60px',
133+
},
134+
children: [
135+
// Subtle dot grid overlay
136+
{
137+
type: 'div',
138+
props: {
139+
style: {
140+
position: 'absolute',
141+
top: 0, left: 0, right: 0, bottom: 0,
142+
backgroundImage: 'radial-gradient(circle, #ffffff08 1px, transparent 1px)',
143+
backgroundSize: '40px 40px',
144+
},
145+
},
146+
},
147+
148+
// Top badge pill — edit label to match your project type
149+
{
150+
type: 'div',
151+
props: {
152+
style: {
153+
background: '#21262d',
154+
border: '1px solid #30363d',
155+
borderRadius: '20px',
156+
padding: '6px 16px',
157+
color: '#e6edf3',
158+
fontSize: '14px',
159+
letterSpacing: '0.08em',
160+
fontWeight: 600,
161+
marginBottom: '24px',
162+
},
163+
children: 'FREE & OPEN SOURCE', // edit this
164+
},
165+
},
166+
167+
// Title block — two lines, first white, second orange
168+
{
169+
type: 'div',
170+
props: {
171+
style: { display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '12px' },
172+
children: [
173+
{
174+
type: 'span',
175+
props: {
176+
style: { fontSize: '72px', fontWeight: 800, color: '#e6edf3', lineHeight: 1.1 },
177+
children: 'Your Project', // edit this
178+
},
179+
},
180+
{
181+
type: 'span',
182+
props: {
183+
style: { fontSize: '72px', fontWeight: 800, color: '#f0883e', lineHeight: 1.1 },
184+
children: 'Name Here', // edit this
185+
},
186+
},
187+
],
188+
},
189+
},
190+
191+
// Subtitle
192+
{
193+
type: 'div',
194+
props: {
195+
style: { fontSize: '20px', color: '#8b949e', marginBottom: '40px', textAlign: 'center' },
196+
children: 'Your project tagline goes here', // edit this
197+
},
198+
},
199+
200+
// Stats row
201+
{
202+
type: 'div',
203+
props: {
204+
style: { display: 'flex', gap: '16px', marginBottom: '40px' },
205+
children: stats.map(s => statCard(s.value, s.label)),
206+
},
207+
},
208+
209+
// Author signature — bottom left
210+
// Remove this block if you don't want it
211+
{
212+
type: 'div',
213+
props: {
214+
style: {
215+
position: 'absolute',
216+
bottom: '36px',
217+
left: '48px',
218+
display: 'flex',
219+
alignItems: 'center',
220+
gap: '14px',
221+
},
222+
children: [
223+
{
224+
type: 'span',
225+
props: {
226+
style: { fontSize: '28px', fontWeight: 800, color: '#c0522a', letterSpacing: '-0.02em' },
227+
children: 'FB.', // your initials
228+
},
229+
},
230+
{
231+
type: 'span',
232+
props: {
233+
style: { fontSize: '16px', color: '#8b949e', fontWeight: 400 },
234+
children: 'your-domain.com', // your domain
235+
},
236+
},
237+
],
238+
},
239+
},
240+
],
241+
},
242+
},
243+
{
244+
width: 1200,
245+
height: 630,
246+
fonts: [{ name: 'Inter', data: fontData, weight: 400, style: 'normal' }],
247+
}
248+
)
249+
250+
const resvg = new Resvg(svg as unknown as string, { fitTo: { mode: 'width', value: 1200 } })
251+
const png = resvg.render().asPng()
252+
253+
return new Response(png.buffer as ArrayBuffer, {
254+
headers: {
255+
'Content-Type': 'image/png',
256+
'Cache-Control': 'public, max-age=86400',
257+
},
258+
})
259+
}

guide/workflows/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ Convert design mockups (Figma, wireframes) into working code.
8181

8282
**When to use**: Frontend development, UI implementation, design system work
8383

84+
### [OG Image Generation](./og-image-generation.md)
85+
86+
Generate social preview images dynamically at build time with Satori and resvg.
87+
88+
**When to use**: Astro projects, keeping social previews accurate without maintaining static PNGs
89+
8490
### [PDF Generation](./pdf-generation.md)
8591

8692
Generate professional PDFs using Quarto/Typst with Claude Code.
@@ -166,6 +172,7 @@ Multi-session task tracking with TodoWrite, tasks API, and context persistence a
166172
| **New project from template** | [Skeleton Projects](./skeleton-projects.md) |
167173
| **Team AI instructions** | [Team AI Instructions](./team-ai-instructions.md) |
168174
| **Documentation** | [PDF Generation](./pdf-generation.md) |
175+
| **Social previews** | [OG Image Generation](./og-image-generation.md) |
169176
| **Conference talk from raw material** | [Talk Preparation Pipeline](./talk-pipeline.md) |
170177
| **Audio feedback** | [TTS Setup](./tts-setup.md) |
171178
| **Multi-agent tasks** | [Agent Teams](./agent-teams.md) |

0 commit comments

Comments
 (0)