Logo

dev-resources.site

for different kinds of informations.

Creating Custom Table of Contents for Astro Content Collections

Published at
5/4/2024
Categories
blogging
contentcreation
astro
Author
billyle
Categories
3 categories in total
blogging
open
contentcreation
open
astro
open
Author
7 person written this
billyle
open
Creating Custom Table of Contents for Astro Content Collections

Having a Table of Contents (ToC) for your blog is nice because it allows users to see an overview of your content and provides a quick way to navigate between sections. To do this in Astro with the Content Collections API, there's a bit of legwork, but the results are satisfying. But first, let's talk about the issues I ran into.

The problem

I tried to do this with the plugin, 'remark-toc' , which is mentioned in the Astro documentation. What I didn't like about it is that if I wanted to include a ToC, I would have to manually add it to the top of all my .md files.

remark-toc display output on the blog post

Another note is that wherever I included it in the markdown, the ToC sits statically and to style it, I would have to target the id and fight my existing blog layout.

using remark-toc in a markdown file

This is not ideal for me. I had to figure out a better way to do this.

Retrieving all headers of a markdown

In the official documentation, there are two ways you can get all the headings of your blog posts. The two ways are when you're importing a .md into a .astro file or using the Astro.glob() function. Neither of those was valid in my case because I'm using the Content Collections API.

Buried in the documentation, I found that you can get the headings from a RenderedEntry if you're using the Content Collections API.

Inside of my /src/pages/posts/[...slug].astro where I am pre-rendering my blog posts using getStaticPaths(), I have a utility function that pulls all my published blog posts into a collection. From there, I extract the headings using a Promise.all().

---
import BlogLayout from "../../layouts/BlogLayout.astro";
import { allPosts } from "@utils/getCollection";
import type { GetStaticPaths } from "astro";

export const getStaticPaths = (async () => {
  const headings = await Promise.all(
    allPosts.map((entry) => entry.render().then((data) => data.headings)),
  );

  const posts = allPosts.map((entry, index) => {
    return {
      params: { slug: entry.slug },
      props: { entry, headings: headings[index] },
    };
  });

  return posts;
}) satisfies GetStaticPaths;

const { entry, headings } = Astro.props;
const { Content } = await entry.render();
---

<BlogLayout {...entry.data} {headings}>
  <Content />
</BlogLayout>

Enter fullscreen mode Exit fullscreen mode

For reference, inside an Astro Collection, you have a list of Entries. These Entries have a render() method that compiles the .md file for rendering. It also returns a property called headings which I used here to collect all the headings in a given markdown.

Here is the shape of that:

// headings shape
const headings: {
  depth: number;
  text: string;
  slug: string;
}[];

Enter fullscreen mode Exit fullscreen mode

With that, I returned it inside the props object which can be extracted from Astro.props.

Creating the heading hierarchy

I have all the headings passed down to my BlogLayout component, and now I can use it. The first thing I need to do is make sure that there is a hierarchy of headings so that the ToC properly indents the headings.

I tried doing this on my own with a recursive function but didn't have too much success. Luckily, I came across this blog by Reza Zahedi that showed me a good foundation to start with.

With the stolen copied code, I noticed that the nesting only allowed one list of subheadings. So if a heading has a depth of 2, and two headings succeeding that is of depth of 3 and 4 respectively, then it outputs something like this:

const nestedHeadings = [
  {
    depth: 2,
    text: "My Heading",
    slug: "my-heading",
    subheadings: [
      {
        depth: 3,
        text: "My Subheading 1",
        slug: "my-subheading-1",
      },
      {
        depth: 4,
        text: "My Subheading 2",
        slug: "my-subheading-2",
      },
    ],
  },
];

Enter fullscreen mode Exit fullscreen mode

I was okay with this since I do not want the ToC to get carried away with indentations. I wanted to prevent from writing any headings greater than 3, so I added a guard to throw an error if I did include one by accident.

Inside my TOCHeading.astro component, I'm exporting an interface:

import type { MarkdownHeading } from "astro";
export interface HeadingHierarchy extends MarkdownHeading {
  subheadings: HeadingHierarchy[];
}

Enter fullscreen mode Exit fullscreen mode

Inside my BlogLayout.astro component:

import type { HeadingHierarchy } from "@ui/components/TOCHeading.astro";
import type { MarkdownHeading } from "astro";

const { headings } = Astro.props;

function createHeadingHierarchy(headings: MarkdownHeading[]) {
  const topLevelHeadings: HeadingHierarchy[] = [];

  headings.forEach((heading) => {
    if (heading.depth > 3) {
      throw Error(
        `Depths greater than 3 not allowed:\n${JSON.stringify(heading, null, 2)}`,
      );
    }
    const h = {
      ...heading,
      subheadings: [],
    };

    if (h.depth === 2) {
      topLevelHeadings.push(h);
    } else {
      let parent = topLevelHeadings[topLevelHeadings.length - 1];
      if (parent) {
        parent.subheadings.push(h);
      }
    }
  });

  return topLevelHeadings;
}

const toc: HeadingHierarchy[] = createHeadingHierarchy(headings ?? []);
const hasToC = toc.length > 1;

Enter fullscreen mode Exit fullscreen mode

I'm using a variable called hasToC since in some cases I have a short blog post with only one heading, and it doesn't make sense to show the ToC. I use this variable to conditionally render the ToC and the appropriate layout.

Rendering the ToC

Rendering is fairly straightforward in Astro. I have a TOCHeading.astro component that I found in the other blog post and made minor adjustments like giving it types and such.

If you're going to use a sticky ToC, be sure that the parent component has a position: relative and that there is no overflow property on it. If your parent is a flex or grid parent, you need to wrap your position: sticky ToC with a container so that it will properly work.

<section class={`${hasToC ? "max-w-7xl mx-auto lg:grid lg:grid-cols-4" : ""}`}>
  {hasToC && (
    <div class="relative mx-auto px-4 prose dark:prose-invert xl:pt-10 2xl:px-0">
      <nav class="xl:sticky xl:top-20">
        <h2 class="text-emerald-400">Table of Contents</h2>
        <ul>
          {toc.map((heading) => (
            <TOCHeading heading={heading} />
          ))}
        </ul>
      </nav>
    </div>
  )}

  <article
    class={`py-10 sm:py-20 px-4 mx-auto prose prose-h1:font-vidaloka dark:prose-invert
            prose-code:before:hidden prose-code:after:hidden
            sm:prose-lg lg:prose-xl
            ${hasToC ? "lg:col-span-3" : ""}
        `}
  >
    <h1>{title}</h1>
    <slot />
  </article>
</section>

Enter fullscreen mode Exit fullscreen mode

The results

As you can see, my Table of Contents appears on the left-hand side. Now you can easily move between sections as you read!

For now, I'm only supporting the sticky ToC for desktops as I haven't found a good UI for tablets and mobile devices yet.

I guess all that's left to do is highlight the ToC heading that is currently being viewed, but I'll do that some other time.

I hope that was a bit helpful if you're trying to add a ToC for your Astro website if you're using the Content Collections API.

Well, thanks for reading and I hope you have a good one.

contentcreation Article's
30 articles in total
Favicon
10 Proven Free Marketing Strategies to Boost Your Cybersecurity Product's Visibility and Generate Leads
Favicon
AnswerThePublic: The Ultimate Guide to Understanding Search Intent and Content Ideation
Favicon
AI in Document Management Systems: Shaping the Future with Accessibility and Efficiency
Favicon
Unlocking the Future: 5 AI Tools Transforming Marketing Today
Favicon
The Art of Audience Engagement: How to Use Content and Q&A Sessions to Build Loyalty in the Digital Age
Favicon
๐Ÿš€ Sora by OpenAI is Here โ€“ AI Video Generation at Your Fingertips! ๐ŸŽฅ
Favicon
Unleash Your Potential: Content Ideas for a Prolific Developer Blog
Favicon
Start a Web Development Blog, Podcast, or YouTube Channel
Favicon
Revolutionizing Content Creation: The Role of AI Image Processing in Entertainment
Favicon
10 Best AI Tools Affiliate Programs: Maximize Your Profits
Favicon
Scalenut Review: Best AI SEO Tool for Content Creation
Favicon
Harnessing Developer Conversations for Product Growth: How Doc-E.ai Drives Real Business Value
Favicon
From Blog Posts to Keynotes: How to Create Powerful Thought Leadership Content
Favicon
Effortless Content Creation with EngagexAI: Videos and Podcasts Made Easy!
Favicon
Future-Proofing Developer Communities: Embracing AI, Data, and Virtual Connectivity
Favicon
Excelling in Tech Content Creation: Strategies for Success in 2024
Favicon
How to Create Developer Tutorials That Inspire and Empower: A Step-by-Step Guide
Favicon
From Code to Content: How I Became a DevRel Superstarโ€”Even with Language Barriers
Favicon
Generative AI in Sales Content Creation
Favicon
๐Ÿง  Generative AI in Sales Content Creation
Favicon
Review: Fifine Ampligame AM6 Condenser Mic
Favicon
How to Craft Technical Tutorials That Developers Will Love (No Code Experience Required!)
Favicon
Empowering Non-Technical Teams to Create Impactful Developer Content with Ease
Favicon
Qote AI Elevating Your Content with the Power of Famous Quotes
Favicon
Keep Astro Content Collection Types in Sync on Git Commit
Favicon
Highlight Table of Content Items Using Intersection Observer
Favicon
Adding RSS Feed Content and Fixing Markdown Image Paths in Astro
Favicon
Creating Custom Table of Contents for Astro Content Collections
Favicon
VideoProc: The Must-Have Tool for Modern Creators
Favicon
Scribbyo: Your AI-Powered Content Creation Powerhouse

Featured ones: