a quicker way to make a table of contents in substack
sharing a little script that makes it faster to make a table of contents
skip to how to quickly make the table of contents here
I just started using Substack recently and shared my last article with my wife.
One of the first things she mentioned was that it would be nice to have a table of contents to make the article easier to navigate and read, which I totally agreed would be great. As I began to research, I found that Substack doesn’t support making a table of contents natively, unlike Notion (which supports building a table of contents automatically via headings)
I encountered this article by
explaining his process to make a table of contents. The process is a workaround involving copying all of the anchor links from your articles, creating all the links with the link button or cmd + k in the substack editor, and then manually formatting the table.The result is a functioning table of contents that will refresh the page and then navigate to the anchor link
It’s not sexy implementation with `#` anchor tags like below
So while we wait for Substack to natively implement table of contents, we will have to manually create them. I have found the fastest way for me to create the table of contents with the help of a little javascript magic ✨.
What you clicked for 🔗
prequisites: how to open your browser’s developer console (https://balsamiq.com/support/faqs/browserconsole/#google-chrome)
My step by step process w/ gifs to make a table of contents
Write your post and use H1 (#), H2 (##), H3 (###), or H4 (####) for any titles that you want to go into your table of contents
Publish your post
Open your post in your browser of choice (i’m using google chrome)
Open your browser’s developer console (here is a guide for the popular browsers)
Paste the below code into the developer console and press enter. This code will find all the anchor links and their titles in your post and then copy it to your clipboard. note: after running the code you have to press tab multiple times or click on your article to copy the links to clipboard.
function getAllHeadingLinks() { return document.getElementsByClassName('header-with-anchor-widget') } let formattedTable = `` const headingLinks = getAllHeadingLinks() let bulletCount = 0 for (const headingLink of headingLinks) { const headingHref = headingLink.getElementsByClassName('header-anchor-widget-button')[0].getAttribute("href") // I know the template string looks weird but we don't want the preceding white spaces ~ formattedTable += ` ${headingLink.innerText} ${headingHref}` } function copyTableOfContents() { return new Promise((resolve, reject) => { const asyncCopyToClipboard = (async () => { try { await navigator.clipboard.writeText(formattedTable); resolve(`successfully copied to clipboard`); } catch (e) { reject(e); } window.removeEventListener("focus", asyncCopyToClipboard); }); window.addEventListener("focus", asyncCopyToClipboard); console.log("Hit <Tab> or click on your substack article to give focus back to document (or the links won't get copied to clipboard);"); }); } // To call: copyTableOfContents().then((r) => console.log(r));
Now, comes the fun part: manually converting the output of the script above into real anchor links… (it would be GREAT if Substack supported pasting in markdown because then the links could be created automagically when copied in… but alas, this is what we gotta do)Edit the post that you want to add the table of contents to and paste the output of the script above into the Substack editor
For each pair of title and link, highlight the link and copy it, then highlight the title, create a link by either pressing cmd + k on mac or the link button, then paste in the link that you copied into the URL field
and then the last part, manually formatting the anchor links into your fancy smancy table of contents… you can use either a bulleted list, numbered list, or anything you want. I used pipes to separate my table of contents because I thought the numbered list took up way too much vertical space. (columns in Substack would be 🔥)
Thanks for reading
Ironically… this post doesn’t need a table of contents. Writing this was a lot of fun and if you’re having trouble with this guide; leave a comment below and I’ll try my best to help!
Smile and take it all in,
Winnie
Awesome post! Saves me a butt ton of time.
I updated the code a little to only scrape the H2s of the post and I found a way to automatically insert the links - if you copy the result to google docs or notion and then copy that result into substack it adds the links automatically. It's not perfect but it's working :)
function getAllH2HeadingLinks() {
return Array.from(document.querySelectorAll('.header-with-anchor-widget'))
.filter(h => h.tagName === 'H2'); // Filter for H2 headings only
}
let formattedTable = ``;
const headingLinks = getAllH2HeadingLinks();
for (const headingLink of headingLinks) {
const headingHref = headingLink.querySelector('.header-anchor-widget-button').getAttribute("href");
const headingText = headingLink.innerText.trim();
// Formatting as Markdown links
formattedTable += `- [${headingText}](${headingHref})\n`;
}
function copyTableOfContents() {
return new Promise((resolve, reject) => {
const asyncCopyToClipboard = async () => {
try {
await navigator.clipboard.writeText(formattedTable.trim());
resolve(`successfully copied to clipboard`);
} catch (e) {
reject(e);
}
window.removeEventListener("focus", asyncCopyToClipboard);
};
window.addEventListener("focus", asyncCopyToClipboard);
console.log("Hit <Tab> or click on your document to give focus back (or the links won't get copied to clipboard);");
});
}
// To call:
copyTableOfContents().then((r) => console.log(r));