Skip to content

Commit a17b810

Browse files
committed
feat(website): add release OG image generator
Programmatic generator for per-release Open Graph images. Runs locally via `pnpm gen:og --version <ver>` and writes a 2400x1260 (2x retina) PNG into the design-resources repo. Stack: satori (JSX/HTML -> SVG) + @resvg/resvg-js (SVG -> PNG) + satori-html (template) + sharp 8-bit palette re-encoding (~500 KB -> ~140 KB with no visible loss on this design — TinyPNG-style libimagequant quantization). Logo SVG is fetched from the assets.rspack.rs CDN at generation time so logo updates flow through without re-committing the template. Space Grotesk (SIL OFL) is committed under assets/fonts/. Background is a brick pattern of pass / running / fail / skip status icons (lucide circle-check / circle-x / loader / circle-dashed, color-matched to @rstest/browser-ui's status palette). Grounds the og frame in the test-runner identity rather than a generic gradient. Layout: 7x12 grid with corner icons pinned to canvas corners (1/4 visible), first/last row/col half-cropped at edges, odd rows offset by half a column for brick stagger. Per-icon stroke-opacity jitter (0.06-0.26) gives a hand-stippled feel; only the loader gets random rotation. A radialGradient vignette is baked into the bg SVG so the v{version} hero text reads cleanly through the wallpaper. The bg SVG is rasterized via Resvg and embedded as <img src="data:..."> in the satori tree — satori's `background: url(data:...)` shorthand parser doesn't accept SVG data URIs.
1 parent a541fa3 commit a17b810

11 files changed

Lines changed: 1020 additions & 0 deletions

File tree

pnpm-lock.yaml

Lines changed: 580 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/dictionary.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ antd
44
apng
55
apos
66
applescript
7+
artboard
78
Asus
89
atrules
910
autodocs
@@ -54,6 +55,7 @@ fnames
5455
frontends
5556
fullhash
5657
gzipped
58+
Grotesk
5759
icss
5860
idents
5961
iife
@@ -116,7 +118,9 @@ publint
116118
pxtorem
117119
quasis
118120
quxx
121+
rasterizes
119122
rebranded
123+
resvg
120124
rolldown
121125
rootdir
122126
rsbuild

website/AGENTS.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,36 @@ This is the documentation website for Rstest, built with [Rspress](https://rspre
1414
pnpm dev # Start dev server
1515
pnpm build # Build for production
1616
pnpm preview # Preview production build
17+
pnpm gen:og # Generate a release Open Graph image (see below)
1718
```
1819

20+
## Open Graph image generation
21+
22+
Per-release og images live in [rstackjs/rstack-design-resources](https://github.com/rstackjs/rstack-design-resources) and are served by the `assets.rspack.rs` CDN. The template lives **in this repo** to keep design-resources as a passive PNG store.
23+
24+
- `scripts/og-image/cli.mts` — entry, parses `--version`/`--description`/`--out`
25+
- `scripts/og-image/render.mts` — fetches the Rstest logo SVG → rasterizes → composes with [satori](https://github.com/vercel/satori) → renders with [@resvg/resvg-js](https://github.com/yisibl/resvg-js) at 2x zoom for retina
26+
- `scripts/og-image/template.mts`[satori-html](https://github.com/natemoo-re/satori-html) template, modeled after the `Rsbuild og image 1.0` artboard in design-resources
27+
28+
### Release workflow
29+
30+
1. Run `pnpm gen:og --version <ver> --description "<tagline>"` from `website/`. Use `--out` to write directly into a local clone of the design-resources repo at `rstest/assets/rstest-og-image-v{version-with-hyphens}.png` (e.g. `v0-5.png`).
31+
2. Commit the PNG in the design-resources repo and open a PR — that repo is the only place release PNGs are stored.
32+
3. After CDN deploy, the per-blog-post URL becomes available at `assets.rspack.rs/rstest/assets/rstest-og-image-v0-5.png`. The `head` function hook in `rspress.config.ts` routes each blog `routePath` to its matching URL.
33+
34+
### Do
35+
36+
- Use Space Grotesk (committed under `scripts/og-image/assets/fonts/` with SIL OFL license)
37+
- Render at 2x via `Resvg({ fitTo: { mode: 'zoom', value: 2 } })` so the PNG stays crisp on retina displays
38+
- Re-encode the resvg output as an 8-bit palette PNG via `sharp({ palette: true, quality: 90, ... })` — drops the file from ~500 KB to ~140 KB with no visible loss
39+
- Fetch the logo from the canonical CDN URL at generation time, not from a committed copy
40+
41+
### Don't
42+
43+
- Don't depend on packages like `geist` that pull in framework peer deps (`next>=13.2`); commit raw `.ttf` files directly instead
44+
- Don't write generated PNGs into this repo; they belong in design-resources
45+
- Don't bake the logo into a static asset; always fetch the SVG so logo updates propagate automatically
46+
1947
## Writing style guidelines
2048

2149
When writing or editing documentation, follow these principles:

website/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ The same as Rspack: [Writing style guide](https://github.com/web-infra-dev/rspac
1515
For images you use in the document, it's better to upload them to the [rstackjs/rstack-design-resources](https://github.com/rstackjs/rstack-design-resources) repository, so the size of the current repository doesn't get too big.
1616

1717
After you upload the images there, they will be automatically deployed under the <https://assets.rspack.rs/>.
18+
19+
## Open Graph images
20+
21+
`scripts/og-image/` generates per-release Open Graph images used by the `og:image` and `twitter:image` meta tags on each release blog post. See [AGENTS.md](./AGENTS.md#open-graph-image-generation) for the template architecture and release workflow.

website/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
"build": "rspress build",
1010
"build:agent-install": "node scripts/buildAgentInstall.mjs",
1111
"dev": "rspress dev",
12+
"gen:og": "node --experimental-strip-types scripts/og-image/cli.mts",
1213
"preview": "rspress preview"
1314
},
1415
"devDependencies": {
16+
"@resvg/resvg-js": "^2.6.2",
1517
"@rsbuild/plugin-sass": "^1.5.2",
1618
"@rspress/core": "2.0.11",
1719
"@rspress/plugin-algolia": "2.0.11",
@@ -26,6 +28,9 @@
2628
"rsbuild-plugin-open-graph": "^1.1.2",
2729
"rspress-plugin-font-open-sans": "^1.0.3",
2830
"rspress-plugin-sitemap": "^1.2.1",
31+
"satori": "^0.26.0",
32+
"satori-html": "^0.3.2",
33+
"sharp": "^0.34.5",
2934
"typescript": "^6.0.3"
3035
}
3136
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
2+
3+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+
This license is copied below, and is also available with a FAQ at:
5+
http://scripts.sil.org/OFL
6+
7+
8+
-----------------------------------------------------------
9+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+
-----------------------------------------------------------
11+
12+
PREAMBLE
13+
The goals of the Open Font License (OFL) are to stimulate worldwide
14+
development of collaborative font projects, to support the font creation
15+
efforts of academic and linguistic communities, and to provide a free and
16+
open framework in which fonts may be shared and improved in partnership
17+
with others.
18+
19+
The OFL allows the licensed fonts to be used, studied, modified and
20+
redistributed freely as long as they are not sold by themselves. The
21+
fonts, including any derivative works, can be bundled, embedded,
22+
redistributed and/or sold with any software provided that any reserved
23+
names are not used by derivative works. The fonts and derivatives,
24+
however, cannot be released under any other type of license. The
25+
requirement for fonts to remain under this license does not apply
26+
to any document created using the fonts or their derivatives.
27+
28+
DEFINITIONS
29+
"Font Software" refers to the set of files released by the Copyright
30+
Holder(s) under this license and clearly marked as such. This may
31+
include source files, build scripts and documentation.
32+
33+
"Reserved Font Name" refers to any names specified as such after the
34+
copyright statement(s).
35+
36+
"Original Version" refers to the collection of Font Software components as
37+
distributed by the Copyright Holder(s).
38+
39+
"Modified Version" refers to any derivative made by adding to, deleting,
40+
or substituting -- in part or in whole -- any of the components of the
41+
Original Version, by changing formats or by porting the Font Software to a
42+
new environment.
43+
44+
"Author" refers to any designer, engineer, programmer, technical
45+
writer or other person who contributed to the Font Software.
46+
47+
PERMISSION & CONDITIONS
48+
Permission is hereby granted, free of charge, to any person obtaining
49+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+
redistribute, and sell modified and unmodified copies of the Font
51+
Software, subject to the following conditions:
52+
53+
1) Neither the Font Software nor any of its individual components,
54+
in Original or Modified Versions, may be sold by itself.
55+
56+
2) Original or Modified Versions of the Font Software may be bundled,
57+
redistributed and/or sold with any software, provided that each copy
58+
contains the above copyright notice and this license. These can be
59+
included either as stand-alone text files, human-readable headers or
60+
in the appropriate machine-readable metadata fields within text or
61+
binary files as long as those fields can be easily viewed by the user.
62+
63+
3) No Modified Version of the Font Software may use the Reserved Font
64+
Name(s) unless explicit written permission is granted by the corresponding
65+
Copyright Holder. This restriction only applies to the primary font name as
66+
presented to the users.
67+
68+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+
Software shall not be used to promote, endorse or advertise any
70+
Modified Version, except to acknowledge the contribution(s) of the
71+
Copyright Holder(s) and the Author(s) or with their explicit written
72+
permission.
73+
74+
5) The Font Software, modified or unmodified, in part or in whole,
75+
must be distributed entirely under this license, and must not be
76+
distributed under any other license. The requirement for fonts to
77+
remain under this license does not apply to any document created
78+
using the Font Software.
79+
80+
TERMINATION
81+
This license becomes null and void if any of the above conditions are
82+
not met.
83+
84+
DISCLAIMER
85+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+
OTHER DEALINGS IN THE FONT SOFTWARE.
113 KB
Binary file not shown.
112 KB
Binary file not shown.

website/scripts/og-image/cli.mts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { mkdir, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import { parseArgs } from 'node:util';
4+
import { renderOgImage } from './render.mts';
5+
6+
const { values } = parseArgs({
7+
options: {
8+
version: { type: 'string', short: 'v' },
9+
description: { type: 'string' },
10+
out: { type: 'string', short: 'o' },
11+
help: { type: 'boolean', short: 'h' },
12+
},
13+
});
14+
15+
if (values.help || !values.version) {
16+
console.log(`Usage: pnpm gen:og --version <ver> [options]
17+
18+
Options:
19+
--version, -v Release version, e.g. 0.5 (required)
20+
--description Optional tagline rendered below the version
21+
--out, -o Output PNG path
22+
(default: rstest-og-image-v<ver>.png in cwd)
23+
--help, -h Show this help
24+
25+
After generating, commit the PNG to rstackjs/rstack-design-resources
26+
under rstest/assets/ so the assets.rspack.rs CDN can serve it.`);
27+
process.exit(values.help ? 0 : 1);
28+
}
29+
30+
const versionSlug = values.version.replace(/\./g, '-');
31+
const outPath = path.resolve(
32+
values.out ?? `rstest-og-image-v${versionSlug}.png`,
33+
);
34+
35+
const png = await renderOgImage({
36+
version: values.version,
37+
description: values.description,
38+
});
39+
40+
await mkdir(path.dirname(outPath), { recursive: true });
41+
await writeFile(outPath, png);
42+
43+
console.log(`Wrote ${outPath} (${(png.length / 1024).toFixed(1)} KB)`);
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { readFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { Resvg } from '@resvg/resvg-js';
5+
import satori from 'satori';
6+
import sharp from 'sharp';
7+
import {
8+
buildTemplate,
9+
CANVAS_HEIGHT,
10+
CANVAS_WIDTH,
11+
type TemplateOptions,
12+
} from './template.mts';
13+
14+
const here = path.dirname(fileURLToPath(import.meta.url));
15+
const fontsDir = path.join(here, 'assets/fonts');
16+
17+
const LOGO_URL = 'https://assets.rspack.rs/rstest/rstest-logo.svg';
18+
19+
// Lucide-mirrored icon node markup, normalized to a 24x24 viewBox.
20+
// Sources: lucide-react `circle-check`, `circle-x`, `loader`, `circle-dashed`
21+
// — the same icons rendered by @rstest/browser-ui for pass / fail / running /
22+
// skip status, see packages/browser-ui/src/utils/constants.tsx.
23+
const ICON_PATHS = {
24+
pass: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
25+
fail: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
26+
running:
27+
'<path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/>',
28+
skip: '<path d="M10.1 2.182a10 10 0 0 1 3.8 0"/><path d="M13.9 21.818a10 10 0 0 1-3.8 0"/><path d="M17.609 3.721a10 10 0 0 1 2.69 2.7"/><path d="M2.182 13.9a10 10 0 0 1 0-3.8"/><path d="M20.279 17.609a10 10 0 0 1-2.7 2.69"/><path d="M21.818 10.1a10 10 0 0 1 0 3.8"/><path d="M3.721 6.391a10 10 0 0 1 2.7-2.69"/><path d="M6.391 20.279a10 10 0 0 1-2.69-2.7"/>',
29+
} as const;
30+
31+
type IconKind = keyof typeof ICON_PATHS;
32+
33+
// Hues mirror @rstest/browser-ui status colors (green/amber/red/gray Geist
34+
// family) so the background reads as "test runner status" at first glance.
35+
const ICON_COLORS: Record<IconKind, string> = {
36+
pass: '#16a34a',
37+
running: '#d97706',
38+
fail: '#dc2626',
39+
skip: '#71717a',
40+
};
41+
42+
const ICON_KINDS: IconKind[] = ['pass', 'running', 'fail', 'skip'];
43+
44+
// The center vignette is baked into the SVG (rather than layered via CSS) so
45+
// satori's `background` shorthand doesn't have to handle a
46+
// `radial-gradient(...), url(...)` layering with data URIs — which it
47+
// doesn't.
48+
function buildBackgroundSvg(width: number, height: number): string {
49+
// iconSize=70 is the largest value that divides the vertical axis cleanly
50+
// (rowSpacing = 1.5 * 70 = 105, 630 / 105 = 6 → rows=7 with the last row
51+
// landing exactly on y=height). Horizontal can't divide cleanly at the
52+
// same iconSize (1200/105 ≈ 11.43), so cols=12 with colSpacing forced to
53+
// 1200/11 ≈ 109.09 — a ~4px deviation. The integer constraint for both
54+
// axes simultaneously only admits iconSize ≤ 20 (GCD(800, 420) = 20),
55+
// which is too small to read as a test status icon.
56+
const iconSize = 70;
57+
const scale = iconSize / 24;
58+
const rows = 7;
59+
const cols = 12;
60+
const rowSpacing = height / (rows - 1);
61+
const colSpacing = width / (cols - 1);
62+
const OPACITY_BASE = 0.16;
63+
const OPACITY_SPREAD = 0.1;
64+
65+
let icons = '';
66+
for (let r = 0; r < rows; r++) {
67+
// Brick pattern: offset by colSpacing/2 (not iconSize/2 — colSpacing is
68+
// wider), intentionally breaking first/last-column alignment on odd rows.
69+
const rowOffset = r % 2 === 1 ? colSpacing / 2 : 0;
70+
const cy = r * rowSpacing;
71+
for (let c = 0; c < cols; c++) {
72+
const cx = c * colSpacing + rowOffset;
73+
if (cx > width + iconSize / 2) continue;
74+
const kind = ICON_KINDS[Math.floor(Math.random() * ICON_KINDS.length)]!;
75+
const color = ICON_COLORS[kind];
76+
// Only `running` (an 8-spoke loader) reads as "spinner caught mid-tick"
77+
// when rotated. Pass/fail/skip are radially symmetric — rotating them
78+
// just looks like a wobble.
79+
const rotation =
80+
kind === 'running' ? (Math.random() * 360).toFixed(1) : '0';
81+
const opacity = (
82+
OPACITY_BASE +
83+
(Math.random() * 2 - 1) * OPACITY_SPREAD
84+
).toFixed(3);
85+
icons += `<g transform="translate(${cx.toFixed(1)} ${cy.toFixed(1)}) rotate(${rotation}) scale(${scale.toFixed(3)}) translate(-12 -12)" stroke="${color}" stroke-opacity="${opacity}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">${ICON_PATHS[kind]}</g>`;
86+
}
87+
}
88+
89+
const vignette = `<defs><radialGradient id="vig" cx="50%" cy="55%" r="60%"><stop offset="0%" stop-color="#ffffff" stop-opacity="0.85"/><stop offset="100%" stop-color="#ffffff" stop-opacity="0"/></radialGradient></defs><rect width="${width}" height="${height}" fill="url(#vig)"/>`;
90+
91+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="#ffffff"/>${icons}${vignette}</svg>`;
92+
}
93+
94+
function svgToPngDataUrl(svg: string, width: number): string {
95+
const png = new Resvg(svg, { fitTo: { mode: 'width', value: width } })
96+
.render()
97+
.asPng();
98+
return `data:image/png;base64,${png.toString('base64')}`;
99+
}
100+
101+
async function fetchLogoSvg(): Promise<string> {
102+
const response = await fetch(LOGO_URL);
103+
if (!response.ok) {
104+
throw new Error(
105+
`Failed to fetch logo from ${LOGO_URL}: ${response.status} ${response.statusText}`,
106+
);
107+
}
108+
return response.text();
109+
}
110+
111+
export type RenderOptions = Omit<
112+
TemplateOptions,
113+
'logoDataUrl' | 'backgroundDataUrl'
114+
>;
115+
116+
export async function renderOgImage(opts: RenderOptions): Promise<Buffer> {
117+
const [regular, bold, logoSvg] = await Promise.all([
118+
readFile(path.join(fontsDir, 'SpaceGrotesk-Regular.ttf')),
119+
readFile(path.join(fontsDir, 'SpaceGrotesk-Bold.ttf')),
120+
fetchLogoSvg(),
121+
]);
122+
123+
const tree = buildTemplate({
124+
...opts,
125+
// 512px source stays crisp when the og PNG is rendered at 2x.
126+
logoDataUrl: svgToPngDataUrl(logoSvg, 512),
127+
backgroundDataUrl: svgToPngDataUrl(
128+
buildBackgroundSvg(CANVAS_WIDTH, CANVAS_HEIGHT),
129+
CANVAS_WIDTH,
130+
),
131+
});
132+
133+
const svg = await satori(tree, {
134+
width: CANVAS_WIDTH,
135+
height: CANVAS_HEIGHT,
136+
fonts: [
137+
{ name: 'Space Grotesk', data: regular, weight: 400, style: 'normal' },
138+
{ name: 'Space Grotesk', data: bold, weight: 700, style: 'normal' },
139+
],
140+
});
141+
142+
// 2x zoom for retina; 2400x1260 fits Twitter/Facebook's ~5MB / 8192px limits.
143+
const resvg = new Resvg(svg, { fitTo: { mode: 'zoom', value: 2 } });
144+
145+
// Re-encode as 8-bit palette PNG via libimagequant (TinyPNG-style lossy
146+
// quantization). The resvg truecolor output is ~500 KB; palette mode drops
147+
// it to ~140 KB with no visible loss on this design (vignette + stroked
148+
// icons quantize cleanly into 256 colors).
149+
return sharp(resvg.render().asPng())
150+
.png({ palette: true, quality: 90, effort: 10, compressionLevel: 9 })
151+
.toBuffer();
152+
}

0 commit comments

Comments
 (0)