How to Use PrismJS Plugins with NodeJS and MarkdownIt
Improve your storytelling with these dead simple hacks for rendering fancy Prism plugins in Node!
With over 7 million weekly downloads on NPM, PrismJS is one of the most widely used code highlighting packages in JavaScript, lauded for its unparalleled extensibility through plugins. But one recurring issue plagues developers: most plugins require a DOM! Fancy plugins such as command-line
, line-numbers
, and the toolbar suite require a DOM to manipulate HTML. This isn't normally possible in runtime environments such as Node, which aren't designed to render UIs, hence, no DOM.
In this post, I’ll demonstrate a few simple changes to easily coerce these plugins into compatibility with NodeJS and MarkdownIt, a popular markdown renderer.1
Why enhance codeblocks?
Simple codeblocks suffice for most writers who need supporting text with pretty colours. But maybe you want your codeblocks to simply look flashier. Or maybe you want better tools to tell a story!
To tell a good story, the characters, plot, and location need to be clear to the audience. Similarly, blog posts and tutorials benefit from enhancements such as command-line demarcations, line numbers, labels, and inline markup.
There are many cases where such codeblocks are warranted (brackets contain remedial plugins):
- Interaction: input and output should be contrasted, think: shell or Jupyter notebook (command-line)
- A post compares or demonstrates a technique in multiple languages (show-language)
- Code is executed in multiple, distinct environments (command-line, label)
- A post refers to a code snippet within a large file (line-numbers)
- A post refers to multiple files (label)
Example: Catching Reverse Shells (Labels and Command-Line)
This is really useful when presenting narratives for red teaming scenarios, where multiple machines are involved.2 Here are some simple notes on catching a reverse shell from a Windows machine.
Get our IP.
Listen for incoming connections on port 4444.
Download and execute
Invoke-PowerShellTcp
on the victim.
It's clear that our character shifts from Scene 1 to Scene 2, with less sentence clutter.
Another problem I notice on many documentation pages is the use of $
, denoting the start of a new command on a shell. These are poor for a couple reasons: it doesn't accurately present the actual code (from both the writer's and readers' PoV) and may disrupt syntax highlighters.
Example: The Obstructive $ (Command-Line)
These are commonly seen in installation scripts or guides. Here's an example installing a Makefile-based project:
Atrocious! You can't copy multiple lines without the nasty $ getting in the way! What terrible UX. It's like when your parent or sibling stands in front of the television!3
Compare it to this:
With that small rant out of the way, let's begin!
Step 1: Expose a DOM API
To make Node seem like a browser, we want to introduce document
and window
globals. These are the two keys variables used by Prism to manipulate HTML. window
isn't really called; it's mostly for UI behaviour. document
, however, is used rather heavily.
This could have adverse effects on libraries which use unified code for both browser and runtime environments. Introducing document
and window
might have unintended spillover effects... In most cases though, this shouldn't break anything.
The easiest solution is to glue a DOM manipulation library such as JSDOM or domino, and create global window
/document
variables. I’ve chosen to use domino since it renders codeblocks much faster than JSDOM, with zero additional dependencies.
NodeJS's global
object enables us to add global variables. Now Prism can access our window
and document
globals.
For convenience later, we’ll also create a function which converts HTML strings to DOM objects. We'll use the template
element to achieve this.4
Step 2: Override MarkdownIt Fence Rendering
While MarkdownIt works great out-of-the-box, the default rendering rules for code blocks are inherently incompatible with most Prism plugins. MarkdownIt's renderer passes text as input to an arbitrary highlight function, but Prism’s full-featured highlighting expects a DOM Element node. They don't speak the same language!
Prism has two primary highlight functions: Prism.highlight
and Prism.highlightElement
. Most server-side libraries are happy to use the former. But a lot of hooks aren't called by .highlight
, so we'll also have to customise the renderer to call .highlightElement
.
We'll reference MarkdownIt's default fence rule and customise it accordingly.
The code presented below will override the .renderer.rules.fence
rule completely, overwriting any previous implementations.
If you use another plugin which reuses the fence rule (e.g. markdown-it-prism
), consider calling code in this order: 1) the below code, 2) other code. This way no code is overwritten and lost.
- Remove the default text-based highlighting
options.highlight
. We still need to escape the HTML because we'll substitute it in a HTML string. - (Optional, if you want
diff
support.) Handlediff-*
languages by loading the right language. - Wrap the escaped code in
<div><pre><code>
with the right attributes5, convert it to a DOM element, highlight it toPrism.highlightElement
, then return the rendered HTML. - We also moved the attributes to
<pre>
instead of<code>
, which is required for PrismJS to function properly.
The most important part is step 3, where we call Prism magic with .highlightElement
.
See Full Changes
If you're using an SSR framework or SSG, you'll need to first access the MarkdownIt object.
In 11ty, for instance, we can access it like so:
markdown-it-attrs
Step 3: Use To add attributes to your codeblocks, introduce markdown-it-attrs
to your MarkdownIt object.
You can now add attributes and classes to your codeblocks which will then be parsed by Prism.
For instance, this:
```js {data-label=hello.js .inspect-me-lol}
function hello() {
console.log("Hello world!");
}
```
becomes:
In 11ty, markdown-it-attrs
is incompatible with eleventy-plugin-syntaxhighlight
due to the way codeblocks are parsed.
Step 4: (Optional) Modify Plugins
With those two changes, we have all we need to start importing fancy plugins!
In case you wish to fine-tune some plugins, you can always copy them into your local project and modify them directly! Some changes I made were:
- modding
line-numbers
for compatibility withcommand-line
anddiff
(Yes, this doesn't really make sense, but who knows if I'll need it in the future?) - modding
show-language
to display the base language when the highlight language isdiff-*
.
You can also add custom attributes with some simple CSS! For instance, I added a CSS class which hides the command-line prompt when a data-rw-prompt
attribute is specified.6 This is useful for long prompts, which may cover the entire screen's width when scrolling on a phone.
And here it is in action on some notes. Try viewing on both mobile and computer (or use your browser DevTools).
Benchmarking DOM Libraries
Understandably, one major bottleneck in our strategy is DOM manipulation. A suitable library needs to parse HTML fragments, manipulate DOM elements, and create new ones.
Although I originally selected JSDOM in my quick-n-dirty hack for its popularity, I later switched to domino for a significant (2x!) speedup. This is based on a simple benchmark, which compares the three most popular(?) NodeJS DOM-manipulation libraries: JSDOM, domino, and LinkeDOM.
Library | Mean (ms) | Min. (ms) | Max. (ms) |
---|---|---|---|
JSDOM | 583 | 492 | 1007 |
domino | 265 | 227 | 477 |
LinkeDOM | 280 | 214 | 666 |
Benchmark Details and CLI Output
The benchmark was run on a MacBook Air with a 1.6 GHz Intel Core i5 processor and 8 GB 2133 MHz LPDDR3 RAM. Benchmark code can be found here.
Moreover, domino has 0 dependencies — it's basically written from the ground up! JSDOM has 21 dependencies. A reduced set of dependencies reduces the attack surface of an application; and with increasing reports of supply chain attacks, it's good to limit such risks.
Final Remarks
I'll be the first to admit — this is a rather crude hack with some loose ends.7 But it works! Not to mention, it looks nice with enough CSS! And to some that's all that matters.
For those interested in the code, you can check out the code here:
Just copy the module into your build and load it.
Other mods:
- copy-to-clipboard: plugin / js / css
- line-numbers: plugin / css
- show-language: plugin
- command-line: css
- toolbar: css
- diff: css (based on a Eleventy customisation)
Footnotes
Instead of monkeypatching the environment (and potentially affecting other libraries), we could also just write new code using regex to modify HTML, removing the requirement for a DOM altogether. But let’s face it, I sleep more soundly knowing the current implementation is mature and battle-tested. By introducing a DOM API to Node, you're placing trust in a library to be spec-compliant. By handwriting regex, you're placing trust in your code (and regex!) to work. Which would you rather have? ↩︎
Not that I have any red teaming writeups, but I foresee the possibility of such posts in the future. ↩︎
To be fair, there's an argument to be made for "not running these commands all at once". Failure isn't handled. But this largely exists in relatively low-level build commands for C/C++, which are renowned for their many toolchains (read: potential build errors). ↩︎
Unlike JSDOM, domino doesn't have a "create fragment from HTML" function, so
template
is the suggested workaround. ↩︎Normally, wrapping it in
<pre><code>
is sufficient. But some plugins such as toolbar will traverse up to the parent ofpre
, so...<div><pre><code>
. Yay, more workarounds! ↩︎Here, rw stands for responsive width. ↩︎
It would be better to pass codeblock attributes directly to Prism without the extra stringification and parsing. Guess I'll leave this as an exercise for the front-end engineers. ↩︎
Comments are back! Privacy-focused, without ads, bloatware, and trackers. Be one of the first to contribute to the discussion — I'd love to hear your thoughts.