Fast HTML5 serialization for Clojure.
Renders Hiccup style HTML vectors to strings.
Highly optimized runtime serialization without macros. Even faster serialization when combined with compiling macros.
- See Compiling Elements.
- See Performance.
Production released.
Add one of these deployments to deps.edn
.
dev.onionpancakes/chassis {:git/url "https://github.com/onionpancakes/chassis"
:git/tag "v1.0.365" :git/sha "3e98fdc"}
dev.onionpancakes/chassis {:mvn/version "1.0.365"}
(require '[dev.onionpancakes.chassis.core :as c])
(defn my-post
[post]
[:div {:id (:id post)}
[:h2.title (:title post)]
[:p.content (:content post)]])
(defn my-blog
[data]
[c/doctype-html5 ; Raw string for <!DOCTYPE html>
[:html
[:head
[:link {:href "/css/styles.css" :rel "stylesheet"}]
[:title "My Blog"]]
[:body
[:h1 "My Blog"]
(for [p (:posts data)]
(my-post p))]]])
(let [data {:posts [{:id "1" :title "foo" :content "bar"}]}]
(c/html (my-blog data)))
;; "<!DOCTYPE html><html><head><link href=\"/css/styles.css\" rel=\"stylesheet\"><title>My Blog</title></head><body><h1>My Blog</h1><div id=\"1\"><h2 class=\"title\">foo</h2><p class=\"content\">bar</p></div></body></html>"
(require '[dev.onionpancakes.chassis.core :as c])
(require '[dev.onionpancakes.chassis.compiler :as cc])
(defn my-post-compiled
[post]
(cc/compile
[:div {:id (:id post)}
[:h2.title (:title post)]
[:p.content (:content post)]]))
(defn my-blog-compiled
[data]
(cc/compile
[c/doctype-html5 ; Raw string for <!DOCTYPE html>
[:html
[:head
[:link {:href "/css/styles.css" :rel "stylesheet"}]
[:title "My Blog"]]
[:body
[:h1 "My Blog"]
(for [p (:posts data)]
(my-post-compiled p))]]]))
(let [data {:posts [{:id "1" :title "foo" :content "bar"}]}]
(c/html (my-blog-compiled data)))
;; "<!DOCTYPE html><html><head><link href=\"/css/styles.css\" rel=\"stylesheet\"><title>My Blog</title></head><body><h1>My Blog</h1><div id=\"1\"><h2 class=\"title\">foo</h2><p class=\"content\">bar</p></div></body></html>"
Require the namespace.
(require '[dev.onionpancakes.chassis.core :as c])
Use c/html
function to generate HTML strings from vectors.
Vectors with global keywords in the head position are treated as normal HTML elements. The keyword's name is used as the element's tag name.
(c/html [:div "foo"])
;; "<div>foo</div>"
Maps in the second position are treated as attributes. Use global keywords to name attribute keys.
(c/html [:div {:id "my-id"} "foo"])
;; "<div id=\"my-id\">foo</div>"
;; Strings also accepted, but discouraged.
;; Use when keywords cannot encode the desired attribute name.
(c/html [:div {"id" "my-id"} "foo"])
;; "<div id=\"my-id\">foo</div>"
The rest of the vector is treated as the element's content. They may be of any type including other elements. Sequences, eductions, and non-element vectors are logically flattened with the rest of the content.
(c/html [:div {:id "my-id"}
"foo"
(for [i (range 3)] i)
"bar"])
;; "<div id=\"my-id\">foo012bar</div>"
Like Hiccup, id and class attributes can be specified along with the tag name using css style #
and .
syntax.
(c/html [:div#my-id.my-class "foo"])
;; "<div id=\"my-id\" class=\"my-class\">foo</div>"
;; Multiple '.' classes concatenates
(c/html [:div.my-class-1.my-class-2 "foo"])
;; "<div class=\"my-class-1 my-class-2\">foo</div>"
;; '.' classes concatenates with :class keyword
(c/html [:div.my-class-1 {:class "my-class-2"} "foo"])
;; "<div class=\"my-class-1 my-class-2\">foo</div>"
;; First '#' determines the id.
;; Extra '#' are uninterpreted.
(c/html [:div## "foo"])
;; "<div id=\"#\">foo</div>"
(c/html [:div#my-id.my-class-1#not-id "foo"])
;; "<div id=\"my-id\" class=\"my-class-1#not-id\">foo</div>"
However there are differences from Hiccup.
;; '#' id takes precedence over :id keyword
(c/html [:div#my-id {:id "not-my-id"} "foo"])
;; "<div id=\"my-id\">foo</div>"
;; '#' id can be place anywhere
(c/html [:div.my-class-1#my-id "foo"])
;; "<div id=\"my-id\" class=\"my-class-1\">foo</div>"
;; '#' id can be place in-between, but don't do this.
;; It will be slightly slower.
(c/html [:div.my-class-1#my-id.my-class-2 "foo"])
;; "<div id=\"my-id\" class=\"my-class-1 my-class-2\">foo</div>"
Use true
/false
to toggle boolean attributes.
(c/html [:button {:disabled true} "Submit"])
;; "<button disabled>Submit</button>"
(c/html [:button {:disabled false} "Submit"])
;; "<button>Submit</button>"
Collections of attribute values are concatenated as spaced strings.
(c/html [:div {:class ["foo" "bar"]}])
;; "<div class=\"foo bar\"></div>"
(c/html [:div {:class #{:foo :bar}}])
;; "<div class=\"bar foo\"></div>"
Maps of attribute values are concatenated as style strings.
(c/html [:div {:style {:color :red
:border "1px solid black"}}])
;; "<div style=\"color: red; border: 1px solid black;\"></div>"
Attribute collections and maps arbitrarily nest.
(c/html [:div {:style {:color :red
:border [:1px :solid :black]}}])
;; "<div style=\"color: red; border: 1px solid black;\"></div>"
Avoid intermediate allocation by writing directly to java.lang.Appendable
using the c/write-html
function.
However, java.lang.StringBuilder
is highly optimized and it may be faster to write to it (and then write the string out) than to write to the Appendable directly. Performance testing is advised.
(let [out (get-appendable-from-somewhere)]
(c/write-html out [:div "foo"]))
Text and attribute values are escaped by default.
(c/html [:div "& < >"])
;; "<div>& < ></div>"
(c/html [:div {:foo "& < > \" '"}])
;; "<div foo=\"& < > " '\"></div>"
Escaping can be disabled locally by wrapping string values with c/raw
.
(c/html [:div (c/raw "<p>foo</p>")])
;; "<div><p>foo</p></div>"
Escaping can be disabled globally by altering vars. Change c/escape-text-fragment
and c/escape-attribute-value-fragment
to
identity
function to allow fragment values to pass through unescaped.
Then use c/escape-text
and c/escape-attribute-value
to escape locally.
(alter-var-root #'c/escape-text-fragment (constantly identity))
(alter-var-root #'c/escape-attribute-value-fragment (constantly identity))
(c/html [:div "<p>foo</p>"])
;; "<div><p>foo</p></div>"
(c/html [:div (c/escape-text "foo & bar")])
;; "<div>foo & bar</div>"
For performance, java.lang.Number
and java.util.UUID
are not escaped by default.
Element tags and attribute keys are not escaped. Be careful when placing dangerous text in these positions.
;; uhoh
(c/html [:<> "This is bad!"])
;; "<<>>This is bad!</<>>"
(c/html [:div {:<> "This is bad!"}])
;; "<div <>=\"This is bad!\"></div>"
Only vectors beginning with keywords are interpreted as elements. Vectors can set their metadata {::c/content true}
to avoid being interpreted as elements, even if they begin with keywords.
;; Not elements
(c/html [0 1 2]) ; => "012"
(c/html ["foo" "bar"]) ; => "foobar"
(c/html ^::c/content [:foo :bar]) ; => "foobar"
;; Use this to generate fragments of elements
(c/html [[:div "foo"]
[:div "bar"]]) ; "<div>foo</div><div>bar</div>"
Only global keywords and strings are interpreted as attribute keys. Everything else is ignored.
(c/html [:div {:foo/bar "not here!"}])
;; "<div></div>"
Alias elements are user defined elements. They resolve to other elements through the c/resolve-alias
multimethod. They must begin with namespaced keywords.
Define alias elements by extending c/resolve-alias
multimethod on a namespaced keyword. It accepts the following 3 arguments of types:
- Tag keyword. Used for the dispatch.
- Attributes map or nil if attrs is absent.
- Content vector, possibly empty if no content.
When implementing aliases, consider the following points:
-
Because namespaced keywords are ignored as attributes, they can be used as arguments for alias elements.
-
The attributes map will contain
#id
and.class
merged from the element tag. By placing the alias element's attribute map as the attribute map of a resolved element, the attributes transfers seamlessly between the two. -
The content vector has metadata
{::c/content true}
to avoid being interpreted as an element.
;; Capitalized name optional, just to make it distinctive.
(defmethod c/resolve-alias ::Layout
[_ {:layout/keys [title] :as attrs} content]
[:div.layout attrs ; Merge attributes
[:h1 title]
[:main content]
[:footer "Some footer message."]])
(c/html [::Layout#blog.dark {:layout/title "My title!"}
[:p "My content!"]])
;; "<div id=\"blog\" class=\"layout dark\"><h1>My title!</h1><main><p>My content!</p></main><footer>Some footer message.</footer></div>"
Instances of clojure.lang.IDeref
and clojure.lang.Fn
are automatically dereferenced at serialization. Functions are invoked on their zero argument arity.
Whether or not if this is a good idea is left to the user.
(defn current-year []
(.getValue (java.time.Year/now)))
(c/html [:footer "My Company Inc " current-year])
;; "<footer>My Company Inc 2024</footer>"
(def delayed-thing
(delay "delayed"))
(c/html [:div {:foo delayed-thing}])
;; "<div foo=\"delayed\"></div>"
They can even deference into other elements.
(defn get-children []
[:p "Child element"])
(c/html [:div.parent get-children])
;; "<div class=\"parent\"><p>Child element</p></div>"
Use c/token-serializer
and c/html-serializer
to access individual tokens and fragment instances. The underlying type implements clojure.lang.IReduceInit
and is intended to be used in a reduce.
(->> (c/token-serializer [:div "foo"])
(eduction (map type))
(vec))
;; [dev.onionpancakes.chassis.core.OpeningTag
;; java.lang.String
;; dev.onionpancakes.chassis.core.ClosingTag]
(->> (c/html-serializer [:div "foo"])
(vec))
;; ["<div>" "foo" "</div>"]
Use c/doctype-html5
, a RawString
wrapping <!DOCTYPE html>
. Because it is a RawString
, it is safe to wrap in a vector to concatenate with the rest of the HTML document.
(c/html [c/doctype-html5 [:html "..."]])
;; "<!DOCTYPE html><html>...</html>"
Use the c/nbsp
constant.
(c/html [:div "foo" c/nbsp "bar"])
;; "<div>foo bar</div>"
Require the namespace.
(require '[dev.onionpancakes.chassis.compiler :as cc])
Slap a cc/compile
wherever speed is needed! Then call c/html
like normal to generate HTML.
;; In defs
(def global-element
(cc/compile [:div "foo"]))
;; In defns
(defn fn-element
[arg]
(cc/compile [:div "foo" arg "bar"]))
;; In aliases
(defmethod c/resolve-alias ::MyElement
[_ attrs content]
(cc/compile
[:div
[:p attrs content]]))
;; In fn args
(fn-element (cc/compile [:p "some content"]))
;; Then call c/html like normal to generate HTML.
(c/html (fn-element 123))
;; "<div>foo123bar</div>"
Chassis provides compiling macros cc/compile
and cc/compile*
. They take one argument, the root HTML tree, and they return compiled versions of the HTML tree. Use them to compile elements and pass their results to c/html
.
(defn my-element []
(cc/compile
[:div [:p "foobar"]]))
(c/html (my-element))
;; "<div><p>foobar</p></div>"
Compiling flattens and compacts the HTML tree, making subsequent calls to c/html
much faster.
(macroexpand-1 '(cc/compile [:div [:p "foobar"]]))
;; Results in:
#object[dev.onionpancakes.chassis.core.RawString 0x11c2d9a2 "<div><p>foobar</p></div>"]
(let [body (identity "some-dynamic-content")]
(pprint
(macroexpand-1
'(cc/compile
[:div.deeply
[:div.nested
[:div.thing
[:p "before" body "after"]]]]))))
;; Results in:
[#object[dev.onionpancakes.chassis.core.RawString 0x66fd28ce "<div class=\"deeply\"><div class=\"nested\"><div class=\"thing\"><p>before"]
body
#object[dev.onionpancakes.chassis.core.RawString 0xe9c5af6 "after</p></div></div></div>"]]
Use cc/compile
for most purposes. For performance, the returned value may or may not be a vector. This is so that compiling small fragments of fully compacted HTML (like <hr>
) is as efficient as possible when iterated over by c/html
.
;; <hr> is not wrapped as a 1-sized vector
(cc/compile [:hr])
;; #object[dev.onionpancakes.chassis.core.RawString 0x6ba58490 "<hr>"]
;; The end result is the same either way,
;; but the runtime serialization is faster this way.
(->> (range 10)
(interpose (cc/compile [:hr]))
(c/html))
;; "0<hr>1<hr>2<hr>3<hr>4<hr>5<hr>6<hr>7<hr>8<hr>9"
Use cc/compile*
to ensure the return value is a vector. Otherwise, it is the same as cc/compile
.
;; <hr> is wrapped as a 1-sized vector
(cc/compile* [:hr])
;; [#object[dev.onionpancakes.chassis.core.RawString 0x24f1caeb "<hr>"]]
A small but subtle difference between cc/compile
and c/html
is that cc/compile
assumes elements are literal vectors with literal keyword tags. Vectors without literal tags, after var resolution, are assumed to be content.
;; Basically don't do this.
(let [footag :div]
(c/html (cc/compile [footag "It's foobarred."])))
;; "divIt's foobarred."
;; Works at runtime.
(let [footag :div]
(c/html [footag "It's foobarred."]))
;; "<div>It's foobarred.</div>"
Ambiguous objects in the second position forces the compiler to emit checks which examine the potential attributes map at runtime.
(let [data {:body "foo"}]
(pprint (clojure.walk/macroexpand-all
;; Compiler can't see what (:body data) returns.
'(cc/compile [:div (:body data)]))))
;; Results in:
[(let*
[attrs13712 (:body data)]
(if ;; Check if 2nd item is attrs map at runtime.
(dev.onionpancakes.chassis.core/attrs? attrs13712)
(dev.onionpancakes.chassis.core/->OpeningTag
nil
:div
nil
nil
attrs13712)
[#object[dev.onionpancakes.chassis.core.RawString 0x1cc8f6fb "<div>"]
attrs13712]))
#object[dev.onionpancakes.chassis.core.RawString 0x6753cbe6 "</div>"]]
Use nil
in second position to force compile the element without attributes.
(let [data {:body "foo"}]
(pprint (macroexpand-1
'(cc/compile [:div nil (:body data)]))))
;; Results in:
[#object[dev.onionpancakes.chassis.core.RawString 0x6e42ae2e "<div>"]
(:body data)
#object[dev.onionpancakes.chassis.core.RawString 0x588c9f7d "</div>"]]
Type hint the second position with either java.util.Map
or clojure.lang.IPersistentMap
to force compile elements with attributes.
(let [data {:attrs {:foo "bar"}
:body "foo"}]
(pprint (macroexpand-1
'(cc/compile [:div ^java.util.Map (:attrs data) (:body data)]))))
;; Results in:
[(dev.onionpancakes.chassis.core/->OpeningTag
nil
:div
nil
nil
(:attrs data))
(:body data)
#object[dev.onionpancakes.chassis.core.RawString 0x6314faa "</div>"]]
Type hinting the argument or bindings also works.
- Note: It doesn't show up correctly in a
macroexpand
, but it does works normally. This is becausecc/compile
examines the type hints from macro implied arg&env
, andmacroexpand
for some reason doesn't capture&env
.
;; Should work!
(defmethod c/resolve-alias ::CompileWithAttrs
[_ ^java.util.Map attrs content]
(cc/compile [:div attrs content]))
(let [^java.util.Map attrs {:foo "bar"}]
(cc/compile [:div attrs "foobar"]))
Certain functions in clojure.core
which returns maps are considered as attributes when called in the second position. Type hinting these invocations is not necessary. They include:
array-map
hash-map
sorted-map
sorted-map-by
assoc
assoc-in
merge
select-keys
update-keys
update-vals
;; Useful in aliases when merging attrs.
(defmethod c/resolve-alias ::AliasWithAttrsMerge
[_ attrs content]
(cc/compile
[:div (merge {:foo "bar"} attrs)
content]))
Call (cc/set-warn-on-ambig-attrs!)
to turn on warnings when compiling elements with ambiguous attributes. It will add a tap which prints out warning messages to *err*
whenever ambiguous attributes are compiled.
Call (cc/unset-warn-on-ambig-attrs!)
to disable.
Functions calls, and generally any list values, block compilation traversal. Call cc/compile
again to compile forms within.
(defn comp-blocked
[]
[:p "blocked"])
(cc/compile [:div "foo" (comp-blocked) "bar"])
;; Results in:
[#object[dev.onionpancakes.chassis.core.RawString 0x67574bda "<div>foo"]
[:p "blocked"]
#object[dev.onionpancakes.chassis.core.RawString 0x565edf06 "bar</div>"]]
Alias elements are implemented as c/resolve-alias
(via c/resolve-alias-with-meta
) function calls. As a result, they also block compilation. However, the arguments passed to c/resolve-alias
will be compiled.
(defmethod c/resolve-alias ::CompileMyAlias
[_ attrs content]
[:div attrs content])
(pprint
(clojure.walk/macroexpand-all
'(cc/compile
[::CompileMyAlias {:foo "bar"}
[:p "content 1"]
[:p "content 2"]])))
;; Results in:
(dev.onionpancakes.chassis.core/resolve-alias-with-meta
nil
:user/CompileMyAlias
{:foo "bar"}
[#object[dev.onionpancakes.chassis.core.RawString 0x34e3a7d6 "<p>content 1</p><p>content 2</p>"]])
Macros are expanded during compilation. Like function calls, those which expand into lists block compilation.
(pprint
(cc/compile
[:ol
(for [i (range 4)]
[:li i])]))
;; Results in:
[[#object[dev.onionpancakes.chassis.core.OpeningTag 0x6e462cc4 "<ol>"]
([:li 0] [:li 1] [:li 2] [:li 3])]
#object[dev.onionpancakes.chassis.core.RawString 0x27b55932 "</ol>"]]
;; Manually call compile in the inner form to reach inside.
(pprint
(cc/compile
[:ol
(for [i (range 4)]
(cc/compile [:li i]))]))
Macros which expand into non-lists can participate in compilation. Therefore, it is possible to use macros to abstract element components in a compile friendly way.
Whether or not if this is a good idea is left to the user.
(defmacro NonBlockingElement
[content]
[:p nil content])
(cc/compile [:div (NonBlockingElement "not-blocked")])
;; Results in:
#object[dev.onionpancakes.chassis.core.RawString 0x31b2d0a8 "<div><p>not-blocked</p></div>"]
Symbols referring to vars containing constant values are resolved to those values during compilation traversal, thereby allowing those constant values to participate in compilation. Constant types include String
, Long
, IPersistentCollection
of constants, and RawString
such as c/doctype-html5
and c/nbsp
. Use cc/constant?
to check if values are constants.
;; Fully compacted!
;; Even with a symbol splitting content in the middle.
(cc/compile [:div "foo" c/nbsp "bar"])
;; Results in:
#object[dev.onionpancakes.chassis.core.RawString 0x7fb21735 "<div>foo bar</div>"]
Chassis provides two analogous compile functions, cc/compile-node
and cc/compile-node*
, for compiling HTML tree at runtime. They are useful for compiling static HTML pages or components.
Because compiling happens at runtime, lists, function calls, and alias elements are no longer compilation barriers and ambiguous attributes are not possible.
Runtime compilation is similar to generating HTML with c/html
but with key differences:
- The return values are
c/raw
strings, allowing the result to be embedded in other HTML components without the HTML tags being escaped. - Stateful values, such as functions and derefs, are not realized.
(defn current-time []
(java.time.LocalTime/now))
(defmethod c/resolve-alias ::CurrentTime
[_ _ _]
[:p "Current time is: " current-time])
(def static-page
(cc/compile-node
[::CurrentTime]))
;; Results in:
[#object[dev.onionpancakes.chassis.core.RawString 0x7a702aaf "<p>Current time is: "]
;; Notice current-time function is not yet called.
#object[user$current_time 0x584d9dc4 "user$current_time@584d9dc4"]
#object[dev.onionpancakes.chassis.core.RawString 0x1c59c510 "</p>"]]
;; Stateful values realized on call to c/html
(c/html static-page)
;; "<p>Current time is: 13:48:14.228299269</p>"
At this time, benchmarks shows Chassis to be 2x faster (and often more!) when compared to other Clojure HTML templating libraries on equivalent benchmark examples.
See bench results in the resource folder.
$ clj -M:dev
Clojure 1.11.1
;; Chassis
user=> (quick-bench (chassis-page data-mid))
Evaluation count : 2712 in 6 samples of 452 calls.
Execution time mean : 229.730870 µs
Execution time std-deviation : 7.583674 µs
Execution time lower quantile : 221.593639 µs ( 2.5%)
Execution time upper quantile : 237.951723 µs (97.5%)
Overhead used : 8.800684 ns
nil
user=> (quick-bench (chassis-page-compiled data-mid))
Evaluation count : 4722 in 6 samples of 787 calls.
Execution time mean : 131.554387 µs
Execution time std-deviation : 4.400562 µs
Execution time lower quantile : 127.024648 µs ( 2.5%)
Execution time upper quantile : 137.206151 µs (97.5%)
Overhead used : 8.800684 ns
nil
user=> (quick-bench (chassis-page-compiled-unambig data-mid))
Evaluation count : 6186 in 6 samples of 1031 calls.
Execution time mean : 100.309952 µs
Execution time std-deviation : 3.392984 µs
Execution time lower quantile : 98.074419 µs ( 2.5%)
Execution time upper quantile : 105.031335 µs (97.5%)
Overhead used : 8.800684 ns
nil
;; Hiccup
user=> (quick-bench (hiccup-page data-mid))
Evaluation count : 990 in 6 samples of 165 calls.
Execution time mean : 615.536499 µs
Execution time std-deviation : 15.886454 µs
Execution time lower quantile : 599.567903 µs ( 2.5%)
Execution time upper quantile : 637.703394 µs (97.5%)
Overhead used : 8.800684 ns
nil
user=> (quick-bench (hiccup-page-compiled data-mid))
Evaluation count : 1044 in 6 samples of 174 calls.
Execution time mean : 594.160734 µs
Execution time std-deviation : 15.249740 µs
Execution time lower quantile : 576.246477 µs ( 2.5%)
Execution time upper quantile : 611.946104 µs (97.5%)
Overhead used : 8.800684 ns
nil
user=> (quick-bench (hiccup-page-compiled-unambig data-mid))
Evaluation count : 2544 in 6 samples of 424 calls.
Execution time mean : 246.390352 µs
Execution time std-deviation : 6.001164 µs
Execution time lower quantile : 240.872342 µs ( 2.5%)
Execution time upper quantile : 255.422063 µs (97.5%)
Overhead used : 8.800684 ns
nil
;; Selmer
user=> (quick-bench (selmer-page data-mid))
Evaluation count : 1428 in 6 samples of 238 calls.
Execution time mean : 455.954085 µs
Execution time std-deviation : 14.867158 µs
Execution time lower quantile : 443.374807 µs ( 2.5%)
Execution time upper quantile : 478.302764 µs (97.5%)
Overhead used : 8.800684 ns
nil
;; Enlive
user=> (quick-bench (enlive-page-item-html data-mid))
Evaluation count : 282 in 6 samples of 47 calls.
Execution time mean : 2.254892 ms
Execution time std-deviation : 83.779038 µs
Execution time lower quantile : 2.156587 ms ( 2.5%)
Execution time upper quantile : 2.341325 ms (97.5%)
Overhead used : 8.800684 ns
nil
Element vector allocation accounts for a small % of the runtime cost.
user=> (quick-bench (page-doall data-mid))
Evaluation count : 34752 in 6 samples of 5792 calls.
Execution time mean : 18.073864 µs
Execution time std-deviation : 623.107379 ns
Execution time lower quantile : 17.421242 µs ( 2.5%)
Execution time upper quantile : 18.715025 µs (97.5%)
Overhead used : 8.800684 ns
nil
The vast proportion of the runtime cost is the iteration of HTML data structure and fragment writes.
Keywords and Strings are interned objects. Therefore the cost of allocating HTML vectors is mostly the cost of allocation vectors, and allocating vectors is really fast.
Released under the MIT License.