I’ve now got a simple, ruby-only, automated way of making Twitter summary cards and OpenGraph image previews for the posts on this blog. Now I just need to iterate on the currently horrible version-zero look of them!

Historically you would have done this using javascript calling Puppeteer driving a headless Chrome instance to generate the screenshot. Mikkel Hartmann has a great write up of doing it this way.

Ruby now has the awesome Ferrum gem which uses the Chrome DevTools Protocol to control Chrome, so you can drop the need for JS.

How I did it

In my Jekyll install I’ve got a _cards symbolic link to my _posts directory, and in my _config.yml I’ve got a defaults section that looks like this:

defaults:
  -
    scope:
      path: "_posts"
    values:
      layout: post  
  -
    scope:
      path: "_cards"
    values:
      layout: card

You need to do this so your posts don’t specify layout: post in their individual frontmatter. If they did, this would override the layout: card that we need the cards collection to use, and we’d just end up with a screenshot of the entire page.

So there’s now a cards collection that mirrors my posts, but that render with their own _layouts/cards.html layout. I use this to generate the post image from.

Because of the symbolic link, every _post entry like _posts/2022-05-11-automating-jekyll-card-generation-with-ruby-ferrum.md will have a corresponding /cards/2022-05-11-automating-jekyll-card-generation-with-ruby-ferrum.html page when you run jekyll serve or jekyll build. It’s these pages we’ll point Chrome at to generate the images.

After adding the Ferrum gem to my Gemfile, I can use this ruby to make images for my posts:

require "Rubygems"
require "Ferrum"

def generate_card(browser, card, png, options={})
  browser.go_to("http://localhost:4000/cards/#{card}")
  # see all the options here https://github.com/rubycdp/ferrum#screenshots
  browser.screenshot(path: "./images/cards/#{png}",
                     full: true,
                     # final image size is window_size x scale
                     scale: 2)
end

browser = Ferrum::Browser.new(window_size: [800, 418])

# Check what cards we need to make
Dir.glob("_posts/*").each do |post|

  post = File.basename(post, ".md")
  png  = post + ".png"
  card = post + ".html"

  generate_card(browser, card, png) unless File.exists?("./images/cards/#{png}")
end

This will use your local version of the jekyll site to create the screenshots from. To ensure it’s running, and for a bunch of other tasks, I use a Makefile. Here’s a simplified version:

default: deploy

clean-site:
# Do this step to ensure we don't accidentally publish drafts etc
	rm -rf _site

# JEKYLL_ENV=production to avoid localhost urls in your jekyll-seo-tag links
serve-site:
	 JEKYLL_ENV=production jekyll serve & echo $$! > /tmp/jekyll.pid && sleep 15

build-cards: serve-site
	ruby ./scripts/generate-cards.rb
	kill -9 $(shell cat /tmp/jekyll.pid)
	rm -f /tmp/jekyll.pid

deploy: clean-site build-cards
	rsync --progress --delete -a -e ssh  _site/ gooby.org:~/jay.gooby.org/public

The deploy recipe cleans the site, serves it, generates the images using the ruby script, then “deploys” the site by copying it to my remote host with rsync. Because it’s the default recipe, I can just call make in the root of my project to get the site live.