Adding tags to Hakyll

Posted on 2023-01-13

I’ve been building this site statically using Hakyll since the beginning and I haven’t found any significant reason to move off it yet. I figured that since there are some topics that keeps repeating I wanted to start tagging my posts. Hakyll supports tags out of the box and with some help from this post it didn’t take me too long to get a decent initial implementation in place.

Adding tags to posts

Hakell allows defining some metadata for each post. In this metadata it’s trivial to add a new tags key, which should define a list of comma-separated tags:

---
title: This is some post
tags: NixOS, Haskell, ...
---

Once all posts have been tagged it’s all about letting Hakyll know what to do with them.

Finding tags

First off Hakyll needs to know from which files to lookup tags (all posts) and where to place the output of each tag list. For this Hakyll provides the buildTags function:

λ: :i buildTags
buildTags ::
  MonadMetadata m => Pattern -> (String -> Identifier) -> m Tags
        -- Defined in ‘Hakyll.Web.Tags’

In my static site generator (ssg) I apply it to the following arguments:

main = do
  -- snip snip
  tags <- buildTags "posts/*" (fromCapture "tags/*.html" . toLower)

Basically this means for all files under ./posts and generate files for each tag under ./tags. A personal preference of mine is to have the file names be lower case, so each tag is converted to lower case for the file name only.

Generating tag post lists

Hakyll can generate one static page for each tag found. The posts are found, filtered based on visibility and sorted before the tags file is rendered with the same template as the “all posts” listing:

tagsRules tags $ \tagStr tagsPattern -> do
  route idRoute
  compile $ do
    posts <- loadAll tagsPattern >>= filterM postIsNotPreview >>= recentFirst
    let postsCtx =
          constField "title" tagStr
            <> listField "posts" postCtx (return posts)
            <> defaultContext

    makeItem ""
      >>= loadAndApplyTemplate "templates/posts.html" postsCtx
      >>= loadAndApplyTemplate "templates/default.html" postsCtx
      >>= relativizeUrls

At this point Hakyll will build tags/*.html pages containing all posts with the given tags. However, there’s no way to find these pages through navigation. In order to make each listing of topics defined by a tag reachable I could choose to have a good old “tag cloud” overview page, using something like renderTagCloud. To start off though I settled on just adding a list of tags to each post, which allows the user to click into similar posts.

Hakyll has various functions for rendering tags into HTML where the simplest is tagsField:

λ: :i tagsField
tagsField :: String -> Tags -> Context a

This function only renders a comma-separated set of <a> anchors, which doesn’t give all the semantics and styling options I would like. Instead, there’s the more general tagsFieldWith which accepts a few additional parameters:

λ: :i tagsFieldWith
tagsFieldWith ::
  (Identifier -> Compiler [String])
  -- ^ Get the tags
  -> (String -> (Maybe FilePath) -> Maybe H.Html)
  -- ^ Render link for one tag
  -> ([H.Html] -> H.Html)
  -- ^ Concatenate tag links
  -> String
  -- ^ Destination field
  -> Tags
  -- ^ Tags structure
  -> Context a
  -- ^ Resulting context

For the most part I’d like the same behavior as tagsField, so I applied the same arguments while providing a separate renderLink function which simply puts the links into <li> as well:

renderLink _ Nothing = Nothing
renderLink tag (Just url) = Just $
  H.li $ H.a ! A.href (H.toValue ("/" <> url)) $ H.toHtml tag

The whole rule ended up like the following:

match "posts/*" $ do
  route $ setExtension "html"
  compile $ do
    let
      renderLink _ Nothing = Nothing
      renderLink tag (Just url) = Just $
        H.li $ H.a ! A.href (H.toValue ("/" <> url)) $ H.toHtml tag
      tagsCtx = tagsFieldWith getTags renderLink mconcat "tags" tags
    postCompiler
      >>= loadAndApplyTemplate "templates/post.html" (tagsCtx <> postCtx)
      >>= saveSnapshot "content"
      >>= loadAndApplyTemplate "templates/default.html" postCtx
      >>= relativizeUrls

Finally, in order to output the list of tags for each post the rendered HTML must be injected into the post template. The tagsCtx places the rendered HTML into a field named tags and so not much more is needed than referencing it in the desired place in the template markup:

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

And that’s pretty much it! Now the posts have tags and it should be possible to see e.g. my list of Nix and NixOS related posts.