/aftermath

Compile Mermaid, KaTeX, and D2 after your SSG runs. (Designed for use with Hugo)

Primary LanguageJavaScriptMIT LicenseMIT

aftermath

aftermath is a Node script for post-processing HTML that was generated by an SSG tool like Hugo. Its purpose is to "pre-render" some things that would otherwise be rendered with client-side Javascript, e.g. KaTeX equations.

Given a directory of HTML files, aftermath will apply the following transformations:

  1. Replace mermaid code blocks with compiled SVGs.
  2. Replace KaTeX syntax with compiled SVGs.
  3. Replace D2 code blocks with compiled SVGs.

Because aftermath is quite slow for decent sized blogs, I recommend using it for production builds only, where minimal Javascript is desirable. During development, client-side Javascript rendering is probably preferable.

Although designed to work with HTML output by Hugo, in theory aftermath doesn't really care where the HTML came from, and could be used with the output from other SSGs (or even handwritten documents!)

Warning

aftermath processes all the HTML using Cheerio. This may result in some spurious transformation (e.g. changing whitespace or escaping) if the output format of Cheerio's .html() is different than the original HTML.

In particular, HTML typographical character references e.g. –, which are generated by the Goldmark markdown renderer's typographer extension, are converted to their un-escaped Unicode equivalent. This aligns with W3C's recommendation but can cause encoding issues if you don't specify a utf8 charset in your HTML.

Installation and Usage

We can "install" aftermath by adding it as a submodule in our blog's Git repository:

cd projects/blog
git submodule add https://github.com/luketurner/aftermath.git aftermath

The aftermath tool is intended to be run in Docker (or equivalent container builder and runtime). Once we've cloned the repo, we can build an aftermath image:

docker build aftermath -t aftermath

If you want to edit configuration settings, you'll find them in aftermath/src/config.mjs, after which you'll need to rebuild the image.

Now we can run an aftermath container:

docker run aftermath

Just running the container like that won't actually do anything useful, though; aftermath transforms HTML in-place, but we haven't given it any HTML to transform.

In order for aftermath to find our HTML, we need to bind-mount a local directory to the /home/aftermath/input directory in the container.

In our case, for compatibility with Github Pages, we've set our blog's publishDir to /docs. So, that's the folder that we want aftermath to transform.

The resulting docker run call looks like:

docker run --mount "type=bind,source=$(pwd)/docs,target=/home/aftermath/input" aftermath

When doing a production build of our blog, we could use the following script (call it build.sh):

#!/usr/bin/env bash
set -e
hugo
docker run --mount "type=bind,source=$(pwd)/docs,target=/home/aftermath/input" aftermath

FAQ

Is this production ready? Should I use it for my blog?

Although aftermath is open-source, it's ultimately a personal project designed for my specific needs, published without any guarantees of behavior. If you want to use aftermath for your blog, I recommend forking it and modifying the code as needed to suit your unique needs.

Why do I need to run a separate tool after hugo?

Although Hugo configuration and theming is powerful, it's not possible to use custom programs during execution. Something like aftermath could be entirely implemented in a Hugo theme if there was some way to call external programs inside a theme (e.g. an exec shortcode). Or plugins, external helpers, or something. The issue, though, is security: something like an exec shortcode could be easily abused by a malicious theme.

A few related Hugo issues:

Because of the above, Hugo is limited in what functionality you can add without forking the Hugo codebase. There are also Hugo modules but I don't really understand those yet.

(Why didn't I fork the codebase? Maintaining an up-to-date fork of hugo is more effort than I want to put in, but I also don't want to get stuck with an out-of-date fork either! I prefer to program against the generated HTML, which should be a consistent interface even for new hugo versions.)

Why did you use Node?

The two main third-party dependencies -- katex and mermaid-cli -- are both installed with npm. KaTeX even has a Javascript API. So Node felt like the natural choice.

Also, I'm familiar with it, so that helps.

Why is this Dockerized?

aftermath is a simple project, but it has a fair number of dependencies, including the Mermaid CLI which pulls in a full headless Chrome. With Docker, the whole development environment becomes self-contained and consistent across systems.

I'm moving towards having all of my development environments Dockerized, actually. And since aftermath isn't really a polished tool, I haven't taken the time to support a non-Dockerized version. (You can still run it without Docker, of course, it's just not tested/documented.)

How can I support hugo serve?

aftermath isn't suitable for use with Hugo's development server.

In a development setting, it's preferable to do the transformation client-side (in the browser), e.g. with:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.css" integrity="sha384-vKruj+a13U8yHIkAyGgK1J3ArTLzrFGBbBc0tDp4ad/EyewESeXE/Iv67Aj8gKZ0" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.js" integrity="sha384-PwRUT/YqbnEjkZO0zZxNqcxACrXe+j766U2amXcgMg5457rve2Y7I6ZJSm2A0mS4" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/contrib/auto-render.min.js" integrity="sha384-+VBxd3r6XgURycqtZ117nYw44OOcIax56Z4dCRWbxyPt0Koah1uHoK0o4+/RRE05" crossorigin="anonymous"
    onload="renderMathInElement(document.body);"></script>
<script defer src="https://cdn.jsdelivr.net/npm/mermaid@9/dist/mermaid.esm.min.mjs" onload="mermaid.initialize({ startOnLoad: true });"></script>

I've tried to write aftermath so the transformations it performs are exactly the same as the ones done dynamically by the above client-side JS.

Note that d2 doesn't have a client-side transformation option that I'm aware of, so d2 diagrams will just render as source code blocks when using hugo serve.