💾 Archived View for gmi.runtimeterror.dev › dynamic-opengraph-images-with-hugo captured on 2024-05-10 at 10:59:20. Gemini links have been rewritten to link to archived content
-=-=-=-=-=-=-
2024-02-19
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.
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.
[6] Generating OpenGraph images with Hugo
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.
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.
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.
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:
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:
<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 code [11] provided the base functionality for what I needed:
{{/* 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.
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.
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 -->
Since I'm calling this method from inside a `with` block I use a `
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 }}
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!
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.
---
Prettify Hugo RSS Feeds with XSLT
---