Skip to main content
kld.dev

Building a table of contents from Astro's markdown headings

I thought I’d share how I built my table of contents navigation from the array of headings that Astro provides. I am pretty sure this applies to other markdown parsers as well.

The flat headings array

In Astro, when you specify a layout in your markdown frontmatter, it not only passes frontmatter as a prop to your layout component, but it also gives you a handy headings prop.

// ArticleLayout.astro
const { frontmatter, headings } = Astro.props;

The headings prop is a flat array of MarkdownHeading objects with depth, text, and slug properties. Something like this:

[
  { depth: 2, text: 'Introduction', slug: 'introduction' },
  { depth: 2, text: 'Solutions', slug: 'solutions' },
  { depth: 3, text: 'Solution 1', slug: 'solution-1' },
  { depth: 3, text: 'Solution 2', slug: 'solution-2' },
  { depth: 2, text: 'Conclusion', slug: 'conclusion' },
];

However, the final markup for the table of contents should have a nested structure like this:

<nav class="toc">
  <ul>
    <li>
      <a href="#introduction">Introduction</a>
    </li>
    <li>
      <a href="#solutions">Solutions</a>
      <ul>
        <li>
          <a href="#solution-1">Solution 1</a>
        </li>
        <li>
          <a href="#solution-2">Solution 2</a>
        </li>
      </ul>
    </li>
    <li>
      <a href="#conclusion">Conclusion</a>
    </li>
  </ul>
</nav>

I’m sure some would argue that we could simply render a flat list and use the depth to indent the items. This might visually look correct, but it would be semantically wrong. We are better than that.

Building a nested table of contents array

This is a function I wrote to take the flat array of headings and return a nested array. I realized later that I could have copied a similar implementation from the Astro Docs source code, but I am pretty happy with mine.

function buildToc(headings) {
  const toc = [];
  const parentHeadings = new Map();
  headings.forEach((h) => {
    const heading = { ...h, subheadings: [] };
    parentHeadings.set(heading.depth, heading);
    // Change 2 to 1 if your markdown includes your <h1>
    if (heading.depth === 2) {
      toc.push(heading);
    } else {
      parentHeadings.get(heading.depth - 1).subheadings.push(heading);
    }
  });
  return toc;
}

If we were to pass in our example array, buildToc would return this:

[
  {
    depth: 2,
    text: 'Introduction',
    slug: 'introduction',
    subheadings: [],
  },
  {
    depth: 2,
    text: 'Solutions',
    slug: 'solutions',
    subheadings: [
      {
        depth: 3,
        text: 'Solution 1',
        slug: 'solution-1',
        subheadings: [],
      },
      {
        depth: 3,
        text: 'Solution 2',
        slug: 'solution-2',
        subheadings: [],
      },
    ],
  },
  {
    depth: 2,
    text: 'Conclusion',
    slug: 'conclusion',
    subheadings: [],
  },
];

Much better! Now we just need to create some components to render our markup.

Components

The Table of Contents component

Let’s go ahead and add our <nav> and <ul> elements to a TableOfContents.astro component:

---
// TableOfContents.astro
const { headings } = Astro.props;
const toc = buildToc(headings);

function buildToc(headings) {
  // ...
}
---

<nav class="toc">
  <ul>
    {toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
  </ul>
</nav>

The Table of Contents Heading component

The only tricky part of rendering our headings is that they may contain subheadings, which may contain subheadings, and so on. We will need to create a recursive TableOfContentsHeading component. To do this, Astro provides Astro.self as a way to reference the current component from within itself.

---
// TableOfContentsHeading.astro
const { heading } = Astro.props;
---

<li>
  <a href={'#' + heading.slug}>
    {heading.text}
  </a>
  {
    heading.subheadings.length > 0 && (
      <ul>
        {heading.subheadings.map((subheading) => (
          <Astro.self heading={subheading} />
        ))}
      </ul>
    )
  }
</li>

Putting it all together

Our final step is to add our new TableOfContents component to our ArticleLayout.astro file:

---
// ArticleLayout.astro
import TableOfContents from '../components/TableOfContents.astro';
import BaseLayout from './BaseLayout.astro';

const { frontmatter, headings } = Astro.props;
// ...
---

<BaseLayout>
  <h1>{frontmatter.title}</h1>
  <TableOfContents headings={headings} />
  <article>
    <slot />
  </article>
</BaseLayout>

All that is left is styling and adding any progressive enhancements you want. My table of contents highlights the sections that are currently in view and has a fancy animated marker based on this Progress Nav demo by Hakim El Hattab. I plan on detailing all of that in a future post.