diff --git a/.gitignore b/.gitignore
index c65c7d42..7b183f21 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ pom.xml.asc
/gh-pages
/node_modules
/_book
+figwheel_server.log
diff --git a/examples/frontend/checkouts/reitit-core b/examples/frontend/checkouts/reitit-core
new file mode 120000
index 00000000..a59d247e
--- /dev/null
+++ b/examples/frontend/checkouts/reitit-core
@@ -0,0 +1 @@
+../../../modules/reitit-core
\ No newline at end of file
diff --git a/examples/frontend/checkouts/reitit-frontend b/examples/frontend/checkouts/reitit-frontend
new file mode 120000
index 00000000..20cdd448
--- /dev/null
+++ b/examples/frontend/checkouts/reitit-frontend
@@ -0,0 +1 @@
+../../../modules/reitit-frontend
\ No newline at end of file
diff --git a/examples/frontend/checkouts/reitit-schema b/examples/frontend/checkouts/reitit-schema
new file mode 120000
index 00000000..a68c7f05
--- /dev/null
+++ b/examples/frontend/checkouts/reitit-schema
@@ -0,0 +1 @@
+../../../modules/reitit-schema
\ No newline at end of file
diff --git a/examples/frontend/project.clj b/examples/frontend/project.clj
new file mode 100644
index 00000000..821e45b9
--- /dev/null
+++ b/examples/frontend/project.clj
@@ -0,0 +1,51 @@
+(defproject frontend "0.1.0-SNAPSHOT"
+ :description "FIXME: write description"
+ :url "http://example.com/FIXME"
+ :license {:name "Eclipse Public License"
+ :url "http://www.eclipse.org/legal/epl-v10.html"}
+
+ :dependencies [[org.clojure/clojure "1.9.0"]
+ [ring-server "0.5.0"]
+ [reagent "0.8.1"]
+ [ring "1.6.3"]
+ [compojure "1.6.1"]
+ [hiccup "1.0.5"]
+ [org.clojure/clojurescript "1.10.238" :scope "provided"]
+ [metosin/reitit "0.1.3"]
+ [metosin/reitit-schema "0.1.3"]
+ [metosin/reitit-frontend "0.1.3"]]
+
+ :plugins [[lein-cljsbuild "1.1.7"]]
+
+ :source-paths []
+ :resource-paths ["resources" "target/cljsbuild"]
+
+ :cljsbuild
+ {:builds
+ [{:id "app"
+ :figwheel true
+ :source-paths ["src"]
+ :watch-paths ["src" "checkouts/reitit-frontend/src"]
+ :compiler {:main "frontend.core"
+ :asset-path "/js/out"
+ :output-to "target/cljsbuild/public/js/app.js"
+ :output-dir "target/cljsbuild/public/js/out"
+ :source-map true
+ :optimizations :none
+ :pretty-print true
+ :preloads [devtools.preload]}}
+ {:id "min"
+ :source-paths ["src"]
+ :compiler {:output-to "target/cljsbuild/public/js/app.js"
+ :output-dir "target/cljsbuild/public/js"
+ :source-map "target/cljsbuild/public/js/app.js.map"
+ :optimizations :advanced
+ :pretty-print false}}]}
+
+ :figwheel
+ {:http-server-root "public"
+ :server-port 3449
+ :nrepl-port 7002}
+
+ :profiles {:dev {:dependencies [[binaryage/devtools "0.9.10"]]
+ :plugins [[lein-figwheel "0.5.16"]]}})
diff --git a/examples/frontend/resources/public/index.html b/examples/frontend/resources/public/index.html
new file mode 100644
index 00000000..bbad514c
--- /dev/null
+++ b/examples/frontend/resources/public/index.html
@@ -0,0 +1,10 @@
+
+
+
+ Reitit frontend example
+
+
+
+
+
+
diff --git a/examples/frontend/src/frontend/core.cljs b/examples/frontend/src/frontend/core.cljs
new file mode 100644
index 00000000..4f4d9276
--- /dev/null
+++ b/examples/frontend/src/frontend/core.cljs
@@ -0,0 +1,50 @@
+(ns frontend.core
+ (:require [reagent.core :as r]
+ [reitit.core :as rc]
+ [reitit.frontend :as rf]
+ [reitit.frontend.history :as rfh]
+ [reitit.coercion.schema :as rs]))
+
+(def router (atom nil))
+
+(defn home-page []
+ [:div
+ [:h2 "Welcome to frontend"]
+ [:div [:a {:href (rfh/href @router ::about)} "go to about page"]]])
+
+(defn about-page []
+ [:div
+ [:h2 "About frontend"]
+ [:a {:href "http://google.com"} "external link"]
+ [:div [:a {:href (rfh/href @router ::frontpage)} "go to the home page"]]])
+
+(defonce match (r/atom nil))
+
+(defn current-page []
+ [:div
+ (if @match
+ [:div [(:view (:data @match))]])
+ (pr-str @match)])
+
+(def routes
+ (rc/router
+ [""
+ [""
+ {:name ::frontpage-root
+ :view home-page}]
+ ["/"
+ [""
+ {:name ::frontpage
+ :view home-page}]
+ ["about"
+ {:name ::about
+ :view about-page}]]]))
+
+(defn init! []
+ (reset! router (rfh/start! routes
+ (fn [m] (reset! match m))
+ {:use-fragment true
+ :path-prefix "/"}))
+ (r/render [current-page] (.getElementById js/document "app")))
+
+(init!)
diff --git a/modules/reitit-frontend/src/reitit/frontend.cljs b/modules/reitit-frontend/src/reitit/frontend.cljs
index a50c4e5f..ae2e5816 100644
--- a/modules/reitit-frontend/src/reitit/frontend.cljs
+++ b/modules/reitit-frontend/src/reitit/frontend.cljs
@@ -1,19 +1,14 @@
(ns reitit.frontend
- "Utilities to implement frontend routing using Reitit.
-
- Controller is way to declare as data the side-effects and optionally
- other data related to the route."
+ ""
(:require [reitit.core :as reitit]
[clojure.string :as str]
- goog.Uri
- [reitit.coercion :as coercion]))
-
-;;
-;; Utilities
-;;
+ [reitit.coercion :as coercion]
+ [goog.events :as e]
+ [goog.dom :as dom])
+ (:import goog.Uri))
(defn query-params
- "Parse query-params from URL into a map."
+ "Given goog.Uri, read query parameters into Clojure map."
[^goog.Uri uri]
(let [q (.getQueryData uri)]
(->> q
@@ -21,70 +16,43 @@
(map (juxt keyword #(.get q %)))
(into {}))))
-(defn get-hash
- "Given browser hash starting with #, remove the # and
- end slashes."
- []
- (-> js/location.hash
- (subs 1)
- (str/replace #"/$" "")))
+(defn query-string
+ "Given map, creates "
+ [m]
+ (str/join "&" (map (fn [[k v]]
+ (str (js/encodeURIComponent (name k))
+ "="
+ ;; FIXME: create protocol to handle how types are converted to string
+ ;; FIXME: array to multiple params
+ (if (coll? v)
+ (str/join "," (map #(js/encodeURIComponent %) v))
+ (js/encodeURIComponent v))))
+ m)))
-;;
-;; Controller implementation
-;;
+(defn match-by-path
+ "Given routing tree and current path, return match with possibly
+ coerced parameters. Return nil if no match found."
+ [router path]
+ (let [uri (.parse Uri path)]
+ (if-let [match (reitit/match-by-path router (.getPath uri))]
+ (let [q (query-params uri)
+ ;; Return uncoerced values if coercion is not enabled - so
+ ;; that tha parameters are always accessible from same property.
+ ;; FIXME: coerce! can't be used as it doesn't take query-params
+ parameters (if (:result match)
+ (coercion/coerce-request (:result match) {:query-params q
+ :path-params (:path-params match)})
+ {:query q
+ :path (:param match)})]
+ (assoc match :parameters parameters)))))
-(defn get-params
- "Get controller parameters given match. If controller provides :params
- function that will be called with the match. Default is nil."
- [controller match]
- (if-let [f (:params controller)]
- (f match)))
-
-(defn apply-controller
- "Run side-effects (:start or :stop) for controller.
- The side-effect function is called with controller params."
- [controller method]
- (when-let [f (get controller method)]
- (f (::params controller))))
-
-(defn- pad-same-length [a b]
- (concat a (take (- (count b) (count a)) (repeat nil))))
-
-(defn apply-controllers
- "Applies changes between current controllers and
- those previously enabled. Resets controllers whose
- parameters have changed."
- [old-controllers new-match]
- (let [new-controllers (map (fn [controller]
- (assoc controller ::params (get-params controller new-match)))
- (:controllers (:data new-match)))
- changed-controllers (->> (map (fn [old new]
- ;; different controllers, or params changed
- (if (not= old new)
- {:old old, :new new}))
- (pad-same-length old-controllers new-controllers)
- (pad-same-length new-controllers old-controllers))
- (keep identity)
- vec)]
- (doseq [controller (map :old changed-controllers)]
- (apply-controller controller :stop))
- (doseq [controller (map :new changed-controllers)]
- (apply-controller controller :start))
- new-controllers))
-
-(defn hash-change [router hash]
- (let [uri (goog.Uri/parse hash)
- match (or (reitit/match-by-path router (.getPath uri))
- {:data {:name :not-found}})
- q (query-params uri)
- ;; Coerce if coercion enabled
- c (if (:result match)
- (coercion/coerce-request (:result match) {:query-params q
- :path-params (:params match)})
- {:query q
- :path (:param match)})
- ;; Replace original params with coerced params
- match (-> match
- (assoc :query (:query c))
- (assoc :params (:path c)))]
- match))
+(defn match-by-name
+ [router name params]
+ ;; FIXME: move router not initialized to re-frame integration?
+ (if router
+ (or (reitit/match-by-name router name params)
+ ;; FIXME: return nil?
+ (do
+ (js/console.error "Can't create URL for route " (pr-str name) (pr-str params))
+ nil))
+ ::not-initialized))
diff --git a/modules/reitit-frontend/src/reitit/frontend/history.cljs b/modules/reitit-frontend/src/reitit/frontend/history.cljs
new file mode 100644
index 00000000..964a665a
--- /dev/null
+++ b/modules/reitit-frontend/src/reitit/frontend/history.cljs
@@ -0,0 +1,114 @@
+(ns reitit.frontend.history
+ ""
+ (:require [reitit.core :as reitit]
+ [clojure.string :as string]
+ [goog.events :as e]
+ [goog.dom :as dom]
+ [reitit.frontend :as rf])
+ (:import goog.history.Html5History
+ goog.Uri))
+
+;; Token is for Closure HtmlHistory
+;; Path is for reitit
+
+(defn- token->path [history token]
+ (if (.-useFragment_ history)
+ token
+ (str (.getPathPrefix history) token)))
+
+(defn- path->token [history path]
+ (subs path (if (.-useFragment_ history)
+ 1
+ (count (.getPathPrefix history)))))
+
+(defn- token->href [history token]
+ (str (if (.-useFragment_ history)
+ (str "#"))
+ (.getPathPrefix history)
+ token))
+
+(def ^:private current-domain (.getDomain (.parse Uri js/location)))
+
+(defn ignore-anchor-click
+ "Ignore click events from a elements, if the href points to URL that is part
+ of the routing tree."
+ [router history e]
+ ;; Returns the next matching anchestor of event target
+ (when-let [el (.closest (.-target e) "a")]
+ (let [uri (.parse Uri (.-href el))]
+ (when (and (or (and (not (.hasScheme uri)) (not (.hasDomain uri)))
+ (= current-domain (.getDomain uri)))
+ (not (.-altKey e))
+ (not (.-ctrlKey e))
+ (not (.-metaKey e))
+ (not (.-shiftKey e))
+ (not (contains? #{"_blank" "self"} (.getAttribute el "target")))
+ ;; Left button
+ (= 0 (.-button e))
+ (reitit/match-by-path router (.getPath uri)))
+ (.preventDefault e)
+ (.replaceToken history (path->token history (.getPath uri)))))))
+
+(defn start!
+ "Parameters:
+ - router The reitit routing tree.
+ - on-navigate Function to be called when route changes.
+
+ Options:
+ - :use-fragment (default true) If true, onhashchange and location hash are used to store the token.
+ - :path-prefix (default \"/\") If :use-fragment is false, this is prepended to all tokens, and is
+ removed from start of the token before matching the route."
+ [router
+ on-navigate
+ {:keys [path-prefix use-fragment]
+ :or {path-prefix "/"
+ use-fragment true}}]
+ (let [history
+ (doto (Html5History.)
+ (.setEnabled true)
+ (.setPathPrefix path-prefix)
+ (.setUseFragment use-fragment))
+
+ event-key
+ (e/listen history goog.history.EventType.NAVIGATE
+ (fn [e]
+ (on-navigate (rf/match-by-path router (token->path history (.getToken history))))))
+
+ click-listen-key
+ (if-not use-fragment
+ (e/listen js/document e/EventType.CLICK
+ (partial ignore-anchor-click router history)))]
+
+ ;; Trigger navigate event for current route
+ (on-navigate (rf/match-by-path router (token->path history (.getToken history))))
+
+ {:router router
+ :history history
+ :close-fn (fn []
+ (e/unlistenByKey event-key)
+ (e/unlistenByKey click-listen-key)
+ (.setEnabled history false))}))
+
+(defn stop! [{:keys [close-fn]}]
+ (if close-fn
+ (close-fn)))
+
+(defn- match->token [history match k params]
+ ;; FIXME: query string
+ (if-let [path (:path match)]
+ (path->token history path)))
+
+(defn href
+ ([state k]
+ (href state k nil))
+ ([state k params]
+ (href state k params nil))
+ ([{:keys [router history]} k params query]
+ (let [match (rf/match-by-name router k params)
+ token (match->token history match k params)]
+ (token->href history token))))
+
+(defn replace-token [{:keys [router history]} k params]
+ (let [match (rf/match-by-name router k params)
+ token (match->token history match k params)]
+ (.replaceToken history token)))
diff --git a/project.clj b/project.clj
index ab2f6e5a..c7172db6 100644
--- a/project.clj
+++ b/project.clj
@@ -62,7 +62,10 @@
[criterium "0.4.4"]
[org.clojure/test.check "0.9.0"]
[org.clojure/tools.namespace "0.2.11"]
- [com.gfredericks/test.chuck "0.2.9"]]}
+ [com.gfredericks/test.chuck "0.2.9"]
+
+ ;; https://github.com/bensu/doo/issues/180
+ [fipp "0.6.12"]]}
:perf {:jvm-opts ^:replace ["-server"
"-Xmx4096m"
"-Dclojure.compiler.direct-linking=true"]
diff --git a/test/cljs/reitit/doo_runner.cljs b/test/cljs/reitit/doo_runner.cljs
index 57b7dc2b..25f522c6 100644
--- a/test/cljs/reitit/doo_runner.cljs
+++ b/test/cljs/reitit/doo_runner.cljs
@@ -5,7 +5,8 @@
reitit.impl-test
reitit.middleware-test
reitit.ring-test
- #_reitit.spec-test))
+ #_reitit.spec-test
+ reitit.frontend.core-test))
(enable-console-print!)
@@ -14,4 +15,5 @@
'reitit.impl-test
'reitit.middleware-test
'reitit.ring-test
- #_'reitit.spec-test)
+ #_'reitit.spec-test
+ 'reitit.frontend.core-test)
diff --git a/test/cljs/reitit/frontend/core_test.cljs b/test/cljs/reitit/frontend/core_test.cljs
new file mode 100644
index 00000000..2d522a4b
--- /dev/null
+++ b/test/cljs/reitit/frontend/core_test.cljs
@@ -0,0 +1,41 @@
+(ns reitit.frontend.core-test
+ (:require [clojure.test :refer [deftest testing is are]]
+ [reitit.core :as r]
+ [reitit.frontend :as rf]
+ [reitit.coercion :as coercion]
+ [schema.core :as s]
+ [reitit.coercion.schema :as schema]))
+
+(deftest match-by-path-test
+ (testing "simple"
+ (let [router (r/router ["/"
+ ["" ::frontpage]
+ ["foo" ::foo]
+ ["bar" ::bar]])]
+ (is (= {:template "/"
+ :data {:name ::frontpage}
+ :path "/"}
+ (rf/match-by-path router "/")))
+ (is (= {:template "/foo"
+ :data {:name ::foo}
+ :path "/foo"}
+ (rf/match-by-path router "/foo")))))
+
+ (testing "schema coercion"
+ (let [router (r/router ["/"
+ [":id"
+ {:name ::foo
+ :parameters {:path {:id s/Int}
+ :query {(s/optional-key :mode) s/Keyword}}}]])]
+ #_#_
+ (is (= {:template "/5"
+ :data {:name ::foo}
+ :path "/5"
+ :parameters {:path {:id 5}}}
+ (rf/match-by-path router "/5")))
+ (is (= {:template "/5?mode=foo"
+ :data {:name ::foo}
+ :path "/5?mode=foo"
+ :parameters {:path {:id 5}
+ :query {:mode :foo}}}
+ (rf/match-by-path router "/5?mode=foo"))))))