
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't need the comment inserted again). Let's 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'll 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's 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's give it a go.
What we'd 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't 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'd rather spend some time shaving those yaks, so let's 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's try to count those children:
(comment
;; ...
(def children (-> (get-children "body")
log))
;; => #'cljcastr.player/children
(count children)
;; => 3
)
Wait, what's 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))
;; => #'cljcastr.player/children
(count children)
;; => 2
)
Excellent! Now let's 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 'Ol JavaScript Object, ofc) there. I guess we'll 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's time to write a few functions to decomplect this mess. Let's 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's 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've already written, it's 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's 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's turn this into a nice function, and whilst we're 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's 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's 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're 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've 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're convinced we're 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's think about what it would take to dehydrate the entire DOM tree rooted at our body tag.
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's 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're 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's 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's 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's not very helpful. First of all, we're only getting the direct children of our body element, which in this case is a single child: the episode-container div. What we'd actually like is the entire tree of elements rooted at body.
Let's 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're 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'll 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 'querySelector' on 'Document':
;; '[object Text]' is not a valid selector.\",
;; ...}"
)
Well, that doesn't look promising. Let's 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't it. Let's 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's feed the first child to el->html and see what happens:
(comment
;; ...
(-> (get-children "#episode-info")
first
el->html)
;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
;; '[object Text]' 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's 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's 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 'querySelector' on 'Document':
;; '[object Text]' is not a valid selector.\",
;; ...}"
)
Hrm, we're back to this. Let's trace through get-text line by line to see if we can figure out what's going on:
(let [el (get-el el)]
...)
So we'd be calling (get-el) with our Text node. Let's try that and see what happens:
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
get-el)
;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
;; '[object Text]' 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're trying to do here is just return the selector arg if we're 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's 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's already an element and just return it.
And whilst we're 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's 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't handle? Sounds good, but the reason we're in this mess is because we were treating anything that wasn't an Element object as a string, and we discovered that Text isn't 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's 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's try turning the #episode-info div into HTML:
(comment
;; ...
(el->html "#episode-info")
;; => "#error {:message \"Could not find instance method: getAttribute\", ...}"
)

Let's slow down for a second and actually think this through. What we're 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's 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's more like it! Better yet, if we look in the browser console, we see some HTML:

Let's 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'd 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>
In addition to all this whitespace, what's that weird stuff we didn't 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't want. Let's 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're haunted by the fact that we'll 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.
We know that this means using :filter-children instead of :remove-children, but what should we allow? How about this:
Let's 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's 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're still not loving that indentation, though.
Let's think about how we'd like our HTML indented:
<head> and <body> should not be indented<head> and <body> should reset indentationIn other words, we'd 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's 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'll 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's 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 indentingNow 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's 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's 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.
It's a bit annoying that we have to keep remembering the elements we want to keep when dumping our HTML, so let's 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're 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's 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've 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's OK, for we have learned the magic incantation for saving a file:
text/html content type.blob: as its scheme) from said blob.download attribute set to a filename of our choosing and its href attribute set to the blob URL we created.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's 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'm 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're reading this). π
All is good in the world... except that clicking the buttons on our lovely player has no effect whatsoever. π
But fear not! 'Tis 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're 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't work. Which isn't that surprising, since we didn't actually attach any event listeners to anything. Let's 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't 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're "Ready to play!", the buttons don't work. π’
Of course, we can evaluate the two forms we just added to the Rich comment, but it's 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.
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's 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'll 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's beside the point): the mighty atom!
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's follow said convention because we aren't iconoclastic hipsters, after all!
(ns cljcastr.player
(:require [clojure.string :as str]))
(defonce state (atom {}))
;; (defn log ...)
;; ...
We use defonce instead of plain ol' 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've done this, let's 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't see "UI initialised!" get printed in the console. However, if we reload the page, we're back to our buttons not working, which makes sense, because we're never calling our new init-ui! function.
Let's 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'll 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!
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's 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's 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't be too tricky. Let's 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's 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's 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't 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' 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.
What people do when they write CSS is use classes to combine related sets of elements. We're not going to be able to do that programmatically, but we can at least spit out something that's 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)))
;; => #'cljcastr.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're 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's 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's 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's 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're 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's 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's now take a step back and think about how we want to generate the rules:
el->css on itel->css on the element, using the custom selectorAnd 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:
index.htmlThe 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's update el->html to take a new option called :exclude-styles?, and then emit the HTML without inline styles if it's 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's 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's simplify a bit: we'll 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's 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's precisely what we were trying to achieve. If we open dev tools and inspect the play button, we see two exciting things:
.playback-button class being applied, defined in main.css
At this point, we've 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's Royalty-Free Commercial License, and was remixed by hand in the GIMP, which does not allow genAI contributions.