How hard could it be to add images for sharing on social media?
Published on , updated on
Social media thumbnails - the cornerstone of society, some would say. These little embedded images in tweets/toots/posts/stories are what make up a lot of the internet traffic these days, and ultimately, determine the number of visitors you will see on your website. It is also a feature my website has been lacking for a very long time.
This is my journey to rectify that.
A few of you are probably shaking your heads, going "just add the image", to which I would say "sure". However, I stumbled across this issue long ago with Zola that asset co-location, the thing that defines how assets get transferred from the raw bucket to the public bucket, changes the paths and makes it tricky to define something as simple as such.
Normally, I would like to create a folder containing two things:
index.md
that Zola parses fromindex.md
This keeps things tidy and not very far apart from one-another, so context is always tied in by the root file structure.
For argument's sake, let's say I wanted to add a post using Zola, and I created a post with the top-level content declaring the title, date, description, and an [extra]
variable containing the image I'd like to use as the cover asset for sharing.
+++
title = "Hello!"
description = "This is a post"
date = 2023-10-31
[extra]
thumbnail = "halloween.jpg"
+++
That [extra]
section contains custom data we'd like to shove into our templating system, so if you are someone who uses a template, it might help for you to understand what [extra]
fields your template incorporates into it's design. Else, if you're like me making your own, let's keep going.
Now, let's try to wedge it into our template.
<h1>{{ page.title }}</h1>
<p>{{ page.description }}</h1>
<img src="{{ page.extra.thumbnail }}" />
You will find out quickly that this, of course, does not work. Why? Because contextually, Zola is not incorporating information about the location of this post with the thumbnail location. By default, "halloween.jpg"
is pointing at the root of the website, which we should say rightfully that it is not.
The next step is to calculate where this URL might actually live, so we need to use a new property of our page, called page.permalink
, which shows us where the content will live when published, so if our content was in content/my-post/
, then our final URL will become /my-post
on the live server. Meaning we can use this to calculate where our thumbnail will be.
Using a concatenation operation, we can join two strings together now.
<h1>{{ page.title }}</h1>
<p>{{ page.description }}</h1>
{% set thumb_url = page.permalink ~ page.extra.thumbnail %}
<img src="{{ thumb_url }}" />
Ta-da! This works! ... For posts that have a thumbnail... Everything else breaks, because the other posts are not likely to have this new [extra]
property we made up.
We can calculate the thumbnail URL path, but now we need to provide some boilerplate logic for the case that a post does not happen to have a thumbnail. Using the above template sample, let's add a simple if
check for it's existence.
{% if page.extra.thumbnail %}
<img src="{{ page.permalink ~ page.extra.thumbnail }}" />
{% endif %}
Thankfully the logic is pretty easy here, if the variable even exists at all, then this if
gets executed, rendering the image tag with our target URL. But, the path to social media thumbnails is a long and treacherous one; we must also provide width and height in order to stub all the information properly.
According to the (warning: Facebook link) Facebook developer pages, Opengraph is a set of tags we can include in our web content that allows people to share your links and have images and other metadata gathered. Unfortunately, this standard has now been adopted to other places, like X, LinkedIn, Mastodon, Discord, Slack, and just about anyone with a major foot in the web game. People are more likely to use your content/application if it plays well with the rest of the players.
It's one thing to provide an image URL and some text, but supplying the width and height is also an important thing to make the lives of other web developers easier. If your social images are set to a constant width and height, it's more easy to deliver your images properly that look as good as possible, and for safety purposes ensure that your image is actually the dimensions it says it is.
Zola can help us with this, using the function get_image_metadata
, which hooks into some code that can read our images and get us back some metadata like width and height. We can use this, provided we add a new block
that contains the appropriate tags for later on.
{% block meta %}
<p>Put this somewhere in your root template</p>
<meta property="og:title" content="Title!" />
<meta property="og:image:width" content="tbd" />
<meta property="og:image:height" content="tbd" />
{% endblock %}
Now by rendering your page, the social sharing thumbnail should include these parts of your page. Now to add the thumbnail itself into the meta.
{% block meta %}
<meta property="og:title" content="{{ page.title }}" />
<meta property="og:description" content="{{ page.title }}" />
<meta property="og:keywords" content="{{ page.title }}" />
<meta property="og:type" content="article" />
<meta property="og:locale" content="en_US" />
<meta property="fb:app_id" content="nonsense, who cares" />
<meta property="og:url" content="{{ page.permalink | safe}}" />
{% if page.extra.thumbnail %}
{% set url = get_url(path=page.path ~ page.extra.thumbnail) %}
{% set meta = get_image_metadata(path=page.path ~ page.extra.thumbnail) %}
<meta property="og:image" content="{{ url | safe }}" />
<meta property="og:image:width" content="{{ meta.width }}" />
<meta property="og:image:height" content="{{ meta.height }}" />
{% endif %}
{% endblock %}
Now I'd say we're done, but not fully done, because we have Twitter additional specs to fit in as well. It's not that bad, all we need to do is wedge in two additional lines for Twitter (renamed X, yes I get tired of reading that line and having to call it X).
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:site" content="@[email protected]" />
These two lines, which I don't particularly give a shit about, affect how Twitter displays your links. I don't have an audience on Twitter, at all, and never will. The first line tells Twitter how to display your link, while the second one is supposed to be the handle of the person on Twitter's platform. Since I don't give a rat's ass about Twitter, I supply it my Mastodon username, which I can only hope messes with their data analytics one day.
Now, you might be tempted to go right ahead and start building your own banner images in your posts - well, you would be correct in that assumption. This code, while sound, is incorrect. You cannot actually build off this setup.
The above code is sound, but it only works if the paths between your input and your output are equivalent. What I mean by this, is that your main Zola content
folder should mirror the output of your public
folder. Let me explain.
This is only a problem if you use a structure for content like I do, where my files are laid out in this pattern:
content/
blog/
yyyy-mm-dd-title/
index.md
halloween.jpg
Whereas, the output becomes:
public/
blog/
title/
index.html
halloween.jpg
This is only a problem if your output slug in your Zola is different from your source content, which for me it sort of is. At build-time, Zola wants something that is supposed to be in content/blog/title
, but because it doesn't exist, Zola fails.
To rectify this, let's examine the docs and inspect one by one each property we might get use out of.
Page.path
contains the path of the output, so noPage.permalink
is the output URL for our live server, not really helpful for thisPage.components
a string-split array of the final path; nopePage.slug
, not a path, but contains the "slug" to use for the URLPage.relative_path
contains the relative directory from the content/
folder, which... is sort of like Page.path
I guess, minus a slash in the frontPage.colocated_path
contains the path from where assets were co-located from, is this our winner?By toying around with Zola and doing some initial print-outs of it to my template, I can see that indeed Page.colocated_path
is actually what I needed. So to fix this, let's change our get_image_metadata()
function to use that instead of Page.path
.
{% set meta = get_image_metadata(path=page.colocated_path ~ page.extra.thumbnail) %}
And just like that, it works fine with zero complaints. Have I finally reached the end of this frustrating journey?
If you generate RSS from a template file, then this part might be helpful as well. I don't think there's a 100% sure-fire way to make sure your images are always picked up in RSS, as I believe it's mostly implementation-based on whoever is writing the RSS reader, but this is what I found works to moderate success.
RSS by itself does not have a meaningful way of expressing images, at least not that I can understand it. RSS is usually made up of an element tree of nodes that hold some contextual data behind it. Usually that goes like this:
<rss>
<channel>
<title>My website</title>
<link>my URL to my webpage</link>
<description>my description</description>
...
This is great for text-based formats, but images are a little more than wedging a URL between two theoretical <img></img>
tags. An image can consist of a URL, attributes like width and height, and even things like alt-text for accessibility. The URL could also in theory not even be a URL, it might be a base-64 encoded string blob, which adds another layer of complexity for RSS readers.
The best way to include an image is to tell an XML parser to not read an image as if it were part of the XML tree. For this we need to use something called "character data", where the text is included literally and can aid RSS readers and give new metadata aspects like including images as thumbnails.
First we start with a CDATA
tag.
<![CDATA[ ... ]]>
This allows us to put whatever we like in here, and here you can insert HTML elements to make it easier for RSS readers to use whatever you feature. So let's wedge an image in.
<![CDATA[<img src="{{ get_url(path=page.path ~ page.extra.thumbnail) }}" />]]>
With this we can now successfully add our thumbnails wherever we so choose, but we're gonna have a problem: RSS relies on the XML spec called "atom", and adding custom elements that feature our images may require us to bring in further resources.
Normally we have a xmlns:atom="https://www.w3.org/2005/RSS"
attribute in our RSS feed to indicate we want to have a namespace group for the Atom format of XML specs, but we can go a bit further to get more. Let's add this: xmlns:content="https://web.resource.org/rss/1.0/modules/content/"
.
This will add a new context for content
, which we can use to incorporate new data into our RSS feed. Just so I have some evidence, this is in use by NPR for their RSS feed.
<content:encoded><![CDATA[...]]></content:encoded>
Mailchimp offers a namespace for additional tags as well, and this is in use by Kotaku. Plug in at the top of your <rss>
tag this attribute as well if you want to try this next bit out: xmlns:media="http://search.yahoo.com/mrss/"
<media:thumbnail url="https://path-to-image.com/image.jpg" />
Should you happen to need further XML goodies, there's a handful of namespaces out there you can pull in for further RSS features. Apple has one for iTunes podcast feeds if you ever find yourself like, making podcasts or something.
I have been trying to add thumbnail images to my website for a very long time, and I scoured the net and couldn't find much. Until I found a random GitHub issue which had my actual problem. Why didn't I find this earlier? Honestly embarassing on my part.
Anyways, thanks for reading and I hope you enjoy the cool hamster I generated with DALL-E. I might not be a fan of the future of AI taking away human jobs and spamming the net with garbage, but I enjoy making dumb art with DALL-E from time to time.
Until next time!