Implementing Open Graph

   

  

Have you published a response to this?

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: Dynamically generated Open Graph image for this blog post.

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!

webdev opengraph serverless

Selfie of Brian LeRoux sitting with his cat Sutr0
Brian LeRoux on April 3, 2024
Follow me • MastodonGitHub