Reading Markdown in JS for Navigation
4 min read

Reading Markdown in JS for Navigation

Our goal was to build a sidebar navigation that allowed user to quickly jump to reference points in our documentation. Secondly, we also wanted to use Markdown for our documentation to allow other users to quickly update the documentation or allow non-technical users to update the documentation as well.

We ended up using a JSON configuration file and JS to dynamically generate the navigation. We read in markdown documents, find all of the h1 and h3 elements. The h1 elements become the top level title in the navigation while the h3 elements become the sub navigation links. We can find those in markdown with # and ### for the titles.

JSON Configuration

We created a config.json file that we read in on application load. In this config.json we defined a couple of pages:

...
"pages": [
	{
    	"title": "Business Services Platform",
        "href": "business-services-platform",
    },
   	{
    	"title": "Digital Banking",
        "href": "digital-banking",
    },
    {
    	"title": "NCR Design System",
        "href": "ncr-design-systems",
    },
    {
    	"title": "Silver",
        "href": "silver",
    },
]
...

Document Structure

Secondly, we created a folder called docs/ where we stored all of our markdown files. For ease of use, we made sure to name the markdown files the same as the href in our config.json.

Reading Our Markdown

With our config.json and our documentation create, we can now load in the configuration, load the files, parse them for titles, and return that out.

Let's assume we have our pages object coming from the config.json

const { pages } = config; // Assume config is the loaded config.json

Now we want to loop through all the pages, require them, and add the markdown to an array. However! When we require them, this is an asychronous function so we need to make sure we wrap it all in an await to load them in the correct order. I searched StackOverFlow and found this asyncForEach function that we use as a helper:

async function asyncForEach(array, callback) {
	for (let index = 0; index < array.length; index++) {
    	await callback(array[index], index, array);
     }
 }

Now that we have that helper function, let's start crafting our function.

function generate(pages) {
    // Step 1. Loop through and read all the pages
    // Helper function
    const readMarkdownFiles = async (pages) => {
    	let files = [];
        await asyncForEach(pages, async(page) => {
        	const file = await require(`~/docs/${page.href}.md`);
            files.push(file);
        });
        return files;
    };
    const docs = await readMarkdownFiles(pages);
}
    

The docs variable will now be an array where each index is the markdown for the file.

Now we can create our navigation variable.

function generate(pages) {
    ...
    const docs = await readMarkdownFiles(pages);
    
    let navigation = [];
 }

Reading each markdown document.

With each markdown document in the array, we can now loop through them. Today, what we want to do is grab all of the H1 and H3 elements in markdown to put them in our navigation. So that means we want all # and ### tags in the markdown document.

We are going to end up with an array where each element is a page object and in that object  it contains an array of sub routes.

// Example object
[
    "title": "Business Services Platform",
    "href": "business-services-platform",
    "routes": [
    	{
            "title": "Subtitle",
            "href": "#subtitle"
        }
        ...
    ]
]

In the below code we will create the default title, path, and routes keys.

docs.forEach((doc) => {
    let title = "";
    let path = "";
    let routes = [];
    
}

Now an assumption with our Markdown documents is that it is properly formatted with line spaces between paragaphs and between elements. A good Markdown formatter should help get this looking pretty.

docs.forEach((doc) => {
    let title = "";
    let path = "";
    let routes = [];
    const allLines = doc.default.split(/\r?\n/);
}

Next, we want to loop through and find the only h1 title element with #:

docs.forEach((doc) => {
    let title = "";
    let path = "";
    let routes = [];
    const allLines = doc.default.split(/\r?\n/);
    allLines.forEach((row) => {
        let line = row.split(" "); // Split the current line by spaces
        if (line[0] === "#") {
            line.shift(); // Remove the 0th index element from the array
            line = line.join(" "); // Rejoin the array into a string
            title = line; // Since this the h1, set it to be the title (assuming 1 h1)
            path = line.toLowerCase().replace(/\W/g, '-'); // From the string, create a path representation with dashes
         }
     }
 }

If the element isn't an h1 but it is an h3 with ###, that will populate the sub navigation.

...

if (line[0] === "#") {
	line.shift(); // Remove the 0th index element from the array
	line = line.join(" "); // Rejoin the array into a string
	title = line; // Since this the h1, set it to be the title (assuming 1 h1)
	path = line.toLowerCase().replace(/\W/g, '-'); // From the string, create a path representation with dashes
} else if (line[0] === "###") {
	line.shift(); // Remove the 0th index element from the array
	line = line.join(" "); // Rejoin the array into a string
	let slug = line.toLowerCase().replace(/\W/g, '-'); // Create a path for the h3 element;
	routes.push({
		title: line,
		path: slug
	});
}
...

Lastly, we need to add this newly created object to our array!

...
naviation.push({
    title,
    path,
    routes
});

We now have an array of objects with our title, the path for easy linking, and then an array of objects containing the subnav title and path. This allows us to easily look through and create the sidebar navigation seen above.

Below is the full function. Let me know if you have any questions on Twitter @kevinguebert.

function generate(pages) {
    // Step 1. Loop through and read all the pages
    // Helper function
    const readMarkdownFiles = async (pages) => {
    	let files = [];
        await asyncForEach(pages, async(page) => {
        	const file = await require(`~/docs/${page.href}.md`);
            files.push(file);
        });
        return files;
    };
    const docs = await readMarkdownFiles(pages);
    docs.forEach((doc) => {
        let title = "";
        let path = "";
        let routes = [];
        const allLines = doc.default.split(/\r?\n/);
        allLines.forEach((row) => {
            let line = row.split(" "); // Split the current line by spaces
            if (line[0] === "#") {
                line.shift(); // Remove the 0th index element from the array
                line = line.join(" "); // Rejoin the array into a string
                title = line; // Since this the h1, set it to be the title (assuming 1 h1)
                path = line.toLowerCase().replace(/\W/g, '-'); // From the string, create a path representation with dashes
             } else if (line[0] === "###") {
                line.shift(); // Remove the 0th index element from the array
                line = line.join(" "); // Rejoin the array into a string
                let slug = line.toLowerCase().replace(/\W/g, '-'); // Create a path for the h3 element;
                routes.push({
                    title: line,
                    path: slug
                 });
              }
        }
        naviation.push({
            title,
            path,
            routes
        });
    });
    return docs;
}