From 2f83320bc7ff51fe70dcb8cc48cf1347c9ec52ab Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Wed, 11 Mar 2026 11:53:50 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 8 + examples/scripts/og-image-astro.ts | 259 +++++++++++++++++++++++++ guide/workflows/README.md | 7 + guide/workflows/og-image-generation.md | 197 +++++++++++++++++++ 4 files changed, 471 insertions(+) create mode 100644 examples/scripts/og-image-astro.ts create mode 100644 guide/workflows/og-image-generation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6caf0..a986f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [3.34.1] - 2026-03-11 + +### Added + +- **`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. + +- **`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. + ## [3.34.0] - 2026-03-11 ### Added diff --git a/examples/scripts/og-image-astro.ts b/examples/scripts/og-image-astro.ts new file mode 100644 index 0000000..1f4f381 --- /dev/null +++ b/examples/scripts/og-image-astro.ts @@ -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: + * + * + * 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', + }, + }) +} diff --git a/guide/workflows/README.md b/guide/workflows/README.md index 7aef374..6dae280 100644 --- a/guide/workflows/README.md +++ b/guide/workflows/README.md @@ -81,6 +81,12 @@ Convert design mockups (Figma, wireframes) into working code. **When to use**: Frontend development, UI implementation, design system work +### [OG Image Generation](./og-image-generation.md) + +Generate social preview images dynamically at build time with Satori and resvg. + +**When to use**: Astro projects, keeping social previews accurate without maintaining static PNGs + ### [PDF Generation](./pdf-generation.md) 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 | **New project from template** | [Skeleton Projects](./skeleton-projects.md) | | **Team AI instructions** | [Team AI Instructions](./team-ai-instructions.md) | | **Documentation** | [PDF Generation](./pdf-generation.md) | +| **Social previews** | [OG Image Generation](./og-image-generation.md) | | **Conference talk from raw material** | [Talk Preparation Pipeline](./talk-pipeline.md) | | **Audio feedback** | [TTS Setup](./tts-setup.md) | | **Multi-agent tasks** | [Agent Teams](./agent-teams.md) | diff --git a/guide/workflows/og-image-generation.md b/guide/workflows/og-image-generation.md new file mode 100644 index 0000000..84c6a97 --- /dev/null +++ b/guide/workflows/og-image-generation.md @@ -0,0 +1,197 @@ +# Dynamic OG Image Generation with Astro + +Generate social preview images automatically at build time instead of maintaining stale static PNGs. Every share on Twitter/X, LinkedIn, or Slack will show accurate, up-to-date stats. + +## Why bother + +Static OG images go stale. The day you add your 200th template or hit 1k GitHub stars, your social preview still shows the old numbers. Dynamic generation solves this once and stays accurate forever. + +The pattern below uses Satori (Vercel) to render a React-like tree to SVG, then resvg to convert to PNG. It runs at build time in Astro — zero runtime cost, no external service. + +## Stack + +| Package | Role | +|---------|------| +| `satori` | Renders JSX-like object tree to SVG | +| `@resvg/resvg-js` | Converts SVG to PNG (Rust, fast) | +| `@fontsource/inter` | Local font files (woff1 format required) | + +## Setup + +```bash +pnpm add satori @resvg/resvg-js @fontsource/inter +``` + +Create the file at `src/pages/og-image.png.ts`. Astro automatically serves it at `/og-image.png`. + +Reference it from your layout: + +```html + + +``` + +See the ready-to-use template: [`examples/scripts/og-image-astro.ts`](../../examples/scripts/og-image-astro.ts) + +## The pattern + +```typescript +import type { APIRoute } from 'astro' +import satori from 'satori' +import { Resvg } from '@resvg/resvg-js' +import { readFileSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export const GET: APIRoute = () => { + const fontData = readFileSync( + resolve(__dirname, '../../node_modules/@fontsource/inter/files/inter-latin-400-normal.woff') + ).buffer as ArrayBuffer + + const svg = satori( + { type: 'div', props: { style: { /* ... */ }, children: [ /* ... */ ] } }, + { width: 1200, height: 630, fonts: [{ name: 'Inter', data: fontData }] } + ) + + const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng() + + return new Response(png.buffer as ArrayBuffer, { + headers: { 'Content-Type': 'image/png' }, + }) +} +``` + +## Dynamic stats from content + +Count your content files at build time instead of hardcoding: + +```typescript +function countQuestions(): number { + const dir = resolve(__dirname, '../content/questions') + let total = 0 + for (const cat of readdirSync(dir, { withFileTypes: true })) { + if (cat.isDirectory()) { + total += readdirSync(resolve(dir, cat.name)) + .filter(f => f.endsWith('.md')).length + } + } + return total +} +``` + +Stats you can auto-count: +- Markdown files in a content directory (questions, articles, docs) +- YAML entries in a data file +- Line count of a large document + +Stats to keep hardcoded (update manually): +- GitHub stars (dynamic, use `1.1k+` as a conservative label) +- Templates from another repo +- Performance benchmarks + +## Gotchas + +### Font format matters + +Satori requires **woff1** or **TTF**. It will silently fail or throw an error with woff2 or remote CDN URLs that redirect to HTML. + +```typescript +// Correct — local woff1 from @fontsource +readFileSync('node_modules/@fontsource/inter/files/inter-latin-400-normal.woff') + +// Fails — woff2 not supported by resvg +readFileSync('node_modules/@fontsource/inter/files/inter-latin-400-normal.woff2') + +// Fails — CDN may return HTML (redirects, auth walls) +await fetch('https://fonts.gstatic.com/s/inter/...') +``` + +### Static files shadow API routes + +Astro dev server serves static files in `public/` **before** API routes. If you have a `public/og-image.png`, it will always be served instead of your dynamic endpoint. + +**Delete it:** +```bash +rm public/og-image.png +``` + +Also check the project root and `dist/` — files there can shadow the route too. Diagnose with `curl -I http://localhost:4321/og-image.png`: if the response has a `Last-Modified` header, you are hitting a static file, not the API route. + +### Browser cache + +After removing the static file, do a hard refresh (`Cmd+Shift+R`) or test in a fresh incognito window. The browser may have cached the old PNG aggressively. + +### `satori` is synchronous in newer versions + +Some versions of satori return a `Promise`, others return `string`. If you get a `[object Promise]` PNG, add `await`: + +```typescript +const svg = await satori(tree, options) +``` + +## Testing + +**Local preview** — visit directly in the browser: +``` +http://localhost:4321/og-image.png +``` + +**Social preview simulation** — paste your prod URL into: +- [opengraph.xyz](https://www.opengraph.xyz) — generic OG debugger +- LinkedIn Post Inspector (`linkedin.com/post-inspector/`) — forces cache refresh for LinkedIn +- Twitter Card Validator (`cards-dev.twitter.com/validator`) + +**CI check** — if you want to catch regressions, you can add a build step that checks the generated PNG file size is above a threshold: + +```bash +# In CI after pnpm build +SIZE=$(wc -c < dist/og-image.png) +if [ "$SIZE" -lt 10000 ]; then + echo "og-image.png looks too small ($SIZE bytes) — generation may have failed" + exit 1 +fi +``` + +## Variants + +### Personal branding (no stats grid) + +```typescript +children: [ + { type: 'span', props: { style: { fontSize: '48px', color: '#c0522a' }, children: 'FB.' } }, + { type: 'span', props: { style: { fontSize: '80px', fontWeight: 800, color: '#f5f5f5' }, children: 'Your Name' } }, + { type: 'span', props: { style: { fontSize: '24px', color: '#8b949e' }, children: 'Your tagline here' } }, +] +``` + +### Project list badges + +```typescript +['project-a.com', 'project-b.com', 'project-c.com'].map(label => ({ + type: 'div', + props: { + style: { background: '#161b22', border: '1px solid #30363d', borderRadius: '8px', padding: '8px 16px' }, + children: [{ type: 'span', props: { style: { color: '#c0522a' }, children: label } }], + }, +})) +``` + +### Terminal-style badge (for CLI tools) + +```typescript +{ + type: 'div', + props: { + style: { background: '#21262d', border: '1px solid #30363d', borderRadius: '20px', padding: '6px 16px', color: '#3fb950', fontFamily: 'monospace' }, + children: '>_ your-cli-tool', + }, +} +``` + +## Keeping stats in sync + +Maintain a single source of truth. When you update stats in the OG image, update them everywhere (landing page badges, README, etc.) in the same commit. + +For projects with multiple landings, create a slash command `/update-stats-image-landings` that walks each repo and prompts you to verify each stat. This prevents drift across sites.