Group Hakyll posts by year (for chronological archives)

This article shows how to create a listing of all Hakyll 4 posts (from now on referred to as articles) grouped by year in reverse chronological order.

We want to produce something like this:

<h2>2010</h2>
<ul>
	<li>Posted on 2010-05-01</li>
	<li>Posted on 2010-03-01</li>
</ul>
<h2>2009</h2>
<ul>
	<li>Posted on 2009-10-09</li>
	<li>Posted on 2010-07-03</li>
</ul>

First the function that groups the article Item objects. First tupel value is the year as integer.

-- Groups article items by year (reverse order).
groupArticles :: [Item String] -> [(Int, [Item String])]
groupArticles = fmap merge . group . fmap tupelise
    where
        merge :: [(Int, [Item String])] -> (Int, [Item String])
        merge gs   = let conv (year, acc) (_, toAcc) = (year, toAcc ++ acc)
                     in  foldr conv (head gs) (tail gs)

        group ts   = groupBy (\(y, _) (y', _) -> y == y') ts
        tupelise i = let path = (toFilePath . itemIdentifier) i
                     in  case (articleYear . takeBaseName) path of
                             Just year -> (year, [i])
                             Nothing   -> error $
                                              "[ERROR] wrong format: " ++ path

-- Extracts year from article file name.
articleYear :: FilePath -> Maybe Int
articleYear s = fmap read $ fmap head $ matchRegex articleRx s

articleYear depends on articleRx used in “Beautify Hakyll post URLs: Removing .html extension and timestamp”. We will also be using functions for reformatting article URLs from this article.

We then just group the articles after we loaded them and generate a list field (the articles) inside another list field (the years). This is unfortunately quite undocumented, but Beerend Lauwers does some explaining on how to work with Hakyll fields in this blog post.

create ["archives.html"] $ do
    route niceRoute -- This just rewrites /archives.html => /archives/
    compile $ do
        articles <- fmap groupArticles $ recentFirst =<<
                        loadAll "articles/*/*.md"

        let archiveCtx =
                listField "years"
                    (
                        field "year" (return . fst . itemBody) <>
                        listFieldWith "articles" articleCtx
                            (return . snd . itemBody)
                    )
                    (sequence $ fmap (\(y, is) -> makeItem (show y, is))
                                                      articles) <>
                constField "title" "Archives" <>
                defaultContext

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

articleCtx :: Context String
articleCtx =
    dateField "date" "%B %e, %Y" <>
    teaserField "teaser" "content" <>
    defaultContext

We can then just render our list with two simple iterations in our template:

$for(years)$
<h2>$year$</h2>
<ul>
$for(articles)$
<li>
  <a href="$url$">$title$</a>
  $if(teaser)$ $teaser$ $endif$
</li>
$endfor$
</ul>
$endfor$
First published on December 2, 2015