Scittlecastr: All You Need is Persistence

This post was entirely written by a human, with no assistance from LLMs or other "AI" technology such as pervert glasses or autonomous weapon systems.
Last time on Scittlecastr: The Scittling, we scittled up a podcast player, using the awesome power of the ClojureScript REPL.
What we found great about this approach was the ability to create HTML elements on the fly and update the page without having to reload, but at the end of our session, we were struck by the fear that if we do reload, we will lose all the state of our running program. Gritting our teeth, we test our hypothesis...

đđđ
The Buddha spake wisely when he claimed that life is a vale of tears. However, we have achieved satori through Scittle, so we discard our fragile ego and expand our consciousness to encompass all things, and discover wisdom in the infinite.
This wisdom was that of having done our interactive development in a Rich comment, thus allowing us build our interface back up by evaluating our player.cljs buffer, then stepping through the Rich comment, evaluating each form one by one (you can use cider-eval-last-sexp (C-c C-e) for this, since you don&apost need the comment inserted again). Let&aposs rebuild our initial ugly player, using our slick new functions.
Make sure to evaluate the entire buffer after a page reload (C-c C-k), or you&aposll get an error similar to this the first time you try to evaluate a form in the REPL:
(comment
;; ...
(get-el "body")
;; => "#error {:message \"Unable to resolve symbol: get-el\",
;; :data {:type :sci/error, :line 1, :column 1, ...}}
)
Having evaluated the buffer, let&aposs get ugly!
(comment
;; ...
(set-children! "body"
[(create-el {:tag "h1"
:classes ["cljcastr"]
:text "cljcastr player"})
(create-el {:tag "audio"
:classes ["cljcastr"]
:attrs {:controls true
:src "https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3"}})])
;; => #object[HTMLBodyElement [object HTMLBodyElement]]
)
That is quite a pain, though. Is there a way to effectively export the running program as HTML so we could save it to our index.html? Well, let&aposs give it a go.
What we&aposd like is to turn the body DOM node into an HTML fragment. We can actually already do this in a clunky way. We log the element:
(comment
;; ...
(-> (get-el "body")
log)
;; => #object[HTMLBodyElement [object HTMLBodyElement]]
)
and then look at the console:

Unfortunately, we can&apost directly use the console output in the REPL. We could of course just copy and paste it from the console window, but that feels too efficient, and we&aposd rather spend some time shaving those yaks, so let&aposs think about how we might generate our own HTML from the DOM tree anchored at the body element.
First, we need to do a depth-first traversal of the children of body. This is quite easy for the time being, since body only has children and no grandchildren or great-grandchildren or whatever. Let&aposs try to count those children:
(comment
;; ...
(def children (-> (get-children "body")
log))
;; => #&aposcljcastr.player/children
(count children)
;; => 3
)
Wait, what&aposs going on here? The JS console actually holds the key. If we look in the console at the previously logged line and expand it, we see:
⌠(2) [h1.cljcastr, audio.cljcastr, protonpass-root-64f9] âč nrepl.cljs:10
⣠0: h1.cljcastr
⣠1: audio.cljcastr
⣠2: protonpass-root-64f9
length: 2
⣠[[Prototype]]: Array(0)
Looks like my Proton Pass extension is adding an element to the body. đŹ
But luckily, we added the cljcastr class to all our elements, so we can use a CSS
selector to limit the children to the ones we actually put there ourselves:
(comment
;; ...
(def children (-> (js/document.querySelectorAll "body > .cljcastr")
seq
log))
;; => #&aposcljcastr.player/children
(count children)
;; => 2
)
Excellent! Now let&aposs see what the element names of the children are:
(comment
;; ...
(->> children
(map get-name)
log)
;; => ("h1" "audio")
)
And their attributes?
(comment
;; ...
(->> children
(map #(.-attributes %))
log)
;; => (#object[NamedNodeMap [object NamedNodeMap]]
;; #object[NamedNodeMap [object NamedNodeMap]])
)
Ah yes, thank you for not using a POJSO (Plain &aposOl JavaScript Object, ofc) there. I guess we&aposll need some sort of function to turn a NamedNodeMap into a Clojure map. No matter, we can write some simple Clojure to fix this:
(comment
;; ...
(->> children
(map (fn [el]
(let [attrs (.-attributes el)]
(->> (range (.-length attrs))
(map #(let [attr (.item attrs %)]
[(-> (.-name attr) keyword) (.-value attr)]))
(into {})))))
log)
;; => ({:class "cljcastr"}
;; {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"})
)
Um. This is... yeah. I think it&aposs time to write a few functions to decomplect this mess. Let&aposs head back up to the top of the file and add a few little functions:
(ns cljcastr.player)
;; ...
;; (defn get-attr ...)
(defn attr->clj
"Given an entry in an attribute map (i.e. `Attr` in a `NamedNodeMap` that is
the value of the `.-attributes` property of an element), returns a vector of
the name (as a keyword) and value of the attribute."
[attr]
[(-> (.-name attr) keyword) (.-value attr)])
(defn get-attrs
"Returns the attributes of an element as a map of attribute names (as keywords)
to attribute values."
[el]
(let [el (get-el el)
attrs (.-attributes el)]
(->> (range (.-length attrs))
(map #(->> % (.item attrs) attr->clj))
(into {}))))
;; (defn set-attr! ...)
;; ...
(comment
;; ...
(->> children
(map get-attrs)
log)
;; => ({:class "cljcastr"}
;; {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"})
)
Now that we know how to get both the name of the tag and its attributes, let&aposs combine them into a function that we can use to map over a list of elements and transform them into a nice Clojurey data structure. Climbing into our hammock ever so briefly, we sketch out the following:
{:tag "{{tag-name}}"
:attrs {:k1 "{{v1}}"
:k2 "{{v2}}"}}
Given this data structure and the functions we&aposve already written, it&aposs not too difficult to write the code:
(comment
;; ...
(->> children
(map (fn [el]
{:tag (get-name el)
:attrs (get-attrs el)}))
log)
;; => ({:tag "h1", :attrs {:class "cljcastr"}}
;; {:tag "audio",
;; :attrs {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"}})
)
The only thing missing from this is the inner text for the h1 element. Let&aposs add a :text key to our map for elements that have text:
(comment
;; ...
(->> children
(map (fn [el]
(let [text (get-text el)]
(merge {:tag (get-name el)
:attrs (get-attrs el)}
(when-not (empty? text)
{:text text})))))
log)
;; => ({:tag "h1", :attrs {:class "cljcastr"}, :text "cljcastr player"}
;; {:tag "audio",
;; :attrs {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"}})
)
Let&aposs turn this into a nice function, and whilst we&aposre at it, add all of the keys that create-el takes. We also want to handle the case where the function is called with a Clojure representation of an element (i.e. a map), in which case it should just return the map:
(ns cljcastr.player)
;; ...
;; (defn add-style! ...)
(defn el->clj
"Given an HTML element, returns a map containing :tag, :id, :classes, :styles,
:text, and :attrs."
[el]
(if (map? el)
el
(let [el (get-el el)]
(->> {:tag (get-name el)
:id (get-id el)
:classes (get-classes el)
:styles (get-styles el)
:text (get-text el)
:attrs (get-attrs el)}
(remove (fn [[_ v]] (empty? v)))
(into {})))))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> children
(map el->clj)
log)
;; => ({:tag "h1",
;; :classes ("cljcastr"),
;; :text "cljcastr player",
;; :attrs {:class "cljcastr"}}
;; {:tag "audio",
;; :classes ("cljcastr"),
;; :attrs {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"}})
)
I wonder if we can roundtrip an element to a map back to an element?
(comment
;; ...
(->> (create-el {:tag "button"
:id "button-play"
:classes ["cljcastr" "playback-button"]
:styles {:padding "0px"
:margin "0px"
:border "0px"
:background "transparent"
:cursor "pointer"}
:attrs {:aria-label "Play"}})
el->clj
create-el
log)
;; => #object[HTMLButtonElement [object HTMLButtonElement]]
)
Looking good! From this, it&aposs pretty straightforward to turn this into an HTML string:
(comment
;; ...
(->> children
(map el->clj)
(map (fn [{:keys [tag attrs text]}]
(let [attrs-str (if (empty? attrs)
""
(str " "
(->> attrs
(map (fn [[k v]] (str (name k) "=\"" v "\"")))
(str/join " "))))]
(str "<" tag attrs-str ">" text "</" tag ">"))))
(str/join "\n")
log)
;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
;; <audio controls=\"true\"
;; src=\"https://media.zencastr.com/audio-files/...\"
;; class=\"cljcastr\"></audio>"
)
That looks pretty reasonable, so let&aposs extract a few functions:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn el->clj ...)
(defn attrs->str
"Given a map of attributes, returns a string of k=\"v\"..."
[attrs]
(->> attrs
(map (fn [[k v]] (str (name k) "=\"" v "\"")))
(str/join " ")))
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
[el]
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)]
(str "<" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">"
text
"</" tag ">")))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> children
(map el->html)
(str/join "\n")
log)
;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
;; <audio controls=\"true\"
;; src=\"https://media.zencastr.com/audio-files/...\"
;; class=\"cljcastr\"></audio>"
)
Just as we&aposre about to paste our HTML into index.html, a thought occurs to us. We wrote some mucky DOM-tacular JS-interop code to create elements, but now we have an elegant Clojure data structure to describe our elements, and we can turn that into HTML, so why not into DOM nodes as well?
Well, given the way we&aposve built up our functions for dealing with the DOM, it turns out we can do this easily!
(comment
;; ...
(-> (create-el {:tag "h1", :attrs {:class "cljcastr"}, :text "cljcastr player"})
log)
;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]
)
Looking at the browser console, we&aposre convinced we&aposre onto something:
<h1 class="cljcastr">cljcastr player</h1> nrepl.cljs:10
Looks great! Now that we have a nicer way to build up elements interactively, as well as a way to dehydrate elements into HTML, let&aposs think about what it would take to dehydrate the entire DOM tree rooted at our body tag.
Exporting HTML
We pretty much have everything we need:
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map el->html)
(str/join "\n")
log)
;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
;; <audio controls=\"true\"
;; src=\"https://media.zencastr.com/audio-files/...\"
;; class=\"cljcastr\"></audio>"
)
This looks great, so let&aposs build the full podcast player we had before back up in our REPL and try dehydrating that into HTML. Might as well reload the page to make sure we&aposre starting out with a pristine REPL. Remember to eval the buffer (C-c C-k) after reloading!
To rebuild our player, we could just step through the entire Rich comment from the beginning, evaluating each form one by one, but this sounds like a lot of work! Let&aposs add a new add-attrs! function and another adornment to our create-el function to make life easier on ourselves:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn set-attrs! ...)
;; ...
(defn add-attrs! [el attrs]
(let [el (get-el el)]
(set-attrs! el (merge (get-attrs el) attrs))))
;; (defn get-id ...)
;; ...
;; (defn el->html ...)
(defn create-el [{:keys [tag id classes styles text attrs children] :as args}]
(-> (js/document.createElement tag)
(set-attrs! attrs)
(set-id! id)
(set-classes! classes)
(set-styles! styles)
(set-children! children)
(set-text! text)))
;; (defn add-event-listener! ...)
;; ...
(comment
;; ...
(->> (create-el {:tag "div"
:id "episode-container"
:classes ["cljcastr"]
:styles {:width "100%"
:display "flex"
:flex-direction "column"
:align-items "center"}
:children
[
;; episode-info
(create-el {:tag "div"
:id "episode-info"
:classes ["cljcastr"]
:styles {:max-width "420px"
:text-align "center"}
:children
[(create-el {:tag "h3"
:id "podcast-title"
:classes ["cljcastr"]
:text "defn"})
(create-el {:tag "h2"
:id "episode-title"
:classes ["cljcastr"]
:text "876b4c306172 Cora Sutton"})
(create-el {:tag "span"
:id "episode-date"
:classes ["cljcastr"]
:styles {:color "grey"}
:text "July 9, 2025"})]})
;;episode-art
(create-el {:tag "div"
:id "episode-art-container"
:classes ["cljcastr"]
:children
[(create-el {:tag "img"
:id "episode-art"
:classes ["cljcastr"]
:styles {:width "400px"
:border-radius "5%"
:padding "10px"}
:attrs {:src "https://media.zencastr.com/image-files/5d9048546bacd9001de88f1c/a5b922ac-7294-4d58-9a33-4308f5d4f50b.png"}})]})
;;audio
(-> (create-el {:tag "audio"
:classes ["cljcastr"]
:attrs {:controls true
:src "https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3"}
:styles {:display "none"}})
(add-event-listener! "timeupdate"
(fn [ev]
(let [audio (.-target ev)]
(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
{:elapsed-time "#elapsed-time"
:remaining-time "#remaining-time"
:progress-slider "#progress-slider"
:progress-bar "#progress-bar"})))))
;;progress
(create-el {:tag "div"
:id "progress"
:classes ["cljcastr"]
:styles {:width "390px"
:margin "1.5em auto 0 auto"}
:children
[(create-el {:tag "span"
:text (secs->ts 0)
:classes ["cljcastr"]
:id "elapsed-time"
:styles {:margin "0 5px 0 5px"
:font-size "0.7rem"
:color "grey"
:float "left"}})
(create-el {:tag "span"
:text (secs->ts 0)
:classes ["cljcastr"]
:id "remaining-time"
:styles {:margin "0 5px 0 5px"
:font-size "0.7rem"
:color "grey"
:float "right"}})
(create-el {:tag "div"
:id "progress-container"
:classes ["cljcastr"]
:styles {:position "relative"
:height "28px"
:clear "both"}
:children
[(create-el {:tag "progress"
:id "progress-bar"
:classes ["cljcastr"]
:attrs {:min 0
:max 0
:value 0}
:styles {:appearance "none"
:height "8px"
:width "100%"
:border "0"
:border-radius "4px"
:margin "0"
:overflow "hidden"
:background-color "lightgray"
:color "#f7931e"
:position "absolute"
:top "10px"
:left "0px"
:z-index 0}})
(create-el {:tag "input"
:id "progress-slider"
:classes ["cljcastr"]
:attrs {:type "range"
:min 0
:max 0
:value 0
:step "any"
:aria-label "Seek"
:aria-valuetext (str (secs->ts 0)
" of "
(secs->ts 0))
:aria-valuemax 0}
:styles {:appearance "none"
:cursor "pointer"
:height "28px"
:width "100%"
:border "0"
:margin "0"
:touch-action "manipulation"
:position "absolute"
:top "0px"
:left "0px"
:z-index 1
:background-color "transparent"}})]})]})
;;playback-controls
(create-el {:tag "div"
:id "playback-controls"
:classes ["cljcastr"]
:children
(->> [:backward :play :pause :forward :speed]
(map (fn [button-name]
[button-name (get playback-controls button-name)]))
(map (fn [[button-name button]]
(let [button-el (create-button button-name button
{:seek-dur-sec seek-dur-sec
:button-class "playback-button"
:img-class "playback-icon"})]
(-> (get-children button-el)
first
(set-styles! {:width "35px"
:filter (str "invert(59%) sepia(92%) saturate(611%) "
"hue-rotate(343deg) brightness(97%) contrast(99%)")}))
button-el))) log)})]})
log
list
(set-children! "body"))
;; => #object[HTMLBodyElement [object HTMLBodyElement]]
(let [audio (get-el "audio.cljcastr")]
(set-text! "#elapsed-time" (secs->ts (.-currentTime audio)))
(set-text! "#remaining-time" (secs->ts (- (.-duration audio)
(.-currentTime audio))))
(add-attrs! "#progress-bar" {:max (.-duration audio)
:value (.-currentTime audio)})
(add-attrs! "#progress-slider" {:max (.-duration audio)
:value (.-currentTime audio)
:aria-valuetext (str (secs->ts (.-currentTime audio))
" of "
(secs->ts (.-duration audio)))
:aria-valuemax (.-duration audio)}))
;; => #object[HTMLInputElement [object HTMLInputElement]]
)
Much like Lazarus, our podcast player hath now been resurrected! And also like Lazarus, the method of resurrection required divine powers. But fear not! for this shall be the last time we rely on the devine to rebuild our player.
Let&aposs get back to dehydration, shall we? We can use the same method as before, when we had a really simple player:
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map el->html)
(str/join "\n")
log)
;; => "<div id=\"episode-container\" class=\"cljcastr\"
;; style=\"width: 100%; display: flex; flex-direction: column;
;; align-items: center;\">
;; defn876b4c306172 Cora SuttonJuly 9, 202500:001:29:30</div>"
)
Hrm, that&aposs not very helpful. First of all, we&aposre only getting the direct children of our body element, which in this case is a single child: the episode-container div. What we&aposd actually like is the entire tree of elements rooted at body.
Let&aposs see if we can figure out how to do that.
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map (fn [el]
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el)]
(if (seq children)
(str/join "\n"
(concat [tag-open]
(map el->html children)
[tag-close]))
(str tag-open text tag-close)))))
(str/join "\n")
log)
;; => "<div id=\"episode-container\" class=\"cljcastr\" style=\"...\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"...\">defn876b4c306172 Cora SuttonJuly 9, 2025</div>\n
;; <div id=\"episode-art-container\" class=\"cljcastr\"></div>\n
;; <audio controls=\"true\" src=\"https://media.zencastr.com/audio-files/...\" class=\"cljcastr\" style=\"display: none;\"></audio>\n
;; <div id=\"progress\" class=\"cljcastr\" style=\"...\">00:37-1:28:53</div>\n
;; <div id=\"playback-controls\" class=\"cljcastr\"></div>\n
;; </div>"
)
OK, this is progress, but we&aposre only grabbing the first level of children. In order to include grandchildren and great-grandchildren and great-great-grandchildren (and so on to the last syllable of recorded time), we&aposll need to include our new code in el->html itself:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
[el]
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el)]
(if (seq children)
(str/join "\n"
(concat [tag-open]
(map el->html children)
[tag-close]))
(str tag-open text tag-close))))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map el->html)
(str/join "\n")
log)
;; => "#error {:message \"Failed to execute &aposquerySelector&apos on &aposDocument&apos:
;; &apos[object Text]&apos is not a valid selector.\",
;; ...}"
)
Well, that doesn&apost look promising. Let&aposs see if we can figure this out.
Looking back out at the HTML we generated in previously, we get a hint about which element might be the culprit:
;; => "<div id=\"episode-container\" class=\"cljcastr\" style=\"...\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"...\">defn876b4c306172 Cora SuttonJuly 9, 2025</div>\n
Our #episode-info div looks a bit odd, doesn&apost it. Let&aposs take a closer look:
(comment
;; ...
(get-children "#episode-info")
;; => (#object[HTMLHeadingElement [object HTMLHeadingElement]]
;; #object[HTMLHeadingElement [object HTMLHeadingElement]]
;; #object[HTMLSpanElement [object HTMLSpanElement]])
)
OK, so #episode-info has three children: two headings and a span. Let&aposs feed the first child to el->html and see what happens:
(comment
;; ...
(-> (get-children "#episode-info")
first
el->html)
;; => "#error {:message \"Failed to execute &aposquerySelector&apos on &aposDocument&apos:
;; &apos[object Text]&apos is not a valid selector.\",
;; ...}"
(-> (get-children "#episode-info")
first
get-children
log)
;; => (#object[Text [object Text]])
)
Ah yes, there it is. So there seems to be something special about a Text node that causes el->html to blow up. Let&aposs see what we can actually do with this type of object. Reading the documentation, we see that it inherits from the CharacterData class, which has a handy data attribute that is "string representing the textual data contained in this object". Let&aposs try that out:
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
log)
;; => #object[Text [object Text]]
(-> (get-children "#episode-info")
first
get-children
first
(.-data))
;; => "defn"
)
Armed with this knowledge, we should be able to fix get-text to handle Text nodes:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn get-name ...)
(defn get-text
"Returns the inner text of an element."
[el]
(let [el (get-el el)]
(if (instance? js/CharacterData el)
(.-data el)
(or (.-textContent el)
(.-innerText el)))))
;; (defn set-text! ...)
;; ...
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
get-text)
;; => "#error {:message \"Failed to execute &aposquerySelector&apos on &aposDocument&apos:
;; &apos[object Text]&apos is not a valid selector.\",
;; ...}"
)
Hrm, we&aposre back to this. Let&aposs trace through get-text line by line to see if we can figure out what&aposs going on:
(let [el (get-el el)]
...)
So we&aposd be calling (get-el) with our Text node. Let&aposs try that and see what happens:
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
get-el)
;; => "#error {:message \"Failed to execute &aposquerySelector&apos on &aposDocument&apos:
;; &apos[object Text]&apos is not a valid selector.\",
;; ...}"
)
Aha, so the problem is in get-el, which looks like this:
(defn get-el [selector]
(if (instance? js/Element selector)
selector
(js/document.querySelector selector)))
What we&aposre trying to do here is just return the selector arg if we&aposre passed an element, otherwise assume selector is a string and use Document.querySelector to look up the element. But wait, why should we assume it&aposs a string? Why not actually check? In fact, why not actually write the function in the same way we would describe it in English?
If
selectoris a string, returns the element matching CSS selector. Otherwise, assume it&aposs already an element and just return it.
And whilst we&aposre at it, why not update the docstring of the function to say exactly this?
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn secs->ts ...)
(defn get-el
"If `selector` is a string, returns the element matching CSS selector.
Otherwise, assume it&aposs already an element and just return it."
[selector]
(if (string? selector)
(js/document.querySelector selector)
selector))
;; (defn get-els ...)
;; ...
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
get-text)
;; => "defn"
)
Much better!
However, we still have that annoying word "assume" in our docstring. đŹ
Returns the element matching the specified CSS selector. If
selectoris already an element, just returns the element.
How about we stop assuming, and actually throw an exception when we get an argument we can&apost handle? Sounds good, but the reason we&aposre in this mess is because we were treating anything that wasn&apost an Element object as a string, and we discovered that Text isn&apost a subclass of Element. So what we actually want is a common ancestor of both Text and Element, right? And luckily, there is just such a subclass: Node! đ
Let&aposs use that:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn secs->ts ...)
(defn get-el
"If `selector` is a string, returns the element matching CSS selector.
If `selector` is a `Node`, returns it. Otherwise, throws an exception."
[selector]
(cond
(string? selector)
(js/document.querySelector selector)
(instance? js/Node selector)
selector
:else
(let [cls (-> selector .-constructor .-name)]
(throw (ex-info (str "Expecting a string or a Node; got: " cls)
{:dom/error :dom/error.invalid-class
:js/class cls})))))
;; (defn get-els ...)
;; ...
(comment
;; ...
(get-el "#podcast-title")
;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]
(get-el (js/document.querySelector "#podcast-title"))
;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]
(get-el 42)
;; => "#error {:message \"Expecting a string or a Node; got: Number\", ...}"
(try
(get-el 42)
(catch js/Error e
(ex-data e)))
;; => {:dom/error :dom/error.invalid-class, :js/class "Number"}
(-> (get-children "#episode-info")
first
get-children
first
get-text)
;; => "defn"
)
Let&aposs try turning the #episode-info div into HTML:
(comment
;; ...
(el->html "#episode-info")
;; => "#error {:message \"Could not find instance method: getAttribute\", ...}"
)

Let&aposs slow down for a second and actually think this through. What we&aposre dealing with is HTML like this:
<h3>defn</h3>
which is represented by a DOM node that we can create like this:
(create-el {:tag "h3", :text "defn"})
And if we currently try to turn this into HTML, we get the "Could not find instance method: getAttribute" error, which makes sense, because neither Text nor any of its parent classes (CharacterData and Node) have a .getAttribute() method, and el->html calls el->clj, which in turn will try to get all sorts of attributes from the poor Text object. đ
We should be able to solve this by making sure el->html can handle a Text node:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
[el]
(if (instance? js/CharacterData el)
(get-text el)
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el)]
(if (seq children)
(str/join "\n"
(concat [tag-open]
(map el->html children)
[tag-close]))
(str tag-open text tag-close)))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(el->html (js/Text. "foo"))
;; => "foo"
(-> (create-el {:tag "h3", :text "defn"})
el->html)
;; => " <h3>\ndefn\n</h3>"
)
That seems somewhat reasonable, so let&aposs see what happens when we turn the entire #episode-info div into HTML now:
(comment
;; ...
(el->html "#episode-info")
;; => " <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">\ndefn\n</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">\n876b4c306172 Cora Sutton\n</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\nJuly 9, 2025\n</span>\n
;; </div>"
)
Hey, not bad!
Feeling confident, we go ahead and dump the entire DOM to HTML:
(comment
;; ...
(-> (el->html "html")
log)
;; => " <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n\n
;; \n
;; <title>\ncljcastr episode player\n</title>\n\n
;; \n
;; <link rel=\"stylesheet\" href=\"main.css\"></link>\n\n\n
;; \n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n\n
;; \n
;; <script>\nvar SCITTLE_NREPL_WEBSOCKET_PORT = 1340;\n</script>\n\n
;; \n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n\n\n\n
;; <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n\n\n</head>\n\n\n\n
;; <body>\n
;; <div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
;; <div class=\"header-wrapper\">
;; <div class=\"header-controls\">
;; ...
;; </div>
;; <span class=\"translate-button-mtz hidden_translate blue\"></span>\n
;; <audio class=\"audio-for-speech\" src=\"\"></audio>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">\ndefn\n</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">\n876b4c306172 Cora Sutton\n</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\nJuly 9, 2025\n</span>\n
;; </div>\n
;; ...
;; <button aria-label=\"Playback speed\" id=\"button-speed\" class=\"cljcastr playback-button\" style=\"padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;\">\n
;; <img src=\"https://www.svgrepo.com/show/151785/speed-meter.svg\" draggable=\"false\" id=\"button-speed-icon\" class=\"cljcastr playback-icon\" style=\"width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);\"></img>\n
;; </button>\n
;; </div>\n
;; </div>\n
;; </body>\n
;; </html>"
Now that&aposs more like it! Better yet, if we look in the browser console, we see some HTML:

Let&aposs copy this HTML from the console and paste it into our index.html:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>
cljcastr episode player
</title>
<link rel="stylesheet" href="main.css"></link>
<script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js" type="application/javascript"></script>
<script>
var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;
</script>
<script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js" type="application/javascript"></script>
<script type="application/x-scittle" src="player.cljs"></script>
</head>
<body>
<div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
<div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
<h3 id="podcast-title" class="cljcastr">
defn
</h3>
<h2 id="episode-title" class="cljcastr">
876b4c306172 Cora Sutton
</h2>
<span id="episode-date" class="cljcastr" style="color: grey;">
July 9, 2025
</span>
</div>
<div id="episode-art-container" class="cljcastr">
<img src="https://media.zencastr.com/image-files/5d9048546bacd9001de88f1c/a5b922ac-7294-4d58-9a33-4308f5d4f50b.png" id="episode-art" class="cljcastr" style="width: 400px; border-radius: 5%; padding: 10px;"></img>
</div>
<audio controls="true" src="https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3" class="cljcastr" style="display: none;"></audio>
<div id="progress" class="cljcastr" style="width: 390px; margin: 1.5em auto 0px;">
<span id="elapsed-time" class="cljcastr" style="margin: 0px 5px; font-size: 0.7rem; color: grey; float: left;">
00:00
</span>
<span id="remaining-time" class="cljcastr" style="margin: 0px 5px; font-size: 0.7rem; color: grey; float: right;">
1:29:30
</span>
<div id="progress-container" class="cljcastr" style="position: relative; height: 28px; clear: both;">
<progress min="0" max="5370.949958" value="0" id="progress-bar" class="cljcastr" style="color: rgb(247, 147, 30); top: 10px; overflow: hidden; background-color: lightgray; width: 100%; z-index: 0; appearance: none; position: absolute; border: 0px; border-radius: 4px; height: 8px; left: 0px; margin: 0px;"></progress>
<input min="0" value="0" aria-valuemax="5370.949958" type="range" style="top: 0px; background-color: transparent; width: 100%; cursor: pointer; z-index: 1; appearance: none; position: absolute; border: 0px; touch-action: manipulation; height: 28px; left: 0px; margin: 0px;" aria-valuetext="00:00 of 1:29:30" max="5370.949958" id="progress-slider" class="cljcastr" aria-label="Seek" step="any"></input>
</div>
</div>
<div id="playback-controls" class="cljcastr">
<button aria-label="Back 15 seconds" id="button-backward" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/495036/backward-15-seconds.svg" draggable="false" id="button-backward-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
<button aria-label="Play" id="button-play" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/436852/play-circle-fill.svg" draggable="false" id="button-play-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
<button aria-label="Pause" id="button-pause" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/436829/pause-circle-fill.svg" draggable="false" id="button-pause-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
<button aria-label="Forward 15 seconds" id="button-forward" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/495321/forward-15-seconds.svg" draggable="false" id="button-forward-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
<button aria-label="Playback speed" id="button-speed" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/151785/speed-meter.svg" draggable="false" id="button-speed-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
</div>
</div>
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>
</body>
</html>
Saving the file, we take a deep breath and refresh the page in the browser. Et voilĂ , our page reappears just as we&aposd left it! đ
This is wonderful and all, but that HTML that we produced is a bit of an eyesore:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>
cljcastr episode player
</title>
<link rel="stylesheet" href="main.css"></link>
...
</head>
<body>
<audio class="audio-for-speech" src=""></audio>
<div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
<div class=\"header-wrapper\">
<div class=\"header-controls\">
<span class=\"sound-translate\"></span>
<span class=\"copy blue\"></span>
<span class=\"settings-translator\"></span>
</div>
<div class=\"header-title\">translator</div>
<div class=\"translate-icons\">
<img class=\"from\" src=\"\"></img>
<img class=\"translate-arrow\" src=\"chrome-extension://bebmphofpgkhclocdbgomhnjcpelbenh/images/right-arrow.png\"></img>
<div class=\"translate_to_dropdown\">
<button class=\"dropbtn dropdown-toggle\" type=\"button\" id=\"dropdownMenuButton1\" aria-expanded=\"false\"></button>
<ul class=\"dropdown-content-wrapper languageSelector blue\">
<li>
<a class=\"dropdown-item\" data-lang=\"af\">Afrikaans</a>
</li>
<li>
<a class=\"dropdown-item\" data-lang=\"sq\">Albanian - shqipe</a>
</li>
<li>
<a class=\"dropdown-item\" data-lang=\"ar\">Arabic - ââ«Ű§ÙŰč۱ۚÙŰ©âŹâ</a>
</li>
...
<li>
<a class=\"dropdown-item\" data-lang=\"yi\">Yiddish - ŚŚŚŚŚ©</a>
</li>
</ul>
</div>
</div>
</div>
<div class=\"translated-text\">
...
<div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
<div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
...
</button>
</div>
</div>
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>
</body>
</html>
Cleaning up our DOM
In addition to all this whitespace, what&aposs that weird stuff we didn&apost add to the DOM?
<audio class="audio-for-speech" src=""></audio>
<div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
<div class=\"header-wrapper\">
<div class=\"header-controls\">
...
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>
It seems that extensions occasionally shove stuff into the DOM, so maybe we should give ourselves a way to filter out nodes we don&apost want. Let&aposs start by adding a new arity to get-children that lets us filter and remove children using an options map:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn set-classes! ...)
(defn get-children
"Returns the children of the specified element. Accepts an `opts` map with the
following keys:
`:filter-children` - predicate passed to `filter` on list of children
`:remove-children` - predicate passed to `remove` on list of children
`filter` is called before `remove`."
([el]
(get-children el {}))
([el {:keys [filter-children remove-children]
:or {filter-children (constantly true)
remove-children (constantly false)} :as opts}]
(->> (get-el el)
.-childNodes
seq
(filter filter-children)
(remove remove-children))))
;; (defn add-child! ...)
;; ...
(comment
;; ...
(->> (get-children "#episode-info")
log)
;; => (#object[Text [object Text]]
;; #object[HTMLHeadingElement [object HTMLHeadingElement]]
;; #object[Text [object Text]]
;; #object[HTMLHeadingElement [object HTMLHeadingElement]]
;; #object[Text [object Text]]
;; #object[HTMLSpanElement [object HTMLSpanElement]]
;; #object[Text [object Text]])
(->> (get-children "#episode-info"
{:filter-children (partial instance? js/HTMLHeadingElement)})
log
(map get-text))
;; => ("\ndefn\n" "\n876b4c306172 Cora Sutton\n")
(->> (get-children "#episode-info"
{:remove-children (partial instance? js/Text)})
log
(map get-text))
;; => ("\ndefn\n" "\n876b4c306172 Cora Sutton\n" "\nJuly 9, 2025\n")
)
Now we need a way to pass those options in to get-children from el->html:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el opts]
(if (instance? js/CharacterData el)
(get-text el)
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el opts)]
(if (seq children)
(str/join "\n"
(concat [tag-open]
(map #(el->html % opts) children)
[tag-close]))
(str tag-open text tag-close))))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(el->html "#episode-info")
;; => " <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n\n
;; \n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">\n\ndefn\n\n</h3>\n\n
;; \n
;; <h2 id=\"episode-title\" class=\"cljcastr\">\n\n876b4c306172 Cora Sutton\n\n</h2>\n\n
;; \n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\n\n
;; July 9, 2025\n\n</span>\n\n\n
;; </div>"
;; Remove empty text nodes
(el->html "#episode-info"
{:remove-children #(empty? (str/replace (get-text %) #"[\n\s]+" ""))})
;; => " <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">\n\ndefn\n\n</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">\n\n876b4c306172 Cora Sutton\n\n</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\n\nJuly 9, 2025\n\n</span>\n
;; </div>"
)
Now we have what we need to clean up our HTML a little bit:
(comment
;; ...
(let [empty-text? #(and (instance? js/Text %)
(empty? (str/replace (get-text %) #"[\n\s]+" "")))]
(el->html "html"
{:remove-children (fn [el]
(or (empty-text? el)
;; Bye-bye Proton Pass!
(str/starts-with? (or (get-name el) "")
"protonpass-")
;; Be gone, Translator!
(some #(str/starts-with? % "translate-")
(get-classes el))
(and (= "audio" (get-name el))
(not (some #{"cljcastr"} (get-classes el))))))}))
;; => " <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n
;; <title>\n\ncljcastr episode player\n\n</title>\n
;; <link rel=\"stylesheet\" href=\"main.css\"></link>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
;; <script>\n\n
;; var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;\n\n
;; </script>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
;; <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
;; </head>\n
;; <body>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; ...
;; </div>\n
;; </div>\n
;; </body>\n
;; </html>"
)
This is cool and all, but the :remove-children function is getting a bit hairy, and we&aposre haunted by the fact that we&aposll need to keep adding to it every time we install a new extension that inserts nodes into the DOM. This is the classic allowlist / denylist problem, and we can take a page out of the secure development notebook and deny all nodes by default, keeping only the ones we actually want.
Plausible deniability
We know that this means using :filter-children instead of :remove-children, but what should we allow? How about this:
- The html, head, and body elements themselves
- All elements that are descendants of the head element (i.e. children, grandchildren, great grandchildren, etc.)
- All elements that are descendants of the body element that have the "cljcastr" class
Let&aposs start by adding a function to check whether an element has a specific class:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn get-classes ...)
(defn has-class?
"Returns true if the element&aposs list of classes contains the specified class."
[el cls]
(-> (get-classes el)
set
(contains? cls)))
;; (defn set-classes! ...)
;; ...
(comment
;; ...
(has-class? "#button-backward" "cljcastr")
;; => true
)
Now, how about a function that checks whether one element is a descendant of another?
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn get-els ...)
(defn descendant?
"Returns truthy if the specified element is an ancestor of the element named by
the specified CSS selector. NOTE: `ancestor-sel` must be a string containing
a CSS selector, not an element!"
[el ancestor-sel]
(-> (get-el el)
(.closest ancestor-sel)))
;; (defn get-attr ...)
;; ...
(comment
;; ...
(descendant? "#button-backward" "#episode-container")
;; => #object[HTMLDivElement [object HTMLDivElement]]
(descendant? "#button-backward" "#episode-info")
;; => nil
)
Given this, we should be able to write the :filter-children function of our dreams:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn ??? ...)
;; (defn ??? ...)
;; ...
(comment
;; ...
(el->html "html"
{:filter-children (fn [el]
(cond
(instance? js/Text el)
(-> (get-text el)
(str/replace #"[\n\s]+" "")
not-empty)
(instance? js/Element el)
(or (contains? #{"html" "head" "body"}
(get-name el))
(descendant? el "head")
(and (descendant? el "body")
(has-class? el "cljcastr")))))})
;; => " <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n
;; <title>cljcastr episode player</title>\n
;; <link rel=\"stylesheet\" href=\"main.css\"></link>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
;; <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
;; <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
;; </head>\n
;; <body>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; ...
;; </div>\n
;; </div>\n
;; </body>\n
;; </html>\n
;; "
)
This is definite progress! We&aposre still not loving that indentation, though.
Indenting for fun and profit
Let&aposs think about how we&aposd like our HTML indented:
- Indent children by two spaces
<head>and<body>should not be indented<head>and<body>should reset indentation- The content of text nodes should go on the same line as the parent node
In other words, we&aposd like our HTML to look like this:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>cljcastr episode player</title>
...
</head>
<body>
<div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
<div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
<h3 id="podcast-title" class="cljcastr">defn</h3>
<h2 id="episode-title" class="cljcastr">876b4c306172 Cora Sutton</h2>
<span id="episode-date" class="cljcastr" style="color: grey;">July 9, 2025</span>
</div>
...
</body>
</html>
Let&aposs start with rule #4. First of all, what does the content of one of our text nodes look like?
(comment
;; ...
(->> (get-children "#podcast-title")
first
get-text)
;; => "\ndefn\n"
)
OK, it starts with a newline and ends with two newlines. How about we get rid of those?
(comment
;; ...
(->> (get-children "#podcast-title")
first
get-text
str/trim)
;; => "defn"
)
Great! Now we can plug this into el->html. We also need to stop putting newlines between the children (this will mess up our formatting, but we&aposll address that as we work our way through the rest of the rules in our list).
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el opts]
(if (instance? js/CharacterData el)
(str/trim (get-text el))
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el opts)]
(if (seq children)
(str/join ""
(concat [tag-open]
(map #(el->html % opts) children)
[tag-close]))
(str tag-open text tag-close))))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(el->html "#podcast-title")
;; => " <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>"
)
Great! That&aposs rule #4 ticked off the list. Of course, this has made a bit of a mess of other things:
(comment
;; ...
(el->html "#episode-info")
;; => " <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\"> <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3> <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2> <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span></div>"
)
Note the extreme lack of newlines there. We can address this by adding some indentation rules to our opts map:
:indent-level- number of levels to indent:indent-str- string to use when indenting
Now for a little helper function:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn indent
"Returns `indent-str` repeated `indent-level` times."
[indent-level indent-str]
(->> (repeat indent-level indent-str)
(str/join "")))
;; (defn el->html ...)
;; ...
(comment
;; ...
(->> (range 3)
(map #(str (indent % " ") "indent " %)))
;; => ("indent 0" " indent 1" " indent 2")
)
Let&aposs plug this into el->html:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn indent ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el {:keys [indent-level indent-str]
:or {indent-level 0, indent-str " "}
:as opts}]
(if (instance? js/CharacterData el)
(str/trim (get-text el))
(let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
children-html (->> (get-children el opts)
(map #(el->html % (assoc opts :indent-level
(inc indent-level))))
(str/join ""))
tag-open (str (indent indent-level indent-str)
"<" tag
(if (empty? attrs-str) "" (str " " attrs-str))
">"
(if (str/ends-with? children-html "\n")
"\n"
""))
tag-close (str (if (str/ends-with? children-html "\n")
(indent indent-level indent-str)
"")
"</" tag ">\n")]
(str tag-open children-html tag-close)))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(el->html "#episode-info")
;; => "<div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
;; </div>\n"
)
Biting our bottom lip to keep the fear at bay, we unleash el->html on the entire DOM:
(comment
;; ...
(el->html "html"
{:filter-children (fn [el]
(cond
(instance? js/Text el)
(-> (get-text el)
(str/replace #"[\n\s]+" "")
not-empty)
(instance? js/Element el)
(or (contains? #{"html" "head" "body"}
(get-name el))
(descendant? el "head")
(and (descendant? el "body")
(has-class? el "cljcastr")))))})
;; => "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n
;; <title>cljcastr episode player</title>\n
;; ...
;; </head>\n
;; <body>\n
;; <audio class=\"audio-for-speech\" src=\"\"></audio>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
;; </div>\n
;; ...
;; </div>\n
;; </body>\n
;; </html>\n
;; "
)
This looks great! Or rather, almost great, because we forgot about rules #2 and #3:
2. `<head>` and `<body>` should not be indented
3. `<head>` and `<body>` should reset indentation
Turns out we can implement both of these rules with a one-line addition to el->html! Well, maybe two lines, because a comment seems appropriate.
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn indent ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el {:keys [indent-level indent-str]
:or {indent-level 0, indent-str " "}
:as opts}]
(if (instance? js/CharacterData el)
(str/trim (get-text el))
(let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
;; Reset indentation for <head> and <body> tags
indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)
children-html (->> (get-children el opts)
(map #(el->html % (assoc opts :indent-level
(inc indent-level))))
(str/join ""))
tag-open (str (indent indent-level indent-str)
"<" tag
(if (empty? attrs-str) "" (str " " attrs-str))
">"
(if (str/ends-with? children-html "\n")
"\n"
""))
tag-close (str (if (str/ends-with? children-html "\n")
(indent indent-level indent-str)
"")
"</" tag ">\n")]
(str tag-open children-html tag-close)))))
;; (defn format-css ...)
;; ...
In case the two new lines where hard to spot, here they are again:
;; Reset indentation for <head> and <body> tags
indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)
Now let&aposs try again with the HTML dumping:
(comment
;; ...
(el->html "html"
{:filter-children (fn [el]
(cond
(instance? js/Text el)
(-> (get-text el)
(str/replace #"[\n\s]+" "")
not-empty)
(instance? js/Element el)
(or (contains? #{"html" "head" "body"}
(get-name el))
(descendant? el "head")
(and (descendant? el "body")
(has-class? el "cljcastr")))))})
;; => "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n
;; <title>cljcastr episode player</title>\n
;; <link rel=\"stylesheet\" href=\"main.css\"></link>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
;; <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
;; <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
;; </head>\n
;; <body>\n
;; <audio class=\"audio-for-speech\" src=\"\"></audio>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
;; </div>\n
;; ...
;; </div>\n
;; </body>\n
;; </html>\n
;; "
)
Looking at the browser console confirms that our HTML looks exactly as we wanted! And we could copy that sweet sweet HTML from the console and paste it into our index.html and move on with our lives... or we could shave just one more yak.
Shaving that final yak
It&aposs a bit annoying that we have to keep remembering the elements we want to keep when dumping our HTML, so let&aposs create a nice little set of functions to take care of that:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn update-playback-position-indicators! ...)
(defn keep-child? [el]
(cond
(instance? js/Text el)
(-> (get-text el)
(str/replace #"[\n\s]+" "")
not-empty)
(instance? js/Element el)
(or (contains? #{"html" "head" "body"}
(get-name el))
(descendant? el "head")
(and (descendant? el "body")
(has-class? el "cljcastr")))))
(defn dom->html []
(el->html "html" {:filter-children keep-child?}))
;; (js/console.log ...)
;; ...
(comment
;; ...
(do
(log (dom->html))
nil)
;; => nil
)
Checking the console verifies that this is the HTML we&aposre looking for.
But this is really not the yak we came here to shave. That big old annoying yak staring us in the face is the one that makes us use the mouse (the horror!) to copy a bunch of HTML from the browser console, and then pop back over to Emacs and switch to the index.html buffer and yank and then kill the old contents of the file and save. I mean, who has time for this kind of nonsense? Not us, that&aposs for damn sure!
If only there was a way to save a file from the browser...
(comment
;; ...
(let [filename "boring.html"
html "<html><body><h1>Yawn...</body></html>"
blob-url (-> (js/Blob. [html] (clj->js {:type "text/html"}))
js/URL.createObjectURL)]
(-> (create-el {:tag "a"
:attrs {:download filename
:href blob-url}})
.click))
;; => nil
)
When we evaluate this form, we get a helpful save file dialog of the sort we&aposve seen millions of times before, with boring.html helpfully prefilled.

If you were to save this file in your public directory alongside index.html and then browse to http://localhost:1341/boring.html, you would be quite bored.
But that&aposs OK, for we have learned the magic incantation for saving a file:
- Create a Blob containing some HTML and a
text/htmlcontent type. - Create a blob URL (i.e. a URL with
blob:as its scheme) from said blob. - Create an a element with its
downloadattribute set to a filename of our choosing and itshrefattribute set to the blob URL we created. - Click on the a element.
This is quite straightforward to turn into a couple of functions:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn add-event-listener! ...)
(defn save-file! [filename content content-type]
(let [blob-url (-> (js/Blob. [content] (clj->js {:type content-type}))
js/URL.createObjectURL)]
(-> (create-el {:tag "a"
:attrs {:download filename
:href blob-url}})
.click)))
;; (defn create-button ...)
;; ...
;; (defn dom->html ...)
(defn save-html!
([]
(save-html! "index.html"))
([filename]
(save-html! filename (dom->html)))
([filename html]
(save-file! filename html "text/html")))
;; (js/console.log ...)
;; ...
(comment
;; ...
(save-html!)
;; => nil
)
When the save file dialog pops up, let&aposs save it over top of our existing index.html and then reload the browser.
The page looks exactly the same as before, but our index.html is a work of art, formatted as purdy as a peach, as we say back where ah&aposm from (locating my point of provence shall remain an exercise for the reader, though that information is readily available in the latest episode of
defnâas of this writing, that is; I have no clue when you&aposre reading this). đ
All is good in the world... except that clicking the buttons on our lovely player has no effect whatsoever. đ
Button mashing
But fear not! &aposTis but the work of a moment to fix them. Moving back over to our player.cljs buffer, we mash C-c C-k (cider-load-buffer), then pop down to the bottom of our comment form and search backwards for "(def playback-controls". Once we find what we&aposre looking for, we copy it and paste it into our namespace, right before our "Ready to play!" message:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn save-html! ...)
(def playback-controls
{:play {:src "https://www.svgrepo.com/show/436852/play-circle-fill.svg"
:label "Play"
:click-handler #(.play (get-el "audio.cljcastr"))}
:pause {:src "https://www.svgrepo.com/show/436829/pause-circle-fill.svg"
:label "Pause"
:click-handler #(.pause (get-el "audio.cljcastr"))}
:forward {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
:label "Forward {{seek-dur-sec}} seconds"
:click-handler #(skip-forward! (get-el "audio.cljcastr") seek-dur-sec)}
:backward {:src "https://www.svgrepo.com/show/495036/backward-15-seconds.svg"
:label "Back {{seek-dur-sec}} seconds"
:click-handler #(skip-backward! (get-el "audio.cljcastr") seek-dur-sec)}
:speed {:src "https://www.svgrepo.com/show/151785/speed-meter.svg"
:label "Playback speed"}})
(js/console.log "Ready to play!")
(comment
;; ...
)
We hit C-c C-k again, but are deeply saddened by an error message:
#error {:message "Unable to resolve symbol: seek-dur-sec",
:data {:type :sci/error, :line 10, :column 30,
:message "Unable to resolve symbol: seek-dur-sec",
;; ...
},
;; ...
}
Oops, looks like we need to grab seek-dur-sec as well:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn save-html! ...)
(def seek-dur-sec 15)
;; (def playback-controls ...)
C-c C-k FTW?
Victory! đ
Or maybe more accurately, not defeat, since clicking the buttons still doesn&apost work. Which isn&apost that surprising, since we didn&apost actually attach any event listeners to anything. Let&aposs see if we can figure out how to do that.
(comment
;; ...
(doseq [[el-id {:keys [click-handler]}] playback-controls]
(add-event-listener! (str "#button-" (name el-id)) "click"
click-handler))
;; => nil
)
Now if we click the play button, the episode starts playing! We skip backwards and forwards in the episode, try pausing it, and finally convince ourselves that all the buttons work.
The only problem is that the elapsed time, remaining time, progress bar, and slider aren&apost working. đ€
Oh yeah, we had a handler for that as well. And we might as well attach the listener to the slider that updates the playback position too:
(comment
;; ...
(let [audio (get-el "audio.cljcastr")]
(add-event-listener! "#progress-slider" "input"
#(->> % .-target .-value (set! (.-currentTime audio))))
(add-event-listener! audio "timeupdate"
#(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
(->> [:elapsed-time
:remaining-time
:progress-slider
:progress-bar]
(map (fn [el-id]
[el-id (str "#" (name el-id))]))
(into {}))))
audio)
;; => #object[HTMLAudioElement [object HTMLAudioElement]]
)
If we click play again, we see the timestamps updating and slider moving, and if we move the slider ourselves, we can successfully jump around in the episode. Life is good!
That is, until we hit C-c C-k again and realise that despite the console cheerily telling us that we&aposre "Ready to play!", the buttons don&apost work. đą
Of course, we can evaluate the two forms we just added to the Rich comment, but it&aposs hardly a good user experience to expect our users to connect a ClojureScript REPL, paste in some code, and evaluate it before they can actually play an episode.
Loading the UI
There is a pretty easy solution to this. If we just add the two magic forms right before our "Ready to play!" log statement, the listeners should be attached on page load. Let&aposs try it:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (def playback-controls ...)
(doseq [[el-id {:keys [click-handler]}] playback-controls]
(add-event-listener! (str "#button-" (name el-id)) "click"
click-handler))
(let [audio (get-el "audio.cljcastr")]
(add-event-listener! "#progress-slider" "input"
#(->> % .-target .-value (set! (.-currentTime audio))))
(add-event-listener! audio "timeupdate"
#(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
(->> [:elapsed-time
:remaining-time
:progress-slider
:progress-bar]
(map (fn [el-id]
[el-id (str "#" (name el-id))]))
(into {}))))
audio)
;; (js/console.log ...)
If we save the file and reload the page, the buttons now work from the start. This is good, but there is one oddity that we&aposll notice in the console if we pause playback then evaluate the buffer with C-c C-k again:
Added listener for click events to nrepl.cljs:10
⣠<button class=â"cljcastr" id=â"button-speed"
style=â"padding:â 0px;â margin:â 0px;â border:â 0px;â background:â transparent;â cursor:â pointer;â"
aria-label=â"Playback speed">ââŠâ</button>â
Added listener for input events to nrepl.cljs:10
⣠<input class=â"cljcastr" id=â"progress-slider" type=â"range"
min=â"0" max=â"3138.727422" value=â"0"step=â"any"
style=â"top:â 0px;â background-color:â transparent;â width:â 100%;â cursor:â pointer;â z-index:â 1;â appearance:â none;â position:â absolute;â border:â 0px;â touch-action:â manipulation;â height:â 28px;â left:â 0px;â margin:â 0px;â"
aria-label=â"Seek" aria-valuetext=â"11:â58 of 52:â18"
aria-valuemax=â"3138.727422">â
Added listener for timeupdate events to nrepl.cljs:10
⣠<audio class=â"cljcastr" controls style=â"display:â none;â"
src=â"https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3">â</audio>â
Ready to play!
And if we click play then pause again, we see that the click handlers are firing twice:
Handling click on nrepl.cljs:10
⣠<button class=â"cljcastr" id=â"button-pause"
style=â"padding:â 0px;â margin:â 0px;â border:â 0px;â background:â transparent;â cursor:â pointer;â"
aria-label=â"Pause">ââŠâ</button>â
⣠PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, âŠ}
Handling click on nrepl.cljs:10
⣠<button class=â"cljcastr" id=â"button-pause"
style=â"padding:â 0px;â margin:â 0px;â border:â 0px;â background:â transparent;â cursor:â pointer;â"
aria-label=â"Pause">ââŠâ</button>â
⣠PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, âŠ}
If we evaluate the buffer again, then click play and pause, we see the handlers firing thrice! đ±
Handling click on nrepl.cljs:10
⣠<button class=â"cljcastr" id=â"button-pause"
style=â"padding:â 0px;â margin:â 0px;â border:â 0px;â background:â transparent;â cursor:â pointer;â"
aria-label=â"Pause">ââŠâ</button>â
⣠PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, âŠ}
Handling click on nrepl.cljs:10
⣠<button class=â"cljcastr" id=â"button-pause"
style=â"padding:â 0px;â margin:â 0px;â border:â 0px;â background:â transparent;â cursor:â pointer;â"
aria-label=â"Pause">ââŠâ</button>â
⣠PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, âŠ}
Handling click on nrepl.cljs:10
⣠<button class=â"cljcastr" id=â"button-pause"
style=â"padding:â 0px;â margin:â 0px;â border:â 0px;â background:â transparent;â cursor:â pointer;â"
aria-label=â"Pause">ââŠâ</button>â
⣠PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, âŠ}
OK, so it appears that every time we evaluate the buffer, we get a new click handler. And if we think about it for a moment, it makes sense, because these forms:
(doseq [[el-id {:keys [click-handler]}] playback-controls]
(add-event-listener! (str "#button-" (name el-id)) "click"
click-handler))
(let [audio (get-el "audio.cljcastr")]
(add-event-listener! "#progress-slider" "input"
#(->> % .-target .-value (set! (.-currentTime audio))))
(add-event-listener! audio "timeupdate"
#(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
(->> [:elapsed-time
:remaining-time
:progress-slider
:progress-bar]
(map (fn [el-id]
[el-id (str "#" (name el-id))]))
(into {}))))
audio)
;; (js/console.log ...)
are getting evaluated every time, since they are at the top level of our namespace. Luckily for us, if we whip out our microscope, we find a solution (and a precipitate, but that&aposs beside the point): the mighty atom!
Atomic elbow
A common pattern in ClojureScript apps is to define a single atom that holds all of the state we care about. It is conventional to call this atom state, so let&aposs follow said convention because we aren&apost iconoclastic hipsters, after all!
(ns cljcastr.player
(:require [clojure.string :as str]))
(defonce state (atom {}))
;; (defn log ...)
;; ...
We use defonce instead of plain ol&apos def, because otherwise every time we evaluated the buffer, our state var would be rebound to a fresh new atom, thus defeating the point of keeping state in the first place.
Now that we&aposve done this, let&aposs move our code that attaches event listeners into a function:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (def playback-controls)
(defn init-ui! []
(doseq [[el-id {:keys [click-handler]}] playback-controls]
(add-event-listener! (str "#button-" (name el-id)) "click"
click-handler))
(let [audio (get-el "audio.cljcastr")]
(add-event-listener! "#progress-slider" "input"
#(->> % .-target .-value (set! (.-currentTime audio))))
(add-event-listener! audio "timeupdate"
#(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
(->> [:elapsed-time
:remaining-time
:progress-slider
:progress-bar]
(map (fn [el-id]
[el-id (str "#" (name el-id))]))
(into {}))))
audio)
(log "UI initialised"))
;; (js/console.log ...)
Now if we evaluate the buffer, we don&apost see "UI initialised!" get printed in the console. However, if we reload the page, we&aposre back to our buttons not working, which makes sense, because we&aposre never calling our new init-ui! function.
Let&aposs fix that!
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn init-ui! ...)
(when-not (:initialised? @state)
(init-ui!)
(swap! state assoc :initialised? true))
;; (js/console.log ...)
If we reload the page now, we&aposll see our handlers getting installed and our log statement telling us "UI initialised", as well as the usual chipper "Ready to play!". And if we evaluate the buffer a couple times, we see "Ready to play!" being logged, but nothing about handlers and initialisation.

Better yet, everything seems to work!
What has CSS ever done for us anyway?
Of course, we are not the sort of Clojurians to rest on our laurels, for there are still improvements to be made! It is considered a bit tacky these days to use inline styles, so let&aposs see if we can take a page out of our el->html book and export our styles.
In fact, we already have a function to get a map of styles from an element (get-styles), and code in our set-styles! function that turns a map of styles into a CSS string, so it&aposs but the work of a moment to squish them together:
(comment
;; ...
(->> (get-styles "#progress-bar")
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join "; "))
;; => "color: rgb(247, 147, 30); top: 10px; overflow: hidden;
;; background-color: lightgray; width: 100%; z-index: 0;
;; appearance: none; position: absolute; border: 0px;
;; border-radius: 4px; height: 8px; left: 0px; margin: 0px;"
)
What would be really cool, though, would be to produce a snippet like this:
#progress-bar {
color: rgb(247, 147, 30);
top: 10px;
overflow: hidden;
background-color: lightgray;
width: 100%;
z-index: 0;
appearance: none;
position: absolute;
border: 0px;
border-radius: 4px;
height: 8px;
left: 0px;
margin: 0px;
}
Honestly, that shouldn&apost be too tricky. Let&aposs see what we can do:
(comment
;; ...
(let [el (get-el "#progress-bar")
styles (get-styles el)]
(str "#" (get-id el) " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
;; => "#progress-bar {\n
;; color: rgb(247, 147, 30);\n
;; top: 10px;\n
;; overflow: hidden;\n
;; background-color: lightgray;\n
;; width: 100%;\n
;; z-index: 0;\n
;; appearance: none;\n
;; position: absolute;\n
;; border: 0px;\n
;; border-radius: 4px;\n
;; height: 8px;\n
;; left: 0px;\n
;; margin: 0px;\n
;; }"
)
But what about elements without an ID?
(comment
;; ...
(let [el (get-el "audio.cljcastr")
styles (get-styles el)]
(str "#" (get-id el) " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
;; => "# {\n display: none;\n}"
)
For that, let&aposs fall back to the tag name, plus the class if it exists:
(comment
;; ...
(let [el (get-el "audio.cljcastr")
styles (get-styles el)
id (get-id el)
classes (get-classes el)
sel (if (not-empty id)
(str "#" (get-id el))
(str (get-name el) (if (seq classes)
(str "." (first classes)))))]
(str sel " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
;; => "audio.cljcastr {\n display: none;\n}"
)
This is a bit clunky, though. It would be a lot cleaner if we used el->clj to turn the element into a Clojure datastructure:
(comment
;; ...
(let [el (get-el "audio.cljcastr")
styles (get-styles el)
id (get-id el)
classes (get-classes el)
sel (if (not-empty id)
(str "#" (get-id el))
(str (get-name el) (if (seq classes)
(str "." (first classes)))))]
(str sel " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
;; => "audio.cljcastr {\n display: none;\n}"
)
This code is so special it deserves a name! And lo! that name shall be el->css, and it shall be good.

(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn el->html ...)
(defn format-css
"Returns a CSS string with the specified selector and styles"
[sel styles]
(str sel " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
(defn el->css
"Given an element (or the Clojure representation of an element), returns a CSS
rule corresponding to the element&aposs ID."
[el]
(let [el (log (if (map? el) el (-> (get-el el) el->clj)))
{:keys [styles id tag classes]} el
sel (if (not-empty id)
(str "#" id)
(str tag (if (seq classes)
(str "." (first classes)))))]
(format-css sel styles)))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> (el->css "#progress-bar")
log)
;; => "#progress-bar {\n
;; color: rgb(247, 147, 30);\n
;; top: 10px;\n
;; overflow: hidden;\n
;; background-color: lightgray;\n
;; width: 100%;\n
;; z-index: 0;\n
;; appearance: none;\n
;; position: absolute;\n
;; border: 0px;\n
;; border-radius: 4px;\n
;; height: 8px;\n
;; left: 0px;\n
;; margin: 0px;\n
;; }"
)
This is pretty cool, so why don&apost we add a function to generate CSS for multiple elements at a time?
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn el->css ...)
(defn els->css
"Given an collection of elements (or Clojure representations of elements),
returns CSS rules corresponding to the elements&apos IDs."
[els]
(->> els
(map el->clj)
(remove (comp empty? :styles))
(map el->css)
(str/join "\n\n")))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> (els->css (get-els ".cljcastr"))
log)
;; => "#episode-container {\n
;; width: 100%;\n
;; display: flex;\n
;; flex-direction: column;\n
;; align-items: center;\n
;; }\n
;; \n
;; #episode-info {\n
;; max-width: 420px;\n
;; text-align: center;\n
;; }\n
;; \n
;; ...
)
Casting our gaze upon the console, joy fills our hearts:

However, (and you knew the however was coming, eh dear reader?) our heart rankles at the repetition inherent in the system.
Itâs your birthday. Someone gives you a calfskin wallet.
What people do when they write CSS is use classes to combine related sets of elements. We&aposre not going to be able to do that programmatically, but we can at least spit out something that&aposs a better starting point for a person to pick up and shape to their liking.
How might we go about this?
(comment
;; ...
(def style-groups
(->> (get-els ".cljcastr")
(map el->clj)
(remove (comp empty? :styles))
(group-by :styles)))
;; => #&aposcljcastr.player/style-groups
(count style-groups)
;; => 13
(->> style-groups
(map (fn [[_styles els]]
(map #(select-keys % [:tag :id :classes]) els))))
;; => (({:tag "div", :id "progress-container", :classes ("cljcastr")})
;; ({:tag "div", :id "progress", :classes ("cljcastr")})
;; ({:tag "progress", :id "progress-bar", :classes ("cljcastr")})
;; ({:tag "img", :id "episode-art", :classes ("cljcastr")})
;; ({:tag "input", :id "progress-slider", :classes ("cljcastr")})
;; ({:tag "span", :id "episode-date", :classes ("cljcastr")})
;; ({:tag "div", :id "episode-info", :classes ("cljcastr")})
;; ({:tag "img", :id "button-backward-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-play-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-pause-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-forward-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-speed-icon", :classes ("cljcastr" "playback-icon")})
;; ({:tag "audio", :classes ("cljcastr")})
;; ({:tag "span", :id "elapsed-time", :classes ("cljcastr")})
;; ({:tag "button", :id "button-backward", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-play", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-pause", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-forward", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-speed", :classes ("cljcastr" "playback-button")})
;; ({:tag "span", :id "remaining-time", :classes ("cljcastr")})
;; ({:tag "div", :id "episode-container", :classes ("cljcastr")}))
)
What we&aposre doing here is getting all the elements having "cljcastr" as one of their classes, turning them into Clojure data structures, removing the ones with no inline styles, then grouping them by their styles. This results in a map where the keys are styles and the values are the list of the elements with those styles (see the documentation for the group-by function if this is confusing). We then map over that to look at the tag, ID, and classes of the elements with each unique set of styles.
Grouping elements like this seems useful, so let&aposs create a function to do it:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn el->clj ...)
(defn group-styles
"Given a collection of DOM elements, returns a map keyed by styles. The value
at each key will be a vector of the corresponding elements, in the order they
appeared in the original list.
Example:
(let [els [(create-el {:tag \"div\", :id \"el1\", :styles {:color \"red\"}})
(create-el {:tag \"div\", :id \"el2\", :styles {:color \"blue\"}})
(create-el {:tag \"div\", :id \"el3\"})
(create-el {:tag \"div\", :id \"el4\", :styles {:color \"red\"}})]]
(->> (group-styles els)
(map (fn [[styles els]]
[styles (map #(select-keys % [:id]) els)]))))
;; => ([{:color \"red;\"} ({:id \"el1\"} {:id \"el4\"})]
;; [{:color \"blue;\"} ({:id \"el2\"})])"
[els]
(->> els
(map el->clj)
(remove (comp empty? :styles))
(group-by :styles)))
;; (defn attrs->str ...)
;; ...
Now that we have this, let&aposs see how many of these styles apply to multiple elements:
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(filter (fn [[_ els]] (> (count els) 1)))
(map (fn [[_styles els]]
(map #(select-keys % [:tag :id :classes]) els))))
;; => (({:tag "img", :id "button-backward-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-play-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-pause-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-forward-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-speed-icon", :classes ("cljcastr" "playback-icon")})
;; ({:tag "button", :id "button-backward", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-play", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-pause", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-forward", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-speed", :classes ("cljcastr" "playback-button")}))
)
These look like great candidates for consolidation! But what selector should we use to write the rule?
Well, we know that all of our elements share the "cljcastr" class in common, so if we exclude that, we see that both of these groups are left with a single class!
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(filter (fn [[_ els]] (> (count els) 1)))
(map (fn [[_styles els]]
(map (fn [el]
(-> (select-keys el [:tag :id :classes])
(update :classes #(remove (fn [cls] (= "cljcastr" cls)) %))))
els))))
;; => (({:tag "img", :id "button-backward-icon", :classes ("playback-icon")}
;; {:tag "img", :id "button-play-icon", :classes ("playback-icon")}
;; {:tag "img", :id "button-pause-icon", :classes ("playback-icon")}
;; {:tag "img", :id "button-forward-icon", :classes ("playback-icon")}
;; {:tag "img", :id "button-speed-icon", :classes ("playback-icon")})
;; ({:tag "button", :id "button-backward", :classes ("playback-button")}
;; {:tag "button", :id "button-play", :classes ("playback-button")}
;; {:tag "button", :id "button-pause", :classes ("playback-button")}
;; {:tag "button", :id "button-forward", :classes ("playback-button")}
;; {:tag "button", :id "button-speed", :classes ("playback-button")}))
)
Let&aposs run those through els->css and see what we get:
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(filter (fn [[_ els]] (> (count els) 1)))
(map (fn [[_styles els]]
(->> els
(map (fn [el]
(update el :classes #(remove (fn [cls] (= "cljcastr" cls)) %))))
els->css)))
(str/join "\n\n")
log)
;; => "#button-backward-icon {\n width: 35px;\n ...}"
)
Looking at the console, we see that we&aposre back where we started:
#button-backward-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-play-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-pause-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-forward-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-speed-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-backward {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#button-play {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#button-pause {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#button-forward {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#button-speed {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
What we really want to do is generate a single rule for each group, with the remaining class as the selector. If we update el->css with a second arity that accepts a selector as an argument, we can accomplish this fairly easily:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn format-css ...)
(defn el->css
"Given an element (or the Clojure representation of an element), returns a CSS
rule corresponding to the element&aposs ID."
([el]
(let [el (if (map? el) el (-> (get-el el) el->clj))
{:keys [styles id tag classes]} el
sel (if (not-empty id)
(str "#" id)
(str tag (if (seq classes)
(str "." (first classes)))))]
(el->css el sel)))
([el sel]
(format-css sel (:styles el))))
;; (defn els->css ...)
;; ...
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(filter (fn [[_ els]] (> (count els) 1)))
(map (fn [[_styles [el & _]]]
(let [sel (str "." (->> (:classes el)
(remove (partial = "cljcastr"))
first))]
(el->css el sel))))
(str/join "\n\n")
log)
;; => ".playback-icon {\n width: 35px;\n ...}"
)
And now the console fills our hearts with delight once again!
.playback-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
.playback-button {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
Let&aposs now take a step back and think about how we want to generate the rules:
- Group by styles
- For groups with a single element, just call
el->csson it - For groups with multiple elements, take the first one, drop "cljcastr" from its list of classes, generate a selector from the first remaining class, and then call
el->csson the element, using the custom selector
And if we say this in Clojure, it looks like this:
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(map (fn [[_styles [el & rest-els]]]
(if (seq rest-els)
(let [sel (str "." (->> (:classes el)
(remove (partial = "cljcastr"))
first))]
(el->css el sel))
(el->css el))))
(str/join "\n\n")
log)
;; => "#progress-container {\n position: relative;\n height: 28px;\n ...}"
)
If we look at the console, it appears that all of our prayers are answered! đ
#progress-container {
position: relative;
height: 28px;
clear: both;
}
#progress {
width: 390px;
margin: 1.5em auto 0px;
position: relative;
}
#progress-bar {
color: rgb(247, 147, 30);
top: 10px;
overflow: hidden;
background-color: lightgray;
width: 100%;
z-index: 0;
appearance: none;
position: absolute;
border: 0px;
border-radius: 4px;
height: 8px;
left: 0px;
margin: 0px;
}
#episode-art {
width: 400px;
border-radius: 5%;
padding: 10px;
}
#progress-slider {
top: 0px;
background-color: transparent;
width: 100%;
cursor: pointer;
z-index: 1;
appearance: none;
position: absolute;
border: 0px;
touch-action: manipulation;
height: 28px;
left: 0px;
margin: 0px;
}
#episode-date {
color: grey;
}
#episode-info {
max-width: 420px;
text-align: center;
}
.playback-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
audio.cljcastr {
display: none;
}
#elapsed-time {
margin: 0px 5px;
font-size: 0.7rem;
color: grey;
float: left;
}
.playback-button {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#remaining-time {
margin: 0px 5px;
font-size: 0.7rem;
color: grey;
float: right;
}
#episode-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
Now that we have all of these lovely styles, we can write a couple of functions to export them, following the same pattern as for save-html!:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn save-html! ...)
(defn dom->css []
(->> (get-els ".cljcastr")
group-styles
(map (fn [[_styles [el & rest-els]]]
(if (seq rest-els)
(let [sel (str "." (->> (:classes el)
(remove (partial = "cljcastr"))
first))]
(el->css el sel))
(el->css el))))
(str/join "\n\n")
log))
(defn save-css!
([]
(save-css! "main.css"))
([filename]
(save-css! filename (dom->css)))
([filename css]
(save-file! filename css "text/css")))
;; (def seek-dur-sec ...)
;; ...
(comment
;; ...
(save-css!)
;; => nil
)
We evaluate the save-css! form and save the main.css file alongside our index.html. Now all we have to do to use the styles is two things:
- Import the stylesheet in
index.html - Regenerate our HTML without the inline styles
The first part is simple, we just add one line to our index.html:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>cljcastr episode player</title>
<link rel="stylesheet" href="main.css">
<!-- ... -->
</head>
<!-- ... -->
And now for the new HTML. Let&aposs update el->html to take a new option called :exclude-styles?, and then emit the HTML without inline styles if it&aposs truthy:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn indent ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el {:keys [indent-level indent-str exclude-styles?]
:or {indent-level 0, indent-str " "}
:as opts}]
(if (instance? js/CharacterData el)
(str/trim (get-text el))
(let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
attrs (if exclude-styles? (dissoc attrs :style) attrs)
attrs-str (attrs->str attrs)
;; Reset indentation for <head> and <body> tags
indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)
children-html (->> (get-children el opts)
(map #(el->html % (assoc opts :indent-level
(inc indent-level))))
(str/join ""))
tag-open (str (indent indent-level indent-str)
"<" tag
(if (empty? attrs-str) "" (str " " attrs-str))
">"
(if (str/ends-with? children-html "\n")
"\n"
""))
tag-close (str (if (str/ends-with? children-html "\n")
(indent indent-level indent-str)
"")
"</" tag ">\n")]
(str tag-open children-html tag-close)))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map #(el->html % {:exclude-styles? true}))
(str/join "\n")
log)
;; => "<div id=\"episode-container\" class=\"cljcastr\">\n
;; <div id=\"episode-info\" class=\"cljcastr\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\">July 9, 2025</span>\n
;; </div>\n
;; ...
;; </div>\n
;; "
)
Let&aposs update dom->html and save->html! to allow us to exclude styles if we wanna. But how to do it? dom->html is simple: we just add a 1-arity version of the function that takes an options map. save-html! on the other hand requires a tiny bit of design. We could add yet another arity, but then we have the problem of distinguising between what the arguments mean. Let&aposs simplify a bit: we&aposll have the 0-arity function to fill in all the defaults, and a 1-arity version taking an options map:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn keep-child? ...)
(defn dom->html
([]
(dom->html {}))
([opts]
(el->html "html"
(merge opts
{:filter-children keep-child?}))))
(defn save-html!
([]
(save-html! {}))
([{:keys [filename html]
:or {filename "index.html"}
:as opts}]
(let [html (or html (dom->html opts))]
(save-file! filename html "text/html"))))
;; (defn save-html! ...)
;; ...
Now we can save a new version of index.html, this time without the styles:
(comment
;; ...
(save-html! {:exclude-styles? true})
;; => nil
)
After saving, let&aposs check index.html to make sure things look good:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>cljcastr episode player</title>
<link rel="stylesheet" href="main.css"></link>
<script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js" type="application/javascript"></script>
<script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>
<script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js" type="application/javascript"></script>
<script type="application/x-scittle" src="player.cljs"></script>
</head>
<body>
<div id="episode-container" class="cljcastr">
<div id="episode-info" class="cljcastr">
<h3 id="podcast-title" class="cljcastr">defn</h3>
<h2 id="episode-title" class="cljcastr">876b4c306172 Cora Sutton</h2>
<span id="episode-date" class="cljcastr">July 9, 2025</span>
</div>
...
</div>
</body>
</html>
Holding our breath, we reload the page, and... see the same thing we were just looking at. đ
But actually, that&aposs precisely what we were trying to achieve. If we open dev tools and inspect the play button, we see two exciting things:
- No inline styles in the button element
- In the Styles panel, there is a
.playback-buttonclass being applied, defined inmain.css

At this point, we&aposve built a nice podcast player interactively, then created a persistence mechanism so we can safely reload the page. This feels really good, right? đ
We bask in the feeling for an altogether brief moment, then start thinking of ways to improve things. Like adding that transcript we were talking about many thousands of word ago? Or maybe not limiting ourselves to a single defn episode (even if it is a đ„ episode!)?
Our minds start to fill with the possibilities, but we come to our senses and decide some hammock time (in the form of a nice long walk perhapsâmaybe even with one of our favourite human or canine companions?) is in order.
Next time on Scittlecastr, we return from our hammock time and get to scittling them features!
The preview image for this post was based on the Teenager looking at equation on chalk board photo by Rafa Fernandez from his Back To School Boy photoshoot. The photo is used under The Noun Project&aposs Royalty-Free Commercial License, and was remixed by hand in the GIMP, which does not allow genAI contributions.