Setting up a static website with Hakyll

Posted on 2021-04-07
Tags: Haskell, Web

Table of Contents

Introduction

This tutorial is will help you set up a Hakyll blog similar to mine. The reason I’m writing this is to have a simple, linear, step-by-step tutorial with all the things I find essential on a blog, namely a RSS feed and tags.

One of the advantages of using Hakyll to statically generate websites is its native integration with Pandoc, so we can create files in any format supported by Pandoc and they will be automatically converted to HTML. In my case, for an example, I write documents using org-mode inside Emacs, then I just let Hakyll do the rest of the work converting files and adding tags. It’s so simple and enjoyable to do.

Enough talk, let’s begin.

Installation

You can install Hakyll using two different methods, the one I chose was using cabal. The other one is by using stack. Both methods are similar, so there isn’t much difference between each other. You can install cabal using ghcup, or using your Linux distro standard repositories. After installing cabal on your system, you can install Hakyll by typing:

$ cabal new-install hakyll

Building the website

You can create the default website by typing:

$ hakyll-init site-name

where site-name is the name you will give to the site’s directory. Make sure that ~/.ghcup/env is in your $PATH. Inside the site-name directory, you will find the default website files, execute cabal new-run site to set up the default website. You can check it out buy running cabal new-run site watch, then it will be available on your local host. Essentially, the generated website is located in the _site subdirectory, all the other files, except the site.hs file, are files to be converted to HTML or Templates. After making changes to some file, or adding new files to the default website, you can use cabal new-run site build to incrementally build your website. Although, if you make changes to site.hs, you need to run cabal new-run site rebuild instead. The site.hs file is where all the magic happens, it seems frightening at first, by it’s actually simple to work with.

You can now create files in any format supported by Pandoc and, after running cabal new-run site build, Hakyll will create the corresponding HTML files inside the _site subdirectory. You might have noticed that all files that already exist start with a block like this one:

---
title: Something
author: Some Name
---

This is the Metadata of the file, and it will be useful to automatically generate Names, Dates and Tags. For example, the Metadata of this post that you are reading right now is:

---
title: Setting up a static website with Hakyll
description: Building a Hakyll website with tags and a RSS feed
tags: Haskell, Computers
---

Configuration

We can now start to configure the website. Configuration is done by editing the site.hs file. Inside the file you will find some imported modules, Hakyll and Data.Monoid (mapped), and you see the hakyll function being called.

main :: IO ()
main = hakyll $ do

After that you will see some Hakyll Rules like these:

match "images/*" $ do
    route   idRoute
    compile copyFileCompiler

Here, match will look for all the files inside the images/ directory and use the route and compile functions with the idRoute and copyFileCompiler arguments respectively.

The route function is used to determine the output file inside _site. For example, if you have a markdown file named article.md inside the posts/ directory, then you will probably want Hakyll to place the converted HTML file _site/posts/article.html directory. That can be accomplished by using the setExtension "html" route. But if you don’t want to convert the file? For example, you have an image inside your images/ directory and you just want to have a copy of that image inside the _site/image/, how do you do it? You just need to use the identity route idRoute and it’s done.

The compile function determines the way you want your files to be compiled. The route function just determines the place and name for the output, but to convert the file you want to compile it with something. There are different ways to do it, but if you want to Pandoc to convert your files to HTML, you should use the pandocCompiler argument. If you want to give Pandoc more options, then you should use pandocCompilerWith instead. But if you want to do nothing to the file, then just use copyFileCompiler.

Notice that there are other functions and arguments inside site.hs that I haven’t mentioned, you can learn more by reading the documentation for them. I just wanted to explain the ones that are most commonly used.

Another Rule worth mentioning is create, while match looks for a file that already exists, create will generate a file for you, you can find an example inside the site.hs file:

create ["archive.html"] $ do
    route idRoute
    compile $ do

RSS feed

Setting up a RSS feed using Hakyll is easy, since Hakyll has a built-in support for it. First you will need to define a FeedConfiguration as the following example:

myFeedConfiguration :: FeedConfiguration
myFeedConfiguration = FeedConfiguration
    { feedTitle       = "My Blog feed"
    , feedDescription = "This is the RSS feed for my blog"
    , feedAuthorName  = "John Doe"
    , feedAuthorEmail = "john@mail.com"
    , feedRoot        = "https://mywebsite.com"
    }

You don’t need to use all the fields above. Now you probably want to the feed to contain the content inside your posts, to do that you will need to use Snapshots to save the content inside your posts during compilation and to load then to your feed later. To do that you will need to add

>>= saveSnapshot "content"

To the match "posts/*" section. It will look something like this:

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

Now you just need to load the content saved by the Snapshot and to create the RSS feed, to do that you will need to add the following block inside site.hs:

create ["rss.xml"] $ do
    route idRoute
    compile $ do
        let feedCtx = postCtx `mappend` bodyField "description"
        posts <- fmap (take 15) . recentFirst =<<
            loadAllSnapshots "posts/*" "content"
        renderRss myFeedConfiguration feedCtx posts

Adding Tags

As mentioned above, this file has the following metadata:

---
title: Setting up a static website with Hakyll
description: Building a Hakyll website with tags and a RSS feed
tags: Haskell, Computers
---

You can see that I added the Haskell and Computer tags to it. To create tags we need first to fetch them inside our posts. To do that we need to add the following line to site.hs:

tags <- buildTags "posts/*" fromCapture "tags/*.html"

The line above will create the tags under "tags/*.html" format by fetching them inside the "posts/*" directory. Now all we need to do is to add those tags to the their respective post and to list all the tags in the archive page. To add the tags to their posts we need to change the post Context (I know I haven’t mentioned what a Context is, but we don’t need to understand them to add tags to our posts). To do that, need to remove the current postCtx:

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

and add the following block in its place site.hs:

postCtxWithTags :: Tags -> Context String
postCtxWithTags tags = mconcat
    [ dateField "date" "%Y-%m-%d"
    , tagsField "tags" tags
    , defaultContext

Then change all the occurrences of postsCtx inside site.hs to postsCtxWithTags. Now to add the list of tags to the archive.html file, we need to add the following line to the create ["archive.html"] rule:

field "taglist" (\_ -> renderTagList tags) `mappend`

In the end, it should look to something like this:

create ["archive.html"] $ do
    route idRoute
    compile $ do
        posts <- recentFirst =<< loadAll "posts/*"
        let archiveCtx =
                listField "posts" (postCtxWithTags tags) (return posts) `mappend`
                field "taglist" (\_ -> renderTagList tags) `mappend`
                constField "title" "Archives"            `mappend`
                defaultContext

Now add this rule anywhere after that:

tagsRules tags $ \tag pattern -> do
    let title = "Posts tagged \"" ++ tag ++ "\""
    route idRoute
    compile $ do
        posts <- recentFirst =<< loadAll pattern
        let ctx = constField "title" title
                  `mappend` listField "posts" (postCtxWithTags tags) (return posts)
                  `mappend` defaultContext

        makeItem ""
            >>= loadAndApplyTemplate "templates/tag.html" ctx
            >>= loadAndApplyTemplate "templates/default.html" ctx
            >>= relativizeUrls

Great, now we just need to make our tags visible. To do that we need to edit the Templates inside the templates/ directory. Your templates/posts.html should look like this"

<article>
    <section class="header">
        Posted on $date$
        $if(author)$
            by $author$
        $endif$
    </section>
    <section class="header">
        $if(tags)$
        Tags: $tags$
        $endif$
    </section>
    <section>
        $body$
    </section>
</article>

We added the tags to their respective posts. Now we’ll need to create a tag template to list the posts inside a tag, you can just copy the code inside template/post-list.html to a new file: template/tag.html. This what it should look like:

$partial("templates/post-list.html")$

Finally, change your templates/archive.html file to something like this:

<section>
        <b>Tags:</b> $taglist$
</section>
<br>Here is a list of all my previous posts:
$partial("templates/post-list.html")$

Now you just need to rebuild site.hs (~cabal new-run site rebuild) and it’s done!

Resources

To learn more about Hakyll, you can read these tutorials, and the Hackage page.