├── .gitignore ├── deps.edn ├── .github └── workflows │ └── clojars.yml ├── CHANGELOG.md ├── perf ├── huff │ └── perf.clj └── timings.md ├── src └── huff2 │ ├── extension.clj │ └── core.clj ├── bb.edn ├── test └── huff │ ├── extension_test.clj │ ├── core2_test.clj │ └── hiccup22_test.clj ├── readme.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .clj-kondo/.cache/v1/clj/cheshire.core.transit.json 2 | .clj-kondo/.cache/v1/lock 3 | .calva/ 4 | .clj-kondo/ 5 | .cpcache/ 6 | .lsp/ 7 | target/ -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {metosin/malli {:mvn/version "0.19.1"}} 3 | :aliases {:perf {:extra-paths ["perf" "perf_resources"] 4 | :extra-deps {hiccup/hiccup {:mvn/version "1.0.5"} 5 | com.lambdaisland/hiccup {:mvn/version "0.0.33"} 6 | com.taoensso/tufte {:mvn/version "2.6.3"} 7 | criterium/criterium {:mvn/version "0.4.6"}}} 8 | :build {:ns-default build 9 | :deps {io.github.seancorfield/build-clj {:git/tag "v0.8.3" :git/sha "7ac1f8d"} 10 | io.github.clojure/tools.build {:mvn/version "0.9.6"}}} 11 | :test {:extra-paths ["test"] 12 | :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd" :git/url "https://github.com/cognitect-labs/test-runner"}} 13 | :main-opts ["-m" "cognitect.test-runner"] 14 | :exec-fn cognitect.test-runner.api/test}}} 15 | -------------------------------------------------------------------------------- /.github/workflows/clojars.yml: -------------------------------------------------------------------------------- 1 | name: Clojure CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4.1.1 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Prepare java 23 | uses: actions/setup-java@v4.2.0 24 | with: 25 | java-version: '21' 26 | distribution: 'temurin' 27 | 28 | - name: Install clojure tools 29 | uses: DeLaGuardo/setup-clojure@12.5 30 | with: 31 | cli: latest 32 | bb: latest 33 | 34 | - name: Run tests 35 | run: bb tests 36 | 37 | - name: Build jar 38 | run: bb uber 39 | 40 | - name: Deploy to clojars 41 | env: 42 | CLOJARS_USERNAME: ${{ secrets.CLOJARS_USERNAME }} 43 | CLOJARS_PASSWORD: ${{ secrets.CLOJARS_PASSWORD }} 44 | run: bb deploy 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | 6 | 7 | ## [Unreleased] 8 | 9 | ### Fixed 10 | 11 | ### Changed 12 | - Started CHANGELOG.md 13 | 14 | ### Dependencies 15 | 16 | ## [0.2.21] 17 | 18 | ### Fixed 19 | - Extensions for component nodes now work ([#22](https://github.com/escherize/huff/pull/22)) 20 | 21 | ### Changed 22 | - Syntax for `emit` multimethod 2nd arg changed to match [malli's new parse output](https://github.com/metosin/malli#parsing-values). 23 | - Updated documentation and README (85f8c9b, 0fb9cc5) 24 | - Dropped support for `huff/core.clj` as it depended on malli 13's parsing format 25 | 26 | ### Dependencies 27 | - Bumped malli to version 1.9 ([#23](https://github.com/escherize/huff/pull/23), 9a40428) 28 | 29 | ## Previous Changes 30 | 31 | For changes prior to this changelog, please refer to the git commit history: 32 | ``` 33 | git log --oneline 34 | ``` 35 | -------------------------------------------------------------------------------- /perf/huff/perf.clj: -------------------------------------------------------------------------------- 1 | (ns huff.perf 2 | (:require 3 | [clojure.java.io :as io] 4 | [criterium.core :as bench] 5 | [hiccup.core :as hiccup] 6 | [huff.core :as h] 7 | [taoensso.tufte :as tufte :refer (defnp p profiled profile)])) 8 | 9 | ;; `page.edn` is wikipedia's list of common misconceptions passed through html2hiccup. 10 | 11 | ;; we support fragments: 12 | (defonce big-page (read-string (str "[:<> " (slurp (io/resource "big_page.edn")) "]"))) 13 | (defonce medium-page (read-string (str "[:<> " (slurp (io/resource "medium_page.edn")) "]"))) 14 | 15 | (defn print-bench [] 16 | (println "# Dynamic Performance Testing") 17 | (println "## Benching with big-page ([the wikipedia list of common misconceptions](https://en.wikipedia.org/wiki/List_of_common_misconceptions) as hiccup)") 18 | (println "### hiccup") 19 | (println "```") 20 | (bench/bench (hiccup/html (drop 1 big-page))) 21 | (println "```") 22 | (println "### huff") 23 | (println "```") 24 | (bench/bench (h/html big-page)) 25 | (println "```")(println)(println) 26 | 27 | (println "## Benching with medium_page ([a github Issue page](https://github.com/escherize/huff/issues/8) ~ 40kbp)") 28 | (println "### hiccup") 29 | (println "```") 30 | (bench/bench (hiccup/html (drop 1 medium-page))) 31 | (println "```") 32 | (println "### huff") 33 | (println "```") 34 | (bench/bench (h/html medium-page)) 35 | (println "```")) 36 | 37 | 38 | 39 | 40 | (comment 41 | (tufte/add-basic-println-handler! {}) 42 | (profile {} (h/html big-page))) 43 | -------------------------------------------------------------------------------- /src/huff2/extension.clj: -------------------------------------------------------------------------------- 1 | (ns huff2.extension 2 | (:require 3 | [clojure.walk :as walk] 4 | [huff2.core :as h] 5 | [malli.core :as m])) 6 | 7 | (defn custom-fxns! [hiccup-schema] 8 | {:*explainer (m/explainer hiccup-schema) 9 | :*parser (m/parser hiccup-schema)}) 10 | 11 | (defn- hiccup-branches? [element] 12 | (and (vector? element) 13 | (= :orn (first element)) 14 | (-> element second ::h/branches))) 15 | 16 | (defn add-schema-branch 17 | "[[tag-schema]] dictates what functions will be passed to the [[huff2.core/emit]] function which you must supply." 18 | ([hiccup-schema node-name] 19 | (add-schema-branch hiccup-schema node-name 20 | ;; This is the simplest form that a branch should have: 21 | ;; the arg vector for [[huff2.core/emit]] should then look like: 22 | ;; [append! [_tag [_tag values]]] 23 | [:cat [:= node-name] [:* :any]])) 24 | ([hiccup-schema node-name node-schema] 25 | (walk/postwalk 26 | (fn [element] 27 | ;; We want to add a branch to the hiccup branches. 28 | (if (hiccup-branches? element) 29 | (vec (concat 30 | (take 2 element) 31 | [[node-name node-schema]] 32 | (drop 2 element))) 33 | element)) 34 | hiccup-schema))) 35 | 36 | (comment 37 | 38 | ;; Simplest example: 39 | 40 | (def my-schema 41 | (add-schema-branch h/hiccup-schema :my/child-counter-tag)) 42 | 43 | ;;-(defmethod emit :fragment-node [append! [_ {:keys [children]}] opts] 44 | ;;+(defmethod emit :fragment-node [append! {{{:keys [children]} :values} :value} opts] 45 | 46 | (defmethod h/emit :my/child-counter-tag [append! {{:keys [values]} :value} _opts] 47 | (append! "I have " (count values) " children.")) 48 | 49 | ;; Call your function, integrated with hiccup: 50 | 51 | (h/html 52 | (custom-fxns! my-schema) 53 | [:div>h1 [:my/child-counter-tag "one" "two" "three"]]) 54 | ;; => "

I have 3 children.

" 55 | 56 | 57 | ) 58 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:deps {com.escherize/huff {:local/root "."}} 2 | :tasks 3 | {clean {:doc "Clean up" 4 | :task (do 5 | (println "Cleaning up...") 6 | (clojure "-T:build clean"))} 7 | uber {:doc "Build uberjar" 8 | :task (do 9 | (run 'clean) 10 | (println "Building uberjar...") 11 | (clojure "-T:build jar"))} 12 | 13 | deploy {:doc "Deploy to Clojars" 14 | :task (do 15 | (println "Deploying to clojars") 16 | (clojure "-T:build deploy"))} 17 | 18 | -bb-tests {:requires ([clojure.string :as str] 19 | [clojure.test :as t] 20 | [babashka.classpath :as cp] 21 | [babashka.fs :as fs]) 22 | :task (do 23 | (cp/add-classpath "src:test") 24 | (let [test-nss (for [test-file (fs/glob "test" "**.clj") 25 | :let [test-ns (-> test-file 26 | str 27 | (->> (re-matches (re-pattern "test/(.*).clj"))) 28 | second 29 | (str/replace "_" "-" ) 30 | (str/replace "/" ".") 31 | symbol)]] 32 | (do (require test-ns) test-ns)) 33 | results (apply t/run-tests test-nss)] 34 | (when (pos? (+ (:fail results) (:error results))) 35 | (System/exit 1))))} 36 | tests {:doc "Run tests in bb and Clj." 37 | :task (do 38 | (println "+---------------------------+") 39 | (println "| Running tests in Babashka |") 40 | (println "+---------------------------+") 41 | (run '-bb-tests) 42 | (println "+--------------------------+") 43 | (println "| Running tests in Clojure |") 44 | (println "+--------------------------+") 45 | (clojure "-M:test"))}}} 46 | -------------------------------------------------------------------------------- /perf/timings.md: -------------------------------------------------------------------------------- 1 | # Performance Testing 2 | ## Benching with big-page ([the wikipedia list of common misconceptions](https://en.wikipedia.org/wiki/List_of_common_misconceptions) as hiccup) 3 | ### hiccup 4 | ``` 5 | Evaluation count : 240 in 60 samples of 4 calls. 6 | Execution time mean : 295.702125 ms 7 | Execution time std-deviation : 5.093515 ms 8 | Execution time lower quantile : 285.061056 ms ( 2.5%) 9 | Execution time upper quantile : 306.661192 ms (97.5%) 10 | Overhead used : 7.091991 ns 11 | 12 | Found 13 outliers in 60 samples (21.6667 %) 13 | low-severe 3 (5.0000 %) 14 | low-mild 6 (10.0000 %) 15 | high-mild 1 (1.6667 %) 16 | high-severe 3 (5.0000 %) 17 | Variance from outliers : 6.2766 % Variance is slightly inflated by outliers 18 | ``` 19 | ### pre-compiled hiccup template 20 | ``` 21 | Evaluation count : 240 in 60 samples of 4 calls. 22 | Execution time mean : 294.781939 ms 23 | Execution time std-deviation : 4.182724 ms 24 | Execution time lower quantile : 291.247774 ms ( 2.5%) 25 | Execution time upper quantile : 302.174457 ms (97.5%) 26 | Overhead used : 7.091991 ns 27 | 28 | Found 2 outliers in 60 samples (3.3333 %) 29 | low-severe 1 (1.6667 %) 30 | low-mild 1 (1.6667 %) 31 | Variance from outliers : 1.6389 % Variance is slightly inflated by outliers 32 | ``` 33 | ### huff 34 | ``` 35 | Evaluation count : 300 in 60 samples of 5 calls. 36 | Execution time mean : 227.239306 ms 37 | Execution time std-deviation : 10.571710 ms 38 | Execution time lower quantile : 217.694693 ms ( 2.5%) 39 | Execution time upper quantile : 253.611046 ms (97.5%) 40 | Overhead used : 7.091991 ns 41 | 42 | Found 11 outliers in 60 samples (18.3333 %) 43 | low-severe 5 (8.3333 %) 44 | low-mild 6 (10.0000 %) 45 | Variance from outliers : 31.9964 % Variance is moderately inflated by outliers 46 | ``` 47 | 48 | 49 | ## Benching with medium_page ([a github Issue page](https://github.com/escherize/huff/issues/8) ~ 40kbp) 50 | ### hiccup 51 | ``` 52 | Evaluation count : 1320 in 60 samples of 22 calls. 53 | Execution time mean : 46.369788 ms 54 | Execution time std-deviation : 764.313065 µs 55 | Execution time lower quantile : 45.722580 ms ( 2.5%) 56 | Execution time upper quantile : 47.554377 ms (97.5%) 57 | Overhead used : 7.091991 ns 58 | 59 | Found 2 outliers in 60 samples (3.3333 %) 60 | low-severe 1 (1.6667 %) 61 | low-mild 1 (1.6667 %) 62 | Variance from outliers : 5.6508 % Variance is slightly inflated by outliers 63 | ``` 64 | ### pre-compiled hiccup template 65 | ``` 66 | Evaluation count : 1320 in 60 samples of 22 calls. 67 | Execution time mean : 47.383825 ms 68 | Execution time std-deviation : 1.103090 ms 69 | Execution time lower quantile : 46.392809 ms ( 2.5%) 70 | Execution time upper quantile : 49.398033 ms (97.5%) 71 | Overhead used : 7.091991 ns 72 | ``` 73 | ### huff 74 | ``` 75 | Evaluation count : 2340 in 60 samples of 39 calls. 76 | Execution time mean : 25.463353 ms 77 | Execution time std-deviation : 554.140695 µs 78 | Execution time lower quantile : 24.811835 ms ( 2.5%) 79 | Execution time upper quantile : 27.012070 ms (97.5%) 80 | Overhead used : 7.091991 ns 81 | 82 | Found 2 outliers in 60 samples (3.3333 %) 83 | low-severe 2 (3.3333 %) 84 | Variance from outliers : 9.4501 % Variance is slightly inflated by outliers 85 | ``` 86 | -------------------------------------------------------------------------------- /test/huff/extension_test.clj: -------------------------------------------------------------------------------- 1 | (ns huff.extension-test 2 | (:require 3 | [clojure.test :refer [deftest is testing]] 4 | [huff2.extension :as h2e] 5 | [huff2.core :as h])) 6 | 7 | (deftest simplest-extension 8 | (let [my-schema (h2e/add-schema-branch h/hiccup-schema :my/child-counter-tag) 9 | ;; emit gets passed: #malli.core.Tag{:key :my/child-counter-tag, 10 | ;; :value [:my/child-counter-tag ["one" "two" "three"]]} 11 | _add-emitter (defmethod h/emit :my/child-counter-tag [append! {[_tag values] :value} _opts] 12 | (is (= values ["one" "two" "three"])) 13 | (append! "I have " (count values) " children.")) 14 | my-html (partial h/html (h2e/custom-fxns! my-schema))] 15 | (is (= "

I have 3 children.

" 16 | (str (my-html [:div>h1 [:my/child-counter-tag "one" "two" "three"]])))))) 17 | 18 | (deftest eq-extension 19 | (let [my-schema (h2e/add-schema-branch h/hiccup-schema :=) 20 | _add-emitter (defmethod h/emit := [append! {[_ values] :value} _opts] 21 | (apply append! values)) 22 | my-html (partial h/html (h2e/custom-fxns! my-schema))] 23 | (is (= "

" 24 | (str (my-html [:div>h1 [:= ""]])))))) 25 | 26 | (deftest catn-named-values-in-schema 27 | (let [my-schema (h2e/add-schema-branch 28 | h/hiccup-schema 29 | :my/doubler-tag 30 | [:catn 31 | [:tag [:= :my/doubler-tag]] 32 | [:number :int]]) 33 | ;; emit gets passed: [:my/doubler-tag {:tag :my/doubler-tag, :number 3}] 34 | _add-emitter (defmethod h/emit 35 | :my/doubler-tag 36 | [append! 37 | {{{:keys [_ number] :as arg} :values} :value} 38 | _opts] 39 | (is (= {:tag :my/doubler-tag, :number 3} arg)) 40 | (append! (* 2 number))) 41 | custom-fxns (h2e/custom-fxns! my-schema) 42 | my-html (partial h/html custom-fxns)] 43 | ;; it knows what is not allowed: 44 | (is (= :malli.core/invalid ((:*parser custom-fxns) [:my/doubler-tag "G"]))) 45 | (is (= "

6

" (str (my-html [:div>h1 [:my/doubler-tag 3]])))))) 46 | 47 | (deftest adding-both-of-them 48 | (let [my-schema (-> h/hiccup-schema 49 | (h2e/add-schema-branch :my/child-counter-tag) 50 | (h2e/add-schema-branch :my/doubler-tag [:catn 51 | [:tag [:= :my/doubler-tag]] 52 | [:number :int]])) 53 | _add-emitter (defmethod h/emit :my/child-counter-tag 54 | [append! {[_tag values] :value} _opts] 55 | (append! "I have " (count values) " children.")) 56 | _add-emitter (defmethod h/emit :my/doubler-tag 57 | [append! {{{:keys [_ number]} :values} :value} _opts] 58 | (append! (* 2 number))) 59 | my-html (partial h/html (h2e/custom-fxns! my-schema))] 60 | (is (= "

I have 4 children.

20

I have 3 children.
" 61 | (str (my-html 62 | [:div 63 | [:>h1 [:my/child-counter-tag "one" "two" "three" "four"]] 64 | [:>h1 [:my/doubler-tag 10]] 65 | [(keyword "") [:my/child-counter-tag 66 | [:my/doubler-tag 3] 67 | [:my/doubler-tag 3] 68 | [:my/doubler-tag 3]]]])))))) 69 | 70 | (deftest extensions-apply-to-component-nodes 71 | (let [my-schema (h2e/add-schema-branch h/hiccup-schema :my/child-counter-tag) 72 | _add-emitter (defmethod h/emit :my/child-counter-tag [append! 73 | {[_tag values] :value} 74 | _opts] 75 | (is (= values ["one" "two" "three"])) 76 | (append! "I have " (count values) " children.")) 77 | my-html (partial h/html (h2e/custom-fxns! my-schema))] 78 | (testing "a component node that has hiccup that is only valid with the extension" 79 | (is (= 80 | "

I have 3 children.

" 81 | (str (my-html [(constantly [:div>h1 [:my/child-counter-tag "one" "two" "three"]])]))))))) 82 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # huff 2 | 3 | Hiccup in pure Clojure 4 | 5 | ## Usage 6 | 7 | [![Clojars Project](https://img.shields.io/clojars/v/io.github.escherize/huff.svg)](https://clojars.org/io.github.escherize/huff) 8 | 9 | ``` clojure 10 | (require '[huff2.core :as h]) 11 | ``` 12 | 13 | ## Rationale 14 | 15 | When it comes to hiccup libraries, there's a venn-diagram "has ergonomic and modern affordances" and "works on babashka". So [huff](https://github.com/escherize/huff) is my way of saying why not both? 16 | 17 | - [Weavejester's hiccup library](https://github.com/weavejester/hiccup) runs on babashka, but is missing some of the newer features hiccup afficianados have come to demand. 18 | 19 | - [Lambda Island's hiccup](https://github.com/lambdaisland/hiccup) also provides a modern api, but overall I'd still call it a subset of huff's features. 20 | 21 | ## Features 22 | 23 | - 🏭 Use [**functions** as **components**](#use-functons-as-components) 24 | - 🎨 Style maps work as you'd expect `[:div {:style {:font-size 30}}]` 25 | - 🔀 Include classes and ids tags [in any order](#tag-parsing) 26 | - `:div.a#id.b` or `:div.a.c#id` or `:#id.a.c` all work! 27 | - 🔒️ HTML-encoded by default 28 | - 👵 Runs on [babashka](https://github.com/babashka/babashka) (unlike `lambdaisland/hiccup`) 29 | - 🏎 Performance: 22-48% faster than hiccup/hiccup for runtime-generated HTML [without pre-compilation](https://github.com/escherize/huff/issues/8) 30 | - 🙂 Hiccup-style fragments to return multiple forms `(list [:li.a] [:li.b])` 31 | - 🙃 Reagent-style fragments to return multiple forms `[:<> [:li.a] [:li.b]]` 32 | - 🤐 Extreme shorthand syntax `[:. {:color :red}]` `
` 33 | - 🦺 Tested against slightly modified hiccup 2 tests 34 | - 🪗 [Extensible grammar](#extendable-grammar) 35 | - 📦 [raw-strings](https://github.com/escherize/huff/issues/5) 36 | 37 | ### Tag Parsing 38 | 39 | Parse tags for id and class (in any order). 40 | 41 | ```clojure 42 | (h/html [:div.hello#world "!"]) 43 | ;; =>
!
44 | ``` 45 | 46 | #### Nested tag parsing 47 | 48 | ```clojure 49 | (println (h/html [:div.left-aligned>p#user-parent>span {:id "user-name"} "Jason"])) 50 | 51 | ;=>

Jason

52 | ``` 53 | 54 | ### [reagent](https://github.com/reagent-project/reagent)-like fragment tags 55 | 56 | ```clojure 57 | (h/html [:<> [:div "d"] [:<> [:<> [:span "s"]]]]) 58 | ;; => 59 |
d
s 60 | ``` 61 | 62 | This is useful for returning multiple elements from a function: 63 | 64 | ```clojure 65 | (defn twins [x] [:<> 66 | [:div.a x] 67 | [:div.b x]]) 68 | 69 | (h/html [:span.parent [twins "elements"]]) 70 | ;;=> 71 | 72 |
elements
73 |
elements
74 |
75 | 76 | ``` 77 | 78 | Nest and combine them with lists to better convey intent to expand: 79 | 80 | ``` clojure 81 | (h/html 82 | [:ol 83 | [:<> (for [x [1 2]] 84 | [:li>p.green {:id (str "id-" x)} x])]]) 85 | 86 | ;;=> 87 |
    88 |
  1. 89 |

    1

    90 |
  2. 91 |
  3. 92 |

    2

    93 |
  4. 94 |
95 | 96 | ``` 97 | 98 | ### Style map rendering 99 | 100 | ```clojure 101 | (h/html [:div {:style {:border "1px red solid" 102 | :background-color "#ff00ff"}}]) 103 | ;; =>
104 | 105 | (h/html [:. {:style {:width (h/px 3)}}]) 106 | ;;=>
107 | ``` 108 | 109 | ### Raw HTML tag: 110 | 111 | This is nice if you want to e.g. render markdown in the middle of your hiccup. Note: We disable this by default and it must be manually enabled in the call to `html` or `page`, 112 | 113 | ``` clojure 114 | 115 | (h/html [:hiccup/raw-html "
raw
"]) 116 | ;; =Throws=> :hiccup/raw-html is not allowed. Maybe you meant to set allow-raw to true? 117 | 118 | (h/html {:allow-raw true} [:hiccup/raw-html "
raw
"]) 119 | ;;=>
raw
120 | ``` 121 | 122 | Another nice-to-have is to disallow raw html in un-trusted data getting passed into to the compiler, but being able to do that as a dev. 123 | 124 | ``` clojure 125 | (h/html [:div [:hiccup/raw-html (h/raw-string "