Typesetting S-Expressions with CSS
Problem statement CSS is a very powerful library for styling and laying out page elements, but almost no extant HTML-based syntax highlighting tools for Clojure take full advantage of it. Instead, most people opt for code-based syntax highlighters (or no syntax highlighting at all).
Assumptions
- The structural and sequential nature of Lisp expressions means they more readily lend themselves to CSS selectors than the code of other programming languages.
- Server side rendering with rewrite-clj means that all of the relevant syntax and semantics of Clojure code can be preserved via appropriate HTML classes.
- Whitespace conveys no information in Clojure, so good formatting can be achieved without requiring 1:1 preservation of whitespace in the original source code.
- Relaxing the requirement to exactly preserve whitespace will allow for more responsive and flexible display of Clojure source code based on viewport size.
Goals
I believe that Clojure source code deserves beautiful syntax highlighting that leverages the full power of modern CSS.
- Text should be directly copyable from a HTML page to a source file or buffer.
- CSS rules should be compatible with responsive layouts.
Prior art
Several bloggers in the Haskell community do a very good job of presenting source code as a coherent part of an overall document. Chris Done and Oskar Wickström both have excellent blogs.
Wickström's blending of diagrams, code, and prose to illustrate technical concepts is one of the points of inspiration for Fabricate, the static website generator I wrote and used to generate these examples.
Nikita Prokopov has proposed a simple, future-proof method of indenting Clojure source code that I believe can be adapted to CSS.
CSS can do better than "add two spaces here", a constraint that exists due to the limitations of plaintext. However, it is a useful starting point when thinking about how to lay out nested Clojure forms.
The multimethod-based generation of Hiccup elements from rewrite-clj nodes was adapted and extended from Michiel Borkent's quickblog; he helpfully describes the approach in Writing a Clojure highlighter from scratch. I believe that only tools that can leverage the full structure of Clojure's source code are appropriate to the task of generating effective documentation of Clojure code.
This page will have notes on several experiments in using CSS layout features for the display of Clojure expressions. Readers are strongly encouraged to use their web browser's inspector mode to see the hierarchy of elements and CSS rules used to generate these layouts.
aligning map elements to CSS grid
This page already uses CSS grid for its layout, so one starting point would be to just align the elements representing the map contents to the main grid.{:a/key "value", :repeatCount "indefinite", :some/key "some other value", :values "0;2000", :dur "360s", :additive "sum", :attributeName "stroke-dashoffset"}
Problems with this approach:
- whitespace and commas are treated as grid elements unless there's a way to get the grid to ignore them; the
nth-child
selector will not skip elements set todisplay: none;
- lack of support for CSS subgrid means that nested maps will be difficult to align
laying map elements out with flexbox: basic example
{:a/key "value", :repeatCount "indefinite", :some/key "some other value", :values "0;2000", :dur "360s", :additive "sum", :attributeName "stroke-dashoffset"}
It appears to be easier to fully hide insignificant elements with Flexbox than grid, but it may be harder to align keys to a left baseline. Inserting line breaks for each key may also be more difficult than with grid.
A manually constructed example of grid layout for a map
This example uses nested flexboxes create an aligned container for the map contents and manually inserted zero-height elements before each new map key to force line breaks within the flexbox at the appropriate places.{:a/key "value", :attributeName "stroke-dashoffset", :values "0;2000", :some/key "some other value", :additive "sum", :dur "360s", :repeatCount "indefinite"}Problems: the flex-basis 100% elements prevents flex-shrink from sizing the map down to an appropriate size. It similarly will also cause problems with flex-wrap: wrap on the top-level flexbox unless the nested flexbox for the map elements has a manually constrained size (this example does).
But overall this has the right look. The keys are aligned, the text is still wholly copyable into a plaintext buffer, and it flows nicely with the typographic baseline established by the main text.
The challenge is generating this type of layout programatically from rewrite-clj nodes, and doing so in a way that doesn't generate absurd layouts when displaying potentially nested maps.
Another example with manually <span>ed key/value pairs
Because the elements are being generated by rewrite-clj, one possible option would be to process them in a way that groups together key/value pairs. Here is a manual example that shows taking advantage of Flexbox layout to distribute these pairs in the space provided by the map contents element.
{:a/key "value", :attributeName "stroke-dashoffset", :values "0;2000", :some/key "some other value", :additive "sum", :dur "360s", :repeatCount "indefinite"}
This also looks appealing, ensuring a decent visual separation between the map elements while preventing keys and values from being split across a linebreak, which helps with visual comprehension of the map. Given that it does not require inserting elements or pseudo-elements that emulate a line break, it may be both much more robust and easier to implement than any solution that involves the insertion of invisible elements to manipulate flexbox rules.
laying map elements out with flexbox: controlling layout with flex-basis
{:a/key "value", :repeatCount "indefinite", :nested/map {:b 3, :a 2}, :nested/map.2 {:longer.key/two "another longer value to take up more flexbox space", "a longer key" "with a correspondingly longer value"}, :some/key "some other value", :values "0;2000", :dur "360s", :additive "sum", :attributeName "stroke-dashoffset"}
At first glance, this is pretty close to optimal. Setting flex-basis to 40% or so generates a layout with aligned columns. However, the layout gets trickier with nested maps. Flex-grow and flex-shrink might be the right way to do it; I just need to make sure the rules can apply at an arbitrary level of nesting.
Using fabricate
's multimethods to separate sequence contents from enclosing braces
In order to dynamically rebind a multimethod, the body of it should be bound to a function assigned to a dynamic var.
(defn ^:dynamic node->map [node]
(apply #'page/span "map" (#'page/span "open-brace" "{")
(conj (mapv #'page/node->hiccup (:children node))
(#'page/span "close-brace" "}"))))
(defmethod page/node->hiccup :map [node]
(node->map node))
(defn node->map-contents [node]
(apply #'page/span "map map-flex-contents"
(#'page/span "open-brace" "{")
[(reduce conj (#'page/span "map-contents")
(mapv page/node->hiccup (:children node)))
(#'page/span "close-brace" "}")]))
Using a new node->hiccup conversion to set map contents apart from opening and closing braces
Separating enclosing elements from the contents of a collection is probably a worthwhile idea regardless of the collection type. Here's how it might look in the context of a nested map.
{:a/key "value", :repeatCount "indefinite", :nested/map {:b 3, :a 2}, :nested/map.2 {:longer.key/two "another longer value to take up more flexbox space", "a longer key" "with a correspondingly longer value"}, :some/key "some other value", :values "0;2000", :dur "360s", :additive "sum", :attributeName "stroke-dashoffset"}
This is starting to look more like a proper layout. The display of whitespace elements can be controlled within the context of the flexbox by setting their flex-basis
property to 0 and adding a negative left margin that's exactly equal in size to the column gap of the flex parent (times -1) - in this case max(4px, 0.75em)
would be offset by min(4px, -0.75em)
. Importantly, avoiding using display: none
(as the previous examples did) on the whitespace means that whitespace will correctly copy out of a code element laid out in this manner.
However, it feels like the small nested map should be able to shrink down to a smaller size and fit on a single line, while the larger nested map should expand to take up the full line underneath its key. It's not yet clear how they can be made to do that.
A function definition
A function definition presents more complex layout challenges than arranging key/value pairs in a map. It may require more preprocessing on the server side to separate into the basic semantic elments needed for effective display.
Basic example
(defn de-jong "Returns A Peter de Jong attractor function.\n\nUse with clojure.core/iterate." {:added "2023-06-11"} [ ] (fn [[ ]] [(- (Math/sin (* a y)) (Math/cos (* b x))) (- (Math/sin (* c x)) (Math/cos (* d y)))]))
(defn ([x] x) ([x y] (if (> x y) x y)) ([x y & more] (reduce mymax (mymax x y) more)))
Manual example
(defn de-jong "Returns A Peter de Jong attractor function.
Use with clojure.core/iterate." {:added "2023-06-11" :description A reader in Holland, Peter de Jong of Leiden, has already suggested some other iteration formulas that produce bizarre shapes and images. } [ ] (fn [[ ]] [(- (Math/sin (* a y)) (Math/cos (* b x))) (- (Math/sin (* c x)) (Math/cos (* d y)))]))