22 Aug 2022

Render Markdown To HTML

  • #Markdown
  • #HTML
  • #JavaScript

Markdown is a lightweight markup language, and the syntax is supported by lots of web and desktop applications, such as Notion, Github and Slack. In comparison to HTML, it's easier to write, format, and read your content in markdown.

In saying that, using markdown for all my blog posts will give me a simplistic blogging experience.

The challenge now is rendering the Markdown content to the browser. There are many popular markdown processors out there, and for this blog I decided to go with remark.

The great thing about remark is being part of a UnifiedJS ecosystems. This allows us to chain different packages as a plugin to help extracting and manipulating content. It does this by taking Markdown, HTML or plain text and turning it into a AST (abstract syntax tree) data structure that the plugins within the ecosystem can consume.

Here are simple steps to converting a markdown file into HTML string that I use for my blog.

// process-markdown.ts

import toVFile, { VFile } from 'to-vfile';
import unified from 'unified';
import remarkParse from 'remark-parse';
import remark2rehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';

const processor = unified()
  .use(remarkParse)
  .use(remark2rehype)
  .use(rehypeStringify);

export async function processMarkdown(filename: string): Promise<VFile> {
  const file = await toVFile.read(filename);
  const data = await processor.process(file);

  return { content: data.contents };
}

As you can notice, I imported three plugins to do these 3 things:

Let's say you have the following markdown file.

# Test Post

This is a test post.

If I reference the above file path to the processMarkdown function, the return value should look similar to below.

{ content: "<h1>Test Post</h1>\n<p>This is a test post.</p>" }

Adding Metadata

Next, I want to add metadata on top of the markdown file. This is where I want to record the list of tags and the date created of each blog post. I also want to move the title there so that I can use it in two places. As a <h1> in the <body> and a <title> within the <head>.

The metadata format should look like this.

---
title: Test Post
date: 08/28/2021
tags: 
 - Markdown
 - HTML
 - JavaScript
---

This is a test post.

The update I want to make in processMarkdown is to split the metadata from the rest of the markdown content.

// process-markdown.ts

import toVFile, { VFile } from 'to-vfile';
import unified from 'unified';
import remarkParse from 'remark-parse';
import remark2rehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import remarkFrontmatter from 'remark-frontmatter';
import jsyaml from 'js-yaml';
import dayjs from 'dayjs';

const parser = unified()
  .use(remarkParse)
  .use(rehypeStringify)
  .use(remarkFrontmatter, ["yaml"]);

const processor = unified()
  .use(remark2rehype)
  .data('settings', { fragment: true })
  .use(rehypeStringify);

export async function processMarkdown(filename: string): Promise<VFile> {
  const file = await toVFile.read(filename);
  const tree = parser.parse(file);
  const [metadata, ...body] = tree.children;

  if (!metadata || metadata.type !== "yaml") {
    throw new Error("Missing metadata inside the markdown file");
  }

  const { title, date, tags } = jsyaml.load(metadata.value);
  tree.children = body;

  const nodes = await processor.run(tree);
  const content = processor.stringify(nodes);

  return {
    content,
    metadata: {
      title,
      date: dayjs(date).format("DD MMM, YYYY"),
      tags,
    }
  };
}

The above change may look complex, but it's not at all, let's break it down.

In the first iteration of processMarkdown, the processor.process was used and it internally ran these three methods, parse, run and stringify.

Since I want to take the metadata out from the rest of the content. I need to independently run the parse method first to create the syntax tree and then remove the first child node from the tree.

I want to point out the metadata syntax type is in YAML and I added remark-frontmatter plugin to easily recognise and format any YAML syntax inside the tree. Then I used jsyaml to convert the node value string into JSON.

Once the metadata is removed, I run the remaining run and stringify methods. The updated return value should have metadata along with the HTML string.

{
  metadata: {
    title: "Test Post",
    date: "28 Aug, 2021",
    tags: ["Markdown", "HTML", "JavaScript"]
  },
  content: "<p>This is a test post.</p>"
}

Rendering Code Block

Finally, It's not a developer blog post without a stylish syntax highlight for the code block. One of the most popular syntax highlighting library is highlight.js.

Luckily there is a UnifiedJS plugin to integrate highlight.js into the transform process, rehype-highlight.

Let's add the plugin to the processMarkdown.

// process-markdown.ts

const processor = unified()
  .use(remark2rehype)
  .use(rehypeHighlight)
  .use(rehypeStringify);

highlight.js already provides you with different stylings you can use, all you have to do is import the css file of your choosing to the project. I am currently using Atom One Light theme.

import 'highlight.js/styles/atom-one-light.css';

To finalise the markdown file we have been referencing for this post, let's insert a JavaScript code block to it.

---
title: Test Post
date: 08/28/2021
tags:
 - Markdown
 - HTML
 - JavaScript
---

This is a test post.

```JavaScript
const greeting = 'Hello World';
```

...and this is the final view.

Final View