How I (Re)built This Site

Earlier this month I finished redesigning this website. It wasn’t just a facelift, but a full from-the-ground-up rewrite. In this article I’d like to discuss the new tech stack which now powers this site.

Why Rebuild?

The previous version of this website was built on Eleventy 0.9 and copious jank. It was actually running on two build systems, Eleventy for rendering HTML and Gulp (remember Gulp?) for asset handling. These tools were brought together in an unholy union through a mess of npm scripts.

That was… unpleasant, but what was most damning about the old codebase was how hard it was to work on. Every time I went more than a few months without touching the project, something broke and I’d have to jiggle around the dependencies to get it working again. The result, unsurprisingly, is that I just didn’t want to work on the site anymore. That’s different now.

The Setup

If you want to build a website from scratch in $CURRENT_YEAR, you’ll be flush with shiny new JavaScript-y tools to choose from. In spite of that, I’m sticking with Eleventy, because while it’s not perfect it does what I need it to do. While the current wave of frontend frameworks has plenty of great buzzwordy features— server-side rendering, code splitting, client-side routing, etc.— I’m here to tell you that if you’re just building a blog like I am, you’re not gonna need it. All I want is to throw some HTML files up on a server without shipping a single byte of JavaScript to the client, and for that, Eleventy works.

Speaking of Eleventy, it just celebrated its 2.0 release shortly before I started work on the rewrite. Eleventy has certainly come a long way since I built the previous version of this site, and thanks to some new features this time around, the end result is something I feel a lot better about working on going forward.

The biggest improvement is that I’ve managed to integrate all asset processing directly into Eleventy, so it’s now the only built tool I’m using. Another big win was using Eleventy’s plugin system to modularize my configuration file. The main config function is mostly just plugin imports, and the code for all the different asset types is neatly separated.

Directory Structure

Eleventy has a lot of functionality pre-configured out of the box, but I am just not a fan of the directory structure it expects by default. The layout I’ve been using for almost all my web projects for years now is src/ for source (unprocessed) files, public/ for files which should be copied over untouched, and dist/ for build artifacts. Luckily, Eleventy makes this setup easy enough to achieve.

Here’s what that looks like in my eleventy.config.cjs file.

module.exports = function (eleventyConfig) {
	eleventyConfig.addPassthroughCopy({
		'./public/': '/',
	});

	return {
		dir: {
			input: 'src',
			output: 'dist',
		},
	};
};

Build Directory Cleaning

Something that Eleventy doesn’t do out of the box is delete old artifacts between builds. Luckily, clean builds can be achieved by just adding this snippet to the config function.

const fs = require('fs/promises');

eleventyConfig.on('eleventy.before', async ({ dir }) => {
	await fs.rm(dir.output, { recursive: true, force: true });
});

Much better.

Template Engine

Eleventy boasts support for a lot of built-in template languages. I write all of my articles in Markdown, but I still need something to build page layouts with.

I have some beef with template languages. They always end up implementing their own custom syntax for data processing, and any time you need to go beyond the builtins you have to write a bunch of bridging code. I just want to call foo(bar), but now I need to jump through hoops to implement {{ bar | foo }}. I suppose this limitation makes sense with a compiled language, but I’m using a scripting language, so why can’t it just eval() whatever I put in the template?

On the previous version of this site I used EJS, which does let you write JavaScript right in the template, and it was nice. But, I still wasn’t completely happy with it because it felt like there weren’t good options for organizing more complex templates. What I eventually realized is that I didn’t want JS-in-HTML, I wanted HTML-in-JS. I wanted JSX.

Eleventy doesn’t have built-in support for JSX, so I had to implement it myself. First, the JSX files need to be transpiled to regular JavaScript, which I used esbuild to do. After that, I could use vhtml to render it directly to an HTML string without React.

I came up with three ways of integrating this with Eleventy:

  1. Transpile .jsx files to .11ty.js files on disk before building
  2. Implement JSX as a custom Eleventy template language
  3. Extend require() to transpile .jsx as they’re imported

Option #1 would have been easiest to implement, but having the transpiled .js files cluttering up the project was a deal-breaker. Option #2 is probably the most robust solution, but I quickly realized that it would have required by far the most effort to get working. That left #3.

const Module = require('module');
const esbuild = require('esbuild');
const pirates = require('pirates');

function jsxPlugin(eleventyConfig) {
	eleventyConfig.addExtension(['11ty.jsx'], {
		key: '11ty.js',
	});

	if (!('.jsx' in Module._extensions)) {
		pirates.addHook(
			(code, filename) =>
				esbuild.transformSync(code, {
					platform: 'node',
					loader: 'jsx',
					format: 'cjs',
					jsx: 'transform',
					jsxFactory: 'h',
					jsxFragment: 'null',
				}).code,
			{ exts: ['.jsx'] }
		);
	}
};

And with just this little bit of code, I had full JSX support. As far as Eleventy is concerned, these JSX files are just .11ty.js templates, so we have full access to Eleventy data. It even supports ECMAScript modules, something Eleventy normally doesn’t like.

import h from 'vhtml';

export const data = { title: 'Hello World' };

export function render(data) {
	return <p>Hello world</p>;
}

PostCSS Support

For CSS processing, I’ve dropped SASS and replaced it with PostCSS. CSS has come a long way in the last several years, and I’ve found that tools like SASS are just not worth the hassle. PostCSS is a lot more minimal, though that of course depends on your selection of plugins.

To integrate with Eleventy, I added CSS as a custom template language (something Eleventy didn’t support when I last used it). Raw .css files go in, processed .css files come out. My approach is roughly based on this example from the docs.

const postcss = require('postcss');
const loadConfig = require('postcss-load-config');

function cssPlugin(eleventyConfig) {
	let postcssConfig;

	eleventyConfig.addTemplateFormats('css');

	eleventyConfig.addExtension('css', {
		outputFileExtension: 'css',

		async init() {
			postcssConfig = await loadConfig();
		},

		async compile(inputContent, inputPath) {
			const { plugins, options } = postcssConfig;
			const processor = postcss(plugins);

			return async ({ page }) => {
				const config = { ...options, from: inputPath, to: page.outputPath };
				const result = await processor.process(inputContent, config);
				return result.css;
			};
		},
	});
};

As for my selection of plugins, I focused on improving developer ergonomics while still writing mostly “vanilla” CSS files rather than extending the language with a bunch of custom syntax. I chose postcss-import so I could split up my CSS into multiple files and @import them without the performance penalty of actually using @import. I also added postcss-nesting so I could use the new nesting syntax, which is a huge quality of life improvement but has basically no browser support right now. Both of these were good choices, and I’m very happy with how the CSS on this site has come together.