The basics of Hakyll

Posted on August 5, 2024
Tags: ,

Basics of the basics

What

To quote the creator :

Hakyll is a Haskell library for generating static sites, and uses a xmonad-DSL for configuration. Integration with pandoc gives us Markdown and TeX support

My blog (as the footer indicates) is (proudly !) generated using Hakyll.

Why

Hakyll has tons of functionalities, and those it doesn’t have, I can program. Not only do I get to focus on content by writing everything in Markdown (and TeX for the trees in the Orchard), I can automate most features in Haskell.

Also, I think writing HTML is a pain, I like static websites, and pages like posts list, RSS feed, sitemap, tags list are generated automatically.

How

Write compilations rules, write your CSS and your templates, use whatever format you want for you content as long as Pandoc supports it, and voilà, you have a site.

And in practice ?

The idea

The same as for XMonad ! We’ll write our own program using Hakyll as a library (but to be clear, Hakyll does most of the job by giving us nice abstractions and a DSL).

But, simpler than XMonad, when creating our site, using hakyll-init [-f] <directory> (-f is to force the creation of files), we get a nice example project, on top of which we can build on.

Get the example project, and let us analyze the code…

The simple part

main :: IO ()
main = hakyll $ do
    match "images/*" $ do
        route   idRoute
        compile copyFileCompiler

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

Just like XMonad, we define a main function, that uses hakyll (resp. xmonad for XMonad) with some sort of configuration. However, more complex than Hakyll, we are not merely configuring a program. We are creating Rules that define the compilation process of our site !

In the Rules monad, each block define a compilation rule. Here, we have the two simplest: we just need to copy those files from our source directory (by default the directory where we execute our program) to the _site directory (containing the compiled site).

To that effect, we match the correct files, we specify that the route will be the exact same, and that the compiling is just a matter of copying files for the images, and compressing the file for CSS.

The less simple part

match (fromList ["about.rst", "contact.markdown"]) $ do
    route   $ setExtension "html"
    compile $ pandocCompiler
        >>= loadAndApplyTemplate "templates/default.html" defaultContext
        >>= relativizeUrls

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

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

Now we have to compile some files, here a reStructuredText and a Markdown file. The route is just a matter of changing the extension. However, we need to compile the files, and get a full webpage. That’s why we use…

Context

A context is a number of informations to be recorded about our page:

  1. Body
  2. Metadata fields
  3. URL
  4. Path
  5. Title

Everything after (and including) 3. can be altered in metadata fields. If you ever used Markdown, especially R Markdown, you know what those are: the little optional YAML header at the start of a file. Here’s the one of this post

---
title: The basics of Hakyll
date: 2024-08-05
tags: hakyll, haskell
---

We can also append (there’s a Monoid instance for Context :D) fields to the default context, for example dateField. And the Context is used in…

Templates

match "templates/*" $ compile templateBodyCompiler

Templates are a really powerful tool: they do exactly what you think they do, especially the “being cool” part !

There’s no need to specify a route, since we don’t need them as they are in the final site. However, we still “compile” them, to use them elsewhere…

We use a very small set of identifiers:

Then here’s how it’s processed:

  file.md            template.html
/---------\       /---------------------\
⎸ # Title ⎹       ⎸ <html>              ⎹
⎸         ⎹       ⎸   <head>...</head>  ⎹
⎸ Text    ⎹       ⎸   <body>            ⎹
\---------/       ⎸     $body$          ⎹
     |            ⎸   </body>           ⎹
     | Pandoc     ⎸ </html>             ⎹
     |            \---------------------/
     |                |
     |                | loadAndApplyTemplate
     v                |
/----------------\    |            file.html
⎸ <h1>Title</h1> ⎹    |     /---------------------\
⎸                ⎹----+---->⎸ <html>              ⎹
⎸ <p>Text</p>    ⎹          ⎸   <head>...</head>  ⎹
\----------------/          ⎸   <body>            ⎹
                            ⎸     <h1>Title</h1>  ⎹
                            ⎸                     ⎹
                            ⎸     <p>Text</p>     ⎹
                            ⎸   </body>           ⎹
                            ⎸ </html>             ⎹
                            \---------------------/

The difference being, in the example project, that we use two templates ! The first template shows the date, the author (if the field exists) and the body of the post, and the second formats everything into a complete page. The result of the first apply becomes the new $body$ for the second template.

We finish with relativizeUrls, transforming every URL in a relative form.

The not very simple part

Let’s finish with this block:

create ["archive.html"] $ do
    route idRoute
    compile $ do
        posts <- recentFirst =<< loadAll "posts/*"
        let archiveCtx =
                listField "posts" postCtx (return posts) `mappend`
                constField "title" "Archives"            `mappend`
                defaultContext

        makeItem ""
            >>= loadAndApplyTemplate "templates/archive.html" archiveCtx
            >>= loadAndApplyTemplate "templates/default.html" archiveCtx
            >>= relativizeUrls

The next block is more or less the same, so there’s no need to explain it.

In that case, we do not match anything, we are creating a file from scratch. We already know about the root part, the interesting part is the compilation:

  1. We first load all the posts, and sort them by date
  2. We create a new context, containing the title and the list of posts (to be used in a $for(posts)$ loop)
  3. We create a new item, with an empty body, and it goes through both templates

This process can be generalized for a lot of files, but you’ll have to wait for the next post :)