Math formulas on a chalkboard by Karolina Grabowska via Pexels

Math formulas on a chalkboard by Karolina Grabowska via Pexels

Today, we’ll be adding support for math in our Hugo-generated website!

First, let’s consider that we have two different options for rendering math: client-side or server-side.

Client-side rendering involves sending the raw math code (TeX) and then using JavaScript on the client convert the TeX code into rendered output, whether that’s HTML, or SVG, or some other representation. Client-side rendering is in many cases easier, as it doesn’t require any changes on the part of the content creator, since they just add some JS and CSS to the page, which is then rendered by the client browser, thus it enables a simple workflow.1

Server-side rendering is done at the time of the site generation, which is preferred as it is done once (since our site is static), the page loads much faster (don’t need to load extra JavaScript) and renders much faster (no client-side JavaScript running to render math).

Given this, the server-side approach is much more preferred due to its overall efficiencies, but most folks choose to use client-side rendering since the server-side rendering adds a few difficulties for the developer.

Let’s take a look to see what’s involved in making this work, specifically with Hugo — if you’re using another static site generator, or you’re writing HTML manually yourself, you may not have the same constraints as I do.

Spoiler: if you want to see what I ended up using, jump ahead to the Conclusion section at the end of this post.

Client-side rendering

Today, we will be using KaTeX and MathJax, popular JavaScript libraries for client-side rendering of mathematical formulas and equations. From some quick research, KaTeX is faster than MathJax 2, but the MathJax 3 has improved performance significantly over its previous version.

That said, what I read suggests that MathJax has more complete support for LaTeX, so if one doesn’t work for you, try the other one.

In the end, it’s up to you which one you use, and in this post, we will add optional support for both MathJax and KaTeX, and you can choose which one to use for each individual page of your site. The support is optional so that when you don’t enable either of them, the libraries will not be loaded, leading to a faster loading experience for your users.

We’ll start with the instructions from this blog post, but with some additional adjustments to get more complete support for LaTeX features we need.

First, create layouts/partials/helpers/katex.html with the following contents:

<link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.css"
    integrity="sha384-MlJdn/WNKDGXveldHDdyRP1R4CTHr3FeuDNfhsLPYrq2t0UBkUdK2jyTnXPEK1NQ"
    crossorigin="anonymous"
    referrerpolicy="no-referrer">

<script
    defer
    src="https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.js"
    integrity="sha384-VQ8d8WVFw0yHhCk5E8I86oOhv48xLpnDZx5T9GogA/Y84DcCKWXDmSDfn13bzFZY"
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
    type="text/javascript"></script>

<script
    defer
    src="https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/contrib/auto-render.min.js"
    integrity="sha384-+XBljXPPiv+OzfbB3cVmLHf4hdUFHlWNZN5spNQ7rmHTXpd7WvJum6fIACpNNfIR"
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
    type="text/javascript"></script>

<script type="text/javascript">
  document.addEventListener("DOMContentLoaded", function() {
    renderMathInElement(document.body, {
      delimiters: [
        {left: "$$", right: "$$", display: true},
        {left: "\\[", right: "\\]", display: true},
        {left: "$", right: "$", display: false},
        {left: "\\(", right: "\\)", display: false},
      ],
    });
  });
</script>
Expand to see my current version of katex.html.
<link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.css"
    integrity="sha384-MlJdn/WNKDGXveldHDdyRP1R4CTHr3FeuDNfhsLPYrq2t0UBkUdK2jyTnXPEK1NQ"
    crossorigin="anonymous"
    referrerpolicy="no-referrer">

<script
    defer
    src="https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.js"
    integrity="sha384-VQ8d8WVFw0yHhCk5E8I86oOhv48xLpnDZx5T9GogA/Y84DcCKWXDmSDfn13bzFZY"
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
    type="text/javascript"></script>

<script
    defer
    src="https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/contrib/auto-render.min.js"
    integrity="sha384-+XBljXPPiv+OzfbB3cVmLHf4hdUFHlWNZN5spNQ7rmHTXpd7WvJum6fIACpNNfIR"
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
    type="text/javascript"></script>

<script type="text/javascript">
  document.addEventListener("DOMContentLoaded", function() {
    renderMathInElement(document.body, {
      delimiters: [
        {left: "$$", right: "$$", display: true},
        {left: "\\[", right: "\\]", display: true},
        {left: "$", right: "$", display: false},
        {left: "\\(", right: "\\)", display: false},
      ],
    });
  });
</script>

The above contents were taken from the KaTeX website with minor changes; check back there for any newer versions of files and their hashes.

Similarly, let’s create layouts/partials/helpers/mathjax.html with the following contents per the MathJax docs:

<script type="text/javascript">
  MathJax = {
    tex: {
      displayMath: [['$$', '$$'], ['\\[', '\\]']],
      inlineMath: [['$', '$'], ['\\(', '\\)']],
    },
  };
</script>
<script
    async
    id="MathJax-script"
    src="https://cdn.jsdelivr.net/npm/mathjax@3.2.0/es5/tex-mml-chtml.js"
    integrity="sha384-+BSz3oj3ILMYvOBr16U9i0H4RZRmGyQQ+1q9eqr8T3skmAFrJk8GmgwgqlCZdNSo"
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
    type="text/javascript"></script>
Expand to see my current version of mathjax.html.
<script type="text/javascript">
  MathJax = {
    tex: {
      displayMath: [['$$', '$$'], ['\\[', '\\]']],
      inlineMath: [['$', '$'], ['\\(', '\\)']],
    },
  };
</script>
<script
    async
    id="MathJax-script"
    src="https://cdn.jsdelivr.net/npm/mathjax@3.2.0/es5/tex-mml-chtml.js"
    integrity="sha384-+BSz3oj3ILMYvOBr16U9i0H4RZRmGyQQ+1q9eqr8T3skmAFrJk8GmgwgqlCZdNSo"
    crossorigin="anonymous"
    referrerpolicy="no-referrer"
    type="text/javascript"></script>

Note 1: this is a very simple usage of MathJax meant for illustration and convenience. MathJax is highly configurable, and you can choose specific modules you would like to load, based on your needs. Please see the documentation for more details and adjust for your use case if needed.

Note 2: MathJax does not provide integrity hashes for their JavaScript files; how do we compute them for this and future versions? You have several options:

  • The easy way: put in a fake integrity, and open the URL in Chrome; it will fail to load the resource, but when you open the Developer Console, Chrome will tell you what hash it computed for the file (which didn’t match your fake one), which you can then use yourself.

  • The formal way, thanks to this comment on MathJax issue about subresource integrity:

    $ curl '<URL>' -o - | openssl dgst -sha384 -binary - | openssl base64 -A
    

Another blog post talks about adding the following contents to the layouts/partials/head.html which involves making a copy of your theme’s head.html and then extending it with those contents. However, the theme I’m currently using (Papermod) already supports easy extensibility of the <head> element contents — it provides and auto-includes an empty file layouts/partials/extend_head.html (really, it only has comments) and includes it into layouts/partials/head.html, so I don’t have to branch layouts/partials/head.html and can just create extend_head.html in my own directory.

Thus, in my case, I created a new file layouts/partials/extend_head.html in my directory which overrides the default version of extend_head.html and added the following contents into it:

{{ if (eq $.Page.Params.math "katex") }}
  {{ partial "helpers/katex.html" . }}
{{ else if (eq $.Page.Params.math "mathjax") }}
  {{ partial "helpers/mathjax.html" . }}
{{ end }}

Note: if your theme doesn’t support extensibility for the <head> element easily as Papermod does, you will need to copy your theme’s layouts/partials/head.html and extend it, as described in the other blog post. Also, consider submitting a feature request or providing a patch to your Hugo theme with a similar change as you can see in the Papermod theme for everyone’s convenience.

Finally, to enable math in particular post, we have to add one of the following to the frontmatter:

  • math: katex to enable KaTeX for the post
  • math: mathjax to enable MathJax for the post

Here’s an example snippet of this post’s frontmatter, which is using KaTeX:

---
title: "Writing math with Hugo"
tags: [hugo, math]
math: katex
---

To switch this post to use MathJax instead, just make the following change:

 ---
 title: "Writing math with Hugo"
 tags: [hugo, math]
-math: katex
+math: mathjax
 ---

Let’s test it out! For starters, here’s a well-known formula:

$$ E = mc^2 $$

and here’s another equation that puts together a few fundamental constants:

$$ e^{i \pi} + 1 = 0 $$

We can also do write integrals:

$$ \int \cos(x) dx = \sin(x) + C $$

and define derivatives:

$$ f’(x) = \lim_{t \to 0} \frac{f(x + t) - f(x)}{t} $$

All seems fine, right? Let’s try a matrix:

$$
  \begin{bmatrix}
    a & b \\
    c & d \\
    e & f \\
  \end{bmatrix}
$$

which renders in MathJax as:2

and in KaTeX as (yes, TeX shows up on the page, mixed with HTML):

Neither of these renderings looks right; what’s going on?

Turns out, Hugo’s Markdown parser (Goldmark) converts the \\ sequences into \<br>, so the above TeX matrix definition results in the following HTML:

<p>
  $$
    \begin{bmatrix}
      a &amp; b \<br>
      c &amp; d \<br>
      e &amp; f \<br>
    \end{bmatrix}
  $$
</p>

However, \\ are newlines in TeX, and critical for matrices as well as aligning multiple equations in a single environment. Since the KaTeX and MathJax processing is happening client-side, by the time the JavaScript code runs, it only sees \<br>, not \\, which means that KaTeX doesn’t process the math inside those regions due to the spurious HTML tag in the middle of the LaTeX region, while MathJax is simply missing the newline.

There are several possible solutions to this issue:

  1. use \\\\ instead of \\ in Markdown source to keep \\ in the output
  2. use \newline instead of \\ in LaTeX code, as that won’t disappear
  3. keep using \\ with a custom shortcode to avoid any processing of the \\

The first two options are straight-forward, and don’t require any configuration, but they only work with MathJax — it won’t help if you’re using KaTeX. Option 3 lets you use the standard \\ newline markers, but requires using the custom shortcode described below, which is a bit of an overhead.

Adding the math shortcode to handle newlines in LaTeX

I saw this identified as an issue in Hugo’s Discourse, and although folks have provided answers, they are not included on the site itself, but rather via external links, and all the links are dead (and not available on the Internet Archive), so there are no complete examples there.3

As discussed in a related thread regarding similar application of MathJax (alternative to KaTeX) together with Goldmark, to pass through the sequence \\ correctly from Markdown all the way to client-side rendering, we need to take a few additional steps.

First, we need to create a Hugo shortcode to keep the LaTeX literal code as-is, without processing by Goldmark. Create a file named layouts/shortcodes/math.html with the following contents:

{{ .Inner }}

which basically just says to pass through the inner contents of the shortcode as-is.

Then, surround the math code block $$ ... $$ with the shortcode, so now it should look like this:4

{{< math >}}
$$
  \begin{bmatrix}
    a & b \\
    c & d \\
    e & f \\
  \end{bmatrix}
$$
{{< /math >}}

and it will render as follows:2

$$ \begin{bmatrix} a & b \\ c & d \\ e & f \\ \end{bmatrix} $$

Success!

Summary

To summarize, here are all the steps that we took to enable client-side rendered math in our posts.

First, one-time setup for your site:

  1. Create the files:
    • layouts/partials/helpers/katex.html and
    • layouts/partials/helpers/mathjax.html
  2. Add the handling for the katex and mathjax params in the frontmatter. Depending on your theme, either:
    • copy your theme’s layouts/partials/head.html into your tree, or
    • if your theme already has a blank partial that’s already loaded into head.html and intended for extensibility, create a file matching that name instead
  3. Create the file layouts/shortcodes/math.html

Then, for each post:

  1. Specify either math: katex or math: mathjax in the frontmatter
  2. Surround math blocks that use \\ with the {{< math >}} shortcode

Here’s the difference in rendering the above matrix in KaTeX vs. MathJax:2

KaTeX
(client-side)
MathJax
(client-side)

The renderings are quite similar with some minor differences in spacing.

Server-side rendering

In the ideal world, we would just integrate the math processing directly into Hugo, so that running the Hugo process, we would get both the Markdown processing as well as the rendering the TeX math code as well. And this is what was tried with a Hugo pull request to add KaTeX server-side math rendering support to Hugo in Feb 2020 which integrates the Goldmark extension for rendering KaTeX with QuickJS5.

However, it was not merged, because Hugo has a policy of not allowing any code that uses C (rather than pure Go), because it limits Hugo’s portability. The one exception that Hugo supports right now is Sass, which is part of the hugo-extended binary, so it looks like the server-side rendering of math via KaTeX will not be merged in the foreseeable future.6

It is worth mentioning that the person who proposed that pull request switched from Hugo to Hexo, another static site generator which is written in Node, to get the benefit of server-side rendered math, because it is so important to them. I’m not ready to move away from Hugo as that will require finding another theme and adapting my entire site to use it, so I am still interested in making this work with my current Hugo workflow.

An alternative was proposed to add goldmark-mathjax to Hugo; however, per a comment on that PR, goldmark-mathjax doesn’t actually do server-side rendering, but rather avoids the need for the {{< math >}} shortcode we needed to add previously. That said, FastAI folks are maintaining a fork of Hugo (repo) with that patch if you’re interested.

The author of goldmark-qjs-katex ended up forking Hugo to their own repository named kahugo with the necessary changes to make it render math statically during site generation. He’s also written two blog posts (one, two) about the development and usage of their KaTeX-enabled Hugo fork.

Using kahugo

If you’re interested in trying this out, you can build kahugo via:

$ git clone https://github.com/graemephi/kahugo.git
$ cd kahugo
$ go build

Note: this requires Go 1.16 or later to build.

The post frontmatter should be changed as follows:

 ---
 title: "Writing math with Hugo"
 tags: [hugo, math]
-math: katex
+math: katexSsr
 ---

Update layouts/partials/extend_head.html as follows:

 {{ if (eq $.Page.Params.math "katex") }}
   {{ partial "helpers/katex.html" . }}
+{{ else if (eq $.Page.Params.math "katexSsr") }}
+  {{ partial "helpers/katex_ssr.html" . }}
 {{ else if (eq $.Page.Params.math "mathjax") }}
   {{ partial "helpers/mathjax.html" . }}
 {{ end }}
Expand to see my current version of extend_head.html.
{{ if (eq $.Page.Params.math "katex") }}
  {{ partial "helpers/katex.html" . }}
{{ else if (eq $.Page.Params.math "katexSsr") }}
  {{ partial "helpers/katex_ssr.html" . }}
{{ else if (eq $.Page.Params.math "mathjax") }}
  {{ partial "helpers/mathjax.html" . }}
{{ end }}

And here’s what should be in layouts/helpers/katex_ssr.html:

<link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.css"
    integrity="sha384-MlJdn/WNKDGXveldHDdyRP1R4CTHr3FeuDNfhsLPYrq2t0UBkUdK2jyTnXPEK1NQ"
    crossorigin="anonymous"
    referrerpolicy="no-referrer">
Expand to see my current version of katex_ssr.html.
<link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.css"
    integrity="sha384-MlJdn/WNKDGXveldHDdyRP1R4CTHr3FeuDNfhsLPYrq2t0UBkUdK2jyTnXPEK1NQ"
    crossorigin="anonymous"
    referrerpolicy="no-referrer">

Server-side rendering does not require using the {{< math >}} shortcode that we developed above for client-side rendering, but if you already have pages using the {{< math >}} shortcode that you would like to make compatible with server-side rendering (as I do), you can update it as follows:

-{{ .Inner }}
+{{ if (eq $.Page.Params.math "katexSsr") }}
+  {{ .Inner | markdownify }}
+{{ else }}
+  {{ .Inner }}
+{{ end }}
Expand to see my current version of layouts/shortcodes/math.html.
{{ if (eq $.Page.Params.math "katexSsr") }}
  {{ .Inner | markdownify }}
{{ else }}
  {{ .Inner }}
{{ end }}

Then, develop and render your site with the kahugo binary you built above:

$ kahugo server [...flags...]

You can then build the minified site for production locally via:

$ kahugo --minify [...flags...]

and if you happen to use Netlify (as I have been for this site), you can upload it manually using Netlify’s CLI via:

$ netlify deploy

To integrate this into Netlify build, add a command to your netlify.toml such as the following (see this blog post for an example):

# Warning: I have not tested this myself, and I'm not using this myself at this
# time; use at your own risk. If this works well for you, please let me know!

[build]
publish = "public"
command = "curl -sL [kahugo binary URL] && ./kahugo --minify"

[context.deploy-preview]
command = "curl -sL [kahugo binary URL] && ./kahugo --minify -b ${DEPLOY_PRIME_URL}"

Note: you would need to provide hosting for your pre-built kahugo binary, since the repo mentioned above does not provide any binaries in the releases section. I wasn’t able to build kahugo directly from the Git repo via a go install or go get commands; YMMV.

Caveats of server-side rendering with kahugo

The downside here is that kahugo, the fork of Hugo with KaTeX support, does not appear to be actively maintained, so you will be stuck on an older version of Hugo, or you will need to take on the maintenance of this fork and re-apply the changes to each new release of Hugo, which I do not have the time for right now.

Additionally, my initial testing of server-side rendering with kahugo shows that variables are not italicized by default, leading to the following rendering of variables in a matrix:2

KaTeX
(server-side)
KaTeX
(client-side)
MathJax
(client-side)

To get equivalent behavior server-side that’s default in client-side rendering with KaTeX and MathJax (and TeX in general), I would have to enclose each variable in {\it ...}, which significantly reduces readability, and increasing amount of typing, so I opted not to use this approach.

Potential server-side rendering alternatives

An issue filed on goldmark-qjs-katex suggests exploring godzilla and/or otto, which are different JavaScript runtimes, and if they don’t depend on CGO, they may be eligible to be included into Hugo mainline by default.

However, this seems like a non-trivial undertaking, and it’s unclear if anyone is currently working on this. If you do make progress on this, please update that issue.

According to Hugo documentation, Hugo supports Pandoc for files with the *.pandoc or *.pdc extension (or the markup value in front matter set to pandoc or pdc), and Pandoc supports rendering math in HTML. This might lead one to assume that we can get server-side rendering of math this way, but alas, most of the options are not applicable:

  • --mathjax (passed by Hugo by default) is a path to the MathJax JS file, and Pandoc just marks the TeX math in the file with the markers that MathJax will recognize
  • --mathml produces MathML, but that’s only supported by Firefox and Safari
  • --webtex converts LaTeX to URL-encoded image URLs which will be rendered by third-party servers dynamically
  • --katex has the same functionality as --mathjax above
  • --gladtex may be the only viable server-side rendering option here, but it requires running an additional command-line tool gladtex after pandoc is run (which is invoked by hugo), which complicates local development since hugo server never exits

There are Pandoc filters for both MathJax as well as KaTeX, so that may be helpful to convert TeX to SVG and display math inline in the HTML documents.

Pandoc seems to be also well-integrated in Quarto, a publishing system for scientific and technical documents. Quarto also supports Hugo, so there’s a potential integration / reuse possibility there as well, without having to switch to another static site generator and look for another theme, and adopt all existing posts to a new blog engine.

Conclusion

Having spent a lot of time researching server-side rendering for math, and trying out the available options for Hugo (which I’ve documented above), I decided to use client-side rendering with KaTeX for now, which is how this page is currently rendered, at the time of publication.

Hope this article and the pointers here have been useful!

If you end up using anything described here, or if you end up pursuing server-side rendering, please let me know how it works for you.


  1. There are also client-side Markdown processors as well as client-side syntax highlighting libraries↩︎

  2. All illustrative renderings in this post (both client-side and server-side) were previously included on the page using declarative shadow DOM with templates and slots in an attempt to isolate the KaTeX and MathJax rendering in a separate DOM subtree, but we ended up needing to contain them via <iframe> to isolate the independent renderings entirely, because without <iframe>, shadow DOM was unable to isolate JavaScript entirely and their effects were leaking out of their shadow DOM container and impacted the rest of the page and other rendered chunks, leading to very confusing results. ↩︎ ↩︎ ↩︎ ↩︎

  3. This is why Stack Overflow likes to avoid link-only answers, because inevitably, external sites go down, disappear, or their domain names are lost, sold, etc. and then the only reference for the solved problem is now gone. Always copy the relevant portion of a code snippet so that your answer is self-contained! ↩︎

  4. Note that to include the shortcode literal into a post, you need to escape the internal contents of {{...}} with /* ... */; for details, see this very helpful blog post↩︎

  5. QuickJS (code) is a JavaScript interpreter written in C. ↩︎

  6. See also additional discussion on another Hugo issue: Consider markdown extensions for math typesetting in Hugo↩︎