diff --git a/project.clj b/project.clj index e81c06ff..51a918f0 100644 --- a/project.clj +++ b/project.clj @@ -96,6 +96,7 @@ [org.clojure/core.async]] [hiccup "2.0.0-alpha2"] + [selmer "1.12.61"] ;; needed for java 11 [javax.xml.bind/jaxb-api "2.4.0-b180830.0359"] diff --git a/resources/templates/interop-smoke.html b/resources/templates/interop-smoke.html new file mode 100644 index 00000000..deac9901 --- /dev/null +++ b/resources/templates/interop-smoke.html @@ -0,0 +1,7 @@ +
+

{{ title }}

+ {# a Hiccup-rendered component, passed in pre-rendered and emitted verbatim #} + {{ hiccup_frag|safe }} + +
diff --git a/src/clj/auto_ap/ssr/selmer.clj b/src/clj/auto_ap/ssr/selmer.clj new file mode 100644 index 00000000..b96315e8 --- /dev/null +++ b/src/clj/auto_ap/ssr/selmer.clj @@ -0,0 +1,43 @@ +(ns auto-ap.ssr.selmer + "Selmer rendering + the Hiccup<->Selmer interop bridge for the SSR form/wizard + migration (see .claude/skills/ssr-form-migration). Interactive, attribute-heavy + components render from Selmer templates with plain-HTML Alpine/HTMX attributes; + the bridge lets a Selmer template embed Hiccup output and lets a Selmer fragment + sit inside a Hiccup tree during the strangler transition. + + Templates live under resources/templates/ and are referenced by classpath-relative + path, e.g. (render \"templates/components/typeahead.html\" ctx)." + (:require + [hiccup.util :as hu] + [hiccup2.core :as h2] + [selmer.parser :as selmer])) + +(defn hiccup->html + "Render a Hiccup form to an HTML string so it can be embedded in a Selmer + context value and emitted with the |safe filter: {{ frag|safe }}." + [hiccup] + (str (h2/html {} hiccup))) + +(defn raw + "Wrap an already-rendered HTML string (e.g. from `render`) so hiccup2 emits it + verbatim instead of escaping it. Use to drop a Selmer fragment into a Hiccup tree: + [:div (sel/raw (sel/render \"...\" ctx))]." + [^String html] + (hu/raw-string html)) + +(defn render + "Render a Selmer template file (classpath-relative path) with `ctx`, returning an + HTML string. Hiccup values in `ctx` should be pre-rendered via `hiccup->html` and + referenced with |safe in the template." + [template ctx] + (selmer/render-file template ctx)) + +(defn render-str + "Render a Selmer template given as a string (handy for tests/REPL)." + [template ctx] + (selmer/render template ctx)) + +(defn render->hiccup + "Render a Selmer template file and wrap the result for safe embedding in Hiccup." + [template ctx] + (raw (render template ctx))) diff --git a/test/clj/auto_ap/ssr/selmer_test.clj b/test/clj/auto_ap/ssr/selmer_test.clj new file mode 100644 index 00000000..1f0790d8 --- /dev/null +++ b/test/clj/auto_ap/ssr/selmer_test.clj @@ -0,0 +1,36 @@ +(ns auto-ap.ssr.selmer-test + (:require + [auto-ap.ssr.selmer :as sut] + [clojure.string :as str] + [clojure.test :refer [deftest is testing]] + [hiccup2.core :as h2])) + +(deftest hiccup->html + (testing "renders a Hiccup form to an HTML string" + (is (= "A & B" + (sut/hiccup->html [:span.label "A & B"]))))) + +(deftest selmer-embeds-hiccup + (testing "a Hiccup component renders inside a Selmer template via |safe" + (let [frag (sut/hiccup->html [:span.badge "from hiccup"]) + out (sut/render-str "
{{frag|safe}}
" {:frag frag})] + (is (str/includes? out "from hiccup")) + ;; without |safe the markup would be escaped; |safe keeps it verbatim + (is (not (str/includes? out "<span")))))) + +(deftest selmer-fragment-inside-hiccup + (testing "a Selmer fragment renders inside a Hiccup tree without double-escaping" + (let [sel (sut/render-str "{{label}}" {:url "/x" :label "Go"}) + out (str (h2/html {} [:div (sut/raw sel)]))] + (is (= "
Go
" out))))) + +(deftest render-file-from-classpath + (testing "render-file resolves a template under resources/templates and keeps plain-HTML Alpine/HTMX attrs" + (let [out (sut/render "templates/interop-smoke.html" + {:title "Interop OK" + :hiccup_frag (sut/hiccup->html [:span.badge "from hiccup"])})] + (is (str/includes? out "Interop OK")) + (is (str/includes? out "from hiccup")) + ;; plain-HTML attributes (the whole point of Selmer) survive unambiguously + (is (str/includes? out "x-model=\"value.value\"")) + (is (str/includes? out "tippy?.show()")))))