Frontend
1 Introduction
We write Clojure instead of JavaScript (JS) to add some interactivity to the woven HTML documents. Here are some definitions:
- 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.
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:
The "official" tooling. This is what was described above.
Figwheel. This is an older library that pioneered hot code reloading of CLJS (as JS) during development.
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.
pkgs.clojureAs this is a Clojure project, we have to declare dependencies using deps.edn, where we declare the dependency on Figwheel.
{: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.mainwork 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 classpath2.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.
We can use the above like this:
clojure -M -m figwheel.main --build lilac --replThis 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.
dev-cljs:
sh:invoke-dev-build-watcher
.PHONY: cljs2.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.
{: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:
clojure -M -m figwheel.main --build-once lilac-prodOr if you're using the Makefile, you can just do make prod-cljs, which will invoke the following rule:
prod-cljs:
sh:gen-prod-cljs
sh:update-injectable
.PHONY: prod-cljsIf 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
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.
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.
cp -f js/lilac.js js/lilac.js.injectmeWe 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.
js/lilac.js.injectme binary2.3 Figwheel configuration
The cfg:figwheel-main.edn is a place to store common configuration options across all build files.
{: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-cljsto start up Figwheel to make it handle the CSS and JS reloads. Finally you can run
./tangle-loop.shin 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
(: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 --repl3.1 Anchor link history
By default, browsers do not consider links that point to locations in the current page (aka anchor links) as navigable history items. So after clicking on a bunch of anchor links, if you press the back button on your mouse you'd only see the URL bar change, but the window won't change locations.
In ns:lilac.popstate-history we make the browser treat anchor links as worthy of being included in the history, so that the back button works to move around (to previously visited locations) in the current page. It works by creating an event listener which fires for every popstate event, and is the equivalent of the back button.
(ns lilac.popstate-history
(:require [clojure.string :as str]
[lilac.focus :refer [activate]]))
(defn handle-popstate []
(let [current-hash
(if (str/blank? js/window.location.hash)
"#top"
js/window.location.hash)
id (.substring current-hash 1)]
(when-let [target-el (js/document.getElementById id)]
(activate target-el))))
(defn init []
(.addEventListener js/window "popstate" handle-popstate))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
(: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 #{}))))))))) ; 12The init function 3 has four parts:
Read in the heading ancestry JSON (as a native Clojure map), via f:get-heading-ancestry at 4.
Assign f:update-toc-highlights as a callback to fire whenever the visible-headings atom changes at 5.
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.
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.
(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).
(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:
- target
- 🔗 The DOM element whose intersection with the root changed.
- isIntersecting
- 🔗 True if intersecting (visible in viewport), false if not.
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.
| Intent | ClojureScript syntax | JS 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).
(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.
(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
(: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)) ; 22The 19 is the entrypoint. It is concerned with three types of events:
clicking of links 20
changing of URL history (pressing the forward/back button of the mouse without clicking on a link) 21
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.
(defn- weaken-anchor-links []
(let [links (-> js/document (.querySelectorAll "a[href^=\"#\"]"))]
(doseq [link links]
(.addEventListener link "click"
(fn [event]
(let [href (.getAttribute link "href")
id (href->id href)
old-href (.-hash js/location)]
(when-let [target-element (-> js/document (.getElementById id))]
(.preventDefault event) ; 23
(if (= href old-href)
(if (.contains (.-classList target-element) "active") ; 24
(deactivate-element target-element)
(activate-element target-element))
(do
(.pushState (.-history js/window) nil nil href) ; 25
(maybe-scroll-by-hash nil))))))))))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:
activates it 27 by calling into f:activate from ns:lilac.focus, and
calls f:maybe-scroll 28.
f:href->id 26 just retrieves the anchor.
(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).
(defn- maybe-scroll [elt]
(when-not (visible? elt)
(.scrollIntoView elt)))The visibility check is done inside f: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.
(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)
f:deactivate-element
f:deactivate-other-elements
f:activate-element
f:activateThe public function is f:activate, which just calls f:deactivate-other-elements and f:activate-element.
(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).
(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.
Deactivating an element just means removing the "active" class from its list of classes.
(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:
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.
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)
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!)) ; 333.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: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.
(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.
(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.
;; 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.
(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.
(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 #{...}.
(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.
(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)
Named cells (40)
- Footnote (fn:nix-clojure)
- cfg:deps.edn
- cfg:figwheel-main.edn
- cfg:lilac-fe-build
- cfg:lilac-fe-build-prod
- f:activate
- f:activate-element
- f:calculate-initial-state
- f:deactivate-element
- f:deactivate-other-elements
- f:ensure-block-state
- f:get-breadcrumbs-height
- f:get-heading-ancestry
- f:handle-click!
- f:href->id
- f:maybe-scroll
- f:maybe-scroll-by-hash
- f:maybe-scroll-toc
- f:render-block!
- f:rounding-styles
- f:set-enum-class!
- f:state
- f:toggle-class!
- f:update-toc-highlights
- f:update-visible-headings
- f:visible?
- f:weaken-anchor-links
- gitattributes-ignore-compiled-js
- make:Build prod JS
- make:Start frontend development environment (CLJS REPL)
- nix:tools-fe
- ns:lilac.code-block-controls
- ns:lilac.core
- ns:lilac.focus
- ns:lilac.lazy-scroll
- ns:lilac.popstate-history
- ns:lilac.toc-active-entries
- sh:gen-prod-cljs
- sh:invoke-dev-build-watcher
- sh:update-injectable