From e78c4f6cc2f447ab7d90867ad18c1369dbd23ce6 Mon Sep 17 00:00:00 2001 From: Bryan Maass Date: Fri, 27 Feb 2026 20:06:03 -0700 Subject: [PATCH 1/4] Support keywords in :class vectors for Reagent compatibility Add (map stringify) before (remove str/blank?) in the class-merging logic of tag-node emit. Previously, keyword class values like [:flex :flex-auto] caused a ClassCastException because str/blank? expects CharSequence. Now keywords are stringified first, matching Reagent's behavior. --- src/huff2/core.clj | 1 + test/huff/core2_test.clj | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/huff2/core.clj b/src/huff2/core.clj index 4e3727e..ec0ef4e 100644 --- a/src/huff2/core.clj +++ b/src/huff2/core.clj @@ -215,6 +215,7 @@ (string? %) (concat [%] final-tag-classes) (coll? %) (concat % final-tag-classes) (nil? %) final-tag-classes) + (map stringify) (remove str/blank?)))) ;; attrs go on the last tag-info: tag-infos' (update tag-infos (dec (count tag-infos)) (fn [l] (conj (vec l) attrs)))] diff --git a/test/huff/core2_test.clj b/test/huff/core2_test.clj index b6a1cc1..70f1ddc 100644 --- a/test/huff/core2_test.clj +++ b/test/huff/core2_test.clj @@ -8,7 +8,11 @@ (is (= (h/html [:div.c1#id1.c2 {:class ["c3"] :style {:border "1px solid red"}} "x"]) (h/html [:div.c1#id1.c2 {:class "c3" :style {:border "1px solid red"}} "x"]))) (is (= (h/html [:div {:class nil}]) (h/html [:div]))) - (is (= (h/html [:div.a]) (h/html [:div {:class ["a" nil "" ""]}])))) + (is (= (h/html [:div.a]) (h/html [:div {:class ["a" nil "" ""]}]))) + (is (= "
" + (str (h/html [:div {:class [:flex :flex-auto]}])))) + (is (= (h/html [:div {:class ["flex" "flex-auto"]}]) + (h/html [:div {:class [:flex :flex-auto]}])))) (defn as-string [f] (let [sb (StringBuilder.) From 043700ec4787a35f71ae266467fd9bfadc379f50 Mon Sep 17 00:00:00 2001 From: Bryan Maass Date: Fri, 27 Feb 2026 20:07:21 -0700 Subject: [PATCH 2/4] Fix backslash escaping: remove \\ from char->replacement map The backslash entry mapped \\ to ' (single quote entity), which is wrong in two ways: it produced the wrong character, and backslashes don't need HTML escaping per the HTML5 spec. Matches hiccup behavior. Closes #31 --- src/huff2/core.clj | 2 +- test/huff/core2_test.clj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/huff2/core.clj b/src/huff2/core.clj index ec0ef4e..e1e6eac 100644 --- a/src/huff2/core.clj +++ b/src/huff2/core.clj @@ -100,7 +100,7 @@ (def ^:dynamic *escape?* true) -(def ^:private char->replacement {\& "&", \< "<", \> ">", \" """, \\ "'"}) +(def ^:private char->replacement {\& "&", \< "<", \> ">", \" """}) (defn maybe-escape-html "1. Change special characters into HTML character entities when *escape?* diff --git a/test/huff/core2_test.clj b/test/huff/core2_test.clj index 70f1ddc..a1fb787 100644 --- a/test/huff/core2_test.clj +++ b/test/huff/core2_test.clj @@ -90,8 +90,8 @@ (is (= "
<
" (str (h/html [:div "<"])))) (is (= "
>
" (str (h/html [:div ">"])))) (is (= "
"
" (str (h/html [:div "\""])))) - (is (= "
'
" (str (h/html [:div "\\"])))) - (is (= "
&
<
>
"
'
" + (is (= "
\\
" (str (h/html [:div "\\"])))) + (is (= "
&
<
>
"
\\
" (str (h/html [:<> [:div "&"] [:div "<"] From 887867bd0a89bba197350fb520ee4c279fe5fe0d Mon Sep 17 00:00:00 2001 From: Bryan Maass Date: Fri, 27 Feb 2026 20:15:04 -0700 Subject: [PATCH 3/4] Add :attr-mapper option for customizing attribute emission Thread an :attr-mapper function through opts to emit-attrs. When provided, each [key value] attribute pair is mapped through the function before emission, allowing arbitrary attribute transformations (e.g. keyword-to-string lookups, value normalization). Closes #30 --- src/huff2/core.clj | 20 +++++++++++--------- test/huff/core2_test.clj | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/huff2/core.clj b/src/huff2/core.clj index e1e6eac..2755099 100644 --- a/src/huff2/core.clj +++ b/src/huff2/core.clj @@ -159,8 +159,10 @@ (defn- tag->tag+id+classes [tag] (mapv (comp tag->tag+id+classes* keyword) (str/split (name tag) #">"))) -(defn- emit-attrs [append! attrs] - (doseq [[k value] attrs] +(defn- emit-attrs [append! attrs {:keys [attr-mapper]}] + (doseq [[k value] (if attr-mapper + (map attr-mapper attrs) + attrs)] (when-not (or (contains? #{"" nil false} value) (and (coll? value) (empty? value))) @@ -194,7 +196,7 @@ (append! "<") (append! ^String (name tag)) (when (or tag-id (not-empty tag-classes')) - (emit-attrs append! {:id tag-id :class tag-classes'})) + (emit-attrs append! {:id tag-id :class tag-classes'} opts)) (if (contains? void-tags (name tag)) (append! " />") (append! ">")))) @@ -223,8 +225,8 @@ (append! "<") (append! ^String (name tag)) (if attrs - (emit-attrs append! attrs) - (emit-attrs append! {:id tag-id :class (remove str/blank? tag-classes)})) + (emit-attrs append! attrs opts) + (emit-attrs append! {:id tag-id :class (remove str/blank? tag-classes)} opts)) (if (contains? void-tags (name tag)) (append! " />") (append! ">"))) @@ -270,16 +272,16 @@ Can I extend this by adding new types of nodes? Yes: see: [[huff2.extension]]!" ([h] (html {} h)) - ([{:keys [allow-raw *explainer *parser] :or {allow-raw false - *explainer explainer - *parser parser} :as _opts} h] + ([{:keys [allow-raw *explainer *parser attr-mapper] + :or {allow-raw false, *explainer explainer, *parser parser} + :as _opts} h] (let [parsed (*parser h)] (if (= parsed :malli.core/invalid) (let [{:keys [value]} (*explainer h)] (throw (ex-info "Invalid huff form passed to html. See [[hiccup-schema]] for more info" {:value value}))) (let [sb (StringBuilder.) append! (fn append! [& strings] (doseq [s strings :when s] (.append ^StringBuilder sb s)))] - (emit append! parsed {:allow-raw allow-raw :parser *parser}) + (emit append! parsed {:allow-raw allow-raw :parser *parser :attr-mapper attr-mapper}) (raw-string (str sb))))))) (defn page diff --git a/test/huff/core2_test.clj b/test/huff/core2_test.clj index a1fb787..96e37e1 100644 --- a/test/huff/core2_test.clj +++ b/test/huff/core2_test.clj @@ -77,10 +77,10 @@ (deftest attr-emission-test - (is (= " id=\"x\" class=\"x y\"" (as-string #(#'h/emit-attrs % {:id "x" :class ["x" "y"]})))) - (is (= " id=\"x\" class=\"x y\"" (as-string #(#'h/emit-attrs % {:id "x" :class "x y"})))) - (is (= " id=\"x\"" (as-string #(#'h/emit-attrs % {:id "x" :class []})))) - (is (= " x-data=\"{open: false}\"" (as-string #(#'h/emit-attrs % {:x-data "{open: false}"}))))) + (is (= " id=\"x\" class=\"x y\"" (as-string #(#'h/emit-attrs % {:id "x" :class ["x" "y"]} {})))) + (is (= " id=\"x\" class=\"x y\"" (as-string #(#'h/emit-attrs % {:id "x" :class "x y"} {})))) + (is (= " id=\"x\"" (as-string #(#'h/emit-attrs % {:id "x" :class []} {})))) + (is (= " x-data=\"{open: false}\"" (as-string #(#'h/emit-attrs % {:x-data "{open: false}"} {}))))) (deftest page-test (is (= (h/page {:allow-raw true} [:h1 "hi"]) "

hi

"))) @@ -184,3 +184,12 @@ (str (h/html [:div {:style {:width (-> 10 h/vmin)}}])))) (is (= "
" (str (h/html [:div {:style {:width (-> 10 h/vmax)}}]))))) + +(deftest attr-mapper-test + (is (= "
text
" + (str (h/html + {:attr-mapper (fn [[k v]] + [k (if (keyword? v) + (str/lower-case (name v)) + v)])} + [:div {:id "Capitalized"} [:span {:id :LoWerCaSEd} "text"]]))))) From db37b05138dad27377c2acd71148ba1275628db4 Mon Sep 17 00:00:00 2001 From: Bryan Maass Date: Fri, 27 Feb 2026 20:32:20 -0700 Subject: [PATCH 4/4] Make emit-attrs, emit-style, tag->tag+id+classes, and empty-or-div public These helpers are useful for extending the emit multimethod with custom node types. Changed defn- to defn and removed var-quotes from tests. Closes #29 --- src/huff2/core.clj | 8 ++++---- test/huff/core2_test.clj | 12 ++++++------ test/huff/hiccup22_test.clj | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/huff2/core.clj b/src/huff2/core.clj index 2755099..1d97dad 100644 --- a/src/huff2/core.clj +++ b/src/huff2/core.clj @@ -121,9 +121,9 @@ (defmethod emit :primative [append! {:keys [value]} _opts] (maybe-escape-html append! value)) -(defn- empty-or-div [seen] (if (empty? seen) "div" (str/join seen))) +(defn empty-or-div [seen] (if (empty? seen) "div" (str/join seen))) -(defn- emit-style [append! s] +(defn emit-style [append! s] (append! "style=\"") (cond (map? s) (doseq [[k v] (sort-by first s)] @@ -156,10 +156,10 @@ (step \.) ;; move "seen " into the right place (map [:tag :id :class]))) -(defn- tag->tag+id+classes [tag] +(defn tag->tag+id+classes [tag] (mapv (comp tag->tag+id+classes* keyword) (str/split (name tag) #">"))) -(defn- emit-attrs [append! attrs {:keys [attr-mapper]}] +(defn emit-attrs [append! attrs {:keys [attr-mapper]}] (doseq [[k value] (if attr-mapper (map attr-mapper attrs) attrs)] diff --git a/test/huff/core2_test.clj b/test/huff/core2_test.clj index 96e37e1..778a9ed 100644 --- a/test/huff/core2_test.clj +++ b/test/huff/core2_test.clj @@ -77,10 +77,10 @@ (deftest attr-emission-test - (is (= " id=\"x\" class=\"x y\"" (as-string #(#'h/emit-attrs % {:id "x" :class ["x" "y"]} {})))) - (is (= " id=\"x\" class=\"x y\"" (as-string #(#'h/emit-attrs % {:id "x" :class "x y"} {})))) - (is (= " id=\"x\"" (as-string #(#'h/emit-attrs % {:id "x" :class []} {})))) - (is (= " x-data=\"{open: false}\"" (as-string #(#'h/emit-attrs % {:x-data "{open: false}"} {}))))) + (is (= " id=\"x\" class=\"x y\"" (as-string #(h/emit-attrs % {:id "x" :class ["x" "y"]} {})))) + (is (= " id=\"x\" class=\"x y\"" (as-string #(h/emit-attrs % {:id "x" :class "x y"} {})))) + (is (= " id=\"x\"" (as-string #(h/emit-attrs % {:id "x" :class []} {})))) + (is (= " x-data=\"{open: false}\"" (as-string #(h/emit-attrs % {:x-data "{open: false}"} {}))))) (deftest page-test (is (= (h/page {:allow-raw true} [:h1 "hi"]) "

hi

"))) @@ -118,8 +118,8 @@ (str (h/html [:h1 {:style {:width (-> 20 h/px) :opacity 1}}]))))) (deftest dot-shortcut-for-div-test - (is (= (#'h/tag->tag+id+classes :.) - (#'h/tag->tag+id+classes :div)))) + (is (= (h/tag->tag+id+classes :.) + (h/tag->tag+id+classes :div)))) (deftest lists-work-for-spreading (is (= "
ok
" (str (h/html [:div '([:span "ok"])])))) diff --git a/test/huff/hiccup22_test.clj b/test/huff/hiccup22_test.clj index 4aea30e..c3b1e42 100644 --- a/test/huff/hiccup22_test.clj +++ b/test/huff/hiccup22_test.clj @@ -7,7 +7,7 @@ )) (deftest kw-tag-parsing - (are [x y] (= (#'h/tag->tag+id+classes x) y) + (are [x y] (= (h/tag->tag+id+classes x) y) :div [["div" nil []]] :div.a [["div" nil ["a"]]] :div.a#d [["div" "d" ["a"]]] @@ -64,7 +64,7 @@ (deftest kw-tag-validity (is (= "can't have 2 #'s in a tag." - (try (#'h/tag->tag+id+classes :div#id1#id2) + (try (h/tag->tag+id+classes :div#id1#id2) (catch Exception e (ex-message e)))))) (deftest tag-names