DEV Community

Steven Woodson
Steven Woodson

Posted on • Originally published at on

Adding a Table of Contents to dynamic content in 11ty

This is a follow up post to Pulling WordPress Content into Eleventy, if you haven’t already I’d suggest giving that one a read too!

As part of that transition to using dynamic data, I had lost the automated anchor links that were applied to all h2-h6 headlines. I’m a huge fan of deep links like this because:

  • it makes it easier to share specific sections of an article
  • you can then create a table of contents section, helpful especially for long form content
  • it provides another option for quick scanning and navigation

Well, I finally made some time to re-introduce them! Here’s how I did it.

Preparing the Headlines with Anchor Tags

In my blogposts.js data file I added the following function (utilizing jsdom) that

  • Finds all H2 thru H6 headlines
  • Uses the headline text to create a slug that has stripped out all code, spaces, and special characters
  • Appends an anchor link to each headline
  • Compiles a JavaScript array of all headlines and the generated slugs for the next part

Here’s the code

function prepareHeadlines(content) {
  const dom = new JSDOM(content);
  let headerElements = dom.window.document.querySelectorAll("h2,h3,h4,h5,h6");
  let headers = [];

  if (headerElements.length) {
    headerElements.forEach((header) => {
      const slug = header.innerHTML
        .replace(/(<([^>]+)>)/gi, "")
        .replace(/ |&amp;/gi, " ")
        .replace(/[^a-zA-Z0-9]/gi, "")
        .replace(/ /gi, "-")
        .replace(/-+/gi, "-")

      const title = header.innerHTML.replace(/(<([^>]+)>)/gi, "");

      header.innerHTML =
        header.innerHTML +
        '<a href="#' +
        slug +
        '" class="anchor"><span aria-hidden="true">#</span><span>anchor</span></a>'; = slug;

        slug: slug,
        title: title,
        tagName: header.tagName,

    content = dom.window.document.body.innerHTML;
  return { content: content, headers: headers };
Enter fullscreen mode Exit fullscreen mode

There’s very likely a cleaner way to do all this, but I got it to “good enough” and called it a day. If you’ve got ideas for improvements please use the links at the bottom of this page to let me know!

Constructing the Page Outline

With the headers compiled in the function above, I then wanted to be able to show the headlines in a page outline where sub-headlines were nested under the parent. For example all H3 headlines below an H2.

Here’s a recursive function to handle that part.

function tableOfContentsNesting(headers, currentLevel = 2) {
  const nestedSection = {};

  if (headers.length > 0) {
    for (let index = 0; index < headers.length; index++) {
      const header = headers[index];
      const headerLevel = parseInt(header.tagName.substring(1, 2));

      if (headerLevel < currentLevel) {

      if (headerLevel == currentLevel) {
        header.children = tableOfContentsNesting(
          headers.slice(index + 1),
          headerLevel + 1
        nestedSection[header.slug] = header;

  return nestedSection;
Enter fullscreen mode Exit fullscreen mode

This will create a multidimensional object where each top-level item will optionally have a children property that contains its child headings, and can go all the way down to H6.

Pulling it all together

Those two functions defined above do the heavy lifting, but they still need to be applied to the processContent function so the data that the site uses will have this new content available.

Here’s the relevant changes:

// Applying ID anchors to headlines and returning a flat list of headers for an outline
const prepared = prepareHeadlines(post.content.rendered);

// Code highlighting with Eleventy Syntax Highlighting
const formattedContent = highlightCode(prepared.content);

// Create a multidimensional outline using the flat outline provided by prepareHeadlines
const tableOfContents = tableOfContentsNesting(prepared.headers);

// Return only the data that is needed for the actual output
return await {
  content: post.content.rendered,
  formattedContent: formattedContent,
  tableOfContents: tableOfContents,
  custom_fields: post.custom_fields ? post.custom_fields : null,
Enter fullscreen mode Exit fullscreen mode

I opted to save the formattedContent separate from the content so I could have the unformatted content to use in my XML Feed that doesn’t really need all that extra HTML. I then also added tableOfContents so I can use it in my template. Speaking of, that brings us to the next section.

Creating a Table of Contents Macro

Because the tableOfContents is a multidimensional object that can be (theoretically) 5 levels deep, I wanted to make sure the table of contents I’m adding to the page would be able to handle all that too.

So, I turned to the handy dandy Nunjucks Macros to do the job. I chose macros because I can pass just the data I want it to be concerned with and not have to mess around with global data in standard templates. I’ll admit I tried a template first and ended up in some infinite loop situations, lesson learned!

Here’s the Table of Contents macro I created, saved at site/_includes/macros/tableofcontents.njk.

{% macro tableOfContents(items) %}
  {% for key, item in items %}
    <li><a href="#{{ item.slug }}">{{ item.title | safe }}</a>
      {%- if item.children | length %}
        {{ tableOfContents(item.children) }}
      {% endif %}
  {% endfor %}
{% endmacro %}
Enter fullscreen mode Exit fullscreen mode

Pretty simple right? That’s because it’s set up to run recursively, so it’s calling itself to create the nested lists that we’re going to render on the page.

Adding to the Blogpost Template

Alright it’s the moment of truth, let’s get all that into the page template!

I chose to put this list inside a <details> element so it can be collapsed by default, though you can also update to include open as a property of <details> to have it collapsible but open by default. Up to you!

<nav aria-label="Article">
    <summary>In This Article</summary>
    {{ tableOfContents(blogpost.tableOfContents) }}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

That’s all there was to it. It’s not a lot of code but I’ll admit the recursive aspects took me a bit of head scratching to figure out initially. Hoping it saves you a bit of that struggle.

If you end up using this, or improving upon it, please reach out and let me know!

Top comments (1)

Sloan, the sloth mascot
Comment deleted