Astro provides. I am pretty sure this applies to other markdown parsers as well.
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.
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.
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 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>
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.