Skip to content

Commit 5e41056

Browse files
committed
Generate OG images statically at build time
1 parent 533541a commit 5e41056

6 files changed

Lines changed: 424 additions & 123 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ public/rss.xml
4646

4747
# worktrees
4848
.worktrees/
49+
50+
# Generated OG images (scripts/generateOgImages.mjs)
51+
public/images/og/

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"analyze": "ANALYZE=true next build",
88
"dev": "next-remote-watch ./src/content",
99
"prebuild:rsc": "node scripts/buildRscWorker.mjs",
10-
"build": "node scripts/buildRscWorker.mjs && next build && node --experimental-modules ./scripts/downloadFonts.mjs",
10+
"build": "node scripts/buildRscWorker.mjs && next build && node --experimental-modules ./scripts/downloadFonts.mjs && node ./scripts/generateOgImages.mjs",
1111
"lint": "next lint && eslint \"src/content/**/*.md\"",
1212
"lint:fix": "next lint --fix && eslint \"src/content/**/*.md\" --fix",
1313
"format:source": "prettier --config .prettierrc --write \"{plugins,src}/**/*.{js,ts,jsx,tsx,css}\"",
@@ -51,6 +51,7 @@
5151
"@babel/plugin-transform-modules-commonjs": "^7.18.6",
5252
"@babel/preset-react": "^7.18.6",
5353
"@mdx-js/mdx": "^2.1.3",
54+
"@resvg/resvg-js": "^2.6.2",
5455
"@types/body-scroll-lock": "^2.6.1",
5556
"@types/classnames": "^2.2.10",
5657
"@types/debounce": "^1.2.1",
@@ -102,6 +103,7 @@
102103
"retext": "^7.0.1",
103104
"retext-smartypants": "^4.0.0",
104105
"rss": "^1.2.2",
106+
"satori": "^0.26.0",
105107
"tailwindcss": "^3.4.1",
106108
"typescript": "^5.7.2",
107109
"unist-util-visit": "^2.0.3",

scripts/generateOgImages.mjs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
// Generates a static OG image for every content page, so social
9+
// cards show the page title without any runtime image generation.
10+
// Run after `next build` (see the `build` script in package.json).
11+
12+
import fs from 'fs';
13+
import path from 'path';
14+
import satori from 'satori';
15+
import {Resvg} from '@resvg/resvg-js';
16+
import matter from 'gray-matter';
17+
18+
const ROOT = process.cwd();
19+
const CONTENT_DIR = path.join(ROOT, 'src', 'content');
20+
const OUT_DIR = path.join(ROOT, 'public', 'images', 'og');
21+
const CONCURRENCY = 8;
22+
23+
const bold = fs.readFileSync(
24+
path.join(ROOT, 'public', 'fonts', 'Optimistic_Display_W_Bd.ttf')
25+
);
26+
const medium = fs.readFileSync(
27+
path.join(ROOT, 'public', 'fonts', 'Optimistic_Display_W_Md.ttf')
28+
);
29+
30+
function el(type, style, children) {
31+
return {type, props: {style, children}};
32+
}
33+
34+
function card(title, pagePath) {
35+
const footer = ('react.dev' + pagePath).toUpperCase();
36+
const logo = {
37+
type: 'svg',
38+
props: {
39+
width: 80,
40+
height: 72,
41+
viewBox: '-10.5 -9.45 21 18.9',
42+
fill: 'none',
43+
children: [
44+
{type: 'circle', props: {cx: 0, cy: 0, r: 2, fill: '#58c4dc'}},
45+
{
46+
type: 'g',
47+
props: {
48+
stroke: '#58c4dc',
49+
strokeWidth: 1,
50+
fill: 'none',
51+
children: [
52+
{type: 'ellipse', props: {rx: 10, ry: 4.5}},
53+
{
54+
type: 'ellipse',
55+
props: {rx: 10, ry: 4.5, transform: 'rotate(60)'},
56+
},
57+
{
58+
type: 'ellipse',
59+
props: {rx: 10, ry: 4.5, transform: 'rotate(120)'},
60+
},
61+
],
62+
},
63+
},
64+
],
65+
},
66+
};
67+
return el(
68+
'div',
69+
{
70+
width: '100%',
71+
height: '100%',
72+
display: 'flex',
73+
flexDirection: 'column',
74+
padding: '72px 80px',
75+
backgroundColor: '#23272f',
76+
backgroundImage:
77+
'radial-gradient(circle at 25% 30%, #343a46 0%, #23272f 55%)',
78+
},
79+
[
80+
el('div', {display: 'flex', alignItems: 'center', gap: '20px'}, [
81+
logo,
82+
el(
83+
'div',
84+
{
85+
fontSize: 48,
86+
fontFamily: 'Optimistic Display Bold',
87+
color: '#f6f7f9',
88+
},
89+
'React'
90+
),
91+
]),
92+
el(
93+
'div',
94+
{
95+
marginTop: 'auto',
96+
marginBottom: 'auto',
97+
fontSize: title.length > 24 ? 72 : 96,
98+
fontFamily: 'Optimistic Display Bold',
99+
color: '#f6f7f9',
100+
lineHeight: 1.1,
101+
wordBreak: 'break-word',
102+
},
103+
title
104+
),
105+
el(
106+
'div',
107+
{
108+
fontSize: 28,
109+
fontFamily: 'Optimistic Display Medium',
110+
color: '#99a1b3',
111+
letterSpacing: '0.08em',
112+
},
113+
footer
114+
),
115+
]
116+
);
117+
}
118+
119+
async function renderCard(title, pagePath) {
120+
const svg = await satori(card(title, pagePath), {
121+
width: 1200,
122+
height: 630,
123+
fonts: [
124+
{
125+
name: 'Optimistic Display Bold',
126+
data: bold,
127+
weight: 700,
128+
style: 'normal',
129+
},
130+
{
131+
name: 'Optimistic Display Medium',
132+
data: medium,
133+
weight: 500,
134+
style: 'normal',
135+
},
136+
],
137+
});
138+
return new Resvg(svg, {fitTo: {mode: 'width', value: 1200}}).render().asPng();
139+
}
140+
141+
function collectSidebarTitles() {
142+
const titles = new Map();
143+
for (const name of fs.readdirSync(path.join(ROOT, 'src'))) {
144+
if (!/^sidebar.*\.json$/.test(name)) continue;
145+
const walk = (node) => {
146+
if (node.path && node.title) {
147+
titles.set(node.path, node.title);
148+
}
149+
for (const child of node.routes ?? []) walk(child);
150+
};
151+
walk(JSON.parse(fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')));
152+
}
153+
return titles;
154+
}
155+
156+
const sidebarTitles = collectSidebarTitles();
157+
158+
function collectPages(dir, out) {
159+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
160+
const full = path.join(dir, entry.name);
161+
if (entry.isDirectory()) {
162+
collectPages(full, out);
163+
} else if (entry.name.endsWith('.md')) {
164+
const rel = path.relative(CONTENT_DIR, full).replace(/\.md$/, '');
165+
const segments = rel.split(path.sep);
166+
if (segments[segments.length - 1] === 'index') {
167+
segments.pop();
168+
}
169+
const pagePath = '/' + segments.join('/');
170+
const {data} = matter(fs.readFileSync(full, 'utf8'));
171+
const title = data.title ?? sidebarTitles.get(pagePath);
172+
if (title) {
173+
out.push({title: String(title), pagePath});
174+
}
175+
}
176+
}
177+
return out;
178+
}
179+
180+
export function ogImageFileName(pagePath) {
181+
return pagePath.replace(/^\//, '').replace(/\//g, '-') + '.png';
182+
}
183+
184+
async function main() {
185+
const pages = collectPages(CONTENT_DIR, []);
186+
fs.mkdirSync(OUT_DIR, {recursive: true});
187+
let done = 0;
188+
const queue = [...pages];
189+
async function worker() {
190+
for (;;) {
191+
const page = queue.shift();
192+
if (!page) return;
193+
const png = await renderCard(page.title, page.pagePath);
194+
fs.writeFileSync(path.join(OUT_DIR, ogImageFileName(page.pagePath)), png);
195+
done++;
196+
if (done % 100 === 0) {
197+
console.log(`og-images: ${done}/${pages.length}`);
198+
}
199+
}
200+
}
201+
await Promise.all(Array.from({length: CONCURRENCY}, worker));
202+
console.log(`og-images: generated ${done} images in public/images/og`);
203+
}
204+
205+
main().catch((error) => {
206+
console.error(error);
207+
process.exit(1);
208+
});

src/components/Layout/Page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,12 @@ export function Page({
130130
titleForTitleTag={meta.titleForTitleTag}
131131
isHomePage={isHomePage}
132132
image={
133-
isHomePage || !title
133+
// OG images are generated per page at build time by
134+
// scripts/generateOgImages.mjs. Error pages have dynamic
135+
// titles, so they keep the static section image.
136+
isHomePage || !title || cleanedPath.startsWith('/errors')
134137
? `/images/og-` + section + '.png'
135-
: `/api/og?title=${encodeURIComponent(
136-
title
137-
)}&path=${encodeURIComponent(cleanedPath)}`
138+
: `/images/og/${cleanedPath.slice(1).replace(/\//g, '-')}.png`
138139
}
139140
searchOrder={searchOrder}
140141
/>

src/pages/api/og.tsx

Lines changed: 0 additions & 114 deletions
This file was deleted.

0 commit comments

Comments
 (0)