Markdown Plugins 101

Intro to writing your custom remark/rehype plugins

Introduction

Writing a previous post, I had the need to reference 2 lines of code in a snippet (code-block) probably 12-15 lines long…

This is how code-blocks show up in my posts:

let line1 = 'one';
let line2 = 'two';
let line3 = 'three';
let line4 = 'four';
let line5 = 'five';
let line6 = 'six';
let line7 = 'seven';
let line8 = 'eight';
let line9 = 'nine';

It would have been convenient to have line numbers for each line, and then I could reference in my text like Take a look at line 7....

I’ve never used, let alone, created a plugin for markdown display, and I was in the middle of this other post, so I left that tangent for later (this post).

In that previous post, I ended up doing this:

let line1 = 'one';
let line2 = 'two';
let line3 = 'three';
let line4 = 'four';
let line5 = 'five';
let line6 = 'six';
let line7 = 'seven'; (1)
let line8 = 'eight';
let line9 = 'nine'; (2)

And now I had a way to tell the readers to reference lines marked as (1) and (2)

Research the tools

The post that shed a light into the concept(s): Transforming Markdown with Remark & Rehype

After some reading into remark and rehype tooling, here is what you should know as a beginner:

Remark

Remark works with an AST that has been built from your Markdown. So it contains elements like headings, paragraphs, images, links, etc.

For the given markdown:

# I am Heading

I am para and [link](https://www.example.com)

![i-am-image](https://cdn.discordapp.com/attach.../unknown.png)

This tree is built:

{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "I am Heading",
        }
      ],
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "I am para and ",
        },
        {
          "type": "link",
          "title": null,
          "url": "https://www.example.com",
          "children": [
            {
              "type": "text",
              "value": "link",
            }
          ],
        }
      ],
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "image",
          "title": null,
          "url": "https://cdn.discordapp.com/attach.../unknown.png",
          "alt": "i-am-image",
        }
      ]
    }
  ],
}

Rehype

Rehype works with an AST that has been built after your Markdown has been processed by other remark plugins, and has been turned into HTML.

For the same markdown, this AST is available for rehype:

{
  "type": "root",
  "children": [
    {
      "type": "element",
      "tagName": "h1",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "I am Heading",
        }
      ],
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "I am para and ",
        },
        {
          "type": "element",
          "tagName": "a",
          "properties": {
            "href": "https://www.example.com",
            "title": "https://www.example.com",
          },
          "children": [
            {
              "type": "text",
              "value": "link",
            }
          ],
        }
      ],
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "element",
          "tagName": "img",
          "properties": {
            "src": "https://cdn.discordapp.com/attachments/967980999214506066/968002490429747250/unknown.png",
            "alt": "i-am-image"
          },
          "children": [],
        }
      ],
    }
  ],
}

While similar, it has a few differentiations.

Building your first plugins

Let’s build 2 plugins, one for remark, and one for rehype.

Our remark-plugin will focus on code-blocks in our markdown, and will add line-numbers to the beginning or each line.

Finished output:

01| let line1 = 'one';
02| let line2 = 'two';
03| let line3 = 'three';
04| let line4 = 'four';
05| let line5 = 'five';
06| let line6 = 'six';
07| let line7 = 'seven';
08| let line8 = 'eight';
09| let line9 = 'nine';
10| let line0 = 'zero';

Our rehype-plugin will also focus on code-blocks, and will look for special tokens in the text and replace them with images.

let line1 = 'one'; 
let line2 = 'two'; 
let line3 = 'three'; 
let line4 = 'four'; 
let line5 = 'five'; 
let line6 = 'six'; 
let line7 = 'seven'; 
let line8 = 'eight'; 
let line9 = 'nine'; 

Some tokens in the text have been replaced with images.

Helpers

To make your plugin authoring super-simple, you should leverage the following tool/package:

unist-util-visit

npm i unist-util-visit --save-dev

--save-dev as your markdown is worked on at build-time and not needed on the browser.

Concepts

Visiting every element in the AST

What a plugin should do is go thru every element in the syntax tree and work on its elements of interest.

For this, the unist-util-visit package provides us with a nice function that will do this for us, and call another function provided by us to do some work on the elements of interest.

Plugin shell

1| import { visit } from 'unist-util-visit';
2| function transformer (ast) {
3|   visit(ast, 'elements-of-interest', visitor);
4|   function visitor (node) {
5|     // do work on element-of-interest (node)
6|   }
7| };
8| function plugin () { return transformer; };
9| export default plugin;

line-numbers

Remark plugin

Start requirements:

After some initial inspection of ASTs, I’ve determined that a markdown code-block has an element type of code.

Inspection was done by doing some console.log(JSON.stringify(ast,null,2)) to see what the AST looked like.

The above code-block in this post yields:

{
  "type": "code",
  "lang": "javascript",
  "meta": "line-numbers hello world",
  "value": "import { visit } from 'unist-util-visit';\r\nfunction transformer (ast) {\r\n  visit(ast, 'elements-of-interest', visitor);\r\n  function visitor (node) {\r\n    // do work on element-of-interest (node)\r\n  }\r\n};\r\nfunction plugin () { return transformer; };\r\nexport default plugin;",
}

Note the "lang" property, and the "meta" property. These come from the line that begins the code-fence with the 3 back-ticks.

```javascript line-numbers hello world

This is how I started the code-block.

The "lang" comes from the word touching the fence, and "meta" is whatever else is typed afterwards.
Also note the "value" is all the text in one line.

"meta" is important for our plugin, because this is how I will determine if the line-numbers should appear for this particular code-block.

If "line-numbers" is not included in the "meta", then the plugin will ignore this element. This piece makes the line-numbers opt-in per code-block.

Implementation

01| import { visit } from 'unist-util-visit';
02| 
03| const nwline = '\r\n';
04| 
05| function transformer (ast) {
06|   visit(ast, 'code', visitor);
07|   //console.log(JSON.stringify(ast,null,2));
08|   function visitor (node) {
09|     //console.log(JSON.stringify(node,null,2));
10|     let metatokens = [];
11|     if (node.meta) { metatokens = node.meta.split(' '); }
12|     if (!metatokens.includes('line-numbers')) return;
13|     if (!node.value) return;
14|     let lines = node.value.split(nwline);
15|     let nwlines = [];
16|     let padsize = 1;
17|     if (lines.length > 9) padsize = 2;
18|     if (lines.length > 99) padsize = 3;
19|     lines.forEach((line, idx) => {
20|       nwlines.push(`${_lpad(idx+1, padsize, '0')}| ${line}`);
21|     });
22|     node.value = nwlines.join(nwline);
23|   }
24| 
25|   function _lpad (txt, sz, padwith) {
26|     let r = txt.toString();
27|     while (r.length < sz) r = padwith + r;
28|     return r;
29|   }
30| }
31| 
32| function plugin () {
33|   return transformer;
34| }
35| 
36| export default plugin;

Directions

Line(s)Info
03Specify our line-beak character(s)
10-12Determine if opted-in
14Split text/value into lines
16-18Determine prepend zero-count for line-number
19-21Prepend line-numbers to each line
22Update element value
25Helper padding fx

Activation

Adding your new plugin to Astro config.

If this is the first markdown config change you do, you will have to also account for the default Astro plugins.

Your astro.config.mjs should look similar to this:

// astro.config.js
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
//custom plugins 
import lineNumbersPlugin from './src/components/mdPlugins/remark/line-numbers.mjs';

// https://astro.build/config
export default defineConfig({
  integrations: [vue()],
  markdown: {
    remarkPlugins: [
      'remark-gfm', 'remark-smartypants',
      [ lineNumbersPlugin, {} ] 
    ],
    rehypePlugins: [
      'rehype-slug'
    ]
  },
});

(1) Depending where you saved your plugin file, adjust the import.
(2) Include your plugin function into the markdown.remarkPlugins array

Changes in the plugin were not applied to the dev server. I had to stop+start it on every change.

note-numbers

Rehype Plugin

Start requirements

Upon inspection of ASTs, I’ve determined that a markdown code-block shows up as "type"="raw".

A lot of things show up as type raw, so I have included an extra check .startsWith('<pre ') to short-circuit the plugin and only work on <pre> elements.

Implementation

import { visit } from 'unist-util-visit';

let theme = 'ograd'; 

function transformer (ast) {
  visit(ast, 'raw', visitor); 
  //console.log(JSON.stringify(ast,null,2));
  function visitor (node) {
    if (!node.value.startsWith('<pre ')) return; 
    //console.log(JSON.stringify(node,null,2));
    node.value = node.value  
      .replace(/\/\/0\/\//g, makepng(0))
      .replace(/\/\/1\/\//g, makepng(1))
      .replace(/\/\/2\/\//g, makepng(2))
      .replace(/\/\/3\/\//g, makepng(3))
      .replace(/\/\/4\/\//g, makepng(4))
      .replace(/\/\/5\/\//g, makepng(5))
      .replace(/\/\/6\/\//g, makepng(6))
      .replace(/\/\/7\/\//g, makepng(7))
      .replace(/\/\/8\/\//g, makepng(8))
      .replace(/\/\/9\/\//g, makepng(9))
      ;
  }

  function makepng (num) {  
    return `<img src="https://raw.githubusercontent.com/readonlychild/mao-assets/main/digits/${theme}/${num}.png" height="24" style="margin:-10px -10px -10px 0;" />`;
  }
}

function plugin (options) { 
  if (options.theme) theme = options.theme;
  return transformer;
}

export default plugin;

Directions

NoteInfo
(1)Default a theme value
(2)Set raw as element-of-interest
(3)Short-circuit non <pre> elements
(4)Replace special tokens with an <img> tag
(5)Helper fx for <img> tag
(6)Receive theme option from Astro.config

Notes

Writing this post I thought about supporting a theme override per code-block, but have not come up with an implementation. The rehype AST does not seem to have an equivalent to "meta".

At the start of the plugin, I mentioned I want to use ((1)) as the special token to look for and replace. There was an “issue” with this, for example, the javascript lang on remark grabbed the number in the middle of the parenthesis, and wrapped it on a span to change its color. The token ((1)) did not make it intact to the rehype steps. I switched it to //1// and that has been working. Hopefully it does not break on other languages

Numbers greater than 9 are not supported.

themes (site-wide) are supported.

Themes

I set-up several “themes”, that consist of digit pngs. Each folder has pngs for digits 0-9 and can be used by the plugin

Available themes

themesample
balls
blue-circle
bo
d4
f4
linn
ograd
red-circle
red-square

Activation

Adding your new plugin to Astro config.

Your astro.config.mjs should now look similar to this:

// astro.config.js
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
//custom plugins 
import lineNumbersPlugin from './src/components/mdPlugins/remark/line-numbers.mjs';
import noteNumbersPlugin from './src/components/mdPlugins/rehype/note-numbers.mjs';

// https://astro.build/config
export default defineConfig({
  integrations: [vue()],
  markdown: {
    remarkPlugins: [
      'remark-gfm', 'remark-smartypants',
      [ lineNumbersPlugin, {} ]
    ],
    rehypePlugins: [
      'rehype-slug',
      [ noteNumbersPlugin, { theme: 'blue-circle' } ] 
    ]
  },
});

(1) Depending where you saved your plugin file, adjust the import.
(2) Include your plugin function into the markdown.rehypePlugins array

Changes in the plugin were not applied to the dev server. I had to stop+start it on every change.

Note we can configure the theme for the plugin to use.

Conclusion

Once I understood the AST part of the process, making the plugins became something achievable

I hope this helps you to create any number of markdown plugins to enhance your code blocks or your posts in general.



Back to Post List
ok!