20th Jul 2024–1 Feb 2025   ·   5 min read   ·      

Integrating KaTeX Into Hakyll

Contents
Being quite into mathematics, I sometimes blog about it.
Not as much as I should, I guess, but nowadays when I write maths it feels like a waste to not have it go into either Anki, Org Roam, or a paper, and these notes are not necessarily written/ready for public consumption. Oh well.
There are very capable solutions for rendering LaTeX in html documents out there, which in particular solve the problem of properly aligning the fragments with the rest of the text. One of them is KaTeX, advertising itself to be easily executed on the server-side, avoiding the use of extraneous client-side JavaScript. Integrating it with Hakyll turned out to be relatively straightforward, yet I haven’t seen an actual implementation anywhere; this post is supposed to fill that gap.

My dark MathJax past§

One of my quite strongly held opinions is that, for static websites such as this one, client-side LaTeX rendering is completely unnecessary, and actually just a waste of resources. As a result, I’ve been using MathJax to insert LaTeX fragments into the html after it’s compiled from Markdown. This setupstolen essentially verbatim from Gwernuses the now deprecated mathjax-node-page to crawl through the already rendered html pages, and, upon recognising a math fragment, replaces that text with the rendered formula. The call to mathjax-node-page is trivial to parallelise on a per-file level with something like gnu parallel, and so the whole thing actually works quite well.

However, the fact that this is “external” to Pandoc’s pipeline and requires a separate build.sh file to be created has always felt a bit awkward to me.
Especially because, unlike in Gwern’s case, this site is not super complex to build; there aren’t any other moving parts that would require me to leave Haskell.
Plus, Hakyll is already capable of using ghc’s parallel runtimewhy outsource a part of that to an external tool? At some point, the annoyance I felt at this became stronger than the inertia my old setup had, so here we are.

A brighter future with KaTeX§

Naturally, when you change something you really want to change somethingat least I do—so instead of using MathJax v3’s native support for these kinds of things, why not try something new? An often cited alternative to MathJax is KaTeX, yet another JavaScript library that promises decent maths rendering on the web. This one is pretty good, though; it’s supposed to be faster than MathJax, and has “server side rendering” as a big bullet point on its landing page. Sounds exactly like what I’m looking for.

KaTeX has a cli of the same name, but booting up the node runtime for every single maths fragment sounds perfectly dreadful to me, so let’s not do that. As such, one probably can’t avoid writing at least a little bit of JavaScript. Thankfully, integrating KaTeX into Pandoc itself seems to be a well-trodden path, so other people have already done this for me. For example, pandoc#6651 has a tiny script—essentially just calling katex.​render​To​Stringthat is fed maths on stdin, and then produces html on stdout. Slightly adjusted to support inline and display maths, it looks like this:
import { readLines } from "https://deno.land/std@0.224.0/io/mod.ts";
import katex from "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs";

for await (const line of readLines(Deno.stdin)) {
  try {
    let DISPLAY    = ":DISPLAY ";
    let useDisplay = line.startsWith(DISPLAY);
    let cleanLine  = useDisplay ? line.substring(DISPLAY.length) : line;
    console.log(katex.renderToString(cleanLine, {
      displayMode: useDisplay,
      strict: "error",
      throwOnError: true,
    }));
  } catch (error) {
    throw new Error(`Input: ${line}\n\nError: ${error}`);
  }
}

Having this in place, all that’s left is to crawl through Pandoc’s ast, and feed each maths fragment to KaTeX. Transforming its ast is something that Pandoc does very well, so the code is usually swiftly written. Indeed, both the Block and Inline types have a Math constructor which we can match on.
󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

󠀠

Mind the BlockArgumentsand the “s”.
import Data.Text    qualified as T
import Data.Text.IO qualified as T
import GHC.IO.Handle (BufferMode (NoBuffering), Handle, hSetBuffering)
import Hakyll
import System.Process (runInteractiveCommand)
import Text.Pandoc.Definition (Block (..), Inline (..), MathType (..), Pandoc)
import Text.Pandoc.Walk (walk, walkM)

hlKaTeX :: Pandoc -> Compiler Pandoc
hlKaTeX pandoc = recompilingUnsafeCompiler do
  (hin, hout, _, _) <- runInteractiveCommand "deno run scripts/math.ts"
  hSetBuffering hin  NoBuffering
  hSetBuffering hout NoBuffering

  (`walkM` pandoc) \case
    Math mathType (T.unwords . T.lines . T.strip -> text) -> do
      let math :: Text
            = foldl' (\str (repl, with) -> T.replace repl with str)
                     case mathType of
                       DisplayMath{-s-} -> ":DISPLAY " <> text
                       InlineMath{-s-}  ->                text
                     macros
      T.hPutStrLn hin math
      RawInline "html" <$> getResponse hout
    block -> pure block
 where
  -- KaTeX might sent the input back as multiple lines if it involves a
  -- matrix of coordinates. The big assumption here is that it does so only
  -- when matrices—or other such constructs—are involved, and not when it
  -- sends back "normal" HTML.
  getResponse :: Handle -> IO Text
  getResponse handle = go ""
   where
    go :: Text -> IO Text
    go !str = do
      more <- (str <>) <$> T.hGetLine handle
      if ">" `T.isSuffixOf` more  -- end of HTML snippet
      then pure more
      else go   more

  -- I know that one could supply macros to KaTeX directly, but where is the
  -- fun in that‽
  macros :: [(Text, Text)]
  macros =
    [ ("≔"       , "\\mathrel{\\vcenter{:}}=")
    , ("\\defeq" , "\\mathrel{\\vcenter{:}}=")
    , ("\\to"    , "\\longrightarrow")
    , ("\\mapsto", "\\longmapsto")
    , ("\\cat"   , "\\mathcal")
    , ("\\kVect" , "\\mathsf{Vect}_{\\mathtt{k}}")
    ]

The (T.unwords . T.lines . T.strip -> text) view pattern is because KaTeX really does not seem to like it when there is a line breakeven a semantically irrelevant one—in a formula. Perhaps this is a setting I’ve overlooked. Other than that the code should be reasonably self-explanatory; there are a few macro definitions that are copied from the now deleted build.sh and some fiddling to make the stdout handle actually output the full response.
Seemingly as always when subprocesses are involved, the hardest thing is to actually get all of the incantations right such that buffering does not deadlock your program indefinitely.

The hlKaTeX function, having a Pandoc -> Compiler Pandoc signature, can be given to pandocCompilerWithTransformM like any other function:
myPandocCompiler :: Compiler (Item String)
myPandocCompiler =
  pandocCompilerWithTransformM
    defaultHakyllReaderOptions
    defaultHakyllWriterOptions
    hlKaTeX

And that’s pretty much it!

Adding css§

All that’s left is to include the custom css and special fonts that KaTeX relies upon. The former can be downloaded from their cdn, and the latter are easily obtained from the latest release by copying the fonts directory. The fonts are both reasonably small and loaded on demand, such that the website does not blow up in size with this switch.

Conclusion§

The whole affair was much easier than Inot knowing any JavaScript—expected it to be, and actually turned out to be quite fun. Of course, nothing at all has changed on the user-side of things, which is to say that the new KaTeX fragments look pretty much exactly the same as the old MathJax maths. Still, the warm feeling I had when deleting that build.sh shell script tells me that this was not solely an exercise in futility. Or perhaps I’ve fully embraced rolling the boulder up the hill by now.

If you’re interested, the commit adding it to my setup can be found here.
End of post fleuron Have a comment? Write me an email!