refactor: use hiccup instead of string concatenation in todomvc

Replace hand-built HTML strings with hiccup vectors rendered by a
minimal hiccup→HTML converter (example/todomvc/pocketbook/hiccup.cljs).
Supports CSS-style selectors (:div.class#id), attribute maps, void
elements, and nested children.
This commit is contained in:
Florian Schroedl
2026-04-16 19:51:41 +02:00
parent 86b54e1291
commit c971988ce9
2 changed files with 122 additions and 63 deletions

View File

@@ -0,0 +1,68 @@
(ns pocketbook.hiccup
"Minimal hiccup → HTML string renderer."
(:require [clojure.string :as str]))
(def ^:private void-tags
#{"area" "base" "br" "col" "embed" "hr" "img" "input"
"link" "meta" "param" "source" "track" "wbr"})
(defn- esc [s]
(-> (str s)
(str/replace "&" "&")
(str/replace "<" "&lt;")
(str/replace ">" "&gt;")
(str/replace "\"" "&quot;")))
(defn- render-attrs [m]
(let [sb (js/Array.)]
(doseq [[k v] m]
(when (and (some? v) (not (false? v)))
(.push sb " ")
(.push sb (name k))
(when-not (true? v)
(.push sb "=\"")
(.push sb (esc (str v)))
(.push sb "\""))))
(.join sb "")))
(defn- parse-tag
"Parse :div.foo.bar#baz → [\"div\" \"foo bar\" \"baz\"]."
[tag]
(let [full (name tag)
id (second (re-find #"#([^.#]+)" full))
no-id (str/replace full #"#[^.#]+" "")
parts (str/split no-id #"\.")
tag-name (if (seq (first parts)) (first parts) "div")
classes (str/join " " (rest parts))]
[tag-name (when (seq classes) classes) id]))
(deftype RawHTML [s])
(defn raw
"Wrap a string to be emitted without HTML escaping."
[s]
(RawHTML. s))
(defn html
"Convert a hiccup form to an HTML string."
[form]
(cond
(nil? form) ""
(instance? RawHTML form) (.-s form)
(string? form) (esc form)
(number? form) (str form)
(seq? form) (apply str (map html form))
(vector? form)
(let [[tag & rest] form
[tag-name tag-classes tag-id] (parse-tag tag)
[attrs children] (if (map? (first rest))
[(first rest) (next rest)]
[nil rest])
attrs (cond-> (or attrs {})
tag-id (assoc :id tag-id)
tag-classes (update :class #(str tag-classes (when % (str " " %)))))
inner (apply str (map html children))]
(if (contains? void-tags tag-name)
(str "<" tag-name (render-attrs attrs) " />")
(str "<" tag-name (render-attrs attrs) ">" inner "</" tag-name ">")))
:else (esc (str form))))

View File

@@ -2,6 +2,7 @@
"TodoMVC built on Pocketbook — offline-first, synced, Clojure-native." "TodoMVC built on Pocketbook — offline-first, synced, Clojure-native."
(:require [pocketbook.core :as pb] (:require [pocketbook.core :as pb]
[pocketbook.store.idb :as idb] [pocketbook.store.idb :as idb]
[pocketbook.hiccup :refer [html]]
[cljs.core.async :refer [go <!]] [cljs.core.async :refer [go <!]]
[clojure.string :as str])) [clojure.string :as str]))
@@ -83,60 +84,51 @@
;; Rendering ;; Rendering
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defn- esc [s]
(-> (str s)
(str/replace "&" "&amp;")
(str/replace "<" "&lt;")
(str/replace ">" "&gt;")
(str/replace "\"" "&quot;")))
(defn- render-todo-item [[id doc]] (defn- render-todo-item [[id doc]]
(let [editing? (= id @!editing) (let [editing? (= id @!editing)]
classes (str (when (:completed doc) " completed") [:li {:class (str "todo-item"
(when editing? " editing"))] (when (:completed doc) " completed")
(str "<li class=\"todo-item" classes "\" data-id=\"" (esc id) "\">" (when editing? " editing"))
"<div class=\"view\">" :data-id id}
"<button class=\"toggle\" data-action=\"toggle\" data-id=\"" (esc id) "\">" [:div.view
(if (:completed doc) "◉" "○") [:button {:class "toggle" :data-action "toggle" :data-id id}
"</button>" (if (:completed doc) "◉" "○")]
"<label class=\"todo-label\" data-action=\"edit-start\" data-id=\"" (esc id) "\">" [:label {:class "todo-label" :data-action "edit-start" :data-id id}
(esc (:text doc)) (:text doc)]
"</label>" [:button {:class "destroy" :data-action "destroy" :data-id id} "×"]]
"<button class=\"destroy\" data-action=\"destroy\" data-id=\"" (esc id) "\">&times;</button>" (when editing?
"</div>" [:input {:class "edit-input" :data-action "edit-input"
(when editing? :data-id id :value (:text doc)}])]))
(str "<input class=\"edit-input\" data-action=\"edit-input\" data-id=\"" (esc id) "\""
" value=\"" (esc (:text doc)) "\" />")) (defn- filter-btn [label filter-kw current]
"</li>"))) [:button {:class (str "filter-btn" (when (= filter-kw current) " selected"))
:data-action "filter" :data-filter (name filter-kw)}
label])
(defn- render-footer [active-count total-count] (defn- render-footer [active-count total-count]
(let [current @!filter] (let [current @!filter]
(str "<footer class=\"app-footer\">" [:footer.app-footer
"<span class=\"todo-count\">" [:span.todo-count [:strong active-count] " "
"<strong>" active-count "</strong> " (if (= 1 active-count) "item" "items") " left"]
(if (= 1 active-count) "item" "items") " left" [:nav.filters
"</span>" (filter-btn "All" :all current)
"<nav class=\"filters\">" (filter-btn "Active" :active current)
"<button class=\"filter-btn" (when (= :all current) " selected") "\" data-action=\"filter\" data-filter=\"all\">All</button>" (filter-btn "Completed" :completed current)]
"<button class=\"filter-btn" (when (= :active current) " selected") "\" data-action=\"filter\" data-filter=\"active\">Active</button>" (when (pos? (- total-count active-count))
"<button class=\"filter-btn" (when (= :completed current) " selected") "\" data-action=\"filter\" data-filter=\"completed\">Completed</button>" [:button {:class "clear-completed" :data-action "clear-completed"}
"</nav>" "Clear completed"])]))
(when (pos? (- total-count active-count))
"<button class=\"clear-completed\" data-action=\"clear-completed\">Clear completed</button>")
"</footer>")))
(defn- render-sync-status [] (defn- render-sync-status []
(let [pending (when @!todos (pb/pending-count @!todos)) (let [pending (when @!todos (pb/pending-count @!todos))
online? (.-onLine js/navigator)] online? (.-onLine js/navigator)]
(str "<div class=\"sync-bar\">" [:div.sync-bar
"<span class=\"sync-dot " (if online? "online" "offline") "\"></span>" [:span {:class (str "sync-dot " (if online? "online" "offline"))}]
"<span class=\"sync-text\">" [:span.sync-text
(cond (cond
(not online?) "Offline — changes saved locally" (not online?) "Offline — changes saved locally"
(and pending (pos? pending)) (str "Syncing " pending " change" (when (> pending 1) "s") "…") (and pending (pos? pending)) (str "Syncing " pending " change"
:else "Synced") (when (> pending 1) "s") "…")
"</span>" :else "Synced")]]))
"</div>")))
(defn- render! [] (defn- render! []
(let [container (js/document.getElementById "app") (let [container (js/document.getElementById "app")
@@ -145,23 +137,22 @@
active (count (active-todos))] active (count (active-todos))]
(when container (when container
(set! (.-innerHTML container) (set! (.-innerHTML container)
(str (html
"<header class=\"app-header\">" [:div
"<h1>todos</h1>" [:header.app-header
"<div class=\"input-row\">" [:h1 "todos"]
(when (pos? total) [:div.input-row
(str "<button class=\"toggle-all" (when (all-completed?) " checked") "\" data-action=\"toggle-all\"></button>")) (when (pos? total)
"<input id=\"new-todo\" class=\"new-todo\" placeholder=\"What needs to be done?\" autofocus />" [:button {:class (str "toggle-all" (when (all-completed?) " checked"))
"</div>" :data-action "toggle-all"} ""])
"</header>" [:input {:id "new-todo" :class "new-todo"
(when (pos? total) :placeholder "What needs to be done?" :autofocus true}]]]
(str "<section class=\"main\">" (when (pos? total)
"<ul class=\"todo-list\">" [:section.main
(apply str (map render-todo-item todos)) [:ul.todo-list
"</ul>" (map render-todo-item todos)]
"</section>" (render-footer active total)])
(render-footer active total))) (render-sync-status)])))))
(render-sync-status))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Event delegation ;; Event delegation