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.