Webmentions + Eleventy Talk
Slides and resources from my talks at JamStack Toronto and Magnolia JS.
Join the Indie Web by adding Webmentions to your serverless Eleventy static site with this step-by-step tutorial. No client-side JavaScript needed!
I am a huge fan of the static site generator Eleventy so far, and I was super excited to try out Webmentions with them.
Webmention is a web standard for mentions and conversations across the web, a powerful building block that is used for a growing federated network of comments, likes, reposts, and other rich interactions across the decentralized social web.
—from IndieWeb.org
They are a cool tool for enabling social interactions when you host your own content. Max Böck wrote an excellent post, Static Indieweb pt2: Using Webmentions, which walks through his implementation. He also created an Eleventy starter, eleventy-webmentions, which is a basic starter template with webmentions support.
So why am I writing this post? Sadly, I started with the eleventy-base-blog, and didn't notice the eleventy-webmentions starter until after I had already built my site. I also struggled to fully build out the functionality, partly because I'm still an Eleventy n00b. So I wanted to share the detailed steps I used in the hopes that it will help more of you join the Indie Web.
The perspective of this post is adding webmentions to an Eleventy site after the fact. The files, folders, and config architecture match the eleventy-base-blog
, but you can likely use this as a starting point for any Eleventy site. Make sure you watch out for spots where your analogous architecture may be different.
This post will cover how to:
The code in this post is a mash up of Max Böck's original post and personal site, the eleventy-webmentions starter, Zach Leatherman's personal site, and the edits I made during my implementation. I am hugely grateful for their work, as I never would have gotten this far without it.
Before we get started, let's outline the setup. My setup uses Eleventy paired with Github and Netlify.
If you're familiar with most Netlify setups, when I push my code to Github, that triggers a build and deploy on Netlify because the content of the main
branch has been updated. We're going to add a cache folder plugin that will stay in our build between deploys. This is where we will save our webmentions so that we only need to import new ones on build.
Another cool tool we will use is Github actions to fake a cron job to trigger deploys on a recurring schedule. This is so we can import new webmentions every X hours.
Finally, we need to save a secret API token for pulling our webmentions. We want to save that securely in our Netlify environment variables. Then, we can use netlify-cli
to use that secret when running in our local environment.
First, we need to sign up with webmention.io, the third-party service that lets us use the power of webmentions on static sites.
If your sign in was successful, you should be directed to the webmentions dashboard where you will be given two <link>
tags. You should put these in the <head>
of your website:
<!-- _includes/layouts/base.njk -->
<link rel="webmention" href="https://webmention.io/sia.codes/webmention" />
<link rel="pingback" href="https://webmention.io/sia.codes/xmlrpc" />
You'll also be given an API key. We want to safely store that WEBMENTION_IO_TOKEN
in our Netlify environment variables.
To use the environment variable locally while developing on your machine, install the netlify-cli
:
npm install netlify-cli -g
And change your development server script to use netlify dev
. It will use your build script save in Netlify and hydrate all environment variables. If your previous script set input and output directories in the command itself, you will need to move those settings to your .eleventy.js config instead.
You probably want some content in your webmentions. If you use Twitter, Bridgy is a great way to bring in mentions from Twitter. First make sure your website is listed in your profile, then link it.
When we run a build with NODE_ENV=production
, we are going to fetch new webmentions from the last time we fetched. These will be saved in _cache/webmentions.json
. These mentions come from the webmention.io API.
When we do any build, for each page:
_cache/webmentions.json
, only keep webmentions that match the URL of the page (for me, this is each blog post).webmentionsByType
function to filter for one type (e.g., likes or replies)size
function to calculate the count of those mentions by typeFirst, we need to set up our domain as another property in our _data/metadata.json
. Let's also add our root URL for use later:
// _data/metadata.json
{
//...other metadata
"domain": "sia.codes",
"url": "https://sia.codes"
}
Next, we'll add a few more dependencies:
$ npm install -D lodash node-fetch netlify-plugin-cache-folder
We will only request new webmentions in production builds. Thus, we need to update our build
script to set the NODE_ENV
in our package.json
. This is the script that should be set as your Build Command in Netlify. To build locally, we'll need that environment variable locally. So add another script called build:local
. For this to work, you main need to push these updates to Github so that Netlify has the new scripts:
// package.json
{
// ... more config
"scripts": {
"build": "NODE_ENV=production npx @11ty/eleventy",
"build:local": "NODE_ENV=production netlify build",
"start": "netlify dev",
// more scripts...
}
To finish setting up our cache folder plugin, we also need to create a netlify.toml file in the root of our project with the following contents:
[build]
command = "npm run build"
[[plugins]]
package = "netlify-plugin-cache-folder"
Now we can focus on the fetch code. Okay, okay, I know this next file is beaucoup long, but I thought it was more difficult to understand out of context. Here are the general steps happening in the code:
_cache/webmentions.json
.// _data/webmentions.js
const fs = require('fs')
const fetch = require('node-fetch')
const unionBy = require('lodash/unionBy')
const domain = require('./metadata.json').domain
// Load .env variables with dotenv
require('dotenv').config()
// Define Cache Location and API Endpoint
const CACHE_FILE_PATH = '_cache/webmentions.json'
const API = 'https://webmention.io/api'
const TOKEN = process.env.WEBMENTION_IO_TOKEN
async function fetchWebmentions(since, perPage = 10000) {
// If we dont have a domain name or token, abort
if (!domain || !TOKEN) {
console.warn('>>> unable to fetch webmentions: missing domain or token')
return false
}
let url = `${API}/mentions.jf2?domain=${domain}&token=${TOKEN}&per-page=${perPage}`
if (since) url += `&since=${since}` // only fetch new mentions
const response = await fetch(url)
if (response.ok) {
const feed = await response.json()
console.log(`>>> ${feed.children.length} new webmentions fetched from ${API}`)
return feed
}
return null
}
// Merge fresh webmentions with cached entries, unique per id
function mergeWebmentions(a, b) {
return unionBy(a.children, b.children, 'wm-id')
}
// save combined webmentions in cache file
function writeToCache(data) {
const dir = '_cache'
const fileContent = JSON.stringify(data, null, 2)
// create cache folder if it doesnt exist already
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir)
}
// write data to cache json file
fs.writeFile(CACHE_FILE_PATH, fileContent, err => {
if (err) throw err
console.log(`>>> webmentions cached to ${CACHE_FILE_PATH}`)
})
}
// get cache contents from json file
function readFromCache() {
if (fs.existsSync(CACHE_FILE_PATH)) {
const cacheFile = fs.readFileSync(CACHE_FILE_PATH)
return JSON.parse(cacheFile)
}
// no cache found.
return {
lastFetched: null,
children: []
}
}
module.exports = async function () {
console.log('>>> Reading webmentions from cache...');
const cache = readFromCache()
if (cache.children.length) {
console.log(`>>> ${cache.children.length} webmentions loaded from cache`)
}
// Only fetch new mentions in production
if (process.env.NODE_ENV === 'production') {
console.log('>>> Checking for new webmentions...');
const feed = await fetchWebmentions(cache.lastFetched)
if (feed) {
const webmentions = {
lastFetched: new Date().toISOString(),
children: mergeWebmentions(cache, feed)
}
writeToCache(webmentions)
return webmentions
}
}
return cache
}
Now that we've populated our webmentions cache, we need to use it. First we have to generate the functions, or "filters" that Eleventy will use to build our templates.
First, I like keeping some filters separated from the main Eleventy config so that it doesn't get too bogged down. The separate filters file will define each of our filters in an object. The keys are the filter names and the values are the filter functions. In _11ty/filters.js
, add each of our new filter functions:
// _11ty/filters.js
const { DateTime } = require("luxon"); // Already in eleventy-base-blog
module.exports = {
getWebmentionsForUrl: (webmentions, url) => {
return webmentions.children.filter(entry => entry['wm-target'] === url)
},
size: (mentions) => {
return !mentions ? 0 : mentions.length
},
webmentionsByType: (mentions, mentionType) => {
return mentions.filter(entry => !!entry[mentionType])
},
readableDateFromISO: (dateStr, formatStr = "dd LLL yyyy 'at' hh:mma") => {
return DateTime.fromISO(dateStr).toFormat(formatStr);
}
}
Now to use these new filters, in our .eleventy.js
, we need to loop through the keys of that filters object to add each filter to our Eleventy config:
// .eleventy.js
// ...Other imports
const filters = require('./_11ty/filters')
module.exports = function(eleventyConfig) {
// Filters
Object.keys(filters).forEach(filterName => {
eleventyConfig.addFilter(filterName, filters[filterName])
})
// Other configs...
I do not have a sanitize HTML filter because I noticed the content data has a text
field that is already sanitized. Maybe this is new or not true for all webmentions. I'll update this post if I add it in.
Now we're ready to put it all together and render our webmentions. I put them at the bottom of each blog post, so in my _includes/layouts/post.njk
, I add a new section for the webmentions. Here, we are setting a variable called webmentionUrl
to the page's full URL, and passing it into the partial for the webmentions.njk
template:
<!-- _includes/layouts/post.njk -->
<section>
<h2>Webmentions</h3>
{% set webmentionUrl %}{{ page.url | url | absoluteUrl(site.url) }}{% endset %}
{% include 'webmentions.njk' %}
</section>
Now we can write the webmentions template. In this example, I will show links, retweets, and replies. First, I set all of the variables I will need for rendering in a bit:
<!-- _includes/webmentions.njk -->
<!-- Filter the cached mentions to only include ones matching the post's url -->
{% set mentions = webmentions | getWebmentionsForUrl(metadata.url + webmentionUrl) %}
<!-- Set reposts as mentions that are `repost-of` -->
{% set reposts = mentions | webmentionsByType('repost-of') %}
<!-- Count the total reposts -->
{% set repostsSize = reposts | size %}
<!-- Set likes as mentions that are `like-of` -->
{% set likes = mentions | webmentionsByType('like-of') %}
<!-- Count the total likes -->
{% set likesSize = likes | size %}
<!-- Set replies as mentions that are `in-reply-to` -->
{% set replies = mentions | webmentionsByType('in-reply-to') %}
<!-- Count the total replies -->
{% set repliesSize = replies | size %}
With our variables set, we can now use that data for rendering. Here I'll walk through only "replies", but feel free to see a snapshot of how I handled the remaining sets in this gist.
Since replies are more complex than just rendering a photo and link, I call another template to render the individual webmention. Here we render the count of replies and conditionally plural-ify the word "Reply". Then we loop through the reply webmentions to render them with a new nunjucks partial:
<!-- _includes/webmentions.njk -->
<!-- ...setting variables and other markup -->
{% if repliesSize > 0 %}
<div class="webmention-replies">
<h3>{{ repliesSize }} {% if repliesSize == "1" %}Reply{% else %}Replies{% endif %}</h3>
{% for webmention in replies %}
{% include 'webmention.njk' %}
{% endfor %}
</div>
{% endif %}
Finally, we can render our replies using that new partial for a single reply webmention. Here, if the author has a photo, we show it, otherwise we show an avatar. We also conditionally show their name if it exists, otherwise we show "Anonymous". We use our readableDateFromISO
filter to show a human-friendly published date, and finally render the text of the webmention:
<!-- _includes/webmention.njk -->
<article class="webmention" id="webmention-{{ webmention['wm-id'] }}">
<div class="webmention__meta">
{% if webmention.author %}
{% if webmention.author.photo %}
<img src="{{ webmention.author.photo }}" alt="{{ webmention.author.name }}" width="48" height="48" loading="lazy">
{% else %}
<img src="{{ '/img/avatar.svg' | url }}" alt="" width="48" height="48">
{% endif %}
<span>
<a class="h-card u-url" {% if webmention.url %}href="{{ webmention.url }}" {% endif %} target="_blank" rel="noopener noreferrer"><strong class="p-name">{{ webmention.author.name }}</strong></a>
</span>
{% else %}
<span>
<strong>Anonymous</strong>
</span>
{% endif %}
{% if webmention.published %}
<time class="postlist-date" datetime="{{ webmention.published }}">
{{ webmention.published | readableDateFromISO }}
</time>
{% endif %}
</div>
<div>
{{ webmention.content.text }}
</div>
</article>
Time to bravely jumping into the black hole...
Does it work?!?! We can finally test it out. First run npm run build:local
to generate an initial list of webmentions that is saved to the _cache/webmentions.json
file. Then run your local development server and see if the rendering worked! Of course, you'll need to have at least one webmention associated with a page to see anything. 😁
You can see the result of my own implementation below. Good luck! Let me know how it turns out or if you find in bugs or errors in this post!
Webmentions let us own our content on our own domains while still engaging socially with other people through likes, replies, and other actions. How have you used webmentions on your site? Tweet at me to let me know!
Continue your journey by using Microformats. Keith Grant has a great write-up in his article Adding Webmention Support to a Static Site. Check out the "Enhancing with Microformats" section for an explanation and examples.
I'm a freelance performance engineer and web developer, and I'm available for your projects.
Hire meSlides and resources from my talks at JamStack Toronto and Magnolia JS.
Setting and using data in the static site generator Eleventy
Encourage users to retweet or share a post based on whether a Tweet already exists for your blog post.
If you liked this article and think others should read it, please share it.
Oh that's cool! source
Exactly that! source
Really like many elements of your site, incl the fonts. Gonna take closer look soonish and likely copycat some into my aaronpeters.nl source
Thanks! I wanted to experiment with variable fonts. Recursive was really cool because one of the variables lets you do monospace, so my regular copy and my code are actually the same font just monospaced or not. Their site is beyond cool 🕶 recursive.design source
Replied to An In-Depth Tutorial of Webmentions + Eleventy by Sia Karamalegos (sia.codes)Add Webmentions to your Eleventy static site with this step-by-step tutorial.Congratulations Sia, this is awesome! Since most of your responses are coming from Twitter, I thought I’d send you one from WordPress i... source
Yay!!! 🎉🎉 source
@tomayac I'm not a pro myself but https://write.as/ and https://joinplu.me/ do implement it. I don't know if there's anything off-the-shelf Write.as source
@charlag Thanks, I’ll have a look! source
Yay! source
Thanks for share. This is very interesting! source
Thank you for the write-up and the example code! This helped me a lot. ❤ source
You're welcome! source
Ooooh nice adaptation! source
Thanks Bryan - and thank you for writing the post in the first place, it was so insightful and helpful! source
Hey there! I manage content efforts at Fauna. Would you be willing to participate in our write with Fauna program about this project? Please DM me for details. source
Which image is broken for you? source
If you mention it they will come 😅 I will like/comment anything if you need a test source
Thank you! Going to share something now without being too spammy 😅 source
Just shared something here if you don't mind? Just want to see if anything comes up in the Webmentions API (thank you!) twitter.com/daviddarnes/st… source
I thought the same when I set up mine 😆 source
Definitely! source
Thanks for mentions! They don’t seem to be appearing in the API or dashboard, is there a huge delay? source
Oh jinx, I’m like halfway through it! 🙌🏼 source
Oh cool! Let us know how you get on, I’m currently stumbling through it 🥴 source
It's not the easiest thing to set up source
Update: I have mentions data! I'll admit that I didn't quite understand how everything works 😅. But I think I get it, Webmentions.io provides the API and web crawling and Brid.gy adds to it with the walled gardens that are social networks (I think?) source
Awesome! The data flow you describes sounds right 🙌🏼 I’ve done a first pass but I’m not using bridgy yet, so need to ask indieweb pals to send me mentions to test it 😄 source
Happy to do some shares for ya 👍🏻 source
Oooh, bookmarked source
I’ll ping you this evening when I’m back at it 😄 source
Also, webmentions are enabled on my site sia.codes/posts/webmenti… source
It was actually this post by @declan_byrd about importing his Mastodon posts to his website that reminded me... it's going to be my next step.The idea is to self-host all my "notes" (short form toot/tweet like content) so that it doesn't really matter if a server or social media site goes down. Merg... source
@sia thank you! source
@box464 thanks for the shout out! source
@box464 @sia Sia does wonderful work! source
@lewisdaleuk Nice thank you! No major reason beyond just wanting simplicity. Better for my brain if it’s just one thing, at least for a small blog like this. source
@elperronegro @eleventy @mxbck yay glad you found it helpful! source
@henry @sia thanks! I already had a look at her great tutorial but had difficulty making it work with #astro (obviously cause it's for #11ty ????) Asked around in the #discord but no answer yet, hopefully I can make it work some day ???? 11ty astro discord source
@stvfrnzl oh i hear you. good luck mate! and to answer your earlier question, i used Figma for my slides! source
@stvfrnzl While these are mostly 11ty focused, you might find something useful among these blog posts: https://11tybundle.dev/categories/webmentions/ 30 posts in 'Webmentions' source
@bobmonsour niiiiice thank you so much ???? source
Thanks for sharing! Very detailed blog post. I was wondering how you were getting webmentions from Bluesky into webmention.io, but you answered my question by mentioning Bridgy. I'll be able to sleep tonight! source
These are webmentions via the IndieWeb and webmention.io. Mention this post from your site: