I'd like to have my plugin execute JavaScript code. What's the best way to do this?

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.

Please do not use AppLifecycleListener the way you are trying.

Use com.intellij.openapi.startup.ProjectActivity and postStartupActivity instead.

Thanks for the info. :grinning_face: I’ll fix that.

But I also hope someone has a cleaner way to execute JavaScript for me!

I would recommend exploring our Markdown plugin sources. It is also possible to embed more JS to pages loaded by JCEF. It is much easier than executing JS in process via GraalVM

So far my explorations of the Markdown plugin sources have helped me find things like the executeJavaScript() method, and have provided me with the vague knowledge that there’s clearly some built-in way to execute JS, but only enough knowledge to leave me confused and finding dead ends.

I’ll keep trying, especially if you or someone else can give me a few pointers.

For instance, is there a way to create a separate off-screen JCEF page that I can use just for executing JS code, with which I can do something similar to executeJavaScript() but get a String return value with the results of the executed JS?

I’ve tried to figure out a way to do just that, but keep running into private and protected methods that block me from making much progress. I don’t want to alter any code outside of the scope of the Markdown plugin for the purpose of gaining more access to JCEF functionality.

It is also possible to embed more JS to pages loaded by JCEF. It is much easier than executing JS in process via GraalVM

What I’m trying to do is provided alternatively-parsed content for Markdown code fences.

If I do this by purely by executing JS directly within the JCEF page used for the Markdown preview, the only way I can think of now to accomplish what I’m trying to do would be to first embed unparsed code inside code fences, along with a JavaScript calls to highlight.js to parse that raw code after a Markdown preview page has been rendered.

That might do the trick, but it seems more awkward than simply putting already-parsed code inside the code fences the first time those fences are rendered.

It is also possible to embed more JS to pages loaded by JCEF.

Okay, I’m trying to do this. It doesn’t seem very straight-forward!

I tried this, for instance, in MarkdownJCEFHtmlPanel.kt:

  private fun buildIndexContent(): String {
    @Language("JavaScript")
    val scriptExtra = """
<script>
  function parseCodeFence(id) {
    const node = document.getElementById('cfid-' + id);

    if (node)                   // Not what it's really supposed to do, just a test
      node.style.color = 'red'; // that this call can be reached.
  }
</script>
"""
    // language=HTML
    return """
      <!DOCTYPE html>
      <html>
        <head>
          <title>IntelliJ Markdown Preview</title>
          <meta http-equiv="Content-Security-Policy" content="$contentSecurityPolicy"/>
          <meta name="markdown-position-attribute-name" content="${HtmlGenerator.SRC_ATTRIBUTE_NAME}"/>
          $scriptingLines
          $stylesLines
          $scriptExtra // <-- Tried to add my extra scripting here
        </head>
      </html>
    """
  }

…but there’s no sign (code turning red) that anything happens. I think my added JS is just being thrown away somewhere along the line. When I try to follow back up the code stack when the above is executed, I end up as back at some sort of event dispatcher and can’t figure out beyond that what happens.

The value of $scriptingLines is:

<script src="http://localhost:63343/markdownPreview/1792944079/incremental-dom.min.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>
<script src="http://localhost:63343/markdownPreview/1792944079/incremental-dom-additions.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>
<script src="http://localhost:63343/markdownPreview/1792944079/BrowserPipe.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>
<script src="http://localhost:63343/markdownPreview/1792944079/ScrollSync.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>
<script src="http://localhost:63343/markdownPreview/1792944079/tex-svg.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>
<script src="http://localhost:63343/markdownPreview/1792944079/mathjax-render.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>
<script src="http://localhost:63343/markdownPreview/1792944079/copy-button.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>
<script src="http://localhost:63343/markdownPreview/1792944079/commandRunner/commandRunner.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>
<script src="http://localhost:63343/markdownPreview/1792944079/processLinks/processLinks.js?_ijt=pi5g5e511v5anvc4r9inp0ln9a"></script>

…which shows that other scripts are somehow loaded from an internal web server. If I could figure out how to host other scripts there, I’d do that, but it’s not at all clear. I suspect the script hosting code is probably inside some .jar file somewhere where I can’t search the source code for it.

When I search for, say, incremental-dom.min.js, I find nothing that shows me how some internal server recognizes that script name and returns it.

Okay, some progress.

I’ve figured out how the scripts loaded by the Markdown preview page are found here:

intellij-community/plugins/markdown/core/resources/org/intellij/plugins/markdown/ui/preview/jcef

…so that I just have to store my extra JS code in that directory, and also add the file names of that code to baseScripts in MarkdownJCEFHtmlPanel.

Now I understand why I didn’t find that names of the scripts anywhere else. They become bundled class resources.

My injected code fence scripts like this:

<script>parseCodeFence(2)</script>

…however don’t work yet for unknown reasons. I at least verified that the JS added to ...markdown/ui/preview/jcef does get injected into the preview page by creating a timed event to clear the page that successfully ran.

I’d still prefer to execute JS outside of the preview page and inject the results with syntax color coding already supplied, but at least I’m getting closer to a backup plan.

On to the next mystery.

I might be close to making highlighter.js work inside the JCEF browser, but in trying to debug something, I realized a big problem.

If I use in-page JavaScript to modify the DOM, updating fence contents with modified HTML with new color coding, that might end up looking fine on screen, but…

Tools/Markdown/Export Markdown File To..

…doesn’t work properly doing that. It just saves the original HTML source with no awareness of the dynamic changes that get made. :frowning: