Implementing Open Graph
These days blog posts get discovered primarily on social media and if you want to leave a good impression Open Graph (OG) images add a nice touch of polish. This is an indieweb blog and in true DIY fashion I wanted to implement OG support myself.
Here's the final result:
I started out by creating a plausibly decent background image with my avatar (and my cat Sutr0). Nothing helps build credability more than a friendly cat picture on the internet. I used the open source image editor Krita to create a jpeg. That part was easy at least.
Programmatic Image Manipulation
In theory, my chosen platform of AWS Lambda is really fucking good at tweaking images. Streaming from S3 was one of the original serverless use cases. In the olden days the AWS Lambda runtime had a copy of ImageMagick just chilling there for you to use. These days, its up to you to figure out how you want to manipulate images. I do not want to get native code involved, because this cascades into other issues, so I started evaluating pure JS image manipulation implementations.
I started out with PureImage which felt promising at first but became quickly clear while it may be "pure" JS output the source was TypeScript and despite the myriad dist options the module wasn't loading with a basic Node 20.x esmodule; while possible to maybe add another compilation pass to fix it I wasn't interested in compounding the problem with more tangentially related solutions.
My second pass was with the JIMP library. While it too has all the transpiling shenanigans the distribution worked without issue in a basic Node esmodule. Back in business! Similar to so many other libraries these days this one is also huge on disk but I'm not concerned about coldstart performance here as we'll be putting the OG images in a completely isolated Lambda function that isn't user facing.
The handler code for serving an image dynamically is pretty clear:
import preflight from '@architect/views/preflight.mjs'
import arc from '@architect/functions'
import render from './render.mjs'
export let handler = arc.http(fn)
async function fn (req) {
let title = req.params.title
let data = await preflight(req)
let meta = data.meta.find(e => e.link === `/notes/${ title }`)
if (!meta) return {
code: 404,
html: `Could not find: ${ title }`
}
let out = await render({
title: meta.title,
link: `https://webdev.rip${ meta.link }`,
summary: meta.summary,
author: 'Brian LeRoux',
published: 'Posted on ' + new Intl.DateTimeFormat('en-US', {dateStyle: 'long'}).format(new Date(meta.date)),
})
return {
statusCode: 200,
headers: {
'content-type': 'image/jpeg',
'cache-control': 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0'
},
isBase64Encoded: true,
body: out.toString('base64')
}
}
The render
function implementation is where the image drawing logic lives:
import jimp from 'jimp'
import fs from 'node:fs'
import path from 'node:path'
export default async function render ({title, link, summary, author, published }) {
const dir = import.meta.dirname
const base = path.join(dir, 'og-image.jpg')
const img = await jimp.read(base)
const serif = await jimp.loadFont(path.join(dir, `calistoga-white-72.fnt`))
const sans = await jimp.loadFont(jimp.FONT_SANS_32_WHITE)
const sansmol = await jimp.loadFont(jimp.FONT_SANS_16_WHITE)
// write the title
img.print(serif, 60, 60, title)
// draw the summary; maybe doing a cheesy line wrap
let tokens = summary.split(/\s+/)
let max = 10
let y = 180
if (tokens.length > max) {
let lines = []
for (let i = 0; i < tokens.length; i += max) {
lines.push(tokens.slice(i, i + max))
}
for (let line of lines) {
img.print(sans, 60, y, line.join(' '))
y += 32
}
}
else {
img.print(sans, 60, y, summary)
}
// draw the footer stuff
img.print(serif, 320, 400, author)
img.print(sansmol, 320, 480, published)
img.print(sans, 320, 512, link)
return img.getBufferAsync(jimp.MIME_JPEG)
}
Maybe the hardest part was creating my own cheesy line wrap function. 😂 Otherwise, I wanted a slightly nicer title typeface so I used SnowB to transform Calistoga TTF into a BMP font. You can browse all the Lambda source code here.
Syntax Highlighting Side Quest
Upon realizing I wanted to write a blog post about my incredible OG journey the concept of syntax highlighting suddenly became a requirement. Classic moment of software discovery. Similar to image manipulation there are legions of syntax highlighting solutions within the JS ecosystem. Here too, Sturgeons Law rules, where 90% of them are, respectfully, bloated crapware. I settled on Prism. It's light, fluffy, well supported, battle tested, and runs completely server-side so I don't need to pollute my client-side with a bunch of janky DOM manipulations. View source on this page to see my custom <hd-code></hd-code>
element for syntax highlighting. Side quest completed!
Final Boss
The final boss for implementing OG is adding a handful of meta
elements to the head.
<meta property=og:title content="webdev.rip - a web developers blog by Brian LeRoux">
<meta property=og:type content=website>
<meta property=og:url content=https://webdev.rip/notes/implementing-open-graph>
<meta property=og:image content=https://webdev.rip/og-img/implementing-open-graph>
And thats it!