Adding a Table of Contents to dynamic content in 11ty
Tags: webdev, eleventy, a11y, static
Published: 2023-02-12 18:04:53

---
title: Adding a Table of Contents to dynamic content in 11ty
published: true
date: 2023-02-12 17:47:03 UTC
tags: webdev, eleventy, a11y, static
canonical_url: https://stevenwoodson.com/blog/adding-a-table-of-contents-to-dynamic-content-in-11ty
---

 ![](https://content.walnutcreekcreative.com/stevewoodson/wp-content/uploads/sites/3/2023/02/Adding-a-Table-of-Contents-to-dynamic-content-in-11ty.png)

This is a follow up post to [Pulling WordPress Content into Eleventy](https://stevenwoodson.com/blog/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](https://github.com/jsdom/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, "-")
        .toLowerCase();

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

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

      headers.push({
        slug: slug,
        title: title,
        tagName: header.tagName,
      });
    });

    content = dom.window.document.body.innerHTML;
  }
  return { content: content, headers: headers };
}
```

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) {
        break;
      }

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

  return nestedSection;
}
```

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
// https://www.11ty.dev/docs/plugins/syntaxhighlight/
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,
```

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](https://mozilla.github.io/nunjucks/templating.html#keyword-arguments) 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) %}
<ul>
  {% for key, item in items %}
    <li><a href="#{{ item.slug }}">{{ item.title | safe }}</a>
      {%- if item.children | length %}
        {{ tableOfContents(item.children) }}
      {% endif %}
    </li>
  {% endfor %}
</ul>
{% endmacro %}
```

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">
  <details>
    <summary>In This Article</summary>
    {{ tableOfContents(blogpost.tableOfContents) }}
  </details>
</nav>
```

## 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!