Server-side LaTeX rendering in Hugo

Published: | 8 min read

Utilizing post-build processing to achieve server-side LaTeX rendering in Hugo with Katex.

Hugo is an amazing static web framework that enables building fully-featured websites in no time. However, despite its many great features, there persists one rather annoying issue: Mathematics / LaTeX rendering with Hugo

Great efforts have been made by Hugo to support the rendering of LaTeX formulas with tools like MathJax and Katex. There is even an entire documentation page on that matter. However, if you have ever wanted to implement LaTeX rendering in Hugo yourself, you will know that all these official solutions are client-side only.

While client-side rendering is not the end of the world, it is rather unfortunate that JavaScript code must be run by the user to render static LaTeX markup. This is essentially unnecessary work for the browser and may lead to a split-second of flickering or even layout shifts depending on the client. Although there has been a merge request to implement Katex support in Hugo’s markdown renderer, Goldmark, that MR was sadly rejected due to its usage of Cgo. Ever since this MR was closed back in 2020, no official way exists for realizing server-side LaTeX rendering at the time of writing this post (2024).

Naturally, the community came up with its own ways of still realizing server-side rendering. The most famous example being a fork of Hugo: kahugo. Sadly, that fork is no longer actively maintained and using forks is also not particularly preferred, given one needs to manually build and update them. As a second alternative, the maintainer of kahugo described in a blogpost the usage of Hugo’s Pandoc support to achieve server-side rendering. However, as the author notes themself, that approach is rather slow and not ideal.

Forgoing the constraint that the server-side rendering must happen as part of Hugo’s rendering routine, there exists the possibility of utilizing a post build script to achieve server-side rendering.
This approach makes use of a node.js DOM parser to modify the HTML files produced by Hugo to achieve server-side rendering. The following guide will explain how this is done and how you can apply it to your Hugo website too.

Post-build rendering setup

This guide will only focus on utilizing Katex server-side. It may be possible to achieve a similar solution with MathJax as well.

The approach of post-build rendering is quite straightforward. We simply want to perform Katex’s “find-and-replace LaTeX markup” functionality on server-side HTML files. For this we will use the npm package of Katex:

npm i katex

Unfortunately, the server-side (npm) version of Katex does not offer the same functionality that exists in Katex’s client-side version. We cannot just give it an HTML file and get a LaTeX-processed HTML file in return. In order to realize Katex’s find-and-replace, we will have to do the DOM parsing it does ourselves. I.e. we will need to do DOM parsing on the server-side.
There exist many server-side DOM parsers like JSDOM, however, as a more lightweight and modern alternative this guide will use LinkeDom:

npm i linkedom

With these two dependencies set up, we have everything we need to have Katex process an HTML file. However, having access to the DOM on the server-side is not enough to filter out LaTeX code. The DOM API allows us to easily query HTML nodes, but it will not let us specifically query for LaTeX code in the form of $$ ... $$.
The server-side version of Katex only accepts pure LaTeX code strings. That is, we must specifically extract the ... and then replace the $$...$$ snippet with whatever HTML markup Katex gives us.

Preparing HTML markup for DOM parsing

In order to utilize the DOM API to find LaTeX code, we will have Hugo generate HTML that wraps all LaTeX code into HTML <latex> nodes which we can query.

For example, we want Hugo to convert every $$\forall N$$ into something like <latex>\forall N</latex>. Doing this, we can utilize our DOM parser with querySelectorAll("latex") to find every LaTeX code snippet and give its innerHTML to the Katex server-side renderer.

To perform the transformation above with Hugo, we will use a find-and-replace regular expression when we pass the .Content variable into a Hugo template.
At this point, it becomes very important to discuss the different LaTeX delimiters and the distinction between inline and block math expressions.

Commonly $...$ is used to denote inline math and $$...$$ to denote block math. Unfortunately, utilizing both delimiter types simultaneously is not well-behaved with Katex. Using the dollar sign syntax will also make our Hugo regular expression more complicated since we will then need to add rules that account for it being escaped (\$). If we don’t do this then any pair of dollar signs will result in math code!

To make the entire implementation easier it is highly recommended to utilize \(...\) and \[...\] for math inline and block statements respectively. These delimiters are also compatible with normal client-side rendering, meaning they are forward compatible in case one day Hugo officially supports server-side rendering.

Utilizing these delimiters we can now go about replacing them with our special <latex> nodes for later parsing. Do make sure to also enable the Goldmark Passthrough Extension as described in Hugo’s client-side documentation in step 2.

The finding and replacing of the math delimiters in the .Content variable is achieved by this Hugo code:

{{ if .Params.katexServer }}
    {{ $block := replaceRE  "\\\[(.*?)\\\]" "<span class=\"katex-display\"><latex>$1</latex></span>" .Content }}
    {{ $inline := replaceRE  "\\\((.*?)\\\)" "<latex>$1</latex>" $block }}
    {{ $inline | safeHTML }}
{{ else }}
    {{ .Content | safeHTML }}
{{ end }}

Notice that for block math (\[...\]), the <latex> node is further wrapped inside <span class="katex-display">. This is done so that the Katex stylesheet will correctly render the math as a block. Remember that even when processing LaTeX server-side, you must still provide the Katex stylesheet. You can find it as part of the Katex client-side release here.

Post-build script

If all preparation was set up correctly, any math you write in Hugo should now exist inside the final HTML files as wrapped inside <latex> nodes. We can now write our post-build script to have Katex render these nodes into actual HTML markup.

Let’s first take a look at the function that converts the input HTML with the <latex> nodes into the final processed HTML:

import katex from "katex";
import {parseHTML} from "linkedom";

function replaceMath(html) {
const {document} = parseHTML(html);
const latexEls = document.querySelectorAll("latex");

    latexEls.forEach((le) => {
    	const processedHtml = katex.renderToString(le.innerHTML);
    	le.outerHTML = processedHtml;
    });

    return document.documentElement.outerHTML;

}

We iterate over every <latex> node and give the innerHTML of it to Katex for rendering. Finally, we replace the outerHTML of the node with the processed HTML from Katex. This way we get rid of the <latex> nodes which are obviously not a valid HTML element.
Once we have done this for every node, we return the processed document as a string (documentElement.outerHTML).

Voila! We have successfully done server-side LaTeX rendering.

Of course, we are not done. We still need to write the processed HTML back into the file we got it from. Furthermore, we obviously want to automate this process by iterating over every HTML file in Hugo’s output directory. We can accomplish all of this with the node:fs module:

import fs from "node:fs/promises";

const targetDir = "/tmp/hugoOutputDirectory/";
const map = new Map();
const files = await fs.readdir(targetDir, {recursive: true});

files
// To optimize further, you could consider parsing the Hugo frontmatter to find which files
// use katex on the server-side. I.e. "katex-server: true"
.filter((f) => !f.includes("node_modules") && f.endsWith(".html"))
.forEach((f) => {
map.set(f, fs.readFile(targetDir + f));
});

const writeArray = [];

for (const [fileName, v] of map) {
const content = (await v).toString();
const convertedContent = replaceMath(content);
// You might want to optimize to only write files whose content actually changed
writeArray.push(fs.writeFile(targetDir + fileName, convertedContent));
}

await Promise.all(writeArray);

Note that the code above is already partially optimized by utilizing Promise.all() and filtering certain file paths. If you are using Linux, I recommend you to change your Hugo output directory to a path that is tmpfs mounted, given this script will (re)write a lot of files.

You are welcome to adjust the script to your liking and further optimize it, of course.

Summary and caveats

The outlined solution works quite well for achieving server-side LaTeX rendering in Hugo. Arguably, it can be a bit convoluted to set up and the fact it relies on modifying generated HTML files is also not nice. Personally, I still prefer it in comparison to having maintain a Hugo fork or to hack into Hugo’s HTML build process. That said, this approach does come with one major issue of its own:

Live reloading is practically impossible. Because the script is post-build, it will not be automatically triggered as part of the hugo server command. This means that whenever you want to see your rendered LaTeX code you must manually call the script every time a reload happens.

If you are writing a text that includes a lot of LaTeX code, this can be rather frustrating. Of course, you can always use some software to preview your Markdown in, but it does defeat the purpose of hugo server.

Provided you can tolerate this issue and any other shortcomings of this guide’s solution, I hope you can utilize this approach for your own Hugo website!