
Table of Contents
Introduction
I enjoy the process of writing blog posts on a website that I am building from a template I found on Astro’s website. However, building a website from scratch means that I don’t get all the bells and whistles that would usually built into blogging websites. One of the features I find myself having to do the most is building out a table of contents for each of my blog posts.
I do not plan my blog posts. I write them around an idea and edit them afterwards. I don’t build a template of headings that I fill with content. In fact, it’s usually the opposite. I write the content, then thik about a heading afterwards.
In order to avoid interruptions to my flow, I don’t update the table of contents as I create the headings, but this typically happens in the process of editing.
This is a tedious task.
So I thought, why not try to automate this process. I did some research online and found some packages that would help me with this, but I am trying to reduce the bloat of my website so I opted out of using any third-party packages.
Instead, I decided to build my own script that suits the logic of my website. I am going to share the script with you then go through the thought process later in the blog so that you may benefit from the work or use it in your own blog.
The Full Script
(function(){
const toc = document.getElementById('toc');
if (!toc) return;
toc.innerHTML = "";
const headings = document.querySelectorAll('.container h2, .container h3, .container h4, .container h5, .container h6');
const root = document.createElement('ul');
let currentList = root;
const listStack = [root];
let prevLevel = 2;
headings.forEach(heading => {
if (heading.id.includes('table-of-contents')) return;
const level = parseInt(heading.tagName.charAt(1));
const li = document.createElement('li');
const id = heading.id || heading.textContent.trim().toLowerCase().replace(/\s+/g, "-");
heading.id = id;
li.innerHTML = `<a href="#${id}">${heading.textContent}</a>`
if (level > prevLevel){
const newList = document.createElement('ul');
listStack[listStack.length - 1].lastElementChild.appendChild(newList);
listStack.push(newList);
currentList = newList
} else if (level < prevLevel) {
let diff = prevLevel - level;
while(diff-- > 0) listStack.pop();
currentList = listStack[listStack.length - 1]
}
currentList.appendChild(li);
prevLevel = level;
});
toc.appendChild(root);
})()
The Code Logic
The Self Calling Function
Let’s start with the first little bit of logic, the function.
// Code block 1
(function(){
// script code
})()
So, what’s going on here?
I wrapped my function in a set of parentheses and used a secondary set of parrentheses to call it.
I realized the need to get this function to run every time a blog post was generated. Instead of giving the function a name and calling that function after the writing out the script, I took care of that logic in one fell swoop.
The equivalent of the code above would be something like this:
// Code block 2
function generateTOC(){
// script code
}
generateTOC()
Both these function calls do the same thing. In code block 1, you don’t have to name the function and it will self call. Whereas in code block 2, you’ll need a function name and then to call it after you’re done with the script. Since I don’t believe I will have much JavaScript associated with my blog posts written in markdown, I opted in for the lesser code option.
Container Logic
const toc = document.getElementById('toc');
if (!toc) return;
toc.innerHTML = "";
const headings = document.querySelectorAll('.container h2, .container h3, .container h4, .container h5, .container h6');
This part of the code looks for the target container that we will be placing our table of contents in. I set this container to be a nav element since the purpose of the table of contents is to navigate this page. I put a fail safe if check to exit the code if I can’t find the container because the rest of my code would be redundant.
The headings variable then crawls the container using the .querySelectorAll() method and stores all the headings within. This variable returns an array of headings.
I use .container class selector here because I want to stay within the blog container as there are other headings on my page that I want this function to omit.
If we console log this constant, this is what we get the following. Note: I am using a previous blog for this example.
headings: NodeList(5):
0: <h2 id="-table-of-contents">
1: <h2 id="introduction">
2: <h2 id="the-full-script">
3: <h2 id="the-code-logic">
4: <h3 id="the-self-calling-function">
The Stack
Let’s get to the meat of this function. The table of contents holds a list of headings, so we’re going to need an unordered list to hold all of our list items. So I start off with the root unordered list and set it to our currentList variable that we will use to track where we are as we build out the heading table of contents.
const root = document.createElement('ul');
let currentList = root;
const listStack = [root];
let prevLevel = 2;
I then start a stack in the variable listStack. I am using a stack here because I need to keep track of which list I am creating. If I want to nest my headings, I need to nest unordered lists within each other. See the code block below for reference.
Furthermore, I am keeping track of the heading level I am at in the loop. I will never have an H1 that I need to keep track of, so I start my prevLevel variable at 2 for H2.
<ul> // root variable
<li><a href="#-preface"> Preface</a></li>
<li>
<a href="#things-i-learned">Things I Learned</a>
<ul>
<li><a href="#-the-importance-of-curiosity"> The Importance of Curiosity</a></li>
<li><a href="#artificial-intelligence">Artificial Intelligence</a></li>
<li><a href="#adding-a-touch-of-whimsy-to-your-websites">Adding A Touch Of Whimsy To Your Websites</a></li>
<li><a href="#-thinking-performance"> Thinking Performance</a></li>
</ul>
</li>
<li><a href="#-accessibility"> Accessibility</a></li><li><a href="#-next-steps"> Next Steps</a></li>
</ul>
At this point, the currentList variable is an array with an empty unordered list <ul> tag.
listStack: Array [ ul ]
Since I build out these blogs, I tend to keep headings in chronological order, so an H3 will likely be nested under an H2. I will probably never use an H3 to represent a section and expect it to hold the same meaning as H2s. With that in mind, I built out this .forEach() loop to start building out the list in the previous code block.
The table of contents heading that I have on the page is an <h2> element, I don’t want to add that to my list so I skip that part ofthe loop.
headings.forEach(heading => {
if (heading.id.includes('table-of-contents')) return;
I use the level variable to check the current heading and create a new list item to prepare it for the unordered list.
const level = parseInt(heading.tagName.charAt(1));
const li = document.createElement('li');
If I remembered to make an id for the heading, I store it in the id variable. If not, I use the code after the || (or) operator to read the heading and make that my id and set it as the heading id.
const id = heading.id || heading.textContent.trim().toLowerCase().replace(/\s+/g, "-");
heading.id = id;
In order for our table of contents to be symantically correct, we nest list items <li> inside of our unordered list <ul>. The inner HTML of each list item will have our anchor tags <a> and our heading titles.
li.innerHTML = `<a href="#${id}">${heading.textContent}</a>`
There are a couple of variables we need to keep track of here. We are going to run a check each time we come across a new heading. We use the level variable and compare it to the previous heading level prevLevel and then perform a specific action.
If the level is greater than prevLevel which means that we may have come across and H3 when the previous heading was an H2, we create a new unordered list <ul> to nest our lists and create the tiered table of contents effect.
If the level is lower than prevLevel, so we are now back at an H2 when previously our heading was an H3, we need to calculate how far out we need to step to match the heading level, stepping out of our nested unordered lists <ul>. To do that, we need to remove our nested unordered lists from our stack using the .pop() method and set our crrent list to the last element in our stack.
if (level > prevLevel){
const newList = document.createElement('ul');
listStack[listStack.length - 1].lastElementChild.appendChild(newList);
listStack.push(newList);
currentList = newList
} else if (level < prevLevel) {
let diff = prevLevel - level;
while(diff-- > 0) listStack.pop();
currentList = listStack[listStack.length - 1]
}
// add console log here
console.log({level, prevLevel, listStack, currentList})
Adding console logs after the if checks, we’ll see this result.
Object { level: 2, prevLevel: 2, listStack: (1) […], currentList: ul }
Object { level: 2, prevLevel: 2, listStack: (1) […], currentList: ul }
Object { level: 3, prevLevel: 2, listStack: (2) […], currentList: ul }
Object { level: 3, prevLevel: 3, listStack: (2) […], currentList: ul }
Object { level: 3, prevLevel: 3, listStack: (2) […], currentList: ul }
Object { level: 3, prevLevel: 3, listStack: (2) […], currentList: ul }
Object { level: 2, prevLevel: 3, listStack: (1) […], currentList: ul }
Object { level: 2, prevLevel: 2, listStack: (1) […], currentList: ul }
Don’t forget to add the current list to your list item using the .appendChild() method and set your previous level tracker variable prevLevel to the current level and repeat the loop.
currentList.appendChild(li);
prevLevel = level;
// close the function
});
Finally, I add my table of contents to the parent container using the .appendChild() method.
toc.appendChild(root);
Hope that helps! Reach out via the contact form if you have any questions.
Edits
December 9, 2025
I added a new button to my index page to direct people to my latest blog post and I realized that my automatic table of contents script wasn’t firing.
After some investigation, I realized it was because I am using Astro and the ClientRouter API for smooth page transitions. So, I had to alter the function to work with the ClientRouter API since it does not consider these transitions a new page load and therefore does not run scripts built into the page.
So, I moved the function to its own toc.js file and named the function initTOC() and now I fire the script with the eventListener whenever the astro:page-load event is triggered. This helps to ensure the table of contents is always present.
The logic is in the code block below.
<script>
import { initTOC } from '/scripts/toc.js';
document.addEventListener('astro:page-load', () => {
initTOC();
});
</script>
This helps ensure that every time a new page is loaded via the Astro ClientRouter(), my script is fired.
This is why you should test your work!