Better Clojure highlighting with nextjournal/clojure-mode

Almost two weeks ago I wrote about how I replaced my Octopress blog with about 200 lines of babashka. I also wrote that I used highlight.js for highlighting. This blog is mostly about Clojure/Script, so improvements in highlighting of Clojure code are welcome. Highlight.js does a good job, but it's not as good as it can be. E.g. Nextjournal's clojure-mode, which is a CodeMirror 6 integration for Clojure, covers a lot more detail. So I tried to strip the demo of clojure-mode to the bare mininum for just for highlighting. I ended up with about 320kb (117kb gzipped) of optimized JS based on a CLJS implementation. Trying to squeeze the last bit out by rewriting it to JS and bundling it with Rollup.js like the CodeMirror docs suggest yielded 600kb at first. But it turned out I needed to enable the terser output plugin and this got the bundle size down to 200kb (79kb gzipped).

In the future it might be possible to get even smaller builds when CodeMirror 6 gets better support for 'just highlighting'. There is a discussion about it here.

To automate building the highlighter code when I re-render this blog, I again used babashka tasks:

{:paths ["."]
 {:requires ([babashka.fs :as fs]
             [tasks-helper :as th])
  build-highlighter {:doc "Build Clojure highlighter JS"
                         (seq (fs/modified-since "public/clojure_highlighter.js"
                       (shell "npx rollup -c rollup.config.js"))}
  render {:doc "Render blog"
          :depends [build-highlighter]
          :task (load-file "render.clj")}

If the public/clojure_highlighter.js file is already there and JavaSceipt sources hasn't changed, the build-higlighter step won't take any significant time due to the use of fs/modified-since which I blogged about here.

$ bb clean
$ time bb render

src/clojure_highlighter/main.js → public/clojure_highlighter.js...
created public/clojure_highlighter.js in 2.7s
bb render   6.26s  user 0.84s system 155% cpu 4.557 total

$ time bb render
bb render   0.09s  user 0.05s system 81% cpu 0.174 total

The above snippet is still highlighted with Highlight.js, but <code class="clojure">...</code> is highlighted with clojure-mode.

To show off the highlighting, here is the CLJS code for the highlighter, which I ported to JS later on.

(ns clojure-highlighter.main
  (:require ["@codemirror/highlight" :as highlight :refer [tags]]
            ["@codemirror/language" :as language]
            ["@codemirror/state" :refer [EditorState]]
            ["@codemirror/view" :as view :refer [EditorView]]
            ["lezer-clojure" :as lezer-clj]
            [clojure.string :as str]

(def theme
  (.theme EditorView
          #js {".cm-content" #js {:white-space "pre-wrap"
                                  :padding "10px 0"}
               "&.cm-focused" #js {:outline "none"}
               ".cm-line" #js {:padding "0 9px"
                               :line-height "1.6"
                               :font-size "16px"
                               :font-family "var(--code-font)"}
               #js {:border-bottom "1px solid var(--teal-color)"
                    :color "inherit"}
               ".cm-gutters" #js {:background "transparent"
                                  :border "none"}
               ".cm-gutterElement" #js {:margin-left "5px"}
               ;; only show cursor when focused
               ".cm-cursor" #js {:visibility "hidden"}
               "&.cm-focused .cm-cursor" #js {:visibility "visible"}}))

(def style-tags
  (doto #js {:DefLike (.-keyword tags)
             "Operator/Symbol" (.-keyword tags)
             "VarName/Symbol" (.definition tags (.-variableName tags))
             :Boolean (.-atom tags)
             "DocString/..." (.-emphasis tags)
             :Discard! (.-comment tags)
             :Number (.-number tags)
             :StringContent (.-string tags)
             :Keyword (.-atom tags)
             :Nil (.-null tags)
             :LineComment (.-lineComment tags)
             :RegExp (.-regexp tags)}
    (goog.object/set "\"\\\"\"" (.-string tags))))

(def parser lezer-clj/parser)

(def the-parser
  (.configure parser
              #js {:props #js [(highlight/styleTags style-tags)]}))

(def syntax
  (.define language/LezerLanguage
           #js {:parser the-parser}
           #js {:languageData
                #js {:commentTokens {:line ";;"}}}))

(defonce extensions #js[theme
                        (.of (.-editable EditorView) false)

(defn ^:dev/after-load render []
  (let [elts (js/document.querySelectorAll "code.clojure")]
    (.forEach elts
              (fn [elt]
                (new EditorView
                     #js {:state
                          (.create EditorState
                                   #js{:doc (str/trim (.-innerText elt))
                                       :extensions #js [extensions]})
                          :parent elt})
                (.remove (.-firstChild elt))))))

You can find the code for this blog on Github, including the above code.


Published: 2021-11-05

Tagged: clojure