brandon.hornseth

A Minimal HTML+CSS to OpenGraph Image Generator

tech

I’ve been wanting a simple way to generate Open Graph images for posts on this Jekyll blog and finally got all the pieces glued together. This script I wrote lives in my repository as bin/og. It’s definitely not portable to other operating systems, not user-friendly, and relies on some random packages I’ve installed from Homebrew, but it works and it saves me several minutes per year, so it was definitely worth the effort!

To start, I made a barebones HTML page that included all the CSS needed to render the open graph image. Once I had that in place, I converted it to an ERB template and replaced the static content with template variables.

In order to inject the right content, I needed to parse the Frontmatter from the jekyll posts. I thought Jekyll might have a fancy way of doing this, but it doesn’t. It’s just a straightforward regular expression:

module Jekyll
  class Document
    YAML_FRONT_MATTER_REGEXP = %r!\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)!m.freeze

    def read_content(**opts)
      self.content = File.read(path, **Utils.merged_file_read_opts(site, opts))
      if content =~ YAML_FRONT_MATTER_REGEXP
        self.content = Regexp.last_match.post_match
        data_file = SafeYAML.load(Regexp.last_match(1))
        merge_data!(data_file, :source => "YAML front matter") if data_file
      end
    end

Here’s an IRB session replaying the key parts of this code.

>> YAML_FRONT_MATTER_REGEXP = %r!\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)!m.freeze

# Read the file from disk
>> post_content = File.read('_posts/2026-01-12-a-minimal-html-to-image-opengraph-generator.md')

# Test the file contents against the regex
?> if post_content =~ YAML_FRONT_MATTER_REGEXP
?>   # the regex matched! parse the match as YAML
?>   YAML.safe_load(Regexp.last_match(1), permitted_classes: [Date])
>> end
=> 
{
  "layout" => "post",
  "title" => "A Minimal HTML+CSS to OpenGraph Image Generator",
  "date" => #<Date: 2026-01-12 ((2461053j,0s,0n),+0s,-Infj)>,
  "category" => "tech",
  "description" => "Working on the blog is more fun than writing the blog",
  "opengraph_image" => nil
}

Now that I’m able to dynamically generate the HTML, the last step is converting that to an image. There are loads of online services for this like HTCI, all of which I’m sure are great. But the last thing I want is another account for something I’m going to use at most a few times per month. If we’re going to try to do this locally, most of the ideas I found use headless Chrome. I happen to have a laptop with Chrome installed, so I set about figuring out how to make use of that. Turns out aside from the Mac OS clunkiness of running an Application from the command line, it’s pretty straightforward.

# Generate a screenshot image using Google Chrome in headless mode
# NOTE: we oversize the image because of a bug in Chrome that causes a white
# bar to appear at the bottom of the screenshot
system(
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
  "--headless=new",                           # Run Chrome in headless mode using the newer implementation
  "--disable-gpu",                            # Disable GPU acceleration, not needed
  "--run-all-compositor-stages-before-draw",  # Helps ensure the render is complete
  "--hide-scrollbars",                        # Hide scrollbars
  "--window-size=920,600",
  "--force-device-scale-factor=3",
  "--screenshot=path-to-output.png",
  "file:///path/to/tempfile.html"
)

A couple of important things I found through trial and error with this setup:

  1. There’a an annoying bug in Chrome that causes ~80px tall white bars at the bottom of the image. They don’t seem keen on fixing it, so I ended up having to oversize the image and passing it through Image Magick to trim the whitespace off.
  2. The device scale argument is key. That tells chrome to render for a 3x pixel density display, which ensures the final image is high quality and free of any text aliasing artifacts.

Here’s the full script:

#!/usr/bin/env ruby

require 'bundler/setup'

# Load the gems in the gemfile so we can use code from Jekyll
Bundler.require(:default)
require 'erb'
require 'jekyll'
require 'date'
require 'yaml'
require 'tempfile'
require 'fileutils'

TEMPLATE_PATH = '_erb/opengraph-alt.html.erb'

if ARGV.empty?
  puts "Usage: #{$0} <path_to_post_file>"
  exit 1
end

post_file = ARGV.first

unless File.exist?(post_file)
  puts "Error: File '#{post_file}' not found"
  exit 1
end


content = File.read(post_file)

if content =~ Jekyll::Document::YAML_FRONT_MATTER_REGEXP
  frontmatter_yaml = $1
  frontmatter = YAML.safe_load(frontmatter_yaml, permitted_classes: [Date])

  # Read and render the ERB template
  template = ERB.new(File.read(TEMPLATE_PATH))
  result = template.result_with_hash({
    title:       frontmatter.fetch('title'),
    date:        frontmatter.fetch('date'),
    description: frontmatter.fetch('description')
  })

  # Convert the post filename to a slug for the output image filename
  post_basename = File.basename(post_file, File.extname(post_file))
  post_slug_source = post_basename.sub(/^\d{4}-\d{2}-\d{2}-/, '')
  post_slug = Jekyll::Utils.slugify(post_slug_source)
  post_slug = post_basename if post_slug.empty?
  output_path = File.expand_path("#{post_slug}-og.png", Dir.pwd)

  # Create a temporary HTML file to hold the rendered template
  Tempfile.create(['opengraph', '.html']) do |html|
    html.puts(result)
    html.flush
    
    # Generate a screenshot image using Google Chrome in headless mode
    # NOTE: we oversize the image because of a bug in Chrome that causes a white
    # bar to appear at the bottom of the screenshot
    system(
      "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
      "--headless=new",
      "--disable-gpu",
      "--run-all-compositor-stages-before-draw",
      "--hide-scrollbars",
      "--window-size=920,600",
      "--force-device-scale-factor=3",
      "--screenshot=#{output_path}",
      "file://#{html.path}"
    )

    # Trim the white bar off the bottom of the screenshot
    system("magick", output_path, "-trim", "+repage", output_path)

    # Optimize the PNG file size using pngcrush
    optimized_path = output_path.sub(/\.png\z/, "-crushed.png")
    system("pngcrush", output_path, optimized_path)
    FileUtils.mv(optimized_path, output_path, force: true)
  end
else
  puts "Error: No frontmatter found in '#{post_file}'"
  exit 1
end