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:
68
example/todomvc/pocketbook/hiccup.cljs
Normal file
68
example/todomvc/pocketbook/hiccup.cljs
Normal 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 "<" "<")
|
||||||
|
(str/replace ">" ">")
|
||||||
|
(str/replace "\"" """)))
|
||||||
|
|
||||||
|
(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))))
|
||||||
@@ -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 "&" "&")
|
|
||||||
(str/replace "<" "<")
|
|
||||||
(str/replace ">" ">")
|
|
||||||
(str/replace "\"" """)))
|
|
||||||
|
|
||||||
(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) "\">×</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
|
||||||
|
|||||||
Reference in New Issue
Block a user