Frontend


1 Introduction

We write Clojure instead of JavaScript (JS) to add some interactivity to the woven HTML documents. Here are some definitions:

The main reason for using Clojure is because it is a much more pleasant language to use than JS. It is also more established than something like GHC's JS backend.

2 ClojureScript with Figwheel

There are three major CLJS tech stacks:

  1. The "official" tooling. This is what was described above.

  2. Figwheel. This is an older library that pioneered hot code reloading of CLJS (as JS) during development.

  3. shadow-cljs. This is a newer library that has tighter integration with NPM and the JS ecosystem. For example, to create a new project with it you must use the npx command which depends on having Node.js installed.

We choose to use Figwheel because it's a smaller library with fewer moving parts. Eventually when we feel the need to leverage NPM libraries (such as React), we could make the move to shadow-cljs.

2.1 Dependencies

Because of the relative immaturity of the Nix package manager's integration with Clojure1, we don't bother trying to set up a fully reproducible environment with it. Instead we just install the clojure binary and let it do the work of fetching all dependencies.

nix:tools-fe
pkgs.clojure

As this is a Clojure project, we have to declare dependencies using deps.edn, where we declare the dependency on Figwheel.

cfg:deps.edn
🎯 deps.edn
{:deps {com.bhauman/figwheel-main {:mvn/version "0.2.20"}
        com.bhauman/rebel-readline-cljs {:mvn/version "0.1.4"}}
 :paths ["resources" "src/fe"]}

The :paths ["target"] is necessary for helping

clj -M -m figwheel.main

work properly. Otherwise we'll see an error like

[Figwheel:WARNING] Attempting to dynamically add "target" to classpath!
[Figwheel:WARNING] Target directory "target" is not on the classpath
[Figwheel:WARNING] Please fix this by adding "target" to your classpath

2.2 Build configuration file

The build configuration file lilac.cljs.edn is the main way of configuring a particular build. These are essentially compiler options for the CLJS to JS compilation process.

cfg:lilac-fe-build
🎯 lilac.cljs.edn
{:main lilac.core
 :optimizations :none ; 1
 :output-to "js/lilac.js"
 :output-dir "js/cljs-dev"
 :asset-path "js/cljs-dev"}

We can use the above like this:

sh:invoke-dev-build-watcher
clojure -M -m figwheel.main --build lilac --repl

This will create a REPL that can evaluate CLJS and run it as JS in the browser tab at http://localhost:9500 per the :open-url setting in cfg:figwheel-main.edn. The lilac in --build lilac comes from the lilac substring in the lilac.cljs.edn configuration file (this is a Figwheel convention).

The :optimizations option is set to :none 1 because that's required for the REPL to work. Actually it is already the default value, but we specify it here for explicitness.

The traditional way to invoke sh:invoke-dev-build-watcher without typing everything out is to save it as an :aliases entry inside deps.edn. However we have a unified Makefile interface already at Makefile, and so we just use that instead.

make:Start frontend development environment (CLJS REPL)
dev-cljs:
	sh:invoke-dev-build-watcher
.PHONY: cljs

2.2.1 Known limitations

We cannot load the development-mode JS in cfg:lilac-fe-build in documents outside of the doc directory, such as plan.org (Plan) because although we can correctly see lilac.js, that script itself sources folders that are in the wrong relative directory. Below is a sample of that hardcoded behavior (what lilac.js looks like):

window.CLOSURE_UNCOMPILED_DEFINES = {"figwheel.repl.connect_url":"ws:\/\/localhost:8080\/figwheel-connect?fwprocess=48e35a&fwbuild=lilac"};
window.CLOSURE_NO_DEPS = true;
if(typeof goog == "undefined") document.write('<script src="js/cljs-dev/goog/base.js"></script>');
document.write('<script src="js/cljs-dev/goog/deps.js"></script>'); // 2
document.write('<script src="js/cljs-dev/cljs_deps.js"></script>');
document.write('<script>if (typeof goog == "undefined") console.warn("ClojureScript could not load :main, did you forget to specify :asset-path?");</script>');
document.write('<script>goog.require("figwheel.core");</script>');
document.write('<script>goog.require("figwheel.main");</script>');
document.write('<script>goog.require("figwheel.repl.preload");</script>');
document.write('<script>goog.require("devtools.preload");</script>');
document.write('<script>goog.require("figwheel.main.system_exit");</script>');
document.write('<script>goog.require("figwheel.main.css_reload");</script>');
document.write('<script>goog.require("process.env");</script>');
document.write('<script>goog.require("lilac.core");</script>');

In 2, it tells the HTML source to pull from a child directory of js/cljs-dev/.... This only works for HTML documents that are in the project root directory, because that's where the js folder is located.

This is not a problem for the Optimized production build, because that build uses a self-contained, single JS file (and thus does not need to import other files like in development mode).

2.2.2 Optimized production build

We have to use the following configuration file to get a single minified JS file.

cfg:lilac-fe-build-prod
🎯 lilac-prod.cljs.edn
{:main lilac.core
 :optimizations :advanced
 :output-to "js/lilac.js"
 :output-dir "js/cljs-prod"
 :asset-path "js/cljs-prod"}

You can run it like this:

sh:gen-prod-cljs
clojure -M -m figwheel.main --build-once lilac-prod

Or if you're using the Makefile, you can just do make prod-cljs, which will invoke the following rule:

make:Build prod JS
prod-cljs:
	sh:gen-prod-cljs
	sh:update-injectable
.PHONY: prod-cljs

If we are readying Lilac for a production release, it will need to have the contents of the single JavaScript file (at js/lilac.js) injected into the CLI binary. To perform this injection, we have to

  1. Run make prod-cljs to ensure we have the latest js/lilac.js file. This also creates a js/lilac.js.injectme which is a copy of the just-created (production) js/lilac.js file, per sh:update-injectable.

  2. Recompile the CLI. This can be done with make tangle because tangling itself relies on the locally compiled version of the lilac CLI. Because we use the file-embed library to inject the JavaScript file into the CLI in JavaScript injection, Cabal will automatically detect whether the JavaScript file has changed and recompile the CLI if needed.

sh:update-injectable just overwrites the path js/lilac.js.injectme with what's in js/lilac.js.

sh:update-injectable
cp -f js/lilac.js js/lilac.js.injectme

We cannot simply inject js/lilac.js directly because the development environment also uses the same file path in cfg:lilac-fe-build (and the development version of this file path does not have a self-contained JS file).

We track the js/lilac.js.injectme file in Git .gitignore, because it's needed for compiling the Lilac CLI in JavaScript injection. But don't want to see this file treated as regular text file (because it cannot be sensibly merged, and it doesn't make sense to show textual diffs of it between revisions as it is minified JS). For this reason we treat it as a binary file for Git.

gitattributes-ignore-compiled-js
js/lilac.js.injectme binary

2.3 Figwheel configuration

The cfg:figwheel-main.edn is a place to store common configuration options across all build files.

cfg:figwheel-main.edn
🎯 figwheel-main.edn
{:watch-dirs ["src/fe"]
 :css-dirs ["css"]
 :open-url "http://localhost:9500"
 :target-dir "js"
 :clean-outputs true}

If you want to override these common ones in a build, you have to specify it using metadata. For example, you could do

^{:css-dirs ["css"]}
{:main lilac.core}

in cfg:lilac-fe-build and it would override the :css-dirs option only for that build.

2.4 Using Figwheel in development

Lilac can serve itself with lilac serve (Serving). So you can just open up

lilac serve .

in another terminal, and then do

make dev-cljs

to start up Figwheel to make it handle the CSS and JS reloads. Finally you can run

./tangle-loop.sh

in a third terminal window to make Lilac tangle itself on every change (tangle-loop.sh).

3 lilac.core namespace

This is the main top-level Clojure namespace where we load all of our custom JS behavior.

ns:lilac.core
🎯 src/fe/lilac/core.cljs
(ns lilac.core
  (:require [lilac.popstate-history :as history]
            [lilac.toc-active-entries :as tae]
            [lilac.lazy-scroll :as lazy-scroll]
            [lilac.code-block-controls :as cbc]))

(history/init)
(tae/init)
(lazy-scroll/init)
(cbc/init)
(js/console.log "Lilac loaded successfully.")

The following can start the REPL but also automatically load the given Clojure namespace (in this case lilac.core), so that saving that file automatically reloads it.

clojure -M -m figwheel.main --compile lilac.core --repl

3.2 Active headings TOC indicator

We want the TOC to highlight the headings we are currently looking at, so that we can easily see where we are overall in the document. This works by checking which heading container <div> elements are visible in the viewport.

Heading containers are created in f:weaveHeading. Every time visibility changes, we update the visible-headings atom, which is a set of visible heading IDs. We also update the active and active-ancestor classes of the items in the Page contents. We highlight the active heading from the visible heading containers, and also all of their ancestor headings by reading in the Heading ancestry information, which is woven in as a JSON object in the HTML inside f:weaveHeadingAncestry.

ns:lilac.toc-active-entries
🎯 src/fe/lilac/toc_active_entries.cljs
(ns lilac.toc-active-entries
  (:require [clojure.walk :refer [keywordize-keys]]
            [clojure.set]))

(defonce visible-headings (atom #{}))

f:get-heading-ancestry
f:update-visible-headings
f:update-toc-highlights
f:maybe-scroll-toc

(defn init [] ; 3
  (let [heading-ancestry (get-heading-ancestry)] ; 4

    (add-watch visible-headings :lilac-toc-updater
      (fn [_key atom _old-state _new-state]
        (update-toc-highlights heading-ancestry @atom) ; 5
        (maybe-scroll-toc @atom))) ; 6

    (let [options #js {:rootMargin "0px 0px 0px 0px" :threshold 0} ; 7
          heading-containers
            (.querySelectorAll js/document ".heading-container")
          observer
            (new js/IntersectionObserver update-visible-headings options)] ; 8
      (doseq [heading-container heading-containers]
        (.observe observer heading-container))) ; 9

    (let [toc-entry-links (.querySelectorAll js/document ".toc-entry a")]
      (doseq [link toc-entry-links]
        (.addEventListener
           link "click" ; 10
           (fn [event]
             (let [clicked-link (.-currentTarget event)]
               (when-not (.contains (.-classList clicked-link) "active") ; 11
                 #(reset! visible-headings #{}))))))))) ; 12

The init function 3 has four parts:

  1. Read in the heading ancestry JSON (as a native Clojure map), via f:get-heading-ancestry at 4.

  2. Assign f:update-toc-highlights as a callback to fire whenever the visible-headings atom changes at 5.

  3. Use the browser's Intersection Observer API to watch all heading containers at 9. Make f:update-visible-headings get called (as another kind of callback) any time a heading container becomes (or no longer becomes) visible in the viewport 8. f:update-visible-headings modifies the visible-headings atom.

  4. Assign click handlers to TOC entry links 10, so that if a user clicks on a link from the TOC, we trigger a reset of the visible-headings atom at 12. This is so that the Intersection Observer API can populate that atom again in its callback. This way, we cleanly separate what the user does in the main viewport (scrolling it to trigger the Intersection Observer API), and what the user does in the TOC by clicking on links. If we don't have these click handlers in the TOC, then clicking on the TOC can potentially end up with weird snapping behavior.

    Note that the reset of the atom is only done if the heading that is clicked on is not already active 11 (that is, we don't do anything if the heading is already visible).

The :rootMargin "0px 0px 0px 0px" 7 just makes the entire viewport the thing to check intersections against, and the :threshold 0 makes the observer sensitive to even just 1 pixel of intersection.

Now let's look at how we get the heading ancestry from the JSON. Recall that it is woven into the document by f:weaveHeadingAncestry (f:weaveObject calls it). It gets woven with the #heading-ancestry ID, and so we use that to find the element from the DOM.

f:get-heading-ancestry
(defn get-heading-ancestry []
  (if-let [script-tag (.querySelector js/document "#heading-ancestry")]
    (let [json-string (.-textContent script-tag)
          js-object (.parse js/JSON json-string)]
      (keywordize-keys (js->clj js-object)))
    (do
      (.error js/console
              "Warning: Heading ancestry data not found; using empty map.")
      {})))

If we find the element, we convert the JSON map into a Clojure one. The main transformation is the conversion of string keys into keywords, with keywordize-keys. This makes the map lookup faster (as strings require character-by-character comparison).

Next is f:update-visible-headings which updates the visible-headings atom whenever there is an intersection event created by the observer in init. Because we observe the heading containers, we have to extract the heading ID that they contain, by dropping the first 18 characters (the heading-container- part of their ID; see f:weaveHeading to see its construction).

f:update-visible-headings
(defn update-visible-headings [entries]
  (doseq [entry entries]
    (let [heading-container-id (.. entry -target -id)
          heading-id (subs heading-container-id 18)
          visible? (.-isIntersecting entry)
          modifier (if visible? conj disj)]
      (swap! visible-headings modifier heading-id))))

The Intersection Observer API passes along a list of IntersectionObserverEntry objects. This object has the following properties of interest to us:

The code in f:update-visible-headings uses some special characters. The .. in (.. entry -target -id) is a threading macro, which threads the result of the previous operation as the object for the next operation. So we start with the entry object and then access its target property, which gives us the element (heading container), and then the id of that element.

The .- in (.-isIntersecting entry) is for accessing the isIntersecting property of the entry object.

Table 1. CLJS and JS interop syntax.
IntentClojureScript syntaxJS equivalent
Property access(.-prop obj)obj.prop
Method call(.method obj args)obj.method(args)

f:update-toc-highlights calculates visible-headings-with-ancestors 13, which is the set of relevant ancestor headings for the current set of visible-headings, and uses this to check each TOC entry. It highlights the active headings (and their ancestors).

f:update-toc-highlights
(defn update-toc-highlights [ancestry visible-headings]
  (let [toc-entries (.querySelectorAll js/document ".toc-entry")
        toc-container (.querySelector js/document ".toc")
        visible-headings-with-ancestors ; 13
          (reduce (fn [acc id]
                      (let [ancestors (set (get ancestry (keyword id) []))]
                        (clojure.set/union acc ancestors)))
            visible-headings
            visible-headings)]

    (doseq [toc-entry toc-entries]
      (when-let [toc-entry-link (.querySelector toc-entry "a")]
        (let [heading (subs (.-hash toc-entry-link) 1) ; 14
              class-list (.-classList toc-entry)]
          (cond
            (contains? visible-headings heading)
            (do
              (.add class-list "active")
              (.remove class-list "active-ancestor"))

            (contains? visible-headings-with-ancestors heading)
            (do
              (.add class-list "active-ancestor")
              (.remove class-list "active"))

            :else
            (do
              (.remove class-list "active")
              (.remove class-list "active-ancestor"))))))))

The heading (subs (.-hash toc-entry-link) 1) 14 is for removing the initial # part of the ID (so foo instead of #foo).

f:maybe-scroll-toc (potentially) scrolls the TOC so that it shows the active heading. It is called in 6. We scroll up or down, depending on the relative position of the TOC entry and where the TOC container is located. And within each case, we scroll all the way to the top or all the way to the bottom if the active heading is the first or last TOC entry.

f:maybe-scroll-toc
(defn maybe-scroll-toc [visible-headings]
  (let [toc-entries (.querySelectorAll js/document ".toc-entry")
        toc-container (.querySelector js/document ".toc") ; 15
        all-active-entries
          (filter (fn [toc-entry]
                    (when-let [toc-entry-link (.querySelector toc-entry "a")]
                      (contains?
                        visible-headings
                        (subs (.-hash toc-entry-link) 1))))
                  toc-entries)

        first-active (first all-active-entries)
        last-active (last all-active-entries)]

    (when (seq all-active-entries)
      (let [toc-rect (.getBoundingClientRect toc-container)
            first-active-rect (.getBoundingClientRect first-active)
            last-active-rect (.getBoundingClientRect last-active)]

        (cond
          (< (.-top first-active-rect) (.-top toc-rect))
          (if (= first-active (first toc-entries)) ; 16
            (.scrollTo toc-container #js ; 17
                       {:top 0 :behavior "instant"})
            (.scrollIntoView first-active #js
                             {:behavior "instant" :block "start"}))

          (> (.-bottom last-active-rect) (.-bottom toc-rect))
          (if (= last-active (last toc-entries)) ; 18
            (.scrollTo toc-container #js
                       {:top (.-scrollHeight toc-container)
                        :behavior "instant"})
            (.scrollIntoView last-active #js
                             {:behavior "instant" :block "end"}))

          :else nil)))))

It's important to make sure that the toc-container 15 is the element that has a scrollbar. Otherwise the scrolling actions will have no effect.

We scroll up if we see that the first active TOC entry is above the TOC container 16. This way we show all active headings, not just the first or last one. For example, if we need to scroll up but only scroll to the last active TOC entry (and there are other entries above it), we will still leave some of these other active entries out of view. We do the equivalent thing for scrolling down (where we check for the bounding box of the last active TOC entry) 18.

When ClojureScript encounters #js 17, it converts the Clojure data structure that follows it into its nearest JavaScript equivalent. So for Clojure maps, it creates a JSON object.

3.3 Lazy scroll

We don't want to do the default scroll behavior if the link target is already visible in the viewport (where scrolling means putting the target element at the very top of the page). In short, we should only scroll if we need to. The prevention of unconditional scrolling is handled in ns:lilac.lazy-scroll.

ns:lilac.lazy-scroll
🎯 src/fe/lilac/lazy_scroll.cljs
(ns lilac.lazy-scroll
  (:require [lilac.focus :refer [activate activate-element deactivate-element]]
            [clojure.string :as str]))

f:href->id
f:get-breadcrumbs-height
f:visible?
f:maybe-scroll
f:maybe-scroll-by-hash
f:weaken-anchor-links

(defn init [] ; 19
  (weaken-anchor-links) ; 20
  (.addEventListener js/window "popstate" maybe-scroll-by-hash) ; 21
  (maybe-scroll-by-hash nil)) ; 22

The 19 is the entrypoint. It is concerned with three types of events:

  1. clicking of links 20

  2. changing of URL history (pressing the forward/back button of the mouse without clicking on a link) 21

  3. initial page load 22

When we're clicking the forward and back (history) buttons on a mouse, we call f:maybe-scroll-by-hash 20 (which tries to scroll to the element in the current history (URL)) because pressing these mouse buttons are not associated with click events (otherwise, just f:weaken-anchor-links will suffice).

The initial page load 22 is an edge case where there is no associated event (no mouse click or history manipulation is involved), but where the URL could still end with an anchor at the end.

Let's now examine f:weaken-anchor-links which also relies on f:maybe-scroll-by-hash.

23 disables the browser's default action which performs the scrolling unconditionally. Instead, we invoke f:maybe-scroll-by-hash to only scroll if we have to. 25 is necessary because otherwise the browser history is not modified when clicking on an anchor link (in Anchor link history we made the browser treat anchor links as history-modifying events).

The check at 24 removes the active class if the user is clicking on the same link twice in a row. This lets the user effectively un-highlight the target of a link, just in case the highlighted CSS is too jarring on the eyes. It does not affect the history, so it doesn't matter how many times the user clicks on the same link pointing to the same target (pressing the back button will get them immediately back to the other element just before they started spamming the current target).

Let's finally look at f:maybe-scroll-by-hash, which is the real workhorse. It just tries to find the current anchor (aka fragment identifier, or the bit that starts with a hash like #foo in href=#foo), and then:

  1. activates it 27 by calling into f:activate from ns:lilac.focus, and

  2. calls f:maybe-scroll 28.

f:maybe-scroll-by-hash
(defn- maybe-scroll-by-hash [_popstate-event]
  (let [href (.-hash (.-location js/window))
        id (href->id href)] ; 26
    (when-let [target-element (-> js/document (.getElementById id))]
      (activate target-element) ; 27
      (maybe-scroll target-element)))) ; 28

f:href->id 26 just retrieves the anchor.

f:href->id
(defn- href->id [href]
  (let [href (.-hash (.-location js/window))
        href-final (if str/blank? href "#top")
        id (.substring href-final 1)]
    id))

f:maybe-scroll only scrolls to the target element if it is out view (outside the viewport).

f:maybe-scroll
(defn- maybe-scroll [elt]
  (when-not (visible? elt)
    (.scrollIntoView elt)))

The visibility check is done inside f:visible?.

f:visible?
(defn- visible? [elt]
  (let [rect (.getBoundingClientRect elt) ; 29
        bch (get-breadcrumbs-height)
        rect-top (- (.-top rect) bch)
        rect-bottom (- (.-bottom rect) bch)
        window-height (.-innerHeight js/window)
        is-visible (and (< rect-top window-height) ; 30
                        (> rect-bottom 0))]
    is-visible))

At 29 we calculate the bounding box of the target element, and perform a "is it visible" check at 30. The css:breadcrumbs element (in the navigation bar) hides whatever is underneath it from the human user, but for purposes of the viewport (and .getBoundingClientRect) it is ignored. So we have to compensate for it with f:get-breadcrumbs-height.

f:get-breadcrumbs-height
(defn- get-breadcrumbs-height []
  (or (some-> js/document
              (.querySelector ".breadcrumbs")
              (.-parentElement)
              (.-offsetHeight))
      0))

3.4 Element activation

Activating the target element means adding the active HTML class to it, as seen in ns:lilac.focus.

ns:lilac.focus
🎯 src/fe/lilac/focus.cljs
(ns lilac.focus)

f:deactivate-element
f:deactivate-other-elements
f:activate-element
f:activate

The public function is f:activate, which just calls f:deactivate-other-elements and f:activate-element.

f:activate
(defn activate [elt]
  (deactivate-other-elements (.-id elt))
  (activate-element elt))

f:activate-element is simple because it just adds the active class to the class list (i.e., convert class="foo" to class="foo active" for a given HTML element).

f:activate-element
(defn activate-element [elt]
  (let [class-list (.-classList elt)]
    (.add class-list "active")))

The tricky part is f:deactivate-other-elements. If we don't do this, we'll end up just activating new elements while leaving previously-activated ones still active. Since all anchor links point to things inside the <main> HTML element, we limit our search of elements to anchors somewhere along the hierarchy underneath <main> 31.

f:deactivate-other-elements
(defn- deactivate-other-elements [active-id]
  (when-let [main-elt (-> js/document (.querySelector "main"))]
    (let [child-anchors (-> main-elt (.querySelectorAll "[id]"))] ; 31
      (doseq [elt child-anchors]
        (when (not= (.-id elt) active-id)
          (deactivate-element elt))))))

Deactivating an element just means removing the "active" class from its list of classes.

f:deactivate-element
(defn deactivate-element [elt]
  (let [class-list (.-classList elt)]
    (.remove class-list "active")))

3.5 Code block controls

Code blocks can have two possible buttons:

  1. M (for metadata): This is for displaying all raw metadata (most of which is uninteresting, and which by default is hidden). See Hidden (raw) metadata. This button is available on all code blocks on hover.

    The button is generated here.

  2. Expand contents (for expanding code block body text): This is for showing the body text, but only when it is hidden. This button is optional, and is only displayed if you have #+header: :collapsed true for your block. Example.

    The button is generated here.

The tricky part with these buttons is that they result in different elements being hidden or displayed. There is additional complication in that the way these buttons interact with each other results in how we round out borders 39.

ns:lilac.code-block-controls contains logic for dealing with the concerns raised above. The key is that it keeps track of each code block's state (whether something is visible or not, whether the code block is anonymous, etc) in an atom in f:state, and then every time that state changes it re-renders the changed code block in f:render-block!.

ns:lilac.code-block-controls
🎯 src/fe/lilac/code_block_controls.cljs
(ns lilac.code-block-controls)

f:state
f:calculate-initial-state
f:ensure-block-state
f:handle-click!
f:toggle-class!
f:rounding-styles
f:set-enum-class!
f:render-block!

(defn init []
  (add-watch state :render
    (fn [_ _ _ new-state]
      (when-let [block-id (:updated-block-id new-state)]
        (render-block! block-id)))) ; 32
  (.addEventListener js/document "click" handle-click!)) ; 33

3.5.1 State handling

First let's discuss state handling, as it's the foundational piece. f:state holds an atom which holds the mutable state. Each block gets its own entry in the :block-ids map 34. The :updated-block-id 35 stores the ID of the most-recently updated block which needs to get re-rendered.

f:state
(defonce state
  (atom {:block-ids {} ; 34
         :updated-block-id nil})) ; 35

f:handle-click! is the entrypoint for state handling. It looks at the data-action=... attribute to the nearest code block, and then figures out which exact button was clicked by looking at value of the data-action part. Depending on the action, it toggles just the :meta-visible? or :body-visible? boolean 36. Then it performs the update to the state atom 37.

f:handle-click!
(defn- handle-click! [e]
  (when-let [btn (.closest (.-target e) "[data-action]")]
    (when-let [block (.closest btn ".code-block-container")]
      (let [block-id (keyword (.-id block))
            action  (.getAttribute btn "data-action")]

        (case action
          ("toggle-code-block-meta-raw" "toggle-code-block-body")
          (let [state-key (if (= action "toggle-code-block-meta-raw") ; 36
                            :meta-visible?
                            :body-visible?)]
            (swap! state #(-> % ; 37
                              (ensure-block-state block-id block)
                              (update-in [:block-ids block-id state-key] not)
                              (assoc :updated-block-id block-id))))

          (js/console.warn "[WARN] Unrecognized action:" action))))))

f:ensure-block-state just makes sure that we have an entry for the specific block that has been clicked on. If we don't have an entry for it, it creates the initial state for it with f:calculate-initial-state. The primary reason behind using these functions is to lazily load each block's state is it is clicked on by the user; if the user doesn't click on anything then the state atom will remain empty. If we were to eagerly load each block's state, that would introduce unnecessary computation on page load.

f:ensure-block-state
(defn- ensure-block-state [st block-id block-el]
  (if (contains? (:block-ids st) block-id)
    st
    (assoc-in st [:block-ids block-id] (calculate-initial-state block-el))))

f:calculate-initial-state examines the initial state of the block to determine the boolean state values we will use to keep track of it.

f:calculate-initial-state
;; Pure data calculation function: returns initial map from HTML markers
(defn- calculate-initial-state [container]
  (let [body (.querySelector container ".code-block-body")
        is-collapsed? (.contains (.-classList body) "collapsed")
        has-collapse-button? (.contains (.-classList body) "collapsible")
        is-anonymous? (.contains (.-classList body) "anonymous")]
    {:meta-visible?        false
     :body-visible?        (not is-collapsed?)
     :has-collapse-button? has-collapse-button?
     :anonymous?           is-anonymous?}))

Finally, we associate the change of the f:state atom with a callback to f:render-block! at 32, before wiring up the click event lister on the document to f:handle-click! 33.

Now we are ready to talk about rendering the block based on the state.

3.5.2 Rendering

f:render-block! is the main entrypoint for rendering a changed code block. It examines the state of the block in the state atom by dereferencing it 38, then modifies the CSS classes depending on the situation 40.

There are some intricate CSS class selection rules involved, mainly due to how we round the border corners of the various pieces involved 39.

f:render-block!
(defn- render-block! [block-id]
  (if-let [container (.getElementById js/document (name block-id))]
    (let [block-state (get-in @state [:block-ids block-id]) ; 38
          anon?            (:anonymous? block-state)
          body-vis?        (:body-visible? block-state)
          collapse-button? (:has-collapse-button? block-state)
          meta-vis?        (:meta-visible? block-state)

          raw-meta (.querySelector container ".code-block-meta-raw")
          body (.querySelector container ".code-block-body")
          controls (.querySelector container ".code-block-controls-inline")

          body-needs-full-rounding?
            (and anon? (not meta-vis?) (not collapse-button?) body-vis?)

          rounding-style ; 39
            (cond
              (and anon? meta-vis? body-vis?) "not-rounded"
              (and anon? (not meta-vis?) (not body-vis?)) "fully-rounded"
              (and anon? meta-vis? (not body-vis?)) "bottom-rounded"
              (and anon? (not meta-vis?) body-vis?) "top-rounded"
              (and (not anon?) meta-vis? body-vis?) "not-rounded"
              (and (not anon?) (not meta-vis?) (not body-vis?)) "bottom-rounded"
              (and (not anon?) meta-vis? (not body-vis?)) "bottom-rounded"
              (and (not anon?) (not meta-vis?) body-vis?) "not-rounded"
              :else nil)]

      ; 40
      (toggle-class! raw-meta "hidden" (not meta-vis?))
      (toggle-class! body "collapsed" (not body-vis?))
      (toggle-class! body "fully-rounded" body-needs-full-rounding?)
      (toggle-class! controls "hide-border-bottom" body-vis?)
      (set-enum-class! controls rounding-styles rounding-style))

    (js/console.warn "[WARN] Render target not found for ID:"
                     (name block-id))))

f:toggle-class! guards the .toggle object method with a nil check. This is important because not all blocks have all controls.

f:toggle-class!
(defn- toggle-class! [el class-name enable?]
  (when el
    (.toggle (.-classList el) class-name enable?)))

f:rounding-styles captures all possible rounding styles for the .code-block-controls-inline DIV. In a sense this is like an enum data type found in other programming languages. In our case it's a Clojure set #{...}.

f:rounding-styles
(def rounding-styles
  #{"fully-rounded" "top-rounded" "bottom-rounded" "not-rounded"})

f:set-enum-class! is the set-wise version of f:toggle-class!. It turns off all values first, before adding the one we want to enable.

f:set-enum-class!
(defn- set-enum-class! [el all-variants target-variant]
  (when el
    (let [class-list (.-classList el)]
      (doseq [variant all-variants]
        (.remove class-list variant))
      (when target-variant
        (.add class-list target-variant)))))

4 Glossary

Clojure
Lisp dialect which traditionally targets the JVM (Java runtime).
ClojureScript (CLJS)
Clojure library which is able to convert Clojure code into JavaScript. In a sense this is a Clojure to JS compiler.
Closure
Library developed by Google which performs optimizations on JS. It is a JS to JS optimizer. ClojureScript uses this to clean up the JS code that it generates.
isIntersecting
True if intersecting (visible in viewport), false if not.
target
The DOM element whose intersection with the root changed.

5 Footnotes

1. As of October 2025, there is no mention of Clojure in https://nixos.org/manual/nixpkgs/stable/#chap-language-support (source code). ↑ ¶ (Cell 21)

Page metrics

Tangled files (10)

  1. deps.edn
  2. figwheel-main.edn
  3. lilac-prod.cljs.edn
  4. lilac.cljs.edn
  5. src/fe/lilac/code_block_controls.cljs
  6. src/fe/lilac/core.cljs
  7. src/fe/lilac/focus.cljs
  8. src/fe/lilac/lazy_scroll.cljs
  9. src/fe/lilac/popstate_history.cljs
  10. src/fe/lilac/toc_active_entries.cljs

Named cells (40)

  1. Footnote (fn:nix-clojure)
  2. cfg:deps.edn
  3. cfg:figwheel-main.edn
  4. cfg:lilac-fe-build
  5. cfg:lilac-fe-build-prod
  6. f:activate
  7. f:activate-element
  8. f:calculate-initial-state
  9. f:deactivate-element
  10. f:deactivate-other-elements
  11. f:ensure-block-state
  12. f:get-breadcrumbs-height
  13. f:get-heading-ancestry
  14. f:handle-click!
  15. f:href->id
  16. f:maybe-scroll
  17. f:maybe-scroll-by-hash
  18. f:maybe-scroll-toc
  19. f:render-block!
  20. f:rounding-styles
  21. f:set-enum-class!
  22. f:state
  23. f:toggle-class!
  24. f:update-toc-highlights
  25. f:update-visible-headings
  26. f:visible?
  27. f:weaken-anchor-links
  28. gitattributes-ignore-compiled-js
  29. make:Build prod JS
  30. make:Start frontend development environment (CLJS REPL)
  31. nix:tools-fe
  32. ns:lilac.code-block-controls
  33. ns:lilac.core
  34. ns:lilac.focus
  35. ns:lilac.lazy-scroll
  36. ns:lilac.popstate-history
  37. ns:lilac.toc-active-entries
  38. sh:gen-prod-cljs
  39. sh:invoke-dev-build-watcher
  40. sh:update-injectable