Dec 9: Building a blog
Post categories
Frontend Developer
This is the ninth post in the Fastmail Advent 2024 series. The previous post was Dec 8: Guiding principles. The next post is Dec 10: Sunsetting Pobox.
Today we’ll look at how we went about creating our new blog and marketing site, why we chose the tools we did, and how we resolved issues we bumped into along the way.
Moving away from WordPress
N.B. Eleventy now provides a tool to assist with migrating content from WordPress
Our last site was powered by WordPress and, despite its age, it also powers a large proportion of the websites out there. Just because something’s popular doesn’t mean it’s the right tool for us. The added costs of running a WordPress instance aren’t insignificant, and making large, sweeping changes to any aspect of the site required a level of WordPress knowledge that few people wanted to learn (or admit they knew). So, what we wanted was something simple, that anyone can contribute to, and considerably lowered our running costs.
Meet Eleventy
Eleventy (11ty) is a Static Site Generator (SSG). It’s also a tool that fit our needs perfectly. It was written with Node (1 fewer language to know), allowed us to write content in markdown, and spat out HTML which can be hosted anywhere. It’s also really fast. Adding extra functionality to it is also dead simple.
For example, we can easily add a new font family like so:
// -----------------
// source/_data/webfonts.js
// -----------------
const config = {
// ...
roca: [
{ weight: THIN, name: 'th' },
{ weight: LIGHT, name: 'lt' },
{ weight: REGULAR, name: 'rg' },
],
};
function getRocaVariant({ weight, name }) {
return {
'@font-face': {
'font-family': 'roca',
'src': `url(/assets/fonts/roca/rocaone-${name}-webfont.woff2) format('woff2')`,
'font-weight': weight,
'font-style': 'normal',
'font-display': 'swap',
},
};
}
export default { roca: config.roca.map(getRocaVariant) }
// -----------------------------
// source/_includes/layouts/base.liquid
// -----------------------------
<style>
{%- toCSS from: webfonts.roca -%}
</style>
// ------------
// .eleventy.js
// ------------
eleventyConfig.addPassthroughCopy('source/assets/fonts');
eleventyConfig.addLiquidTag('toCSS', function (/* liquidEngine */) {
return {
parse(tagToken) {
this.args = new Hash(tagToken.args);
},
*render(context, emitter) {
const { from } = yield this.args.render(context);
// Always a good idea to cache processed CSS/JS where you can.
// This can speed your builds up significantly.
if (cssCache.has(from)) {
emitter.write(cssCache.get(from));
return;
}
let css = '';
for (const ruleset of from) {
let stringifiedDecl = '';
for (const [selector, declarations] of Object.entries(
ruleset,
)) {
for (const [prop, value] of Object.entries(
declarations,
)) {
stringifiedDecl = `${stringifiedDecl}${prop}:${value};`;
}
css = `${css}${selector}{${stringifiedDecl}}`;
}
}
// Process your CSS however you like
const { css: processedCss } = yield processCss(css);
cssCache.set(from, processedCss);
emitter.write(processedCss);
},
};
});
There, not much code and now adding a font to the site is super easy!
Eleventy doesn’t make you do everything though, they provide plugins for optimising images, per-page CSS/JS/HTML bundling, syntax highlighting and many, many more. There’s also a very active community out there full of tips, tricks and guides that’ll help you get the most out of the software.
Leveraging our design system
The design system we use in the Fastmail client is called Elemental. It’s well suited to displaying content in an information-dense environment without overwhelming users. While in the exploratory phase of rebuilding the site, we quickly discovered our existing spacing and typography didn’t fit our vision for the new site. Everything was too compact! We wanted a new set of design tokens that adapted well to any screen size and reduced the burden on a designer to create different layouts for different viewports.
Utopia met these needs precisely for us and provided a great framework for thinking about new layouts. In hindsight, I think we could have gotten away with a much simpler set of tokens, but I won’t deny the confidence it gave us in moving forward on this project.
Partial site building
We’ve got quite a few images on our site, mainly due to the number of blog posts we have—it goes all the way back to 2001! An unfortunate side-effect of this is that it significantly increases how long a build takes simply due to the sheer number of images we need to process and generate.
For draft posts, we don’t need to build every post on the blog. In fact, we only need the post (or posts) the author is working on. To achieve this, we can use permalink objects to tell Eleventy that it shouldn’t render the content of a particular page.
// -------------------------
// source/_data/NO_RENDER.js
// -------------------------
// If we set the value of a page's permalink to this object, then the content
// won't be rendered during build. Under the hood, Eleventy is checking if the
// object has a `build` property to decide if it should render the page during
// static generation.
export default { norender: '' };
// --------------------------------------
// source/content/posts/+data.11tydata.js
// --------------------------------------
// Now, we can do something like this in a directory data file:
function shouldRenderPost(data) {
// Posts to render
return data.env.postsToRender
? data.env.postsToRender.some(
(id) =>
id === data.id + '' ||
id === data.permalink ||
`/blog/${id}/` === data.permalink ||
path.normalize(id) === path.normalize(data.page.inputPath)
)
: true;
}
export default {
layout: 'layouts/post',
tags: ['posts'],
eleventyComputed: {
permalink: function (data) {
return shouldRenderPost(data)
? data.permalink
: data.NO_RENDER;
},
},
};
You can get the list of posts that have changed with a few git commands. Some of this will depend on your build environment, the following code is applicable to Cloudflare Pages:
#!/usr/bin/env bash
set -euxo pipefail
filter() {
while read line; do
for x in $line; do
if $1 "$x"; then
echo "$x"
fi
done
done
}
isBlogContent() {
local EXT=${1##*.}
[[ $1 == source/content/posts/* && $EXT == 'md' ]]
}
if [[ -n "${CF_PAGES_BRANCH+x}" ]] && [[ $CF_PAGES_BRANCH == draft* ]]; then
git remote set-branches origin '*'
git fetch --depth=100 origin production $CF_PAGES_BRANCH
CHANGES=$(git diff --name-only origin/production...HEAD | filter isBlogContent | tr '\n' ' ' | xargs)
POST_IDS=$CHANGES npx eleventy
else
npx eleventy
npm run pagefind:index
fi
This significantly sped up build times on draft branches, going from ~5 minutes to ~1:30 per build.
Creating a workflow for developers
We want people to be able to publish blog posts by simply merging a PR. But if anyone can contribute, how will they know what should go in each field of the front matter? What tags can they use? How do they find their author ID? Sure, you can write some documentation, but wouldn’t it be better to write a few simple scripts that help us enforce content rules?
These scripts go a long way to empowering a developer to contribute without getting bogged down in the idiosyncrasies of our particular system. Tools like enquirer help you bash out a quick script that keeps your content consistent while providing an easy-to-use CLI.
Eleventy also provides hooks to validate your data during build. And don’t forget, if you want to use existing site data in these scripts, use the same tools as Eleventy such as LiquidJS and gray-matter.
Enabling non-technical contributors
So far this is all sounding great if you know how to use git. Just write some markdown and go. But if you’re unfamiliar with git, then writing a simple blog post can seem daunting. We needed a CMS that integrated with our repository, didn’t keep our data behind a proprietary API, and allowed technical and non-technical contributors to use the workflow that felt most comfortable to them.
CloudCannon meets each of these requirements and fully enables anyone to contribute to the website. Rather than storing your data, CloudCannon integrates with your site by reading your existing content and providing an interface to create, edit, and delete it. You can further enhance your integration by using their component workflow, Bookshop. This will also allow you to build pages using a visual editor by composing Bookshop components in their UI.
I recommend checking out CloudCannon’s GitHub profile, they have a lot of interesting projects geared toward enhancing static sites.
Edge worker enhancements
We can’t quite get all the way with static assets alone. For our use case, we wanted to display localised currencies on our pricing page based on the user’s country. We chose Cloudflare Pages as our hosting platform, so here we’ll use a Pages Function to achieve the desired outcome.
Cloudflare provide a great interface for transforming HTML on the fly. Simply build an HTMLRewriter
instance and use the transform
method to modify the body of your response. For us, it looks something like this:
// ------------------------------
// functions/pricing/[country].ts
// ------------------------------
// Imported from our 11ty project
import billingCountries from '../../source/_data/billingCountries.json';
export const onRequest: PagesFunction<Env, 'country'> = async (context) => {
// ...
return new HTMLRewriter()
.on('[data-plan-id] [data-swap]', {
element(el) {
el.replace(swapElements[el.getAttribute('data-swap')], {
html: true,
});
},
})
.on('[name="select-currency"] option', {
element(el) {
if (el.hasAttribute('selected')) {
el.removeAttribute('selected');
}
if (el.getAttribute('value').toLowerCase() === country) {
el.setAttribute('selected', '');
}
},
})
.on('[data-tax-notice]', {
element(el) {
el.setInnerContent(swapElements['taxNotice']);
},
})
.on('[name="plan-type"]', {
element(el) {
if (!url.searchParams.has('plan-type', 'business')) {
return;
}
const isBusiness = el.getAttribute('value') === 'business';
if (isBusiness) {
el.setAttribute('checked', 'checked');
} else {
el.removeAttribute('checked');
}
},
})
.transform(await context.env.ASSETS.fetch(pageURL));
}
// --------------------------
// functions/pricing/index.ts
// --------------------------
import billingCountries from '../../source/_data/billingCountries.json';
const supportedCountries = billingCountries.reduce((countries, country) => {
countries.add(country.code);
return countries;
}, new Set());
export const onRequest: PagesFunction<Env> = (context) => {
let country = context.request.cf.country;
if (!supportedCountries.has(country)) {
country = 'US';
}
const url = new URL(context.request.url);
url.pathname = `/pricing/${country.toLowerCase()}/`;
return Response.redirect(url.href, 302);
};
Now, when a user visits fastmail.com/pricing/, they’ll be redirected to a page with prices in their currency (if we support it).
Unfortunately, this is the part of our site that isn’t portable. Hopefully, once WinterCG picks up steam, we’ll start seeing much better compatibility between these different runtimes. But for now, using an Edge worker does mean accepting a certain level of lock-in with your provider.
Wrapping up
Personally, I’ve found Eleventy to be a great piece of software. It puts you in full control of the site you want to build and gives you the tools to extend its functionality with ease.