Automating OpenGraph Image Generation with Lume is surprisingly straight forward.
Science has shown that people are a million times more likely to click on a link if it has an image. Maybe a bazillion times. You've probably reached this article because you saw a tweet linking to it, and you thought to yourself: holy cow, that's an incredible banner image! I must click it!
Here's how I added them to mine: I hired a guy, and he made them for me!
Then we had a huge falling out (he didn't like my favorite HTML color, hotpink
) and I ended up having to do them myself. Damn!
Obviously, I didn't want to make them manually, so it was time to figure out how to automate their creation.
Just a few weeks ago, Vercel announced Vercel OG Image Generation, a feature of their platform that allows you to generate these images on the fly.
But now I faced a new problem: all their cool new bits were designed for use within Next.js, their React-based web application framework, while this new blog is just a statically generated website built with Lume (which I was gushing over in an earlier post.)
At the heart of their offering is Satori, a new library that can render HTML and (a subset of) CSS to SVG. It was time to find out how I can use it from within Lume. This encompassed three things:
- Finding out how to create a PNG file for every blog post on the site
- Rendering the HTML and CSS to SVG via Satori
- Converting that SVG to an actual PNG
Let's go! 🚀
Creating a PNG for every blog post
Lume made this surprisingly easy. It has a concept of templates, special documents that can create not one, but multiple pages in the final build of the site. They are required to export a default generator function that yields a list of pages to be created.
For this site, this template started out simple, like this:
export default async function* ({ search }) {
/* Look through all pages whose `type` attribute equals `post` */
for (const page of search.pages("type=post")) {
yield {
url: `${page.dest.path}.png`;
content: "insert actual PNG bits here",
};
}
}
Just this little snippet made sure that for a blog at /posts/hello-world/
, there would also be a /posts/hello-world/index.png
.
I also added the Metas plugin to the site and configured it to default to use a relative-linked image.png
like this:
metas:
image: ./index.png
# ...
This basically makes it so every page will default to a local index.png
file for its OpenGraph image, while still allowing me to override it where I want to.
Rendering the HTML and CSS to SVG via Satori
Now I needed to create some SVG for each post. Satori allows me to use JSX for this, which is a nice touch. It supports a subset of HTML and CSS, making heavy use of Flexbox for layout (as far as I understand, it uses Facebook's Yoga layout engine.)
For this site, this ended up looking like this:
const svg = await satori(
<div
style={{
display: "flex",
height: "100%",
width: "100%",
padding: 60,
flexDirection: "column",
backgroundImage: "linear-gradient(to bottom, #222, #333)",
color: "rgba(255, 255, 255, 0.8)",
textShadow: "5px 5px 5px rgba(0, 0, 0, 0.5)",
}}
>
<div style={{ fontSize: 60, fontWeight: 700 }}>hmans.dev</div>
<div
style={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
justifyContent: "flex-end",
}}
>
<div style={{ color: "hotpink", fontSize: 90, fontWeight: 700 }}>
{page.data.title}
</div>
<div style={{ fontSize: 60, fontWeight: 700 }}>{page.data.subtitle}</div>
</div>
</div>,
options
);
As you can see, it's just JSX-ed HTML with inline styles.
The options
object you can see at the end of that snippet configures output dimensions and available fonts. The latter were a little bit tricky because in order for Satori to even be able to render any text, you need to load the font files into memory and pass them to Satori.
I ended up downloading the Inter font from Google Fonts and loading them into memory like this:
const inter = await Deno.readFile("./src/fonts/Inter-Regular.ttf");
const interBold = await Deno.readFile("./src/fonts/Inter-Bold.ttf");
Now my Satori options
looked like this:
const options: SatoriOptions = {
width: 1200,
height: 627,
fonts: [
{
name: "Inter",
data: inter,
weight: 400,
style: "normal",
},
{
name: "Inter",
data: interBold,
weight: 700,
style: "normal",
},
],
};
Converting the SVG to a PNG
The last step was to convert the SVG to a PNG. For this, I used the Deno resvg-wasm package, a wrapper around the resvg SVG rendering library. This essentially boiled down to:
import { render } from "https://deno.land/x/resvg_wasm@0.2.0/mod.ts";
/* later */
await render(svgForPost(post));
Easy!
Putting it all together
That's it, that's the whole thing! I've put the full template source up on GitHub if you want to take a look and/or steal it. Some potential improvements for the future:
- Caching! At the moment this will regenerate all images for all posts every time the site is built. At the moment, with only a handful of posts, this is fine, but I expect it will get slow and annoying as the site grows.
- Randomization! I could generate a random seed from the blog post title an use that to maybe randomize the background gradients a little, or introduce some other graphical elements. This could be fun!
- Extract into a plugin! Yeah, this is a very obvious candidate for a Lume plugin, but I want to get to know the framework a little better first.