├── .gitignore ├── README.md ├── dev └── user.clj ├── project.clj ├── resources ├── about.md ├── public │ └── assets │ │ ├── images │ │ └── 404.gif │ │ └── stylesheets │ │ └── style.css └── words.txt └── src └── omelette ├── data.clj ├── main.clj ├── render.clj ├── route.cljx ├── serve.clj └── view.cljs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /lib 3 | /classes 4 | /checkouts 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Omelette 2 | 3 | ## Isomorphic Clojure[Script] 4 | 5 | #### [Om](https://github.com/swannodette/om)/[React](http://facebook.github.io/react/), [Sente](https://github.com/ptaoussanis/sente), and JDK8's Nashorn JavaScript engine 6 | 7 | Clone and run `lein demo` (requires [Leiningen](http://leiningen.org/) and JDK8). 8 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require [clojure.tools.namespace.repl :refer [refresh]] 3 | [com.stuartsierra.component :as component] 4 | [omelette.main :as main])) 5 | 6 | (def system nil) 7 | 8 | (defn init [] 9 | (alter-var-root #'system (constantly (main/system 3000)))) 10 | 11 | (defn start [] 12 | (alter-var-root #'system component/start)) 13 | 14 | (defn stop [] 15 | (alter-var-root #'system #(when % (component/stop %)))) 16 | 17 | (defn go [] 18 | (init) 19 | (start)) 20 | 21 | (defn reset [] 22 | (stop) 23 | (refresh :after 'user/go)) 24 | 25 | (comment 26 | (pr system) 27 | (init) 28 | (start) 29 | (stop) 30 | (go) 31 | (reset) 32 | (main/browse system)) 33 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject omelette "0.0.0" 2 | 3 | :description "Example of mirrored server/client rendering and routing using React/Om, Sente, and the Nashorn JavaScript engine." 4 | 5 | :license {:name "Eclipse Public License" 6 | :url "http://www.eclipse.org/legal/epl-v10.html" 7 | :distribution :repo 8 | :comments "Same as Clojure"} 9 | 10 | :main omelette.main 11 | 12 | :global-vars {*warn-on-reflection* true} 13 | 14 | :source-paths ["src" "target/src"] 15 | 16 | :resource-paths ["resources" "target/resources"] 17 | 18 | :dependencies [[ankha "0.1.3"] 19 | [com.stuartsierra/component "0.2.1"] 20 | [com.taoensso/encore "1.6.0"] 21 | [com.taoensso/sente "0.14.1"] 22 | [compojure "1.1.8"] 23 | [hiccup "1.0.5"] 24 | [http-kit "2.1.18"] 25 | [markdown-clj "0.9.44"] 26 | [om "0.6.4"] 27 | [org.clojure/clojure "1.6.0"] 28 | [org.clojure/clojurescript "0.0-2227"] 29 | [org.clojure/core.async "0.1.298.0-2a82a1-alpha"] 30 | [org.clojure/core.match "0.2.1"] 31 | [ring "1.3.0"] 32 | [ring/ring-anti-forgery "0.3.2"] 33 | [sablono "0.2.17"]] 34 | 35 | :plugins [[com.keminglabs/cljx "0.4.0"] 36 | [lein-cljsbuild "1.0.3"] 37 | [lein-pdo "0.1.1"]] 38 | 39 | :hooks [cljx.hooks leiningen.cljsbuild] 40 | 41 | :cljx {:builds [{:source-paths ["src"], :output-path "target/src", :rules :clj} 42 | {:source-paths ["src"], :output-path "target/src", :rules :cljs}]} 43 | 44 | :cljsbuild {:builds [{:source-paths ["src" "target/src"] 45 | :compiler {:preamble ["react/react.min.js"] 46 | :output-to "target/resources/public/assets/scripts/main.js" 47 | :output-dir "target/resources/public/assets/scripts" 48 | :source-map "target/resources/public/assets/scripts/main.js.map" 49 | :optimizations :whitespace}}]} 50 | 51 | :profiles {:dev {:dependencies [[org.clojure/tools.namespace "0.2.4"]] 52 | :source-paths ["dev"]} 53 | :build {}} 54 | 55 | :aliases {"demo" ["with-profile" "build" 56 | "do" "clean," 57 | "cljx" "once," 58 | "run"] 59 | "build-auto" ["with-profile" "build" 60 | "do" "clean," 61 | "cljx" "once," 62 | ["pdo" 63 | "cljx" "auto," 64 | "cljsbuild" "auto"]]}) 65 | -------------------------------------------------------------------------------- /resources/about.md: -------------------------------------------------------------------------------- 1 | 2 | Zombie ipsum reversus ab viral inferno, nam rick grimes malum cerebro. De carne lumbering animata corpora quaeritis. Summus brains sit, morbo vel maleficia? De apocalypsi gorger omero undead survivor dictum mauris. Hi mindless mortuis soulless creaturas, imo evil stalking monstra adventus resi dentevil vultus comedat cerebella viventium. Qui animated corpse, cricket bat max brucks terribilem incessu zomby. The voodoo sacerdos flesh eater, suscitat mortuos comedere carnem virus. Zonbi tattered for solum oculi eorum defunctis go lum cerebro. Nescio brains an Undead zombies. Sicut malus putrid voodoo horror. Nigh tofth eliv ingdead. 3 | 4 | Cum horribilem walking dead resurgere de crazed sepulcris creaturis, zombie sicut de grave feeding iride et serpens. Pestilentia, shaun ofthe dead scythe animated corpses ipsa screams. Pestilentia est plague haec decaying ambulabat mortuos. Sicut zeder apathetic malus voodoo. Aenean a dolor plan et terror soulless vulnerum contagium accedunt, mortui iam vivam unlife. Qui tardius moveri, brid eof reanimator sed in magna copia sint terribiles undeath legionis. Alii missing oculis aliorum sicut serpere crabs nostram. Putridi braindead odores kill and infect, aere implent left four dead. 5 | 6 | Lucio fulci tremor est dark vivos magna. Expansis creepy arm yof darkness ulnis witchcraft missing carnem armis Kirkman Moore and Adlard caeruleum in locis. Romero morbo Congress amarus in auras. Nihil horum sagittis tincidunt, zombie slack-jawed gelida survival portenta. The unleashed virus est, et iam zombie mortui ambulabunt super terram. Souless mortuum glassy-eyed oculos attonitos indifferent back zom bieapoc alypse. An hoc dead snow braaaiiiins sociopathic incipere Clairvius Narcisse, an ante? Is bello mundi z? 7 | 8 | In Craven omni memoria patriae zombieland clairvius narcisse religionis sunt diri undead historiarum. Golums, zombies unrelenting et Raimi fascinati beheading. Maleficia! Vel cemetery man a modern bursting eyeballs perhsaps morbi. A terrenti flesh contagium. Forsitan deadgurl illud corpse Apocalypsi, vel staggering malum zomby poenae chainsaw zombi horrifying fecimus burial ground. Indeflexus shotgun coup de poudre monstra per plateas currere. Fit de decay nostra carne undead. Poenitentiam violent zom biehig hway agite RE:dead pœnitentiam! Vivens mortua sunt apud nos night of the living dead. 9 | 10 | Whyt zomby Ut fames after death cerebro virus enim carnis grusome, viscera et organa viventium. Sicut spargit virus ad impetum, qui supersumus flesh eating. Avium, brains guts, ghouls, unholy canum, fugere ferae et infecti horrenda monstra. Videmus twenty-eight deformis pale, horrenda daemonum. Panduntur brains portae rotting inferi. Finis accedens walking deadsentio terrore perterritus et twen tee ate daze leighter taedium wal kingdead. The horror, monstra epidemic significant finem. Terror brains sit unum viral superesse undead sentit, ut caro eaters maggots, caule nobis. 11 | 12 | -------------------------------------------------------------------------------- /resources/public/assets/images/404.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domkm/omelette/cc77e572139e3fbd9f7d56d65c3d0d9ec9f43ad0/resources/public/assets/images/404.gif -------------------------------------------------------------------------------- /resources/public/assets/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | *{ 2 | font-family: 'Open Sans', sans-serif; 3 | } 4 | 5 | body{ 6 | background: #F6F6F6; 7 | } 8 | 9 | h1{ 10 | margin-bottom: 1em; 11 | } 12 | 13 | img{ 14 | max-width: 100%; 15 | margin-bottom: 1em; 16 | } 17 | 18 | .row{ 19 | margin-left: 5%; 20 | margin-bottom: 5%; 21 | } 22 | 23 | .left{ 24 | border-radius: 3px 3px 0 0; 25 | box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.08); 26 | background: white; 27 | } 28 | 29 | .right{ 30 | border-radius: 3px 3px 0 0; 31 | box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.08); 32 | background: white; 33 | } 34 | 35 | #query{ 36 | border-radius: 3px; 37 | } 38 | 39 | .search-options div{ 40 | display: inline-block; 41 | margin-right: 2em; 42 | margin-top: 2em; 43 | } 44 | 45 | .search-options label{ 46 | padding-left: 0.5em; 47 | } 48 | 49 | .search-results{ 50 | margin-top: 2em; 51 | } 52 | 53 | .search-results ul{ 54 | padding: 0; 55 | list-style: none; 56 | } 57 | 58 | .search-results li:nth-child(odd){ 59 | background-color: #e7e7e7; 60 | } -------------------------------------------------------------------------------- /src/omelette/data.clj: -------------------------------------------------------------------------------- 1 | (ns omelette.data 2 | (:require [clojure.java.io :as io] 3 | [clojure.string :as str])) 4 | 5 | (def ^:private words 6 | (with-open [file (-> "words.txt" io/resource io/reader)] 7 | (->> file 8 | line-seq 9 | (filter #(= (str/lower-case %) %)) 10 | doall))) 11 | 12 | (defn- compile-re [query options] 13 | (let [pre (when (:prefix options) 14 | (str "^" query)) 15 | in (when (:infix options) 16 | (str "^.+" query ".+$")) 17 | post (when (:postfix options) 18 | (str query "$"))] 19 | (->> [pre in post] 20 | (filter identity) 21 | (str/join "|") 22 | re-pattern))) 23 | 24 | (defn search 25 | "Takes a string and set. 26 | Set can `:prefix`, `:infix`, and `:postfix`. 27 | Returns words that have string in any of the positions specified in options." 28 | [query options] 29 | (->> words 30 | (filter #(re-find (compile-re query options) %)) 31 | (take 42))) 32 | 33 | (defn about 34 | "Returns raw markdown for the \"About\" page." 35 | [] 36 | (-> "about.md" io/resource slurp)) 37 | -------------------------------------------------------------------------------- /src/omelette/main.clj: -------------------------------------------------------------------------------- 1 | (ns omelette.main 2 | (:gen-class) 3 | (:require [com.stuartsierra.component :as component] 4 | [omelette.route :as route] 5 | [omelette.serve :as serve])) 6 | 7 | (defn system 8 | ([] (system nil)) 9 | ([port] (component/system-map 10 | :router (route/router) 11 | :server (component/using 12 | (serve/server port) 13 | [:router])))) 14 | 15 | (defn browse [system] 16 | (->> (get-in system [:server :port]) 17 | (str "http://localhost:") 18 | java.net.URI. 19 | (.browse (java.awt.Desktop/getDesktop)))) 20 | 21 | (defn -main [& _] 22 | (-> (system) 23 | component/start 24 | browse)) 25 | -------------------------------------------------------------------------------- /src/omelette/render.clj: -------------------------------------------------------------------------------- 1 | (ns omelette.render 2 | (:require [clojure.java.io :as io] 3 | [hiccup.page :refer [html5 include-css include-js]]) 4 | (:import [javax.script 5 | Invocable 6 | ScriptEngineManager])) 7 | 8 | (defn- render-fn* [] 9 | (let [js (doto (.getEngineByName (ScriptEngineManager.) "nashorn") 10 | ; React requires either "window" or "global" to be defined. 11 | (.eval "var global = this") 12 | (.eval (-> "public/assets/scripts/main.js" 13 | io/resource 14 | io/reader))) 15 | view (.eval js "omelette.view") 16 | render-to-string (fn [edn] 17 | (.invokeMethod 18 | ^Invocable js 19 | view 20 | "render_to_string" 21 | (-> edn 22 | list 23 | object-array)))] 24 | (fn render [title state-edn] 25 | (html5 26 | [:head 27 | [:meta {:charset "utf-8"}] 28 | [:meta {:http-equiv "X-UA-Compatible" :content "IE=edge,chrome=1"}] 29 | [:meta {:name "viewport" :content "width=device-width"}] 30 | [:title (str title " | Omelette")]] 31 | [:body 32 | [:noscript "If you're seeing this then you're probably a search engine."] 33 | (include-css "//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.1.1/css/bootstrap.css") 34 | (include-css "//fonts.googleapis.com/css?family=Open+Sans:300") 35 | (include-css "/assets/stylesheets/style.css") 36 | (include-js "/assets/scripts/main.js") 37 | ; Render view to HTML string and insert it where React will mount. 38 | [:div#omelette-app (render-to-string state-edn)] 39 | ; Serialize app state so client can initialize without making an additional request. 40 | [:script#omelette-state {:type "application/edn"} state-edn] 41 | ; Initialize client and pass in IDs of the app HTML and app EDN elements. 42 | [:script {:type "text/javascript"} "omelette.view.init('omelette-app', 'omelette-state')"]])))) 43 | 44 | (defn render-fn 45 | "Returns a function to render fully-formed HTML. 46 | (fn render [title app-state-edn])" 47 | [] 48 | (let [pool (ref (repeatedly 3 render-fn*))] 49 | (fn render [title state-edn] 50 | (let [rendr (dosync 51 | (let [f (first @pool)] 52 | (alter pool rest) 53 | f)) 54 | rendr (or rendr (render-fn*)) 55 | html (rendr title state-edn)] 56 | (dosync (alter pool conj rendr)) 57 | html)))) 58 | -------------------------------------------------------------------------------- /src/omelette/route.cljx: -------------------------------------------------------------------------------- 1 | (ns omelette.route 2 | (:require [clojure.string :as str] 3 | [taoensso.encore :as encore] 4 | [taoensso.sente :as sente] 5 | #+clj [clojure.core.match :refer [match]] 6 | #+clj [com.stuartsierra.component :as component] 7 | #+clj [compojure.core :as compojure] 8 | #+clj [compojure.route] 9 | #+clj [omelette.data :as data] 10 | #+clj [omelette.render :as render] 11 | #+cljs [cljs.core.async :as csp] 12 | #+cljs [cljs.core.match] 13 | #+cljs [goog.events] 14 | #+cljs [om.core :as om :include-macros true]) 15 | #+cljs 16 | (:require-macros [cljs.core.async.macros :as csp] 17 | [cljs.core.match.macros :refer [match]]) 18 | #+cljs 19 | (:import goog.history.EventType 20 | goog.history.Html5History)) 21 | 22 | (defn- encode-search-options 23 | "Takes an options set. 24 | Returns a path-segment string." 25 | [options] 26 | (->> [:prefix :infix :postfix] 27 | (map options) 28 | (remove nil?) 29 | (map name) 30 | (str/join "-"))) 31 | 32 | (defn- decode-search-options 33 | "Takes a path-segment string. 34 | Returns an options set." 35 | [string] 36 | (->> (str/split string #"-") 37 | (map keyword) 38 | set)) 39 | 40 | (def ^:private valid-options-str? 41 | #{"prefix" 42 | "infix" 43 | "postfix" 44 | "prefix-infix" 45 | "infix-postfix" 46 | "prefix-postfix" 47 | "prefix-infix-postfix"}) 48 | 49 | (defn- search-path->state 50 | "Takes query and options path-segments. 51 | Returns corresponding app state." 52 | [query options] 53 | (if (valid-options-str? options) 54 | [:omelette.page/search {:query query 55 | :options (decode-search-options options)}] 56 | [:omelette.page/not-found {}])) 57 | 58 | (defn path->state 59 | "Converts a path to an app state. 60 | (path->state \"/search/prefix/omelette\") 61 | => [:omelette.page/search {:query \"omelette\" :options #{:prefix}}]" 62 | [path] 63 | (let [default-search (search-path->state "omelette" "prefix-infix-postfix") 64 | path-segments (->> (-> path str/lower-case (str/split #"/")) 65 | (remove str/blank?) 66 | vec)] 67 | (match 68 | path-segments 69 | [] default-search 70 | ["search"] default-search 71 | ["search" options query] (search-path->state query options) 72 | ["about"] [:omelette.page/about {}] 73 | :else [:omelette.page/not-found {}]))) 74 | 75 | (defn state->path 76 | "Converts an app state to a path. 77 | (state->path [:omelette.page/search {:query \"omelette\" :options #{:prefix}}]) 78 | => \"/search/prefix/omelette\"" 79 | [[k data]] 80 | (let [page (name k)] 81 | (if (= page "search") 82 | (str "/search/" 83 | (-> data :options encode-search-options) 84 | "/" 85 | (:query data)) 86 | (str "/" page)))) 87 | 88 | (defn- search-state->title 89 | "Takes search page data map. 90 | Returns a string describing the search." 91 | [{:keys [query options]}] 92 | (let [opt-pairs {:prefix "start with" 93 | :infix "include" 94 | :postfix "end with"} 95 | [a b c :as opts] (->> (keys opt-pairs) 96 | (map (comp opt-pairs options)) 97 | (remove nil?)) 98 | opts-str (condp = (count opts) 99 | 1 a 100 | 2 (encore/format "%s or %s" a b) 101 | 3 (encore/format "%s, %s, or %s" a b c))] 102 | (encore/format "words that %s \"%s\"" 103 | opts-str 104 | query))) 105 | 106 | (defn state->title 107 | "Converts an app state to a title. 108 | (state->title [:omelette.page/search {:query \"omelette\" :options #{:prefix}}]) 109 | => \"words that begin with \"omelette\"\"" 110 | [[k data]] 111 | (let [page (name k)] 112 | (if (= page "search") 113 | (search-state->title data) 114 | (->> (str/split page #"-") 115 | (map str/capitalize) 116 | (str/join " "))))) 117 | 118 | #+clj 119 | (defn- handler-fn 120 | "Takes a Sente channel socket map. 121 | Returns an event handler function." 122 | [{:keys [send-fn]}] 123 | (fn handler 124 | [{{{uid :uid, :as session} :session, :as ring-req} :ring-req, 125 | [page data :as event] :event, 126 | ?reply-fn :?reply-fn} 127 | & [?recv]] 128 | (let [; Sente passes a dummy reply function if one is not provided. 129 | ; This usage of Sente is probably unusual since the handler is 130 | ; directly invoked below. Check if it's a dummy reply function and, 131 | ; if it is, reply by sending the new state back to the client. 132 | reply (if (-> ?reply-fn meta :dummy-reply-fn?) 133 | #(send-fn uid %) 134 | ?reply-fn)] 135 | (condp = (name page) 136 | "search" (->> data 137 | ((juxt :query :options)) 138 | (apply data/search) 139 | (assoc data :results) 140 | (vector page) 141 | reply) 142 | "about" (->> (data/about) 143 | (hash-map :markdown) 144 | (vector page) 145 | reply) 146 | "not-found" (reply event) 147 | (prn "Unmatched event: " event))))) 148 | 149 | #+clj 150 | (defn- wildcard-ring-route 151 | "Takes an event handler function. 152 | Returns a wildcard Ring route for server-side rendering." 153 | [handler] 154 | (let [render (render/render-fn)] 155 | (compojure/GET 156 | "*" 157 | {{uid :uid, :as session} :session, uri :uri, :as req} 158 | (let [state (handler {:?reply-fn identity ; `identity` returns the new state. 159 | :event (path->state uri) 160 | :ring-req req})] 161 | (assoc req 162 | ; Render HTML with title and state EDN. 163 | :body (render (state->title state) (pr-str state)) 164 | ; Clients must have a UID in order to receive messages. 165 | :session (assoc session :uid (or uid (java.util.UUID/randomUUID))) 166 | ; Use the state to determine the status. 167 | :status (if (-> state first name (= "not-found")) 168 | 404 169 | 200)))))) 170 | 171 | #+clj 172 | (defn- ring-routes 173 | "Takes an event handler function and Sente channel socket map. 174 | Returns Ring routes for Sente, static resources, and GET requests." 175 | [handler {:keys [ajax-post-fn ajax-get-or-ws-handshake-fn]}] 176 | (compojure/routes 177 | (compojure/POST "/chsk" req (ajax-post-fn req)) ; /chsk routes for Sente. 178 | (compojure/GET "/chsk" req (ajax-get-or-ws-handshake-fn req)) 179 | (compojure.route/resources "/") ; Serve static resources. 180 | (wildcard-ring-route handler))) 181 | 182 | #+clj 183 | (defrecord Router [] 184 | component/Lifecycle 185 | (start 186 | [component] 187 | (if (:stop! component) 188 | component 189 | (let [chsk (sente/make-channel-socket! {}) 190 | handler (handler-fn chsk) 191 | routes (ring-routes handler chsk) 192 | stop! (sente/start-chsk-router-loop! handler (:ch-recv chsk))] 193 | (assoc component 194 | :stop! stop! 195 | :ring-routes routes)))) 196 | (stop 197 | [component] 198 | (when-let [stop! (:stop! component)] 199 | (stop!)) 200 | (dissoc component :stop! :ring-routes))) 201 | 202 | #+clj 203 | (defn router 204 | "Creates a router component. 205 | Key :ring-routes should be used by an http-kit server." 206 | [] 207 | (map->Router {})) 208 | 209 | #+cljs 210 | (defn- start-history! 211 | "Takes an Om component. 212 | Initializes an Html5History object and adds it to the component local state." 213 | [owner] 214 | (let [history (doto (Html5History.) 215 | (.setUseFragment false) 216 | (.setPathPrefix "") 217 | (.setEnabled true))] 218 | ; Listen for navigation events that originate from the browser 219 | ; and update the app state based on the new path. 220 | (goog.events/listen 221 | history 222 | EventType.NAVIGATE 223 | (fn [event] 224 | (when (.-isNavigation event) 225 | (csp/put! (om/get-shared owner :nav-tokens) (.-token event))))) 226 | ; Add history to local state. 227 | (om/set-state! owner :history history))) 228 | 229 | #+cljs 230 | (defn- stop-history! 231 | "Takes an Om component with a history object. 232 | Disables the history object." 233 | [owner] 234 | (let [history (om/get-state owner :history)] 235 | ; Remove goog.events listeners from history object. 236 | (goog.events/removeAll history) 237 | ; Disable history object. 238 | (.setEnabled history false))) 239 | 240 | #+cljs 241 | (defn- start-nav-loop! 242 | "Takes a cursor and an Om component. 243 | Listens to shared :nav-tokens channel and updates cursor." 244 | [data owner] 245 | ; Update app state with state derived from navigation tokens. 246 | (->> (om/get-shared owner :nav-tokens) 247 | (csp/map< path->state ) 248 | (csp/reduce #(om/update! data [] %2 :nav) nil))) 249 | 250 | #+cljs 251 | (defn- handler-fn 252 | "Takes a cursor. 253 | Returns an event handler function that will update the cursor." 254 | [data] 255 | (fn handler [event _] 256 | (match 257 | event 258 | [:chsk/state {:first-open? true}] (println "Channel socket successfully established!") 259 | [:chsk/state chsk-state] (println "Chsk state change: " chsk-state) 260 | ; Events sent from the server have an ID of `:chsk/recv`. 261 | ; Update app state with the new state. 262 | ; This is a potential bug since events are not guaranteed to be sequential. 263 | [:chsk/recv state] (when (= (first state) 264 | (first @data)) 265 | (om/update! data state)) 266 | :else (prn "Unmatched event: " event)))) 267 | 268 | #+cljs 269 | (defn- start-router-loop! 270 | "Takes a cursor, an Om component, and a Sente channel socket map. 271 | Starts the channel socket router loop and adds `:stop!`, 272 | a function to stop the loop, to the component local state." 273 | [data owner {:keys [ch-recv]}] 274 | (->> ch-recv 275 | (sente/start-chsk-router-loop! (handler-fn data)) 276 | (om/set-state! owner :stop!))) 277 | 278 | #+cljs 279 | (defn- stop-router-loop! 280 | "Takes an Om component with a running router loop. 281 | Stops the router loop." 282 | [owner] 283 | ((om/get-state owner :stop!))) 284 | 285 | #+cljs 286 | (defn- update-history! 287 | "Takes an Om component with an Html5History object and a transaction. 288 | Updates history with the new state." 289 | [owner {:keys [new-state old-state]}] 290 | (let [history (om/get-state owner :history) 291 | new-path (state->path new-state)] 292 | (if-not (= (first old-state) 293 | (first new-state)) 294 | (.setToken history new-path) ; Set when page changes; 295 | (.replaceToken history new-path)))) ; replace otherwise. 296 | 297 | #+cljs 298 | (defn- update-title! 299 | "Takes a transaction. 300 | Updates `document` title with new state." 301 | [{:keys [new-state]}] 302 | (set! js/document.title 303 | (-> new-state state->title (str " | Omelette")))) 304 | 305 | #+cljs 306 | (defn- send-state! 307 | "Takes a timeout ID, a Sente channel socket map, and a transaction. 308 | Cancels the timeout and schedules a new app state request. 309 | Returns a new timeout ID." 310 | [timeout {:keys [send-fn]} {:keys [new-state]}] 311 | (js/clearTimeout timeout) 312 | (js/setTimeout #(send-fn new-state) 250)) 313 | 314 | #+cljs 315 | (defn- start-tx-loop! 316 | "Takes a cursor, Om component, and a Sente channel socket map. 317 | Starts a loop that uses transactions tagged `:nav` to: 318 | * update `document.title` 319 | * update `window.history` 320 | * schedule a request for a new app state" 321 | [data owner chsk] 322 | (let [txs (csp/sub (om/get-shared owner :transactions-pub) :nav (csp/chan))] 323 | (csp/go-loop 324 | [timeout nil 325 | tx (csp/ page name views) data)) 358 | 359 | #+cljs 360 | (defn router 361 | "Creates a router component. 362 | :page-views key in opts should be a map of page name to page views: 363 | {:page-views {\"about\" about-view 364 | \"not-found\" not-found-view}} 365 | Shared :nav-tokens should be a channel onto which other components should put relative paths when links are clicked. 366 | Shared :transactions-pub should be publication of transactions with :tag as the topic-fn." 367 | [data owner opts] 368 | (reify 369 | om/IRender 370 | (render [_] (build-page data opts)) 371 | om/IDidMount 372 | (did-mount [_] (start-router! data owner)) 373 | om/IWillUnmount 374 | (will-unmount [_] (stop-router! owner)))) 375 | -------------------------------------------------------------------------------- /src/omelette/serve.clj: -------------------------------------------------------------------------------- 1 | (ns omelette.serve 2 | (:require [com.stuartsierra.component :as component] 3 | [compojure.core :as compojure] 4 | [compojure.handler :as handler] 5 | [org.httpkit.server :as http-kit] 6 | [ring.middleware.anti-forgery :refer [wrap-anti-forgery]])) 7 | 8 | (defrecord Server [port] 9 | component/Lifecycle 10 | (start 11 | [component] 12 | (if (:stop! component) 13 | component 14 | (let [server 15 | (-> component 16 | :router 17 | :ring-routes 18 | (wrap-anti-forgery {:read-token (comp :csrf-token :params)}) 19 | handler/site 20 | (http-kit/run-server {:port (or port 0)})) 21 | port 22 | (-> server meta :local-port)] 23 | (println "Web server running on port " port) 24 | (assoc component :stop! server :port port)))) 25 | (stop 26 | [component] 27 | (when-let [stop! (:stop! component)] 28 | (stop! :timeout 250)) 29 | (dissoc component :stop! :router))) 30 | 31 | (defn server 32 | "Takes a port number. 33 | Returns an http-kit server component. 34 | Requires `(get-in this [:router :ring-routes])` to be a routes." 35 | [port] 36 | (map->Server {:port port})) 37 | -------------------------------------------------------------------------------- /src/omelette/view.cljs: -------------------------------------------------------------------------------- 1 | (ns omelette.view 2 | (:require [ankha.core :as ankha] 3 | [cljs.core.async :as csp] 4 | [cljs.reader :as edn] 5 | [clojure.string :as str] 6 | [goog.dom] 7 | [markdown.core :as md] 8 | [om.core :as om :include-macros true] 9 | [om.dom :as dom :include-macros true] 10 | [omelette.route :as route] 11 | [sablono.core :as html :refer-macros [html]]) 12 | (:require-macros [cljs.core.async.macros :as csp])) 13 | 14 | ; Print using Nashorn's `print` function if `console` is undefined. 15 | (if (exists? js/console) 16 | (enable-console-print!) 17 | (set-print-fn! js/print)) 18 | 19 | (defn- search-view-query [owner query] 20 | [:div.search-query 21 | {:on-change #(->> (-> % 22 | .-target 23 | .-value 24 | (str/replace #"\W|\d|_" "") 25 | str/lower-case) 26 | (om/set-state! owner :query))} 27 | (html/search-field "query" query)]) 28 | 29 | (defn- search-view-options [owner options] 30 | [:div.search-options 31 | (for [[option label] {:prefix "starts with" 32 | :infix "includes" 33 | :postfix "ends with"} 34 | :let [handler (fn [event] 35 | (let [new-opts ((if (-> event 36 | .-target 37 | .-checked) 38 | conj 39 | disj) 40 | options 41 | option)] 42 | (when (seq new-opts) 43 | (om/set-state! owner :options new-opts))))]] 44 | [:div 45 | {:on-change handler} 46 | (html/check-box (name option) (-> options option boolean)) 47 | (html/label (name option) label)])]) 48 | 49 | (defn search-view [data owner] 50 | (reify 51 | om/IInitState 52 | (init-state 53 | [_] 54 | (dissoc (om/value data) :results)) 55 | om/IWillUpdate 56 | (will-update 57 | [_ _ new-state] 58 | ; Update data if the current and new states are different. 59 | (when-not (= (om/get-render-state owner) 60 | new-state) 61 | ; Tag it with `:nav` so the router receives it. 62 | (om/update! data [] new-state :nav))) 63 | om/IRenderState 64 | (render-state 65 | [_ {:keys [query options]}] 66 | (html 67 | [:div 68 | [:form {:on-submit #(.preventDefault %)} 69 | (search-view-query owner query) 70 | (search-view-options owner options)] 71 | [:div.search-results 72 | (if-let [results (:results data)] 73 | (if (seq results) 74 | (html/unordered-list results) 75 | [:h3 "No Results"]) 76 | [:h3 "Loading..."])]])))) 77 | 78 | (defn about-view [data] 79 | (om/component 80 | (html 81 | [:div 82 | (when-let [markdown (:markdown data)] 83 | (->> markdown 84 | md/mdToHtml 85 | (hash-map :__html) 86 | (hash-map :dangerouslySetInnerHTML) 87 | (vector :div)))]))) 88 | 89 | (defn not-found-view [] 90 | (om/component 91 | (html 92 | (html/image "/assets/images/404.gif")))) 93 | 94 | (defn- app-view-nav [data owner] 95 | [:nav.navbar.navbar-default 96 | [:ul.nav.navbar-nav 97 | (for [[href content] {"/" "Search" 98 | "/about" "About" 99 | "/not-found" "Not Found"} 100 | :let [active? (= (-> content 101 | str/lower-case 102 | (str/replace #" " "-")) 103 | (-> data 104 | first 105 | name)) 106 | handler (fn [event] 107 | (.preventDefault event) 108 | (csp/put! (om/get-shared owner :nav-tokens) href))]] 109 | [:li (when active? {:class "active"}) 110 | [:a {:href href 111 | :on-click handler} 112 | content]])]]) 113 | 114 | (defn app-view [data owner] 115 | (om/component 116 | (html 117 | [:div.container-fluid 118 | (app-view-nav data owner) 119 | [:div.row 120 | [:div.col-xs-5.left 121 | [:h1 (route/state->title data)] 122 | (om/build route/router 123 | data 124 | {:opts {:page-views {"about" about-view 125 | "search" search-view 126 | "not-found" not-found-view}}})] 127 | [:div.col-xs-1] 128 | [:div.col-xs-5.right 129 | [:h1 "App State"] 130 | (om/build ankha/inspector data)]]]))) 131 | 132 | (declare app-container 133 | app-state) 134 | 135 | (defn render 136 | "Renders the app to the DOM. 137 | Can safely be called repeatedly to rerender the app."[] 138 | (let [transactions (csp/chan) 139 | transactions-pub (csp/pub transactions :tag)] 140 | (om/root app-view 141 | app-state 142 | {:target app-container 143 | :tx-listen #(csp/put! transactions %) 144 | :shared {:nav-tokens (csp/chan) 145 | :transactions transactions 146 | :transactions-pub transactions-pub}}))) 147 | 148 | (defn ^:export render-to-string 149 | "Takes an app state as EDN and returns the HTML for that state. 150 | It can be invoked from JS as `omelette.view.render_to_string(edn)`." 151 | [state-edn] 152 | (->> state-edn 153 | edn/read-string 154 | (om/build app-view) 155 | dom/render-to-str)) 156 | 157 | (defn ^:export init 158 | "Initializes the app. 159 | Should only be called once on page load. 160 | It can be invoked from JS as `omelette.view.init(appElementId, stateElementId)`." 161 | [app-id state-id] 162 | (->> state-id 163 | goog.dom/getElement 164 | .-textContent 165 | edn/read-string 166 | atom 167 | (set! app-state)) 168 | (->> app-id 169 | goog.dom/getElement 170 | (set! app-container)) 171 | (render)) 172 | --------------------------------------------------------------------------------