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 ""))) + :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 - "
    " - "

    todos

    " - "
    " - (when (pos? total) - (str "")) - "" - "
    " - "
    " - (when (pos? total) - (str "
    " - "" - "
    " - (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