It took me several attemps to run and actually maintain some kind of personal blog, so I’m happy I finally settled down on this one, based on Hakyll. Main reason why Hakyll was that I wanted to learn Haskell and best way for me to learn something new is to apply it for some real world use case. After publishing first few blog posts I realized that some are pretty long with many headings and having table of contents would definitely be helpful for readers.

So I spent some time searching best solution and found that Pandoc (library used by Hakyll to convert Markdown to HTML) already contains built-in support for this. In this blog post, I’ll show how to implement simple table of contents for blog posts, that can be easily customized to your specific needs. Because the below solution is the one I use for my blog, you can also check full source code to get details about imported modules, etc.

1 Expected features

As first step, I wrote down main features I’d like to have in the ideal implementation:

  • option to enable / disable table of contents per blog post
  • automatic numbering for table of contents anchor links and also blog post headings
  • render table of contents only for full blog posts, not for previews on landing page

Fortunately all of these can be implemented in pretty straightforward way, as shown in following chapters.

2 Implementation

Pandoc already contains support for rendering table of contents and it can be enabled by setting the writerTableOfContents field from WriterOptions to True. Hakyll implements support for this as well, so the rendered table of contents is then available as $toc$ context field.

2.1 Integration with Hakyll

withTOC :: WriterOptions
withTOC = defaultHakyllWriterOptions
        { writerNumberSections  = True
        , writerTableOfContents = True
        , writerTOCDepth        = 2
        , writerTemplate        = Just tocTemplate
        }

tocTemplate :: Template Text
tocTemplate = either error id . runIdentity . compileTemplate "" $ T.unlines
  [ "<div class=\"toc\"><div class=\"header\">Table of Contents</div>"
  , "$toc$"
  , "</div>"
  , "$body$"
  ]

The writerNumberSections option is worth mentioning, because it automatically adds numbering to both table of contents links and the headings inside blog post (as you can also see on this page). These WriterOptions can then be used for rendering blog posts like this:

match "posts/*" $ do
  route   $ setExtension "html"
  compile $ pandocCompilerWith defaultHakyllReaderOptions withTOC
      >>= loadAndApplyTemplate "templates/default.html" defaultContext

Problem with this implementation is that table of contents is rendered always for each blog post, which may be unwanted, mainly for shorter ones. Let’s see how this can be fixed.

2.2 Enabling per blog post

One way how to implement enabling/disabling of table of contents per blog post is to detect presence of some custom field in YAML header of the markdown file. Let’s say we want to have it disabled by default and enable it by adding tableOfContents field to YAML header:

---
title: My blog post
tags: one two threee
tableOfContents: true
---

Markdown text here...

Based on presence of this field, we would choose whether to render or not the table of contents (we aren’t checking the actual value, just whether the field is present or not):

match "posts/*" $ do
  route   $ setExtension "html"
  compile $ do
    underlying <- getUnderlying
    toc        <- getMetadataField underlying "tableOfContents"
    let writerOptions' = maybe defaultHakyllWriterOptions (const withTOC) toc
    pandocCompilerWith defaultHakyllReaderOptions writerOptions'
      >>= loadAndApplyTemplate "templates/default.html" defaultContext

2.3 Adding stylesheets

Although the above code renders the table of content for blog posts and adds automatic numbering to heading, it would be still nice to add some CSS to make things better looking.

2.3.1 Adding styles to table of contents

In the above example, the actual rendered table of contents is wrapped in <div> element with .toc CSS class, which allows to apply some styling on it. If you want to change styles for the section numbers of table of contents (as used on this page), you can modify it using the .toc-section-number class.

2.3.2 Adding styles to headings

Headings itself now contain the automatically generated section numbers, and it’s likely that you’d like to visually separate them from the rest of the heading. This can be done by adding styles to .toc-section-number class.

2.4 Making headings clickable

One last nice to have feature would be to transform headings inside blog post into anchors, so they can be both clicked and the links can be copied by users to share exact part of your blog post. Unfortunately Pandoc doesn’t render headings as anchors by default. There is probably some way how to directly modify the Pandoc’s AST, but for now I was pretty happy with quick&dirty solution based on JavaScript and jQuery. It’s not that big deal in this case, because this DOM modification doesn’t cause any visual disruptions when the page is loading and it’s loaded much earlier before user is able to do any interactions.

// '.post-content' is the enclosing element of the blog post
$('.post-content').children('h1, h2, h3, h4, h5').each(function () {
  var id = $(this).attr('id');
  var text = $(this).html();

  $(this)
    .html('')
    .append('<a href="#' + id + '" class="header-link">' + text + '</a>');
});

3 Conclusion

Adding table of contents to your blog posts (mainly the longer ones) can help visitors navigate the content. Fortunately in case of Hakyll, the implementation itself is not that difficult, mainly thanks to the underlying Pandoc. And with help of some CSS and JavaScript, we can make pretty decent looking table of contents that would match our specific needs.