Extending markdown with custom blocks

Learn how to add custom blocks to markdown by leveraging generic directives.

I’m a big fan of simple solutions. By keeping things simple, you keep them maintainable.

That’s why I’m drawn to the idea of building websites where content is managed solely using markdown files. No CMS you have to connect to, just markdown files, that get transformed into HTML files.

And: The syntax of markdown is simple. It let’s you focus on your writing instead of thinking about syntax.

But markdown is too simple

However: In practice I find markdown too simple for managing content of a website.

Because markdown only lets you format text, it’s only useful for pages that solely consist of text and images. Writing blogposts? Markdown is fine.

But even in a blog – how do you manage content for pages that are not text only? Like every other page, that isn’t a blogpost.

Maybe we can extend markdown?

I recently had the idea, that I might be able to extend the default markdown syntax to render custom blocks. This way I could build more complex pages while benefitting from a relatively simple syntax. I dreamed up something inspired by liquid tags:

{{ hero theme=dark }}

## Hero title
Description of the hero block

{{ :hero }}

The idea here would be, that you could provide parameters to the block and pass in markdown content, that would get rendered inside of the block.

However, as I set out to find ways to implement my custom syntax, I found out, that other people have already done what I wanted to achieve.

Enter generic directives

Generic directives is a proposal for a syntax to allow custom plugins within markdown.

It has three types of directives:


// 1. inline elements
:textWithIcon[Custom text]{icon=arrowRight}

// 2. leaf block, allas block that does not let you pass in custom content
::youtubeVideo[Video title]{videoId=dQw4w9WgXcQ}

// 3. container block, allas block that let's you pass in custom content
:::hero{theme=dark,layout=fullwidth}
# Hero title
Description of the hero block
:::

Inline elements

Inline elements will be rendered inline with text – like links or italic text.

The anatomy of an inline element directive is:
:name[content]{key=value,secondKey=secondValue}

  1. : You use a single colon to signal you want to use an inline element.
  2. name After that you provide the name of the directive.
  3. [content] Inside the square brackets you provide the content that gets rendered inside of the element.
  4. {key=value} And lastly inside the curly braces you can add any number of attributes as key=value pairs. Multiple attributes are separated by comas.

Leaf blocks

Leaf blocks let you render block content. The syntax is exactly the same as for inline elements except that you use two colons to specify the element.

::name[content]{key=value,secondKey=secondValue}

Container blocks

Container blocks also let you render block content, but allow you to pass in more content – and even other directives or markdown content.

The syntax differs: A container block spans over multiple lines, it gets opened and closed with three colons :::.


:::name[inline content]{key=value,secondKey=secondValue}

## Markdown works in here
this will be rendered as a paragraph with a [link](https://example.com).

:::

You might wonder, why you can pass in content inside the square brackets and in between the opening and closing directives. As far as I can tell, the content inside the square brackets can serve as an unnamed attribute.

Implementation

To add generic directives to a website, you need to transform the raw markdown text into a markdown abstract syntax tree (mdast). This will return you JSON that you can use to render your custom blocks.

For node environments, one package that generates an mdast is mdast-util-from-markdown.

Additionally you need to pass in two plugins to enable generic directives: micromark-extension-directive and mdast-util-directive.

import { fromMarkdown } from "mdast-util-from-markdown";
import { directive } from "micromark-extension-directive";
import { directiveFromMarkdown } from "mdast-util-directive";

const tree = fromMarkdown(rawMarkdownText, {
	extensions: [directive()],
	mdastExtensions: [directiveFromMarkdown()],
});

// Might return 
// {
//  type: 'root',
//  children: [
//    {
//      type: 'containerDirective',
//      name: 'hero',
//      attributes: {},
//      children: [Array],
//      position: [Object]
//    }],
//  position: {
//    start: { line: 1, column: 1, offset: 0 },
//    end: { line: 15, column: 1, offset: 405 }
//  }
// }

Now you could loop over the children in the mdast tree and handle the custom cases – like the containerDirective in the example above.

In a future post, I will write about how to implement generic directives in Astro.

This post was published on