Logo

dev-resources.site

for different kinds of informations.

Adding a Table of Contents to dynamic content in 11ty

Published at
2/12/2023
Categories
webdev
eleventy
a11y
static
Author
stevenwoodson
Categories
4 categories in total
webdev
open
eleventy
open
a11y
open
static
open
Author
13 person written this
stevenwoodson
open
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, "-")
        .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 };
}
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) {
        break;
      }

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

static Article's
30 articles in total
Favicon
The Magic of `static` in Java: One for All, and All for One!
Favicon
First AWS Project
Favicon
Astro vs Visual Studio 2022 as Static Site Generators
Favicon
Dynamic vs Static typing (again)
Favicon
A real life example of using Late Static Binding in PHP.
Favicon
Back to the roots: advantages to static web development
Favicon
Choosing Between Static and Non-Static Methods in PHP: Real-World Use Cases and Code Examples
Favicon
C++, Static Object - Singleton pattern
Favicon
C++, Static Object - share data
Favicon
Hosting static sites with Cloudflare R2 and MinIO Client
Favicon
šŸ˜± Book Release: Eleventy by Example ā€“ Learn 11ty with 5 in-depth projects
Favicon
Host Your Static Website Files for Free with Staticsave: The Fastest and Easiest Way to Share Your Content Online
Favicon
Adding a Table of Contents to dynamic content in 11ty
Favicon
Mix static & client-side rendering on same page with SvelteKit
Favicon
The Top Five Static Site Generators (SSGs) for 2023 ā€”Ā and when to use them
Favicon
Position-relative and absolute
Favicon
Looking for a TinaCMS or Tina Cloud alternative?
Favicon
Editing Content with Hugo Shortcodes: A Shortcode-Aware CMS
Favicon
How to make use of the GitLab CI for Rust Projects
Favicon
Artisanal Web Development
Favicon
C - Static libraries
Favicon
Simplifying switcheroos
Favicon
What is a Static Site Generator?
Favicon
Static vs Dynamic Websites: The Definitive Guide
Favicon
Ten Myths about Static Websites
Favicon
Dart Typing šŸ’« šŸŒŒ āœØ
Favicon
Hosting Static Content with SSL on Azure
Favicon
Watching your Core Web Vitals on Jamstack
Favicon
Automatically update your GitHub Pages website with posts from dev.to
Favicon
Password Protection for Cloudflare Pages

Featured ones: