Turning manu.ninja up to 11ty

I recently migrated manu.ninja to Eleventy. Never have I ever had such a pleasant experience with a static site generator, and I’ve tried many of them. This is why I want to share five snippets from my setup that might be useful for your own Eleventy projects.

Spinal Tap

Eleventy’s goal is to be a simpler static site generator, a JavaScript alternative to Jekyll. It transforms a directory of templates into HTML. In contrast to most other static site generators you can choose your own templating language. It’s zero config by default, but quick and easy to hack your own collections, data, filters, shortcodes, transforms and plugins.

Eleventy has taken the JavaScript community by storm, so I won’t talk about who’s using it, for what projects, and how they’re using it. I also won’t mention that it’s a joy to get good performance out of Eleventy sites. I just want to share five snippets from my configuration, in the hope that they might be useful to you. So let’s get started.

Calculate related posts for each post

The ability to add collections is very powerful. You can do anything you want with the array that you get by filtering your content. For example, you can map through all of your posts and add a property related to each of them.

To get the related articles for each post I implemented a very simple scoring system. A blog post gets a point for each category and tag that matches the current blog post’s categories and tags. Then I sort the blog posts by score and slice the array to its top five articles.

eleventyConfig.addCollection('posts', function (collection) {
const posts = collection.getFilteredByGlob('src/posts/*.md').reverse();
return posts.map((a) => {
let related = [];
posts.forEach((b) => {
if (a.data.permalink !== b.data.permalink) {
const alpha = [...a.data.categories, ...a.data.tags].sort();
const beta = [...b.data.categories, ...b.data.tags].sort();

const matches = alpha.filter((keyword) => {
return beta.includes(keyword);
});

const score = matches.length;
if (score > 1) {
related.push(Object.assign({}, b, { score }));
}
}
});
a.data.related = related.sort((a, b) => b.score - a.score).slice(0, 5);
return a;
});
});

I also don’t add the whole related post objects to the related property. This will lead to an error in Eleventy, telling you that you Tried to use templateContent too early. Supposedly it introduces a circular reference, which is why I had to replace the following line with a factory that gives me only the data necessary to display my related posts:

        if (score > 1) {
- related.push(Object.assign({}, b, { score }));
+ related.push(Object.assign(relatedPost(b), { score }));
}
function relatedPost(post) {
return {
title: post.data.title,
url: post.data.external || post.url,
language: post.data.language,
isExternal: !!post.data.external,
categories: post.data.categories,
};
}

Prevent orphans with a safe title wrap

Good typography on the web is hard. Due to the web’s fluid nature it’s also very easy to get orphans in your text, that is the last word of a title or sentence jumps to the next line.

You can manually fix this by inserting   (non-breaking space) characters. Older developers remember them from table layouts, but their true purpose is to provide a whitespace where the browser is not allowed to insert a line break. I have manually added these characters to my page titles in the past … I really don’t want the browser to insert a link break anywhere in Vue CLI 3.

When moving to Eleventy I thought it was time for a filter that does this for me. It checks the last two words in a string and enters   characters if both strings are shorter than 10 characters.

eleventyConfig.addFilter('safeTitleWrap', (title) => {
const tokens = title.split(' ');
const end = [tokens.pop(), tokens.pop()];
return (
tokens.join(' ') +
(end[0].length + end[1].length < 10 ? '&nbsp;' : ' ') +
end[1] +
'&nbsp;' +
end[0]
);
});

Yes, 10 characters is an arbitrary number, but it fits my page titles. The filter manages to “glue” the last three words together if necessary. By the way, did you know that in German the typographic “orphan” is called “Hurenkind”, which translates to “son of a bitch”?

Compile, minify and inline Sass stylesheets

I am using node-sass directly to compile and minify my Sass stylesheets. The output then goes into the shortcode styles, so you can write {% styles %} in any template. To inline styles on every page I used the shortcode in the <head> of my _includes/layouts/default.njk template.

eleventyConfig.addShortcode('styles', () => {
const { css } = sass.renderSync({
file: __dirname + '/../styles/index.scss',
outputStyle: 'compressed',
});
return `<style>${css}</style>`;
});

I had first naively implemented the filter without any caching. The Sass compilation ran for every one of my pages, that is almost a hundred times during an Eleventy build. When I watched my files and aggressively safed between code changes (I hit ⌘S a lot …), this number exploded, as Eleventy runs the whole build process for each file change it registers.

This is why I’ve implemented a simple debounce, so that Sass renders the styles every 5 seconds. My Eleventy build takes between 5-10 seconds, so the styles will at least be generated once, at most twice when a build takes very long.

eleventyConfig.addShortcode('styles', () => {
const styles = generateCachedStyles();
return `<style>${styles}</style>`;
});
const { renderSync } = require('node-sass');

let lastModified = 0;
let cachedStyles = '';

module.exports = function generateCachedStylesheet() {
if (lastModified < Date.now() - 5000) {
const { css } = renderSync({
file: __dirname + '/../styles/index.scss',
outputStyle: 'compressed',
});
lastModified = Date.now();
cachedStyles = css;
}
return cachedStyles;
};

Purge CSS for each HTML file separately

The above shortcode compiles, minifies and inlines CSS, but there’s now a lot of dead code on pages that use only few of the available selectors. So I decided to add PurgeCSS to my setup. PurgeCSS needs the finished HTML, so it runs during the last transform in the Eleventy configuration. Also, I only have to do this for production builds.

if (process.env.ELEVENTY_ENV === 'production') {
eleventyConfig.addTransform('purge-styles', purgeStyles);
}

The following script replaces the <style>...</style> block in each HTML with a purged stylesheet, containing only the styles used on that particular page. This removes 75-95% of CSS. For example, the article list only uses 5% of the available styles.

const generateCachedStylesheet = require('./build-styles');
const { PurgeCSS } = require('purgecss');

const styles = generateCachedStylesheet();
const pattern = /<style>.*?<\/style>/s;

module.exports = async function purgeStyles(content, path) {
if (path.endsWith('.html')) {
const [{ css: result }] = await new PurgeCSS().purge({
content: [{ raw: content.replace(pattern, ''), extension: 'html' }],
css: [{ raw: styles }],
});
console.log(path, styles.length, result.length);
return content.replace(pattern, `<style>${result}</style>`);
}
return content;
};

Add offline support and preload articles

I use the Workbox CLI to generate a Service Worker. This gives you offline support for flaky network conditions, like when you use the Wi-Fi on your public transportation. At the same time it preloads articles in the background, so that subsequent page requests are very fast.

To save people’s bandwidth the Service Worker only caches the essentials: HTML files (containing inlined CSS and JavaScript), fonts and my logo(s) and picture. Images in articles use runtime caching, which works similar to a browser’s own HTTP cache. Therefore, the page you have last visited is available offline including its images.

module.exports = {
globDirectory: 'public/',
globPatterns: [
'**/*.{html,woff2}',
'favicon.ico',
'logo.svg',
'author.jpg'
],
swDest: 'public/sw.js',
runtimeCaching: [
{
urlPattern: /\.(?:gif|jpg|png|mp4)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxAgeSeconds: 3600,
maxEntries: 24,
},
},
},
],
};

When you look at the scripts for my production build you can see that the Workbox CLI simply runs after the Eleventy build. If you don’t know npm-run-all, please check it out. It can help you clean up your package.json scripts.

{
"build:clean": "rm -rf public",
"build:eleventy": "ELEVENTY_ENV=production eleventy",
"build:service-worker": "workbox generateSW workbox-config.js",
"build": "npm-run-all build:*"
}

Conclusion

Those were five snippets that I think might be useful for your own Eleventy projects. The source code for manu.ninja is on GitHub, if you want to see the full Eleventy setup. Be sure to check out the list of websites on 11ty.dev/docs/starter for more ideas on how to configure Eleventy. As always, please let me know if you find this article useful or have any feedback or questions.

Why Eleventy?

The ease and speed of how to change everything to your taste is what I felt missing from Jekyll and Gatsby. The first version of manu.ninja was a Jekyll blog. If I wanted to change anything I had to learn Ruby or hope that a plugin already exists. Gatsby on the other hand has a lot of overhead with its source plugins and client-side hydration. It also took 2+ minutes to build the less than a hundred sites that manu.ninja consists of on my faithful MacBook Air (13-inch, Mid 2013).

When I heard about Eleventy the first time I knew It might be a good fit for manu.ninja 4.0. Due to the pandemic I can only work half the usual hours on my job so there was my chance …

You can support manu.ninja via PayPal and buy me a 🍺 or a cup o’ joe.