Starting with Hakyll

Posted on July 28, 2026

I started building this ste with hakyll from a clean slate and added bits and peaces I found and liked on the web and from the hakyll template. Alexander Batischev wrote about precompressing files on his hakyll blog and also published the source. I slightly rewrote the compression function to use hopfli and brotli.

brotli :: Rules()
brotli = do
  route $ customRoute $ (<.> "br") . toFilePath
  compile $ do
    identifier <- getUnderlying
    body <- loadBody (setVersion Nothing identifier)
    makeItem body
      >>= brotliCompiler

brotliCompiler :: Item String -> Compiler (Item LBS.ByteString)
brotliCompiler = withItemBody (return . Brotli.compress . LBS.fromStrict . TE.encodeUtf8 . T.pack)

hopfli :: Rules ()
hopfli = do
  route $ customRoute $ (<.> "gz") . toFilePath
  compile $ do
    identifier <- getUnderlying
    body <- loadBody (setVersion Nothing identifier)
    makeItem body
      >>= hopfliCompiler

hopfliCompiler :: Item String -> Compiler (Item BS.ByteString)
hopfliCompiler = withItemBody (return
  . Hopfli.compressWith (defaultCompressOptions {numIterations = 100}) GZIP
  . TE.encodeUtf8 . T.pack)

Then I can use the compression rules like this:

  match "css/*" $ do
    route   idRoute
    compile compressCssCompiler

  match "css/*" $ version "gzip" $ hopfli
  match "css/*" $ version "brotli" $ brotli

Similarly for a create rule:

  create ["archive/index.html"] $ version "brotli" $ brotli
  create ["archive/index.html"] $ version "gzip" $ hopfli

Unfortunately, the haskell brotli library needs brotli as a system dependency, which I provide with devenv, which also provides the haskell environment. I also added minhtml from nixpkgs to compress html by piping it through minhtml:

minhtml :: Item String -> Compiler (Item String)
minhtml = withItemBody (unixFilter "minhtml" [])

which is to be used like

  match "posts/**.md" $ do
    route $ setExtension "html"
    compile $ pandocCompiler
      >>= loadAndApplyTemplate "templates/post.html"    postCtx
      >>= loadAndApplyTemplate "templates/default.html" postCtx
      >>= relativizeUrls
      >>= minhtml

The match rule looks like that because i chose to author my posts like posts/2026/07/28/starting-with-hakyll/index.md. This is slightly more cumbersome to author, but produces clean URLs without .html for free and allows me to keep images local to the directory of the article they are used in. Hakyll still parses the date with that directory schema.

One catch with using index.html for clean URls is that Hakyll also uses the Route, that determines where files are written, in a url field in the Context that is used to generate internal links. Currently that happens in the postCtx Context that is used on the archive and front page from the Hakyll template to render a list of posts.

I adapted the function to clean up the Context from Andreas Herrmann, who also wrote about it in his hakyll blog. I have to keep the trailing slash, so that images resolve to the local directory and not the parent directory. I’l accept the little clutter of a trailing slash for much higher authoring convenience.

postCtx :: Context String
postCtx =
  stripIndexHtml `mappend`
  dateField "date" "%B %e, %Y" `mappend`
  defaultContext

stripIndexHtml :: Context a
stripIndexHtml = mapContext removeIndexStr (urlField "url")
  where
    -- Credit to Yann Esposito, with minor changes
    -- http://yannesposito.com/Scratch/en/blog/Hakyll-setup/
    removeIndexStr :: String -> String
    removeIndexStr url =
      case splitFileName url of
        (dir, "index.html") -> dir
        _                   -> url

Next, I added some minimal CSS to support dark and light mode. To get dark and light versions of code blocks with highlighting, I used the create ["css/syntax.css"] rule by Rebecca Skinner to dump the CSS for the breezeDark and tango styles and manually copying it to my default.css, wrapped in media queries:

@media (prefers-color-scheme: dark) { /* breezeDark */ }

@media (prefers-color-scheme: light) { /* tango */ }

That shall be all for now.