HTML5 serialization for Clojure.
Renders Hiccup style HTML vectors to strings.
Highly optimized runtime serialization without macros. See Performance.
Currently for my personal use. Future breaking changes possible.
{:deps
{dev.onionpancakes/chassis
{:git/url "https://github.com/onionpancakes/chassis"
:git/sha "<GIT SHA>"}}}
(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 the namespace.
(require '[dev.onionpancakes.chassis.core :as c])
Use 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 are logically flattened along 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>"
;; 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>"
A collection of attribute values are concatenated as a spaced string.
(c/html [:div {:class ["foo" "bar"]}])
;; "<div class=\"foo bar\"></div>"
(c/html [:div {:class #{:foo :bar}}])
;; "<div class=\"bar foo\"></div>"
A map of attribute values are concatenated as a style string.
(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
by using the 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"]))
By default, text and attribute values are escaped.
(c/html [:div "& < >"])
;; "<div>& < ></div>"
(c/html [:div {:foo "& < > \" '"}])
;; "<div foo=\"& < > " '\"></div>"
Escapes can be disabled locally by wrapping string values with raw
.
(c/html [:div (c/raw "<p>foo</p>")])
;; "<div><p>foo</p></div>"
Escapes can be disabled globally by altering vars. Change escape-text-fragment
and escape-attribute-value-fragment
to
identity
function to allow fragment values to pass through unescaped.
(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>"
For performance, java.lang.Number
and java.util.UUID
bypass the default escapement.
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. A vector can set its metadata ::c/content
key to true to avoid being interpreted as an element, even if it begins with a keyword.
;; Not elements
(c/html [0 1 2]) ; => "123"
(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 resolve-alias
multimethod. They must begin with namespaced keywords.
Define an alias element by extending the resolve-alias
multimethod with a namespaced keyword and a function implementation receiving 4 arguments: metadata map, tag keyword, attributes map, and content vector.
Since namespaced keywords are not interpreted as attributes, they can be used as arguments for alias elements.
Attribute map received (3rd arg) contains the merged attributes from the alias element, including id
and class
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.
Content subvector received (4th arg) contains the content of the alias element. It has metadata {::c/content true}
to avoid being interpreted as an element.
The metadata and tag (1st and 2nd arg) are not needed for normal use case but is provided for advanced tinkering.
;; 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])
;; #'user/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 token-serializer
and html-serializer
to access individual tokens and fragment instances. The underlying type, TokenSerializer
, 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 doctype-html5
. It's just a RawString wrapping <!DOCTYPE html>
. Because it's 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 nbsp
constant.
(c/html [:div "foo" c/nbsp "bar"])
;; "<div>foo bar</div>"
At this time, benchmarks shows Chassis to be ~50% to +100% faster when compared to other Clojure HTML templating libraries. See bench results in the resource folder.
However, the dev benchmark example is contrived and benchmarking with real world data is recommended.
$ clj -M:dev
Clojure 1.11.1
user=> (quick-bench (chassis-page data-mid))
Evaluation count : 2040 in 6 samples of 340 calls.
Execution time mean : 296.440399 µs
Execution time std-deviation : 18.138611 µs
Execution time lower quantile : 280.674056 µs ( 2.5%)
Execution time upper quantile : 319.907138 µs (97.5%)
Overhead used : 8.824566 ns
nil
user=> (quick-bench (hiccup-page data-mid))
Evaluation count : 1104 in 6 samples of 184 calls.
Execution time mean : 594.344971 µs
Execution time std-deviation : 37.178706 µs
Execution time lower quantile : 562.081951 µs ( 2.5%)
Execution time upper quantile : 636.998749 µs (97.5%)
Overhead used : 8.824566 ns
nil
Element vector allocation accounts for less than 0.1% of the runtime cost.
user=> (quick-bench (page data-mid))
Evaluation count : 3926280 in 6 samples of 654380 calls.
Execution time mean : 166.441170 ns
Execution time std-deviation : 40.458902 ns
Execution time lower quantile : 131.334184 ns ( 2.5%)
Execution time upper quantile : 227.189879 ns (97.5%)
Overhead used : 8.824566 ns
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.