challenges

MDX Table of Contents: Part 1 (Solution)

This was a tough challenge! There wasn’t any UI development – the task was about behind-the-scenes string processing.

You can find the code updates for the solution on GitHub . Read on for explanations and commentary.

The bulk of this solution is writing a function to extract the headings from an MDX file. The guidelines stated the headings would be designated in the Markdown style , using #, ##, ###, etc – like this:

Markdown headings

# A top-level heading
 
## A level 2 heading
Some text here
 
### A level 3 heading
More text

Step 1: Write the function to extract headings

To start, I created a new file: src/helpers/headings-helpers.ts to contain a function that would take a string of MDX content and return an array of HeadingData objects. First, I defined an HeadingData interface to designate which information each heading will need:

headings-helpers.ts

export interface HeadingData {
  title: string;
  level: number;
}

This will eventually include an id for the heading DOM element, but that’s for a later workshop.

For extracting the headings, I borrowed heavily from kaf-lamed-beyt’s extract-md-headings code .

This is my function:

headings-helpers.ts

export function extractMdxHeadings(mdxContent: string): Array<HeadingData> {
  const headings: Array<HeadingData> = [];
 
  // match the `#` syntax for headings
  const headingMatcher = /^(#+)\s(.+)$/gm;
 
  let match = headingMatcher.exec(mdxContent);
  while (match !== null) {
    const level = match[1].length;
    const title = match[2].trim();
 
    if (level === 2 || level === 3) {
      // record this heading
      headings.push({ title, level });
 
    }
    // get next match
    // Note: the following statement must be
    //   *outside* the `if` statement above, 
    //   otherwise an infinite loop will occur 
    //   for headings of level greater than 3. 
    //   Thanks to Alberto for pointing this out! 
    //   https://github.com/bonnie/howd-mdx-toc/issues/7
    match = headingMatcher.exec(mdxContent);
  }
 
  return headings;
}

Let’s take a look at the regular expression (on line 5) first:

/^(#+)\s(.+)$/gm;

This is looking for any line that begins with one or more # characters and then some whitespace.

  • the ^ anchors the expression to the beginning of the line
  • #+ means one or more # characters
  • \s means a whitespace character

The parentheses around the #+ will “capture” a string with whatever matches within the paretheses — in this case, however many # characters it finds. We can access that using match[1] (line 9).

Then we’re looking for the rest of the line:

  • the (.+) captures one or more non-newline characters (a . matches anything but a newline).
  • the $ anchors the end of the regular expression to the end of the line.

This capture group will contain everything between the space character and the end of the line – in other words, the heading text. That gets read from match[2] on line 10. The .trim() trims any whitespace from the ends of the string.

The gm at the end of the regular expression are flags . g means keep looking for multiple matches, and m means to search each line as a separate string.

The rest of the function runs a while loop to keep finding matches using this regular expression, and appends the details of each match to the headings array – as long as the headings are level 2 or 3, per the specification of this task.

Step 2: Call the function from loadBlogPost

All right, we have a function that extracts headings from an MDX string… but where are we going to get the MDX string? The string is available within the loadBlogPost function, in src/helpers/file-helpers.ts .

Once we have the content (separated from the frontmatter using gray-matter ), we can feed it into our extractMdxHeadings function, and then add it to the return value.

before

export const loadBlogPost = React.cache(async (slug: string) => {
  const rawContent = await readFile(`${BLOG_POST_DIR_PATH}/${slug}.mdx`);
  const { data: frontmatter, content } = matter(rawContent);
 
  return { frontmatter, content };
});

after

export const loadBlogPost = React.cache(async (slug: string) => {
  const rawContent = await readFile(`${BLOG_POST_DIR_PATH}/${slug}.mdx`);
  const { data: frontmatter, content } = matter(rawContent);
  const headings = extractMdxHeadings(content);
 
  return { frontmatter, content, headings };
});

Step 3: Print the headings in the BlogPost component

Finally, destructure headings from the return value of loadBlogPost and print the result. This is done in the src/components/BlogPost/BlogPost.tsx file.

before

async function BlogPost({ params }: BlogPostParams) {
  const { frontmatter, content } = await loadBlogPost(
    params.postSlug
  );
  
  // ...
}

after

async function BlogPost({ params }: BlogPostParams) {
  const { frontmatter, content, headings } = await loadBlogPost(
    params.postSlug
  );
 
  // temporarily print to the console until there's a UI
  console.log("HEADINGS", headings);
  
  // ...
}

Because BlogPost is a React Server Component , the console.log output will be shown in the terminal where you’re running npm run dev – not in the browser console.

Terminal output

HEADINGS [
  { title: 'Understanding Your Learning Style', level: 2 },
  { title: 'Visual Learners', level: 3 },
  { title: 'Auditory Learners', level: 3 },
  { title: 'Kinesthetic Learners', level: 3 },
  { title: 'Effective Learning Techniques', level: 2 },
  { title: 'Spaced Repetition', level: 3 },
  { title: 'Active Recall', level: 3 },
  { title: 'Chunking', level: 3 },
  { title: 'Interleaved Practice', level: 3 },
  { title: 'Visualization', level: 3 },
  { title: 'Embracing Technology for Learning', level: 2 },
  { title: 'Conclusion', level: 2 }
]
 GET /posts/learning-techniques 200 in 471ms