Wrapping Emoji in your 11ty build

dozens of emoji stickers piled one on top of the other

Léonie Watson recommends wrapping emoji in a span with an aria-label. On our 11ty pages, we can use the addTransform API to post-process all of our HTML pages. Kitty Giraudel wrote a short transform function which accomplishes the task well in most cases.

In addition to the accessibility improvement, I use CSS text clip to fancy up some of the links and headings on my pages. Geoff Graham explains that there's not yet a CSS-native way to exclude emoji from that hack, so wrapping them up lets me target them with CSS.

But what do you do when emoji land on your page as part of the alt-text of images delivered through third-party services? In my case, I might get mastodon avatars via webmention.io. If you apply the simple content.replace method, you'll end up with broken HTML.

<header class="p-author h-card">
  <a target="_blank" rel="noopener" href="https://social.bennypowers.dev/@bp" class="avatar">
    <img src="https://webmention.io/avatar/yadda-yadda"
         title="Benny Powers <span class="emoji" role="img" aria-label="flag: Canada" title="flag: Canada">🇨🇦</span><span class="emoji" role="img" aria-label="flag: Israel" title="flag: Israel">🇮🇱</span>️">

Yikes! In order to avoid this problem we have to parse the HTML first and only replace emojis we find within text nodes. Let's use Andrea Giammarchi's Linkedom, as well as his emoji-short-name library, to build upon Kitty's solution from above:

const getRegex = require('emoji-regex')
const names = require('emoji-short-name')

const linkedom = require('linkedom');

/** @param {import('@11ty/eleventy/src/UserConfig')} eleventyConfig */
module.exports = function(eleventyConfig, { exclude }) {
    /** @param {string} content */
    function (content) {
      if (!this.page.outputPath.endsWith?.('.html') || (exclude && this.page.outputPath.match?.(exclude))) {
        return content;
      } else {
        const RE = getRegex();
        const { document, Node } = linkedom.parseHTML(content);
        document.querySelectorAll(':is(a, h1, h2, h3, h4, h5, h6, .magic-color, p)').forEach(el => {
          el.childNodes.forEach(node => {
            if (node.nodeType === Node.TEXT_NODE && node.textContent.match(RE)) {
              const cont = document.createElement('div');
              cont.innerHTML = node.nodeValue.replace(RE, emoji =>
                `<span class="emoji" role="img" aria-label="${names[emoji] ?? 'unknown emoji'}">${emoji}</span>`);
        return document.toString();

So for every HTML file written by 11ty that's not matched to the exclude pattern, we'll create a server-side DOM with linkedom, then for each header, paragraph, or element using the background clip hack; for each of it's text node children; if the node contains an emoji, we'll replace that node with a new list of nodes. In the new node list, each emoji character is a new HTMLSpanElement node bearing the appropriate attributes.

This way our emoji will pop out both visually and auditorially, while reducing the chance we'll breaking the page in unexpected ways.