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 textStep 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\smeans 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