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:
- 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. 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!2
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.3
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 full implementation can be found in this site's repository.)
This will override the rule completely without reusing previous implementations. If you use another plugin which modifies and reuses the fence rule (e.g. markdown-it-prism
), consider performing this overwrite first before calling the other plugin.
- Remove the default text-based highlighting
options.highlight
. We still escape the HTML since we'll substitute it in an HTML string. - Handle
diff-*
languages by loading the right language. - Wrap the escaped code in
<div><pre><code>
with the right attributes, 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 to present, 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.4 This may be 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.
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 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.5 But it works! Not to mention, it looks nice with enough CSS! And to some that's all that matters.
Footnotes
One workaround is to use regex to modify HTML — 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 to work. Which would you rather have? ↩︎
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. ↩︎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.