Adding a Table of Contents to MDX Articles - Modern Next.js Blog Series #24

Published on

This article is also published at it 邦幫忙 2022 iThome Ironman Contest

After adding a page progress bar in the previous article, we continue with another dazzling and useful feature: "Article Sidebar Table of Contents," allowing readers to grasp the structure of the article at a glance!

The final effect is as follows:

table of contents dark

table of contents light

The code changes for this article are as follows:

https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/compare/day23-nprogress...day24-table-of-contents


Adding a Table of Contents to the Side of Article Pages

We will add two types of interactions for the upcoming table of contents: one is highlighting the currently viewed section title in red as you scroll through the article, and the other is auto-scrolling to the corresponding section of the article when you click any title in the table.

The latter effect, scrolling on click, utilizes the anchor links added with rehype-slug in Article 20: Adding Anchor Links to Headings.

Installing github-slugger to Convert Title Text to Slugs

The table of contents needs to know the titles and their anchors within the article. Here, we need github-slugger to convert title text to slugs.

The internal implementation of rehype-slug also uses github-slugger for slug conversion, so there's no need to worry about inconsistency.

Let's install github-slugger:

pnpm add github-slugger pnpm add -D @types/github-slugger

Passing the Original MDX Content of Articles to Article Pages to Capture All Titles

The sidebar table of contents needs to know all the titles in the article. We will implement it to only display h2 and h3 titles.

We can extract all titles from the ## and ### in the original MDX content of the article.

The Post object passed to us by Contentlayer includes a body.raw attribute, where we can get the original MDX content of the article.

Modify src/pages/posts/[slug].tsx to pass it to the article page:

// ... export const getStaticProps: GetStaticProps<Props> = ({ params }) => { // ... const post: PostForPostPage = { title: postFull.title, date: postFull.date, description: postFull.description, path: postFull.path, socialImage: postFull.socialImage || null, body: { code: postFull.body.code, // Add the following line raw: postFull.body.raw, }, }; // ...

Adding the <TableOfContents/> Component to Display the Table of Contents on the Side of Article Pages

Add src/components/TableOfContents.tsx, where the logic and styles for the table of contents are defined:

// ref: https://github.com/ekomenyong/kommy-mdx/blob/main/src/components/TOC.tsx import clsx from "clsx"; import GithubSlugger from "github-slugger"; import { useEffect, useRef, useState } from "react"; // eslint-disable-next-line no-unused-vars type UseIntersectionObserverType = (setActiveId: (id: string) => void) => void; const useIntersectionObserver: UseIntersectionObserverType = (setActiveId) => { const headingElementsRef = useRef<{ [key: string]: IntersectionObserverEntry; }>({}); useEffect(() => { const callback = (headings: IntersectionObserverEntry[]) => { headingElementsRef.current = headings.reduce((map, headingElement) => { map[headingElement.target.id] = headingElement; return map; }, headingElementsRef.current); const visibleHeadings: IntersectionObserverEntry[] = []; Object.keys(headingElementsRef.current).forEach((key) => { const headingElement = headingElementsRef.current[key]; if (headingElement.is Intersecting) visibleHeadings.push(headingElement); }); const getIndexFromId = (id: string) => headingElements.findIndex((heading) => heading.id === id); if (visibleHeadings.length === 1) { setActiveId(visibleHeadings[0].target.id); } else if (visibleHeadings.length > 1) { const sortedVisibleHeadings = visibleHeadings.sort( (a, b) => getIndexFromId(b.target.id) - getIndexFromId(a.target.id) ); setActiveId(sortedVisibleHeadings[0].target.id); } }; const observer = new IntersectionObserver(callback, { rootMargin: "0px 0px -70% 0px", }); const headingElements = Array.from( document.querySelectorAll("article h2,h3") ); headingElements.forEach((element) => observer.observe(element)); return () => observer.disconnect(); }, [setActiveId]); }; type Props = { source: string; }; const TableOfContents = ({ source }: Props) => { const headingLines = source .split("\n") .filter((line) => line.match(/^###?\s/)); const headings = headingLines.map((raw) => { const text = raw.replace(/^###*\s/, ""); const level = raw.slice(0, 3) === "###" ? 3 : 2; const slugger = new GithubSlugger(); return { text, level, id: slugger.slug(text), }; }); const [activeId, setActiveId] = useState<string>(); useIntersectionObserver(setActiveId); return ( <div className="mt-10"> <p className="mb-5 text-lg font-semibold text-gray-900 transition-colors dark:text-gray-100"> Table of Contents </p> <div className="flex flex-col items-start justify-start"> {headings.map((heading, index) => { return ( <button key={index} type="button" className={clsx( heading.id === activeId ? "font-medium text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" : "font-normal text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200", heading.level === 3 && "pl-4", "mb-3 text-left text-sm transition-colors hover:underline" )} onClick={(e) => { e.preventDefault(); document.querySelector(`#${heading.id}`)?.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest", }); }} > {heading.text} </button> ); })} </div> </div> ); }; export default TableOfContents;

The logic is mostly modified from EkomEnyong.com blog's TOC.tsx file, and the styles are inspired by Tailwind Nextjs Starter Blog (repo).

Finally, modify src/components/PostLayout.tsx to use it:

import { useRouter } from "next/router"; import CustomLink from "@/components/CustomLink"; import PageTitle from "@/components/PageTitle"; import PostBody from "@/components/PostBody"; import TableOfContents from "@/components/TableOfContents"; import formatDate from "@/lib/formatDate"; export interface PostForPostLayout { date: string; title: string; body: { raw: string }; } // ... export default function PostLayout({ post, nextPost, prevPost, children, }: Props) { const { date, title, body: { raw }, } = post; const { locale } = useRouter(); return ( <article> <div className="divide-y divide-gray-200 transition-colors dark:divide-gray-700"> // ... <div className="pb-8 transition-colors lg:grid lg:grid-cols-4 lg:gap-x-6" style={{ gridTemplateRows: "auto 1fr" }} > <div className="divide-y divide-gray-200 pt-10 pb-8 transition-colors dark:divide-gray-700 lg:col-span-3"> <PostBody>{children}</PostBody> </div> {/* DESKTOP TABLE OF CONTENTS */} <aside> <div className="hidden lg:sticky lg:top-24 lg:col-span-1 lg:block"> <TableOfContents source={raw} /> </div> </aside> </div> // ... </div> </article> ); }

Results

Done! Use pnpm dev, and when you view an article page on a desktop browser, you will see the table of contents added to the side!

All h2 and h3 subheadings will be displayed, with the current reading section highlighted in red as you scroll through the page, and clicking on a title will scroll to the specified section.

The final effect is as follows:

table of contents dark

table of contents light

The code changes for this article are as follows:

https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/compare/day23-nprogress...day24-table-of-contents

References

Next Article

Congratulations on successfully adding a sidebar table of contents, providing readers with a clear overview of the article structure!

In the next article, we will continue to add another dazzling and useful feature: Comment functionality!