Last weekend I learned how to make a Table of Contents utilizing Contentlayer's computed fields, today I am sharing it with you guys! Here is what we'll be building today:

• toggleable on a per-post basis by simply adding a toc: true to the frontmatter
• Works flawlessly with Nested Headings

Now that that's out of the way, let's get started.

Setup

File structure

Intended Audience

This tutorial is Intended for people who are already using contentlayer for their blog, so I won't be covering how to setup one from scratch, I'll also be using NextJS 12 but most of the steps are framework agnostic.

Here is the (simplified) file structure, that I'll be navigating through out this project

src/
├─ content/
│  ├─ posts/
│  │ ├─ fancy-post.mdx
│  │ ├─ another-cool-post.mdx
├─ pages/
│  ├─ blog/
│  │  ├─ [slug].js
├─ contentlayer.config.js

Installing the Necessary Packages

for this project we'll only need 2 packages, pretty cool isn't it ? we'll look into what each one does later

npm install github-slugger rehype-slug
// or...
yarn add github-slugger rehype-slug

The code

First go to your contentlayer.config.js, specifically the makeSource function, it should look something like this

contentlayer.config.js
export default makeSource({
contentDirPath: "content",
documentTypes: [Post],
});

Now create a markdown or mdx proprety inside the parameter of this function and add the following rehype plugin. in my case I'll be using mdx.

contentlayer.config.js
import rehypeSlug from "rehype-slug";
export default makeSource({
contentDirPath: "content",
documentTypes: [Post],
mdx: {
rehypePlugins: [rehypeSlug],
},
});

What rehypeSlug does is simply adding an id to every heading in the page, it doesn't create a link that wraps the heading though, if two headings have the same name, it will increment a number at the end (i.e cool-heading-1)

Now find the Document Type Definition for your articles, it should be in the same contentlayer.config.js file as before, and add the following headings Computed Value.

contentlayer.config.js
const Post = defineDocumentType(() => ({
name: "Post",
contentType: "mdx",
// Location of Post source files (relative to contentDirPath)
filePathPattern: posts/*.mdx,
fields: {
title: {
type: "string",
required: true,
},
// other fields...
},
computedFields: {
type: "json",
resolve: async (doc) => {},
},
// Other Document types...

Now inside the resolve method we'll write the code that will fetch every heading from the MDX file using a very simple complex regex.

contentlayer.config.js
headings: {
type: "json",
resolve: async (doc) => {
const headingsRegex = /\n(?<flag>#{1,6})\s+(?<content>.+)/g;
},
},
Explaining the Regex 🗿

In short, Hi ## It's me and ###nospace won't match, but # Hello World will. Along with it 2 properties will be returned. flag = "#" and content= "Hello World" which we'll be using later.

Now we'll map over the array of matches in the document and return the data that we'll need, which is derived from the regex Named Control Groups, notice how we used flag.length to count the number of hashtags in the heading thus getting the heading's level. finally let's return the data we've mapped over.

contentlayer.config.js
headings: {
type: "json",
resolve: async (doc) => {
const regXHeader = /\n(?<flag>#{1,6})\s+(?<content>.+)/g;

({ groups }) => {
const flag = groups?.flag;
const content = groups?.content;
return {
level: flag.length,
text: content,
};
}
);
},
},

We also need to generate a slug from the contents of the headings, which crucially needs to be the same as the one we generated earlier, that's why we'll use github-slugger because it uses the same generation method rehype-slug. we made sure to check whether content is empty or not to avoid getting an error if there is an empty heading somewhere.

contentlayer.config.js
// make sure to have this import at the top of the file
import GithubSlugger from "github-slugger"
type: "json",
resolve: async (doc) => {
const regXHeader = /\n(?<flag>#{1,6})\s+(?<content>.+)/g;
const slugger = new GithubSlugger()
({ groups }) => {
const flag = groups?.flag;
const content = groups?.content;
return {
level: flag.length,
text: content,
slug: content ? slugger.slug(content) : undefined
};
}
);
},
}

Displaying the TOC

Now that most of the logic is done, move to your posts page, mine is src/pages/posts/[slug].jsx, somewhere before or after the mdx component

export const getStaticProps = () => {
// your post fetching logic goes here
return { props: { post } }
}
export default function singlePostPage( { post } ) {

return (
<div>
<div>
{/* leave this empty for now*/}
</div>
</div>

{/* the rest of the page goes here*/}
)
}

Now we'll map over the headings and display the table of contents, I've intentially made the styling pretty barebones so that you have the liberty to use whatever framework you want.

<div>
<div>
return (
<div key={#${heading.slug}}> <a href={heading.slug}> {heading.text} </a> </div> ) })} </div> </div> Handling Nested Headings you might have noticed that the one thing that's missing right now, is that all the headings appear as if they are on the same level even when they're not, you could go about this programatically with nested arrays, but I found the best method was to keep it simple and conditionally add a padding-left depending on the heading level. so if the top-level heading, then we add no padding and if it's a second-level heading we add say padding-left: 1rem and so on To start let's go back to the contentlayer.config.js and convert the level number to words (i.e 1 -> one, 2 -> two, etc..) contentlayer.config.js headings: { type: "json", resolve: async (doc) => { const regXHeader = /\n(?<flag>#{1,6})\s+(?<content>.+)/g; const slugger = new GithubSlugger() const headings = Array.from(doc.body.raw.matchAll(regXHeader)).map( ({ groups }) => { const flag = groups?.flag; const content = groups?.content; return { level: flag?.length == 1 ? "one" : flag?.length == 2 ? "two" : "three", text: content, slug: content ? slugger.slug(content) : undefined }; } ); return headings; }, } now go back to [slug].js and add a data-attribute to the Table of contents' <a> tags. <div> <h3>On this page<h3> <div> {post.headings.map(heading => { return ( <div key={#${heading.slug}}>
</a>
</div>
)
})}
</div>
</div>

and simply conditionally style the a tags based on the value of that data-attribute, the reason we converted the level into words is because apparently data-attributes don't accept numbers as values.

a[data-level="two"] {
}

a[data-level="three"] {
}

a[data-level="four"] {
}

If you're using tailwindcss 3v, you can do the same thing pretty elegantly too

<a
className="data-[level=two]:pl-2 data-[level=three]:pl-4"
>
</a>

As a final touch let's allow ourselves to toggle the TOC on a per-post basis, once again we'll need to go back to the contentlayer config and a toc field that's set to false by default

contentlayer.config.js
  fields: {
title: {
type: "string",
required: true,
},
date: {
type: "string",
required: true,
},
description: {
type: "string",
required: true,
},
toc: {
type: "boolean",
required: false,
default: false,
},
},

then only show the TOC when the field is set to true

{post.toc ? (
<div>
<div>
return (
<div key={#\${heading.slug}}>
</a>
</div>
)
})}
</div>
</div>
): undefined }

Final thoughts

And that's it! I hope you got your TOC working, if you're facing any problems feel free to reach out on mastodon! this article has been soo fun to write. I'll see you again in a few weeks

if you've enjoyed this article,consider buying me a coffee, it supports this site and caffeinates me so that I can keep producing awesome content!

Replies

There are no replies to be found!