💾 Archived View for gmi.runtimeterror.dev › dynamic-opengraph-images-with-hugo captured on 2024-08-24 at 23:56:43. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2024-08-18)

🚧 View Differences

-=-=-=-=-=-=-

💻 [runtimeterror $]

2024-02-19

Dynamically Generating OpenGraph Images With Hugo

I've lately seen some folks on social.lol [1] posting about their various strategies for automatically generating Open Graph images [2] for their Eleventy [3] sites. So this weekend I started exploring how I could do that for my Hugo [4] site.

[1] social.lol

[2] Open Graph images

[3] Eleventy

[4] Hugo

During my search, I came across a few different approaches using external services or additional scripts to run at build time, but I was hoping for a way to do this with Hugo's built-in tooling. I eventually came across a tremendously helpful post from Aaro [5] titled Generating OpenGraph images with Hugo [6]. This solution was exactly what I was after, as it uses Hugo's image functions [7] to dynamically create a share image for each page.

[5] Aaro

[6] Generating OpenGraph images with Hugo

[7] image functions

I ended up borrowing heavily from Aaro's approach while adding a few small variations for my OpenGraph images.

Here's how I did it.

New resources

Based on Aaro's suggestions, I used GIMP [8] to create a 1200x600 image for the base. I'm not a graphic designer so I kept it simple while trying to match the site's theme.

[8] GIMP

I had to install the Fira Mono font Fira Mono `.ttf` [9] to my `~/.fonts/` folder so I could use it in GIMP, and I wound up with a decent recreation of the little "logo" at the top of the page.

[9] Fira Mono `.ttf`

Image: Red background with a command prompt displaying "[runtimeterror.dev] $" in white and red font.

That fits with the vibe of the site, and leaves plenty of room for text to be added to the image.

I also wanted to use that font later for the text overlay, so I stashed both of those resources in my `assets/` folder:

Image: File explorer window showing a directory structure with folders such as '.github/workflows', 'archetypes', 'assets' with subfolders 'css', 'js', and files 'FiraMono-Regular.ttf', 'og_base.png' under 'RUNTIMETERROR'.

OpenGraph partial

Hugo uses an internal template [10] for rendering OpenGraph properties by default. I needed to import that as a partial so that I could override its behavior. So I dropped the following in `layouts/partials/opengraph.html` as a starting point:

[10] internal template

<meta property="og:title" content="{{ .Title }}" />
<meta property="og:description" content="{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end }}" />
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}" />
<meta property="og:url" content="{{ .Permalink }}" />
<meta property="og:locale" content="{{ .Lang }}" />
{{- if .IsPage }}
  {{- $iso8601 := "2006-01-02T15:04:05-07:00" -}}
  <meta property="article:section" content="{{ .Section }}" />
  {{ with .PublishDate }}<meta property="article:published_time" {{ .Format $iso8601 | printf "content=%q" | safeHTMLAttr }} />{{ end }}
  {{ with .Lastmod }}<meta property="article:modified_time" {{ .Format $iso8601 | printf "content=%q" | safeHTMLAttr }} />{{ end }}
{{- end -}}
{{- with .Params.audio }}<meta property="og:audio" content="{{ . }}" />{{ end }}
{{- with .Params.locale }}<meta property="og:locale" content="{{ . }}" />{{ end }}
{{- with .Site.Params.title }}<meta property="og:site_name" content="{{ . }}" />{{ end }}
{{- with .Params.videos }}{{- range . }}
<meta property="og:video" content="{{ . | absURL }}" />
{{ end }}{{ end }}

To use this new partial, I added it to my `layouts/partials/head.html`:

{{ partial "opengraph" . }}

which is in turn loaded by `layouts/_defaults/baseof.html`:

  <head>
    {{- partial "head.html" . -}}
  </head>

So now the customized OpenGraph content will be loaded for each page.

Aaro's OG image generation

Aaro's code [11] provided the base functionality for what I needed:

[11] Aaro's code

{{/* Generate opengraph image */}}
{{- if .IsPage -}}
  {{ $base := resources.Get "og_base.png" }}
  {{ $boldFont := resources.Get "/Inter-SemiBold.ttf"}}
  {{ $mediumFont := resources.Get "/Inter-Medium.ttf"}}
  {{ $img := $base.Filter (images.Text .Site.Title (dict
    "color" "#ffffff"
    "size" 52
    "linespacing" 2
    "x" 141
    "y" 117
    "font" $boldFont
  ))}}
  {{ $img = $img.Filter (images.Text .Page.Title (dict
    "color" "#ffffff"
    "size" 64
    "linespacing" 2
    "x" 141
    "y" 291
    "font" $mediumFont
  ))}}
  {{ $img = resources.Copy (path.Join .Page.RelPermalink "og.png") $img }}
  <meta property="og:image" content="{{$img.Permalink}}">
  <meta property="og:image:width" content="{{$img.Width}}" />
  <meta property="og:image:height" content="{{$img.Height}}" />
  <!-- Twitter metadata (used by other websites as well) -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content="{{ .Title }}" />
  <meta name="twitter:description" content="{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end -}}"/>
  <meta name="twitter:image" content="{{$img.Permalink}}" />
{{ end }}

The `resources.Get` [12] bits import the image and font resources to make them available to the `images.Text` [13] functions, which add the site and page title texts to the image using the designated color, size, placement, and font.

[12] `resources.Get`

[13] `images.Text`

The `resources.Copy` line moves the generated OG image alongside the post itself and gives it a clean `og.png` name rather than the very-long randomly-generated name it would have by default.

And then the `<meta />` lines insert the generated image into the page's `<head>` block so it can be rendered when the link is shared on sites which support OpenGraph.

This is a great starting point for what I wanted to accomplish, but I made some changes to my `opengraph.html` partial to tailor it to my needs.

My tweaks

As I mentioned earlier, I wanted to have three slightly-different recipes for baking my OG images: one for the homepage, one for standard posts, and one for posts with an associated thumbnail. They all use the same basic code, though, so I wanted to be sure that my setup didn't repeat itself too much.

My code starts with fetching my resources up front, and initializing an empty `$text` variable to hold either the site description *or* post title:

{{ $img := resources.Get "og_base.png" }}
{{ $font := resources.Get "/FiraMono-Regular.ttf" }}
{{ $text := "" }}

For the site homepage, I set `$text` to hold the site description:

{{- if .IsHome }}
  {{ $text = .Site.Params.Description }}
{{- end }}

On standard post pages, I used the page title instead:

{{- if .IsPage }}
  {{ $text = .Page.Title }}
{{ end }}

If the page has a `thumbnail` parameter defined in the front matter, Hugo will use `.Resources.Get` to grab the image.

{{- with .Params.thumbnail }}
  {{ $thumbnail := $.Resources.Get . }}

<-- note -->

The `resources.Get` function [14] (little r) I used earlier works on *global* resources, like the image and font stored in the site's `assets/` directory. On the other hand, the `Resources.Get` method [15] (big R) is used for loading *page* resources, like the file indicated by the page's `thumbnail` parameter.

<-- /note -->

[14] `resources.Get` function

[15] `Resources.Get` method

Since I'm calling this method from inside a `with` block I use a ` gemini - kennedy.gemi.dev in front of the method name to get the parent context [16]. Otherwise, the leading `.` would refer directly to the `thumbnail` parameter (which isn't a page and so doesn't have the method available).

[16] parent context

Anyhoo, after the thumbnail is loaded, I use the `Fit` image processing method [17] to scale down the thumbnail. It is then passed to the `images.Overlay` function [18] to *overlay* it near the top right corner of the `og_base.png` image.

[17] `Fit` image processing method

[18] `images.Overlay` function

  {{ with $thumbnail }}
    {{ $img = $img.Filter (images.Overlay (.Process "fit 300x250") 875 38 )}}
  {{ end }}
{{ end }}

Then I insert the desired text:

{{ $img = $img.Filter (images.Text $text (dict
  "color" "#d8d8d8"
  "size" 64
  "linespacing" 2
  "x" 40
  "y" 300
  "font" $font
))}}
{{ $img = resources.Copy (path.Join $.Page.RelPermalink "og.png") $img }}

All together now

After merging my code in with the existing `layouts/partials/opengraph.html`, here's what the whole file looks like:

{{ $img := resources.Get "og_base.png" }} 
{{ $font := resources.Get "/FiraMono-Regular.ttf" }}
{{ $text := "" }}
<meta property="og:title" content="{{ .Title }}" />
<meta property="og:description" content="{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end }}" />
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}" />
<meta property="og:url" content="{{ .Permalink }}" />
<meta property="og:locale" content="{{ .Lang }}" />
{{- if .IsHome }} 
  {{ $text = .Site.Params.Description }}
{{- end }}
{{- if .IsPage }}
  {{- $iso8601 := "2006-01-02T15:04:05-07:00" -}}
  <meta property="article:section" content="{{ .Section }}" />
  {{ with .PublishDate }}<meta property="article:published_time" {{ .Format $iso8601 | printf "content=%q" | safeHTMLAttr }} />{{ end }}
  {{ with .Lastmod }}<meta property="article:modified_time" {{ .Format $iso8601 | printf "content=%q" | safeHTMLAttr }} />{{ end }}
  {{ $text = .Page.Title }} 
{{ end }}
{{- with .Params.thumbnail }} 
  {{ $thumbnail := $.Resources.Get . }}
  {{ with $thumbnail }}
    {{ $img = $img.Filter (images.Overlay (.Process "fit 300x250") 875 38 )}}
  {{ end }}
{{ end }}
{{ $img = $img.Filter (images.Text $text (dict
  "color" "#d8d8d8"
  "size" 64
  "linespacing" 2
  "x" 40
  "y" 300
  "font" $font
))}}
{{ $img = resources.Copy (path.Join $.Page.RelPermalink "og.png") $img }} 
<meta property="og:image" content="{{$img.Permalink}}">
<meta property="og:image:width" content="{{$img.Width}}" />
<meta property="og:image:height" content="{{$img.Height}}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ .Title }}" />
<meta name="twitter:description" content="{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end -}}"/>
<meta name="twitter:image" content="{{$img.Permalink}}" />
{{- with .Params.audio }}<meta property="og:audio" content="{{ . }}" />{{ end }}
{{- with .Site.Params.title }}<meta property="og:site_name" content="{{ . }}" />{{ end }}
{{- with .Params.videos }}{{- range . }}
<meta property="og:video" content="{{ . | absURL }}" />
{{ end }}{{ end }}

And it works!

Image: Black background with text "Dynamic Opengraph Images With Hugo", a command prompt "[runtimeterror.dev] $", and colorful hexagon shapes with "HUGO" letters.

I'm sure this could be further optimized by someone who knows what they're doing. I'd really like to find a better way of positioning the thumbnail overlay to better account for different heights and widths. But for now, I'm pretty happy with how it works, and I enjoyed learning more about Hugo along the way.

---

📧 Reply by email

Related articles

SilverBullet: Self-Hosted Knowledge Management Web App

Generate a Dynamic robots.txt File in Hugo with External Data Sources

Automate Packer Builds with GithHub Actions

---

Home

This page on the big web