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:
The code changes for this article are as follows:
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:
Copied!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:
Copied!// ... 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:
Copied!// 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:
Copied!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:
The code changes for this article are as follows:
References
- kommy-mdx/TOC.tsx at main · ekomenyong/kommy-mdx
- timlrx/tailwind-nextjs-starter-blog: This is a Next.js, Tailwind CSS blogging starter template. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
- Flet/github-slugger: Generate a slug just like GitHub does for markdown headings.
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!