This isn’t a new plug-in. I’m experimenting with adding new capabilities to the built-in Markdown plugin, and trying very hard not to touch any code outside of the Markdown plugin.
I’m trying to implement more GitHub-like code fence syntax coloring. The best means of doing this that I’ve found so far is a project called highlight.js. As the .js implies, this code is written in JavaScript.
I’ve tried, and failed, to find a way to leverage the built-in CefBrower code and its related executeJavaScript() method. If there’s a way to do this I can’t figure it out. Even if I had succeeded in creating an offscreen browser (which I haven’t), the executeJavaScript() method returns void, so I wouldn’t even know how to see the results of executing JavaScript code that way anyway.
I need to create a JavaScript environment where once, and only once, the highlight.js code is loaded and made ready for use, an environment which then remains in place for subsequent repeated calls to the highlighting function.
The AI assistant has been full of terrible suggestions that simply don’t work. To the extent the assistant might be roughly onto something, it has suggested using GraalVM, but the AI is full of numerous, only incorrect, ways to add such a dependency to the Markdown plugin.
And I don’t even know yet how heavy-weight a solution that would be even if I could get it to work.
Any good ideas on how to either properly use the CefBrower code to create a reusable JavaScript environment out of which I can easily execute JS code and see the results?
A better way to access Node.js if the user has it installed?
Is there another already-available API I could use but just haven’t found?
Is there a lightweight dependency for handling JavaScript I can add to the Markdown plugin code alone, hopefully with some (unlike that provided by the AI assistant) clear and functional guidance on how to successfully add that dependency?
For purely entertainment purposes I’ll show you the horrible hack I’ve come up with for executing JavaScript code for now. I’m sure this would never pass muster as a contribution to the IDEA code base, but it has allowed me to get on with the rest of the work I wanted to do. Seeing this code might also provide a clearer idea of what I’m trying to accomplish.
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.intellij.plugins.markdown.extensions.common.highlighter
import com.intellij.ide.AppLifecycleListener
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.io.FileUtil
import com.intellij.util.SlowOperations
import org.intellij.lang.annotations.Language
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.File
import java.net.URL
import java.net.URLDecoder
import java.net.URLEncoder
internal class MarkdownApplicationInitListener : AppLifecycleListener {
override fun appStarted() {
if (!started) {
startUp()
}
}
companion object {
@Volatile var process: Process? = null
@Volatile var stdout: BufferedReader? = null
@Volatile var stderr: BufferedReader? = null
@Volatile var stdin: BufferedWriter? = null
@Volatile var started = false
@Volatile var processEnded = false
@Volatile var ready = false
@Volatile var sleeper: Thread? = null
@Volatile var haveDelayed = false
private fun startUp() {
started = true
Thread {
val nodePath = findNodePath()
if (nodePath == null) {
processEnded = true
sleeper?.interrupt()
return@Thread
}
val highlightJS = URL("https://unpkg.com/@highlightjs/cdn-assets@11.11.1/highlight.min.js").readText()
@Language("JavaScript")
val jsExtra =
"""
const lines = [];
let lineTimer;
let pendingInput = ""
let pendingResolve;
process.stdin.on('data', (data) => {
const newLines = (pendingInput + data.toString()).split(/\r\n|\r|\n/);
pendingInput = newLines.pop();
lines.push(...newLines);
if (lines.length > 0) {
if (lineTimer) {
clearInterval(lineTimer);
lineTimer = undefined;
}
if (pendingResolve) {
const resolve = pendingResolve;
pendingResolve = undefined;
resolve(lines.shift());
}
}
})
const getLine = () => new Promise(resolve => {
pendingResolve = resolve;
const line = lines.shift();
if (line) {
pendingResolve = undefined;
resolve(line);
}
else
lineTimer = setTimeout(() => {
lineTimer = undefined;
pendingResolve = undefined;
resolve(lines.shift());
}, 1000);
});
const waitForInput = () => {
getLine().then(line => {
line = decodeURIComponent(line?.replaceAll('+', ' ') || '');
if (line === "ready?") {
// Force a little exercise of the code before reporting ready.
hljs.highlightAuto('<span class="foo">bar</span>')
process.stdout.write("ready\n");
}
else if (line) {
let [_, language, content] = /^([^:]*):(.*)$/s.exec(line) || [];
if (language === 'json5') {
language = 'json';
}
try {
const html = (language ? hljs.highlight(content, { language, ignoreIllegals: true }) :
hljs.highlightAuto(content)).value;
process.stdout.write(encodeURI(html).replaceAll('+', '%2B') + '\n');
}
catch (e) {
process.stderr.write(e.message + '\n');
}
}
waitForInput();
});
};
waitForInput();
""".trimIndent()
val tempFile = FileUtil.createTempFile("highlight-js-plu", ".js")
tempFile.writeText(highlightJS + "\n" + jsExtra)
process = Runtime.getRuntime().exec(arrayOf(nodePath, tempFile.absolutePath))
stdout = BufferedReader(process!!.inputReader())
stderr = BufferedReader(process!!.errorReader())
stdin = BufferedWriter(process!!.outputWriter())
stdin?.write("ready?\n")
stdin?.flush()
read(stdout!!, 10000)
tempFile.delete()
ready = true
sleeper?.interrupt()
process!!.waitFor()
processEnded = true
ready = false
sleeper?.interrupt()
}.start()
}
private fun read(reader: BufferedReader, maxWait: Int? = 0): String {
val output = StringBuilder()
var wait = maxWait ?: 0
var line: String?
while (wait > 0 && !reader.ready()) {
Thread.sleep(50.coerceAtMost(wait).toLong())
wait -= 50
}
while (reader.ready()) {
line = reader.readLine()
if (line != null) {
output.append(line).append("\n")
}
else {
break
}
}
return output.toString()
}
internal fun parseToHTML(language: String, content: String): String? {
if (!started) {
startUp()
}
if (processEnded || content.trim().isEmpty() || (haveDelayed && process?.isAlive != true)) {
return null
}
SlowOperations.assertSlowOperationsAreAllowed()
if (!ready) {
sleeper = Thread.currentThread()
try {
Thread.sleep(if (haveDelayed) 250 else 10000)
}
catch (_: InterruptedException) { Thread.sleep(100) }
sleeper = null
haveDelayed = true
if (!ready) {
return null
}
}
stdin?.write(URLEncoder.encode("$language:$content", "UTF-8") + "\n")
stdin?.flush()
return URLDecoder.decode(read(stdout!!, 100), "UTF-8")
}
private fun findNodePath(): String? {
findNodeExecutable()?.let { return it }
return findNodeInCommonLocations()
}
private fun findNodeExecutable(): String? {
val pathEnv = System.getenv("PATH") ?: return null
val paths = pathEnv.split(File.pathSeparator)
val nodeExecutableName = if (SystemInfo.isWindows) "node.exe" else "node"
// Search for Node.js in all directories of the PATH variable
return paths.map { File(it, nodeExecutableName) }
.firstOrNull { it.exists() }
?.absolutePath
}
private fun findNodeInCommonLocations(): String? {
val commonPaths = listOf(
"/usr/local/bin/node", // macOS, Linux
"/usr/bin/node", // Linux
"/opt/homebrew/bin/node", // macOS (Homebrew on Apple Silicon)
"C:\\Program Files\\nodejs\\node.exe", // Windows default
"C:\\Program Files (x86)\\nodejs\\node.exe" // Windows x86
)
return commonPaths
.map { File(it) }
.firstOrNull { it.exists() && it.isFile }
?.absolutePath
}
}
}
For all the ugliness of this code, it does work.
You can’t, by the way, change the syntax colors with the existing Custom CSS featuring because the Markdown plugin, as is, hardcodes syntax colors as HTML style attributes, not as CSS classes. Those colors come from your currently selected theme. Besides the colors themselves not being alterable via Custom CSS, the logic of the syntax classifications is very different than GitHub does as well.
Highlight.js isn’t a perfect match for GitHub’s syntax analysis, but it comes a lot closer.
Dark theme colors also work terribly if you want to see how you GitHub Markdown will look in the GitHub light theme if you want to, as I do, use a dark theme everywhere else other than previewing Markdown.