After several attempts to run and actually maintain some personal blog, I finally settled down on this one based on Hakyll. Main reason was that I decided to learn Haskell and the best way for me how to learn new technology is to use it for some real world application. After publishing several blog posts I realized that some of them are pretty long with many headings and having some kind of table of contents would definitely help to navigate them.

I spent some time by searching optimal solution and found that Pandoc, library used by Hakyll to convert Markdown to HTML, already contains pretty decent built-in support for this. In this blog post, I’ll demonstrate how to implement simple table of contents for blog posts, that can be easily customized to your specific needs.

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 "$toc$\n$body$"

The writerNumberSections option is worth mentioning, because it automatically adds numbering to both table of content 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

First thing we need to do is to wrap the rendered table of contents into some <div> container with custom class, so we can refer it later in stylesheet. This can be done by changing the writerTemplate field:

withTOC :: WriterOptions
withTOC = defaultHakyllWriterOptions
        { writerNumberSections  = True
        , writerTableOfContents = True
        , writerTOCDepth        = 2
        , writerTemplate        = Just "\n<div class=\"toc\"><div class=\"header\">Table of Contents</div>\n$toc$\n</div>\n$body$"

Now we can add proper styling to the .toc CSS class. 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();

    .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 content that would match our specific needs.