Generating OG images in Elixir | jola.dev
Home
About
Blog
Newsletter
Projects
Talks
I recently added per-page OG images to this blog and it was less complicated than I expected. On top of that, I was able to completely stay within the Elixir ecosystem! If you’re not familiar, OpenGraph image meta tags let you define an image that should be rendered as a preview for the page, which is used in social media and messaging apps.
So let’s start with the requirements: I wanted a low effort approach to creating per blog post OG images, and that new posts should automatically get images without any additional work. Additionally, I wanted matching images for pages like /about. I’m using NimblePublisher to render markdown to HTML at build time and I wanted to follow the same principle for the OG images.
Image library
Step one was figuring out how to generate these images on the fly. The JS community has takumi-js and satori that take a basic HTML template and render it to an SVG/PNG using the Yoga layout engine. This is very convenient because you can write a HTML/CSS and get an image out of it, and it’s fast enough that you can generate images ad-hoc.
import { render } from "takumi-js";
import { writeFile } from "node:fs/promises";
const image = await render(
div tw="w-full h-full flex items-center justify-center bg-gradient-to-b from-blue-100 to-red-50">
h1 tw="text-6xl font-bold">Hello from Takumih1>
div>,
{ width: 1200, height: 630 },
);
await writeFile("./output.png", image);
Now, you could shell out to node to use either one of those libraries and that would probably work fine for me, I already have nodejs in my build environment anyway, but where’s the fun in that! With some minimal digging I found the incredible Image library by the prolific Kip Cole.
Image uses libvips under the hood, but will automatically pull that in for you at build time, so you don’t have to worry about it. The library offers an incredible amount of functionality, of which we’ll just be using a tiny bit. However, it does not have an HTML/CSS layout engine, so this will be more like drawing on a canvas. Nothing we can’t overcome though! Here’s a basic example of what we’re aiming for.
# 1200x630 is the standard OG image size
canvas = Image.new!(1200, 630, color: "#0a0a0a")
# Grab the site logo from disk
{:ok, logo} = Image.open("priv/static/images/logo.png")
{:ok, logo} = Image.thumbnail(logo, 72)
canvas = Image.compose!(canvas, logo, x: 80, y: 80)
# wordmark, vertically centered against the 72px logo
{:ok, wordmark} = Image.Text.text("jola.dev",
font: "Inter", font_size: 48, font_weight: :bold,
text_fill_color: "white")
y = 80 + div(72 - Image.height(wordmark), 2)
canvas = Image.compose!(canvas, wordmark, x: 80 + 72 + 20, y: y)
# Add the page/post title
{:ok, title} = Image.Text.text("Hello, World",
font: "Inter", font_size: 72, font_weight: :bold,
text_fill_color: "white", width: 1040)
canvas = Image.compose!(canvas, title, x: 80, y: 470 - Image.height(title))
# Add the description
{:ok, desc} = Image.Text.text("A description.",
font: "Inter", font_size: 32,
text_fill_color: "#a3a3a3", width: 1040)
canvas = Image.compose!(canvas, desc, x: 80, y: 500)
Image.write!(canvas, "og.png")
And here’s the image I get from running it.
Hooking it up
As mentioned I wanted to follow the basic design of NimblePublisher and generate the images at build time and keep them in memory. This is not necessarily the most efficient way to do this, writing the images to disk and serving them using Plug.Static would be more performant, especially if you’re creating a lot of them, but would also add additional complexity. I’ll probably go there eventually, but for now keeping them in memory works great.
Note that all code examples are simplified for the blog post, take a look at the repo for this blog to see the version with all the edge cases covered https://github.com/joladev/jola.dev/blob/main/lib/jola_dev/og_image.ex.
Starting with mapping page paths to titles and descriptions, we can grab the post.title and post.description from our NimblePublisher posts, and then manage pages like home and about manually. This gives us a single place to control the content that we generate images for.
defmodule JolaDev.OGImage.Catalog do
alias JolaDev.Blog
@static_content %{
"" => {"Home", "The home page!"},
"about" => {"About", "About the page!"},
}
def all_slugs do
static = Map.keys(@static_content)
posts = Enum.map(Blog.all_posts(), &"posts/#{&1.id}")
static ++ posts
end
def content_for("posts/" <> id) do
case Blog.find_by_id(id) do
nil -> :error
post -> {post.title, post.description}
end
end
def content_for(slug) when is_map_key(@static_content, slug),
do: Map.fetch!(@static_content, slug)
end
And then we hook up our...