Bidi vs Silk

In previous blog posts I mention that Bidi and Silk are essentially equivalent. I don’t believe this anymore. I now prefer Silk and I can show you why with a little example. First, let’s define some routes:

(def silk-routes (domkm.silk/routes [[:home-page [[]]]
 [:about [["about"]]]]))

(def bidi-routes ["/" {"" :home-page
 "about" :about-page}])

When it comes to defining routes, I find both a bit cryptic. Bidi feels a bit easier to read but I found it was harder to write in some scenarios.

Continue reading →

Bidi vs Silk

In previous blog posts I mention that Bidi and Silk are essentially equivalent. I don’t believe this anymore. I now prefer Silk and I can show you why with a little example. First, let’s define some routes:

(def silk-routes (domkm.silk/routes [[:home-page [[]]]
                                     [:about [["about"]]]]))

(def bidi-routes ["/" {""      :home-page
                       "about" :about-page}])

When it comes to defining routes, I find both a bit cryptic. Bidi feels a bit easier to read but I found it was harder to write in some scenarios.

Let’s parse some paths:

(bidi.bidi/match-route bidi-routes "/")
; => {:handler :home-page}

(domkm.silk/arrive silk-routes "/")
; => {:domkm.silk/name :home-page, :domkm.silk/pattern {:path []}, :domkm.silk/routes #object[domkm.silk.Routes 0x7a46e3be "domkm.silk.Routes@7a46e3be"], :domkm.silk/url #domkm.silk.URL{:scheme nil, :user nil, :host nil, :port nil, :path [], :query {}, :fragment nil}}

(bidi.bidi/match-route bidi-routes "/about")
; => {:handler :about-page}

(domkm.silk/arrive silk-routes "/about")
; => {:domkm.silk/name :about, :domkm.silk/pattern {:path ["about"]}, :domkm.silk/routes #object[domkm.silk.Routes 0x7a46e3be "domkm.silk.Routes@7a46e3be"], :domkm.silk/url #domkm.silk.URL{:scheme nil, :user nil, :host nil, :port nil, :path ["about"], :query {}, :fragment nil}}

So far I would consider them equivalent. Silk gives you more information about the route but is also more noisy. I personally dislike the namespaced keywords, but that’s easily solved with:

(defn sanitize-silk-keywords [matched-route]
  (rename-keys matched-route {:domkm.silk/name    :name
                              :domkm.silk/pattern :pattern
                              :domkm.silk/routes  :routes
                              :domkm.silk/url     :url}))

The real difference, for me, comes when I try to parse /about?,which should be the same as /about and some lazy URL handling libraries emit the former rather than the latter. Silk first:

(domkm.silk/arrive silk-routes "/about?")
; => {:domkm.silk/name :about, :domkm.silk/pattern {:path ["about"]}, :domkm.silk/routes #object[domkm.silk.Routes 0x7a46e3be "domkm.silk.Routes@7a46e3be"], :domkm.silk/url #domkm.silk.URL{:scheme nil, :user nil, :host nil, :port nil, :path ["about"], :query {}, :fragment nil}}

No surprised there. What about Bidi:

(bidi.bidi/match-route bidi-routes "/about?")
; => nil

Oops, what happened here? A bit of digging around lead me to the issue Why are query parameters not supported anymore? Essentially, Bidi’s design is that you shouldn’t route on query arguments (which I would agree in principle) and thus it relays on others to separate the two, something I don’t like at all. In a ClojureScript application it would requiring some pre-parsing of the URL, while Silk does it for you.

Silk also seems to support, if not routing, at least reporting port, host, user, scheme and even fragment. This can come in handy at some point. If you want to learn more about using Silk and Pushy, take a look at the blog post No-hashes bidirectional routing in re-frame with Silk and Pushy.

Picture by MartialArtsNomad.com

No-hashes bidirectional routing in re-frame with bidi and pushy

Note: this is a bidi counterpart to the silk article: No-hashes bidirectional routing in re-frame with silk and pushy. The content is very similar, only the code changes.

I recently replaced secretary with bidi and pushy in a re-frame project that was created fresh out of leiningen template and this is how I did it, but first, the reason.

What I like about bidi is that it’s bidirectional. It not only parses URLs into data structures but also generates URLs from data structures. This is not unique to bidi, silk also does it and feature-wise they are almost equivalent. On bidi’s website you can find this table comparing various routing libraries:

Library clj cljs Syntax Isomorphic? Self-contained? Extensible?
Compojure Macros
Moustache Macros
RouteOne Macros
Pedestal Data
gudu Data
secretary Macros
silk Data
fnhouse Macros
bidi Data

I don’t have a strong reason to chose bidi over silk, both seem like excellent choices. I also added pushy to the mix so I could have nice URLs, such as /about  instead of /#/about. The reason for this is that it looks better, follows the semantics of URLs better and allows for server side rendering, to be covered in a future article. It uses HTML5 pushState which by now it’s widely supported.

Let’s get started

I’m going to use a fresh project for this article, but the information here is applicable to any similar situation. Let’s get started:

lein new re-frame projectx +routes

The first step is to get rid of secretary and include bidi and pushy by replacing

[secretary "1.2.3"]

with

[bidi "1.20.3"]
[kibu/pushy "0.3.2"]

The Routes

The routes.cljs  file will be completely re-written. The namespace declaration will include bidi and pushy:

(ns projectx.routes
  (:require [bidi.bidi :as bidi]
            [pushy.core :as pushy]
            [re-frame.core :as re-frame]))

The first step is to define the routes we want. One of the designing features of bidi is that routes are data structures, not function/macro calls:

(def routes ["/" {""      :home
                  "about" :about}])

The app-routes function that used to define functions is replaced by one that sets up pushy:

(defn app-routes []
  (pushy/start! (pushy/pushy dispatch-route parse-url)))

parse-url  is a very simple function that uses bidi/match-route to turn a URL into a data structure representing it:

(defn- parse-url [url]
  (bidi/match-route routes url))

Then dispatch-route is called with that structure:

(defn- dispatch-route [matched-route]
  (let [panel-name (keyword (str (name (:handler matched-route)) "-panel"))]
    (re-frame/dispatch [:set-active-panel panel-name])))

The :handler of the matched-route will be :home or :about and panel-name  is constructed to be :home-panel  or :about-panel so that we can dispatch setting the active panel the same way secretary used to do it. And that’s the core of it. You now have to change URLs in the view to be /about  and / instead of /#/about and /#/ respectively.

Generating URLs

The whole point of using bidi was to not hard-code the URLs, for that I added this function to routes.cljs:

(def url-for (partial bidi/path-for routes))

which allowed me to replace the previous URLs with:

(routes/url-for :about)

and

(routes/url-for :home)

This works fine until you try to re-load the about page and you get a 404 Not Found error. This is because the server doesn’t know about the /about  URL or any other URL the client-side might support. What you need to do is just send serve the application no matter what the URL and let the client do the dispatching (with any exceptions you might have for APIs and whatnot).

I’m actually not quite sure how you do it with a figwheel-only project, probably by setting a ring handler. With compojure I ended up creating a route like this:

(routes (ANY "*" [] (layout/render "app.html")))

Something else you might consider is passing the whole matched-route  structure to the handler, so that the handler has access to path and query attributes.

And that’s it! Now you have beautiful bi-directional no-hash client-side routing.

Summary

For your reference, the full routes.cljs :

(ns projectx.routes
  (:require [bidi.bidi :as bidi]
            [pushy.core :as pushy]
            [re-frame.core :as re-frame]))

(def routes ["/" {""      :home
                  "about" :about}])

(defn- parse-url [url]
  (bidi/match-route routes url))

(defn- dispatch-route [matched-route]
  (let [panel-name (keyword (str (name (:handler matched-route)) "-panel"))]
    (re-frame/dispatch [:set-active-panel panel-name])))

(defn app-routes []
  (pushy/start! (pushy/pushy dispatch-route parse-url)))

(def url-for (partial bidi/path-for routes))

and the full views.cljs:

(ns projectx.views
    (:require [re-frame.core :as re-frame]
              [projectx.routes :as routes]))

;; --------------------
(defn home-panel []
  (let [name (re-frame/subscribe [:name])]
    (fn []
      [:div (str "Hello from " @name ". This is the Home Page.")
       [:div [:a {:href (routes/url-for :about)} "go to About Page"]]])))

(defn about-panel []
  (fn []
    [:div "This is the About Page."
     [:div [:a {:href (routes/url-for :home)} "go to Home Page"]]]))

;; --------------------
(defmulti panels identity)
(defmethod panels :home-panel [] [home-panel])
(defmethod panels :about-panel [] [about-panel])
(defmethod panels :default [] [:div])

(defn main-panel []
  (let [active-panel (re-frame/subscribe [:active-panel])]
    (fn []
      (panels @active-panel))))

No-hashes bidirectional routing in re-frame with silk and pushy

Update: there’s a version of this article that uses bidi instead of silk: No-hashes bidirectional routing in re-frame with bidi and pushy. The content is very similar, only the code changes.

I recently replaced secretary with silk and pushy in a re-frame project that was created fresh out of leiningen template and this is how I did it, but first, the reason.

What I like about silk is that it’s bidirectional. It not only parses URLs into data structures but also generates URLs from data structures. This is not unique to silk, bidi also does it and feature-wise they are almost equivalent. On bidi’s website you can find this table comparing various routing libraries:

Library clj cljs Syntax Isomorphic? Self-contained? Extensible?
Compojure Macros
Moustache Macros
RouteOne Macros
Pedestal Data
gudu Data
secretary Macros
silk Data
fnhouse Macros
bidi Data

I don’t have a strong reason to chose silk over bidi, both seem like excellent choices. I also added pushy to the mix so I could have nice URLs, such as /about  instead of /#/about. The reason for this is that it looks better, follows the semantics of URLs better and allows for server side rendering, to be covered in a future article. It uses HTML5 pushState which by now it’s widely supported.

Let’s get started

I’m going to use a fresh project for this article, but the information here is applicable to any similar situation. Let’s get started:

lein new re-frame projectx +routes

The first step is to get rid of secretary and include silk and pushy by replacing

[secretary "1.2.3"]

with

[com.domkm/silk "0.1.1"]
[kibu/pushy "0.3.2"]

The routes

The routes.cljs  file will be completely re-written. The namespace declaration will include silk and pushy:

(ns projectx.routes
  (:require [clojure.set :refer [rename-keys]]
            [domkm.silk :as silk]
            [pushy.core :as pushy]
            [re-frame.core :as re-frame]))

The first step is to define the routes we want. One of the designing features of silk is that routes are data structures, not function/macro calls:

(def routes (silk/routes [[:home [[]]]
                          [:about [["about"]]]]))

The app-routes function that used to define functions is replaced by one that sets up pushy:

(defn app-routes []
  (pushy/start! (pushy/pushy dispatch-route parse-url)))

parse-url  is a very simple function that uses silk/arrive to turn a URL into a data structure representing it:

(defn- parse-url [url]
  (silk/arrive routes url))

Then dispatch-route is called with that structure:

(defn- dispatch-route [matched-route]
  (let [matched-route (sanitize-silk-keywords matched-route)
        panel-name (keyword (str (name (:name matched-route)) "-panel"))]
    (re-frame/dispatch [:set-active-panel panel-name])))

The :name of the matched-route will be :home or :about and panel-name  is constructed to be :home-panel  or :about-panel so that we can dispatch setting the active panel the same way secretary used to do it.

sanitize-silk-keywords  just simplifies the namespaced keywords silk produces:

(defn- sanitize-silk-keywords [matched-route]
  (rename-keys matched-route {:domkm.silk/name    :name
                              :domkm.silk/pattern :pattern
                              :domkm.silk/routes  :routes
                              :domkm.silk/url     :url}))

And that’s the core of it. You now have to change URLs in the view to be /about  and / instead of /#/about and /#/ respectively.

Generating URLs

The whole point of using silk was to not hard-code the URLs, for that I added this function to routes.cljs:

(def url-for (partial silk/depart routes))

which allowed me to replace the previous URLs with:

(routes/url-for :about)

and

(routes/url-for :home)

This works fine until you try to re-load the about page and you get a 404 Not Found error. This is because the server doesn’t know about the /about  URL or any other URL the client-side might support. What you need to do is just send serve the application no matter what the URL and let the client do the dispatching (with any exceptions you might have for APIs and whatnot).

I’m actually not quite sure how you do it with a figwheel-only project, probably by setting a ring handler. With compojure I ended up creating a route like this:

(routes (ANY "*" [] (layout/render "app.html")))

Something else you might consider is passing the whole matched-route  structure to the handler, so that the handler has access to path and query attributes.

And that’s it! Now you have beautiful bi-directional no-hash client-side routing.

Summary

For your reference, the full routes.cljs :

(ns projectx.routes
  (:require [clojure.set :refer [rename-keys]]
            [domkm.silk :as silk]
            [pushy.core :as pushy]
            [re-frame.core :as re-frame]))

(def routes (silk/routes [[:home [[]]]
                          [:about [["about"]]]]))

(defn- parse-url [url]
  (silk/arrive routes url))

(defn- sanitize-silk-keywords [matched-route]
  (rename-keys matched-route {:domkm.silk/name    :name
                              :domkm.silk/pattern :pattern
                              :domkm.silk/routes  :routes
                              :domkm.silk/url     :url}))

(defn- dispatch-route [matched-route]
  (let [matched-route (sanitize-silk-keywords matched-route)
        panel-name (keyword (str (name (:name matched-route)) "-panel"))]
    (re-frame/dispatch [:set-active-panel panel-name])))

(defn app-routes []
  (pushy/start! (pushy/pushy dispatch-route parse-url)))

(def url-for (partial silk/depart routes))

and the full views.cljs:

(ns projectx.views
    (:require [re-frame.core :as re-frame]
              [projectx.routes :as routes]))

;; --------------------
(defn home-panel []
  (let [name (re-frame/subscribe [:name])]
    (fn []
      [:div (str "Hello from " @name ". This is the Home Page.")
       [:div [:a {:href (routes/url-for :about)} "go to About Page"]]])))

(defn about-panel []
  (fn []
    [:div "This is the About Page."
     [:div [:a {:href (routes/url-for :home)} "go to Home Page"]]]))

;; --------------------
(defmulti panels identity)
(defmethod panels :home-panel [] [home-panel])
(defmethod panels :about-panel [] [about-panel])
(defmethod panels :default [] [:div])

(defn main-panel []
  (let [active-panel (re-frame/subscribe [:active-panel])]
    (fn []
      (panels @active-panel))))