Skip to content

Commit f828bb5

Browse files
committed
refactor frontendsupport prerender and og-images scripts to use shared @cutting/devtools utilities, move puppeteer/express dependencies to devtools package with optional peer dependencies
1 parent 7155ef0 commit f828bb5

6 files changed

Lines changed: 204 additions & 109 deletions

File tree

apps/frontendsupport/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dependencies": {
2323
"@cutting/assert": "workspace:*",
2424
"@cutting/component-library": "workspace:*",
25+
"@cutting/devtools": "workspace:*",
2526
"@cutting/hooks": "workspace:*",
2627
"@cutting/markdown": "workspace:*",
2728
"@cutting/react-hook-form-components": "workspace:*",
Lines changed: 7 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,16 @@
1-
import { createServer } from 'node:http';
2-
import type { AddressInfo } from 'node:net';
3-
4-
import express from 'express';
5-
import { mkdir, readFile, writeFile } from 'fs/promises';
1+
import { generateOgImages, type OgImageTarget } from '@cutting/devtools/prerender.js';
2+
import { readFile } from 'fs/promises';
63
import { dirname, join } from 'path';
7-
import puppeteer from 'puppeteer';
8-
import serveStatic from 'serve-static';
94
import { fileURLToPath } from 'url';
105

116
const __dirname = dirname(fileURLToPath(import.meta.url));
127
const clientDir = join(__dirname, '../dist/client');
13-
const ogDir = join(clientDir, 'og');
14-
15-
const VIEWPORT = { width: 1200, height: 630 };
16-
17-
async function slugs(): Promise<string[]> {
18-
const list = JSON.parse(await readFile(join(clientDir, 'posts', 'posts-list.json'), 'utf-8')) as Array<{
19-
slug: string;
20-
}>;
21-
return list.map((post) => post.slug);
22-
}
23-
24-
async function generate(): Promise<void> {
25-
const app = express();
26-
app.use(serveStatic(clientDir, { index: ['index.html'] }));
27-
app.use((_req, res) => res.sendFile(join(clientDir, 'index.html')));
28-
const server = createServer(app);
29-
await new Promise<void>((resolve) => server.listen(0, resolve));
30-
const port = (server.address() as AddressInfo).port;
31-
32-
const browser = await puppeteer.launch({
33-
headless: true,
34-
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--font-render-hinting=none'],
35-
});
36-
37-
try {
38-
await mkdir(ogDir, { recursive: true });
39-
const posts = await slugs();
408

41-
for (const slug of posts) {
42-
const page = await browser.newPage();
43-
await page.setViewport(VIEWPORT);
44-
await page.goto(`http://localhost:${port}/posts/${slug}/`, { waitUntil: 'networkidle0', timeout: 50000 });
45-
const screenshot = await page.screenshot({ type: 'png' });
46-
await writeFile(join(ogDir, `${slug}.png`), screenshot);
47-
await page.close();
48-
}
9+
type Post = { slug: string };
4910

50-
console.log(`Generated ${posts.length} OG images`);
51-
} finally {
52-
await browser.close();
53-
server.close();
54-
}
11+
async function targets(): Promise<OgImageTarget[]> {
12+
const list = JSON.parse(await readFile(join(clientDir, 'posts', 'posts-list.json'), 'utf-8')) as Post[];
13+
return list.map((post) => ({ path: `/posts/${post.slug}/`, name: post.slug }));
5514
}
5615

57-
generate().catch((error) => {
58-
console.error(error);
59-
process.exit(1);
60-
});
16+
await generateOgImages({ clientDir, targets: await targets() });
Lines changed: 14 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { mkdir, readFile, writeFile } from 'fs/promises';
1+
import { prerender, type PrerenderRoute } from '@cutting/devtools/prerender.js';
2+
import { readFile } from 'fs/promises';
23
import { dirname, join } from 'path';
34
import { fileURLToPath } from 'url';
45

@@ -14,66 +15,22 @@ const DEFAULT_META = {
1415
'https://res.cloudinary.com/ddospxsc8/image/upload/w_1000,ar_16:9,c_fill,g_auto,e_sharpen/v1767264044/rescue_dieu8h.png',
1516
};
1617

17-
type Meta = { title: string; description: string; image: string };
18-
type Post = { slug: string; title: string; description: string };
19-
2018
const STATIC_ROUTES = ['/', '/about', '/contact', '/email/confirmation', '/posts', '/testimonials'];
2119

22-
async function posts(): Promise<Post[]> {
23-
const listPath = join(clientDir, 'posts', 'posts-list.json');
24-
return JSON.parse(await readFile(listPath, 'utf-8')) as Post[];
25-
}
26-
27-
function outputPath(route: string): string {
28-
const segment = route === '/' ? '' : route;
29-
return join(clientDir, segment, 'index.html');
30-
}
31-
32-
function setMeta(html: string, key: string, value: string): string {
33-
const re = new RegExp(`(="${key}" content=")[^"]*(")`);
34-
return html.replace(re, (_match, before: string, after: string) => `${before}${value}${after}`);
35-
}
36-
37-
async function prerender(): Promise<void> {
38-
const template = await readFile(join(clientDir, 'index.html'), 'utf-8');
39-
40-
const routeMeta = new Map<string, Meta>(STATIC_ROUTES.map((route) => [route, DEFAULT_META]));
41-
for (const post of await posts()) {
42-
routeMeta.set(`/posts/${post.slug}`, {
43-
title: post.title,
44-
description: post.description || DEFAULT_META.description,
45-
image: `${BASE_URL}/og/${post.slug}.png`,
46-
});
47-
}
48-
49-
for (const [route, meta] of routeMeta) {
50-
const values: Record<string, string> = {
51-
'og:url': `${BASE_URL}${route}`,
52-
'twitter:url': `${BASE_URL}${route}`,
53-
'og:title': meta.title,
54-
'og:image:alt': meta.title,
55-
'twitter:title': meta.title,
56-
'og:description': meta.description,
57-
'twitter:description': meta.description,
58-
'og:image': meta.image,
59-
'twitter:image:src': meta.image,
60-
};
20+
type Post = { slug: string; title: string; description: string };
6121

62-
let html = template;
63-
for (const [key, value] of Object.entries(values)) {
64-
html = setMeta(html, key, value);
65-
}
66-
html = html.replace(/<title>[^<]*<\/title>/, `<title>${meta.title}</title>`);
22+
async function routes(): Promise<PrerenderRoute[]> {
23+
const list = JSON.parse(await readFile(join(clientDir, 'posts', 'posts-list.json'), 'utf-8')) as Post[];
6724

68-
const target = outputPath(route);
69-
await mkdir(dirname(target), { recursive: true });
70-
await writeFile(target, html, 'utf-8');
71-
}
25+
const staticRoutes: PrerenderRoute[] = STATIC_ROUTES.map((path) => ({ path, ...DEFAULT_META }));
26+
const postRoutes: PrerenderRoute[] = list.map((post) => ({
27+
path: `/posts/${post.slug}`,
28+
title: post.title,
29+
description: post.description || DEFAULT_META.description,
30+
image: `${BASE_URL}/og/${post.slug}.png`,
31+
}));
7232

73-
console.log(`Pre-rendered ${routeMeta.size} routes (head meta only)`);
33+
return [...staticRoutes, ...postRoutes];
7434
}
7535

76-
prerender().catch((error) => {
77-
console.error(error);
78-
process.exit(1);
79-
});
36+
await prerender({ clientDir, baseUrl: BASE_URL, routes: await routes() });

packages/devtools/package.json

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
"types": "./tools/index.d.ts",
1616
"import": "./tools/index.min.js"
1717
},
18+
"./prerender.js": {
19+
"types": "./tools/prerender/index.d.ts",
20+
"import": "./tools/prerender/index.js"
21+
},
1822
"./paths.js": {
1923
"import": "./tools/config/paths.js"
2024
},
@@ -82,6 +86,11 @@
8286
],
8387
"devDependencies": {
8488
"@types/babel__core": "7.20.5",
89+
"@types/express": "5.0.3",
90+
"@types/serve-static": "1.15.9",
91+
"express": "5.2.1",
92+
"puppeteer": "25.1.0",
93+
"serve-static": "2.2.1",
8594
"@types/babel__plugin-transform-runtime": "7.9.5",
8695
"@types/babel__preset-env": "7.10.0",
8796
"@types/copy": "0.3.5",
@@ -102,8 +111,22 @@
102111
"react-dom": "19.2.7"
103112
},
104113
"peerDependencies": {
114+
"express": "*",
115+
"puppeteer": "*",
105116
"react": "19.2.7",
106-
"react-dom": "19.2.7"
117+
"react-dom": "19.2.7",
118+
"serve-static": "*"
119+
},
120+
"peerDependenciesMeta": {
121+
"express": {
122+
"optional": true
123+
},
124+
"puppeteer": {
125+
"optional": true
126+
},
127+
"serve-static": {
128+
"optional": true
129+
}
107130
},
108131
"volta": {
109132
"extends": "../../package.json"
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
2+
import { createServer } from 'node:http';
3+
import type { AddressInfo } from 'node:net';
4+
import { dirname, join } from 'node:path';
5+
6+
export interface PrerenderRoute {
7+
path: string;
8+
title: string;
9+
description: string;
10+
image: string;
11+
}
12+
13+
export interface PrerenderOptions {
14+
clientDir: string;
15+
baseUrl: string;
16+
routes: PrerenderRoute[];
17+
}
18+
19+
export interface OgImageTarget {
20+
path: string;
21+
name: string;
22+
}
23+
24+
export interface OgImagesOptions {
25+
clientDir: string;
26+
targets: OgImageTarget[];
27+
viewport?: { width: number; height: number };
28+
}
29+
30+
function setMeta(html: string, key: string, value: string): string {
31+
const re = new RegExp(`(="${key}" content=")[^"]*(")`);
32+
return html.replace(re, (_match, before: string, after: string) => `${before}${value}${after}`);
33+
}
34+
35+
function outputPath(clientDir: string, route: string): string {
36+
const segment = route === '/' ? '' : route;
37+
return join(clientDir, segment, 'index.html');
38+
}
39+
40+
export async function prerender({ clientDir, baseUrl, routes }: PrerenderOptions): Promise<void> {
41+
const template = await readFile(join(clientDir, 'index.html'), 'utf-8');
42+
43+
for (const route of routes) {
44+
const values: Record<string, string> = {
45+
'og:url': `${baseUrl}${route.path}`,
46+
'twitter:url': `${baseUrl}${route.path}`,
47+
'og:title': route.title,
48+
'og:image:alt': route.title,
49+
'twitter:title': route.title,
50+
'og:description': route.description,
51+
'twitter:description': route.description,
52+
'og:image': route.image,
53+
'twitter:image:src': route.image,
54+
};
55+
56+
let html = template;
57+
for (const [key, value] of Object.entries(values)) {
58+
html = setMeta(html, key, value);
59+
}
60+
html = html.replace(/<title>[^<]*<\/title>/, `<title>${route.title}</title>`);
61+
62+
const target = outputPath(clientDir, route.path);
63+
await mkdir(dirname(target), { recursive: true });
64+
await writeFile(target, html, 'utf-8');
65+
}
66+
67+
console.log(`Pre-rendered ${routes.length} routes (head meta only)`);
68+
}
69+
70+
export async function generateOgImages({
71+
clientDir,
72+
targets,
73+
viewport = { width: 1200, height: 630 },
74+
}: OgImagesOptions): Promise<void> {
75+
const { default: express } = await import('express');
76+
const { default: serveStatic } = await import('serve-static');
77+
const { default: puppeteer } = await import('puppeteer');
78+
79+
const app = express();
80+
app.use(serveStatic(clientDir, { index: ['index.html'] }));
81+
app.use((_req, res) => res.sendFile(join(clientDir, 'index.html')));
82+
const server = createServer(app);
83+
await new Promise<void>((resolve) => server.listen(0, resolve));
84+
const port = (server.address() as AddressInfo).port;
85+
86+
const browser = await puppeteer.launch({
87+
headless: true,
88+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--font-render-hinting=none'],
89+
});
90+
91+
try {
92+
const ogDir = join(clientDir, 'og');
93+
await mkdir(ogDir, { recursive: true });
94+
95+
for (const target of targets) {
96+
const page = await browser.newPage();
97+
await page.setViewport(viewport);
98+
await page.goto(`http://localhost:${port}${target.path}`, { waitUntil: 'networkidle0', timeout: 50000 });
99+
const screenshot = await page.screenshot({ type: 'png' });
100+
await writeFile(join(ogDir, `${target.name}.png`), screenshot);
101+
await page.close();
102+
}
103+
104+
console.log(`Generated ${targets.length} OG images`);
105+
} finally {
106+
await browser.close();
107+
server.close();
108+
}
109+
}

0 commit comments

Comments
 (0)