Intermediate

Table of Contents

A heading-extraction filter that builds a page's table of contents from its own rendered HTML — no separate outline to maintain.

Live Implementation

This table of contents was extracted from this page's own headings at build time:

How It Works

markdown-it-anchor is set up in eleventy.config.js to add an id to every h2h4 in rendered Markdown, based on the heading text. That alone makes every heading deep-linkable, like #folder-structure.

A custom toc filter then takes a page’s already-rendered HTML and runs one regex over it, pulling out { level, id, text } for each h2/h3 in the order they appear. There’s no separate list to keep in sync anywhere. The table of contents can’t drift from the real headings because it’s built from them.

The Live Implementation panel above is running that exact filter against this page’s own content right now. Every entry it lists is a real heading on this page, and clicking one jumps straight to that section.

Folder Structure

src/_11ty/filters/toc.js                     ← toc()
src/assets/js/toc.js                          ← optional scroll-spy highlight
src/_includes/demo-live/table-of-contents.njk ← usage

Important Files

src/_11ty/filters/toc.js

export default function toc(content = "") {
    const headingRegex = /<h([2-3])[^>]*\sid="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/g;
    const headings = [];
    let match;
    while ((match = headingRegex.exec(content)) !== null) {
        const [, level, id, innerHtml] = match;
        const text = innerHtml.replace(/<[^>]*>/g, "").trim();
        headings.push({ level: Number(level), id, text });
    }
    return headings;
}

src/assets/js/toc.js

const toc = document.querySelector("[data-toc]");

if (toc && "IntersectionObserver" in window) {
    const links = [...toc.querySelectorAll("a[href^='#']")];
    const targets = links.map((link) => document.getElementById(link.getAttribute("href").slice(1))).filter(Boolean);

    const observer = new IntersectionObserver(
        (entries) => {
            for (const entry of entries) {
                if (!entry.isIntersecting) continue;
                const link = links.find((l) => l.getAttribute("href") === `#${entry.target.id}`);
                if (!link) continue;
                links.forEach((l) => l.parentElement.classList.remove("is-active"));
                link.parentElement.classList.add("is-active");
            }
        },
        { rootMargin: "0px 0px -70% 0px" }
    );

    targets.forEach((target) => observer.observe(target));
}

Notes

  • You need at least two h2/h3 headings on a page for this to show anything.
  • Only h2 and h3 show up by default. h4 is skipped on purpose to keep the outline from getting cluttered; tweak the regex in toc() if you want it back.
  • The scroll-spy script is optional. The list of links works fine without it.