Adding a Table of Contents to dynamic content in 11ty
This is a follow up post to Pulling WordPress Content into Eleventy, I’ve also written about how to make use of WordPress post categories and tags too. If you haven’t already I’d suggest giving those 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#anchor
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(/ |&/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#anchor
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#anchor
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#anchor
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) %}
<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#anchor
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#anchor
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!
Need help with an upcoming project?
I'd love to hear more about what you have coming up and how I can help bring it to life! My earliest availability is currently Q1 2025.
Get in Touch