diff --git a/deps.edn b/deps.edn index 039e1d6..0cd1bcc 100644 --- a/deps.edn +++ b/deps.edn @@ -72,4 +72,8 @@ {:extra-deps {com.pitch/uix.core {:mvn/version "1.0.1"} com.pitch/uix.dom {:mvn/version "1.0.1"}}} + :dustingetz + {:extra-deps + {com.datomic/peer {:mvn/version "1.0.7075"} + com.hyperfiddle/hfql {:local/root "vendor/hfql"}}} }} diff --git a/src/dustingetz/electric_y_combinator.md b/src/dustingetz/electric_y_combinator.md new file mode 100644 index 0000000..303515f --- /dev/null +++ b/src/dustingetz/electric_y_combinator.md @@ -0,0 +1,59 @@ +# Electric Y Combinator — Electric Clojure + +by Dustin Getz, 2023 July 24 + +[Electric Clojure](https://github.com/hyperfiddle/electric/) is a new way to write reactive web apps in Clojure/Script which advertises **strong composition across the frontend/backend boundary**, i.e. network transparent composition (see [wikipedia: Network transparency](https://en.wikipedia.org/wiki/Network_transparency)). + +As a proof of strong composition, and a demonstration of how this is different from weaker forms of composition like React Server Components, we offer **distributed Y-combinator** and a demo of using it to recursively walk a server filesystem hierarchy and render a browser HTML frontend, in one pass. + +If this is your first time seeing Electric, start with the [live tutorial](https://electric.hyperfiddle.net/). + +### Figure 1: Fibonacci with Electric Y Combinator + +!fiddle-ns[](dustingetz.y-fib/Y-fib) + +What's happening +- Recursive fibonacci +- The recursion is traced to the dom by side effect +- There is **no self-recursion**: we use `Y` to inject recursion into `Fib` via higher order fn `Recur` rather than `Fib` calling itself directly by its name. +- As a reminder, Electric functions are called with `new` +- there is lexical closure, e.g. `Gen` and `F` inside `Y` definition + +Key ideas +- The **Y Combinator** (wikipedia: [Fixed-point combinator](https://en.wikipedia.org/wiki/Fixed-point_combinator)) captures the essence of recursive iteration in a simple lambda expression. ChatGPT can tell you more about this. +- Y works in Electric Clojure, demonstrating that Electric lambda *is* lambda. +- Note: you don't actually need `Y` for recursion, this is just a demo. + +Ok, lets try something harder: + +### Figure 2: Recursive walk over server file system, streamed to DOM + +!fiddle-ns[](dustingetz.y-dir/Y-dir) + +What's happening +- Recursive file system traversal over "./src" directory on the server +- there's a DOM text input that filters the tree, try typing "electric", both client and server refresh live +- **direct frontend/backend composition**: the tree walker, `Dir-tree`, interweaves `e/client` and `e/server` forms arbitrarily. e.g. L26 composes `dom/li` with `file-get-name`, the filename streams across the boundary in mid-flight. The network topology is complex, and irrelevant – Electric takes care of it. +- **automatic incremental/streaming network** (i.e. **streaming lexical scope**), not request/response. No dataloaders. +- **reactive recursion**: the DOM resources are reused across reaction frames, they are not recreated unnecessarily. + - I.e., when we type into the input, the DOM change is minimized + - this minimization is not implemented in electric-dom but rather emergent from the *evaluation model of the language* + - the actual "reactive stack frames" are reused + - the DOM resource lifecycle (mount/unmount) is simply bound to the lifecycle of the reactive frame they exist in! + +Key ideas +- **network-transparent composition**: the Electric functions transmit data over the network (as implied by the AST) in a way which is invisible to the application programmer +- **strong composition**: it's actual lambda, which means it scales with complexity. Higher order functions, closures, recursion, are the exact primitive you need to build rigorous abstractions that don't leak. + + +# Conclusion + +Electric Clojure's goal is to raise the abstraction ceiling in web development, by making it so that your application logic can be expressed out of nothing but lambda. And we really mean it. Remember "Functional Core Imperative Shell"? Where is the imperative shell here? + +Lambda is the difference between abstraction and boilerplate. I challenge you to take a hard look at your current web project, stare at whatever convoluted machine you have to deal with this week, and ask yourself: Why does this exist? What artificial problem is preventing this from being a 20 line expression? + +P.S. You'll notice in the URL that we've essentially URL-encoded an S-expression and routed to it. Why do you suppose we do that? ... *This essay is a function.* + +--- + +To learn more about Electric, check out the [live tutorial](https://electric.hyperfiddle.net/), the [github repo](https://github.com/hyperfiddle/electric/) or [clone the starter app](https://github.com/hyperfiddle/electric-starter-app) and start playing. \ No newline at end of file diff --git a/src/dustingetz/essay.cljc b/src/dustingetz/essay.cljc new file mode 100644 index 0000000..7393e12 --- /dev/null +++ b/src/dustingetz/essay.cljc @@ -0,0 +1,27 @@ +(ns dustingetz.essay + (:require clojure.string + [electric-fiddle.fiddle :refer [Fiddle-fn Fiddle-ns]] + [electric-fiddle.fiddle-markdown :refer [Custom-markdown]] + [electric-fiddle.index :refer [Index]] + [hyperfiddle :as hf] + [hyperfiddle.electric :as e] + [hyperfiddle.electric-dom2 :as dom] + [hyperfiddle.history :as history])) + +(def essays + {'electric-y-combinator "src-fiddles/dustingetz/electric_y_combinator.md" + 'hfql-intro "src-fiddles/dustingetz/hfql_intro.md" + 'hfql-teeshirt-orders "src-fiddles/dustingetz/hfql_teeshirt_orders.md"}) + +(e/def extensions + {'fiddle Fiddle-fn + 'fiddle-ns Fiddle-ns}) + +(e/defn Essay [& [?essay]] + #_(e/client (dom/div #_(dom/props {:class ""}))) ; fix css grid next + (e/client + (let [essay-filename (get essays ?essay)] + (cond + (nil? ?essay) (binding [hf/pages essays] (Index.)) + (nil? essay-filename) (dom/h1 (dom/text "Essay not found: " history/route)) + () (Custom-markdown. extensions essay-filename))))) diff --git a/src/dustingetz/fiddles.cljc b/src/dustingetz/fiddles.cljc new file mode 100644 index 0000000..b2cd048 --- /dev/null +++ b/src/dustingetz/fiddles.cljc @@ -0,0 +1,42 @@ +(ns dustingetz.fiddles + (:require [hyperfiddle.electric :as e] + [hyperfiddle :as hf] + [dustingetz.scratch.demo-explorer-hfql :refer [DirectoryExplorer-HFQL]] + [dustingetz.hfql-intro :refer [With-HFQL-Bindings + Teeshirt-orders-1 + Teeshirt-orders-2 + Teeshirt-orders-3 + Teeshirt-orders-4 + Teeshirt-orders-5]] + dustingetz.scratch + [dustingetz.y-fib :refer [Y-fib]] + [dustingetz.y-dir :refer [Y-dir]] + [dustingetz.essay :refer [Essay]] + [dustingetz.painter :refer [Painter]] + + [electric-fiddle.main] + #?(:clj models.teeshirt-orders-datomic) + )) + +(e/def fiddles + {`Y-fib Y-fib + `Y-dir Y-dir + `Essay (With-HFQL-Bindings. Essay) + `Teeshirt-orders-1 (With-HFQL-Bindings. Teeshirt-orders-1) + `Teeshirt-orders-2 (With-HFQL-Bindings. Teeshirt-orders-2) + `Teeshirt-orders-3 (With-HFQL-Bindings. Teeshirt-orders-3) + `Teeshirt-orders-4 (With-HFQL-Bindings. Teeshirt-orders-4) + `Teeshirt-orders-5 (With-HFQL-Bindings. Teeshirt-orders-5) + `DirectoryExplorer-HFQL (With-HFQL-Bindings. DirectoryExplorer-HFQL) + `Painter Painter + `dustingetz.scratch/Scratch dustingetz.scratch/Scratch}) + +#?(:clj + (models.teeshirt-orders-datomic/init-datomic)) + +(e/defn FiddleMain [ring-req] + (e/client + (binding [hf/pages fiddles] + (e/server + (electric-fiddle.main/Main. ring-req))))) + diff --git a/src/dustingetz/fly.toml b/src/dustingetz/fly.toml new file mode 100644 index 0000000..8c07a82 --- /dev/null +++ b/src/dustingetz/fly.toml @@ -0,0 +1,31 @@ +app = "electric-fiddle" +primary_region = "ewr" +kill_signal = "SIGINT" +kill_timeout = "5s" + +[experimental] + auto_rollback = true + +[[services]] + protocol = "tcp" + internal_port = 8080 + processes = ["app"] + + [[services.ports]] + port = 80 + handlers = ["http"] + force_https = true + + [[services.ports]] + port = 443 + handlers = ["tls", "http"] + [services.concurrency] + type = "connections" + hard_limit = 200 + soft_limit = 150 + + [[services.tcp_checks]] + interval = "15s" + timeout = "2s" + grace_period = "1s" + restart_limit = 0 diff --git a/src/dustingetz/hfql_intro.cljc b/src/dustingetz/hfql_intro.cljc new file mode 100644 index 0000000..3acded8 --- /dev/null +++ b/src/dustingetz/hfql_intro.cljc @@ -0,0 +1,121 @@ +(ns dustingetz.hfql-intro + (:require [clojure.spec.alpha :as s] + [contrib.electric-codemirror :refer [CodeMirror]] + contrib.str + #?(:clj [datomic.api :as d]) + [electric-fiddle.index :refer [Index]] + [hyperfiddle.api :as hf] + [hyperfiddle.electric :as e] + [hyperfiddle.electric-dom2 :as dom] + [hyperfiddle.hfql :refer [hfql]] + [hyperfiddle.hfql-tree-grid :refer [with-gridsheet-renderer]] + [hyperfiddle.rcf :as rcf :refer [tests tap % with]] + #?(:clj [models.teeshirt-orders-datomic :as model + :refer [orders genders shirt-sizes]]))) + +(e/defn Codemirror-edn [x] + (CodeMirror. {:parent dom/node :readonly true} identity contrib.str/pprint-str + x)) + +(e/defn Teeshirt-orders-1 [] + (Codemirror-edn. + (e/server + (hfql [hf/*$* hf/db] + {(orders "") + [:db/id + :order/email]})))) + +(e/defn Teeshirt-orders-2 [] + (with-gridsheet-renderer + (e/server + (hfql [hf/*$* hf/db] + {(orders .) + [:db/id + :order/email]})))) + +(e/defn Teeshirt-orders-3 [] + (with-gridsheet-renderer + (e/server + (hfql [hf/*$* hf/db] + {(orders .) + [:db/id + :order/email + :order/gender + :order/shirt-size]})))) + +(e/defn Teeshirt-orders-4 [] + #_ + (e/client + (dom/table + (e/server + (e/for-by :db/id [record (orders "")] + (let [{:keys [db/id + order/email + order/gender + order/shirt-size]} record] + (e/client + (dom/tr + (dom/td (dom/input id)) + (dom/td (dom/input email)) + (dom/td (dom/select + :value gender + :options (e/fn [filter] + (e/server (genders filter))))) + (dom/td (dom/select + :value shirt-size + :options (e/fn [filter] + (e/server (shirt-sizes gender filter))))))))))))) + +(e/defn Teeshirt-orders-5 [] + (with-gridsheet-renderer + (e/server + (hfql [hf/*$* hf/db] + {(orders .) + [:db/id + :order/email + :order/gender + :order/shirt-size]})))) + +(e/defn With-HFQL-Bindings [F & args] + (e/client + (e/fn [& args'] + (e/server + (binding [hf/db model/*$* ; hfql compiler + hf/*nav!* model/nav! ; hfql compiler + hf/*schema* model/-schema ; hfql gridsheet renderer + ] + (e/client + (e/apply F (concat args args')))))))) + +(e/defn Scratch [_] + (e/client + (dom/h1 (dom/text "hi")) + (dom/pre (dom/text (pr-str (e/server ((e/partial-dynamic [hf/*$* hf/db] #(orders ""))))))) + (dom/pre (dom/text (pr-str (e/server (str hf/db))))) + (dom/pre (dom/text (pr-str (e/server (hfql [hf/*$* hf/db] 42))))) + (dom/pre (dom/text (pr-str (e/server (hfql [hf/*$* hf/db] :db/id 1))))) + (with-gridsheet-renderer + (e/server (hfql [hf/*$* hf/db] :db/id 1))))) + +#?(:clj + (tests + (alter-var-root #'hf/*$* (constantly model/*$*)) + (some? hf/*$*) := true + (orders "") := [1 2 3] + + (with (e/run (tap (binding [hf/db hf/*$* + hf/*nav!* model/nav!] + (hfql [] :db/id 1)))) + % := 1) + + (with (e/run (tap (binding [hf/db hf/*$* + hf/*nav!* model/nav!] + (hfql [] + {(orders "") + [:db/id + :order/email + :order/gender]})))) + % := {`(orders "") + [{:db/id 1, :order/email "alice@example.com", :order/gender :order/female} + {:db/id 2, :order/email "bob@example.com", :order/gender :order/male} + {:db/id 3, :order/email "charlie@example.com", :order/gender :order/male}]}))) diff --git a/src/dustingetz/hfql_intro.md b/src/dustingetz/hfql_intro.md new file mode 100644 index 0000000..3ee8fe5 --- /dev/null +++ b/src/dustingetz/hfql_intro.md @@ -0,0 +1,11 @@ +# HFQL intro + +by Dustin Getz, 2023 July - wip + +!fiddle[](dustingetz.hfql-intro/Teeshirt-orders-1) + +!fiddle[](dustingetz.hfql-intro/Teeshirt-orders-2) + +!fiddle[](dustingetz.hfql-intro/Teeshirt-orders-3) + +* Fix router diff --git a/src/dustingetz/hfql_teeshirt_orders.md b/src/dustingetz/hfql_teeshirt_orders.md new file mode 100644 index 0000000..e308dc3 --- /dev/null +++ b/src/dustingetz/hfql_teeshirt_orders.md @@ -0,0 +1,28 @@ +# Teeshirt Orders – HFQL demo + +!fiddle-ns[](hfql-demo.hfql-teeshirt-orders/HFQL-teeshirt-orders) + + +What's happening +* there's a CRUD table, backed by a query +* the table is specified by 4 lines of HFQL + database schema + the clojure.spec for the query +* the filter input labeled `needle` is reflected from the clojure.spec on `orders`, which specifies that the `orders` query accepts a single `string?` parameter named `:needle` +* type "alice" into the input and see the query refresh live + +Novel forms +* `hf/hfql` +* `binding`: Electric dynamic scope, it's reactive and used for dependency injection +* `hf/db` +* `hf/*schema*` +* `hf/*nav!*` + +Key ideas +* HFQL's mission is to let you model CRUD apps in as few LOC as possible. +* HFQL generalizes graph-pull query notation into a declarative UI specification language. +* spec-driven UI +* macroexpands down to Electric +* network-transparent +* composes with Electric as e/defn +* scope + +Dependency injection with Electric bindings \ No newline at end of file diff --git a/src/dustingetz/painter.cljc b/src/dustingetz/painter.cljc new file mode 100644 index 0000000..4f197e2 --- /dev/null +++ b/src/dustingetz/painter.cljc @@ -0,0 +1,47 @@ +(ns dustingetz.painter + "video: https://gist.github.com/dustingetz/d58a6134be310e05307ca0b586c30947 +upstream: https://github.com/formicagreen/electric-clojure-painter" + (:require [hyperfiddle.electric :as e] + [hyperfiddle.electric-dom2 :as dom])) + +(def emojis ["🕉" "🧬" "🧿" "🌀" "♻️" "🐍" "🐱" "🫥" "🌰" "🐞" "🐹" "🪙" "🕸" "📞"]) +#?(:cljs (def mousedown (atom false))) +#?(:cljs (defonce current-emoji (atom "🐱"))) +#?(:clj (defonce vertices (atom []))) + +(e/defn Painter [] + (e/client + (dom/style {:margin "0" :overflow "hidden" :background "lightblue" :user-select "none" :font-size "30px"}) + (dom/element "style" (dom/text "@keyframes fadeout { from { opacity: 1; } to { opacity: 0; } }")) + (dom/div + (dom/style {:width "100vw" :height "100vh"}) + (dom/on "mousedown" (e/fn [e] (reset! mousedown true))) + (dom/on "mouseup" (e/fn [e] (reset! mousedown false))) + + (dom/on "mousemove" + (e/fn [e] (let [x (.-clientX e) y (.-clientY e) + m (e/watch mousedown) + e (e/watch current-emoji)] + (when m + (e/server + (swap! vertices conj [x y e])))))) + + (dom/div + (dom/style {:background "#fff5" :backdrop-filter "blur(10px)" :position "fixed" + :top "0" :left "0" :height "100vh" :padding "10px"}) + (e/for-by identity [emoji emojis] + (dom/div (dom/text emoji) + (dom/style {:cursor "pointer"}) + (dom/on "click" (e/fn [e] (reset! current-emoji emoji))))) + (dom/div (dom/text "🗑️") + (dom/style {:cursor "pointer" :padding-top "50px"}) + (dom/on "click" (e/fn [e] (e/server (reset! vertices [])))))) + + (dom/div + (e/for-by identity [[x y e] (e/server (e/watch vertices))] + (dom/div (dom/text e) + (dom/style {:width "10px" :height "10px" + :position "absolute" + :left (str x "px") + :top (str y "px") + :user-select "none" :z-index "-1" :pointer-events "none"}))))))) \ No newline at end of file diff --git a/src/dustingetz/scratch.cljc b/src/dustingetz/scratch.cljc new file mode 100644 index 0000000..db6a53f --- /dev/null +++ b/src/dustingetz/scratch.cljc @@ -0,0 +1,7 @@ +(ns dustingetz.scratch + (:require [hyperfiddle.electric :as e] + [hyperfiddle.electric-dom2 :as dom])) + + +(e/defn Scratch [] + (e/client (dom/pre (dom/text "yo")))) diff --git a/src/dustingetz/y_dir.cljc b/src/dustingetz/y_dir.cljc new file mode 100644 index 0000000..20afa62 --- /dev/null +++ b/src/dustingetz/y_dir.cljc @@ -0,0 +1,47 @@ +(ns dustingetz.y-dir + (:require [contrib.str :refer [includes-str?]] + [hyperfiddle.electric :as e] + [hyperfiddle.electric-dom2 :as dom] + [hyperfiddle.electric-ui4 :as ui] + dustingetz.y-fib)) + +#?(:clj (defn file-is-dir [h] (.isDirectory h))) +#?(:clj (defn file-is-file [h] (.isFile h))) +#?(:clj (defn file-list-files [h] (.listFiles h))) +#?(:clj (defn file-get-name [h] (.getName h))) +#?(:clj (defn file-absolute-path [^String path-str & more] + (-> (java.nio.file.Path/of ^String path-str (into-array String more)) + .toAbsolutePath str))) + +(e/defn Y [Gen] + (new (e/fn [F] (F. F)) + (e/fn F [F] + (Gen. (e/fn Recur [x] + (new (F. F) x)))))) + +(e/defn Dir-tree [Recur] + (e/server + (e/fn [[h s]] + (cond + (file-is-dir h) + (e/client + (dom/li (dom/text (e/server (file-get-name h))) + (dom/ul + (e/server + (e/for [x (file-list-files h)] + (Recur. [x s])))))) ; recur + + (file-is-file h) + (when (includes-str? (file-get-name h) s) + (let [name_ (e/server (file-get-name h))] + (e/client (dom/li (dom/text name_))))))))) + +(e/defn Y-dir [] + (e/client + (dom/div + (let [!s (atom "") s (e/watch !s)] + (ui/input s (e/fn [v] (reset! !s v))) + (dom/ul + (e/server + (let [h (clojure.java.io/file (file-absolute-path "./src"))] + (new (Y. Dir-tree) [h s])))))))) diff --git a/src/dustingetz/y_fib.cljc b/src/dustingetz/y_fib.cljc new file mode 100644 index 0000000..b15ad82 --- /dev/null +++ b/src/dustingetz/y_fib.cljc @@ -0,0 +1,23 @@ +(ns dustingetz.y-fib + (:require [hyperfiddle.electric :as e] + [hyperfiddle.electric-dom2 :as dom])) + +(e/defn Y [Gen] + (new (e/fn [F] (F. F)) ; call-with-self + (e/fn F [F] + (Gen. (e/fn Recur [x] + (new (F. F) x)))))) + +(e/defn Trace [x] + (e/client (dom/div (dom/text x))) + x) + +(e/defn Fib [Recur] + (e/fn [x] + (Trace. + (case x + 0 1 + (* x (Recur. (dec x))))))) + +(e/defn Y-fib [] + (new (Y. Fib) 15)) \ No newline at end of file