PageSourceSearch

https://jmglov.net/blog/

html jmglov.net collected 2026-10-03 22:11:03 UTC 121,496 bytes, 1,801 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3  <head>
4    <title>jmglov&#39;s blog</title>
5    <meta charset="utf-8"/>
6    <link type="application/atom+xml" rel="alternate" href="atom.xml" title="jmglov&#39;s blog">
7    <link rel="stylesheet" href="style.css">
8    
8<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/prism.min.js"></script>
8
9    
9<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/components/prism-clojure.min.js"></script>
9
10    
10<script type="text/javascript" src="https://livejs.com/live.js"></script>
10
11    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/themes/prism.min.css">
12
13    <!-- See Features > favicon in README.md -->
14    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
15    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
16    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
17    <link rel="manifest" href="/site.webmanifest">
18    <meta name="msapplication-TileColor" content="#da532c">
19    <meta name="theme-color" content="#ffffff">
20
21
22    <!-- Social sharing (Facebook, Twitter, LinkedIn, etc.) -->
23    <meta name="title" content="jmglov&#39;s blog">
24    <meta name="twitter:title" content="jmglov&#39;s blog">
25    <meta property="og:title" content="jmglov&#39;s blog">
26    <meta property="og:type" content="website">
27
28    <meta name="description" content="A blog about stuff but also things.">
29    <meta name="twitter:description" content="A blog about stuff but also things.">
30    <meta property="og:description" content="A blog about stuff but also things.">
31
32
33    <meta name="twitter:url" content="https://jmglov.net/blog/index.html">
34    <meta property="og:url" content="https://jmglov.net/blog/index.html">
35
36
37    <meta name="twitter:card" content="summary">
38
39
40
41  </head>
42  <body>
43
44    <div class="site-header">
45      <div class="wrapper">
46        <div class="site-nav">
47          <div>
48            🗃️ <a class="page-link" href="archive.html">Archive</a>
49          </div>
50          <div>
51            🏷 <a class="page-link" href="tags/index.html">Tags</a>
52          </div>
53        <div>
54            <img src="assets/rss-color-svgrepo-com.svg" alt="RSS icon"
55                style="width: 15px; height: 15px;" />
56            <a class="page-link" href="atom.xml">Feed</a>
57          </div>
58          
59          <div>
60            🪪 <a class="page-link" href="https://jmglov.net/">About</a>
61          </div>
62          
63        </div>
64        <div>
65          <h1 class="site-title">
66            <a class="page-link" href="index.html">jmglov&#39;s blog</a>
67          </h1>
68          <p>A blog about stuff but also things.</p>
69        </div>
70      </div>
71    </div>
72
73    <div class="wrapper">
74
75      
76    <div>
77        <h1><a href="2025-01-02-running-round-malaren.html">Why can&#39;t I just run around Lake Mälaren?</a></h1>
78        <p><img src="assets/2025-01-02-running-round-malaren-preview.png" alt="A lakeshore under a blue sky" title="Which way is Mälaren, Gandalf?" width=800px /></p><p>"Is this the lake we're running around?"</p><p>My sister uttered these fateful words on a sunny day in May 2024, knowing not the events she would set into motion. For the lake she was referring to, dear reader, was none other than <a href='https://en.wikipedia.org/wiki/M%C3%A4laren'>Lake
79Mälaren</a>, which:</p><blockquote><p> is the third-largest freshwater lake in Sweden (after Vänern and Vättern). Its  area is 1,140 km2 (440 sq mi) and its greatest depth is 64 m (210 ft). Mälaren  spans 120 km (74.6 mi) from east to west. </p></blockquote><p>I responded flippantly at the time, probably something to the tune of "How much time you got?", and she laughed, but unbeknownst to me, an idea was planted in the fertile soil of her mind, one which would sprout months later in a text conversation.</p><p>"So Josh," she probably wrote (I'm far too lazy to look up what she actually wrote) "remember when I asked you about running around that lake? Why don't you just do it?"</p><p>"OMG what is wrong with you?" I possibly responded. "Don't you remember me saying that it's like a million miles"&ndash;I have no idea how many miles 300-ish kilometres is&ndash;"in circumference?"</p><p>"Yeah," she maybe said, "but what if you didn't do it all at once? You know how Grandpop hiked the entire <a href='https://en.wikipedia.org/wiki/Appalachian_Trail'>Appalachian
80Trail</a> in fits and starts? You could do the same thing, right?"</p><p>She had me there. Whilst I certainly can't run 300-ish kilometres in one go, there's no good reason why I can't run around the damned thing 20-ish kilometres at a time. Just rinse and repeat 15 times and Bob's my uncle, right? (Actually, Will's my uncle, but that's neither here nor there.)</p><p>It just so happened that around the same time, in a great fit of irony, I ran down to the lake from my house for a swim, capped off as always by a dive off a 5 metre platform deployed for the amusement of beachgoers (save when the lake is actually frozen over, because diving headfirst into 
80ice is discouraged by 9 of 10 leading brain surgeons).</p><p><img src="assets/break-the-ice-dude.gif" alt="A man tries to jump into a frozen pool" title="Don't do this!" /></p><p>I've done this hundreds of times, but when I hit the water this time, my left hand caught the water in a way that caused my left arm to rotate in a direction that the human shoulder joint is apparently not designed to do, and since I'm famously "over 35", that led to my shoulder hurting whenever I raise my left arm above my head. 🙄</p><p>So I didn't run for a few months, but I did start plotting my path around the lake, and last week, on a particularly mild December day (it was 9° C, somehow), I decided to take a run clockwise down the lakeshore, thinking I could always stop and walk back to a bus stop or <a href='https://en.wikipedia.org/wiki/Stockholm_Metro'>tunnelbana</a> station if my shoulder started hurting mid-run. My plan was to first run down to my usual beach (4.5 kilometres from my house), then continue down the shore to <a href='https://en.wikipedia.org/wiki/Alvik_metro_station'>Alvik
81station</a> on the Green line, which I guessed might be 15-ish kilometres from the beach. In addition to my usual Runkeeper app (which I plan to replace with a ClojureScript app in Scittle one of these days), I tracked my run with a new app I had installed, <a href='https://organicmaps.app/'>Organic
82Maps</a>, which uses <a href='https://www.openstreetmap.org/about'>OpenStreetMap</a> data and can export GPX files.</p><p>To cut to the chase (something I find difficult to be sure), I made it down to Alvik!</p><p><img src="assets/2025-01-02-malaren-circuit.png" alt="A map with my first segment highlighted" title="Do this!" /></p><p>The run turned out to be 14.07 km in total, which meant that I got just under 10 km of the way around Mälaren. Only 30 more runs to go! 🎉</p><p><img src="assets/2025-01-02-malaren-entire.png" alt="A map zoomed out to show the entirety of the lake, with my first segment highlighted" title="OMG how?" /></p>
83        
84        <div class="post-footer">
85            <div class="post-details">
86
87                <div>
88                    🏷
89
90                    <span class="tag">
91                        <a href="tags/running.html">running</a>
92                    </span>
93
94                </div>
95
96
97                <div>📝 Published: 2025-01-02</div>
98            </div>
99
100        </div>
101    </div>
102
103    <div>
104        <h1><a href="2025-01-01-why-cant-i-just.html">Why can&#39;t I just?</a></h1>
105        <p><img src="assets/2025-01-01-why-cant-i-just-resolution.jpg" alt="A picture of a three-monitor computer display labelled &quot;My New Year's resolution is 5760x1080&quot;" title="Now that's a wide load!" width=800px /></p><p>I'm not one for New Year's resolutions, so instead I'll simply ask myself some questions.</p><p>Why can't I just</p><ul><li>go to the gym three times a week?</li><li>get 10,000 steps a day even without Rover pulling me along?</li><li>go for a nice long run at least once a week, even if there's snow on the  ground?</li><li>write something in my blog every morning, even if it's just a series of random  thoughts or a bawdy limerick of my own composition?</li><li>write anything in my blog that's fewer than 10,000 words? 🙄</li><li>finish my draft of the <a href='https://jmglov.net/blog/draft-heart-of-clojure-2024.html'>Heart of Clojure conference
106  report</a>?</li><li>record Season 1 of <a href='https://politechspod.com/'>Politechs</a> with Ray?</li><li>finish writing my <a href='https://otranscribe.com/'>oTranscribe</a> clone with Scittle?</li><li>take part 2 of the amazing <a href='https://www.nand2tetris.org/'>Nand to Tetris</a>  course?</li><li>read more of "Crepúsculo: Un amor peligroso"?</li><li>OMG finally stop eating cheese already?</li></ul><p>The answer to all of these questions is basically "you can just, so why not just?"</p><p>Happy New Year! 🎉</p>
107        
108        <div class="post-footer">
109            <div class="post-details">
110
111                <div>
112                    🏷
113
114                    <span class="tag">
115                        <a href="tags/waffle.html">waffle</a>
116                    </span>
117
118                    <span class="tag">
119                        <a href="tags/writing.html">writing</a>
120                    </span>
121
122                </div>
123
124
125                <div>📝 Published: 2025-01-01</div>
126            </div>
127
128        </div>
129    </div>
130
131    <div>
132        <h1><a href="2024-09-18-podcast-soundcljoud.html">Building a podcast with Clojure</a></h1>
133        <p>In addition to spending far too much of my time doing silly things with Clojure and then even farther too much of my time writing about doing silly things with Clojure, I spend some of my time thinking about, talking about, and participating in labour organising here in Sweden. As I was talking about unions and such to Ray one day, no doubt six tangents into one of my usual rambling explorations of an idea, he interrupted my flow. "Stop!" he said, "for I have a plan so cunning you could pin a tail on it and call it a weasel!" Curiosity piqued, I enquired as to the nature of said plan. "We should make a podcast," he continued, "and on this podcast, we should talk about tech workers and why it makes sense for them to unionise. And we should focus on Sweden, since it's a fairly unique labour market, plus you know interesting people who we could interview."</p><p>"Ray," I rejoined, "that truly is a plan of weasel-grade cunning. I have but one suggestion that will turn this good idea into a great one." "And what," quoth he, "pray tell, is that suggestion?" "Babashka! Scittle! Clojure!" I exclaimed, so full of excitement I was having troubling supporting my proper nouns with clauses of explanatory power. "We could use all of this amazing technology for all of the heavy lifting around making a podcast! We could <a href='2022-06-24-s3-https.html'>build a website
134using S3 static hosting</a>, then we could use an approach similar to <a href='https://jmglov.net/blog/tags/blog.html'>how I built my
135blog</a> to create pages for episodes with show notes and transcripts and all that good stuff!"</p><p>So it was agreed, and thus <a href='https://orgtech.se'>
135Organising Tech in Sweden</a> came to be.</p><p><img src="assets/2024-09-18-podcast-soundcljoud-orgtech.png" alt="The words 'Organising Tech in Sweden' superimposed on raised fists with a Swedish flag with a circuit board pattern in the background" title="Divided we beg, united we bargain!" width=512px /></p><h2 id="building_the_website">Building the website</h2><p>As with any of my recent projects, my first step is always to create a directory and drop a <a href='https://github.com/babashka/scittle/'>Scittle</a>-enabled <code>bb.edn</code> in it:</p><pre class="language-text"><code class="lang-text language-text">: &#126;; mkdir &#126;/code/orgtech-se
136: &#126;; cd !$
137</code></pre><p><strong>bb.edn</strong></p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
138        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
139        io.github.babashka/http-server
140        {:git/sha &quot;e203166a020509d126149ff8046489857ce5c89f&quot;}}
141 :tasks
142 {http-server {:doc &quot;Starts http server for serving static files&quot;
143               :requires &#40;&#91;babashka.http-server :as http&#93;&#41;
144               :task &#40;do &#40;http/serve {:port 1341 :dir &quot;public&quot;}&#41;
145                         &#40;println &quot;Serving static assets at http://localhost:1341&quot;&#41;&#41;}
146
147  browser-nrepl {:doc &quot;Start browser nREPL&quot;
148                 :requires &#40;&#91;sci.nrepl.browser-server :as bp&#93;&#41;
149                 :task &#40;bp/start! {}&#41;}
150
151  -dev {:depends &#91;http-server browser-nrepl&#93;}
152
153  dev {:task &#40;do &#40;run '-dev {:parallel true}&#41;
154                 &#40;deref &#40;promise&#41;&#41;&#41;}}}
155</code></pre><p>Since this is a static website, we can create a static <code>public/index.html</code> for it, with the <a href='2022-07-05-hacking-blog-favicon.html'>usual favicon</a> and <a href='2022-08-17-hacking-blog-sharing.html'>social
156sharing</a> stuff:</p><pre class="language-html"><code class="lang-html language-html">&lt;html xmlns=&quot;http://www.w3.org/1999/xhtml&quot;&gt;
157
158&lt;head&gt;
159  &lt;title&gt;
159Organising Tech in Sweden Podcast&lt;/title&gt;
160
161  &lt;meta charset=&quot;utf-8&quot; /&gt;
162  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width&quot; /&gt;
163
164  &lt;link rel=&quot;stylesheet&quot; href=&quot;/css/main.css&quot;&gt;
165
166  &lt;!-- Favicon from https://realfavicongenerator.net/ --&gt;
167  &lt;link rel=&quot;apple-touch-icon&quot; sizes=&quot;180x180&quot; href=&quot;/apple-touch-icon.png&quot;&gt;
168  &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;32x32&quot; href=&quot;/favicon-32x32.png&quot;&gt;
169  &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;16x16&quot; href=&quot;/favicon-16x16.png&quot;&gt;
170  &lt;link rel=&quot;manifest&quot; href=&quot;/site.webmanifest&quot;&gt;
171  &lt;link rel=&quot;mask-icon&quot; href=&quot;/safari-pinned-tab.svg&quot; color=&quot;#5bbad5&quot;&gt;
172  &lt;meta name=&quot;msapplication-TileColor&quot; content=&quot;#da532c&quot;&gt;
173  &lt;meta name=&quot;theme-color&quot; content=&quot;#ffffff&quot;&gt;
174
175  &lt;!-- Social sharing &#40;Facebook, Twitter, LinkedIn, etc.&#41; --&gt;
176  &lt;meta name=&quot;title&quot; content=&quot;
176Organising Tech in Sweden&quot;&gt;
177  &lt;meta name=&quot;twitter:title&quot; content=&quot;Organising Tech in Sweden&quot;&gt;
178  &lt;meta property=&quot;og:title&quot; content=&quot;Organising Tech in Sweden&quot;&gt;
179  &lt;meta property=&quot;og:type&quot; content=&quot;website&quot;&gt;
180
181  &lt;meta name=&quot;description&quot; content=&quot;A limited podcast series exploring union organising in Swedish tech companies&quot;&gt;
182  &lt;meta name=&quot;twitter:description&quot;
183    content=&quot;A limited podcast series exploring union organising in Swedish tech companies&quot;&gt;
184  &lt;meta property=&quot;og:description&quot; content=&quot;A limited podcast series exploring union organising in Swedish tech companies&quot;&gt;
185
186  &lt;meta name=&quot;twitter:url&quot; content=&quot;https://orgtech.se/&quot;&gt;
187  &lt;meta property=&quot;og:url&quot; content=&quot;https://orgtech.se/&quot;&gt;
188
189  &lt;meta name=&quot;twitter:image&quot; content=&quot;https://orgtech.se/img/orgtech-se-preview.jpg&quot;&gt;
190  &lt;meta name=&quot;twitter:card&quot; content=&quot;summary&#95;large&#95;image&quot;&gt;
191  &lt;meta property=&quot;og:image&quot; content=&quot;https://orgtech.se/img/orgtech-se-preview.jpg&quot;&gt;
192  &lt;meta property=&quot;og:image:alt&quot;
193    content=&quot;
193Podcast logo: 'Organising Tech in Sweden' superimposed on raised fists with a Swedish flag with a circuit board pattern in the background&quot;&gt;
194&lt;/head&gt;
195
196&lt;body&gt;
197  &lt;div id=&quot;wrapper&quot;&gt;
198    &lt;div id=&quot;left-side&quot;&gt;
199      &lt;div id=&quot;cover-image&quot;&gt;
200        &lt;img src=&quot;/img/orgtech-se-cover.jpg&quot;
201          title=&quot;Organising Tech in Sweden&quot;
202          alt=&quot;Podcast logo: 'Organising Tech in Sweden' superimposed on raised fists with a Swedish flag with a circuit board pattern in the background&quot; /&gt;
203      &lt;/div&gt;
204      &lt;div id=&quot;aggregators-1&quot;&gt;
205        &lt;div id=&quot;apple&quot;&gt;
206          &lt;a class=&quot;apple-button&quot;
207            href=&quot;https://podcasts.apple.com/us/podcast/organising-tech-in-sweden/id1766442275?itsct=podcast&#95;box&#95;badge&amp;amp;itscg=30200&amp;amp;ls=1&quot;&gt;
208            &lt;img src=&quot;https://tools.applemediaservices.com/api/badges/listen-on-apple-podcasts/badge/en-us?size=250x83&amp;amp;releaseDate=1725494400&quot;
209              title=&quot;Listen on Apple Podcasts&quot;
210              alt=&quot;Listen on Apple Podcasts&quot;
211              class=&quot;apple-button&quot;&gt;
212          &lt;/a&gt;
213        &lt;/div&gt;
214        &lt;div id=&quot;spotify&quot;&gt;
215          &lt;a href=&quot;https://open.spotify.com/show/53psoLoX187axvmgb80l1x&quot;&gt;
216            &lt;img src=&quot;/img/spotify-podcast-badge-blk-grn-330x80.svg&quot;
217              title=&quot;Listen on Spotify&quot;
218              alt=&quot;Listen on Spotify&quot;&gt;
219          &lt;/a&gt;
220        &lt;/div&gt;
221      &lt;/div&gt;
222      &lt;div id=&quot;aggregators-2&quot;&gt;
223        &lt;div id=&quot;podbean&quot;&gt;
224          &lt;a href=&quot;https://www.podbean.com/podcast-detail/2r2tz-31b053/Organising-Tech-in-Sweden-Podcast&quot;
225            rel=&quot;noopener noreferrer&quot; target=&quot;&#95;blank&quot;&gt;
226            &lt;img src=&quot;https://pbcdn1.podbean.com/fs1/site/images/badges/w600&#95;1.png&quot;
227              title=&quot;Listen on Podbean&quot;
228              alt=&quot;Listen on Podbean&quot;&gt;
229          &lt;/a&gt;
230        &lt;/div&gt;
231      &lt;/div&gt;
232    &lt;/div&gt;
233    &lt;div id=&quot;main&quot;&gt;
234      &lt;div id=&quot;header&quot;&gt;
235        &lt;h1 id=&quot;title&quot; class=&quot;header&quot;&gt;Episode 1 is out now!&lt;/h1&gt;
236        &lt;!-- &lt;h1 id=&quot;title&quot; class=&quot;header&quot;&gt;&lt;a href=&quot;episodes/&quot;&gt;Episodes&lt;/a&gt;&lt;/h1&gt; --&gt;
237        &lt;div id=&quot;socials&quot;&gt;
238          &lt;a href=&quot;https://x.com/orgtech&#95;se&quot;&gt;
239            &lt;img src=&quot;/img/twitter-color-svgrepo-com.svg&quot;
240              title=&quot;Follow us on Twitter!&quot;
241              alt=&quot;Twitter logo&quot; /&gt;
242          &lt;/a&gt;
243          &lt;a href=&quot;https://bsky.app/profile/orgtech-se.bsky.social&quot;&gt;
244            &lt;img src=&quot;/img/bluesky-logo.svg&quot;
245              title=&quot;Follow us on Bluesky!&quot;
246              alt=&quot;Bluesky logo&quot; /&gt;
247          &lt;/a&gt;
248        &lt;/div&gt;
249      &lt;/div&gt;
250      &lt;div class=&quot;text&quot;&gt;
251        &lt;p&gt;
252          Organising Tech in Sweden is a limited podcast series exploring union
253          organising in Swedish tech companies. Join us as we sit down with some
254          of the people involved in the campaigns to win collective bargaining
255          rights at two of Sweden's tech unicorns, Klarna and Spotify.
256        &lt;/p&gt;
257        &lt;div id=&quot;production-info&quot;&gt;
258          &lt;div&gt;
259            &lt;p&gt;
260              Listen to our latest episode:&lt;br /&gt;
261              🔊 &lt;a href=&quot;/episodes/ep01-klarna-part1&quot;&gt;Organising Klarna - Part 1&lt;/a&gt;
262            &lt;/p&gt;
263            &lt;p&gt;
264              Produced by Hakuna Matata Produktion
265            &lt;/p&gt;
266            &lt;p&gt;
267              Cover art by &lt;a href=&quot;https://anyakjordan.com/&quot;&gt;Anya K. Jordan&lt;/a&gt;
268              &lt;a href=&quot;https://bsky.app/profile/anyakjordan.bsky.social&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
269            &lt;/p&gt;
270            &lt;p&gt;
271              Theme music by &lt;a href=&quot;https://soundcloud.com/ptzery&quot;&gt;Ptzery&lt;/a&gt;
272            &lt;/p&gt;
273          &lt;/div&gt;
274          &lt;div id=&quot;hmp-logo&quot;&gt;
275            &lt;img src=&quot;/img/hakuna-matata-produktion.png&quot;
276              title=&quot;Hakuna Matata Produktion&quot;
277              alt=&quot;Hakuna Matata Produktion logo&quot;&gt;
278          &lt;/div&gt;
279        &lt;/div&gt;
280      &lt;/div&gt;
281    &lt;/div&gt;
282  &lt;/div&gt;
283  &lt;div id=&quot;news&quot;&gt;
284    &lt;h1&gt;News&lt;/h1&gt;
285    &lt;h2&gt;Episode 1 is out!&lt;/h2&gt;
286    &lt;p&gt;🔊 &lt;a href=&quot;/episodes/ep01-klarna-part1&quot;&gt;Organising Klarna - Part 1&lt;/a&gt;&lt;/p&gt;
287    &lt;p&gt;
288      We kick off Organising Tech in Sweden in style by recounting the story of
289      how a collective bargaining agreement &#40;CBA&#41; was won at Klarna, a major
290      Swedish fintech. In fact, Klarna was the first unicorn in Sweden to be
291      unionised &#40;and probably the first unicorn in Europe as well&#41;!
292    &lt;/p&gt;
293    &lt;p&gt;
294      To hear all about how this went down, your co-hosts Josh and Ray are joined by
295      Thomas, the founder of the Klarna Unionen Club &#40;a union &quot;local&quot;, to use
296      terminology that might be more familiar to US listeners&#41;; Sen, the chair of
297      the club who won the bargaining agreement against the odds; and Kim, a former
298      Klarna employee with extensive knowledge of Swedish labour law and market
299      policy.
300    &lt;/p&gt;
301  &lt;/div&gt;
302&lt;/body&gt;
303
304&lt;/html&gt;
305</code></pre><p>We can grab all the nice images and such from the interwebs:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; curl \
306  https://orgtech.se/orgtech-se-favicon-and-img.tar.gz \
307  | tar xvz -C public
308</code></pre><p>And of course we need to make it nice and responsive so it looks good both on a computer screen and a mobile phone screen. Let's create <code>public/css/main.css</code> and drop some stylish styles therein:</p><pre class="language-css"><code class="lang-css language-css">body {
309  font:
310    1.2em Helvetica,
311    Arial,
312    sans-serif;
313  margin: 20px;
314  padding: 0;
315}
316
317body &gt; div {
318  max-width: 100%;
319  margin-left: auto;
320  margin-right: auto;
321}
322
323img {
324  max-width: 100%;
325}
326
327a {
328  text-decoration: none;
329  &amp;:hover {
330    text-decoration: underline;
331  }
332}
333
334#header {
335  display: flex;
336  justify-content: space-between;
337  align-items: center;
338  gap: 10px;
339}
340
341h1 {
342  font-weight: bold;
343  font-size: larger;
344}
345
346#socials {
347  display: flex;
348  gap: 10px;
349}
350
351#socials img {
352  max-width: 32px;
353  &amp;:hover {
354    transform: scale&#40;1.1&#41;;
355  }
356}
357
358@media screen and &#40;min-width: 600px&#41; {
359
360  body &gt; div {
361    max-width: 800px;
362    margin-top: 1em;
363  }
364
365  #wrapper {
366    display: flex;
367  }
368
369  #cover-image {
370    margin-right: 20px;
371    max-width: 40%;
372  }
373
374}
375</code></pre><p>Now we can fire up a local webserver:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; bb dev
376Serving assets at http://localhost:1341
377Serving static assets at http://localhost:1341
378nREPL server started on port 1339...
379Websocket server started on 1340...
380</code></pre><p>Gaze ye now upon the glories of http://localhost:1341!</p><p><img src="assets/2024-09-18-podcast-soundcljoud-index.png" alt="The words 'Organising Tech in Swede
380n' superimposed on raised fists with a Swedish flag with a circuit board pattern in the background" title="What even is a computer?" width=800px border=1 /></p><h2 id="publishing_the_website">Publishing the website</h2><p>We of course have already registered a domain and done the intricate dance of <a href='2022-06-24-s3-https.html'>setting up S3 static website hosting and CloudFront and all of
381that</a>, so all we need to do to publish our website is copy some files into our S3 bucket. And of course, what better way to do this than with a <a href='https://book.babashka.org/#tasks'>Babashka task</a>?</p><p>As avid REPL-drivers, we want to use our REPL for task development as well, so the first thing we do is create a <code>tasks.clj</code> with a boring <code>publish</code> function in it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks&#41;
382
383&#40;defn publish &#91;{:keys &#91;website-bucket out-dir&#93; :as opts}&#93;
384  &#40;println &#40;format &quot;Publishing %s/ to s3://%s/&quot;
385                   out-dir website-bucket&#41;&#41;&#41;
386</code></pre><p>Now we need to hook that up to <code>bb.edn</code> by setting the classpath appropriately, pulling in our new <code>tasks</code> namespace, defining some options, and adding a <code>publish</code> task:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
387 :paths &#91;&quot;.&quot;&#93;
388 :tasks
389 {:requires &#40;&#91;tasks&#93;&#41;
390  :init &#40;def opts
391          {:website-bucket &quot;orgtech.se&quot;
392           :out-dir &quot;public&quot;}&#41;
393  ;; ...
394  publish &#40;tasks/publish opts&#41;}}
395</code></pre><p>We can now test this:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; bb publish
396Publishing public/ to s3://orgtech.se/
397</code></pre><p>Jumping back to <code>tasks.clj</code>, we fire up a trusty <a href='https://docs.cider.mx/cider/index.html'>CIDER</a> REPL with a <strong>C-c M-j</strong> (<code>cider-jack-in-clj</code>) flourish, followed by <strong>C-c C-k</strong> (<code>cider-load-buffer</code>) to evaluate the buffer (readers following along with an <a href='https://www.vim.org/'>inferior</a> <a href='https://www.jetbrains.com/idea/'>text</a> <a href='https://code.visualstudio.com/'>editor</a> will have to perform whatever complex ritual necessary to start a REPL and connect to it and then evaluate the "file" or whatever your text editor calls the thing you're editing).</p><p>Thus equipped, we can open up a <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
398comment</a>, define some <code>opts</code>, and evaluate our <code>publish</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
399
400  &#40;def opts {:website-bucket &quot;orgtech.se&quot;
401             :out-dir &quot;public&quot;}&#41;  ; C-c C-v f c e
402  ;; =&gt; #'tasks/opts
403
404  &#40;publish opts&#41;  ; C-c C-e
405
406  &#41;
407</code></pre><p>Our REPL buffer now looks something like this:</p><pre class="language-text"><code class="lang-text language-text">Started nREPL server at 127.0.0.1:44571
408For more info visit: https://book.babashka.org/#&#95;nrepl
409;; Connected to nREPL server - nrepl://127.0.0.1:44571
410;; CIDER 1.12.0 &#40;Split&#41;, babashka.nrepl 0.0.6-SNAPSHOT
411;; Babashka 1.3.188
412;;     Docs: &#40;doc function-name&#41;
413;;           &#40;find-doc part-of-name&#41;
414;;   Source: &#40;source function-name&#41;
415;;  Javadoc: &#40;javadoc java-object-or-class&#41;
416;;     Exit: &lt;C-c C-q&gt;
417;;  Results: Stored in vars &#42;1, &#42;2, &#42;3, an exception in &#42;e;
418;;  Startup: /home/jmglov/.nix-profile/bin/bb nrepl-server localhost:0
419Publishing public/ to s3://orgtech.se/
420user&gt; 
421</code></pre><p>OK, now it's time to figure out how to do the actual copying of files to S3. We could of course use the spectacular <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a> to do stuff to AWS right from our Clojure code, but that smacks of effort. 🤔</p><p>Fortunately, we remember that Babashka was originally conceived as a replacement for Bash shell scripting (I mean, the "bash" is right there in the name, so that's kind of a major clue), and we know that there's an <a href='https://aws.amazon.com/cli/'>AWS command line
422tool</a> that knows how to sync stuff from a local directory to S3:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; aws s3 sync help
423SYNC&#40;&#41;                                                                  SYNC&#40;&#41;
424
425NAME
426       sync -
427
428DESCRIPTION
429       Syncs  directories  and S3 prefixes. Recursively copies new and updated
430       files from the source directory to the destination. Only creates  fold-
431       ers in the destination if they contain one or more files.
432
433SYNOPSIS
434            sync
435          &lt;LocalPath&gt; &lt;S3Uri&gt; or &lt;S3Uri&gt; &lt;LocalPath&gt; or &lt;S3Uri&gt; &lt;S3Uri&gt;
436
437&#91;...&#93;
438
439EXAMPLES
440
441       The following sync command syncs objects from a local diretory  to  the
442       specified  prefix and bucket by uploading the local files to s3.  A lo-
443       cal file will require uploading if the size of the local file  is  dif-
444       ferent  than  the  size of the s3 object, the last modified time of the
445       local file is newer than the last modified time of the  s3  object,  or
446       the  local  file  does not exist under the specified bucket and prefix.
447       In this example, the user syncs the bucket mybucket to the  local  cur-
448       rent  directory.   The  local  current  directory  contains  the  files
449       test.txt and test2.txt.  The bucket mybucket contains no objects:
450
451          aws s3 sync . s3://mybucket
452
453       Output:
454
455          upload: test.txt to s3://mybucket/test.txt
456          upload: test2.txt to s3://mybucket/test2.txt
457
458&#91;...&#93;
459</code></pre><p>This looks like just the thing we need, so let's use the power of <a href='https://github.com/babashka/process'>babashka.process</a> to invoke <code>aws s3 sync</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
460  &#40;:require &#91;babashka.process :as p&#93;&#41;&#41;
461
462&#40;defn publish &#91;{:keys &#91;website-bucket out-dir&#93; :as opts}&#93;
463  &#40;let &#91;sync-cmd &#91;&quot;aws s3 sync&quot;
464                  &#40;format &quot;%s/&quot; out-dir&#41;
465                  &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#93;
466    &#40;apply println sync-cmd&#41;
467    &#40;apply p/shell sync-cmd&#41;&#41;&#41;
468
469&#40;comment
470
471  &#40;def opts {:website-bucket &quot;orgtech.se&quot;
472             :out-dir &quot;public&quot;}&#41;  ; C-c C-v f c e
473  ;; =&gt; #'tasks/opts
474
475  &#40;publish opts&#41;  ; C-c C-e
476
477  &#41;
478</code></pre><p>After a brief delay, our REPL buffer now helpfully tells us:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync public/ s3://orgtech.se/
479user&gt; 
480</code></pre><p>And if we have a look in that there bucket, we see some files:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; aws s3 ls --recursive s3://orgtech.se/
4812024-08-23 10:40:05     102613 android-chrome-192x192.png
4822024-08-23 10:40:05     337153 android-chrome-512x512.png
4832024-08-23 10:40:05      96934 apple-touch-icon.png
4842024-08-23 10:40:05        246 browserconfig.xml
4852024-08-23 10:40:05        720 css/main.css
4862024-08-23 10:40:05      47189 favicon-16x16.png
4872024-08-23 10:40:05      48597 favicon-32x32.png
4882024-08-23 10:40:05      12014 favicon.ico
4892024-08-23 10:40:05        745 img/bluesky-logo.svg
4902024-08-23 10:40:05    3231594 img/orgtech-se-cover.jpg
4912024-08-23 10:40:05     513884 img/orgtech-se-preview.jpg
4922024-08-23 10:40:05       1943 img/twitter-color-svgrepo-com.svg
4932024-08-23 10:40:05       1933 img/volume.png
4942024-08-23 10:40:05       3074 index.html
4952024-08-23 10:40:05      33084 mstile-150x150.png
4962024-08-23 10:40:05        426 site.webmanifest
497</code></pre><p>And now browsing to <a href='https://orgtech.se/'>https://orgtech.se/</a> reveals a lovely little website that looks just like the one on http://localhost:1341. 🎉</p><p>Let's change the header in <code>public/index.html</code> to test out the syncing:</p><pre class="language-html"><code class="lang-html language-html">        &lt;h1 id=&quot;title&quot; class=&quot;header&quot;&gt;Coming Thursday, 12 September!&lt;/h1&gt;
498</code></pre><p>Before we YOLO eval our <code>publish</code> function again, we notice that <code>aws s3 sync</code> has a lovely little <code>--dryrun</code> option, which doesn't actually do the stuff but rather prints out what stuff it would do. Let's implement this!</p><p><strong>tasks.clj</strong></p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn publish &#91;{:keys &#91;website-bucket out-dir dryrun&#93;
499                :as opts}&#93;
500  &#40;let &#91;sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
501                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
502                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
503                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;&#93;
504    &#40;apply println sync-cmd&#41;
505    &#40;apply p/shell sync-cmd&#41;&#41;&#41;
506
507&#40;comment
508
509  &#40;publish &#40;assoc opts :dryrun true&#41;&#41;  ; C-c C-e
510
511  &#41;
512</code></pre><p>The REPL window helpfully says:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync --dryrun public/ s3://orgtech.se/
513user&gt; 
514</code></pre><p>but we don't see the output of the <code>aws s3 sync</code> command itself. This is due to the REPL not capturing stdout for the subprocess, I guess. We can handle this thusly:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
515  &#40;:require &#91;babashka.process :as p&#93;&#41;&#41;
516
517&#40;defn shell &#91;&amp; args&#93;
518  &#40;let &#91;p &#40;apply p/shell {:out :string
519                        :err :string
520                        :continue true}
521                 args&#41;&#93;
522    &#40;println &#40;:out p&#41;&#41;
523    &#40;when-not &#40;zero? &#40;:exit p&#41;&#41;
524      &#40;println &#40;:err p&#41;&#41;&#41;
525    p&#41;&#41;
526
527&#40;defn publish &#91;{:keys &#91;website-bucket out-dir dryrun&#93;
528                :as opts}&#93;
529  &#40;let &#91;sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
530                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
531                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
532                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;&#93;
533    &#40;apply println sync-cmd&#41;
534    &#40;apply shell sync-cmd&#41;&#41;&#41;
535
536&#40;comment
537
538  &#40;publish &#40;assoc opts :dryrun true&#41;&#41;  ; C-c C-e
539
540  &#41;
541</code></pre><p>And now the REPL sez:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync --dryrun public/ s3://orgtech.se/
542&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html
543
544user&gt; 
545</code></pre><p>This is what we expect to see: only <code>index.html</code> will be uploaded, since it's the only thing that has changed.</p><p>It would be nice to run this from the command line, but we currently have no way of passing the <code>dryrun</code> option through short of adding it to the <code>opts</code> map in <code>bb.edn</code>. Fortunately for us, there's <a href='https://github.com/babashka/cli'>babashka-cli</a>, which does all sorts of awesome command-line parsing! Let's put it to work:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
546  &#40;:require &#91;babashka.cli :as cli&#93;
547            &#91;babashka.process :as p&#93;&#41;&#41;
548
549;; ...
550
551&#40;comment
552
553  &#40;cli/parse-opts &#91;&quot;--website-bucket&quot; &quot;orgtech.se&quot;
554                   &quot;--out-dir&quot; &quot;public&quot;
555                   &quot;--dryrun&quot;&#93;&#41;
556  ;; =&gt; {:website-bucket &quot;orgtech.se&quot;, :out-dir &quot;public&quot;, :dryrun true}
557
558  &#41;
559</code></pre><p>
559Now we can use <code>parse-opts</code> in our <code>publish</code> function like so:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn publish &#91;default-opts&#93;
560  &#40;let &#91;{:keys &#91;website-bucket out-dir dryrun&#93;
561         :as opts} &#40;merge default-opts
562                          &#40;cli/parse-opts &#42;command-line-args&#42;&#41;&#41;
563        sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
564                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
565                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
566                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;&#93;
567    &#40;apply println sync-cmd&#41;
568    &#40;apply shell sync-cmd&#41;&#41;&#41;
569</code></pre><p>Running this from the command line, we get the desired result:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; bb publish --dryrun
570aws s3 sync --dryrun public/ s3://orgtech.se/
571&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html
572</code></pre><p>And if we omit the <code>--dryrun</code> arg:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; bb publish --dryrun
573aws s3 sync --dryrun public/ s3://orgtech.se/
574upload: public/index.html to s3://orgtech.se/index.html
575</code></pre><p>Amazing!</p><h2 id="do_you_invalidate_parking%3F">Do you invalidate parking?</h2><p>If we open <a href='https://orgtech.se/index.html'>https://orgtech.se/index.html</a> in a browser and view source, however, we get a nasty surprise: the lovely newline we added at the end of the file isn't there! What in the world is going on here?</p><p>Well, it turns out that one of the primary functions of a CDN (Content Distribution Network) like CloudFront is to cache responses so every request that hits an endpoint doesn't have to go all the way back to the origin (in this case, our S3 bucket) to serve the response. So we've fallen prey to #2 in the list of the 4 hardest problems in Computer Science:</p><ol><li>Naming things</li><li>Caching things</li><li>Off by one errors</li></ol><p>What to do, what to do?</p><p>Luckily for us, CloudFront gives us a way to invalidate the cache so the first request for a given endpoint re-fetches from the origin. Even more luckily for us, the AWS CLI surfaces this:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech; aws cloudfront create-invalidation help
576CREATE-INVALIDATION&#40;&#41;                                    CREATE-INVALIDATION&#40;&#41;
577
578NAME
579       create-invalidation -
580
581DESCRIPTION
582       Create a new invalidation.
583
584       See also: AWS API Documentation
585
586SYNOPSIS
587            create-invalidation
588          --distribution-id &lt;value&gt;
589          &#91;--paths &lt;value&gt;&#93;
590&#91;...&#93;
591
592OPTIONS
593       --distribution-id &#40;string&#41;  The distribution's id.
594       --paths  &#40;string&#41;           The space-separated  paths to be invalidated.
595&#91;...&#93;
596</code></pre><p>So what we can do is create an invalidation right after syncing to the S3 bucket in our <code>publish</code> function. In order to do this, we'll need a distribution ID. Let's ask CloudFront about the distributions we have:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech; aws cloudfront list-distributions \
597  | bb -i '&#40;let &#91;ds &#40;-&gt; &#40;str/join &quot;\n&quot; &#42;input&#42;&#41;
598                    &#40;json/parse-string true&#41;
599                    &#40;get-in &#91;:DistributionList :Items&#93;&#41;&#41;&#93;
600             &#40;map &#40;juxt #&#40;get-in % &#91;:Aliases :Items 0&#93;&#41; :Id&#41; ds&#41;&#41;'
601&#40;&#91;&quot;www.jmglov.net&quot; &quot;F2ABC12UVWXYZ9&quot;&#93;
602 &#91;&quot;politechspod.com&quot; &quot;F7E33IJKLMN0P6&quot;&#93;
603 &#91;&quot;www.orgtech.se&quot; &quot;FDCBA42RSTUV3&quot;&#93;&#41;
604</code></pre><p>This looks like the one we're after:</p><pre class="language-text"><code class="lang-text language-text">&#91;&quot;www.orgtech.se&quot; &quot;FDCBA42RSTUV3&quot;&#93;
605</code></pre><p>Let's go ahead and add the distribution ID to our <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
606 {:requires &#40;&#91;tasks&#93;&#41;
607  :init &#40;def opts
608          {:website-bucket &quot;orgtech.se&quot;
609           :out-dir &quot;public&quot;
610           :distribution-id &quot;FDCBA42RSTUV3&quot;}&#41;
611  ;; ...
612  }}
613</code></pre><p>
613Now we can use this in <code>tasks.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn publish &#91;default-opts&#93;
614  &#40;let &#91;{:keys &#91;website-bucket out-dir distribution-id dryrun&#93;
615         :as opts} &#40;merge default-opts
616                          &#40;cli/parse-opts &#42;command-line-args&#42;&#41;&#41;
617        sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
618                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
619                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
620                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;
621        invalidate-cmd &#91;&quot;aws cloudfront create-invalidation&quot;
622                        &quot;--distribution-id&quot; distribution-id
623                        &quot;--paths&quot; :???&#93;&#93;
624        ;; ...
625      &#41;&#41;
626</code></pre><p>OK, now where can we get our paths? Well, recall that <code>aws s3 sync --dryrun</code> helpfully outputs what is to be done:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync --dryrun public/ s3://orgtech.se/
627&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html
628</code></pre><p>Let's consume this from Babashka to grab the paths! First, we'll dirty the dishes:</p><pre class="language-text"><code class="lang-text language-text">: orgtech-se; touch public/index.html public/css/main.css 
629
630: orgtech-se; aws s3 sync --dryrun public/ s3://orgtech.se/
631&#40;dryrun&#41; upload: public/css/main.css to s3://orgtech.se/css/main.css
632&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html
633</code></pre><p>And then parse that output in our <code>tasks.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
634  &#40;:require ; ...
635            &#91;clojure.string :as str&#93;&#41;&#41;
636
637&#40;comment
638
639  &#40;def default-opts {:website-bucket &quot;orgtech.se&quot;
640                     :out-dir &quot;public&quot;
641                     :distribution-id &quot;FDCBA42RSTUV3&quot;}&#41;  ; C-c C-v f c e
642  ;; =&gt; #'tasks/default-opts
643
644  &#40;-&gt;&gt; &#40;shell &quot;aws s3 sync --dryrun public/ s3://orgtech.se/&quot;&#41;
645       :out
646       str/split-lines
647       &#40;map #&#40;str/replace % #&quot;&#94;&#91;&#40;&#93;dryrun&#91;&#41;&#93; upload: public&#40;/\S+&#41; to .+$&quot; &quot;$1&quot;&#41;&#41;&#41;
648  ;; =&gt; &#40;&quot;/css/main.css&quot; &quot;/index.html&quot;&#41;
649
650  &#41;
651</code></pre><p>Now that we know how to determine which files have changed, let's plug this into our <code>publish</code> function to add to the <code>aws cloudfront create-invalidation</code> command:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn publish &#91;default-opts&#93;
652  &#40;let &#91;{:keys &#91;website-bucket out-dir distribution-id dryrun&#93;
653         :as opts} &#40;merge default-opts
654                          &#40;cli/parse-opts &#42;command-line-args&#42;&#41;&#41;
655        sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
656                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
657                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
658                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;
659        ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
660        paths-re &#40;re-pattern &#40;format &quot;&#94;&#91;&#40;&#93;dryrun&#91;&#41;&#93; upload: %s&#40;/\\S+&#41; to .+$&quot;
661                                     out-dir&#41;&#41;
662        invalidate-cmd &#40;concat &#91;&quot;aws cloudfront create-invalidation&quot;
663                                &quot;--distribution-id&quot; distribution-id
664                                &quot;--paths&quot;&#93;
665                               &#40;-&gt;&gt; &#40;apply shell &#40;concat sync-cmd &#91;&quot;--dryrun&quot;&#93;&#41;&#41;
666                                    :out
667                                    str/split-lines
668                                    &#40;map #&#40;str/replace % paths-re &quot;$1&quot;&#41;&#41;&#41;&#41;
669        ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆
670        &#93;
671    &#40;apply println sync-cmd&#41;
672    &#40;apply shell sync-cmd&#41;
673    ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇
674    &#40;apply println invalidate-cmd&#41;
675    &#40;when-not dryrun
676      &#40;apply shell invalidate-cmd&#41;&#41;
677    ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆
678    &#41;&#41;
679
680&#40;comment
681
682  &#40;publish &#40;assoc default-opts :dryrun true&#41;&#41; ; C-c C-e
683
684  &#41;
685</code></pre><p>Our REPL buffer duly notes:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync --dryrun public/ s3://orgtech.se/
686&#40;dryrun&#41; upload: public/css/main.css to s3://orgtech.se/css/main.css
687&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html
688
689aws cloudfront create-invalidation --distribution-id FDCBA42RSTUV3
690                                   --paths /css/main.css /index.html
691</code></pre><p>Looks good, so let's try it for realz:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech; bb publish
692aws s3 sync public/ s3://orgtech.se/
693aws cloudfront create-invalidation --distribution-id FDCBA42RSTUV3
694                                   --paths /css/main.css /index.html
695{
696    &quot;Location&quot;: &quot;https://cloudfront.amazonaws.com/2020-05-31/distribution/FDCBA42RSTUV3/invalidation/ICECSBHVIW089I89RLYODUBMXI&quot;,
697    &quot;Invalidation&quot;: {
698        &quot;Id&quot;: &quot;ICECSBHVIW089I89RLYODUBMXI&quot;,
699        &quot;Status&quot;: &quot;InProgress&quot;,
700        &quot;CreateTime&quot;: &quot;2024-08-25T07:14:55.130Z&quot;,
701        &quot;InvalidationBatch&quot;: {
702            &quot;Paths&quot;: {
703                &quot;Quantity&quot;: 2,
704                &quot;Items&quot;: &#91;
705                    &quot;/css/main.css&quot;,
706                    &quot;/index.html&quot;
707                &#93;
708            },
709            &quot;CallerReference&quot;: &quot;cli-1724570094-253923&quot;
710        }
711    }
712}
713</code></pre><p>This is promising. Let's refill our coffee and then check to see if the invalidation has finishing invalidating:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech; aws cloudfront get-invalidation \
714  --distribution-id FDCBA42RSTUV3 \
715  --id ICECSBHVIW089I89RLYODUBMXI
716{
717    &quot;Invalidation&quot;: {
718        &quot;Id&quot;: &quot;ICECSBHVIW089I89RLYODUBMXI&quot;,
719        &quot;Status&quot;: &quot;Completed&quot;,
720        &quot;CreateTime&quot;: &quot;2024-08-25T07:14:55.130Z&quot;,
721        &quot;InvalidationBatch&quot;: {
722            &quot;Paths&quot;: {
723                &quot;Quantity&quot;: 2,
724                &quot;Items&quot;: &#91;
725                    &quot;/css/main.css&quot;,
726                    &quot;/index.html&quot;
727                &#93;
728            },
729            &quot;CallerReference&quot;: &quot;cli-1724570094-253923&quot;
730        }
731    }
732}
733</code></pre><p>If we now Shift-reload the page in our browser, we'll see the wonderful new header! 🎉</p><h2 id="transcription_made_easy">Transcription made easy</h2><p>Now that we have an amazing website and a way to publish it, let's record an episode and then make a nice trailer to get people pumped up! We'll use <a href='https://zencastr.com/'>Zencastr</a> to do this, which produces a lovely MP3 for us as well as a transcript. For now, we have the following files on disk:</p><pre class="language-text"><code class="lang-text language-text">: organising-tech-in-sweden; tree
734.
735├── bb.edn
736├── ep00-trailer
737│   ├── otis-ep00-trailer.mp3
738│   └── otis-ep0-trailer&#95;transcription.txt
739├── ep01-klarna-part1
740│   ├── otis-ep01-klarna-part1.mp3
741│   └── otis-ep01-klarna-part1&#95;transcription.txt
742├── public
743│   ├── ...
744│   └── index.html
745└── tasks.clj
746</code></pre><p>Zencastr's transcripts are, um, functional, shall we say, but any machine transcription tool will require a human who speaks the actual language being transcribed (in this case, English) to clean things up. Luckily, there's an amazing free (as in source <strong>and</strong> as in beer!) browser-based tool called <a href='https://otranscribe.com/'>oTranscribe</a> that lets us listen to our lovely audio whilst editing the transcript, with keyboard shortcuts for pausing and resuming playback, rewinding and fast forwarding, adjusting playback speed, etc.</p><p>To unlock all this goodness, we'll need to convert our boring Zencastr transcripts, which look like this:</p><pre class="language-text"><code class="lang-text language-text">00:02.00
747jmglov
748Already and we are live now. So welcome everyone to organizing tech in Swede
748n
749I am here my name is Josh I'm here with a ah. Cast of characters that will
750delight in a maze and I will introduce them here in a minute but before we get
751to the cool people. Let me introduce. My co-host Ray joining us all the way from
752Belgium Ray you want to say hey.
753
75400:30.61
755Ray
756Yeah on uncool Belgium hello everyone? Well it's a bit warmer than Sweden now.
757But okay I yeah you were talking about being oh yeah, okay fine.
758
75900:42.78
760jmglov
761Yeah, all right? So we are here like I said to talk about organizing tech in
762Sweden and um, basically what we want to do is introduce. Folks who might not
763know much about Sweden other than Ekea is from here and chocolate. Oh no wait.
764That's Switzerland for some reason and the us. Oh you know Belgium sure sure.
765Sure. Um.
766
767&#91;...&#93;
768</code></pre><p>into amazing OTR (oTranscribe's file format) ones, which look like some HTML stuffed into some JSON.</p><p>To do this converting, we could use <a href='https://github.com/jmglov/transcribble'>Transcribble</a>, which I wrote a while back and forgot to blog about. Or we could just open up <a href='https://otranscribe.com/'>https://otranscribe.com/</a> in our browser and click the big blue "Start transcribing" button, then click the "Choose audio (or video) file" button and choose our <code>&#126;/code/orgtech-se/ep01-klarna-part1/otis-ep01-klarna-part1.mp3</code> file, and then paste in our Zencastr transcript, warts and all. If we click the Play button (or hit Esc, which is oTranscribe's play/pause keyboard shortcut), our episode will start playing, and we can hit Ctrl+J to add a timestamp to the transcript when we hear me say "So, welcome everyone to Organising Tech in Swede
768n". After much listening and editing, which we will just handwave away here, we now have a pristine transcript!</p><p><img src="assets/2024-09-18-podcast-soundcljoud-otr.png" alt="The oTranscribe transcription UI" title="Cleaning this up is an exercise for the reader" width=800px border=1 /></p><p>We'll now click the "Export" button to pop up the "Download transcript as..." dialog, select "oTranscribe format (.otr)", and save as a new <code>&#126;/code/orgtech-se/ep01-klarna-part1/otis-ep01-klarna-part1.otr</code> file.</p><h2 id="feed_me_some_episodes">Feed me some episodes</h2><p>Now that we have some files, we need to stuff those in a feed. Luckily, <a href='2024-07-09-soundcljoud.html#faking_a_podcast_with_selmer'>we have
769some experience with podcast
770feeds</a>. Using <a href='https://github.com/yogthos/Selmer'>Selmer</a> to write the feed worked out pretty nicely then, so let's elect to do the same thing again. In fact, since we already did the hard work of creating code that knows how to write an RSS file for a music album, why don't we see if we can modify it a bit to support podcasts as well?</p><p>Let's pop over to <code>&#126;/code/soundcljoud/processor/main.clj</code> and remind ourselves how we turned an album into an RSS feed:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn process-album &#91;opts dir&#93;
771  &#40;let &#91;info &#40;album-info opts dir&#41;
772        tmpdir &#40;fs/create-temp-dir {:prefix &quot;soundcljoud.&quot;}&#41;
773        info &#40;update info :tracks &#40;partial map #&#40;process-track % tmpdir&#41;&#41;&#41;&#93;
774    &#40;spit &#40;fs/file tmpdir &quot;album.rss&quot;&#41; &#40;rss/album-feed opts info&#41;&#41;
775    &#40;assoc info :out-dir tmpdir&#41;&#41;&#41;
776</code></pre><p>In this case, we fetched Discogs metadata for the album in the <code>album-info</code> function, created a temporary directory, did some transcoding in <code>process-track</code>, then used <code>rss/album-feed</code> to apply a Selmer template to our album metadata. Opening up the <code>soundcljoud.rss</code> namespace, we see that the <code>album-feed</code> function is extremely specific to music albums:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn album-feed &#91;opts album-info&#93;
777  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;album-feed.rss&quot;&#41; slurp&#41;&#93;
778    &#40;selmer/render template
779                   &#40;-&gt; album-info
780                       &#40;update :tracks
781                               &#40;partial map #&#40;update % :mp3-filename
782                                                     fs/file-name&#41;&#41;&#41;
783                       &#40;assoc :date &#40;now&#41;&#41;&#41;&#41;&#41;&#41;
784</code></pre><p>Whilst there's no obvious way to repurpose it, we can follow the same basic pattern:</p><ol><li>Load the template</li><li>Massage the "info" about the <del>album</del> podcast as needed</li><li>Render the template with our "info"</li></ol><p>Let's sketch out a <code>podcast-feed</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn podcast-feed &#91;opts podcast-info&#93;  ;; ❓ podcast-info how?
785  &#40;let &#91;template :???&#93;  ;; ❓ where do we get this?
786    &#40;-&gt;&gt; podcast-info
787         ;; ❓ maybe some massaging here?
788         &#40;selmer/render template&#41;&#41;&#41;&#41;
789</code></pre><p>The first question is where we get the <code>podcast-info</code>. We got <code>album-info</code> from Discogs, but since Discogs presumably knows nothing about our podcast (and why would it?), let's create a static <code>&#126;/code/orgtech/podcast.edn</code> file instead, fill it with whatever data our podcast feed will need (I guess it's time to rhyme), and read in the EDN before calling this function.</p><p>Having made that decision, we must now ask ourselves where we will get our podcast feed template from. In the case of albums, we provided a template as a resource directly from Soundcljoud, so why don't we do that again?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn podcast-feed &#91;opts podcast-info&#93;
790  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;podcast-feed.rss&quot;&#41; slurp&#41;&#93;
791    &#40;-&gt;&gt; podcast-info
792         ;; ❓ maybe some massaging here?
793         &#40;selmer/render template&#41;&#41;&#41;&#41;
794</code></pre><p>In order to know what if any massaging <code>podcast-info</code> will need, we'll need to create the template and the <code>podcast.edn</code> file and see where the gaps are. Let's consult Apple's handy <a href='https://help.apple.com/itc/podcasts_connect/#/itcb54353390'>A Podcaster’s Guide to
795RSS</a> and start writing <code>resources/podcast-feed.rss</code>. First, we need the standard feed skeleton:</p><pre class="language-xml"><code class="lang-xml language-xml">&lt;?xml version='1.0' encoding='UTF-8'?&gt;
796&lt;rss version=&quot;2.0&quot;
797     xmlns:itunes=&quot;http://www.itunes.com/dtds/podcast-1.0.dtd&quot;
798     xmlns:atom=&quot;http://www.w3.org/2005/Atom&quot;&gt;
799  &lt;
799channel&gt;
800    &lt;!-- TBD: some stuff here --&gt;
801  &lt;/channel&gt;
802&lt;/rss&gt;
803</code></pre><p>Now to start populating the contents <code>&lt;channel&gt;</code> tag. According to Apple, we need the following:</p><table><thead><tr><th>Show tags</th><th>Usage</th><th>Parent tag</th></tr></thead><tbody><tr><td><code>&lt;title&gt;</code></td><td>The show title.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;description&gt;</code></td><td>The show description.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:image&gt;</code></td><td>The artwork for the show.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;language&gt;</code></td><td>The language spoken on the show.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:explicit&gt;</code></td><td>The podcast parental advisory information.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:category&gt;</code></td><td>The show category information.</td><td><code>&lt;channel&gt;</code></td></tr></tbody></table><p>This is straightforward enough (except for <code>&lt;itunes:category&gt;</code>, which we'll come back to):</p><pre class="language-xml"><code class="lang-xml language-xml">  &lt;channel&gt;
804    &lt;title&gt;{{podcast.title}}&lt;/title&gt;
805    &lt;description&gt;{{podcast.description|safe}}&lt;/description&gt;
806    &lt;itunes:image href=&quot;{{base-url}}{{podcast.image}}&quot;/&gt;
807    &lt;language&gt;{{podcast.language}}&lt;/language&gt;
808    &lt;itunes:explicit&gt;{{podcast.explicit}}&lt;/itunes:explicit&gt;
809  &lt;/channel&gt;
810</code></pre><p>By the way, that <code>{{podcast.description|safe}}</code> thingy is a Selmer filter that <a href='https://github.com/yogthos/Selmer?tab=readme-ov-file#safe'>exempts the variable from being
811HTML-escaped</a>. Since our description text goes in the body of the <code>&lt;description&gt;</code> tag, we don't want things like "rock & roll" getting rendered as "rock &amp; roll", because that would be yucky.</p><p>Now we need to add that data to our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:base-url &quot;https://orgtech.se&quot;
812 :podcast {:title &quot;
812Organising Tech in Sweden&quot;
813           :description &quot;Organising Tech in Sweden is a limited podcast series exploring union organising in Swedish tech companies. Join us as we sit down with some of the people involved in the campaigns to win collective bargaining rights at two of Sweden's tech unicorns, Klarna and Spotify.&quot;
814           :image &quot;/img/orgtech-se-cover.jpg&quot;
815           :language &quot;en&quot;
816           :explicit true}}
817</code></pre><p>As we were writing <code>podcast.edn</code>, we realised that <code>podcast-info</code> was actually the data in the EDN file under the <code>:podcast</code> key, so we are really just providing an <code>opts</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn podcast-feed &#91;opts&#93;
818  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;podcast-feed.rss&quot;&#41; slurp&#41;&#93;
819    &#40;-&gt;&gt; opts
820         ;; ❓ maybe some massaging here?
821         &#40;selmer/render template&#41;&#41;&#41;&#41;
822</code></pre><p>Let's give this a go in our REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
823
824  &#40;require '&#91;clojure.edn :as edn&#93;&#41;
825  ;; =&gt; nil
826
827  &#40;def opts &#40;-&gt; &#40;slurp &quot;/home/jmglov/code/orgtech-se/podcast.edn&quot;&#41;
828                &#40;edn/read-string&#41;&#41;&#41;
829  ;; =&gt; #'soundcljoud.rss/opts
830
831  opts
832  ;; =&gt; {:base-url &quot;https://orgtech.se&quot;,
833  ;;     :podcast
834  ;;     {:title &quot;
834Organising Tech in Sweden&quot;,
835  ;;      :description
836  ;;      &quot;Organising Tech in Sweden is a...&quot;,
837  ;;      :image &quot;/img/orgtech-se-cover.jpg&quot;,
838  ;;      :language &quot;en&quot;,
839  ;;      :explicit true}}
840
841  &#40;podcast-feed opts&#41;
842  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;
843  ;;     &lt;rss version=\&quot;2.0\&quot;
844  ;;          xmlns:itunes=\&quot;http://www.itunes.com/dtds/podcast-1.0.dtd\&quot;
845  ;;          xmlns:atom=\&quot;http://www.w3.org/2005/Atom\&quot;&gt;
846  ;;       &lt;channel&gt;
847  ;;         &lt;title&gt;Organising Tech in Sweden&lt;/title&gt;
848  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;
849  ;;         &lt;itunes:image href=\&quot;https://orgtech.se/img/orgtech-se-cover.jpg\&quot;/&gt;
850  ;;         &lt;language&gt;en&lt;/language&gt;
851  ;;         &lt;itunes:explicit&gt;true&lt;/itunes:explicit&gt;
852  ;;       &lt;/channel&gt;
853  ;;     &lt;/rss&gt;&quot;
854
855  &#41;
856</code></pre><p>Let's now come back to that tricky <code>&lt;itunes:category&gt;</code> tag. Referring back to the Apple docs, we see:</p><blockquote><p> For a complete list of categories and subcategories, see Apple Podcast  categories. </p><p> Select the category that best reflects the content of your show. If available,  you can also define a subcategory. </p><p> Single category: </p><p>   <code>&lt;itunes:category text=&quot;History&quot; /&gt;</code> </p><p> Category with subcategory: </p><p>   <code>&lt;itunes:category text=&quot;Society &amp;amp; Culture&quot;&gt;</code>    <code>  &lt;itunes:category text=&quot;Documentary&quot; /&gt;</code>    <code>&lt;/itunes:category&gt;</code> </p></blockquote><p>We can add categories to our <code>podcast-feed.rss</code> template using Selmer's <a href='https://github.com/yogthos/Selmer?tab=readme-ov-file#for'>for</a> tag:</p><pre class="language-xml"><code class="lang-xml language-xml">  &lt;channel&gt;
857    &lt;!-- ... --&gt;
858{% for category in podcast.categories %}
859    &lt;itunes:category text=&quot;{{category.text}}&quot;&gt;
860{% for subcategory in category.subcategories %}
861      &lt;itunes:category text=&quot;{{subcategory.text}}&quot; /&gt;
862{% endfor %}
863    &lt;/itunes:category&gt;
864{% endfor %}
865  &lt;/channel&gt;
866</code></pre><p>And now we need to pick a category or two and add them to our <code>podcast.edn</code>. The <a href='https://podcasters.apple.com/support/1691-apple-podcasts-categories'>Apple Podcasts
867categories</a> page lists the options, of which we choose:</p><ul><li>Technology (which has no subcategories)</li><li>News, with subcategory Politics</li></ul><p>Expressing this in EDN, we get:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:base-url &quot;https://orgtech.se&quot;
868 :podcast { ; ...
869           :categories &#91;{:text &quot;Technology&quot;}
870                        {:text &quot;News&quot;
871                         :subcategories &#91;{:text &quot;Politics&quot;}&#93;}&#93;}}
872</code></pre><p>And our REPL shows us what we'd expect to see:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
873
874  &#40;def opts &#40;-&gt; &#40;slurp &quot;/home/jmglov/code/orgtech-se/podcast.edn&quot;&#41;
875                &#40;edn/read-string&#41;&#41;&#41;
876  ;; =&gt; #'soundcljoud.rss/opts
877
878  &#40;podcast-feed opts&#41;
879  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;
880  ;;     &lt;rss version=\&quot;2.0\&quot;
881  ;;          xmlns:itunes=\&quot;http://www.itunes.com/dtds/podcast-1.0.dtd\&quot;
882  ;;          xmlns:atom=\&quot;http://www.w3.org/2005/Atom\&quot;&gt;
883  ;;       &lt;channel&gt;
884  ;;         ...
885  ;;         &lt;itunes:category text=\&quot;Technology\&quot;&gt;
886  ;;         &lt;/itunes:category&gt;
887  ;;         &lt;itunes:category text=\&quot;News\&quot;&gt;
888  ;;           &lt;itunes:category text=\&quot;Politics\&quot; /&gt;
889  ;;         &lt;/itunes:category&gt;
890  ;;       &lt;/channel&gt;
891  ;;     &lt;/rss&gt;&quot;
892
893  &#41;
894</code></pre><p>Having sorted our required tags, let's take a look at Apple's recommended and "situational" tags (which we'll just treat as "recommended"):</p><table><thead><tr><th>
894Show tags</th><th>Usage</th><th>Parent tag</th></tr></thead><tbody><tr><td><code>&lt;itunes:author&gt;</code></td><td>The group responsible for creating the show.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;link&gt;</code></td><td>The website associated with a podcast.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:title&gt;</code></td><td>The show title specific for Apple Podcasts.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:type&gt;</code></td><td>The type of show. Its values can be one of the following:</td><td></td></tr><tr><td></td><td>• <strong>Episodic</strong>. Episodes are intended to be consumed without any specific order.</td><td></td></tr><tr><td></td><td>• <strong>Serial</strong>. Episodes are intended to be consumed in sequential order.</td><td></td></tr><tr><td><code>&lt;copyright&gt;</code></td><td>The show copyright details.</td><td><code>&lt;channel&gt;</code></td></tr></tbody></table><p>Again, this is quite straightforward to add to our template:</p><pre class="language-xml"><code class="lang-xml language-xml">  &lt;channel&gt;
895    &lt;!-- ... --&gt;
896    &lt;itunes:author&gt;{{podcast.author}}&lt;/itunes:author&gt;
897    &lt;link&gt;{{base-url}}&lt;/link&gt;
898    &lt;itunes:title&gt;{{podcast.title}}&lt;/itunes:title&gt;
899    &lt;itunes:type&gt;{{podcast.type}}&lt;/itunes:type&gt;
900    &lt;copyright&gt;{{podcast.copyright}}&lt;/copyright&gt;
901  &lt;/channel&gt;
902</code></pre><p>and to our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:base-url &quot;https://orgtech.se&quot;
903 :podcast { ; ...
904           :author &quot;
904Organising Tech in Sweden&quot;
905           :type &quot;Serial&quot;
906           :copyright &quot;All rights reserved, Organising Tech in Sweden&quot;}}
907</code></pre><p>Testing things out in our REPL, we see what we expect to see. 🙂</p><p>Now it's time to add some episodes! Here are the Apple Podcast required, recommended, and situational tags for episodes:</p><table><thead><tr><th>Show tags</th><th>Usage</th><th>Parent tag</th></tr></thead><tbody><tr><td><code>&lt;title&gt;</code></td><td>An episode title.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;enclosure&gt;</code></td><td>The episode content, file size, and file type information. The <code>&lt;enclosure&gt;</code> tag has three attributes:</td><td><code>&lt;item&gt;</code></td></tr><tr><td></td><td>• <strong>URL</strong>. The URL attribute points to your podcast media file.</td><td></td></tr><tr><td></td><td>• <strong>Length</strong>. The length attribute is the file size in bytes.</td><td></td></tr><tr><td></td><td>• <strong>Type</strong>. The type attribute provides the correct category for the type of file.</td><td></td></tr><tr><td><code>&lt;guid&gt;</code></td><td>The episode’s globally unique identifier (<a href='https://cyber.harvard.edu/rss/rss.html#ltguidgtSubelementOfLtitemgt'>GUID</a>)</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;pubDate&gt;</code></td><td>The date and time when an episode was released. Format the date using the <a href='http://www.faqs.org/rfcs/rfc2822.html'>RFC 2822</a> specifications. For example: <code>Sat, 01 Apr 2023 19:00:00 GMT</code>.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;description&gt;</code></td><td>An episode description.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:duration&gt;</code></td><td>The duration of an episode. Different duration formats are accepted however it is recommended to convert the length of the episode into seconds.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;link&gt;</code></td><td>An episode link URL.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:explicit&gt;</code></td><td>The podcast parental advisory information.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:title&gt;</code></td><td>The show title specific for Apple Podcasts.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:episode&gt;</code></td><td>An episode number.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:episodeType&gt;</code></td><td>The episode type.</td><td><code>&lt;item&gt;</code></td></tr><tr><td></td><td>• <strong>Full</strong>. Specify full when you are submitting the complete content of your show.</td><td></td></tr><tr><td></td><td>• <strong>Trailer</strong>. Specify trailer when you are submitting a short, promotional piece of content that represents a preview of your current show.</td><td></td></tr><tr><td></td><td>• <strong>Bonus</strong>. Specify bonus when you are submitting extra content for your show (for example, behind the scenes information or interviews with the cast) or cross-promotional content for another show.</td><td></td></tr><tr><td><code>&lt;itunes:transcript&gt;</code></td><td>A link to the episode transcript in the Closed Caption format.</td><td><code>&lt;item&gt;</code></td></tr></tbody></table><p>Unfortunately, Transcribble doesn't yet support VTT or SRT transcripts, so we can't provide the transcript directly in iTunes. What we will do instead is display the OTR transcript that we previously prepared in oTranscribe on our episode page (which is yet to be written, but we'll get there in the end). In order to do this, let's add a custom <code>&lt;transcriptUrl&gt;</code> tag.</p><p>Let's start with our template as usual:</p><pre class="language-xml"><code class="lang-xml language-xml">  &lt;channel&gt;
908    &lt;!-- ... --&gt;
909{% for episode in episodes %}
910    &lt;item&gt;
911      &lt;title&gt;{{episode.title}}&lt;/title&gt;
912      &lt;enclosure
913          url=&quot;{{base-url}}{{episode.path}}/{{episode.audio-file}}&quot;
914          length=&quot;{{episode.audio-filesize}}&quot;
915          type=&quot;{{episode.mime-type}}&quot; /&gt;
916      &lt;guid&gt;{{base-url}}{{episode.path}}/{{episode.audio-file}}&lt;/guid&gt;
917      &lt;pubDate&gt;{{episode.date}}&lt;/pubDate&gt;
918      &lt;description&gt;&lt;!&#91;CDATA&#91;{{episode.description|safe}}&#93;&#93;&gt;&lt;/description&gt;
919      &lt;itunes:duration&gt;{{episode.duration}}&lt;/itunes:duration&gt;
920      &lt;link&gt;{{base-url}}{{episode.path}}&lt;/link&gt;
921      &lt;itunes:title&gt;{{episode.title}}&lt;/itunes:title&gt;
922      {% if episode.number %}&lt;itunes:episode&gt;{{episode.number}}&lt;/itunes:episode&gt;{% endif %}
923      &lt;itunes:episodeType&gt;{{episode.type}}&lt;/itunes:episodeType&gt;
924      &lt;transcriptUrl&gt;{{base-url}}{{episode.path}}/{{episode.transcript-file}}&lt;/transcriptUrl&gt;
925    &lt;/item&gt;
926{% endfor %}
927  &lt;/channel&gt;
928</code></pre><p>
928And now we know what episodes need to look like in our <code>podcast.edn</code> file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
929 :episodes
930 &#91;{:number 0
931   :date &quot;Thu, 5 Sep 2024 00:00:00 +0000&quot;
932   :type &quot;Trailer&quot;
933   :title &quot;Trailer&quot;
934   :summary &quot;Union organising seems to be in the air these days, as tech workers wake up and realise that they are, in fact, workers.&quot;
935   :description &quot;
936&lt;p&gt;
937  Union organising seems to be in the air these days, as tech workers wake up and
938  realise that they are, in fact, workers. Here in Sweden, it's no exception.
939  Join us as we sit down with some of the people involved in organising two of
940  Sweden's foremost tech unicorns, Klarna and Spotify. This is Organising Tech in
941  Sweden.
942&lt;/p&gt;
943&lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;
944  To view full show notes, including transcripts, please visit the
945  &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.
946&lt;/p&gt;
947&lt;p&gt;
948  Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;
949  &lt;a href=\&quot;https://bsky.app/profile/anyakjordan.bsky.social\&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
950&lt;/p&gt;
951&lt;p&gt;
952  Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;
953&lt;/p&gt;&quot;
954   :path &quot;/episodes/ep00-trailer&quot;
955   :audio-file &quot;otis-ep00-trailer.mp3&quot;
956   :transcript-file &quot;otis-ep00-trailer.otr&quot;
957   :explicit false
958   :mime-type &quot;audio/mpeg&quot;}
959  {:number 1
960   :date &quot;Thu, 12 Sep 2024 00:00:00 +0000&quot;
961   :type &quot;Full&quot;
962   :title &quot;Organising Klarna - Part 1&quot;
963   :summary &quot;A conversation with three of the organisers behind the successful campaign to win a Collective Bargaining Agreement at Klarna&quot;
964   :description &quot;
965&lt;p&gt;
966  We kick off Organising Tech in Sweden in style by recounting the story of how
967  a collective bargaining agreement &#40;CBA&#41; was won at Klarna, a major Swedish
968  fintech. In fact, Klarna was the first unicorn in Sweden to be unionised &#40;and
969  probably the first unicorn in Europe as well&#41;!
970&lt;/p&gt;
971&lt;p&gt;
972  To hear all about how this went down, your co-hosts Josh and Ray are joined by
973  Thomas, the founder of the Klarna Unionen Club &#40;a union \&quot;local\&quot;, to use
974  terminology that might be more familiar to US listeners&#41;; Sen, the chair of
975  the club who won the bargaining agreement against the odds; and Kim, a former
976  Klarna employee with extensive knowledge of Swedish labour law and market
977  policy.
978&lt;/p&gt;
979&lt;p&gt;
980  This is part 1 of the conversation, which will be concluded in Episode 2.
981&lt;/p&gt;
982&lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;
983  To view full show notes, including transcripts, please visit the
984  &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.
985&lt;/p&gt;
986&lt;p&gt;
987  Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;
988  &lt;a href=\&quot;https://bsky.app/profile/anyakjordan.bsky.social\&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
989&lt;/p&gt;
990&lt;p&gt;
991  Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;
992&lt;/p&gt;&quot;
993   :path &quot;/episodes/ep01-klarna-part1&quot;
994   :audio-file &quot;otis-ep01-klarna-part1.mp3&quot;
995   :transcript-file &quot;otis-ep01-klarna-part1.otr&quot;
996   :explicit false
997   :mime-type &quot;audio/mpeg&quot;}
998  {:preview? true
999   :number 2
1000   :date &quot;Thu, 19 Sep 2024 00:00:00 +0000&quot;
1001   :type &quot;Full&quot;
1002   :title &quot;Organising Klarna - Part 2&quot;
1003   :summary &quot;The conclusion of our conversation with three of the organisers behind the successful campaign to win a Collective Bargaining Agreement at Klarna&quot;
1004   :description &quot;
1005&lt;p&gt;
1006  We finish our conversation with Sen, Thomas, and Kim about how a collective
1007  bargaining agreement &#40;CBA&#41; was won at Klarna. In this episode, we cover the
1008  impact of immigrant workers on organising, the impact of organising on
1009  organisers, and the impact of strikes on negotiations. All of this and a happy
1010  ending too!
1011&lt;/p&gt;
1012&lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;
1013  To view full show notes, including transcripts, please visit the
1014  &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.
1015&lt;/p&gt;
1016&lt;p&gt;
1017  Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;
1018  &lt;a href=\&quot;https://bsky.app/profile/anyakjordan.bsky.social\&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
1019&lt;/p&gt;
1020&lt;p&gt;
1021  Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;
1022&lt;/p&gt;&quot;
1023   :path &quot;/episodes/ep02-klarna-part2&quot;
1024   :audio-file &quot;otis-ep02-klarna-part2.mp3&quot;
1025   :transcript-file &quot;otis-ep02-klarna-part2.otr&quot;
1026   :explicit false
1027   :mime-type &quot;audio/mpeg&quot;}&#93;}
1028</code></pre><p>Testing this in our REPL...</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
1029
1030  &#40;def opts &#40;-&gt; &#40;slurp &quot;/home/jmglov/code/orgtech-se/podcast.edn&quot;&#41;
1031                &#40;edn/read-string&#41;&#41;&#41;
1032  ;; =&gt; #'soundcljoud.rss/opts
1033
1034  &#40;podcast-feed opts&#41;
1035  ;; =&gt; java.lang.NullPointerException soundcljoud.rss /home/jmglov/code/soundcljoud/processor/src/soundcljoud/rss.clj:34:26
1036
1037  &#41;
1038</code></pre><p>...we get an unpleasant surprise. 😮</p><p>This is a bit annoying to debug, but we can surmise that one of the template variables in the episode template must be missing. Doing a little visual inspection identifies the culprit:</p><pre class="language-xml"><code class="lang-xml language-xml">      &lt;enclosure
1039          url=&quot;{{base-url}}{{episode.path}}/{{episode.audio-file}}&quot;
1040          length=&quot;{{episode.audio-filesize}}&quot;
1041          type=&quot;{{episode.mime-type}}&quot; /&gt;
1042</code></pre><p>We don't have <code>audio-filesize</code> in our episode data structure. 😢</p><h2 id="deep_tissue_massage">Deep tissue massage</h2><p>All is not lost, however. Let's cast our minds back to the definition of the <code>podcast-feed</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn podcast-feed &#91;opts&#93;
1043  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;podcast-feed.rss&quot;&#41; slurp&#41;&#93;
1044    &#40;-&gt;&gt; opts
1045         ;; ❓ maybe some massaging here?
1046         &#40;selmer/render template&#41;&#41;&#41;&#41;
1047</code></pre><p>The answer to the question "maybe some massaging here?" now reveals itself to be "Yes. Yes! A thousand times yes!" We also know at least one massage technique we're going to need to use, namely setting the <code>audio-filesize</code> key for each episode. Let's start out by giving ourselves a way to update episodes:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-episode &#91;opts episode&#93;
1048  episode&#41;
1049
1050&#40;defn update-episodes &#91;opts&#93;
1051  &#40;update opts :episodes #&#40;map &#40;partial update-episode opts&#41; %&#41;&#41;&#41;
1052
1053&#40;defn podcast-feed &#91;opts&#93;
1054  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;podcast-feed.rss&quot;&#41; slurp&#41;&#93;
1055    &#40;-&gt;&gt; opts
1056         update-episodes
1057         &#40;selmer/render template&#41;&#41;&#41;&#41;
1058</code></pre><p>
1058Now we can figure out how to add the filesize to each episode. As usual, Babashka's got us covered! Checking out the babashka.fs API documentation, we find a function called <a href='https://github.com/babashka/fs/blob/master/API.md#babashka.fs/size'>babashka.fs/size</a>:</p><blockquote><p> <strong>size</strong> </p><p> <code>&#40;size f&#41;</code> </p><p> Returns the size of a file (in bytes). </p></blockquote><p>Let's mess around a bit in the REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
1059
1060  &#40;def base-dir &quot;/home/jmglov/code/orgtech-se&quot;&#41;
1061  ;; =&gt; #'soundcljoud.rss/base-dir
1062
1063  &#40;def opts &#40;-&gt; &#40;slurp &#40;fs/file base-dir &quot;podcast.edn&quot;&#41;&#41;
1064                &#40;edn/read-string&#41;
1065                &#40;assoc :base-dir base-dir&#41;&#41;&#41;
1066
1067  &#40;let &#91;episode &#40;-&gt; opts :episodes first&#41;
1068        filename &#40;format &quot;%s%s/%s&quot;
1069                         base-dir &#40;:path episode&#41; &#40;:audio-file episode&#41;&#41;&#93;
1070    &#40;fs/size filename&#41;&#41;
1071  ;; =&gt; java.nio.file.NoSuchFileException: 
1072  ;; /home/jmglov/code/orgtech-se/episodes/ep00-trailer/otis-ep00-trailer.mp3 
1073  ;; /home/jmglov/code/soundcljoud/processor/src/soundcljoud/rss.clj:4:5
1074
1075  &#41;
1076</code></pre><p>Oops! Seems like we've traded one problem for another. 😬</p><p>On disk, the files are laid out like this:</p><pre class="language-text"><code class="lang-text language-text">: organising-tech-in-sweden; tree
1077.
1078├── bb.edn
1079├── ep00-trailer
1080│   ├── otis-ep00-trailer.mp3
1081│   └── otis-ep0-trailer&#95;transcription.txt
1082├── ep01-klarna-part1
1083│   ├── otis-ep01-klarna-part1.mp3
1084│   └── otis-ep01-klarna-part1&#95;transcription.txt
1085├── ep02-klarna-part2
1086│   ├── otis-ep02-klarna-part2.mp3
1087│   └── otis-ep02-klarna-part2&#95;transcription.txt
1088├── public
1089│   ├── ...
1090│   └── index.html
1091└── tasks.clj
1092</code></pre><p>But we are looking for the audio file in the path in which it should exist on the server, which makes sense from an RSS feed perspective, which should use paths corresponding to the published site. Our <code>publish</code> task uses <code>aws s3 sync</code> to publish everything in our <code>public/</code> directory, so if we drop the MP3s there, they will get put in the correct place on the S3 website. For now, let's cheat by using our REPL to put the files where they need to go:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
1093
1094  &#40;def opts &#40;-&gt; &#40;slurp &#40;fs/file base-dir &quot;podcast.edn&quot;&#41;&#41;
1095                &#40;edn/read-string&#41;
1096                &#40;assoc :base-dir base-dir
1097                       :out-dir &quot;public&quot;&#41;&#41;&#41;
1098  ;; =&gt; #'soundcljoud.rss/opts
1099
1100  &#40;doseq &#91;episode &#40;:episodes opts&#41;
1101          :let &#91;filename &#40;format &quot;%s/%s%s/%s&quot;
1102                                 &#40;:base-dir opts&#41; &#40;:out-dir opts&#41;
1103                                 &#40;:path episode&#41; &#40;:audio-file episode&#41;&#41;
1104                src-filename &#40;fs/file dir
1105                                      &#40;fs/file-name &#40;:path episode&#41;&#41;
1106                                      &#40;:audio-file episode&#41;&#41;&#93;&#93;
1107    &#40;when-not &#40;fs/exists? filename&#41;
1108      &#40;fs/create-dirs &#40;fs/parent filename&#41;&#41;
1109      &#40;fs/copy src-filename filename&#41;&#41;&#41;
1110  ;; =&gt; nil
1111
1112  &#41;
1113</code></pre><p>OK, this will do for now. Let's grab this code, clean it up a bit, and shove it into our <code>update-episode</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-episode &#91;{:keys &#91;base-dir out-dir&#93; :as opts}
1114                      {:keys &#91;audio-file path&#93; :as episode}&#93;
1115  &#40;assoc episode :audio-filesize
1116         &#40;fs/size &#40;format &quot;%s/%s%s/%s&quot; base-dir out-dir path audio-file&#41;&#41;&#41;&#41;
1117</code></pre><p>Before testing this out in the REPL, we should add the <code>:out-dir</code> key to our <code>podcast.edn</code> so we don't rely on the caller to add it to <code>opts</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:base-url &quot;https://orgtech.se&quot;
1118 :podcast { ... }
1119 :episodes &#91; ... &#93;}
1120</code></pre><p>OK, now we're ready to give it a spin in the REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
1121
1122  &#40;podcast-feed &#40;assoc opts :out-dir &quot;public&quot;&#41;&#41;
1123  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;\n
1124  ;;     &lt;rss version=\&quot;2.0\&quot;\n
1125  ;;          xmlns:itunes=\&quot;http://www.itunes.com/dtds/podcast-1.0.dtd\&quot;\n
1126  ;;          xmlns:atom=\&quot;http://www.w3.org/2005/Atom\&quot;&gt;\n
1127  ;;       &lt;channel&gt;\n
1128  ;;         &lt;title&gt;
1128Organising Tech in Sweden&lt;/title&gt;\n
1129  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;\n
1130  ;;         &lt;itunes:image href=\&quot;https://orgtech.se/img/orgtech-se-cover.jpg\&quot;/&gt;\n
1131  ;;         &lt;language&gt;en&lt;/language&gt;\n
1132  ;;         &lt;itunes:explicit&gt;true&lt;/itunes:explicit&gt;\n\n
1133  ;;         &lt;itunes:category text=\&quot;Technology\&quot;&gt;\n\n
1134  ;;         &lt;/itunes:category&gt;\n\n
1135  ;;         &lt;itunes:category text=\&quot;News\&quot;&gt;\n\n
1136  ;;           &lt;itunes:category text=\&quot;Politics\&quot; /&gt;\n\n
1137  ;;         &lt;/itunes:category&gt;\n\n
1138  ;;         &lt;itunes:author&gt;Organising Tech in Sweden&lt;/itunes:author&gt;\n
1139  ;;         &lt;link&gt;https://orgtech.se&lt;/link&gt;\n
1140  ;;         &lt;itunes:title&gt;Organising Tech in Sweden&lt;/itunes:title&gt;\n
1141  ;;         &lt;itunes:type&gt;Serial&lt;/itunes:type&gt;\n
1142  ;;         &lt;copyright&gt;All rights reserved&lt;/copyright&gt;\n\n
1143  ;;         &lt;item&gt;\n
1144  ;;           &lt;title&gt;Trailer&lt;/title&gt;\n
1145  ;;           &lt;enclosure\n
1146  ;;               url=\&quot;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.mp3\&quot;\n
1147  ;;               length=\&quot;1016937\&quot;\n
1148  ;;               type=\&quot;audio/mpeg\&quot; /&gt;\n
1149  ;;           &lt;guid&gt;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.mp3&lt;/guid&gt;\n
1150  ;;           &lt;pubDate&gt;Thu, 5 Sep 2024 00:00:00 +0000&lt;/pubDate&gt;\n
1151  ;;           &lt;description&gt;&lt;!&#91;CDATA&#91;\n
1152  ;;             &lt;p&gt;\n  Union organising seems to be in the air these days...&lt;/p&gt;\n
1153  ;;             &lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;\n
1154  ;;               To view full show notes, including transcripts, please visit the\n
1155  ;;               &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.\n
1156  ;;             &lt;/p&gt;\n
1157  ;;             &lt;p&gt;\n
1158  ;;               Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;\n
1159  ;;             &lt;/p&gt;\n
1160  ;;             &lt;p&gt;\n
1161  ;;               Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;\n&lt;/p&gt;
1162  ;;           &#93;&#93;&gt;&lt;/description&gt;\n
1163  ;;           &lt;itunes:duration&gt;&lt;/itunes:duration&gt;\n
1164  ;;           &lt;link&gt;https://orgtech.se/episodes/ep00-trailer&lt;/link&gt;\n
1165  ;;           &lt;itunes:title&gt;Trailer&lt;/itunes:title&gt;\n
1166  ;;           &lt;itunes:episode&gt;0&lt;/itunes:episode&gt;\n
1167  ;;           &lt;itunes:episodeType&gt;Trailer&lt;/itunes:episodeType&gt;\n
1168  ;;           &lt;transcriptUrl&gt;
1169  ;;             https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.otr
1170  ;;           &lt;/transcriptUrl&gt;\n
1171  ;;         &lt;/item&gt;\n\n
1172  ;;         ...
1173  ;;       &lt;/channel&gt;\n
1174  ;;     &lt;/rss&gt;\n&quot;
1175
1176  &#41;
1177</code></pre><p>This looks like a good start, but a few things jump out at us:</p><ol><li>Our <code>&lt;itunes:duration&gt;</code> tag is empty</li><li>We still have a few Selmer template variables in our output, for example: `<a   href=\"{{base-url}}{{episode.path}}/\">episode page</a>`</li></ol><p>Let's tackle the duration issue first, because we already have the tools to fix that in the soundcljoud.processor code that we wrote for Garth:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud.audio
1178  &#40;:require &#91;babashka.fs :as fs&#93;
1179            &#91;babashka.process :as p&#93;
1180            &#91;cheshire.core :as json&#93;
1181            &#91;clojure.string :as str&#93;&#41;&#41;
1182
1183&#40;defn mp3-duration &#91;filename&#93;
1184  &#40;-&gt; &#40;p/shell {:out :string}
1185               &quot;ffprobe -v quiet -print&#95;format json -show&#95;format -show&#95;streams&quot;
1186               filename&#41;
1187      :out
1188      &#40;json/parse-string keyword&#41;
1189      :streams
1190      first
1191      :duration
1192      &#40;str/replace #&quot;&#91;.&#93;\d+$&quot; &quot;&quot;&#41;&#41;&#41;
1193
1194;; ...
1195</code></pre><p>Let's pull soundcljoud.audio into our namespace and then grab the duration in <code>update-episode</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud.rss
1196  &#40;:require ; ...
1197            &#91;soundcljoud.audio :as audio&#93;&#41;
1198  &#40;:import ...&#41;&#41;
1199
1200;; ...
1201
1202&#40;defn update-episode &#91;{:keys &#91;base-dir src-dir&#93; :as opts}
1203                      {:keys &#91;audio-file path&#93; :as episode}&#93;
1204  &#40;let &#91;filename &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path audio-file&#41;&#93;
1205    &#40;assoc episode
1206           :audio-filesize &#40;fs/size filename&#41;
1207           :duration &#40;audio/mp3-duration filename&#41;&#41;&#41;&#41;
1208
1209;; ...
1210
1211&#40;comment
1212
1213  &#40;podcast-feed opts&#41;
1214  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;\n
1215  ;;     &lt;rss version=\&quot;2.0\&quot; ...&gt;\n
1216  ;;       &lt;channel&gt;\n
1217  ;;         &lt;title&gt;
1217Organising Tech in Sweden&lt;/title&gt;\n
1218  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;\n
1219  ;;         ...
1220  ;;         &lt;item&gt;\n
1221  ;;           &lt;title&gt;Trailer&lt;/title&gt;\n
1222  ;;           ...
1223  ;;           &lt;itunes:duration&gt;42&lt;/itunes:duration&gt;\n
1224  ;;           ...
1225  ;;         &lt;/item&gt;\n\n
1226  ;;         ...
1227  ;;       &lt;/channel&gt;\n
1228  ;;     &lt;/rss&gt;\n&quot;
1229
1230&#41;
1231</code></pre><p>This looks good, so let's turn our roving eye to the last remaining problem.</p><h2 id="it%27s_templates_all_the_way_down%2C_young_man">It's templates all the way down, young man</h2><p>After rendering our RSS feed template, we somehow still have unrendered Selmer in our output:</p><pre class="language-xml"><code class="lang-xml language-xml">&lt;description&gt;
1232  &lt;!&#91;CDATA&#91;
1233  &lt;p&gt;
1234    Union organising seems to be in the air these days...
1235  &lt;/p&gt;
1236  &lt;p class=&quot;soundcljoud-hidden&quot;&gt;
1237    To view full show notes, including transcripts, please visit the
1238    &lt;a href=&quot;{{base-url}}{{episode.path}}/&quot;&gt;episode page&lt;/a&gt;.
1239  &lt;/p&gt;
1240  &lt;p&gt;
1241    Cover art by &lt;a href=&quot;https://anyakjordan.com/&quot;&gt;Anya K. Jordan&lt;/a&gt;
1242  &lt;/p&gt;
1243  &lt;p&gt;
1244    Theme music by &lt;a href=&quot;https://soundcloud.com/ptzery&quot;&gt;Ptzery&lt;/a&gt;
1245  &lt;/p&gt;&#93;&#93;&gt;
1246&lt;/description&gt;
1247</code></pre><p>Let's see what's going on in our <code>podcast-feed.rss</code> template for episodes:</p><pre class="language-xml"><code class="lang-xml language-xml">{% for episode in episodes %}
1248    &lt;item&gt;
1249      &lt;title&gt;{{episode.title}}&lt;/title&gt;
1250      ...
1251      &lt;description&gt;&lt;!&#91;CDATA&#91;{{episode.description|safe}}&#93;&#93;&gt;&lt;/description&gt;
1252      ...
1253    &lt;/item&gt;
1254{% endfor %}
1255</code></pre><p>So we're plugging <code>episode.description</code> into the template. Let's see what that looks like in our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
1256 :episodes
1257 &#91;{:number 0
1258   :title &quot;Trailer&quot;
1259   ;; ...
1260   :description &quot;
1261&lt;p&gt;
1262  Union organising seems to be in the air these days, as tech workers wake up and
1263  realise that they are, in fact, workers. Here in Sweden, it's no exception.
1264  Join us as we sit down with some of the people involved in organising two of
1265  Sweden's foremost tech unicorns, Klarna and Spotify. This is Organising Tech in
1266  Sweden.
1267&lt;/p&gt;
1268&lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;
1269  To view full show notes, including transcripts, please visit the
1270  &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.
1271&lt;/p&gt;
1272&lt;p&gt;
1273  Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;
1274  &lt;a href=\&quot;https://bsky.app/profile/anyakjordan.bsky.social\&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
1275&lt;/p&gt;
1276&lt;p&gt;
1277  Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;
1278&lt;/p&gt;&quot;
1279   ;; ...
1280   }
1281  ;; ...
1282&#93;}
1283</code></pre><p>Ah-ha! The value of <code>episode.description</code> itself contains some templating. So it looks like we need to render that as well.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-episode &#91;{:keys &#91;base-dir src-dir&#93; :as opts}
1284                      {:keys &#91;audio-file path&#93; :as episode}&#93;
1285  &#40;let &#91;filename &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path audio-file&#41;&#93;
1286    &#40;assoc episode
1287           :audio-filesize &#40;fs/size filename&#41;
1288           :duration &#40;audio/mp3-duration filename&#41;
1289           :description &#40;selmer/render &#40;:description episode&#41;
1290                                       &#40;assoc opts :episode episode&#41;&#41;&#41;&#41;&#41;
1291
1292;; ...
1293
1294&#40;comment
1295
1296  &#40;podcast-feed opts&#41;
1297  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;\n
1298  ;;     &lt;rss version=\&quot;2.0\&quot; ...&gt;\n
1299  ;;       &lt;channel&gt;\n
1300  ;;         &lt;title&gt;
1300Organising Tech in Sweden&lt;/title&gt;\n
1301  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;\n
1302  ;;         ...
1303  ;;         &lt;item&gt;\n
1304  ;;           &lt;title&gt;Trailer&lt;/title&gt;\n
1305  ;;           &lt;description&gt;&lt;!&#91;CDATA&#91;\n
1306  ;;             &lt;p&gt;\n  Union organising seems to be in the air these days...&lt;/p&gt;\n
1307  ;;             &lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;\n
1308  ;;               To view full show notes, including transcripts, please visit the\n
1309  ;;               &lt;a href=\&quot;https://orgtech.se/episodes/ep00-trailer/\&quot;&gt;episode page&lt;/a&gt;.\n
1310  ;;             &lt;/p&gt;\n
1311  ;;             &lt;p&gt;\n
1312  ;;               Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;\n
1313  ;;             &lt;/p&gt;\n
1314  ;;             &lt;p&gt;\n
1315  ;;               Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;\n&lt;/p&gt;
1316  ;;           &#93;&#93;&gt;&lt;/description&gt;\n
1317  ;;           ...
1318  ;;         &lt;/item&gt;\n\n
1319  ;;         ...
1320  ;;       &lt;/channel&gt;\n
1321  ;;     &lt;/rss&gt;\n&quot;
1322
1323  &#41;
1324</code></pre><p>OK, this looks much better! And in fact, it looks so much better that we can declare victory and move on to figuring out how to write this beautiful feed to disk!</p><p>To do that, let's jump back to our <code>orgtech-se/bb.edn</code> and add a task for rendering the feed. We'll need to add the Soundcljoud processor to our deps, then we can pretend we have a <code>tasks/render</code> function and call it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
1325        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
1326        io.github.babashka/http-server
1327        {:git/sha &quot;e203166a020509d126149ff8046489857ce5c89f&quot;}
1328        ;; You can always depend on Soundcljoud!
1329        ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
1330        io.github.jmglov/soundcljoud
1331        {:local/root &quot;/home/jmglov/code/soundcljoud/processor&quot;}
1332        ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆
1333        io.github.jmglov/transcribble
1334        {:local/root &quot;/home/jmglov/code/transcribble/cli&quot;}}
1335 :paths &#91;&quot;.&quot;&#93;
1336 :tasks
1337 {
1338  ;; ...
1339
1340  ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
1341  render {:doc &quot;Create webpages from templates&quot;
1342          :task &#40;tasks/render opts&#41;}
1343  ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆
1344   
1345  ;; ...
1346 }}
1347</code></pre><p>And now we pop over to <code>tasks.clj</code> to implement the task. Sadly, we need to restart our REPL since we added a new dependency and I'm too lazy to learn how to use the new <code>clojure.repl.deps.add-lib</code> from Clojure 1.12 (added to Babashka in version <a href='https://github.com/babashka/babashka/releases/tag/v1.4.192'>1.4.192</a>). In Emacs, we can do this with <strong>C-c C-z</strong> (<code>cider-switch-to-repl-buffer</code>) to jump to the REPL buffer, then <strong>C-c C-q</strong> (<code>cider-quit</code>) to stop the REPL, then **C-c M-j** (<code>cider-jack-in-clj</code>) to start a new REPL. Easy peasy!</p><p>Thus armed with a new REPL, let's pull in the namespaces required to load our <code>podcast.edn</code> and then actually load our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
1348  &#40;:require &#91;babashka.cli :as cli&#93;
1349            &#91;babashka.process :as p&#93;
1350            ;; Pull in some new namespaces
1351            ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇 
1352            &#91;babashka.fs :as fs&#93;
1353            &#91;clojure.edn :as edn&#93;
1354            ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆
1355            &#41;&#41;
1356
1357&#40;comment
1358
1359  &#40;def default-opts {:website-bucket &quot;orgtech.se&quot;
1360                     :out-dir &quot;public&quot;
1361                     :distribution-id &quot;FDCBA42RSTUV3&quot;}&#41;  ; C-c C-v f c e
1362  ;; =&gt; #'tasks/default-opts
1363
1364  &#40;def opts
1365    &#40;let &#91;base-dir &#40;str &#40;fs/cwd&#41;&#41;&#93;
1366      &#40;merge default-opts
1367             &#40;-&gt; &#40;fs/file base-dir &quot;podcast.edn&quot;&#41;
1368                 slurp
1369                 edn/read-string
1370                 &#40;assoc :base-dir base-dir&#41;&#41;&#41;&#41;&#41;
1371  ;; =&gt; #'tasks/opts
1372
1373  opts
1374  ;; =&gt; {:website-bucket &quot;orgtech.se/blog&quot;,
1375  ;;     :out-dir &quot;public&quot;,
1376  ;;     :distribution-id &quot;EPTUS11MTYJF7&quot;,
1377  ;;     :base-url &quot;https://orgtech.se&quot;,
1378  ;;     :src-dir &quot;public&quot;,
1379  ;;     :podcast { ... },
1380  ;;     :episodes &#91; ... &#93;,
1381  ;;     :base-dir &quot;/home/jmglov/code/orgtech-se&quot;}
1382
1383&#41;
1384</code></pre><p>To set ourselves up for success with <code>soundcljoud.rss/podcast-feed</code>, we know that we need our MP3 files in the right place. And in fact, we cheated a bit in our REPL to copy those files to the right place, which means we have some code lying around that we can use! And whilst we're at it, we should also copy the transcript files, since we're referring to them in the rendered feed.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
1385
1386  &#40;doseq &#91;episode &#40;:episodes opts&#41;
1387          file &#40;map episode &#91;:audio-file :transcript-file&#93;&#41;
1388          :let &#91;filename &#40;format &quot;%s/%s%s/%s&quot;
1389                                 &#40;:base-dir opts&#41; &#40;:src-dir opts&#41;
1390                                 &#40;:path episode&#41; file&#41;
1391                src-filename &#40;fs/file &#40;:base-dir opts&#41;
1392                                      &#40;fs/file-name &#40;:path episode&#41;&#41;
1393                                      file&#41;&#93;&#93;
1394    &#40;when-not &#40;fs/exists? filename&#41;
1395      &#40;fs/create-dirs &#40;fs/parent filename&#41;&#41;
1396      &#40;fs/copy src-filename filename&#41;&#41;&#41;
1397  ;; =&gt; nil
1398
1399  &#40;-&gt;&gt; &#40;fs/glob &#40;fs/file &#40;:base-dir opts&#41; &#40;:src-dir opts&#41;&#41; &quot;episodes/&#42;&#42;&quot;&#41;
1400       &#40;map #&#40;-&gt; &#40;str %&#41;
1401                 &#40;str/replace &#40;:base-dir opts&#41; &quot;&quot;&#41;&#41;&#41;&#41;
1402  ;; =&gt; &#40;&quot;/public/episodes/ep01-klarna-part1&quot;
1403  ;;     &quot;/public/episodes/ep01-klarna-part1/otis-ep01-klarna-part1.otr&quot;
1404  ;;     &quot;/public/episodes/ep01-klarna-part1/otis-ep01-klarna-part1.mp3&quot;
1405  ;;     &quot;/public/episodes/ep02-klarna-part2&quot;
1406  ;;     &quot;/public/episodes/ep02-klarna-part2/otis-ep02-klarna-part2.otr&quot;
1407  ;;     &quot;/public/episodes/ep02-klarna-part2/otis-ep02-klarna-part2.mp3&quot;
1408  ;;     &quot;/public/episodes/ep00-trailer&quot;
1409  ;;     &quot;/public/episodes/ep00-trailer/otis-ep00-trailer.otr&quot;
1410  ;;     &quot;/public/episodes/ep00-trailer/otis-ep00-trailer.mp3&quot;&#41;
1411
1412  &#41;
1413</code></pre><p>Now that the files are, well, filed, let's see about rendering the podcast feed.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
1414  &#40;:require ; ...
1415            &#91;soundcljoud.rss :as rss&#93;&#41;&#41;
1416
1417&#40;comment
1418
1419  &#40;let &#91;feed-file &#40;fs/file &#40;:src-dir opts&#41; &quot;feed.rss&quot;&#41;&#93;
1420    &#40;println &#40;format &quot;Writing RSS feed %s&quot; feed-file&#41;&#41;
1421    &#40;-&gt;&gt; &#40;rss/podcast-feed opts&#41;
1422         &#40;spit feed-file&#41;&#41;&#41;
1423  ;; =&gt; nil
1424
1425  &#40;slurp &quot;public/feed.rss&quot;&#41;
1426  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;\n
1427  ;;     &lt;rss version=\&quot;2.0\&quot;\n
1428  ;;          xmlns:itunes=\&quot;http://www.itunes.com/dtds/podcast-1.0.dtd\&quot;\n
1429  ;;          xmlns:atom=\&quot;http://www.w3.org/2005/Atom\&quot;&gt;\n
1430  ;;       &lt;channel&gt;\n
1431  ;;         &lt;title&gt;
1431Organising Tech in Sweden&lt;/title&gt;\n
1432  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;\n
1433  ;;         &lt;itunes:image href=\&quot;https://orgtech.se/img/orgtech-se-cover.jpg\&quot;/&gt;\n
1434  ;;         &lt;language&gt;en&lt;/language&gt;\n
1435  ;;         &lt;itunes:explicit&gt;true&lt;/itunes:explicit&gt;\n
1436  ;;         &lt;itunes:category text=\&quot;Technology\&quot;&gt;\n
1437  ;;         &lt;/itunes:category&gt;\n
1438  ;;         &lt;itunes:category text=\&quot;News\&quot;&gt;\n
1439  ;;           &lt;itunes:category text=\&quot;Politics\&quot; /&gt;\n
1440  ;;         &lt;/itunes:category&gt;\n
1441  ;;         &lt;itunes:author&gt;Organising Tech in Sweden&lt;/itunes:author&gt;\n
1442  ;;         &lt;link&gt;https://orgtech.se&lt;/link&gt;\n
1443  ;;         &lt;itunes:title&gt;Organising Tech in Sweden&lt;/itunes:title&gt;\n
1444  ;;         &lt;itunes:type&gt;Serial&lt;/itunes:type&gt;\n
1445  ;;         &lt;copyright&gt;All rights reserved, Organising Tech in Sweden&lt;/copyright&gt;\n
1446  ;;         &lt;item&gt;\n
1447  ;;           &lt;title&gt;Trailer&lt;/title&gt;\n
1448  ;;           &lt;enclosure\n
1449  ;;               url=\&quot;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.mp3\&quot;\n
1450  ;;               length=\&quot;1016937\&quot;\n
1451  ;;               type=\&quot;audio/mpeg\&quot; /&gt;\n
1452  ;;           &lt;guid&gt;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.mp3&lt;/guid&gt;\n
1453  ;;           &lt;pubDate&gt;Thu, 5 Sep 2024 00:00:00 +0000&lt;/pubDate&gt;\n
1454  ;;           &lt;description&gt;&lt;!&#91;CDATA&#91;\n
1455  ;;             &lt;p&gt;\n
1456  ;;               Union organising seems to be in the air these days...
1457  ;;             &lt;/p&gt;&#93;&#93;&gt;
1458  ;;           &lt;/description&gt;\n
1459  ;;           &lt;itunes:duration&gt;42&lt;/itunes:duration&gt;\n
1460  ;;           &lt;link&gt;https://orgtech.se/episodes/ep00-trailer&lt;/link&gt;\n
1461  ;;           &lt;itunes:title&gt;Trailer&lt;/itunes:title&gt;\n
1462  ;;           &lt;itunes:episode&gt;0&lt;/itunes:episode&gt;\n
1463  ;;           &lt;itunes:episodeType&gt;Trailer&lt;/itunes:episodeType&gt;\n
1464  ;;           &lt;transcriptUrl&gt;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.otr&lt;/transcriptUrl&gt;\n
1465  ;;         &lt;/item&gt;\n
1466  ;;         ...
1467  ;;       &lt;/channel&gt;
1468  ;;     &lt;/rss&gt;
1469
1470  &#41;
1471</code></pre><p>OK, now we have everything we need to write our <code>render</code> function, so let's get to it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn render &#91;default-opts&#93;
1472  &#40;let &#91;base-dir &#40;str &#40;fs/cwd&#41;&#41;
1473        {:keys &#91;episodes src-dir&#93; :as opts}
1474        &#40;merge default-opts
1475               &#40;cli/parse-opts &#42;command-line-args&#42;&#41;
1476               &#40;-&gt; &#40;fs/file base-dir &quot;podcast.edn&quot;&#41;
1477                   slurp
1478                   edn/read-string
1479                   &#40;assoc :base-dir base-dir&#41;&#41;&#41;
1480        feed-file &#40;fs/file src-dir &quot;feed.rss&quot;&#41;&#93;
1481    &#40;doseq &#91;{:keys &#91;path&#93; :as episode} &#40;:episodes opts&#41;
1482            file &#40;map episode &#91;:audio-file :transcript-file&#93;&#41;
1483            :let &#91;filename &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path file&#41;
1484                  src-filename &#40;fs/file base-dir &#40;fs/file-name path&#41; file&#41;&#93;&#93;
1485      &#40;when-not &#40;fs/exists? filename&#41;
1486        &#40;fs/create-dirs &#40;fs/parent filename&#41;&#41;
1487        &#40;fs/copy src-filename filename&#41;&#41;&#41;
1488    &#40;println &#40;format &quot;Writing RSS feed %s&quot; feed-file&#41;&#41;
1489    &#40;-&gt;&gt; &#40;rss/podcast-feed opts&#41;
1490         &#40;spit feed-file&#41;&#41;&#41;&#41;
1491
1492&#40;comment
1493
1494  &#40;render default-opts&#41;
1495  ;; =&gt; nil
1496
1497  &#41;
1498</code></pre><p>We should now be able to aim our web browser at http://localhost:1341/feed.rss and see a lovely podcast feed.</p><p><img src="assets/2024-09-18-podcast-soundcljoud-feed.png" alt="RSS feed displayed in a web browser" title="Feed me nothing but the good stuff!" width=800px border=1 /></p><p>As lovely as this loveliness is, our eye is inexorably and tragically drawn to one thing which we do not love:</p><pre class="language-xml"><code class="lang-xml language-xml">    &lt;item&gt;
1499      ...
1500      &lt;link&gt;https://orgtech.se/episodes/ep00-trailer&lt;/link&gt;
1501      ...
1502    &lt;/item&gt;
1503</code></pre><p>This page, dear reader, does not exist!</p><h2 id="selmer_to_the_rescue">Selmer to the rescue</h2><p>Where does this <code>&lt;link&gt;</code> thingy come from, and why do we need it anyway? Well, if we refer back to <a href='https://help.apple.com/itc/podcasts_connect/#/itcb54353390'>A Podcaster's Guide to
1504RSS</a>, we see:</p><blockquote><p> <code>&lt;link&gt;</code> </p><p> An episode link URL.  This is used when an episode has a corresponding webpage. </p></blockquote><p>Ah, so it's an episode page we need, eh? Well, we have a bunch of info about the episode in our <code>podcast.edn</code> file, and some code that loops over episodes and does stuff in <code>tasks/render</code>, and a deep and abiding love for Selmer, so let's whip up an episode page template, then plug some stuff in whilst we're looping over episodes. We'll start with the template, which we'll drop into a new <code>templates/episode-page.html</code> file:</p><pre class="language-html"><code class="lang-html language-html">&lt;
1504!doctype html&gt;
1505&lt;html class=&quot;no-js&quot; lang=&quot;&quot;&gt;
1506
1507&lt;head&gt;
1508  &lt;title&gt;
1509    {{podcast.title}} Episode {{episode.number}} - {{episode.title}}
1510  &lt;/title&gt;
1511
1512  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;
1513  &lt;meta charset=&quot;utf-8&quot;&gt;
1514  &lt;meta http-equiv=&quot;x-ua-compatible&quot; content=&quot;ie=edge&quot;&gt;
1515
1516  &lt;link rel=&quot;stylesheet&quot; href=&quot;/css/main.css&quot;&gt;
1517
1518  &lt;!-- Favicon from https://realfavicongenerator.net/ --&gt;
1519  &lt;link rel=&quot;apple-touch-icon&quot; sizes=&quot;180x180&quot; href=&quot;/apple-touch-icon.png&quot;&gt;
1520  &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;32x32&quot; href=&quot;/favicon-32x32.png&quot;&gt;
1521  &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;16x16&quot; href=&quot;/favicon-16x16.png&quot;&gt;
1522  &lt;link rel=&quot;manifest&quot; href=&quot;/site.webmanifest&quot;&gt;
1523  &lt;link rel=&quot;mask-icon&quot; href=&quot;/safari-pinned-tab.svg&quot; color=&quot;#5bbad5&quot;&gt;
1524  &lt;meta name=&quot;msapplication-TileColor&quot; content=&quot;#da532c&quot;&gt;
1525  &lt;meta name=&quot;theme-color&quot; content=&quot;#ffffff&quot;&gt;
1526
1527  &lt;!-- Social sharing &#40;Facebook, Twitter, LinkedIn, etc.&#41; --&gt;
1528  &lt;meta name=&quot;title&quot; content=&quot;{{podcast.title}} Episode {{episode.number}} - {{episode.title}}&quot;&gt;
1529  &lt;meta name=&quot;twitter:title&quot; content=&quot;{{podcast.title}} Episode {{episode.number}} - {{episode.title}}&quot;&gt;
1530  &lt;meta property=&quot;og:title&quot; content=&quot;{{podcast.title}} Episode {{episode.number}} - {{episode.title}}&quot;&gt;
1531  &lt;meta property=&quot;og:type&quot; content=&quot;website&quot;&gt;
1532
1533  &lt;meta name=&quot;description&quot; content=&quot;{{episode.summary}}&quot;&gt;
1534  &lt;meta name=&quot;twitter:description&quot; content=&quot;{{episode.summary}}&quot;&gt;
1535  &lt;meta property=&quot;og:description&quot; content=&quot;{{episode.summary}}&quot;&gt;
1536
1537  &lt;meta name=&quot;twitter:url&quot; content=&quot;{{base-url}}{{episode.path}}/index.html&quot;&gt;
1538  &lt;meta property=&quot;og:url&quot; content=&quot;{{base-url}}{{episode.path}}/index.html&quot;&gt;
1539
1540  &lt;meta name=&quot;twitter:image&quot; content=&quot;{{base-url}}{{preview-image}}&quot;&gt;
1541  &lt;meta name=&quot;twitter:card&quot; content=&quot;summary&#95;large&#95;image&quot;&gt;
1542  &lt;meta property=&quot;og:image&quot; content=&quot;{{base-url}}{{preview-image}}&quot;&gt;
1543  &lt;meta property=&quot;og:image:alt&quot; content=&quot;{{podcast.image-alt}}&quot;&gt;
1544&lt;/head&gt;
1545
1546&lt;body&gt;
1547  &lt;div id=&quot;wrapper&quot;&gt;
1548    &lt;div id=&quot;left-side&quot;&gt;
1549      &lt;img id=&quot;cover-image&quot; src=&quot;{{podcast.image}}&quot; alt=&quot;{{podcast.image-alt}}&quot; /&gt;
1550      &lt;div id=&quot;aggregators-1&quot;&gt;
1551        &lt;div id=&quot;apple&quot;&gt;
1552          &lt;a class=&quot;apple-button&quot;
1553            href=&quot;https://podcasts.apple.com/us/podcast/organising-tech-in-sweden/id1766442275?itsct=podcast&#95;box&#95;badge&amp;amp;itscg=30200&amp;amp;ls=1&quot;&gt;
1554            &lt;img src=&quot;https://tools.applemediaservices.com/api/badges/listen-on-apple-podcasts/badge/en-us?size=250x83&amp;amp;releaseDate=1725494400&quot;
1555              title=&quot;Listen on Apple Podcasts&quot; alt=&quot;Listen on Apple Podcasts&quot; class=&quot;apple-button&quot;&gt;
1556          &lt;/a&gt;
1557        &lt;/div&gt;
1558        &lt;div id=&quot;spotify&quot;&gt;
1559          &lt;a href=&quot;https://open.spotify.com/show/53psoLoX187axvmgb80l1x&quot;&gt;
1560            &lt;img src=&quot;/img/spotify-podcast-badge-blk-grn-330x80.svg&quot; title=&quot;Listen on Spotify&quot;
1561              alt=&quot;Listen on Spotify&quot;&gt;
1562          &lt;/a&gt;
1563        &lt;/div&gt;
1564      &lt;/div&gt;
1565      &lt;div id=&quot;aggregators-2&quot;&gt;
1566        &lt;div id=&quot;podbean&quot;&gt;
1567          &lt;a href=&quot;https://www.podbean.com/podcast-detail/2r2tz-31b053/Organising-Tech-in-Sweden-Podcast&quot;
1568            rel=&quot;noopener noreferrer&quot; target=&quot;&#95;blank&quot;&gt;
1569            &lt;img src=&quot;https://pbcdn1.podbean.com/fs1/site/images/badges/w600&#95;1.png&quot;
1570              title=&quot;Listen on Podbean&quot; alt=&quot;Listen on Podbean&quot;&gt;
1571          &lt;/a&gt;
1572        &lt;/div&gt;
1573      &lt;/div&gt;
1574    &lt;/div&gt;
1575    &lt;div id=&quot;main&quot;&gt;
1576      &lt;nav id=&quot;header&quot;&gt;
1577        &lt;h1 id=&quot;title&quot;&gt;{{episode.title}}&lt;/h1&gt;
1578        &lt;div id=&quot;socials&quot;&gt;
1579          {% for social in socials %}
1580          &lt;a href=&quot;{{social.url}}&quot;&gt;
1581            &lt;img src=&quot;{{social.image}}&quot; alt=&quot;{{social.image-alt}}&quot; /&gt;
1582          &lt;/a&gt;
1583          {% endfor %}
1584        &lt;/div&gt;
1585      &lt;/nav&gt;
1586      &lt;div id=&quot;description&quot;&gt;{{episode.description|safe}}&lt;/div&gt;
1587    &lt;/div&gt;
1588  &lt;/div&gt;
1589  &lt;div id=&quot;transcript&quot;&gt;
1590    &lt;h1&gt;Transcript&lt;/h1&gt;
1591    &lt;div id=&quot;transcript-body&quot;&gt;{{episode.transcript-html|safe}}&lt;/div&gt;
1592  &lt;/div&gt;
1593&lt;/body&gt;
1594
1595&lt;/html&gt;
1596</code></pre><p>We should also sprinkle a little extra CSS into our <code>public/css/main.css</code>:</p><pre class="language-css"><code class="lang-css language-css">/&#42; ... &#42;/
1597
1598#aggregators-1 {
1599  display: flex;
1600  justify-content: space-between;
1601  margin-top: 10px;
1602}
1603
1604#aggregators-1 img {
1605  width: 175px;
1606}
1607
1608#apple a {
1609  display: inline-block;
1610  overflow: hidden;
1611}
1612
1613.apple-button {
1614  border-radius: 13px;
1615}
1616
1617#aggregators-2 {
1618  display: flex;
1619  justify-content: space-between;
1620  margin-top: 10px;
1621}
1622
1623#podbean img {
1624  height: 42px;
1625}
1626
1627/&#42; Some paragraphs in the description shouldn't be displayed on the episode page &#42;/
1628p.soundcljoud-hidden {
1629  display: none;
1630}
1631
1632#transcript {
1633  background-color: #e4f1fe;
1634  border: solid 1px;
1635  padding-left: 1em;
1636  padding-right: 1em;
1637  margin-top: 1em;
1638}
1639
1640#transcript-body &gt; br {
1641  display: none;
1642}
1643
1644span.timestamp {
1645  margin-right: 5px;
1646  color: blue;
1647  cursor: pointer;
1648  &amp;:hover {
1649    text-decoration: underline;
1650  }
1651}
1652
1653@media screen and &#40;min-width: 600px&#41; {
1654
1655  /&#42; ... &#42;/
1656
1657  #aggregators-1 img {
1658    width: 155px;
1659  }
1660
1661  #podbean img {
1662    height: 37px;
1663  }
1664
1665  #podbean img {
1666    margin-top: 0px;
1667  }
1668
1669}
1670</code></pre><p>We'll need the following template vars:</p><ul><li>base-url</li><li>episode.description</li><li>episode.number</li><li>episode.path</li><li>episode.summary</li><li>episode.title</li><li>episode.transcript-html</li><li>podcast.image</li><li>podcast.image-alt</li><li>podcast.title</li><li>preview-image</li><li>socials<ul><li>social.url</li><li>social.image</li><li>social.image-alt</li></ul></li></ul><p>Most of this we already have, but there are a couple new things. Let's take the easiest two first.</p><ul><li>podcast.image-alt</li><li>preview-image</li></ul><p>We'll add some alt text for our podcast cover image and a social preview image to <code>podcast.edn</code>. The alt text is just a description of the cover image, and it turns out that the preview image is one of the many things we hardcoded into our <code>public/index.html</code> way back when.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
1671 :preview-image &quot;/img/orgtech-se-preview.jpg&quot;
1672 ;; ...
1673 :podcast { ; ...
1674           :image-alt &quot;
1674Organising Tech in Sweden superimposed on raised fists with a Swedish flag with a circuit board pattern in the background&quot;
1675           ;; ...
1676           }
1677 ;; ...
1678 }
1679</code></pre><p>Let's turn next to <code>socials</code>. This is how we refer to it in the template:</p><pre class="language-html"><code class="lang-html language-html">        {% for social in socials %}
1680        &lt;a href=&quot;{{social.url}}&quot;&gt;
1681          &lt;img src=&quot;{{social.image}}&quot; alt=&quot;{{social.image-alt}}&quot; /&gt;
1682        &lt;/a&gt;
1683        {% endfor %}
1684</code></pre><p>This means that it needs to be a list, and each list item should be a map containing three keys:</p><ul><li>social.url</li><li>social.image</li><li>social.image-alt</li></ul><p>Let's add the following to our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
1685 :preview-image &quot;/img/orgtech-se-preview.jpg&quot;
1686 ;; ...
1687 :socials &#91;{:name &quot;Twitter&quot;
1688            :url &quot;https://x.com/orgtech&#95;se&quot;
1689            :image &quot;/img/twitter-color-svgrepo-com.svg&quot;
1690            :image-alt &quot;Twitter logo&quot;}
1691           {:name &quot;BlueSky&quot;
1692            :url &quot;https://bsky.app/profile/orgtech-se.bsky.social&quot;
1693            :image &quot;/img/bluesky-logo.svg&quot;
1694            :image-alt &quot;BlueSky logo&quot;}&#93;
1695 ;; ...
1696 }
1697</code></pre><p>Finally, we need to conjure up one last key for each episode:</p><ul><li>episode.transcript-html</li></ul><p>Episodes already have a <code>:transcript-file</code> key, which refers to an OTR file. Let's have a quick look at one of those and see what is contained therein:</p><pre class="language-json"><code class="lang-json language-json">{
1698  &quot;text&quot;: &quot;&lt;p&gt;&#91;Theme music begins&#93;&lt;/p&gt;&lt;p&gt;&lt;span class=\&quot;timestamp\&quot; data-timestamp=\&quot;12.111684\&quot;&gt;00:12&lt;/span&gt;&lt;b&gt;Josh&lt;/b&gt;: ... &lt;/p&gt;&quot;,
1699  &quot;media&quot;: &quot;otis-ep01-klarna-part1.mp3&quot;,
1700  &quot;media-time&quot;: 1315.629803
1701}
1702</code></pre><p>What we have here is a JSON file with a thin veneer of metadata around a looooong HTML string. Let's deal with this back in <code>podcast.rss/update-episode</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-episode &#91;{:keys &#91;base-dir src-dir&#93; :as opts}
1703                      ;;                 👇👇👇👇👇👇👇
1704                      {:keys &#91;audio-file transcript-file path&#93; :as episode}&#93;
1705                      ;;                 👆👆👆👆👆👆👆
1706  &#40;let &#91;filename &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path audio-file&#41;
1707        ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
1708        transcript &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path transcript-file&#41;&#93;
1709        ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆
1710    &#40;assoc episode
1711           :audio-filesize &#40;fs/size filename&#41;
1712           :duration &#40;audio/mp3-duration filename&#41;
1713           :description &#40;selmer/render &#40;:description episode&#41;
1714                                       &#40;assoc opts :episode episode&#41;&#41;
1715           ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
1716           :transcript-html &#40;-&gt; &#40;slurp transcript&#41;
1717                                &#40;json/parse-string keyword&#41;
1718                                :text&#41;&#41;&#41;&#41;
1719</code></pre><p>Having satisfied ourselves that we have all of the data we need for our template, let's get to rendering. In our <code>tasks/render</code> function, we're looping over episodes in order to copy the MP3 and OTR files into the right place. Sadly, we can't just pop <code>selmer/render</code> into that <code>doseq</code> and be done with it, because we need to ensure the audio file is in the right place before we can use <code>update-episode</code>. No problem, we'll just add a new bit after spitting our podcast feed:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
1720  &#40;:require ; ...
1721            &#91;selmer.parser :as selmer&#93;&#41;&#41;
1722
1723&#40;defn render &#91;default-opts&#93;
1724  &#40;let &#91;...&#93;
1725    ;; ...
1726    &#40;println &#40;format &quot;Writing RSS feed %s&quot; feed-file&#41;&#41;
1727    &#40;-&gt;&gt; &#40;rss/podcast-feed opts&#41;
1728         &#40;spit feed-file&#41;&#41;
1729    &#40;let &#91;template &#40;slurp &quot;templates/episode-page.html&quot;&#41;
1730          opts &#40;rss/update-episodes opts&#41;&#93;
1731      &#40;doseq &#91;{:keys &#91;path&#93; :as episode} &#40;:episodes opts&#41;
1732              :let &#91;filename &#40;format &quot;%s/%s%s/%s&quot;
1733                                     base-dir out-dir path &quot;index.html&quot;&#41;&#93;&#93;
1734        &#40;println &quot;Writing episode page&quot; filename&#41;
1735        &#40;-&gt;&gt; &#40;selmer/render template &#40;assoc opts :episode episode&#41;&#41;
1736             &#40;spit filename&#41;&#41;&#41;&#41;&#41;&#41;
1737</code></pre><p>And now, the moment of truth!</p><pre class="language-text"><code class="lang-text language-text">: orgtech-se; bb render
1738Writing RSS feed public/feed.rss
1739Writing episode page &#126;/code/orgtech-se/public/episodes/ep00-trailer/index.html
1740Writing episode page &#126;/code/orgtech-se/public/episodes/ep01-klarna-part1/index.html
1741Writing episode page &#126;/code/orgtech-se/public/episodes/ep02-klarna-part2/index.html
1742</code></pre><p>And now if we visit (for example) http://localhost:1341/episodes/ep01-klarna-part1, we should see an amazing webpage:</p><p><img src="assets/2024-09-18-podcast-soundcljoud-episode.png" alt="Episode page displayed in a web browser" title="I feel good, but also empty" width=800px border=1 /></p><h2 id="podcast_half_empty%2C_or_podcast_half_full%3F">Podcast half empty, or podcast half full?</h2><p>Astute observers may have noticed one issue with the episode page: there's no way to play the episode. 🤦🏼</p><p>Fear not! In the next instalment, we'll look at playing a podcast with ClojureScript, perhaps even using our own friend <a href='tags/soundcljoud.html'>Soundcljoud</a>!</p>
1743        
1744        <div class="post-footer">
1745            <div class="post-details">
1746
1747                <div>
1748                    🏷
1749
1750                    <span class="tag">
1751                        <a href="tags/clojure.html">clojure</a>
1752                    </span>
1753
1754                    <span class="tag">
1755                        <a href="tags/soundcljoud.html">soundcljoud</a>
1756                    </span>
1757
1758                    <span class="tag">
1759                        <a href="tags/babashka.html">babashka</a>
1760                    </span>
1761
1762                    <span class="tag">
1763                        <a href="tags/scittle.html">scittle</a>
1764                    </span>
1765
1766                    <span class="tag">
1767                        <a href="tags/clonejure.html">clonejure</a>
1768                    </span>
1769
1770                    <span class="tag">
1771                        <a href="tags/clojurescript.html">clojurescript</a>
1772                    </span>
1773
1774                </div>
1775
1776
1777                <div>📣 Discuss this post <a href="https://clojurians.slack.com/archives/C8NUSGWG6/p1726658139071349">here</a></div>
1778
1779                <div>📝 Published: 2024-09-18</div>
1780            </div>
1781
1782            <div class="post-prev-next">
1783                <div>
1784                    📚 Read more:
1785                    <a href="2024-08-13-soundcljoud-rangey.html">Soundcljoud gets more rangey</a>
1786                </div>
1787            </div>
1788
1789        </div>
1790    </div>
1791
1792
1793
1794      
1795      <div id="archive-footer">
1796        🗃️ <a class="page-link" href="archive.html">Archive</a>
1797      </div>
1798      
1799    </div>
1800  </body>
1801</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.