10 minute read

How I added a table of contents to my blog posts

Better SEO and faster navigation are the reasons behind an automatic and accessible table of contents—here’s how I did it.

To implement this feature in the shortest amount of time I needed to leverage the already existing tools available. This is going to touch on a few fronts, including Eleventy configs, content files, a tiny JavaScript controller and obviously styles, which are the least interesting part of this whole feature and I’m not going to cover here.


If you don’t know what a table of contents is by now, it’s just a small section at the top of the page that allows the reader to quickly glance at all the sections in a blog post as well as navigate to one of them in no time.


Eleventy configuration

Before adding the table of contents itself, I needed to solve the anchors issue first. What is that, you may ask? You see, my blog posts have headings to neatly separate content in different sections—like ”Eleventy configuration” above. But those headings aren’t anchors themselves. Meaning, you wouldn’t be able to link directly to one of the sections if you wanted.

To solve this, and since I’m using markdown-it as the Markdown engine behind all my blog posts, I leaned towards the markdown-it-anchor package. There’s a good reason for it, which I’m going to touch on a bit later.

JavaScript
.eleventy.js
123456
const markdownIt = require('markdown-it')({
  linkify: true,
  typographer: true,
  // Other markdown-it options go here…
  }
)

This is the JavaScript bit that configures markdown-it. To use plugins with it, we can use the use function. So the first thing I did was adding the markdown-it-anchor plugin so that my headings would become linkable.

JavaScript
.eleventy.js
12345678910111213141516171819
const markdownItAnchor = require('markdown-it-anchor')
const markdownIt = require('markdown-it')({
  linkify: true,
  typographer: true,
  // Other markdown-it options go here…
  }
)
.use(markdownItAnchor, {
  slugify: function (s) {
    return s.trim().toLowerCase().replace(/[\s]+/g, '-').replace(/[!?;:,.…’“”]+/g, '')
  },
  permalink: markdownItAnchor.permalink.ariaHidden({
    placement: 'after',
    class: ‘heading__anchor,
    symbol: ‘#’, // Does accept an SVG too
    space: true,
    visuallyHiddenClass: 'visually-hidden',
  }),
})

Notice that I’m using a custom slugify function to replace spaces with hyphens and remove special chars. So that a heading such as Maybe it’s just me. I’m the picky guy… becomes an anchor like #maybe-its-just-me-im-the-picky-guy.

With the code above, you should now be able to see a # symbol next to your headings. Perfect!

Now you need to configure the table of contents itself. We’re going to introduce a new dependency markdown-it-table-of-contents and use it with markdown-it just like we did above. The reason why we’re using this package is because it plays very nicely with markdown-it-anchor in the first place, the author suggests too.

JavaScript
.eleventy.js
123456789101112131415161718192021222324
const markdownItAnchor = require('markdown-it-anchor')
const markdownItTableOfContents = require('markdown-it-table-of-contents')
const markdownIt = require('markdown-it')({
  linkify: true,
  typographer: true,
  // Other markdown-it options go here…
  }
)
.use(markdownItAnchor, {
  slugify: function (s) {
    return s.trim().toLowerCase().replace(/[\s]+/g, '-').replace(/[!?;:,.…’“”]+/g, '')
  },
  permalink: markdownItAnchor.permalink.ariaHidden({
    placement: 'after',
    class: ‘heading__link’,
    symbol: ‘#’, // Does accept an SVG too
    space: true,
    visuallyHiddenClass: 'visually-hidden',
  }),
})
.use(markdownItTableOfContents, {
  includeLevel: [2, 3, 4, 5, 6],
  markerPattern: /^\[\[toc\]\]/im
})

This will output a list of your headings and replace [[toc]] in your posts with it. Notice that I’m ignoring the level 1 heading because that’s my post title itself. I have no other <h1> in my posts.

You can still hijack it before injecting the markup in your page and tweak the markup as you see fit, as I did. By using the transformContainerOpen and transformContainerClose functions you will be able to tweak the markup to your taste. I personally used a <nav> tag as that’s what a table of contents really is, a navigation.

That’s it! The hardest part is done!

Content files

Now to enable the table of contents in your blog posts you’re going to need to add a [[toc]] bit wherever you want them.

I confess that I wasn’t too happy when I noticed I would need to add this to every single blog post. But the truth is that this makes it very flexible. For instance, if you have a blog post where you don’t want a TOC, just don’t add it there.

Or, if you want your TOC in a very specific position, you can just as easily do it. In fact, you can add a TOC in a variety of different places on each blog post. And since you can tweak the markerPattern to whatever you like, you’re not stuck with [[toc]] either. I personally place mine at the very top.

JavaScript controller

This is not mandatory, and you may not want this to begin with. But if you inspect one of my blog posts you will notice that my TOC has a button to toggle it. Meaning, it renders collapsed and then gets expanded only when the user decides to open it.

For this behavior I’m using Stimulus by 37signals. It makes it extremely easy to just sprinkle bits of functionality on top of your existing markup.

I’m not going to touch on how to configure it in your own project or how I did it in my project, because I was already using it for plenty of other things in my website. But the controller for the TOC is literally as follows:

JavaScript
toc_controller.js
1234567
import { Controller } from '@hotwired/stimulus'

export default class TocController extends Controller {
  toggle() {
    this.element.classList.toggle('is-open')
  }
}

That’s it, really!
Every time the toggle() function is called, it toggles the .is-open class in my <nav> table of contents. Which then CSS takes care of expanding or collapsing, as it should.

Even though you can add styles through JavaScript directly, and there’s nothing stopping you from doing so inside the Stimulus controller—which is just a JavaScript class as you can see—you really should leave that for CSS. By just toggling a classname you can effectively separate styles from function.

Takeaways

Adding a table of contents is a good way to kill two birds with one stone—don’t actually kill birds unless you intend to eat them, those are lovely creatures. You add a new and quick way for users to navigate your content but you also allow search engines to scrap your structured content much easier. A better SEO score along with happy users is nothing to say no to.

If you already have a blog, chances are you might be using some technology behind it to render Markdown or any other form of styled content. To add a new feature such as a table of contents, or even the anchored headings, your best bet would be to look into it to see if you can just include a new plugin that leverages all the hard work for you.

Remember that before adding a table of contents you will need to have anchors on your page so that the TOC links actually point somewhere.

Also, consider that there are visually impaired users on the internet and you might want to have a way for them to navigate your content, with a keyboard, for instance.

Lastly, in case you were curious, my blog just like my whole website is made with eleventy. A simple static site generator that’s quick and very flexible. It may not have every bell and whistle, but you can add everything you want yourself, if you need them. And since it’s JavaScript based, makes it very easy to learn and expand to your needs too.

Photo of Pedro