Qwik MDX Frontmatter Menu
Qwik provides a handy way to create a menu based on the h1
-h6
tags in a MDX file.
This is useful for creating a table of contents for your page. You can also define a
list of your MDX articles in a dedicated file and use this function to retrieve the
data for the menu. The problem with this approach is that it does not give you the ability
to get meta data from the MDX files. For this blog for example I used the date
and the
description
in the MDX frontmatter for the lists meta data. This approach requires
NodeJS fs
package and is therefore not available in Vercel Edge Functions. It
works very well with Qwik's static site generation.
Folder structure, component and MDX files
A route loader builds the bridge between the frontend and the backend in Qwik. It is just a function which you can execute in the Qwik component and it returns a signal. The signal is a reactive object which contains the data (if available). This signal can then be used in the template to render the menu. The route loader is executed on the server and therefore is capable of the NodeJS API.
Imagine we have a folder structure as follows and want to receive an array of the frontmatter data of the
MDX files in the index.tsx
file.
/src
/routes
/blog
/index.tsx
/my-first-post/index.mdx
/my-second-post/index.mdx
/my-third-post/index.mdx
Let's first create our component in the index.tsx
file with the route loader "binding". We return an
empty array in the first place.
import { component$, routeLoader$ } from '@builder.io/qwik';
export const useBlogDataLoader = routeLoader$(() => {
return [];
});
export default component$(() => {
const menu = useBlogDataLoader();
return (
<>
<ul>
{menu.value.map((item) => {
return <li></li>;
})}
</ul>
</>
);
});
This creates an empty unordered list (ul tag).
The route loader
As mentioned, the route loader is a function which executes on the server and creates a signal
on the frontend. Here we have access to the file system. So let's loop through the folders and
detect the MDX files first. For this snippet I am using the routerLoader$
function from the
previews example and enhance it as follows.
import { readdirSync } from 'fs';
import { join } from 'path';
export const useBlogDataLoader = routeLoader$(() => {
const dir = readdirSync(join('src', 'routes', 'blog'), {
withFileTypes: true,
});
const directories = dir.filter((dirent) => dirent.isDirectory());
return [];
});
We still return an empty array at this point, so nothing will be rendered. Now it's time to
actually work on the data. We want to get the frontmatter data of the MDX files. Therefore you
should make sure that you have installed the front-matter
package from npm. Now lets imagine
the following MDX file
---
title: Qwik MDX Frontmatter Menu
date: 2023-03-24
description: Learn how to create a dataset based on your MDX files in your Qwik app.
---
# Qwik MDX Frontmatter Menu
This means that we can define the interface for the frontmatter data as follows. This can happen
right before the definition of the route loader in the index.tsx
file.
interface FrontmatterData {
title: string;
date: string;
description: string;
}
Now there is one more thing we need to mention. The URL of the MDX file is not part of the frontmatter
data. We create an interface which extends the FrontmatterData
by an href
attribute.
interface MenuItem extends FrontmatterData {
href: string;
}
Now we are prepared to write a function which takes a filename and reads and returns the actual frontmatter data. In a function scope I prefer to use the lambda function notation. Note: At this point I comment out the parts which already have been discussed. You should not comment them out for the actual implementation.
import { readdirSync } from 'fs';
import { join } from 'path';
export const useBlogDataLoader = routeLoader$(() => {
// const dir = readdirSync(join('src', 'routes', 'blog'), { withFileTypes: true });
// const directories = dir.filter((dirent) => dirent.isDirectory());
// Read the frontmatter data of the MDX files
const readFileFrontmatter = (filename: string): MarkdownAttributes => {
const file = readFileSync(
join('src', 'routes', 'blog', filename, 'index.mdx'),
'utf8'
);
const fileContents = fm<MarkdownAttributes>(file);
return fileContents.attributes;
};
return [];
});
It would be a good practice to validate the data, for example with zod
. But for this article
we will skip this step. It would be a great homework for you if you want to learn more about
data validation. Now we create the actual menu items with the url and return them.
export const useBlogDataLoader = routeLoader$(() => {
// const dir = readdirSync(join('src', 'routes', 'blog'), { withFileTypes: true });
// const directories = dir.filter((dirent) => dirent.isDirectory());
// Read the frontmatter data of the MDX files
// const readFileFrontmatter = (filename: string): MarkdownAttributes => {
// const file = readFileSync(
// join('src', 'routes', 'blog', filename, 'index.mdx'),
// 'utf8'
// );
// const fileContents = fm<MarkdownAttributes>(file);
//
// return fileContents.attributes;
// };
// Create the menu items with an href attribute
const menuItems = directories
.map((dirent) => {
const markdownAttributes = readFileFrontmatter(dirent.name);
return {
href: `/blog/${dirent.name}`,
...markdownAttributes,
} as MenuItem;
})
.sort((a, b) => b.date.getTime() - a.date.getTime());
return menuItems;
});
Note that we loop through all the directories and read the frontmatter of the MDX files using
the readFileFrontmatter
function we have created earlier. Now we attach the href
attribute.
Note that the date attribute already is a Date
object and we can use it to sort our blog posts
by date. Last but not least we return the menu items instead of the empty array.
Build the menu in the template
In this section I won't focus on the styling. It shows how to loop through the elements and how you
can access the properties we have defined in the MenuItem
interface.
export default component$(() => {
const menuItems = useBlogDataLoader();
return (
<ul>
{menuItems.value.map((item) => {
return (
<li key={item.href}>
<Link href={item.href}>
<h1>{item.title}</h1>
<p>{item.description}</p>
<span>{item.date}</span>
</Link>
</li>
);
})}
</ul>
);
});
Enhancements
First of all you should think about data validation of your frontmatter data to
detect errors early. You can use the zod
package for this purpose.
You can also think about client side functionality like filtering, sorting or
pagination. Maybe I am going to cover this in a dedicated article in the future.
Feel free to leave me a DM on Twitter or Linkedin if you have any questions.
Have an awesome day!
References
- Photo by Foo Visuals on Unsplash