Skip to content

Commit ef208e1

Browse files
Dynamic OG images for docs pages (#8527)
* Add dynamic OG images for docs pages * Match OG card design to existing static images * Generate OG images statically at build time * Clean up orphaned lockfile entry * Show section label on OG cards and guard pages without a card * Center title and enlarge section label on OG cards
1 parent e51fc36 commit ef208e1

7 files changed

Lines changed: 441 additions & 5 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",
59.4 KB
Binary file not shown.
59.6 KB
Binary file not shown.

scripts/generateOgImages.mjs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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 SECTION_LABELS = {
24+
learn: 'Learn React',
25+
reference: 'API Reference',
26+
community: 'Community',
27+
blog: 'Blog',
28+
};
29+
30+
const bold = fs.readFileSync(
31+
path.join(ROOT, 'public', 'fonts', 'Optimistic_Display_W_Bd.ttf')
32+
);
33+
const medium = fs.readFileSync(
34+
path.join(ROOT, 'public', 'fonts', 'Optimistic_Display_W_Md.ttf')
35+
);
36+
37+
function el(type, style, children) {
38+
return {type, props: {style, children}};
39+
}
40+
41+
function card(title, pagePath) {
42+
const section = pagePath.split('/')[1] ?? '';
43+
const label = SECTION_LABELS[section] ?? 'React';
44+
const logo = {
45+
type: 'svg',
46+
props: {
47+
width: 80,
48+
height: 72,
49+
viewBox: '-10.5 -9.45 21 18.9',
50+
fill: 'none',
51+
children: [
52+
{type: 'circle', props: {cx: 0, cy: 0, r: 2, fill: '#58c4dc'}},
53+
{
54+
type: 'g',
55+
props: {
56+
stroke: '#58c4dc',
57+
strokeWidth: 1,
58+
fill: 'none',
59+
children: [
60+
{type: 'ellipse', props: {rx: 10, ry: 4.5}},
61+
{
62+
type: 'ellipse',
63+
props: {rx: 10, ry: 4.5, transform: 'rotate(60)'},
64+
},
65+
{
66+
type: 'ellipse',
67+
props: {rx: 10, ry: 4.5, transform: 'rotate(120)'},
68+
},
69+
],
70+
},
71+
},
72+
],
73+
},
74+
};
75+
return el(
76+
'div',
77+
{
78+
width: '100%',
79+
height: '100%',
80+
display: 'flex',
81+
flexDirection: 'column',
82+
padding: '72px 80px',
83+
backgroundColor: '#23272f',
84+
backgroundImage:
85+
'radial-gradient(circle at 25% 30%, #343a46 0%, #23272f 55%)',
86+
},
87+
[
88+
el('div', {display: 'flex', alignItems: 'center', gap: '20px'}, [
89+
logo,
90+
el(
91+
'div',
92+
{
93+
fontSize: 48,
94+
fontFamily: 'Optimistic Display Bold',
95+
color: '#f6f7f9',
96+
},
97+
'React'
98+
),
99+
]),
100+
el(
101+
'div',
102+
{
103+
flexGrow: 1,
104+
display: 'flex',
105+
alignItems: 'center',
106+
fontSize: title.length > 24 ? 72 : 96,
107+
fontFamily: 'Optimistic Display Bold',
108+
color: '#f6f7f9',
109+
lineHeight: 1.1,
110+
wordBreak: 'break-word',
111+
},
112+
title
113+
),
114+
el(
115+
'div',
116+
{
117+
fontSize: 40,
118+
fontFamily: 'Optimistic Display Medium',
119+
color: '#99a1b3',
120+
},
121+
label
122+
),
123+
]
124+
);
125+
}
126+
127+
async function renderCard(title, pagePath) {
128+
const svg = await satori(card(title, pagePath), {
129+
width: 1200,
130+
height: 630,
131+
fonts: [
132+
{
133+
name: 'Optimistic Display Bold',
134+
data: bold,
135+
weight: 700,
136+
style: 'normal',
137+
},
138+
{
139+
name: 'Optimistic Display Medium',
140+
data: medium,
141+
weight: 500,
142+
style: 'normal',
143+
},
144+
],
145+
});
146+
return new Resvg(svg, {fitTo: {mode: 'width', value: 1200}}).render().asPng();
147+
}
148+
149+
function collectSidebarTitles() {
150+
const titles = new Map();
151+
for (const name of fs.readdirSync(path.join(ROOT, 'src'))) {
152+
if (!/^sidebar.*\.json$/.test(name)) continue;
153+
const walk = (node) => {
154+
if (node.path && node.title) {
155+
titles.set(node.path, node.title);
156+
}
157+
for (const child of node.routes ?? []) walk(child);
158+
};
159+
walk(JSON.parse(fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')));
160+
}
161+
return titles;
162+
}
163+
164+
const sidebarTitles = collectSidebarTitles();
165+
166+
function collectPages(dir, out) {
167+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
168+
const full = path.join(dir, entry.name);
169+
if (entry.isDirectory()) {
170+
collectPages(full, out);
171+
} else if (entry.name.endsWith('.md')) {
172+
const rel = path.relative(CONTENT_DIR, full).replace(/\.md$/, '');
173+
const segments = rel.split(path.sep);
174+
if (segments[segments.length - 1] === 'index') {
175+
segments.pop();
176+
}
177+
const pagePath = '/' + segments.join('/');
178+
if (pagePath === '/' || pagePath.startsWith('/errors')) {
179+
continue;
180+
}
181+
const {data} = matter(fs.readFileSync(full, 'utf8'));
182+
const title = data.title ?? sidebarTitles.get(pagePath);
183+
if (title) {
184+
out.push({title: String(title), pagePath});
185+
}
186+
}
187+
}
188+
return out;
189+
}
190+
191+
export function ogImageFileName(pagePath) {
192+
return pagePath.replace(/^\//, '').replace(/\//g, '-') + '.png';
193+
}
194+
195+
async function main() {
196+
const pages = collectPages(CONTENT_DIR, []);
197+
fs.mkdirSync(OUT_DIR, {recursive: true});
198+
let done = 0;
199+
const queue = [...pages];
200+
async function worker() {
201+
for (;;) {
202+
const page = queue.shift();
203+
if (!page) return;
204+
const png = await renderCard(page.title, page.pagePath);
205+
fs.writeFileSync(path.join(OUT_DIR, ogImageFileName(page.pagePath)), png);
206+
done++;
207+
if (done % 100 === 0) {
208+
console.log(`og-images: ${done}/${pages.length}`);
209+
}
210+
}
211+
}
212+
await Promise.all(Array.from({length: CONCURRENCY}, worker));
213+
console.log(`og-images: generated ${done} images in public/images/og`);
214+
}
215+
216+
main().catch((error) => {
217+
console.error(error);
218+
process.exit(1);
219+
});

src/components/Layout/Page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,18 @@ export function Page({
129129
title={title}
130130
titleForTitleTag={meta.titleForTitleTag}
131131
isHomePage={isHomePage}
132-
image={`/images/og-` + section + '.png'}
132+
image={
133+
// OG images are generated per page at build time by
134+
// scripts/generateOgImages.mjs. Pages without a generated
135+
// card (home, errors, 404, 500) use the static section image.
136+
isHomePage ||
137+
!title ||
138+
cleanedPath.startsWith('/errors') ||
139+
cleanedPath === '/404' ||
140+
cleanedPath === '/500'
141+
? `/images/og-${section ?? 'unknown'}.png`
142+
: `/images/og/${cleanedPath.slice(1).replace(/\//g, '-')}.png`
143+
}
133144
searchOrder={searchOrder}
134145
/>
135146
{(isHomePage || isBlogIndex) && (

0 commit comments

Comments
 (0)