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)
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:
Abstract Syntax Trees
(AST)
link
having a url
.type
that lets you know if it is a heading, or a paragraph, an image, or a link…
type
, it contains other properties and can also contain children
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
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.
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.
To make your plugin authoring super-simple, you should leverage the following tool/package:
npm i unist-util-visit --save-dev
--save-dev
as your markdown is worked on at build-time and not needed on the browser.
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.
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;
Remark plugin
Start requirements:
After some initial inspection of ASTs, I’ve determined that a markdown code-block has an element type
of code.
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.
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;
Line(s) | Info |
---|---|
03 | Specify our line-beak character(s) |
10-12 | Determine if opted-in |
14 | Split text/value into lines |
16-18 | Determine prepend zero-count for line-number |
19-21 | Prepend line-numbers to each line |
22 | Update element value |
25 | Helper padding fx |
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
Rehype Plugin
Start requirements
((1))
turns into an image of a “1”((2))
turns into an image of a “2”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.
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;
Note | Info |
---|---|
(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 |
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.
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
theme | sample |
---|---|
balls | |
blue-circle | |
bo | |
d4 | |
f4 | |
linn | |
ograd | |
red-circle | |
red-square |
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
Note we can configure the theme
for the plugin to use.
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.