How to: OpenGraph previews with Hugo


how-to-opengraph-previews-with-hugo.exe
created in this commit
Tags:

Let’s face it. The social preview of my website looks like absolute ass. I guess that makes it consistent with what you can expect when you click a link, but it makes it less motivating to actually share something. For example this is what it looks like on Discord:

An OpenGraph visualisation of the System Stability Post, showing an absurdly large version of my logo.

Yikes. I mean, sure, I like my logo, but this is basically screaming “you know, I never cared enough”. Let’s go and change that!

The star: Go’s templating engine

Hugo is written in Go and Go has html/template in it’s standard library. I am not too sure if this was Rob Pikes wet dream of a functional language, but in any case, that’s how we ended up with an extremely powerful, extendable, and reasonably fast templating engine.

Why a functional language? Simple, take a look at this piece of code we’ll get to later in this post:

{{ $filters = $filters | append
	(images.Text
		$line
		(dict 
			"color" "#000"
			"size" 60 
			"x" 32 
			"y" $y 
			"font" (resources.Get "opengraph-font.ttf")
		)
	)
}}

While not entirely functional (we’re mutating a variable! The horror!), it’s certainly closer than the usual procedural nature most of us are used to.

Inspiration

As “inspiration” (read: I’ll just rip off their style), I’ll take the style of Tachiyomi’s OpenGraph graphic:

A positive example of what an OpenGraph can look like. Not shit.

You can also take a look at it in the wild on FOSStodon.

If you already have a background you want to use, you can just skip straight to the template part

Getting the graphics

Let’s start with the background. Since I will put whatever is needed into the git Repo backing this blog, I’d prefer to generate it instead. Fortunately I know shell scripting and a bit about how to use Imagemagick.

Let’s start with a white canvas we can put the rest of the stuff on.

convert -size 1200x630 xc:white bg.png

basic white background

Neat. Now for the see-through logos in the background. It took me way to long to notice that they are simply placed in rows1. So let’s do that as well. We should probably vary rotation and distance so we don’t get a checkerboard though.

convert -size 1200x630 xc:white \
	$(for i in $(seq 77 400 1200); do echo "( logo.png -resize 64x64 -rotate 155 ) -geometry +$(echo "$i%1200" | bc)-32 -channel A -evaluate multiply 0.1 +channel -composite "; done) \
	$(for i in $(seq -1 300 1200); do echo "( logo.png -resize 64x64 -rotate 70 ) -geometry +$(echo "$i" | bc)+62 -channel A -evaluate multiply 0.1 +channel -composite "; done) \
	# repeat a few times
	bg.png

You might be asking what that idiot is doing there, with those for loops and nested everythings. The answer, dear reader is: I am generating the parameters for Imagemagick. This fills in the ( logo.png -resize 64x64 -rotate 155 ) -geometry +$(echo $i%1200" | bc)-32 -channel A -evaluate multiply 0.1 +channel -composite with the offsets I want and makes the logo’s opacity 10 %.

now with more logos

Looks random enough for me. Now for the smaller logos. We just add some more logos, but with -resize 32x32. The way we make sure these don’t overlap with the already existing logos is by utilizing the one thing mathematicians are afraid of: trial and error.

convert -size 1200x630 xc:white \
	$(for i in $(seq 77 400 1200); do echo "( logo.png -resize 64x64 -rotate 155 ) -geometry +$(echo "$i%1200" | bc)-32 -channel A -evaluate multiply 0.1 +channel -composite "; done) \
	$(for i in $(seq -1 300 1200); do echo "( logo.png -resize 64x64 -rotate 70 ) -geometry +$(echo "$i" | bc)+62 -channel A -evaluate multiply 0.1 +channel -composite "; done) \
	# repeat a few times
 	$(for i in $(seq 310 290 1200); do echo "( "$1" -resize 32x32 -rotate 984 ) -geometry +$(echo "$i%1200" | bc)+550 -channel A -evaluate multiply 0.1 +channel -composite "; done) \
	$(for i in $(seq 20 330 1200); do echo "( "$1" -resize 32x32 -rotate 8946 ) -geometry +$(echo "$i%1200" | bc)+590 -channel A -evaluate multiply 0.1 +channel -composite "; done) \
	bg.png

now with even more logos

Nice! Now we just add an opaque logo in the top-left corner and a bar at the bottom and our background is done:

final background

Putting stuff on the background with templates

Now for the part that was actually day-filling: putting stuff on the background using Go’s templating language and Hugo’s wide range of functions. You can thank me later for letting you copy the hard stuff :D

We start by defining a list of filters (right now with exactly one text-layer that is added to the image):

<!-- define filters -->
{{ $filters := slice
	(images.Text "mpldr" (dict "color" "#000" "size" 40 "x" 112 "y" 38 "font" (resources.Get "opengraph-font.ttf")))
}}
<!-- apply them to the image -->
{{ with resources.Get "opengraph.png" | images.Filter $filters }}
	<!-- and get the link to it -->
	{{ .RelPermalink }}
{{ end }}

Again, just try out positions until you’re happy with the placement. If you struggle with where to put your background and font: inside the assets/ directory that might not exist in your repo yet.2

What you have now is basically what get if you have a page without a title (my index.html for example):

add a name

Wow, that was easy, right! Yes. But we have no logic in there so far. As soon as we add that, shit goes downhill really fast.

{{ $charsPerLine := 30 }}
{{ $count := math.Add (len (seq 0 $charsPerLine (strings.CountRunes .Title))) -1 }}
{{ $i := 0 }}
{{ $title := (safeHTML .Title) }}
{{ $words := strings.Split $title " " }}
{{ $word := 0 }}
{{ $line := "" }}
{{ range $words }}
	{{ $word = math.Add $word 1 }}
	{{ $potentialLine := printf "%s %s" $line . }}
	<!--
	of course CountRunes counts all runes that are not spaces, why would I want
	to know how many spaces there are?! And since these are templates, addition
	is only possible through a function. You see why I called it functional
	earlier?
	-->
	{{ $length := math.Add 
		(strings.CountRunes $potentialLine) 
		(strings.Count " " $potentialLine)
	}}
	<!-- if the word fits onto the line without exceeding our limit -->
	{{ if (le $length $charsPerLine) }}
		{{ $line = $potentialLine }}
		<!-- and is not the overall last word -->
		{{ if (ne $word (len $words)) }}
			<!-- then continue with the next word -->
			{{ continue }}
		{{ end }}
	{{ end }}

	<!-- move up by the number of lines we have and back down based on how many lines we have in total -->
	{{ $y := math.Add ( math.Add 522 ( math.Mul $count -64 ) ) (math.Mul $i 64) }}
	{{ $i = math.Add $i 1 }}
	{{ $filters = $filters | append
	(images.Text
		$line
		(dict 
			"color" "#000"
			"size" 60 
			"x" 32 
			"y" $y 
			"font" (resources.Get "opengraph-font.ttf")
		)
	)
	}}
	<!-- remember: we still have a word left over that didn't fit on the line -->
	{{ $line = . }}
{{end}}

This bad boy does nothing but add the title of the page in lines3. Suffering. Is. Fun. But at least we now have our OpenGraph preview:

final preview

Awesome! Now let’s throw that thing into the header of our website/blog/whatever and take a look at the results.

{{ with resources.Get "opengraph.png" | images.Filter $filters }}
<meta property="og:url" content="{{ $.Page.Permalink }}">
<meta property="og:type" content="website">
<meta property="og:title" content="{{ $.Page.Title }}">
<meta property="og:description" content="{{ $.Page.Description }}">
<meta property="og:image" content="{{ .RelPermalink }}">
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="moritz.sh">
<meta property="twitter:url" content="{{ $.Page.Permalink }}">
<meta name="twitter:title" content="{{ $.Page.Title }}">
<meta name="twitter:description" content="{{ $.Page.Description }}">
<meta name="twitter:image" content="{{ .RelPermalink }}">
{{ end }}

At least that was easy :) Now let’s enjoy the fruits of our labour:

OpenGraph preview in Discord, but this time with the nice graphic

Lessons learned

Lesson 1: Careful with compression

Sure, size is a serious constraint on the internet, but be careful with compression. If the size of your image is an issue, Hugo got you covered: You can just compress the picture to WEBP!

{{ $filters := slice
	(images.Text "mpldr" (dict "color" "#000" "size" 40 "x" 112 "y" 38 "font" (resources.Get "opengraph-font.ttf")))
}}

<!-- do the text stuff or whatever you want -->

<!-- and add the compression *after* you're done -->
{{ $filters = $filters | append (images.Process "webp") }}
{{ with resources.Get "opengraph.png" | images.Filter $filters }}

If you have details though (like the bars of my logo), compression could look quite bad. I have not tried if it is actually visible, but knowing the artifacts are there would bother me.

Lesson 2: Speeding up Imagemagick

My logo is an SVG. That’s great for scaling, and absolutely terrible if you want to generate your background similar to what I did. Every time you add the logo (78 times in my case), it has to first be rasterised. You can speed up processing significantly by “caching” the rasterised version of the image. For example as a PNG. Suddenly generation takes a second instead of 10 minutes.

Lesson 3: With templates, indent before coding

I know that I probably care more about indentation than the average person. With Go templates, I found indentation to be more important than the actual logic. Otherwise you lose yourself in a jungle of braces and have to wrap your head around it from the beginning every time you take a short break. Do yourself the favour: indent your code.

Lesson 4: Order your operations

When you think about it, it’s completely obvious: if you scale down your images, there are fewer pixels the following operations apply to. If you thought about it, good on you. If you didn’t, you’re welcome for significantly faster builds.

Lesson 5: URLs, not paths

Remember the snippet where you include the OpenGraph preview in the header of your page? Turns some, but not all, websites don’t like paths and would prefer URLs. So for maximal compatibility, it should be changed like this:

  {{ with resources.Get "opengraph.png" | images.Filter $filters }}
  <meta property="og:url" content="{{ $.Page.Permalink }}">
  <meta property="og:type" content="website">
  <meta property="og:title" content="{{ $.Page.Title }}">
  <meta property="og:description" content="{{ $.Page.Description }}">
- <meta property="og:image" content="{{ .RelPermalink }}">
+ <meta property="og:image" content="{{ .Permalink }}">
  <meta name="twitter:card" content="summary_large_image">
  <meta property="twitter:domain" content="moritz.sh">
  <meta property="twitter:url" content="{{ $.Page.Permalink }}">
  <meta name="twitter:title" content="{{ $.Page.Title }}">
  <meta name="twitter:description" content="{{ $.Page.Description }}">
- <meta name="twitter:image" content="{{ .RelPermalink }}">
+ <meta name="twitter:image" content="{{ .Permalink }}">
  {{ end }}

  1. Certainly long enough to take a look into how to make something appear random, while being strictly deterministic. ↩︎

  2. I definitely knew that all along and didn’t smash my head against the table until I went into Hugo’s source code just to find out where those assets go! ↩︎

  3. This only works with monospaced fonts. Good luck adapting this for variable-width fonts. Let me know if you manage to do it :) ↩︎


Do you know better? Have a comment? Great! Let me know by sending an email to ~mpldr/public-inbox@lists.sr.ht


If you feel like it, you can Liberapay receiving, or GitHub Sponsors.
Unless stated otherwise the texts of this website are released under CC-BY and code-snippets are released into the public domain.
© Moritz Poldrack

RSS Feed available I am sponsoring the letter @. Yes, that's a thing. This website's content doesn't need AI to be stupid! Website Status