jeluard.hipo

https://github.com/jeluard/hipo.git

git clone 'https://github.com/jeluard/hipo.git'

(ql:quickload :jeluard.hipo)
94

Hipo License Build Status

Usage | Extensibility | Performance | Security

A ClojureScript DOM templating library based on hiccup syntax. Supports live DOM node reconciliation (à la React). hipo aims to be 100% compatible with hiccup syntax.

Clojars Project.

hipo uses Reader Conditionals. Make sure your project depends on Clojure 1.7 and a recent ClojureScript version.

Usage

Creation

hipo.core/create converts an hiccup vector into a DOM node that can be directly inserted in a document.

Note that the hiccup syntax is extended to handle all properties whose name starts with on- as event listener registration. Listeners can be provided as a function or as a map ({:name "my-listener" :fn (fn [] (.log js/console 1))}) in which case they will only be updated if the name is updated.

(ns my-test
  (:require [hipo.core :as hipo]))

(let [el (hipo/create [:div#id.class [:span 1]])]
  (.appendChild js/document.body el)
  ; el is:
  ; <div id="id" class="class">
  ;   <span>1</span>
  ; </div>
  )

Reconciliation

A DOM node can be reconciled to a new hiccup representation using hipo.core/reconciliate!. Each time the reconciliation function is called the DOM element is modified so that it reflects the new hiccup element. The reconciliation performs a diff of hiccup structure (DOM is not read) and tries to minimize DOM changes.

(ns my-test
  (:require [hipo.core :as hipo]))

(let [el (hipo/create [:div#id.class [:span 1]])]
  (.appendChild js/document.body el)
  ; el is:
  ; <div id="id" class="class">
  ;   <span>1</span>
  ; </div>

  ; ... time passes
  (hipo/reconciliate! el [:div#id.class [:span 2]])

  ; el is now;
  ; <div id="id" class="class">
  ;   <span>2</span>
  ; </div>
  )

Children are assumed to keep their position across reconciliations. If children can be shuffled around while still keeping their identity the hipo/key metadata must be used.

(ns my-test
  (:require [hipo.core :as hipo]))

(let [f (fn [s] [:ul (for [i s] ^{:hipo/key i} [:li i])])
      el (hipo/create (f (range 6)))]
  (.appendChild js/document.body el)
  ; ... time passes
  (hipo/reconciliate! el (f (reverse (range 6)))))

Interceptor

Any DOM changes happening during the reconciliation can be intercepted / prevented via an Interceptor implementation. Interceptors are defined by providing a vector as :interceptors value in the option map.

An interceptor must implement the -intercept function that receives 3 arguments:

It's the interceptor responsibility to call the provided function at most once to trigger the eventual change execution. If no interceptor skip the call the change is performed.

Beware that preventing some part of the reconciliation might lead to an inconsistent state.

(ns …
  (:require [hipo.core :as hipo]
            [hipo.interceptor :refer [Interceptor]]))

(deftype PrintInterceptor []
  Interceptor
  (-intercept [_ t m]
    (if (= t :move)
      (println (:target m) "has been moved"))
    (f)))

(let [el (hipo/create [:div])]
  (.appendChild js/document.body el)
  ; ... time passes
  (hipo/reconciliate! el [:span] {:interceptors [(MyInterceptor.)]}))

Some interceptors are bundled by default.

Extensibility

Attribute handling

Element attribute handling can be extending by providing a vector as :attribute-handlers value in the option map. Attribute can be targeted by providing a combination of :ns, :tag and :attr (no value matches all candidates).

:type (:prop or :attr) defines if this attribute should be manipulated via attribute or property access.

(hipo/create [:input {:checked true}]
             {:attribute-handlers [{:target {:tag "input" :attr #{"checked" "value"}} :type :prop}]})

Alternatively provide a custom function via :fn that will be responsible for dealing with this attribute value.

(hipo/create [:span {:style {:background-color "blue"}]
             {:attribute-handlers [{:target {:attr "style"} :fn some-fn}]})

Some handlers are bundled by default.

Namespaces

DOM elements are created assuming the default HTML namespace. Specific namespaces can be used by introducing a namespace when declaring DOM nodes / attributes. This namespace is then used as a key to lookup the full namespace URL. By default svg and xlink are supported.

(hipo/create [:svg/svg
               [:svg/circle {:r "10"}]
               [:svg/use {:xlink/href "#id"}]])
(hipo/create [:div
               [:some-ns/elem]]
             {:namespaces {"some-ns" "some://url"}})

Element creation

A function can be passed to customize an element creation. This is useful when more efficient ways of creating a component are available.

(ns my-ns)

(defn my-custom-fn
  [ns tag attrs]
  ...)

(hipo/create [:div ^:text (my-fn)] {:create-element-fn my-ns/my-custom-fn})

As it can be referenced at macro expansion time the function must be provided as a fully qualified symbol.

Performance

Creation

At compile-time JavaScript code is generated from the hiccup representation to minimize DOM node creation cost at the expense of code size.

(hipo/create [:div.class {:on-click #(.log js/console "click)} [:span]) will be converted into the following ClojureScript:

(let [el (. js/document createElement "div")]
  (.setAttribute "class" "class")
  (. el addEventListener "click" #(.log js/console "click"))
  (. el appendChild (. js/document createElement "span"))
  el)

Interpretation can be forced by providing :force-interpretation? true in the option map. Alternatively :force-compilation? true will make create fail if compilation is not complete.

Attributes defined via a function (as opposed to literal maps) must be annotated with ^:attrs. This allows for simpler generated code as a function in second place can denote either attributes or a child node.

(ns …
  (:require [hipo.core :as hipo]))

(hipo/create [:div ^:attrs (merge {:class "class"} {:id "id"}) (fn [] [:span])])

When the hiccup representation can't be fully compiled the remaining hiccup elements are interpreted at runtime. This might happen when functions or parameters are used. Once in interpreted mode any nested child will not be compiled even if it is a valid candidate for compilation.

(defn children []
  (let [data ...] ; some data accessed at runtime
    (case (:type data)
      1 [:div "content"]
      2 [:ul (for [o (:value data)]
          [:li (str "content-" o)])])))

(hipo/create [:div (children)]) ; anything returned by children will be interpreted at runtime

Type-Hinting

When you know the result of a function call will be converted to an HTML text node (as opposed to an HTML element) the ^:text metadata can be used as a hint for the compiler to optimize the generated JavaScript code.

(defn my-fn []
  (str "content"))

(hipo/create [:div ^:text (my-fn)])

Security

hipo creates HTML element only based on the first element of hiccup vectors. No user-provided string will be used to create elements (no usage of innerHTML to set content). It should be noted that attribute value are not filtered and will be set as-is. Some combination might trigger code evaluation (like href="javascript:..") and should be treated accordingly.

Credits

Initial code comes from the great dommy library which is now focused on DOM manipulation. The original dommy code is available as hipo 0.1.0.

License

Copyright (C) 2013 Prismatic

Copyright (C) 2014 - 2015 Julien Eluard

Distributed under the Eclipse Public License, the same as Clojure.