Asides in Astro Markdown


The remark markdown library provides a surprisingly clean and simple way to extend its behavior. Let’s explore a real problem and see what we can learn.

The Problem

Astro uses remark to convert markdown into html, but if you nest markdown inside html it isn’t processed. Consider this example:

<aside>
  *Yay*
</aside>

Instead of rendering <em>yay</em>, you’ll get *yay*, which is also reasonable, but not what I needed.

A Solution

There are probably a couple of ways to solve this, but remark-directive provides a relatively simple solution. Simply define a new plugin to process a custom block directive. Here’s all of the code:

export function remarkAsidePlugin() {
  return (tree: Root) => {
    visit(tree, (node) => {
      if (node.type === 'containerDirective') {
        if (node.name !== 'aside') return
        
        node.data ??= {}
        const tagName= 'aside'
        
        node.data.hName = tagName
        node.data.hProperties = h(tagName, node.attributes || {}).properties
      }
    })
  }
}

Add it to Astro along with the remark-directive plugin by extending the markdown configuration:

export default defineConfig({
  //…
  markdown: {
    remarkPlugins: [remarkDirective, remarkAsidePlugin]
  }
})

Here’s how you’d use it:

:::aside
  This is almost verbatim from the 
  [readme](https://github.com/remarkjs/remark-directive?tab=readme-ov-file#example-styled-blocks).
:::

In the Weeds

Problem solved, but let’s not miss an opportunity to learn something.

At a high level, remark does three things. First it parses markdown into an abstract syntax tree (AST) representing the document’s structure, next it transforms that syntax tree, and finally it converts it into HTML.

Our plugin lives in the transforming step. It accepts a Root, which is the AST representing your markdown and it recursively visits each node in the tree.

Our callback looks for containerDirective nodes parsed by the remark-directive plugin. When it finds one, it sets hName and hProperties. These define how remark will render the content. When remark converts our AST to HTML, it will use the hName and hProperties to create an aside tag.

One last tidbit. I slipped in the nullish coalescing assignment operator ??=. It’s a great way to set a value if one hasn’t been defined:

const a = {}
a.value ??= false   // {value: false}
a.value ??= "Hello" // {value: false}

In comparison, the idiom of a.value = a.value || "Hello" would have overwritten a.value.

Evergreen Learning

What can we take away and apply elsewhere?

Remark’s approach of adding new types of nodes (containerDirective in this case) to the AST, and making it easy to process those nodes allows encapsulated extension. When extending your software, are there opportunities to insulate the rest of your code from the new cases you’re introducing? Your problem and domain may not look the same, remark’s example is worth keeping in mind.