Implementing Webmentions on a 11ty blog

Implementing Webmentions on a 11ty blog

We had an introduction into what are Webmentions, now let's put this to use and implement them on this blog.

Note: I used Max Böck's article and his code to implement them on my blog.

The Webmentions will look like this:

Webmentions

Step 1. Signing up for webmention.io

Aaron Parecki made this fantastic FREE tool called webmention.io. It's a hosted solution to receiving web mentions.

The sign-up uses indie-auth, so we need to have a link to our Twitter on our website like this:

<a href="https://twitter.com/DailyDevTips1" rel="me">Twitter</a>

Make sure you have your website domain included on your Twitter profile.

Twitter domain pointer

Once, we are logged in we need to add our two links to our domain:

<link rel="webmention" href="https://webmention.io/{username}/webmention" />
<link rel="pingback" href="https://webmention.io/{username}/xmlrpc" />

Replace {username} with your actual domain like daily-dev-tips.com.

Step 3. Connect tweets as Webmentions

So, now we can receive Webmentions, but in all honesty, who will send us a Webmention?

Let's convert people tweeting about our URLs to Webmentions!

We can use a hosted service like bridgy for that.

Just click on the Twitter icon to login.

You can then crawl your website and poll Twitter.

Bridgy polling

Bridgy only gets the most recent Tweets, but you can add a Tweet URL in the Resend for the post button.

Responses with actual Webmentions will look like this:

Webmentions in Bridgy

Step 4. Eleventy function to retrieve all our Webmentions

Now that we have everything setup we can go ahead and create a function in Eleventy that will collect all the Webmentions for the webmention.io API.

In eleventy we can add a custom Data file in our _data folder.

Let's call it webmentions.js

const fs = require('fs');
const fetch = require('node-fetch');
const unionBy = require('lodash/unionBy');
const domain = 'daily-dev-tips.com';

// Load .env variables with dotenv
require('dotenv').config();

// Define Cache Location and API Endpoint
const CACHE_DIR = '_cache';
const API = 'https://webmention.io/api';
const TOKEN = process.env.WEBMENTION_IO_TOKEN;

async function fetchWebmentions(since, perPage = 10000) {
    if (!domain) {
        // If we dont have a domain name, abort
        console.warn('>>> unable to fetch webmentions: no domain name specified in site.json');
        return false;
    }

    if (!TOKEN) {
        // If we dont have a domain access token, abort
        console.warn('>>> unable to fetch webmentions: no access token specified in environment.');
        return false;
    }

    let url = `${API}/mentions.jf2?domain=${domain}&token=${TOKEN}&per-page=${perPage}`;
    if (since) url += `&since=${since}`;

    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 filePath = `${CACHE_DIR}/webmentions.json`;
    const fileContent = JSON.stringify(data, null, 2);
    // create cache folder if it doesnt exist already
    if (!fs.existsSync(CACHE_DIR)) {
        fs.mkdirSync(CACHE_DIR);
    }
    // write data to cache json file
    fs.writeFile(filePath, fileContent, err => {
        if (err) throw err;
        console.log(`>>> webmentions cached to ${filePath}`);
    })
}

// get cache contents from json file
function readFromCache() {
    const filePath = `${CACHE_DIR}/webmentions.json`;

    if (fs.existsSync(filePath)) {
        const cacheFile = fs.readFileSync(filePath);
        const cachedWebmentions = JSON.parse(cacheFile);

        // merge cache with wms for legacy domain
        return {
            lastFetched: cachedWebmentions.lastFetched,
            children: cachedWebmentions.children
        };
    }

    // no cache found.
    return {
        lastFetched: null,
        children: {}
    };
}

module.exports = async function () {
    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') {
        const feed = await fetchWebmentions(cache.lastFetched);
        if (feed) {
            const webmentions = {
                lastFetched: new Date().toISOString(),
                children: mergeWebmentions(cache, feed)
            }
            writeToCache(webmentions);
            return webmentions;
        }
    }
    return cache;
}

A massive file, but basically it reads web mentions for the endpoint at:

https://webmention.io/api/mentions.jf2?domain=${domain}&token=${TOKEN}

It will then merge them with our cache file. This function will run once we build our eleventy blog, so it's not realtime.

To make it realtime, we can leverage other endpoints, but I won't go into that. Find more on Shawn's blog

By making this data file, we can access a variable called {{ webmentions }}.

Step 5. Display Webmentions on our blog

As mentioned we now have the {{ webmentions }} variable.

And in my case, I want to split out the following elements from Webmentions:

  • Likes
  • Repost/Retweets
  • Mentions and Replies

So on our blog page layout let's add the following:

// layouts/post.njk
{% include "partials/components/webmentions.njk" %}

In this Webmentions file we will load all the mentions.

We will first need to get the full URL of the current page:

{% set currentUrl %}{{ site.url + page.url | uniUrlFilter }}{% endset %}

I created a uniUrlFilter, since I'm using quite a lot of emoji's in my URL's

module.exports = function uniUrlFilter(value) {
    return encodeURI(value);
};

Then we need to retrieve the Webmentions for this specific URL:

{%- set mentions = webmentions.children | getWebmentionsForUrl(currentUrl) -%}

And this filter will sort them into a neat array.

const sanitizeHTML = require('sanitize-html');

module.exports = function getWebmentionsForUrl(webmentions, url) {
    const likes = ['like-of'];
    const retweet = ['repost-of'];
    const messages = ['mention-of', 'in-reply-to'];

    const hasRequiredFields = entry => {
        const { author, published, content } = entry;
        return author.name && published && content;
    };
    const sanitize = entry => {
        const { content } = entry;
        if (content['content-type'] === 'text/html') {
            content.value = sanitizeHTML(content.value);
        }
        return entry;
    };

    return {
        'likes': webmentions
            .filter(entry => entry['wm-target'] === url)
            .filter(entry => likes.includes(entry['wm-property'])),
        'retweet': webmentions
            .filter(entry => entry['wm-target'] === url)
            .filter(entry => retweet.includes(entry['wm-property']))
            .filter(hasRequiredFields)
            .map(sanitize),
        'messages': webmentions
            .filter(entry => entry['wm-target'] === url)
            .filter(entry => messages.includes(entry['wm-property']))
            .filter(hasRequiredFields)
            .map(sanitize)
    };
}

As you can see, I filter on three different elements of a Webmention to sort them per piece.

We can then loop over them in our webmentions.njk partial.

<ol>
{% for webmention in mentions.likes %}
    <li class="webmentions__item">
        <a {% if webmention.url %}href="{{ webmention.url }}"{% endif %} target="_blank" rel="noopener noreferrer" title="{{ webmention.author.name }}">
            {% if webmention.author.photo %}
                <img src="{{ webmention.author.photo }}" alt="{{ webmention.author.name }}" width="48" height="48" loading="lazy">
            {% else %}
                <img src="{{ '/assets/images/avatar-default.jpg' | url }}" alt="" width="48" height="48">
            {% endif %}
        </a>
    </li>
{% endfor %}
</ol>

<ol>
{% for webmention in mentions.retweets %}
    <li class="webmentions__item">
        <a {% if webmention.url %}href="{{ webmention.url }}"{% endif %} target="_blank" rel="noopener noreferrer" title="{{ webmention.author.name }}">
            {% if webmention.author.photo %}
                <img src="{{ webmention.author.photo }}" alt="{{ webmention.author.name }}" width="48" height="48" loading="lazy">
            {% else %}
                <img src="{{ '/assets/images/avatar-default.jpg' | url }}" alt="" width="48" height="48">
            {% endif %}
        </a>
    </li>
{% endfor %}
</ol>

<ol>
{% for webmention in mentions.messages %}
    <li class="webmentions__item">
        <a {% if webmention.url %}href="{{ webmention.url }}"{% endif %} target="_blank" rel="noopener noreferrer" title="{{ webmention.author.name }}">
            {% if webmention.author.photo %}
                <img src="{{ webmention.author.photo }}" alt="{{ webmention.author.name }}" width="48" height="48" loading="lazy">
            {% else %}
                <img src="{{ '/assets/images/avatar-default.jpg' | url }}" alt="" width="48" height="48">
            {% endif %}
        </a>
        <strong>{{ webmention.author.name }}</strong>
        <time class="dt-published" datetime="{{ webmention.published | w3DateFilter }}">
            {{ webmention.published | dateFilter }}
        </time>
        {{ webmention.content.html | safe }}
    </li>
{% endfor %}
</ol>

They're we go, just add some styling and your ready to showcase Webmentions on your Eleventy blog.

Thank you for reading, and let's connect!

Thank you for reading my blog. Feel free to subscribe to my email newsletter and connect on Facebook or Twitter

Did you find this article valuable?

Support Chris Bongers by becoming a sponsor. Any amount is appreciated!