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