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