<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wc="https://wilhelm.codes/ns/feed/1.0"><channel><title>Wilhelm Codes · Writing</title><link>https://wilhelm.codes/blog/</link><description>Slinger of nibbles, bits and bytes. Over 25 years of professional experience as a software engineer. Love making glowing rectangles go "beep-boop".</description><generator>Hugo</generator><language>en-US</language><managingEditor>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</managingEditor><webMaster>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</webMaster><lastBuildDate>Fri, 11 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://wilhelm.codes/blog/index.xml" rel="self" type="application/rss+xml"/><item><title>The Safest Login Page is the One I Never Published</title><link>https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/</link><pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/</guid><category>homelab</category><category>umami</category><category>cloudflare</category><category>analytics</category><wc:kind>post</wc:kind><description> In retrospect this really should&amp;rsquo;ve been a two-parter 😬. I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up Umami in the homelab. The install was the easy part; the instructions are dead simple.
Then I got to the bit where you expose it to the internet. I went along with the advice everyone gives, stopped halfway and decided it wasn&amp;rsquo;t good enough for me. So, I did what I normally do and stubbornly wandered off to my own path.</description><content:encoded><![CDATA[<div class="callout callout-notice">
  In retrospect this really should&rsquo;ve been a two-parter 😬.
</div>

<p>I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up <a href="https://umami.is">Umami</a> in the homelab. The install was the easy part; the instructions are dead simple.</p>
<p>Then I got to the bit where you expose it to the internet. I went along with the advice everyone gives, stopped halfway and decided it wasn&rsquo;t good enough for <em>me</em>. So, I did what I normally do and stubbornly wandered off to my own path.</p>
<p>Quick bit of context for anyone who hasn&rsquo;t gone down this particular hole. Umami is a self-hosted analytics platform: a small Node app with a PostgreSQL database to maintain persistence sans the cookies and third parties slurping up all the data. It&rsquo;s as simple as dropping one script tag on your site. It posts a little blob of JSON on each page view and you get a dashboard. My install runs in an LXC on a member node of my homelab&rsquo;s Proxmox cluster.</p>
<p>I landed on it for the &ldquo;normal&rdquo; reasons. Cloudflare&rsquo;s Analytics are free but limited. They don&rsquo;t give me precisely what I&rsquo;m looking for and they&rsquo;re a bit too slow for my liking. And, I&rsquo;m not even going to give Google a single thought; they have enough of my data already.</p>
<p>Which leaves the question: how does a script tag on a public website safely and <em>securely</em> reach a container in my house without punching a hole in my router?</p>
<h2 id="the-answer-everyone-gives-you">
  <a class="heading-link" href="#the-answer-everyone-gives-you">The answer everyone gives you.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The standard answer is a <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/">Cloudflare Tunnel</a>. A small daemon, <code>cloudflared</code>, runs next to your service and dials <em>out</em> to Cloudflare. No dicking around with port forwarding, firewall rules or opening up your router to the world. Traffic arrives at Cloudflare, goes down the pipe your daemon already opened and lands on your service. It&rsquo;s genuinely lovely and it costs nothing. And since I already use Cloudflare to <em>currently</em> host my static websites and domains it was just the pragmatic option.</p>
<p>I think it&rsquo;s worth noting there is a real trade off here. A tunnel means Cloudflare terminates your TLS. Since they decrypt at the edge, they could inspect whatever they like and they re-encrypt on the way down the pipe. For page view pings carrying a URL and a screen size, I genuinely do not care. For anything with secrets in the payload I would care enormously and so should you.</p>
<p>Anyway, Umami has an admin interface. An admin interface typically has a login page. So, most guides you&rsquo;ll find after searching around will tell you to put something like <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/">Cloudflare Access</a> in front of it. Access is an authentication layer at the edge. Basically, someone hits your hostname, Cloudflare intercepts, they sign in against an identity provider and only then does the request continue to your box.</p>
<p>I was about to wire it to my IDP when I actually stopped to read my own ingress config a bit more closely.</p>
<h2 id="tunnels-match-on-path">
  <a class="heading-link" href="#tunnels-match-on-path">Tunnels match on path.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The important little detail I almost skimmed past was that a tunnel&rsquo;s ingress rules do not just match on hostname, but on <em>path</em>.</p>
<p>I had written this, which is what everyone starts with:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="n">ingress</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://umami:3000&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    service</span> <span class="o">=</span> <span class="s2">&#34;http_status:404&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div>
</div>
<p>You write a single hostname and everything on it goes straight to the app while anything else gets a <code>404</code>. Ok.</p>
<p>But my analytics service only <em>needs</em> to expose <em>two things</em> to the public internet; the tracker script and the collect endpoint. Nobody on the internet needs to see <code>/login</code> or the dashboard, the settings, the user management or the API the dashboard talks to. So, why was I publishing everything and then buying into another service to place in front of it?</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="n">ingress</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    path</span>     <span class="o">=</span> <span class="s2">&#34;^/bundle\\.js$&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://nginx:80&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    path</span>     <span class="o">=</span> <span class="s2">&#34;^/api/v1/data$&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://nginx:80&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    service</span> <span class="o">=</span> <span class="s2">&#34;http_status:404&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div>
</div>
<p>I only need precisely <em>two</em> paths out while everything else gets met with a lovely <code>404</code>.</p>
<p>My admin interface still exists, of course ( I am not typing SQL to read my own page views ). It sits on my home network behind my own reverse proxy, reachable from the couch or over my established VPN.</p>
<p>Drawn out, the whole arrangement is two seperate paths that happen to share a name:</p>
<figure class="diagram"><svg id="d-9c432e0a5e48" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 597.145px; background-color: transparent;" viewBox="0 0 597.14453125 635" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#d-9c432e0a5e48{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-9c432e0a5e48 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-9c432e0a5e48 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-9c432e0a5e48 .error-icon{fill:#202225;}#d-9c432e0a5e48 .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-9c432e0a5e48 .edge-thickness-normal{stroke-width:1px;}#d-9c432e0a5e48 .edge-thickness-thick{stroke-width:3.5px;}#d-9c432e0a5e48 .edge-pattern-solid{stroke-dasharray:0;}#d-9c432e0a5e48 .edge-thickness-invisible{stroke-width:0;fill:none;}#d-9c432e0a5e48 .edge-pattern-dashed{stroke-dasharray:3;}#d-9c432e0a5e48 .edge-pattern-dotted{stroke-dasharray:2;}#d-9c432e0a5e48 .marker{fill:#8b8b84;stroke:#8b8b84;}#d-9c432e0a5e48 .marker.cross{stroke:#8b8b84;}#d-9c432e0a5e48 svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-9c432e0a5e48 p{margin:0;}#d-9c432e0a5e48 .label{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;color:#32d74b;}#d-9c432e0a5e48 .cluster-label text{fill:#e3e3de;}#d-9c432e0a5e48 .cluster-label span{color:#e3e3de;}#d-9c432e0a5e48 .cluster-label span p{background-color:transparent;}#d-9c432e0a5e48 .label text,#d-9c432e0a5e48 span{fill:#32d74b;color:#32d74b;}#d-9c432e0a5e48 .node rect,#d-9c432e0a5e48 .node circle,#d-9c432e0a5e48 .node ellipse,#d-9c432e0a5e48 .node polygon,#d-9c432e0a5e48 .node path{fill:#1e1f22;stroke:#3c3f43;stroke-width:1px;}#d-9c432e0a5e48 .rough-node .label text,#d-9c432e0a5e48 .node .label text,#d-9c432e0a5e48 .image-shape .label,#d-9c432e0a5e48 .icon-shape .label{text-anchor:middle;}#d-9c432e0a5e48 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#d-9c432e0a5e48 .rough-node .label,#d-9c432e0a5e48 .node .label,#d-9c432e0a5e48 .image-shape .label,#d-9c432e0a5e48 .icon-shape .label{text-align:center;}#d-9c432e0a5e48 .node.clickable{cursor:pointer;}#d-9c432e0a5e48 .root .anchor path{fill:#8b8b84!important;stroke-width:0;stroke:#8b8b84;}#d-9c432e0a5e48 .arrowheadPath{fill:#dfddda;}#d-9c432e0a5e48 .edgePaths .path{stroke:#8b8b84;stroke-width:1px;}#d-9c432e0a5e48 .flowchart-link{stroke:#8b8b84;fill:none;}#d-9c432e0a5e48 .edgeLabel{background-color:#1e1f22;text-align:center;}#d-9c432e0a5e48 .edgeLabel p{background-color:#1e1f22;}#d-9c432e0a5e48 .edgeLabel rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-9c432e0a5e48 .labelBkg{background-color:rgba(30, 31, 34, 0.5);}#d-9c432e0a5e48 .cluster rect{fill:#202225;stroke:#2c2e31;stroke-width:1px;}#d-9c432e0a5e48 .cluster text{fill:#e3e3de;}#d-9c432e0a5e48 .cluster span{color:#e3e3de;}#d-9c432e0a5e48 .node .collapsed-indicator{fill:#2c2e31;stroke:none;opacity:0.6;}#d-9c432e0a5e48 .node .collapsed-separator{stroke:#2c2e31;stroke-width:0.75px;}#d-9c432e0a5e48 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;background:#202225;border:1px solid hsl(216, 0%, 23.5294117647%);border-radius:2px;pointer-events:none;z-index:100;}#d-9c432e0a5e48 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#32d74b;}#d-9c432e0a5e48 rect.text{fill:none;stroke-width:0;}#d-9c432e0a5e48 .icon-shape,#d-9c432e0a5e48 .image-shape{background-color:#1e1f22;text-align:center;}#d-9c432e0a5e48 .icon-shape p,#d-9c432e0a5e48 .image-shape p{background-color:#1e1f22;padding:2px;}#d-9c432e0a5e48 .icon-shape .label rect,#d-9c432e0a5e48 .image-shape .label rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-9c432e0a5e48 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#d-9c432e0a5e48 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#d-9c432e0a5e48 .node .neo-node{stroke:#3c3f43;}#d-9c432e0a5e48 [data-look="neo"].node rect,#d-9c432e0a5e48 [data-look="neo"].cluster rect,#d-9c432e0a5e48 [data-look="neo"].node polygon{stroke:url(#d-9c432e0a5e48-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 [data-look="neo"].swimlane.cluster rect{filter:none;}#d-9c432e0a5e48 [data-look="neo"].node path{stroke:url(#d-9c432e0a5e48-gradient);stroke-width:1px;}#d-9c432e0a5e48 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-9c432e0a5e48 [data-look="neo"].node circle{stroke:url(#d-9c432e0a5e48-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 [data-look="neo"].node circle .state-start{fill:#000000;}#d-9c432e0a5e48 [data-look="neo"].icon-shape .icon{fill:url(#d-9c432e0a5e48-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-9c432e0a5e48-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-9c432e0a5e48 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="d-9c432e0a5e48_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="d-9c432e0a5e48_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"/><g class="edgePaths"><path d="M260.007,59L243.818,64.917C227.629,70.833,195.252,82.667,179.064,93.833C162.875,105,162.875,115.5,162.875,120.75L162.875,126" id="d-9c432e0a5e48-L_N_CD_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_N_CD_0" data-points="W3sieCI6MjYwLjAwNjY5MTg1NDUwODIsInkiOjU5fSx7IngiOjE2Mi44NzUsInkiOjk0LjV9LHsieCI6MTYyLjg3NSwieSI6MTMwfV0=" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M397.473,59L413.18,64.917C428.887,70.833,460.301,82.667,476.008,98.75C491.715,114.833,491.715,135.167,491.715,155.5C491.715,175.833,491.715,196.167,491.715,216.5C491.715,236.833,491.715,257.167,491.715,275.75C491.715,294.333,491.715,311.167,491.715,323.083C491.715,335,491.715,342,491.715,345.5L491.715,349" id="d-9c432e0a5e48-L_N_TD_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_N_TD_0" data-points="W3sieCI6Mzk3LjQ3MjUyODE3NjIyOTUsInkiOjU5fSx7IngiOjQ5MS43MTQ4NDM3NSwieSI6OTQuNX0seyJ4Ijo0OTEuNzE0ODQzNzUsInkiOjE1NS41fSx7IngiOjQ5MS43MTQ4NDM3NSwieSI6MjE2LjV9LHsieCI6NDkxLjcxNDg0Mzc1LCJ5IjoyNzcuNX0seyJ4Ijo0OTEuNzE0ODQzNzUsInkiOjMyOH0seyJ4Ijo0OTEuNzE0ODQzNzUsInkiOjM1M31d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M162.875,181L162.875,186.917C162.875,192.833,162.875,204.667,162.875,215.833C162.875,227,162.875,237.5,162.875,242.75L162.875,248" id="d-9c432e0a5e48-L_CD_E_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_CD_E_0" data-points="W3sieCI6MTYyLjg3NSwieSI6MTgxfSx7IngiOjE2Mi44NzUsInkiOjIxNi41fSx7IngiOjE2Mi44NzUsInkiOjI1Mn1d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M491.715,404L491.715,409.917C491.715,415.833,491.715,427.667,491.715,438.833C491.715,450,491.715,460.5,491.715,465.75L491.715,471" id="d-9c432e0a5e48-L_TD_P_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_TD_P_0" data-points="W3sieCI6NDkxLjcxNDg0Mzc1LCJ5Ijo0MDR9LHsieCI6NDkxLjcxNDg0Mzc1LCJ5Ijo0MzkuNX0seyJ4Ijo0OTEuNzE0ODQzNzUsInkiOjQ3NX1d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M162.875,303L162.875,307.167C162.875,311.333,162.875,319.667,162.875,327.333C162.875,335,162.875,342,162.875,345.5L162.875,349" id="d-9c432e0a5e48-L_E_C_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_E_C_0" data-points="W3sieCI6MTYyLjg3NSwieSI6MzAzfSx7IngiOjE2Mi44NzUsInkiOjMyOH0seyJ4IjoxNjIuODc1LCJ5IjozNTN9XQ==" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M115.752,404L104.818,409.917C93.884,415.833,72.016,427.667,65.707,438.993C59.398,450.32,68.648,461.14,73.273,466.55L77.898,471.96" id="d-9c432e0a5e48-L_C_X_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_X_0" data-points="W3sieCI6MTE1Ljc1MTYwMDkyMjEzMTE1LCJ5Ijo0MDR9LHsieCI6NTAuMTQ4NDM3NSwieSI6NDM5LjV9LHsieCI6ODAuNDk3MTE4MzQwMTYzOTQsInkiOjQ3NX1d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M162.875,404L162.875,409.917C162.875,415.833,162.875,427.667,157.469,439.027C152.063,450.387,141.251,461.275,135.845,466.718L130.439,472.162" id="d-9c432e0a5e48-L_C_X_2" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_X_2" data-points="W3sieCI6MTYyLjg3NSwieSI6NDA0fSx7IngiOjE2Mi44NzUsInkiOjQzOS41fSx7IngiOjEyNy42MjA1MTc0MTgwMzI3OSwieSI6NDc1fV0=" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M220.882,404L234.341,409.917C247.8,415.833,274.718,427.667,288.178,438.833C301.637,450,301.637,460.5,301.637,465.75L301.637,471" id="d-9c432e0a5e48-L_C_F_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_F_0" data-points="W3sieCI6MjIwLjg4MTk0ODAwMjA0OTE3LCJ5Ijo0MDR9LHsieCI6MzAxLjYzNjcxODc1LCJ5Ijo0MzkuNX0seyJ4IjozMDEuNjM2NzE4NzUsInkiOjQ3NX1d" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M102.297,526L102.297,530.167C102.297,534.333,102.297,542.667,121.664,552.549C141.031,562.431,179.765,573.862,199.132,579.578L218.5,585.294" id="d-9c432e0a5e48-L_X_U_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_X_U_0" data-points="W3sieCI6MTAyLjI5Njg3NSwieSI6NTI2fSx7IngiOjEwMi4yOTY4NzUsInkiOjU1MX0seyJ4IjoyMjIuMzM1OTM3NSwieSI6NTg2LjQyNTg1NDkwNTcyMDd9XQ==" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/><path d="M491.715,526L491.715,530.167C491.715,534.333,491.715,542.667,464.494,553.13C437.273,563.594,382.831,576.188,355.61,582.485L328.389,588.782" id="d-9c432e0a5e48-L_P_U_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_P_U_0" data-points="W3sieCI6NDkxLjcxNDg0Mzc1LCJ5Ijo1MjZ9LHsieCI6NDkxLjcxNDg0Mzc1LCJ5Ijo1NTF9LHsieCI6MzI0LjQ5MjE4NzUsInkiOjU4OS42ODM5ODQ5NjkxMzN9XQ==" data-look="classic" marker-end="url(#d-9c432e0a5e48_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(162.875, 94.5)"><g class="label" data-id="L_N_CD_0" transform="translate(-96.9375, -10.5)"><foreignObject width="193.875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>asked from the internet</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(491.71484375, 216.5)"><g class="label" data-id="L_N_TD_0" transform="translate(-71.6484375, -10.5)"><foreignObject width="143.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>asked from my LAN</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(162.875, 216.5)"><g class="label" data-id="L_CD_E_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>104.21.x.x</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(491.71484375, 439.5)"><g class="label" data-id="L_TD_P_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>10.0.0.200</p></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_E_C_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(50.1484375, 439.5)"><g class="label" data-id="L_C_X_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>/bundle.js</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(162.875, 439.5)"><g class="label" data-id="L_C_X_2" transform="translate(-50.578125, -10.5)"><foreignObject width="101.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>/api/v1/data</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(301.63671875, 439.5)"><g class="label" data-id="L_C_F_0" transform="translate(-63.21875, -10.5)"><foreignObject width="126.4375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>everything else</p></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_X_U_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_P_U_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default" id="d-9c432e0a5e48-flowchart-N-0" data-look="classic" transform="translate(329.77734375, 33.5)"><rect class="basic label-container" style="" x="-143.7890625" y="-25.5" width="287.578125" height="51"/><g class="label" style="" transform="translate(-113.7890625, -10.5)"><rect/><foreignObject width="227.578125" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>analytics.nightcity.network</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-CD-2" data-look="classic" transform="translate(162.875, 155.5)"><rect class="basic label-container" style="" x="-89.0078125" y="-25.5" width="178.015625" height="51"/><g class="label" style="" transform="translate(-59.0078125, -10.5)"><rect/><foreignObject width="118.015625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Cloudflare DNS</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-TD-4" data-look="classic" transform="translate(491.71484375, 378.5)"><rect class="basic label-container" style="" x="-72.1484375" y="-25.5" width="144.296875" height="51"/><g class="label" style="" transform="translate(-42.1484375, -10.5)"><rect/><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Technitium</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-E-6" data-look="classic" transform="translate(162.875, 277.5)"><rect class="basic label-container" style="" x="-93.21875" y="-25.5" width="186.4375" height="51"/><g class="label" style="" transform="translate(-63.21875, -10.5)"><rect/><foreignObject width="126.4375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Cloudflare edge</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-P-8" data-look="classic" transform="translate(491.71484375, 500.5)"><rect class="basic label-container" style="" x="-97.4296875" y="-25.5" width="194.859375" height="51"/><g class="label" style="" transform="translate(-67.4296875, -10.5)"><rect/><foreignObject width="134.859375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>My reverse proxy</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-C-10" data-look="classic" transform="translate(162.875, 378.5)"><rect class="basic label-container" style="" x="-76.359375" y="-25.5" width="152.71875" height="51"/><g class="label" style="" transform="translate(-46.359375, -10.5)"><rect/><foreignObject width="92.71875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>cloudflared</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-X-12" data-look="classic" transform="translate(102.296875, 500.5)"><rect class="basic label-container" style="" x="-51.078125" y="-25.5" width="102.15625" height="51"/><g class="label" style="" transform="translate(-21.078125, -10.5)"><rect/><foreignObject width="42.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>nginx</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-F-16" data-look="classic" transform="translate(301.63671875, 500.5)"><rect class="basic label-container" style="" x="-42.6484375" y="-25.5" width="85.296875" height="51"/><g class="label" style="" transform="translate(-12.6484375, -10.5)"><rect/><foreignObject width="25.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>404</p></span></div></foreignObject></g></g><g class="node default" id="d-9c432e0a5e48-flowchart-U-18" data-look="classic" transform="translate(273.4140625, 601.5)"><rect class="basic label-container" style="" x="-51.078125" y="-25.5" width="102.15625" height="51"/><g class="label" style="" transform="translate(-21.078125, -10.5)"><rect/><foreignObject width="42.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Umami</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="d-9c432e0a5e48-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><defs><filter id="d-9c432e0a5e48-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><linearGradient id="d-9c432e0a5e48-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3c3f43" stop-opacity="1"/><stop offset="100%" stop-color="hsl(216, 0%, 23.5294117647%)" stop-opacity="1"/></linearGradient></svg></figure><p><code>cloudflared</code> decides what gets through and it&rsquo;s only aware of two paths. My side of the picture never touches Cloudflare, or the rest of the internet, at all. That shared fork at the top is possible with split horizon DNS and which route your request takes depends on how you get to that point. I&rsquo;ll explain a bit further down.</p>
<h2 id="why-i-think-this-is-the-better-trade">
  <a class="heading-link" href="#why-i-think-this-is-the-better-trade">Why I think this is the better trade.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I want to be fair to Access here, because it&rsquo;s a great product and there are plenty of setups where it&rsquo;s the right call. If you genuinely need to reach an admin panel from anywhere on a machine you don&rsquo;t control, without a VPN, Access is exactly the kind of tool that&rsquo;ll get you there.</p>
<p>But if you don&rsquo;t need that, consider what you&rsquo;re actually buying in to.</p>
<p>With Access, <code>/login</code> is on the internet. It returns a challenge instead of a form, which is much better than nothing, but the <em>attack surface</em> is still there. There&rsquo;s an auth flow to configure, an IDP to keep working and an additional sign-in every time. Umami doesn&rsquo;t consume the Access token, so you authenticate to Cloudflare and then authenticate again to Umami. Nobody has time for that nonsense.</p>
<p>With path scoping, <code>/login</code> returns a <code>404</code>. There is no flow to misconfigure because there is no flow. There is nothing to leave accidentally open when you change something else eighteen months from now.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Elimination is the ultimate form of hardening.</p>
  </blockquote>
  <figcaption>
    <cite>Pretty much every cybersecurity professional.</cite>
  </figcaption>
</figure>

<p>The thing I keep coming back to: <strong>an attack surface you removed cannot be misconfigured later.</strong> A control you added can. This is all about shrinking the <a href="https://en.wikipedia.org/wiki/Attack_surface">attack surface</a>, which is an idea that&rsquo;s far older than me. We&rsquo;re not doing anything new here. But a standard installation path is built to get the typical user up and running quickly. However, quick doesn&rsquo;t necessarily mean appropriate for something like this.</p>
<p>Worth being precise about which of these is which, though. The <code>404</code> on <code>/login</code> is absence. There is nothing behind it to find no matter how hard you look. Umami does allow you to rename its default tracker paths as well, but that&rsquo;s plainly obscurity and it buys me nothing against anyone who actually takes the time to read my page source. Outside of that, renaming dodges a blocklist that matches on names and known patterns.</p>
<h2 id="does-it-work-though">
  <a class="heading-link" href="#does-it-work-though">Does it work though?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The nice part about this setup is that you can test the public path without leaving the house. Using <code>curl --resolve</code> lets you skip your own DNS and dial the Cloudflare edge directly, while still sending the right hostname, so the request takes the same road a real visitor does.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sI <span class="se">\
</span></span></span><span class="line"><span class="cl">  --resolve analytics.nightcity.network:443:104.21.x.x <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://analytics.nightcity.network/login <span class="p">|</span> head -1</span></span></code></pre></div>
</div>
<p>Run that against a handful of paths and you get:</p>
<div class="code-block">
  <pre tabindex="0"><code>/                  404
/login             404
/bundle.js         200
/script.js         404
/api/v1/data       405
/api/send          404</code></pre>
</div>
<p>That <code>405</code> is the collect endpoint telling me it only accepts <code>POST</code>, which is exactly what I want to see from a <code>GET</code>. It does admit the endpoint exists, where a <code>404</code> would not, but that one has to be findable anyway. Everything else gets a polite middle finger.</p>
<p>Meanwhile, from inside the house, the same hostname gives me the full dashboard. The same two path as before and the split falls out of the DNS I already run for network-wide adblock via a Technitium cluster.</p>
<h3 id="something-worth-pointing-out">
  <a class="heading-link" href="#something-worth-pointing-out">Something worth pointing out.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I updated the ingress config, re-ran my checks and <code>/script.js</code> came back with a <code>200</code>. Which was alarming, because I had just watched it <code>404</code>.</p>
<p>It was cached. Umami serves its tracker with a 24-hour cache header. My earlier testing had pulled it through the edge and Cloudflare was dutifully serving me the copy it already had. Which, to be fair, is exactly what it should normally be doing. A request with a junk query string came back <code>404</code> immediately confirming the origin was doing its job.</p>
<p>Worth knowing if you ever unpublish something: the edge does not find out until its copy expires. Cache invalidation strikes again!</p>
<h2 id="hark-a-plot-twist">
  <a class="heading-link" href="#hark-a-plot-twist">Hark! A plot twist!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Just after I shipped the script tag and confirmed real traffic was hitting Umami, Firefox showed me this:</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: The Firefox popup for Local Network Access.">
      <img
  src="https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_cb80f6f98d090c39.webp"
  srcset="/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_cb80f6f98d090c39.webp 736w, /blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_74200a685c603811.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="308"
  alt="The Firefox popup for Local Network Access."
  loading="lazy"
  decoding="async"
  data-full="/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_53a1cc4f5ba18d12.webp"
  data-credit="Go home Firefox, you are drunk."
  
/>

    </button>
    
      <figcaption>
        
          Go home Firefox, you are drunk.
        
      </figcaption>
    
  </figure>
<p>Sir, this is my own site from my machine on my home network. How <em>dare</em> you?</p>
<p>As I mentioned earlier, I run split horizon DNS. Inside my network, <code>analytics.nightcity.network</code> resolves to a private address on my LAN so I get the dashboard directly. Outside, where <em>you better be</em> reading from, the same name resolves to Cloudflare and goes down the tunnel. That&rsquo;s what makes the two-path thing work. I also mentioned this before in my previous article <a href="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/">My Blog Now Ships From My Homelab!</a> and it&rsquo;s one of my favourite aspects about my setup.</p>
<p>But when I load my <em>public</em> blog from my <em>own house</em>, the browser sees a page served from a public origin trying to load a subresource from <code>10.x.x.x</code>. Browsers have started treating that as exactly the attack it usually is: a website on the internet quietly poking at your router, your printer or your NAS. Firefox calls it <a href="https://support.mozilla.org/en-US/kb/control-personal-device-local-network-permissions-firefox">Local Network Access</a> and blocks it by default. Chrome is heading the same way.</p>
<p>So the one place on earth where my analytics silently do not work is the chair I&rsquo;m sitting in which is precisely how it should work. If it ever bothers me, the fix is to give the tracker its own hostname in a domain my internal resolver doesn&rsquo;t answer for, so it goes out to the edge from everywhere including my lounge room. I won&rsquo;t be doing that as I&rsquo;m all too happy skipping my own page views.</p>
<h2 id="the-one-thing-path-scoping-does-not-fix">
  <a class="heading-link" href="#the-one-thing-path-scoping-does-not-fix">The one thing path scoping does not fix.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Moving the admin interface off the internet does nothing for the collect endpoint. That endpoint has to accept anonymous <code>POST</code>s from every visitor&rsquo;s browser, or it isn&rsquo;t an analytics endpoint. Umami rejects requests carrying a website ID it doesn&rsquo;t recognise, which stops idle nonsense, but my real website ID is sitting in the page source of every page on this site and <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span>. Literally anyone can read it.</p>
<p>So the only real control is applying some kind of rate limit. Cloudflare&rsquo;s free tier, which I&rsquo;m currently on because I&rsquo;m cheap and actually trying to <em>reduce</em> my subscription costs, permits one rule at a ten second window and it insists on counting per <em>data centre</em> rather than <em>globally</em>. It&rsquo;ll stop something stupid. But, it will not stop something determined.</p>
<p>Basically, path scoping shrinks the surface area enormously and then stops. What&rsquo;s left is the stuff that&rsquo;s <em>supposed</em> to be open. Though, the worst that can happen if the service gets hammered is fill my disk with garbage data points. Which would no-shit make me laugh.</p>
<h2 id="-so-naturally-i-put-nginx-in-front-of-it">
  <a class="heading-link" href="#-so-naturally-i-put-nginx-in-front-of-it">&hellip; so, naturally I put Nginx in front of it.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I told myself this was about the rate limit which was a lie. It took a few hours for two better reasons to turn up.</p>
<h3 id="the-origin-is-blind">
  <a class="heading-link" href="#the-origin-is-blind">The origin is blind.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>A tunnel terminates the connection at the edge and opens a fresh one to your box, so every request arrives from the connector sitting next to your service. Umami sees <code>172.16.x.x</code>, the Docker address of <code>cloudflared</code>, for the entire internet. That&rsquo;s also why the usual advice of &ldquo;just put fail2ban on it&rdquo; doesn&rsquo;t really apply. <a href="https://en.wikipedia.org/wiki/Fail2ban">Fail2ban</a> blocks source addresses with a firewall rule and no packet ever arrives from the address you&rsquo;d want to block.</p>
<p>Cloudflare does pass the real client along in a <code>CF-Connecting-IP</code> header. Nginx can promote that header back into being the actual client address, which makes both the logging and the limiting mean something:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Nginx</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">set_real_ip_from</span> <span class="n">172.16.0.0</span><span class="s">/12</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">real_ip_header</span>   <span class="s">CF-Connecting-IP</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">limit_req_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=collect:10m</span> <span class="s">rate=5r/s</span><span class="p">;</span></span></span></code></pre></div>
</div>
<p>The <code>set_real_ip_from</code> range tells Nginx only the connector may assert that header. Nginx answers <code>cloudflared</code> and nothing else here and my internal route goes straight to Umami without passing through it. Meaning nothing on my LAN can forge a client address at me.</p>
<h3 id="umami-logs-nothing-at-all-zilch">
  <a class="heading-link" href="#umami-logs-nothing-at-all-zilch">Umami logs nothing at all. Zilch.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Its own logs are four lines of startup and then a blank stare into the void. Which means that until I put a proxy in front of it, the only record of what hit my analytics endpoint was&hellip; my analytics.</p>
<p>Nginx gave me an access log within about ten seconds of starting and the very first thing it told me was that I had no idea what my traffic was.</p>
<h2 id="in-which-i-am-humbled-by-a-robot">
  <a class="heading-link" href="#in-which-i-am-humbled-by-a-robot">In which I am humbled by a robot.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I had been suprised with my initial visitor numbers. Then I looked properly and roughly ~90% of it was a single machine in Singapore hammering one <a href="https://wilhelm.codes/bits/update-putting-the-site-on-a-diet/">tiny post</a>:</p>
<div class="code-block">
  <pre tabindex="0"><code>browser  os      device  screen      country  hits  sessions
chrome   Mac OS  laptop  1366x1366   SG       53    51</code></pre>
</div>
<p>There are a few tells here. Not only is the screen size a perfect square, <code>50</code>ish sessions with just as many hits from the same region with the same browser settings smells like a bot crawl. It was Kagi, near as I can tell, rendering the page to build a preview for <a href="https://kagi.com/smallweb">Kagi Small Web</a>. I have no real objection to that. It reaches my tracker at all because it runs the JavaScript, which an ordinary crawler never would. This is exactly the sort of thing that pollutes a dataset while you sit there feeling popular.</p>
<p>I don&rsquo;t care about the bots. The point is that I only found it because I&rsquo;d added logging. I only added logging because I was chasing a rate limit I probably didn&rsquo;t need.</p>
<h2 id="the-small-detail-i-liked-most">
  <a class="heading-link" href="#the-small-detail-i-liked-most">The small detail I liked most.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Nginx has a default location block and mine looks like this:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Nginx</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">return</span> <span class="mi">444</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p><code>444</code> is an <a href="https://http.dev/444#configuring-444">Nginx special</a>. It doesn&rsquo;t return a status, but closes the connection quietly.</p>
<p>That rule is doing the same job as the catch-all <code>404</code> in my tunnel ingress. It holds exactly the same line only hop further in. Two independent things now have to be wrong at once before anything reaches my admin interface.</p>
<p>So, the tunnel is Cloudflare&rsquo;s. The edge rate limit is Cloudflare&rsquo;s. The Nginx config is a text file that&rsquo;s <em>mine</em> and it doesn&rsquo;t care in the slightest what is in front of it. Which matters, because I&rsquo;m not 100% sold on keeping Cloudflare around longer-term.</p>
<p>None of the idea is Cloudflare-specific either. Any reverse proxy has a path-scoped location block and any tunnel worth the name matches on more than a hostname. <a href="https://tailscale.com/">Tailscale</a>, <a href="https://pangolin.net/">Pangolin</a>, <a href="https://netbird.io/">NetBird</a> or even vanilla <a href="https://www.wireguard.com/">WireGuard</a> will get you there. I picked Tunnels as a convenient stop-gap until I move on to something a bit more &ldquo;self-hosty&rdquo;. The vendor is incidental. Publish only what <em>has</em> to be public.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>None of this is novel. Tunnels have matched on path since forever, it&rsquo;s in the <a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched">docs</a> and I&rsquo;m certain plenty of people are already doing exactly this. I just hadn&rsquo;t thought about it properly, because the well-trodden advice is &ldquo;put an auth layer in front of it&rdquo; and this kind of consensus is usually good enough that you stop thinking.</p>
<p>But &ldquo;protect the admin panel&rdquo; and &ldquo;publish the admin panel and <em>then</em> protect it&rdquo; are not the same sentence and you should be aware of the difference.</p>
<p>If you&rsquo;re running anything through a tunnel right now, it&rsquo;s worth five minutes with your ingress config. Ask yourself which paths actually <em>need</em> to leave your network. For a lot of self-hosted things the honest answer is &ldquo;fewer than all of them&rdquo;. The best answer is &ldquo;none&rdquo;. And if you&rsquo;d rather find out than wonder, that <code>curl --resolve</code> trick works against any hostname, including yours. Start with <code>/login</code> and see what comes back.</p>
<p>And if you&rsquo;re about to tell me the browser permission dialog is a bug, it isn&rsquo;t. It&rsquo;s three correct systems arguing and I&rsquo;ve decided that&rsquo;s a feature.</p>
<h2 id="would-you-like-to-know-more">
  <a class="heading-link" href="#would-you-like-to-know-more">Would you like to know more?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I kept the theory light in the post, because it kind of goes against the vibe of the site and my own casual style of writing. But the ideas underneath this are old and much better argued elsewhere, so here&rsquo;s where I&rsquo;d start.</p>
<ul>
<li><a href="https://web.mit.edu/Saltzer/www/publications/protection/">The Protection of Information in Computer Systems</a>, Saltzer and Schroeder, 1975. The source of <em>fail-safe defaults</em> ( deny by default, allow by exception, which is exactly what that catch-all <code>404</code> rule is ) and <em>economy of mechanism</em> ( fewer moving parts means fewer parts to get wrong ). It&rsquo;s a fifty year old paper and it reads like one, but section I is short and it has aged as disgracefully as I have.</li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Attack_Surface_Analysis_Cheat_Sheet.html">Attack Surface Analysis</a>, OWASP. The plain-English version of the whole post, minus my nonsense. Useful if you want a structured way to ask &ldquo;what am I actually exposing&rdquo; about something larger than a script tag.</li>
<li><a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched">Tunnel configuration file</a>, Cloudflare. The ingress rules reference. This is the page I had skimmed past twice before noticing that <code>path</code> was sitting right there next to <code>hostname</code>.</li>
<li><a href="https://wicg.github.io/local-network-access/">Local Network Access</a>, WICG. The spec behind the permission dialog that made me raise an eyebrow. If you&rsquo;d rather have prose than a spec, Chrome&rsquo;s <a href="https://developer.chrome.com/blog/local-network-access">New permission prompt for Local Network Access</a> covers the same ground and explains why browsers decided this needed fixing.</li>
</ul>
<p>If you only read one, make it Saltzer and Schroeder. Almost everything I thought I worked out on that Saturday turns out to be in there, but described better by people who got to it first and who are <em>far</em> smarter than me.</p>]]></content:encoded></item><item><title>What Good is a Number You Can't Call?</title><link>https://wilhelm.codes/blog/what-good-is-a-number-you-cant-call/</link><pubDate>Thu, 10 Sep 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/what-good-is-a-number-you-cant-call/</guid><category>plantsm.art</category><category>api</category><category>updates</category><category>hugo</category><wc:kind>post</wc:kind><description>This one&amp;rsquo;s relatively short. I feel like I&amp;rsquo;ve been posting about Plant🌱Smart a bit too much lately. But I&amp;rsquo;ve made a small change to how the site displays emergency information and it matters enough to warrant its own post. So, here we go.</description><content:encoded><![CDATA[<p>This one&rsquo;s relatively short. I feel like I&rsquo;ve been posting about <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> a bit too much lately. But I&rsquo;ve made a small change to how the site displays emergency information and it matters enough to warrant its own post. So, here we go.</p>
<h2 id="some-background">
  <a class="heading-link" href="#some-background">Some background.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>For three years every plant page on <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> ended with the same two phone numbers and both of them were for the US. That never really sat right with me. I even flagged it as roadmap work when I <a href="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/">rebuilt the site</a>, then left it sitting there. The site&rsquo;s whole reason for existing is for the moment your cat eats something and you need an answer now. For most of the people who land there from a search engine, I was answering with a number they can&rsquo;t dial. So, I recently sat down to address that gap, assuming I&rsquo;d spend a few minutes filling in a table of hotlines and be done with it.</p>
<h2 id="every-single-country-had-a-speed-bump">
  <a class="heading-link" href="#every-single-country-had-a-speed-bump">Every single country had a speed bump.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<ul>
<li>Sweden&rsquo;s poison centre stopped answering questions about animals in July 2018 because the human call volume got too high.</li>
<li>The Dutch line only takes calls from vets, so if you&rsquo;re a pet owner in Rotterdam, the correct advice is &ldquo;ring your vet and let them ring the line&rdquo;, which is not a phone number at all.</li>
<li>Belgium answers animal calls only during the day and says outright on its own site that it isn&rsquo;t funded for animal work.</li>
<li>The Australian helpline I&rsquo;d have sworn was free actually starts at <code>$75</code>. This is a bit embarrassing because I live here and <em>should</em> know this.</li>
<li>The UK&rsquo;s fee changes depending on the time of day you call.</li>
<li>The ASPCA doesn&rsquo;t publish its consultation fee anywhere on its own page, so the figure everyone quotes online comes from third parties.</li>
</ul>
<p>The list goes on.</p>
<p>I&rsquo;d have got at least half of these wrong from memory, which is a genuinely unsettling thing to realise about emergency information.</p>
<p>Once again, I had to collate a bunch of disparate data and create a shape to support it before munging it all together. Every entry in the hotlines list now cites the operator&rsquo;s page it came from and the date I read it which is the <a href="https://wilhelm.codes/blog/every-record-now-has-a-source/">same rule</a> I put on the plant records in the last post. A Python script holds the whole list and nags me when an entry goes over a year old and it runs as part of the automated deployment. So, the build gives me a big&rsquo;ole bonk on the nose whenever the list goes stale.</p>
<h2 id="the-boring-technical-stuff">
  <a class="heading-link" href="#the-boring-technical-stuff">The boring technical stuff.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The engineering ended up being the easy half and mostly a matter of deciding what fails safely. Hugo writes every country into the HTML as a partial. A small script then lifts the relevant one out and folds the rest away. It guesses in order: a country you&rsquo;ve pinned, then the location filter if it names exactly one country, then your browser locale.</p>
<p>It never asks permission for anything, because a permission prompt at 2am is the last thing you need while you&rsquo;re panicking about your pet. And, personally, I <em>hate</em> browser prompts asking for location tracking. Hard pass. If the script doesn&rsquo;t run, or if the service worker hands you a stale page, you get all countries instead of the wrong one.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Too many numbers costs you a few seconds. The wrong number costs you the call.</p>
  </blockquote>
  <figcaption>
    <cite>It me.</cite>
  </figcaption>
</figure>

<p>There&rsquo;s also a new <a href="https://plantsm.art/emergency/">emergency</a> page for the first five minutes before you get through: what to do, what not to do and what they&rsquo;ll ask you. The guidance follows the ASPCA and the Pet Poison Helpline which is also cited and linked. After all, the one thing I&rsquo;m definitely not qualified to write is clinical advice.</p>
<h2 id="another-free-gift-the-hotlines-api">
  <a class="heading-link" href="#another-free-gift-the-hotlines-api">Another free gift: the hotlines API.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There&rsquo;s really no obvious centralised place online that collates all this information into a single data set. I&rsquo;ve at least done the work of bootstrapping the handful of most common countries that have helplines and the list will definitely grow over time. So, I&rsquo;ve exposed a new &ldquo;hotlines&rdquo; <a href="https://plantsm.art/api/hotlines.json">API endpoint</a> with its own documented shape.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;countries&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// ... more countries ...
</span></span></span><span class="line"><span class="cl">        <span class="nt">&#34;US&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;checked&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-09-10&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;United States&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;services&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">                <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;audience&#34;</span><span class="p">:</span> <span class="s2">&#34;owner&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;display&#34;</span><span class="p">:</span> <span class="s2">&#34;(888) 426-4435&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;fee&#34;</span><span class="p">:</span> <span class="s2">&#34;A consultation fee applies&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;hours&#34;</span><span class="p">:</span> <span class="s2">&#34;24 hours, every day&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;org&#34;</span><span class="p">:</span> <span class="s2">&#34;ASPCA Animal Poison Control Center&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="s2">&#34;https://www.aspca.org/pet-care/animal-poison-control&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;tel&#34;</span><span class="p">:</span> <span class="s2">&#34;+18884264435&#34;</span>
</span></span><span class="line"><span class="cl">                <span class="p">},</span>
</span></span><span class="line"><span class="cl">                <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;audience&#34;</span><span class="p">:</span> <span class="s2">&#34;owner&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;display&#34;</span><span class="p">:</span> <span class="s2">&#34;(855) 764-7661&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;fee&#34;</span><span class="p">:</span> <span class="s2">&#34;$89 for each incident&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;hours&#34;</span><span class="p">:</span> <span class="s2">&#34;24 hours, every day&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;org&#34;</span><span class="p">:</span> <span class="s2">&#34;Pet Poison Helpline&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="s2">&#34;https://www.petpoisonhelpline.com/about/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="nt">&#34;tel&#34;</span><span class="p">:</span> <span class="s2">&#34;+18557647661&#34;</span>
</span></span><span class="line"><span class="cl">                <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;notice&#34;</span><span class="p">:</span> <span class="s2">&#34;Emergency telephone numbers for animal poisoning. Each country cites the operator page the entry came from and the date a person read it. Services close and numbers change, so check an entry before you rely on it. Coverage is not global. A country in this file with an empty services list has been checked and has no service an owner can call. A country that is absent has not been checked.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updated&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-09-10&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>I felt wrapping it in an <a href="https://www.progress.com/blogs/envelope-design-pattern">envelope</a> was the correct approach here. It leads with the notice and the date I last changed anything. A <code>.countries</code> object follows, keyed by ISO 3166-1 alpha-2 code. At the very least, it gives me room to change a country&rsquo;s metadata later without breaking the top-level shape.</p>
<p>The <code>audience</code> field on each service is what carries the Dutch problem into the data. It reads either <code>owner</code> or <code>vet</code>. A <code>vet</code> entry means the number exists but you can&rsquo;t ring it yourself, so the entry tells you to phone your vet and let them make the call.</p>
<p>Anyway, along with some stylistic changes and code cleanup, that&rsquo;s pretty much all there is to it for this update. I&rsquo;ll be giving <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> a rest for a bit while all the changes settle in. This gives me some time to work on other fun things!</p>
<p>Enjoy, nerds!</p>]]></content:encoded></item><item><title>Every Record Now Has a Source</title><link>https://wilhelm.codes/blog/every-record-now-has-a-source/</link><pubDate>Sat, 05 Sep 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/every-record-now-has-a-source/</guid><category>plantsm.art</category><category>api</category><wc:kind>post</wc:kind><description>As an experiment, last week I introduced r/InternetIsBeautiful to Plant🌱Smart. To my surprise, it was overall very well received! Obviously, there were a few inevitable bad-faith comments from your standard-issue dickheads, but along with a lot of positive vibes I also got heaps of great suggestions and feature requests. So, this post covers not only a database scrub, but everything I&amp;rsquo;ve added as a result of the thread.</description><content:encoded><![CDATA[<p>As an experiment, last week I introduced <a href="https://www.reddit.com/r/InternetIsBeautiful/">r/InternetIsBeautiful</a> to <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span>. To my surprise, it was overall very <a href="https://www.reddit.com/r/InternetIsBeautiful/comments/1w5utet/i_got_tired_of_frantically_googling_is_this_plant/">well received</a>! Obviously, there were a few inevitable bad-faith comments from your standard-issue dickheads, but along with a lot of positive vibes I also got heaps of great suggestions and feature requests. So, this post covers not only a database scrub, but everything I&rsquo;ve added as a result of the thread.</p>
<h2 id="the-database-lost-some-weight">
  <a class="heading-link" href="#the-database-lost-some-weight">The database lost some weight.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Originally, the plant database held around <code>480</code> records. It now holds around <code>280</code>, so roughly <code>200</code> records are gone. About <code>170</code> of them came from a single symptom list and all of them included &ldquo;cardiac failure&rdquo;. That one symptom pushed every associated record to the top severity level. They read as &ldquo;critical&rdquo; because of the shared symptom list, not because of the plant itself. The list came from older sourced data that I never properly scrubbed and vetted, so I nixed those records entirely from the primary data set.</p>
<p>Sucks, but a smaller database you can trust beats a bigger one you cannot.</p>
<h3 id="how-i-source-vetted-data">
  <a class="heading-link" href="#how-i-source-vetted-data">How I source vetted data.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>At the moment, I check each record against the following authoritative data sources:</p>
<ul>
<li>The <a href="https://www.aspca.org/">ASPCA</a> toxic plant list. It gives the clinical signs for dogs, cats and horses. This is the original reference that started the project way back in December of 2022.</li>
<li>The <a href="https://poisonousplants.cvmbs.colostate.edu/">Guide to Poisonous Plants</a> from the College of Veterinary Medicine and Biomedical Sciences at Colorado State University. It gives the clinical signs for each body system and it names the animals that the plant affects. It is stronger than the ASPCA list on horses and other farm animals.</li>
</ul>
<p>Every one of the <code>280</code> or so records that remain traces back to at least one of these two references. This is a <em>hard</em> requirement. If a plant can&rsquo;t clear it, it doesn&rsquo;t get a record.</p>
<p>Both references are copyrighted material. I cite them and link to them directly without publishing any of their text. Each record holds my own symptom names only. If you&rsquo;re curious, the <a href="https://plantsm.art/attributions/">attributions page</a> lists every licensed source used to build the site.</p>
<p>If you know of another authoritative data set that covers animals, the plants that are toxic to them and the associated symptoms, please let me know in the comments below or on the <a href="https://plantsm.art/contribute/">contribute page</a>.</p>
<h3 id="what-i-removed-specifically">
  <a class="heading-link" href="#what-i-removed-specifically">What I removed, specifically.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I removed the inaccurate records and enriched and corrected the rest. These changes break down into three groups.</p>
<ol>
<li>The old data set covered seven animal types. The update reduces coverage to cats, dogs and horses. That doesn&rsquo;t mean I&rsquo;ve given up on birds, fish, reptiles and small mammals. If I find good data for any of those groups, they go straight back in. Until then, they&rsquo;re parked.</li>
<li>I also changed the rule that sets the severity level of each record. The old rule just took the worst symptom on the record. Now there&rsquo;s an extra condition: a record with a single &ldquo;critical&rdquo; symptom and no &ldquo;severe&rdquo; symptoms gets marked &ldquo;severe&rdquo; instead. Nothing huge, but I felt it was worth mentioning.</li>
<li>A handful of records turned out to be duplicates, so I merged them.</li>
</ol>
<div class="callout callout-warning">
  A plant that is not in this database is a plant I cannot verify. If I can&rsquo;t verify it, it&rsquo;s not something I would personally call safe. Always consult your veterinarian when in doubt!
</div>

<h2 id="the-plant-index-is-now-a-field-guide">
  <a class="heading-link" href="#the-plant-index-is-now-a-field-guide">The plant index is now a &ldquo;Field Guide&rdquo;.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Reddit user <a href="https://www.reddit.com/user/Abbot_of_Cucany/">u/Abbot_of_Cucany</a> mentioned that their bird guide indexes each species twice: the common name in plain type and the scientific name in italic type. I thought this was a great idea, so I built it. Of course, I <a href="https://plantsm.art/plants/">got a little carried away</a>, so the page now also holds a filter box. Type part of a name and the page shows every matching plant on record. A letter row sticks to the top of the screen while you scroll and it marks the letter you&rsquo;re currently reading through.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: The field guide showing a partial filter match for &#34;pea&#34;.">
      <img
  src="https://wilhelm.codes/blog/every-record-now-has-a-source/field-guide_hu_565905eb6e9ebe77.webp"
  srcset="/blog/every-record-now-has-a-source/field-guide_hu_565905eb6e9ebe77.webp 736w, /blog/every-record-now-has-a-source/field-guide_hu_22f9451b693f70db.webp 1104w, /blog/every-record-now-has-a-source/field-guide_hu_e79f76b4507b8f5a.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="455"
  alt="The field guide showing a partial filter match for &#34;pea&#34;."
  loading="lazy"
  decoding="async"
  data-full="/blog/every-record-now-has-a-source/field-guide_hu_95762cf08491285a.webp"
  data-credit="The field guide, filtered on &#34;pea&#34;."
  
/>

    </button>
    
      <figcaption>
        
          The field guide, filtered on &#34;pea&#34;.
        
      </figcaption>
    
  </figure>
<h2 id="the-front-page-got-some-filter-updates">
  <a class="heading-link" href="#the-front-page-got-some-filter-updates">The front page got some filter updates!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Some people requested more filters, so I delivered. The additions cover severity, plant shape and the geographical regions where you might come across a plant in the wild. Those last two data points were already sourced, but not really in use. At least not in any obvious way. I also added a newer, tighter, photo-centric grid view for smaller screens. Personally, I think it looks good, but I&rsquo;m not sure if I&rsquo;m going to keep it. Let me know what you think!</p>
<p>The extra dropdowns squished everything together in the filter bar, so I had to rethink how to display them. I replaced all the individual dropdowns with a single multi-level filter menu. You can now click around and filter as you go. I also shrank the icons a bit to save more space, but they expand when you hover over them with a mouse or tap them on a small screen.</p>
<p>Also, worth noting: your filter selections stay in the browser between visits.</p>
<div class="masonry">
  
  <button type="button" class="zoom" aria-label="Zoom image: Expanding the animal filter on hover.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-one_hu_59139fcdbc92cbdb.webp"
    srcset="/blog/every-record-now-has-a-source/filter-one_hu_59139fcdbc92cbdb.webp 232w, /blog/every-record-now-has-a-source/filter-one_hu_7ee35f28aaa54166.webp 464w, /blog/every-record-now-has-a-source/filter-one_hu_a6061e0eb2569431.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="57"
    alt="Expanding the animal filter on hover."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-one_hu_727147be8822309.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The top-level filter dropdown.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-two_hu_3e391d4292ae8b58.webp"
    srcset="/blog/every-record-now-has-a-source/filter-two_hu_3e391d4292ae8b58.webp 232w, /blog/every-record-now-has-a-source/filter-two_hu_9f6e4f737a9412f3.webp 464w, /blog/every-record-now-has-a-source/filter-two_hu_10b6a6629641ca96.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="241"
    alt="The top-level filter dropdown."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-two_hu_c0e71fd6803796db.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The new shape filter.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-three_hu_996831283bb9b3cb.webp"
    srcset="/blog/every-record-now-has-a-source/filter-three_hu_996831283bb9b3cb.webp 232w, /blog/every-record-now-has-a-source/filter-three_hu_90a3df2b9559054e.webp 464w, /blog/every-record-now-has-a-source/filter-three_hu_a22fb765af80dc6.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="237"
    alt="The new shape filter."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-three_hu_72ee94969247d390.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: Default filter bar view.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-four_hu_577e7bef322884da.webp"
    srcset="/blog/every-record-now-has-a-source/filter-four_hu_577e7bef322884da.webp 232w, /blog/every-record-now-has-a-source/filter-four_hu_90a8fa52c83043ac.webp 464w, /blog/every-record-now-has-a-source/filter-four_hu_7271fc9ef5ce4738.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="70"
    alt="Default filter bar view."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-four_hu_55074a478d89b991.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The new severity filter.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/filter-five_hu_f60bb397f0112d7b.webp"
    srcset="/blog/every-record-now-has-a-source/filter-five_hu_f60bb397f0112d7b.webp 232w, /blog/every-record-now-has-a-source/filter-five_hu_53bb61d19f9ba214.webp 464w, /blog/every-record-now-has-a-source/filter-five_hu_11ae826fade33dfb.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="181"
    alt="The new severity filter."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/filter-five_hu_b4cb749954053bad.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The new card view option.">
      <img
    src="https://wilhelm.codes/blog/every-record-now-has-a-source/card-view_hu_4695a5952fd7838e.webp"
    srcset="/blog/every-record-now-has-a-source/card-view_hu_4695a5952fd7838e.webp 232w, /blog/every-record-now-has-a-source/card-view_hu_69eb238bc3ca1fa7.webp 464w, /blog/every-record-now-has-a-source/card-view_hu_4d215804aba7fd2a.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="158"
    alt="The new card view option."
    loading="lazy"
    decoding="async"
    data-full="/blog/every-record-now-has-a-source/card-view_hu_ba40ad5ce671bb.webp"
    
    
  />
  
    </button>

</div>

<p>The data for the &ldquo;Found in&rdquo; filter comes from <a href="https://www.gbif.org/">GBIF</a>, the Global Biodiversity Information Facility. GBIF counts the wild sightings of each plant in each country. A country counts as a match when it holds one percent or more of the sightings for that plant. It&rsquo;s a totally arbitrary, non-scientific threshold that I imposed to keep the country list as tight as possible.</p>
<h2 id="safe-plants-get-a-facelift">
  <a class="heading-link" href="#safe-plants-get-a-facelift">Safe plants get a facelift!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The <a href="https://plantsm.art/safe/">safe plants list</a> now holds over <code>30</code> common household plants. The new entries include the spider plant, the bamboo palm, sage and baby&rsquo;s breath, among others. A new entry joins this list only when the ASPCA marks the plant as non-toxic and only when the plant is absent from my own database. Each entry also carries a note that names the toxic plant people confuse it with. For instance, bamboo palm is safe while sago palm is deadly.</p>
<p>Based on a couple of user suggestions, each safe plant also includes a small gallery of three related photos sourced directly from the naturalist community at <a href="https://www.inaturalist.org/">iNaturalist</a>. Clicking a photo opens a lightbox with a zoomed-in view along with attribution and licensing information.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A preview of some safe plant entries.">
      <img
  src="https://wilhelm.codes/blog/every-record-now-has-a-source/safe-plants_hu_518e75c0605083eb.webp"
  srcset="/blog/every-record-now-has-a-source/safe-plants_hu_518e75c0605083eb.webp 736w, /blog/every-record-now-has-a-source/safe-plants_hu_15c13933de798fc2.webp 1104w, /blog/every-record-now-has-a-source/safe-plants_hu_a53afb27ae8153fe.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="196"
  alt="A preview of some safe plant entries."
  loading="lazy"
  decoding="async"
  data-full="/blog/every-record-now-has-a-source/safe-plants_hu_9063c7d8087527b8.webp"
  data-credit="A few entries from the new safe plants list."
  
/>

    </button>
    
      <figcaption>
        
          A few entries from the new safe plants list.
        
      </figcaption>
    
  </figure>
<h2 id="it-now-has-a-blog-sort-of">
  <a class="heading-link" href="#it-now-has-a-blog-sort-of">It now has a blog&hellip; sort of.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This part wasn&rsquo;t a suggestion. In addition to the <a href="https://plantsm.art/notes/">updates</a> page, I thought some kind of small blog would make sense. The articles I write about the site live <em>here</em>, where visitors to <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> will most likely never read them. So, instead of building actual blogging functionality, I decided to syndicate the articles from here. I updated my Hugo templates to generate an RSS feed for each tag. Every article about <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> carries the <code>plantsm.art</code> tag, so the site can ingest all the relevant content on its own.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A syndicated feed that&#39;s in desperate need of more content.">
      <img
  src="https://wilhelm.codes/blog/every-record-now-has-a-source/field-notes_hu_7010e8441282d499.webp"
  srcset="/blog/every-record-now-has-a-source/field-notes_hu_7010e8441282d499.webp 736w, /blog/every-record-now-has-a-source/field-notes_hu_69ea7ea798302772.webp 1104w, /blog/every-record-now-has-a-source/field-notes_hu_90054e81197cd882.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="522"
  alt="A syndicated feed that&#39;s in desperate need of more content."
  loading="lazy"
  decoding="async"
  data-full="/blog/every-record-now-has-a-source/field-notes_hu_9daffcd17af182c0.webp"
  data-credit="A syndicated feed that&#39;s in desperate need of more content."
  
/>

    </button>
    
      <figcaption>
        
          A syndicated feed that&#39;s in desperate need of more content.
        
      </figcaption>
    
  </figure>
<p>The page shows one featured note first, just the latest entry in the list. Five recent notes follow as cards. Finally, an archive section gives one row per note, grouped by year. There&rsquo;s just not enough content at the moment to fill that section out, so it stays hidden for now. Also, something you can&rsquo;t see just yet: a text filter appears after <code>15</code> or so entries and works just like the one in the field guide.</p>
<h2 id="finally-some-changes-to-the-api">
  <a class="heading-link" href="#finally-some-changes-to-the-api">Finally, some changes to the API.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The API gives one file for each animal in the database. Since the data scrub, four of those files no longer exist: <code>birds.json</code>, <code>small-mammals.json</code>, <code>reptiles.json</code> and <code>fish.json</code>. The database itself no longer holds plant records for those animals. The <a href="https://plantsm.art/api/">API page</a> documents the files that remain. Every other endpoint and every other shape stays the same.</p>
<h2 id="whats-next">
  <a class="heading-link" href="#whats-next">What&rsquo;s next?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I want the other four animal types back. A rabbit owner needs this data as much as a dog owner does. I&rsquo;m on the lookout for a reference that gives the symptoms per plant for birds, fish, reptiles and small mammals, but there&rsquo;s just not a lot of authoritative data out there. If you know of one, tell me in the comments below or on the <a href="https://plantsm.art/contribute/">contribute page</a>.</p>
<p>I also want those <code>170</code> records back, but each one needs a reference that names the plant and gives the clinical signs. Until then, they stay on the back burner.</p>
<p>That pretty much covers it. It&rsquo;s been a fairly hectic week of coding and rolling out updates, but it&rsquo;s been a lot of fun. At the time of writing, the <a href="https://www.reddit.com/r/InternetIsBeautiful/comments/1w5utet/i_got_tired_of_frantically_googling_is_this_plant/">Reddit post</a> sits at over <code>530</code> upvotes! It&rsquo;s always risky sharing something you&rsquo;ve made with the world, but more often than not, people appreciate it. It really put a smile on my face.</p>
<p>Till next time!</p>]]></content:encoded></item><item><title>Why I Rebuilt Plant Smart</title><link>https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/</guid><category>hugo</category><category>plantsm.art</category><category>woodpecker</category><category>performance</category><wc:kind>post</wc:kind><description>Back in June I wrote a little blurb that said, in its entirety, &amp;ldquo;I should update Plant🌱Smart.&amp;rdquo; Bolder and more visionary words have never been written. So bold, in fact, I went right back to demolishing this blog instead and let the poor thing sit for another two months.
Well, I finally did the thing.</description><content:encoded><![CDATA[<p>Back in June I wrote <a href="https://wilhelm.codes/bits/blurb-i-should-update-plantsmart/">a little blurb</a> that said, in its entirety, &ldquo;I should update <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span>.&rdquo; Bolder and more visionary words have never been written. So bold, in fact, I went right back to <a href="https://wilhelm.codes/blog/some-long-overdue-demolition/">demolishing this blog</a> instead and let the poor thing sit for another two months.</p>
<p>Well, I finally did the thing.</p>
<p>Three and a half years ago I wrote <a href="https://wilhelm.codes/blog/why-i-built-plant-smart/">Why I Built <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span></a>. A free and searchable database of plants that are toxic to pets. I built it over a holiday break to answer a simple question, &ldquo;Can I bring this plant home without endangering the fluffy little gremlin that runs our household?&rdquo; It was also my excuse to learn SvelteKit, TypeScript, TailwindCSS and Vite, because my frontend peers wouldn&rsquo;t shut the hell up about them.</p>
<p>The site then chugged along quietly and untouched for years, which sounds like a compliment to its architecture and very much is not one to the maintainer.</p>
<h2 id="the-toolchain-aged-like-fine-milk">
  <a class="heading-link" href="#the-toolchain-aged-like-fine-milk">The toolchain aged like fine milk.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The thing no one really thinks about when they build something like this with shiny new frontend tools is that it doesn&rsquo;t rust, it <em>curdles</em>. When I finally came back to give <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> a fresh coat of paint, the build no longer ran. Not &ldquo;a few deprecation warnings&rdquo; didn&rsquo;t run. It didn&rsquo;t run at all. Two years of an unattended <code>package.json</code> had fermented into something of a cheese-like consistency.</p>
<p>My original requirements list from 2022 said the site must be fast, static, ( almost ) free to run and open with its data. Nowhere did it say &ldquo;must still compile in two years&rdquo;.</p>
<p>So, I decided to spend a few weeks off and on to throw out the toolchain and give it a much-needed refresh. Just like this site, Hugo now builds the entire thing. There&rsquo;s a single hand-written stylesheet and a few small scripts of delicious vanilla JavaScript. No <code>npm install</code>, because there is no longer anything to install. If I disappear for another two years ( likely ), the site will build exactly the same the day I come back.</p>
<h2 id="first-things-first-absolute-parity">
  <a class="heading-link" href="#first-things-first-absolute-parity">First things first, absolute parity.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The rebuild had one rule that saved me from myself: the first pass had to be a 1:1 replica of the old site. It needed the same pages, filters, URLs and quirks. The pass was meant to be boring on purpose. I had to resist the temptation of doing both the replatforming and redesigning all in one go.</p>
<p>My primary constraint was the plant data. <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span>&rsquo;s whole gimmick is what I called a &ldquo;dumb API&rdquo;. The entire primary database lives in <a href="https://plantsm.art/api/plants.json">one JSON file</a> with other, smaller, supplemental files you could use as lookup tables. Since this was all hosted on Cloudflare&rsquo;s CDN it was effectively free to use for others without keys or annoying rate limits. The old derivations were originally done by a pile of Go magefiles. The new site had to reproduce them exactly, down to the deliberately unsorted first-appearance ordering, or every downstream consumer would notice.</p>
<p>Hugo, it turns out, can do all of it. A content adapter reads the database and births every plant page directly with no intermediate files. A handful of templates re-implement the old Go derivation logic and regenerate the API on every build. I diffed the generated files against the originals and they came back value-identical on the first honest attempt.</p>
<p>Then came my favourite commit of the whole project:</p>
<div class="code-block">
  <pre tabindex="0"><code>retire the sveltekit stack: remove src, magefiles and the node toolchain
566 files changed, 112 insertions(+), 9905 deletions(-)</code></pre>
</div>
<p>I deleted nearly ten thousand lines and the site did more than before. This is every developer&rsquo;s <em>favourite</em> kind of commit.</p>
<h2 id="i-made-the-site-cute-on-purpose">
  <a class="heading-link" href="#i-made-the-site-cute-on-purpose">I made the site cute on purpose.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>With the replica shipped, the redesign could actually be fun. The old site was fine but it was very much &ldquo;2022 Tailwind starter&rdquo; vibe. A lot of cool greys, hard shadows and gradients on anything that stood still. Meanwhile the logo, a randomised squishy little pastel potted plant with a face, deserved better surroundings.</p>
<p>So, I set out to make the logo the design brief. Cream paper instead of white with warm espresso ink instead of a dull grey. Sage and moss greens with honey and clay accents were all lifted straight from the icon set. Every shadow is tinted warm because plain black shadows on cream look like stickers. The corners got a bit rounder and the controls became pills. The whole thing is set in <a href="https://fonts.google.com/specimen/Fredoka">Fredoka</a>, a rounded display face that weighs about <code>30kB</code> which I thought fit perfectly with the cute little mascot.</p>
<p>Here are a few before and after shots of the main content pages, with a bonus shot of the new pill navigation that smaller screens finally got:</p>
<div class="strip">
  
  <button type="button" class="zoom" aria-label="Zoom image: The homepage before the redesign.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/home-before_hu_c134c356ab255f31.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/home-before_hu_c134c356ab255f31.webp 1x, /blog/why-i-rebuilt-plant-smart/home-before_hu_fd4c4f2261904a3.webp 2x"
    
    width="337"
    height="224"
    alt="The homepage before the redesign."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/home-before_hu_bd376468f433ee37.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The homepage as it currently stands.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/home-after_hu_7bf504f00e2d4b7b.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/home-after_hu_7bf504f00e2d4b7b.webp 1x, /blog/why-i-rebuilt-plant-smart/home-after_hu_18e93a68df4a3045.webp 2x"
    
    width="326"
    height="224"
    alt="The homepage as it currently stands."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/home-after_hu_e3c3f0257d1fdaa7.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The plant details page before the redesign.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/details-before_hu_778178bb15ddf6e2.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/details-before_hu_778178bb15ddf6e2.webp 1x, /blog/why-i-rebuilt-plant-smart/details-before_hu_47cac0840311163a.webp 2x"
    
    width="235"
    height="224"
    alt="The plant details page before the redesign."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/details-before_hu_a7d36e9ec06698d9.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The plant details page as it currently stands.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/details-after_hu_be1325861b1ce8de.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/details-after_hu_be1325861b1ce8de.webp 1x, /blog/why-i-rebuilt-plant-smart/details-after_hu_5bc6a433aa239b54.webp 2x"
    
    width="140"
    height="224"
    alt="The plant details page as it currently stands."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/details-after_hu_672f659284e5d144.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The new pill navigation for mobile users.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/mobile-pill-navigation_hu_292dde7b92e8dcc8.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/mobile-pill-navigation_hu_292dde7b92e8dcc8.webp 1x, /blog/why-i-rebuilt-plant-smart/mobile-pill-navigation_hu_18b41e7ae3eacdf6.webp 2x"
    
    width="102"
    height="224"
    alt="The new pill navigation for mobile users."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/mobile-pill-navigation_hu_9d3bd352027c355a.webp"
    
    
  />
  
    </button>

</div>

<h2 id="the-data-enrichments-will-continue-until-morella-improves">
  <a class="heading-link" href="#the-data-enrichments-will-continue-until-morella-improves">The data enrichments will continue until &ldquo;Morella&rdquo; improves.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Get it? No? Ok, I&rsquo;ll see myself out.</p>
<p>The database had severity ratings for every plant, computed from symptom groupings and the old site displayed them precisely nowhere. Now, every card has a severity pill rated from Mild to Critical. The pills are also clickable filters on the main page&rsquo;s plant cards. Search happens as you type and if you fumble the Latin a bit, a little <a href="https://en.wikipedia.org/wiki/Levenshtein_distance">Levenshtein</a> pass offers &ldquo;Did you mean <em>Lilium</em>?&rdquo;. I thought this was a little bit more helpful than what the old site did after literally joking about spelling mistakes in its empty state.</p>
<p>A few other mentionable updates:</p>
<ul>
<li>A <a href="https://plantsm.art/safe/">safe plants list</a>, because the site only ever told you what <em>not</em> to buy. Every entry has to clear the ASPCA non-toxic list and be absent from my own toxic database before it&rsquo;s allowed in.</li>
<li>A <a href="https://plantsm.art/symptoms/">symptom finder</a> that allows you to perform a plant search backwards. You can click on multiple symptoms and it&rsquo;ll filter accordingly.</li>
<li>An emergency strip on every plant page with the poison-control hotlines. Unfortunately, for now, these are US numbers. Localising them by region is on the roadmap.</li>
<li>Wikipedia descriptions for nearly every plant credited under CC BY-SA.</li>
<li>Around 3,000 common-name redirects, so <code>/plant/peace-lily/</code> finally lands somewhere useful.</li>
<li>Because it made sense, the whole thing is now a PWA and works completely offline. So, now you can use the site in a greenhouse with no signal, which is precisely where this site is most likely to get used.</li>
</ul>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>My friends can no longer accuse me of trying to teach people how to poison their neighbour's pets.</p>
  </blockquote>
  <figcaption>
    <cite>J'Accuse...!</cite>
  </figcaption>
</figure>

<p>Once I started auditing the data properly, I started to see a few additional gaps. There were several plants with multiple entries under different, but similar, names. I deduped and merged the symptom data and also updated the severity groupings to make more sense.</p>
<p>I also quickly realised that merging <code>seizure</code> into <code>seizures</code> would have silently promoted over a hundred plants to &ldquo;Critical&rdquo;. The severity groups treated those two spellings as <em>different levels</em>. The little cleaner script I threw together caught it during a dry run. The script grew a rule about never merging across severity levels as a result. The plants that genuinely deserved promotion got one. For instance <a href="https://plantsm.art/plant/ageratina-altissima/">white snakeroot</a>, the plant that killed Lincoln&rsquo;s mother, was somehow rated &ldquo;Severe&rdquo;. The script properly re-rated it to &ldquo;Critical&rdquo;.</p>
<p>The enrichment didn&rsquo;t stop there. I sourced proper taxonomy for every record via GBIF, per-plant toxicity principles with a glossary and little world maps showing their native origins.</p>
<h2 id="there-was-also-quite-a-bit-of-fat-to-trim">
  <a class="heading-link" href="#there-was-also-quite-a-bit-of-fat-to-trim">There was also quite a bit of fat to trim.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Somewhere in all this I noticed every page weighed over <code>200kB</code>. This included pages that were basically just copy. The culprits were painfully self-inflicted. There was an <code>85kB</code> inline script shipping all ten animated logo SVGs to every single page. The same animal icon inlined seventy times on the front page. Oh, and let&rsquo;s not forget my personal favourite, the attributions page carrying <code>149kB</code> of pure <em>indentation</em> that a template loop had quietly emitted. I was shipping whitespace at scale.</p>
<p>The fixes were old-school and very satisfying. A single fingerprinted and cached JS bundle, SVG sprites so each drawing only ships once and one well-placed whitespace trim yielded the following results:</p>
<table>
	<thead>
			<tr>
					<th>page</th>
					<th>before</th>
					<th>after</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>home</td>
					<td>247 KB</td>
					<td>56 KB</td>
			</tr>
			<tr>
					<td>attributions</td>
					<td>238 KB</td>
					<td>24 KB</td>
			</tr>
			<tr>
					<td>safe plants</td>
					<td>270 KB</td>
					<td>33 KB</td>
			</tr>
			<tr>
					<td>plant pages</td>
					<td>114 KB</td>
					<td>36 KB</td>
			</tr>
	</tbody>
</table>
<p>No framework was harmed, or indeed present, in the making of this facelift.</p>
<h2 id="we-ship-from-the-homelab-now">
  <a class="heading-link" href="#we-ship-from-the-homelab-now">We ship from the homelab now.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The last box to tick from the original post was the bittersweet one. In 2022 I wanted the project &ldquo;open-source and community-driven&rdquo; on GitHub. In practice, the community contribution was me, at midnight, fixing my own typos. So, the repository followed this blog into my homelab using the exact same moving parts as outlined in <a href="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/">My Blog Now Ships From My Homelab!</a>. It just made sense after this site&rsquo;s migration. The <em>data</em> remains as open as ever through the API, which was always the part worth opening. That will never change.</p>
<p>While migrating I had a small panic about the 315MB of plant photos sitting in my working tree, imagining every CI build hauling them across the network. Turns out my 2023 self had already gitignored them and the entire repository clones at 7.5MB. Every once in a blue moon past me does something right. I&rsquo;ve decided to let sleeping dogs lie and not investigate what else he buried in there.</p>
<h2 id="was-the-juice-worth-the-squeeze">
  <a class="heading-link" href="#was-the-juice-worth-the-squeeze">Was the juice worth the squeeze?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Very much so. The site is faster, cuter and finally says the most important thing it knows on every card: <em>how</em> dangerous is this plant, not just <em>whether</em>. It builds from nothing with a single binary and deploys itself when I push. The data is cleaner and more robust than it has ever been and enriched well past what 2022 me had the patience for.</p>
<p>But the honest win is the same one as the blog demolition. The whole thing is now boring to operate and boring is what lets a hobby project survive its maintainer&rsquo;s attention span.</p>
<p>If you have a plant-curious pet, <a href="https://plantsm.art"><span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span></a> is free, the <a href="https://plantsm.art/api/">API</a> is free and the <a href="https://plantsm.art/safe/">safe list</a> might even let you buy something nice for yourself. And if four years from now there&rsquo;s a third post in this series, may it be shorter than this one.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>The data enrichments will continue until "Morella" improves.</p>
  </blockquote>
  <figcaption>
    <cite>C'mon, this was a GREAT pun!</cite>
  </figcaption>
</figure>

<p>Anyways, adios&hellip;</p>]]></content:encoded></item><item><title>My Blog Now Ships From My Homelab!</title><link>https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/</link><pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/</guid><category>hugo</category><category>woodpecker</category><category>forgejo</category><category>cloudflare</category><category>homelab</category><wc:kind>post</wc:kind><description>If you&amp;rsquo;ve given GitHub&amp;rsquo;s status page a quick glance recently, you&amp;rsquo;ll immediately see that it&amp;rsquo;s been going through it. Every outage is a small reminder that maybe we shouldn&amp;rsquo;t rely too much on single points of failure we can&amp;rsquo;t directly control.
So, having been bitten several times by intermittent outages and service disruptions, I figured a small personal win would be to migrate the repository for this site to the homelab. Now, it lives on my own Forgejo forge and the builds run on my own Woodpecker cluster. The only things that leave the network are static build artefacts that get pushed up to Cloudflare Pages.
The switch was surprisingly straightforward and took less than 30 minutes overall.</description><content:encoded><![CDATA[<p>If you&rsquo;ve given GitHub&rsquo;s status page a quick glance recently, you&rsquo;ll immediately see that it&rsquo;s been <em>going through it</em>. Every outage is a small reminder that maybe we shouldn&rsquo;t rely too much on single points of failure we can&rsquo;t directly control.</p>
<p>So, having been bitten several times by intermittent outages and service disruptions, I figured a small personal win would be to migrate the repository for <em>this</em> site to the homelab. Now, it lives on my own <a href="https://forgejo.org">Forgejo</a> forge and the builds run on my own <a href="https://woodpecker-ci.org">Woodpecker</a> cluster. The only things that leave the network are static build artefacts that get pushed up to Cloudflare Pages.</p>
<p>The switch was surprisingly straightforward and took less than 30 minutes overall.</p>
<p>Before anyone reaches for the comment box, no, you don&rsquo;t need a homelab for any of this. Forgejo and a Woodpecker cluster ( server and at least one agent ) will happily run on a spare mini PC, an old laptop, that Raspberry Pi you bought with the best of intentions or even as a single Docker Compose file running on the machine you use every day.</p>
<h2 id="how-complex-can-this-possibly-be">
  <a class="heading-link" href="#how-complex-can-this-possibly-be">How complex can this possibly be?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The first useful thing I did was ask myself what GitHub was <em>actually</em> doing for me when it comes to managing this site. After some mulling about, I could only really list the following:</p>
<ul>
<li>Hosting a git repository.</li>
<li>Hosting the comment threads, via <a href="https://giscus.app">giscus</a> Discussions.</li>
</ul>
<p>That&rsquo;s pretty much it. I don&rsquo;t use Actions and GitHub alone never built this site. <a href="https://pages.cloudflare.com">Cloudflare Pages</a> watched the <code>main</code> branch and did the building itself. This is something I covered back in <a href="https://wilhelm.codes/blog/my-blog-publishing-setup/">My Blog Publishing Setup</a>. GitHub&rsquo;s one job was holding a bunch of markdown files.</p>
<p>That&rsquo;s a strange thing to accept as a single point of failure.</p>
<h2 id="the-new-shape-of-things">
  <a class="heading-link" href="#the-new-shape-of-things">The new shape of things.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The replacement looks like this:</p>
<ul>
<li>I push to Forgejo, which lives inside my homelab network.</li>
<li>Forgejo fires off a message to the Woodpecker Server over a webhook. Also inside the network.</li>
<li>The Woodpecker Server assigns the job to an available Woodpecker Agent, which clones the repo, builds the site with Hugo and pushes the result to Cloudflare Pages with <a href="https://developers.cloudflare.com/workers/wrangler/">wrangler</a>.</li>
<li>Cloudflare serves the files at the edge, exactly as before.</li>
</ul>
<p>I just added support for mermaid diagrams, so of course I&rsquo;m not going to let the opportunity to show it off slide:</p>
<figure class="diagram"><svg id="d-702390838d2d" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="max-width: 865px; background-color: transparent;" viewBox="-50 -10 865 421" role="graphics-document document" aria-roledescription="sequence"><g><rect x="615" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="C" rx="3" ry="3" class="actor actor-bottom"/><text x="690" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="690" dy="0">Pages</tspan></text></g><g><rect x="400" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-bottom"/><text x="475" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="475" dy="0">Woodpecker</tspan></text></g><g><rect x="200" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="F" rx="3" ry="3" class="actor actor-bottom"/><text x="275" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="275" dy="0">Forgejo</tspan></text></g><g><rect x="0" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-bottom"/><text x="75" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">The desk</tspan></text></g><g><line id="actor3" x1="690" y1="65" x2="690" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="C" data-et="life-line" data-id="C"/><g id="root-3" data-et="participant" data-type="participant" data-id="C"><rect x="615" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="C" rx="3" ry="3" class="actor actor-top"/><text x="690" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="690" dy="0">Pages</tspan></text></g></g><g><line id="actor2" x1="475" y1="65" x2="475" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="W" data-et="life-line" data-id="W"/><g id="root-2" data-et="participant" data-type="participant" data-id="W"><rect x="400" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-top"/><text x="475" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="475" dy="0">Woodpecker</tspan></text></g></g><g><line id="actor1" x1="275" y1="65" x2="275" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="F" data-et="life-line" data-id="F"/><g id="root-1" data-et="participant" data-type="participant" data-id="F"><rect x="200" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="F" rx="3" ry="3" class="actor actor-top"/><text x="275" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="275" dy="0">Forgejo</tspan></text></g></g><g><line id="actor0" x1="75" y1="65" x2="75" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="D" data-et="life-line" data-id="D"/><g id="root-0" data-et="participant" data-type="participant" data-id="D"><rect x="0" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-top"/><text x="75" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">The desk</tspan></text></g></g><style>#d-702390838d2d{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-702390838d2d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-702390838d2d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-702390838d2d .error-icon{fill:#202225;}#d-702390838d2d .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-702390838d2d .edge-thickness-normal{stroke-width:1px;}#d-702390838d2d .edge-thickness-thick{stroke-width:3.5px;}#d-702390838d2d .edge-pattern-solid{stroke-dasharray:0;}#d-702390838d2d .edge-thickness-invisible{stroke-width:0;fill:none;}#d-702390838d2d .edge-pattern-dashed{stroke-dasharray:3;}#d-702390838d2d .edge-pattern-dotted{stroke-dasharray:2;}#d-702390838d2d .marker{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .marker.cross{stroke:#8b8b84;}#d-702390838d2d svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-702390838d2d p{margin:0;}#d-702390838d2d .actor{stroke:#3c3f43;fill:#1e1f22;stroke-width:1;}#d-702390838d2d rect.actor.outer-path[data-look="neo"]{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d rect.note[data-look="neo"]{stroke:hsl(52.6829268293, 60%, 93.9215686275%);fill:#fff5ad;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d text.actor&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .actor-line{stroke:#8b8b84;}#d-702390838d2d .innerArc{stroke-width:1.5;stroke-dasharray:none;}#d-702390838d2d .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#8b8b84;}#d-702390838d2d .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#8b8b84;}#d-702390838d2d [id$="-arrowhead"] path{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .sequenceNumber{fill:#74747b;}#d-702390838d2d [id$="-sequencenumber"]{fill:#8b8b84;}#d-702390838d2d [id$="-crosshead"] path{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .messageText{fill:#32d74b;stroke:none;}#d-702390838d2d .labelBox{stroke:#3c3f43;fill:#1e1f22;filter:none;}#d-702390838d2d .labelText,#d-702390838d2d .labelText&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .loopText,#d-702390838d2d .loopText&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .sectionTitle,#d-702390838d2d .sectionTitle&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#3c3f43;fill:#3c3f43;}#d-702390838d2d .note{stroke:hsl(52.6829268293, 60%, 93.9215686275%);fill:#fff5ad;}#d-702390838d2d .noteText,#d-702390838d2d .noteText&gt;tspan{fill:#333;stroke:none;font-weight:normal;}#d-702390838d2d .activation0{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .activation1{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .activation2{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .actorPopupMenu{position:absolute;}#d-702390838d2d .actorPopupMenuPanel{position:absolute;fill:#1e1f22;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#d-702390838d2d .actor-man circle,#d-702390838d2d line{fill:#1e1f22;stroke-width:2px;}#d-702390838d2d g rect.rect{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));stroke:#3c3f43;}#d-702390838d2d .node .neo-node{stroke:#3c3f43;}#d-702390838d2d [data-look="neo"].node rect,#d-702390838d2d [data-look="neo"].cluster rect,#d-702390838d2d [data-look="neo"].node polygon{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].swimlane.cluster rect{filter:none;}#d-702390838d2d [data-look="neo"].node path{stroke:url(#d-702390838d2d-gradient);stroke-width:1px;}#d-702390838d2d [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-702390838d2d [data-look="neo"].node circle{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].node circle .state-start{fill:#000000;}#d-702390838d2d [data-look="neo"].icon-shape .icon{fill:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g/><defs><symbol id="d-702390838d2d-computer" width="24" height="24"><path transform="scale(.5)" d="M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z"/></symbol></defs><defs><symbol id="d-702390838d2d-database" fill-rule="evenodd" clip-rule="evenodd"><path transform="scale(.5)" d="M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z"/></symbol></defs><defs><symbol id="d-702390838d2d-clock" width="24" height="24"><path transform="scale(.5)" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z"/></symbol></defs><defs><marker id="d-702390838d2d-arrowhead" refX="7.9" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M -1 0 L 10 5 L 0 10 z"/></marker></defs><defs><marker id="d-702390838d2d-crosshead" markerWidth="15" markerHeight="8" orient="auto" refX="4" refY="4.5"><path fill="none" stroke="#000000" stroke-width="1pt" d="M 1,2 L 6,7 M 6,2 L 1,7" style="stroke-dasharray: 0, 0;"/></marker></defs><defs><marker id="d-702390838d2d-filled-head" refX="15.5" refY="7" markerWidth="20" markerHeight="28" orient="auto"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker id="d-702390838d2d-sequencenumber" refX="15" refY="15" markerWidth="60" markerHeight="40" orient="auto"><circle cx="15" cy="15" r="6"/></marker></defs><defs><marker id="d-702390838d2d-solidTopArrowHead" refX="7.9" refY="7.25" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 8 L 0 8 z"/></marker></defs><defs><marker id="d-702390838d2d-solidBottomArrowHead" refX="7.9" refY="0.75" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 0 L 0 8 z"/></marker></defs><defs><marker id="d-702390838d2d-stickTopArrowHead" refX="7.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 7 7" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><defs><marker id="d-702390838d2d-stickBottomArrowHead" refX="7.5" refY="0" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 7 L 7 0" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><text x="174" y="80" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">git push ( main )</text><line x1="76" y1="109" x2="271" y2="109" class="messageLine0" data-et="message" data-id="i0" data-from="D" data-to="F" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="374" y="124" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">webhook</text><line x1="276" y1="153" x2="471" y2="153" class="messageLine0" data-et="message" data-id="i1" data-from="F" data-to="W" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="476" y="168" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">diagrams, changelog, hugo --gc</text><path d="M 476,197 C 536,187 536,227 476,217" class="messageLine0" data-et="message" data-id="i2" data-from="W" data-to="W" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="581" y="242" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">wrangler pages deploy</text><line x1="476" y1="271" x2="686" y2="271" class="messageLine0" data-et="message" data-id="i3" data-from="W" data-to="C" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="384" y="286" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">the same site, but newer</text><line x1="689" y1="315" x2="79" y2="315" class="messageLine1" data-et="message" data-id="i4" data-from="C" data-to="D" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"/></svg></figure><p>The part I want you to notice is the direction of travel. Under the old setup, Cloudflare reached <em>into</em> GitHub to pull my source. Under the new one, nothing reaches in at all. The forge and the CI server accept no traffic from the internet. The only packet that crosses the network boundary is an <em>outbound</em> upload of compiled static assets.</p>
<p>That reversal is the entire security model which was pretty much the point. What looks like &ldquo;the old way but more steps&rdquo; allows me to control every aspect of the pipeline up to <em>just</em> before we ship the build artefacts. A push-based deploy means self-hosting your forge requires exposing exactly nothing. No reverse proxy gymnastics, no VPN for the webhook and no port forwarding required.</p>
<p>If my homelab bursts into flames mid-afternoon ( a real possibility as I <em>do</em> live in Queensland, Australia ) Cloudflare keeps serving the last deploy and nobody visiting the site would ever know. That being said, nobody actually <em>does</em> visit this site, so&hellip;</p>
<h2 id="a-small-intermission">
  <a class="heading-link" href="#a-small-intermission">A small intermission.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A short aside, because it&rsquo;s a question <em>I&rsquo;d</em> ask. My internal services hang off a domain I genuinely own, but resolving it depends on where you&rsquo;re standing. Inside the network a locally hosted 3-node <a href="https://technitium.com/dns/">Technitium</a> cluster runs the hosted zone and the router sends all DNS requests through it. Initially, I used this for network-wide adblock, which works an absolute treat. It also means the forge and the CI cluster resolve to their internal addresses. Outside the network, the public zone points at a landing page hosted elsewhere.</p>
<p>Same hostname and two completely different worlds depending on which side of the wall you&rsquo;re on. <a href="https://en.wikipedia.org/wiki/Split-horizon_DNS">Split-horizon DNS</a> is nothing new, but it&rsquo;s what lets me use real TLS-friendly hostnames for internal services without those services ever being reachable from the internet.</p>
<p>If I <em>did</em> decide to open some services to the outside world, like my Navidrome server, I could use an overlay network like <a href="https://netbird.io">Netbird</a> to provide friends and family access via a familiar naming convention without ever opening a port on the router.</p>
<h2 id="the-part-cloudflare-doesnt-clearly-advertise">
  <a class="heading-link" href="#the-part-cloudflare-doesnt-clearly-advertise">The part Cloudflare doesn&rsquo;t clearly advertise.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A Pages project is either connected to a git provider or it accepts direct uploads. Never both. As long as your project is wired to GitHub, <code>wrangler pages deploy</code> will be rejected and you will get screamed at.</p>
<p>Disconnect the git integration in the project settings and the project quietly converts to &ldquo;Direct Upload&rdquo; mode. It keeps its name, its custom domain and its whole deployment history. It just stops watching a repository and starts waiting for you to hand it those delicious files. While you&rsquo;re in there, make sure the production branch is properly specified: <code>main</code> in my case. This matters because Wrangler tags each upload with a branch name. An upload whose branch matches the production branch goes live on the site. An upload under any other name becomes a preview deployment with its own URL instead.</p>
<p>After that, the deploy is one command requiring two secrets:</p>
<ul>
<li>An API token scoped to <code>Cloudflare Pages: Edit</code></li>
<li>Your Cloudflare account ID.</li>
</ul>
<p>Both go into the Woodpecker Server as repository secrets.</p>
<h2 id="finally-our-favourite-yaml">
  <a class="heading-link" href="#finally-our-favourite-yaml">Finally, our favourite! YAML.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The whole thing is just one file named <code>.woodpecker.yml</code> that squats in the repository root.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">YAML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">when</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">event</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, manual]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span><span class="l">main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">clone</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">git</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">woodpeckerci/plugin-git</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">settings</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">partial</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">diagrams</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">minlag/mermaid-cli:11.16.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./bin/render-diagrams</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">hugomods/hugo:debian-git-0.163.3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HUGO_ENV</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HUGO_ENVIRONMENT</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">git config --global --add safe.directory &#39;*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./bin/changelog</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">hugo --gc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">node:22-slim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">CLOUDFLARE_API_TOKEN</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">from_secret</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare_api_token</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">CLOUDFLARE_ACCOUNT_ID</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">from_secret</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare_account_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">        npx --yes wrangler@4 pages deploy public
</span></span></span><span class="line"><span class="cl"><span class="sd">        --project-name=wilhelm-codes
</span></span></span><span class="line"><span class="cl"><span class="sd">        --branch=main
</span></span></span><span class="line"><span class="cl"><span class="sd">        --commit-hash=&#34;${CI_COMMIT_SHA}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">when</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">event</span><span class="p">:</span><span class="w"> </span><span class="l">push</span></span></span></code></pre></div>
</div>
<h3 id="only-build-off-main">
  <a class="heading-link" href="#only-build-off-main">Only build off <code>main</code>.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I test design changes and new entries locally, so a push to any other branch doesn&rsquo;t start a pipeline.</p>
<h3 id="we-want-a-full-clone-not-a-shallow-one">
  <a class="heading-link" href="#we-want-a-full-clone-not-a-shallow-one">We want a full clone, not a shallow one.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>This site&rsquo;s <a href="https://wilhelm.codes/changelog/">changelog page</a> renders the git log and Hugo&rsquo;s <code>enableGitInfo</code> reads commit dates for every page. This is something I wrote about in <a href="https://wilhelm.codes/blog/a-changelog-that-builds-itself/">A Changelog That Builds Itself</a>. CI systems love shallow clones and a shallow clone here means I can&rsquo;t convey the full historical picture on the page. Worse, <code>enableGitInfo</code> derives every article&rsquo;s dates from that same truncated log, so they all come out wrong besides. Setting <code>partial: false</code> makes Woodpecker fetch the entire history.</p>
<h3 id="diagrams-render-before-anything-else">
  <a class="heading-link" href="#diagrams-render-before-anything-else">Diagrams render before anything else.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Articles on this site can embed <a href="https://mermaid.js.org">Mermaid</a> diagrams, like the sequence diagram further up the page. They render to static SVG at build time, not in your browser, so the site ships zero diagram JavaScript. A small script hashes each diagram&rsquo;s source, renders anything that has no matching SVG yet and prunes any SVG nothing references any more. I run it locally and commit the SVGs alongside the content, which means on a clean commit this step does precisely nothing. It exists as a safety net for the day I inevitably forget.</p>
<p>So when I said the diagram was rendered by the pipeline it describes, I meant it literally. If I ever edit that diagram and forget to render it, this step is what quietly saves the deploy.</p>
<h3 id="the-build-step-is-a-stock-hugo-image">
  <a class="heading-link" href="#the-build-step-is-a-stock-hugo-image">The build step is a stock Hugo image.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Nothing is installed on the agent itself unless you count Docker image pulls. The image pins the exact Hugo version, ships the extended edition and includes <code>git</code> and <code>bash</code>. Both of which the changelog script that generates the data requires. The <code>safe.directory</code> line exists because the cloned workspace is owned by a different user than the one inside the container and <code>git</code> these days refuses to read a repository it thinks you stole.</p>
<h3 id="the-deploy-step-is-a-stock-node-image">
  <a class="heading-link" href="#the-deploy-step-is-a-stock-node-image">The deploy step is a stock Node image.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p><code>npx</code> pulls Wrangler at run time. Wrangler then reads the two secrets from the environment and pushes <code>public/</code> up to Pages and that&rsquo;s pretty much it. Dependencies are snagged and cached at build time, which means nothing to maintain on the agents. Either step&rsquo;s version gets bumped by editing one line.</p>
<div class="callout callout-info">
  I already have <a href="https://distribution.github.io">distribution</a> hosted in the homelab for my local Docker images. I could easily configure it to support <a href="https://distribution.github.io/distribution/recipes/mirror/">pull-through caching</a>, so I don&rsquo;t keep having to pull directly from Docker Hub every time a build agent&rsquo;s local cache expires.
</div>

<p>Complete parity with what Cloudflare&rsquo;s own build pipeline was doing, except now I can read every line of it and make it do whatever I need.</p>
<h2 id="i-did-run-into-some-speed-bumps-along-the-way">
  <a class="heading-link" href="#i-did-run-into-some-speed-bumps-along-the-way">I did run into some speed bumps along the way.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>For the record, it took only three tries to get the pipeline to a &ldquo;green&rdquo; state.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A Woodpecker Server build history screenshot showing the first 3 attempts.">
      <img
  src="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_195442048d80ae9b.webp"
  srcset="/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_195442048d80ae9b.webp 736w, /blog/my-blog-now-ships-from-my-homelab/three-tries_hu_ad889cca69fa6327.webp 1104w, /blog/my-blog-now-ships-from-my-homelab/three-tries_hu_132d2f011cbace60.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="232"
  alt="A Woodpecker Server build history screenshot showing the first 3 attempts."
  loading="lazy"
  decoding="async"
  data-full="/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_8fa8b30bf4601afd.webp"
  data-credit="Third time&#39;s a charm."
  
/>

    </button>
    
      <figcaption>
        
          Third time&#39;s a charm.
        
      </figcaption>
    
  </figure>
<p>In order of discovery:</p>
<ul>
<li>The plain version tags of the Hugo image <em>are</em> the extended edition. The <code>reg-</code> prefix means regular and none of them include <code>git</code> unless the tag says <code>git</code>. The tag you guess first ( <code>exts-&lt;version&gt;</code> ) doesn&rsquo;t exist. It&rsquo;s an odd convention, which is why it threw me off.</li>
<li>Woodpecker substitutes <code>${VAR}</code> expressions in the YAML <em>before</em> the shell ever runs. Anything it doesn&rsquo;t know becomes an empty string. My <code>safe.directory &quot;${CI_WORKSPACE}&quot;</code> quietly became <code>safe.directory &quot;&quot;</code>. Hence the asterisk / glob in the YAML.</li>
<li>The changelog script opens with <code>#!/usr/bin/env bash</code> and the default Hugo images are Alpine. <code>env: can't execute 'bash'</code> is the whole error message you get, so I just went with the Debian variants instead which include <code>bash</code> out of the box.</li>
</ul>
<p>None of these are hard problems, but worth pointing out.</p>
<h2 id="im-not-deleting-the-old-repository-completely">
  <a class="heading-link" href="#im-not-deleting-the-old-repository-completely">I&rsquo;m not deleting the old repository&hellip; completely.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The GitHub repository still exists if only to provide a place for the giscus integration to store comments. It offloads most of this functionality to GitHub&rsquo;s Discussions feature. Delete the repo and every comment ever left on this site goes with it; all 2 of them.</p>
<p>It no longer needed the full source history to do that job either. It was ported on over to my local forge with Forgejo&rsquo;s native GitHub repository migration feature. So, I force-pushed the whole history into a black hole, leaving a single commit containing only a README. The remaining husk also has a second job coming; it&rsquo;ll eventually host the companion code for tutorial content, so readers can clone examples without ever touching my forge.</p>
<p>Each service kept exactly the job it&rsquo;s uniquely good at. Cloudflare serves files from the edge better than my house ever could and no fucking way am I publicly opening ports on a router. I&rsquo;d rather lick the rim of a pub toilet. GitHub Discussions gives commenters an identity they already have. Everything else now lives at home.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Was any of this necessary? For a static blog that deploys in under a minute, probably not. The old setup worked fine and if yours does too, keep it. I&rsquo;m not here to convince anyone.</p>
<p>There&rsquo;s a special kind of satisfaction in watching a pipeline run end to end on hardware you can physically point at, knowing the only cloud dependency left is the one actually earning its keep. The next GitHub outage can happen without me. At least for <em>this</em> project anyway.</p>]]></content:encoded></item><item><title>Glazier Release: v0.1.5</title><link>https://wilhelm.codes/blog/glazier-release-015/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/glazier-release-015/</guid><category>go</category><category>hcl</category><category>tmux</category><category>glazier</category><wc:kind>post</wc:kind><description>Today, I minted and tagged Glazier v0.1.5. Immediately after, realised I never announced v0.1.4, the release with all the actual features in it. So, Happy Birthday, you get a twofer.</description><content:encoded><![CDATA[<p>Today, I minted and tagged <a href="https://github.com/wilhelm-murdoch/glazier">Glazier</a> <code>v0.1.5</code>. Immediately after, realised I never announced <code>v0.1.4</code>, the release with all the actual features in it. So, Happy Birthday, you get a twofer.</p>
<h2 id="lets-get-v015-out-of-the-way-first">
  <a class="heading-link" href="#lets-get-v015-out-of-the-way-first">Let&rsquo;s get <code>v0.1.5</code> out of the way first.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There&rsquo;s not much to see here, honestly. This is a routine housekeeping and dependency bump release. The only change worthy of a mention is is a bump of <code>golang.org/x/text</code> to address <a href="https://pkg.go.dev/vuln/GO-2026-5970">GO-2026-5970</a>. This is the sort of thing you patch promptly even when the practical exposure for a tmux wrapper is <code>nil</code> ( ha-ha-ha ). Alongside that, the Go toolchain moved up to <code>1.26.4</code> and <code>testify</code>, <code>urfave/cli</code> and a stack of GitHub Actions all got their routine nudges courtesy of Dependabot. Hey, thanks little buddy!</p>
<p>Download the latest release, replace your current binary and you should be good to go.</p>
<h2 id="v014-is-where-the-features-live">
  <a class="heading-link" href="#v014-is-where-the-features-live"><code>v0.1.4</code> is where the features live.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<div class="callout callout-info">
  Before we get into it I should point out this release introduces a few breaking changes to the spec that should be easy enough to resolve thanks to Glazier&rsquo;s awesome built-in error reporting.
</div>

<p>Back in July I wrote about <a href="https://wilhelm.codes/blog/i-couldnt-talk-myself-out-of-typed-variables/">adding typed variable blocks to Glazier</a>. That work shipped on July 17th as <code>v0.1.4</code>.</p>
<p>The short recap, if you didn&rsquo;t read that post is that profiles can now declare the inputs they accept as first-class <code>variable</code> blocks. Just like, you guessed it, Terraform. Each block can carry a primitive type (<code>string</code>, <code>number</code> or <code>bool</code>), a <code>default</code> and a <code>description</code>. No default means the variable is required. Pass a <code>--var</code> the profile never declared and you get a proper located error.</p>
<p>Values are read through the <code>var.</code> namespace and only that namespace:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">variable</span> <span class="s2">&#34;district&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;the district the gig is themed after&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  type</span>        <span class="o">=</span> <span class="k">string</span>
</span></span><span class="line"><span class="cl"><span class="n">  default</span>     <span class="o">=</span> <span class="s2">&#34;watson&#34;</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">variable</span> <span class="s2">&#34;fixer&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  type</span> <span class="o">=</span> <span class="k">string</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">session</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;gig-${var.district}&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span> <span class="o">=</span> <span class="s2">&#34;${var.fixer}-ops&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;echo ${var.fixer} has the next job&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> glaze up --var <span class="nv">fixer</span><span class="o">=</span>wakako
</span></span><span class="line"><span class="cl"><span class="gp">$</span> glaze up --var <span class="nv">district</span><span class="o">=</span>arasaka --var <span class="nv">fixer</span><span class="o">=</span>wakako
</span></span></code></pre></div>
</div>
<p>That post covers the design reasoning, the type coercion and the breaking namespace migration in detail. So, no need to reshash it all here. What it <em>doesn&rsquo;t</em> cover is everything else that snuck into the release after I hit publish button.</p>
<h2 id="support-for-locals">
  <a class="heading-link" href="#support-for-locals">Support for locals.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Variables are <em>inputs</em>. They cross the boundary from the outside world into your profile via <code>--var</code> flags, var files or defaults. But the moment I had proper variables I ran into their natural companion problem; <em>derived</em> values. The thing you compute from an input once and then want to reuse in five places without copy-pasting the same expression five times.</p>
<p>Terraform solves this with <code>locals</code> and, because Glazier remains an unapologetic love letter to Terraform&rsquo;s parser, it does as well:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">variable</span> <span class="s2">&#34;district&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="s2">&#34;night city&#34;</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">locals</span> {
</span></span><span class="line"><span class="cl"><span class="n">  slug</span>    <span class="o">=</span> <span class="k">lower</span><span class="p">(</span><span class="k">replace</span><span class="p">(</span><span class="k">var</span><span class="p">.</span><span class="k">district</span><span class="p">,</span> <span class="s2">&#34; &#34;, &#34;-&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">  session</span> <span class="o">=</span> <span class="s2">&#34;gig-${local.slug}&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  logfile</span> <span class="o">=</span> <span class="s2">&#34;${path.pwd}/logs/${local.slug}.log&#34;</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">session</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="k">local</span><span class="p">.</span><span class="k">session</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span> <span class="o">=</span> <span class="s2">&#34;${local.slug}-ops&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;tail -f ${local.logfile}&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span> <span class="o">=</span> <span class="s2">&#34;editor&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;nvim&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>You declare named values in a <code>locals</code> block at the top level and read them back through <code>local.&lt;name&gt;</code>. Plural block and a singular reference is exactly how Terraform does it.</p>
<p>A local can reference the <code>var.*</code>, <code>env.*</code>, <code>path.*</code> namespaces, the whole function library and, crucially, <em>other locals</em>. In any order. This works fine:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">locals</span> {
</span></span><span class="line"><span class="cl"><span class="n">  session</span> <span class="o">=</span> <span class="s2">&#34;gig-${local.slug}&#34;</span><span class="c1">                    # references slug...
</span></span></span><span class="line"><span class="cl"><span class="n">  slug</span>    <span class="o">=</span> <span class="k">lower</span><span class="p">(</span><span class="k">replace</span><span class="p">(</span><span class="k">var</span><span class="p">.</span><span class="k">district</span><span class="p">,</span> <span class="s2">&#34; &#34;, &#34;-&#34;</span><span class="p">))</span><span class="c1"> # ...declared after it
</span></span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Under the hood resolution just keeps iterating, evaluating whatever it can each pass until the end. Whatever&rsquo;s left over then reports its <em>real</em> evaluation diagnostics, so a genuine fat-fingered mistake surfaces as the actual error rather than being masked by ordering machinery. If you accidentally declare the same local twice you&rsquo;ll get told exactly where both declarations live.</p>
<p>The one thing you can&rsquo;t do with a local is set it from the command line and this is by design. Variables are the contract with the outside world; locals are private. If a value should be overridable, it&rsquo;s a variable. If it&rsquo;s just an expression you&rsquo;re tired of repeating, it&rsquo;s a local. Having the language enforce that distinction is one of those small things that keeps a profile clean as it grows.</p>
<p>They also pair nicely with the new <code>random</code> function and HCL&rsquo;s inline comprehensions, both of which landed in this release too:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">locals</span> {
</span></span><span class="line"><span class="cl"><span class="n">  editors</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;nvim&#34;, &#34;hx&#34;, &#34;vim&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">  greeting</span> <span class="o">=</span> <span class="k">random</span><span class="p">([</span><span class="k">for</span> <span class="k">g</span> <span class="k">in</span> <span class="p">[</span><span class="s2">&#34;hello&#34;, &#34;choom&#34;, &#34;wake up samurai&#34;</span><span class="p">]</span> <span class="err">:</span> <span class="k">title</span><span class="p">(</span><span class="k">g</span><span class="p">)])</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p><code>random(list)</code> returns a uniformly random element of a list as a string. Did anyone ask for this? Absolutely not, but you deserve nice things.</p>
<h2 id="lol-lmfao">
  <a class="heading-link" href="#lol-lmfao">lol&hellip; lmfao.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The July post ended with me musing about adding <code>*.tfvars</code>-style functionality and asking, and I quote, &ldquo;Surely, I won&rsquo;t fall for this again?&rdquo;</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>He, indeed, fell for this again.</p>
  </blockquote>
  <figcaption>
    <cite>narrator</cite>
  </figcaption>
</figure>

<p>Yeah, so <code>v0.1.4</code> also ships with <code>--var-file</code> support:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="c1"># gig.vars
</span></span></span><span class="line"><span class="cl"><span class="n">district</span> <span class="o">=</span> <span class="s2">&#34;japantown&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">fixer</span>    <span class="o">=</span> <span class="s2">&#34;wakako&#34;</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> glaze up --var-file gig.vars
</span></span><span class="line"><span class="cl"><span class="gp">$</span> glaze up --var-file gig.vars --var <span class="nv">fixer</span><span class="o">=</span>dino
</span></span></code></pre></div>
</div>
<p>A var file is plain HCL; a <code>name = value</code> attributes written once per line. I originally supported JSON files too and then ripped that out before release, because maintaining a second parse path for a format nobody was going to use failed the smell test. In other words, &ldquo;YAGNI&rdquo;.</p>
<p>Precedence works the way you&rsquo;d expect: defaults go first, a var file overrides defaults and an explicit <code>--var</code> flag always has the final say. In the second command above <code>fixer</code> comes out as <code>dino</code> no matter what the file thinks.</p>
<p>Strictness from the flag side applies here too. An entry in a var file that names a variable the profile never declared is an error. Values are coerced to their declared types and every problem in the file is reported all at once for convenience.</p>
<h2 id="our-first-contributor-has-entered-the-chat">
  <a class="heading-link" href="#our-first-contributor-has-entered-the-chat">Our first contributor has entered the chat!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This release contains the first fix from an actual outside contributor. <a href="https://github.com/ThreeToes">ThreeToes</a> tracked down an annoying initialisation bug where the first window in a profile would be ignored and cleaned up on startup. Leaving you with either a stray default terminal or only the subsequent windows.</p>
<p>Someone who isn&rsquo;t me not only ran the thing, they read the source and fixed it. That&rsquo;s a genuinely lovely milestone for a hobby project. Thanks, man!</p>
<p>Oh, and the repo finally has a proper <a href="https://github.com/wilhelm-murdoch/glazier/blob/main/SPEC.md">SPEC.md</a>. A full reference for the profile format describing every block, namespace and function.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Release notes are like backups. Everyone agrees they&rsquo;re important and nobody does them until it&rsquo;s embarrassing.</p>
<p>As always, it&rsquo;s <a href="https://github.com/wilhelm-murdoch/glazier">up on GitHub</a> under MIT. If you&rsquo;ve got Go installed:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> go install github.com/wilhelm-murdoch/glazier/cmd/glaze@latest
</span></span></code></pre></div>
</div>
<p>Or, grab a prebuilt binary from the <a href="https://github.com/wilhelm-murdoch/glazier/releases">releases page</a>, checksums and provenance attestations are included. Small note on this, I do plan on adding support for all your favourite package managers to make this process a bit more intuitive. Homebrew first as a test run, then all the other you&rsquo;d expect.</p>
<p>If you&rsquo;ve opinions on <code>locals</code>, var files or what Glazier should learn next, the comments are right below.</p>
<p>P.S. - I&rsquo;ve another secret project in the works and I&rsquo;m using it to declaratively generate Open Graph social cards for the blog. I thought I&rsquo;d create a format dedicated to Glazier releases.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: The Open Graph release card for Glazier v0.5.1">
      <img
  src="https://wilhelm.codes/blog/glazier-release-015/og_hu_3d6b8ec9a330023c.webp"
  srcset="/blog/glazier-release-015/og_hu_3d6b8ec9a330023c.webp 736w, /blog/glazier-release-015/og_hu_d7182b2892d9721b.webp 1104w, /blog/glazier-release-015/og_hu_e05a468bc0a12ed2.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="386"
  alt="The Open Graph release card for Glazier v0.5.1"
  loading="lazy"
  decoding="async"
  data-full="/blog/glazier-release-015/og_hu_ca00927e46de581.webp"
  data-credit="Generated using HCL and Ogre..."
  data-credit-href="https://wilhelm.codes"
/>

    </button>
    
      <figcaption>
        
          <a href="https://wilhelm.codes" target="_blank" rel="noopener">Generated using HCL and Ogre...</a>
        
      </figcaption>
    
  </figure>]]></content:encoded></item><item><title>Some Long Overdue Demolition</title><link>https://wilhelm.codes/blog/some-long-overdue-demolition/</link><pubDate>Sun, 23 Aug 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/some-long-overdue-demolition/</guid><category>hugo</category><category>css</category><category>performance</category><category>wilhelm.codes</category><wc:kind>post</wc:kind><description>Two months ago I wrote Some Long Overdue Housekeeping. A post about giving this blog a proper facelift. I untangled my Tailwind setup, modernised Hugo and taught it to drive the compiler natively and generally felt very pleased with the state of things.
So, of course just weeks later I have this burning desire to nuke it all from orbit!</description><content:encoded><![CDATA[<p>Two months ago I wrote <a href="https://wilhelm.codes/blog/some-long-overdue-housekeeping/">Some Long Overdue Housekeeping</a>. A post about giving this blog a proper facelift. I untangled my Tailwind setup, modernised Hugo and taught it to drive the compiler natively and generally felt very pleased with the state of things.</p>
<p>So, of course just weeks later I have this burning desire to nuke it all from orbit!</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>This means no more standalone Tailwind config, no committed stylesheet, no <code>node_modules</code> in <code>static/</code> and no build toolchain leaking onto the live site. Very cool!</p>
  </blockquote>
  <figcaption>
    <cite>me, in June, admiring a thing I have since bulldozed</cite>
  </figcaption>
</figure>

<p>To be clear, the June cleanup wasn&rsquo;t <em>wrong</em>. The pipeline was genuinely tidy. But, a tidy pipeline is still a pipeline and somewhere between then and now I started asking a more annoying question: why does a personal blog, a pile of static text, need a JavaScript toolchain at all? Why do I need to buy into the Tailwind framework when I knew full well how to write vanilla CSS with my own hands? Every <code>npm install</code> was a little tax that I&rsquo;d eventually have to pay with interest the next time I wanted to update the site&rsquo;s dependencies. I felt as if I wasn&rsquo;t maintaining a blog. I was maintaining the machinery that produces a blog and the machinery had some very strong opinions.</p>
<p>So, I did a hard reset with a <em>very</em> satisfying amount of deleting.</p>
<h2 id="the-demolition-">
  <a class="heading-link" href="#the-demolition-">The demolition &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Here&rsquo;s the bill for the redesign commit:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">230 files changed, 3951 insertions(+), 33365 deletions(-)</span></span></code></pre></div>
</div>
<p>That&rsquo;s roughly a 10-to-1 ratio of deleted to written. Which, as a software engineer, is my favourite kind of ratio. I happily added the following the to chopping block:</p>
<ul>
<li><strong>Tailwind v4 and the typography plugin.</strong> Replaced by one hand-written CSS file. The whole thing gzips to under <code>~10kB</code> with syntax highlighting included.</li>
<li><strong>The entire Node toolchain.</strong> The <code>package.json</code>, lockfile, Prettier and its plugins. There is no <code>npm install</code> step anymore because there is no npm anymore.</li>
<li><strong>Fuse.js and the ⌘K search palette.</strong> Nobody was searching this blog. I checked. It was me. I was nobody.</li>
<li><strong>Two self-hosted webfonts.</strong> More on this in a second, because it&rsquo;s the biggest single win.</li>
<li><strong>The changelog page.</strong> This pulled from the GitHub events API and shipped with an <code>18,016</code>-line JSON fixture for local dev. Eighteen <em>thousand</em> lines of someone else&rsquo;s API response, committed to my repo. So, a page barely anybody visited could render in development. I mean, I added it back becase <em>I</em> missed it, but it&rsquo;s <em>much</em> more efficient now.</li>
<li><strong>68 of the 79 icon SVGs.</strong> A 390-line drawer menu and a taxonomy I&rsquo;d stopped using.</li>
</ul>
<p>The layouts went from <code>2,868</code> lines of template across <code>48</code> files to about <code>800</code> lines across <code>21</code>. Not because I got cleverer, but because most of that code was written for the joy of it. Which, look, is a fine reason to build something. It&rsquo;s just a bad reason to keep serving it to strangers. And with all that complexity and cruft came what felt like a lot of brittleness.</p>
<h2 id="the-fonts-had-to-go-too">
  <a class="heading-link" href="#the-fonts-had-to-go-too">The fonts had to go too.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This one hurt a little. I like Inter and Rubik. Together they were <code>449kB</code> of <code>woff2</code> riding along on every first visit. Which made them, by an enormous margin, the heaviest thing on the site.</p>
<p>So, I dropped them and switched to the system font stack which, to my surprise, actually looked <em>better</em> on the site. The actual typeface was doing very little. Your operating system already ships a perfectly good set. All lovingly hinted by people with far more patience and experience than myself. Best of all it costs zero bytes to use!</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>By Grabthar's hammer... What a savings.</p>
  </blockquote>
  <figcaption>
    <cite>Dr. Lazarus from Galaxy Quest</cite>
  </figcaption>
</figure>

<h2 id="hugo-finally-home-alone">
  <a class="heading-link" href="#hugo-finally-home-alone">Hugo, finally home alone.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Thanks to all the trimmed fat, the build process is effectively just <code>hugo</code>. No more extended binary, SCSS, image processing or defining <code>NODE_VERSION</code> in Cloudflare Pages required. Fewer dependencies mean fewer moving parts and that&rsquo;s always an improvement.</p>
<h2 id="numbers-or-it-didnt-happen">
  <a class="heading-link" href="#numbers-or-it-didnt-happen">Numbers, or it didn&rsquo;t happen.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Right after the deploy I proudly measured the new site and announced it was &ldquo;under <code>100kB</code>&rdquo;. Which would have been true if the production site were served by my dev server, uncompressed, with a <code>40kB</code> livereload script attached. I made the mistake of measuring the network stats on my local development server. <em>Womp, womp</em>.</p>
<p>The real numbers, from the live site with Cloudflare&rsquo;s <code>brotli</code> doing its thing:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Before</th>
					<th>After</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Cold home page</td>
					<td><code>~750kB</code></td>
					<td><code>~24kB</code></td>
			</tr>
			<tr>
					<td>Webfonts</td>
					<td><code>449kB</code></td>
					<td><code>0kB</code></td>
			</tr>
			<tr>
					<td>Stylesheet ( compressed )</td>
					<td>it&rsquo;s complicated</td>
					<td>under <code>~10kB</code></td>
			</tr>
			<tr>
					<td><code>npm install</code></td>
					<td>required</td>
					<td>there is nothing to install</td>
			</tr>
	</tbody>
</table>
<p>The single largest asset on the home page is now the favicon. <code>7.5kB</code>; roughly a third of the total weight. I think you&rsquo;re allowed to stop optimising when your largest single asset is your <code>favicon.ico</code>. It&rsquo;s an image of my gorgeous mug. It stays. I&rsquo;m quite attached to it.</p>
<h2 id="an-ambitious-first-pass">
  <a class="heading-link" href="#an-ambitious-first-pass">An ambitious first pass.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>In retrospect, I wanted to make everything simpler and unintentionally over-compensated in the opposite direction. I stripped out too much and was now staring at a sad little blog with zero personality.</p>
<p>So, the second half of the job was building the personality back and making my selections a bit more deliberate. One thing led to another and I think I&rsquo;ve produced my finest work yet on a personal blog. It&rsquo;s small and lightening fast. I&rsquo;m not sure where else I can optimise without drastically affecting the overall vibe of the design; the law of diminishing returns&hellip; returns.</p>
<p>The thing about now having such a light frame is adding to it stops feeling like a burden, or something future Wilhelm will have to pay. For example, here are the biggest things I&rsquo;ve added and they <em>barely</em> increased the overall &ldquo;weight&rdquo; of the design.</p>
<h3 id="series">
  <a class="heading-link" href="#series">Series<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>A proper taxonomy for multi-part writing. Built because I&rsquo;ve been wanting to write a Cyberpunk-themed set of tutorials for writing advanced HCL in Go. The series will be about eight posts long and outside of sharing a tag, I had no way to string them together. Series posts wear a stacked-sheet card, a segmented progress meter that is absolutely <em>not</em> a cyberdeck RAM readout and a panel showing every part in the run, including the ones that don&rsquo;t exist yet.</p>
<p>The unpublished parts of a series ship as glyph noise and decode, left to right, when you point at them. The real titles sit in <code>aria-label</code> the whole time, so a screen reader is never handed the static. The browser tab pulls the same trick on every page load, because a title bar is just a very small marquee nobody was using.</p>
<p>Try moving your mouse pointer over some of the &ldquo;encrypted&rdquo; titles on the new <a href="https://wilhelm.codes/series">dedicated</a> page.</p>
<h3 id="tired-leds">
  <a class="heading-link" href="#tired-leds">Tired LEDs<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>The lit chips in the series meters, static leaders in the main page&rsquo;s hero section and a scattered handful of cells in the activity graph flicker like hardware that has seen some things. Their animation clocks share no common factors, so the pattern never visibly repeats. The same no-RNG randomness as the heading glitch. On page load the whole graph boots like an old panel, dark, then a sputter per cell, then steady stream of blinking boxes. Reminiscent of the old monochrome screens and beige boxes of my youth.</p>
<p>Christ, I&rsquo;m getting old.</p>
<h3 id="changelog-resurrected">
  <a class="heading-link" href="#changelog-resurrected">Changelog, resurrected<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Yes, the one from the butcher&rsquo;s block above. However, instead of hitting the associated Github repository directly at build time using a PAT, the new one doesn&rsquo;t call anyone. Commit data is sourced <em>directly</em> from Cloudflare Pages&rsquo; shallow clone it checks out during build time. There&rsquo;s a simple 20-or-so-line shell script reads <code>git log</code> and Hugo renders the result. <code>HEAD</code> gets a lit node that flickers, <a href="https://wilhelm.codes/changelog">because why the hell not</a>?</p>
<h3 id="image-shortcodes">
  <a class="heading-link" href="#image-shortcodes">Image shortcodes<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Something I&rsquo;ve been meaning to add is support for various ways to display images. Each image can now be clicked-to-zoom with attribution in the following forms:</p>
<ul>
<li>Masonry.</li>
<li>A scrollable, horizontal display.</li>
<li>Last, but not least, the typicla single image display.</li>
</ul>
<p>There&rsquo;s actually a sneaky, super-secret page I use to test all my shortcodes and typography stylings where you can <a href="https://wilhelm.codes/blog/markdown-elements/">see everything for yourself</a>.</p>
<p>Even with all these complex additions, it all adds up to a few kilobytes of CSS and inline script. Which is to say the entire second wave of features weighs an order of magnitude less than one of the webfonts I deleted.</p>
<h2 id="my-new-favourite-fidget-toy">
  <a class="heading-link" href="#my-new-favourite-fidget-toy">My new favourite fidget toy.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The part I didn&rsquo;t expect was to glue my eyeballs to the Firefox throttle menu under its network tools panel.</p>
<p>I flipped it to a 2G profile just for shits and giggles - call it a generous <code>50kb</code> per second - and did the napkin math. The old site&rsquo;s <code>750kB</code> works out to between <code>60</code> to <code>90</code> seconds of staring at a blank viewport. The new site lands in about four seconds on a connection profile most of the industry stopped thinking about a decade ago.</p>
<p>I have reloaded this page on simulated garbage networks far more times than any reasonable person should, and it is satisfying <em>every single time</em>. The page just&hellip; appears. Like a website. Remember plain&rsquo;ole websites?</p>
<p>None of this is novel, I know. The &ldquo;your blog doesn&rsquo;t need <code>2MB</code> of JavaScript&rdquo; essay has been written a hundred times. Usually on a blog serving <code>2MB</code> of JavaScript. I&rsquo;m not even the hundred-and-first person to do this. But, there&rsquo;s a difference between nodding along to the idea and watching your own site load over a simulated tin-can connection before you can finish blinking.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The funny thing about the June post is that it ended with me feeling like the blog was finally sorted. And it was! For the architecture it had. It just turned out the architecture itself was the thing that needed to go.</p>
<p>The site is now boring in the best possible way. It builds with one command, it has no dependencies to rot behind my back and the whole design fits in one stylesheet I can actually hold in my head ( not really ). Fewer moving parts, fewer things to forget, fewer surprises the next time I touch it in six months. Best of all, it looks fucking great!</p>
<p>Set-and-forget, but this time with almost nothing left to forget. &hellip; Right?</p>]]></content:encoded></item><item><title>It's OK to Let Go</title><link>https://wilhelm.codes/blog/its-ok-to-let-go/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/its-ok-to-let-go/</guid><category>go</category><category>open-source</category><wc:kind>post</wc:kind><description>So, I&amp;rsquo;m a sucker for wordplay and puns. Anyway&amp;hellip;
There&amp;rsquo;s a line in my shell history that goes back to 2022: go get github.com/wilhelm-murdoch/go-collection. For years it was the first dependency into almost every Go project I tinkered with, sometimes before I&amp;rsquo;d even give the idea a solid shape. I&amp;rsquo;d open Neovim, initialise a module and then my little collection library got pulled in. Just like muscle memory.
Today I&amp;rsquo;m archiving it. Not because it broke and not because I got bored of it, but because the Go standard library quietly walked up and did its job better than it ever could. This post is part explanation and part eulogy, because I think we&amp;rsquo;re generally quite bad at ending software on purpose and it&amp;rsquo;s worth practising in public.</description><content:encoded><![CDATA[<p>So, I&rsquo;m a sucker for wordplay and puns. Anyway&hellip;</p>
<p>There&rsquo;s a line in my shell history that goes back to 2022: <code>go get github.com/wilhelm-murdoch/go-collection</code>. For years it was the first dependency into almost every Go project I tinkered with, sometimes before I&rsquo;d even give the idea a solid shape. I&rsquo;d open Neovim, initialise a module and then my little collection library got pulled in. Just like muscle memory.</p>
<p>Today I&rsquo;m archiving it. Not because it broke and not because I got bored of it, but because the Go standard library quietly walked up and did its job better than it ever could. This post is part explanation and part eulogy, because I think we&rsquo;re generally quite bad at ending software on purpose and it&rsquo;s worth practising in public.</p>
<h2 id="why-even-write-it-in-the-first-place">
  <a class="heading-link" href="#why-even-write-it-in-the-first-place">Why even write it in the first place?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Waaaay back in 2022 Go 1.18 had just shipped generics after roughly a decade of the community asking, arguing and writing increasingly unhinged <code>interface{}</code> workarounds. I wanted to actually <em>learn</em> the new type parameter machinery rather than just read about it and I was also deeply tired of writing the same <code>for</code> loop to check whether a slice contained a thing for the hundredth time.</p>
<p>So <a href="https://github.com/wilhelm-murdoch/go-collection">go-collection</a> happened. A single generic <code>Collection[T]</code> type wrapping a slice, with all the conveniences I kept reaching for: <code>Contains</code>, <code>Find</code>, <code>Filter</code>, <code>Map</code>, <code>Sort</code>, <code>Batch</code>, <code>Push</code>, <code>Pop</code> and a few dozen friends. All chainable, fluent, tested and small.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>There are far more comprehensive modules out there, but this one works quite well for my purposes.</p>
  </blockquote>
  <figcaption>
    <cite>me, in the README, setting expectations appropriately for once</cite>
  </figcaption>
</figure>

<p>That sentence was doing more work than I realised at the time. It wasn&rsquo;t trying to compete with the big functional-utility libraries. It was scratching a personal itch and for a few years it scratched it well. Then Go&rsquo;s standard library grew and slurped most of it up.</p>
<h2 id="taking-a-trip-to-the-morgue">
  <a class="heading-link" href="#taking-a-trip-to-the-morgue">Taking a trip to the morgue.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Cause of death is easy to establish here, because it happened in three well-documented releases. Go 1.21 shipped the <code>slices</code> package. Go 1.22 and 1.23 finished the job. Let&rsquo;s walk through it, method by method, like the nerds we are.</p>
<h3 id="does-it-have-the-thing">
  <a class="heading-link" href="#does-it-have-the-thing">Does it have the thing?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>The bread and butter. Probably eighty percent of my actual usage of this library was some flavour of &ldquo;is this thing in there?&rdquo;:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">fruits</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">collection</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;apple&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;orange&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;strawberry&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">fruits</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="s">&#34;orange&#34;</span><span class="p">)</span><span class="w"> </span><span class="c1">// true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">fruits</span><span class="p">.</span><span class="nf">FindIndex</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">i</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="nx">item</span><span class="w"> </span><span class="kt">string</span><span class="p">)</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">return</span><span class="w"> </span><span class="nx">strings</span><span class="p">.</span><span class="nf">HasPrefix</span><span class="p">(</span><span class="nx">item</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;str&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">})</span><span class="w"> </span><span class="c1">// 2</span></span></span></code></pre></div>
</div>
<p>As of Go 1.21, the standard library does both on a plain slice with no wrapper type in sight:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">fruits</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;apple&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;orange&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;strawberry&#34;</span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">slices</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="nx">fruits</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;orange&#34;</span><span class="p">)</span><span class="w"> </span><span class="c1">// true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">slices</span><span class="p">.</span><span class="nf">IndexFunc</span><span class="p">(</span><span class="nx">fruits</span><span class="p">,</span><span class="w"> </span><span class="kd">func</span><span class="p">(</span><span class="nx">item</span><span class="w"> </span><span class="kt">string</span><span class="p">)</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">return</span><span class="w"> </span><span class="nx">strings</span><span class="p">.</span><span class="nf">HasPrefix</span><span class="p">(</span><span class="nx">item</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;str&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">})</span><span class="w"> </span><span class="c1">// 2</span></span></span></code></pre></div>
</div>
<p>No <code>New</code>, no <code>.Items()</code> to unwrap at the end when some other API wants a real slice. It&rsquo;s just my function with better ergonomics and a compiler team maintaining it. I&rsquo;m fine with this.</p>
<h3 id="sorting-where-i-dont-even-get-to-feel-bitter">
  <a class="heading-link" href="#sorting-where-i-dont-even-get-to-feel-bitter">Sorting, where I don&rsquo;t even get to feel bitter.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Here&rsquo;s my <code>Sort</code>. I want you to look at what using it actually required:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">numbers</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">collection</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">5</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">numbers</span><span class="p">.</span><span class="nf">Sort</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span><span class="w"> </span><span class="nx">j</span><span class="w"> </span><span class="kt">int</span><span class="p">)</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">left</span><span class="p">,</span><span class="w"> </span><span class="nx">_</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">numbers</span><span class="p">.</span><span class="nf">At</span><span class="p">(</span><span class="nx">i</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">right</span><span class="p">,</span><span class="w"> </span><span class="nx">_</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">numbers</span><span class="p">.</span><span class="nf">At</span><span class="p">(</span><span class="nx">j</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">return</span><span class="w"> </span><span class="nx">left</span><span class="w"> </span><span class="p">&lt;</span><span class="w"> </span><span class="nx">right</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">})</span></span></span></code></pre></div>
</div>
<p>Index-based comparators, so you had to reach <em>back into the collection you were currently sorting</em> to get at the values. I inherited that design from the old <code>sort.Slice</code> idiom and it was clunky then too. The modern equivalent:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">numbers</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="p">[]</span><span class="kt">int</span><span class="p">{</span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">5</span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">slices</span><span class="p">.</span><span class="nf">Sort</span><span class="p">(</span><span class="nx">numbers</span><span class="p">)</span></span></span></code></pre></div>
</div>
<p>And when you&rsquo;re sorting something with actual structure, <code>SortFunc</code> hands you the two elements directly, with <code>cmp.Compare</code> doing the boring part:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">slices</span><span class="p">.</span><span class="nf">SortFunc</span><span class="p">(</span><span class="nx">people</span><span class="p">,</span><span class="w"> </span><span class="kd">func</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span><span class="w"> </span><span class="nx">b</span><span class="w"> </span><span class="nx">Person</span><span class="p">)</span><span class="w"> </span><span class="kt">int</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="k">return</span><span class="w"> </span><span class="nx">cmp</span><span class="p">.</span><span class="nf">Compare</span><span class="p">(</span><span class="nx">a</span><span class="p">.</span><span class="nx">Age</span><span class="p">,</span><span class="w"> </span><span class="nx">b</span><span class="p">.</span><span class="nx">Age</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">})</span></span></span></code></pre></div>
</div>
<p>This isn&rsquo;t a case of the standard library catching up to my library. It blew my version right out of the water.</p>
<h3 id="the-killing-blow-came-with-iterators">
  <a class="heading-link" href="#the-killing-blow-came-with-iterators">The killing blow came with iterators.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Go 1.22 added <code>slices.Concat</code>, which retired my <code>Concat</code>. Fine, that one was three lines anyway. But Go 1.23 shipped range-over-function iterators and with them <code>slices.Chunk</code>, and that&rsquo;s the release where I stopped pretending this was still a contest. <code>Batch</code> was one of the few methods I was genuinely proud of:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">records</span><span class="p">.</span><span class="nf">Batch</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">batch</span><span class="p">,</span><span class="w"> </span><span class="nx">index</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="nx">item</span><span class="w"> </span><span class="nx">Record</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="c1">// process item as part of batch N</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">},</span><span class="w"> </span><span class="mi">100</span><span class="p">)</span></span></span></code></pre></div>
</div>
<p>Now it&rsquo;s a language feature wearing a standard library hat:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="k">for</span><span class="w"> </span><span class="nx">chunk</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">slices</span><span class="p">.</span><span class="nf">Chunk</span><span class="p">(</span><span class="nx">records</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="c1">// chunk is a []Record of up to 100 items</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>A real <code>for</code> loop. You can <code>break</code> out of it, <code>continue</code> past things and return early. My callback-based version couldn&rsquo;t do any of that without contorting itself. The trouble is that a wrapper library can only ever be as expressive as the language it wraps and the language just grew.</p>
<h3 id="the-stuff-that-never-needed-a-library">
  <a class="heading-link" href="#the-stuff-that-never-needed-a-library">The stuff that never needed a library.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>An honest confession while we&rsquo;re standing over the body: a decent chunk of the API never deserved to exist in the first place. <code>Push</code>, <code>Pop</code>, <code>Shift</code>, <code>Unshift</code>. I wrote JavaScript for years before Go was ever a public thing and apparently nobody walks away from that unscathed.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">c</span><span class="p">.</span><span class="nf">Push</span><span class="p">(</span><span class="s">&#34;e&#34;</span><span class="p">)</span><span class="w">         </span><span class="c1">// s = append(s, &#34;e&#34;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">item</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">c</span><span class="p">.</span><span class="nf">Pop</span><span class="p">()</span><span class="w"> </span><span class="c1">// item = s[len(s)-1]; s = s[:len(s)-1]</span></span></span></code></pre></div>
</div>
<p>The comments on the right are the entire implementation. Idiomatic Go slice handling was already good at this in 2012. Those methods existed because my fingers missed <code>Array.prototype</code>, not because Go was missing anything.</p>
<h3 id="the-survivors-if-you-could-even-call-them-that">
  <a class="heading-link" href="#the-survivors-if-you-could-even-call-them-that">The &ldquo;survivors&rdquo;, if you could even call them that.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Here&rsquo;s the wrinkle that makes this more interesting than a straight obituary. <code>Map</code>, <code>Filter</code>, <code>Reduce</code>, <code>Some</code>, <code>None</code>; the functional stalwarts <em>still</em> aren&rsquo;t in the standard library, all these years later. On paper, that&rsquo;s the corner of the market my little module could have retired to. A niche! Sustained relevance!</p>
<p>Except, no. First, because my <code>Map</code> had a secret shame:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">c</span><span class="w"> </span><span class="o">*</span><span class="nx">Collection</span><span class="p">[</span><span class="nx">T</span><span class="p">])</span><span class="w"> </span><span class="nf">Map</span><span class="p">(</span><span class="nx">f</span><span class="w"> </span><span class="kd">func</span><span class="p">(</span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="nx">T</span><span class="p">)</span><span class="w"> </span><span class="nx">T</span><span class="p">)</span><span class="w"> </span><span class="p">(</span><span class="nx">out</span><span class="w"> </span><span class="nx">Collection</span><span class="p">[</span><span class="nx">T</span><span class="p">])</span></span></span></code></pre></div>
</div>
<p>See it? <code>T</code> in, <code>T</code> out. Go methods can&rsquo;t introduce new type parameters, so a method-based <code>Map</code> can never change the element type. Strings to strings, ints to ints. A <code>Map</code> that can only map onto itself is a <code>Map</code> in vibes only; the moment you want lengths of strings you&rsquo;re back to writing a loop anyway.</p>
<p>And second, more fundamentally: the Go community collectively shrugged and decided the loop was fine. The proposal discussions happened, the experiments ran and the ecosystem largely settled on this:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">names</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">([]</span><span class="kt">string</span><span class="p">,</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="nb">len</span><span class="p">(</span><span class="nx">ducks</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">for</span><span class="w"> </span><span class="nx">_</span><span class="p">,</span><span class="w"> </span><span class="nx">duck</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">ducks</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">	</span><span class="nx">names</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span><span class="w"> </span><span class="nx">strings</span><span class="p">.</span><span class="nf">ToUpper</span><span class="p">(</span><span class="nx">duck</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>Four lines. Obvious to anyone who&rsquo;s read any Go at all, no import, nothing to learn, nothing to maintain and debugger-friendly. Even where <code>go-collection</code> wasn&rsquo;t superseded, it turned out to be unnecessary, which is somehow a more thorough defeat.</p>
<h2 id="software-is-a-tool-not-a-legacy">
  <a class="heading-link" href="#software-is-a-tool-not-a-legacy">Software is a tool, not a legacy.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>So that&rsquo;s the technical half. Here&rsquo;s the part I actually wanted to write about.</p>
<p>We don&rsquo;t really have a culture of <em>finishing</em> software. A project is either &ldquo;actively maintained&rdquo; or it&rsquo;s &ldquo;abandoned&rdquo;, with all the guilt that word drags behind it. Issues accumulate, badges rot and the README slowly becomes a lie because nobody wants to be the person who admits the thing is done, or no longer necessary. I&rsquo;ve had this module sitting in that limbo for a while now, still getting pulled into new projects out of habit while a strictly better replacement sat in the standard library the whole time.</p>
<p>But software isn&rsquo;t a legacy; it&rsquo;s a tool. Tools serve a purpose. This one&rsquo;s purpose was to teach me generics in their first year of existence and to spare me a few thousand <code>for</code> loops while the language sorted itself out. Purpose served on both counts. And of all the ways for a library to die, being absorbed by the standard library is the best one available. It means the gap you filled was real and now it&rsquo;s just <em>gone</em>, fixed at the correct layer, for everyone and forever. That&rsquo;s not a failure state. That&rsquo;s the mission succeeding so hard the mission ceases to exist.</p>
<p>Keeping it alive from here would mean maintaining a slightly worse, slightly slower, personally-branded copy of <code>slices</code> out of pure sentiment. Nobody needs that, least of all me. I&rsquo;m busy enough and would much rather move on to newer, more interesting things.</p>
<h2 id="the-practical-bits">
  <a class="heading-link" href="#the-practical-bits">The practical bits.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>So today, the repo gets the full send-off:</p>
<ul>
<li>A deprecation notice at the top of the README, pointing anyone who lands there at the <a href="https://pkg.go.dev/slices">slices</a> package and at this post.</li>
<li>The repository gets <a href="https://github.com/wilhelm-murdoch/go-collection">archived</a> on GitHub. Read-only, clearly signposted and locked in eternal stasis.</li>
</ul>
<p>If you&rsquo;re one of the however-many people with this module in a <code>go.mod</code> somewhere nothing breaks. The Go module proxy caches published versions more or less permanently, so <code>v1.0.11</code> will keep resolving long after we&rsquo;re all dust. Archiving just makes the end of our relationship official rather than leaving it implied.</p>
<p>If you&rsquo;ve got a little module of your own in the same situation that has been superseded, purpose spent, kept alive out of habit, then consider this your permission slip. Write the notice, hit archive and go build that next thing. An honest ending is a kindness to your users and to yourself. It&rsquo;s OK to let go.</p>
<p><a href="https://github.com/wilhelm-murdoch/glazier/commit/8b6b84a220225fe714b8623aac1b183d64faf09a">Here</a> is an example of me tearing <code>go-collection</code> out of my very recent Glazier project. This one stung a little at how easily it was replaced.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Thanks, little buddy. You were there before the ecosystem knew what generics were supposed to look like, you appeared in more of my <code>go.mod</code> files than any dependency I didn&rsquo;t write, and you were, right to the end, exactly as comprehensive as I needed you to be.</p>
<p><code>go get</code> in peace.</p>
]]></content:encoded></item><item><title>I Added Variable Block Support To Glazier</title><link>https://wilhelm.codes/blog/i-couldnt-talk-myself-out-of-typed-variables/</link><pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/i-couldnt-talk-myself-out-of-typed-variables/</guid><category>go</category><category>hcl</category><category>tmux</category><category>glazier</category><wc:kind>post</wc:kind><description>I knew the moment I thought about potentially adding variable definition blocks as a first-class feature Glazier it was going to bother me until I went about implementing it. So, as expected, a week later, here we are. I am happy to report that I&amp;rsquo;ve maintained my spotless record of not talking myself out of something tricky.
So, over the course of the past few days Glazier learned a new trick. Profiles can now declare the variables they accept, give them primitive types, mark them as required and read them back through a proper var. namespace.</description><content:encoded><![CDATA[<p>I knew the moment I thought about potentially adding variable definition blocks as a first-class feature Glazier it was going to bother me until I went about implementing it. So, as expected, a week later, here we are. I am happy to report that I&rsquo;ve maintained my spotless record of <em>not</em> talking myself out of something tricky.</p>
<p>So, over the course of the past few days Glazier learned a new trick. Profiles can now declare the variables they accept, give them primitive types, mark them as required and read them back through a proper <code>var.</code> namespace.</p>
<h2 id="out-with-the-old">
  <a class="heading-link" href="#out-with-the-old">Out with the old.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Technically speaking, Glazier has had variable support since day one. You could pass <code>--var region=watson</code> and reference <code>${region}</code> anywhere in your profile and that was that. Anything you passed got dumped into a flat namespace and any name you <em>referenced</em>, but forgot to set, quietly resolved to an empty string.</p>
<p>That&rsquo;s fine right up until you fat-finger <code>${reigon}</code> and spend ten minutes wondering why your session name has a hole in it. The whole reason I built this thing was to get Terraform&rsquo;s &ldquo;here is exactly what you did wrong and where&rdquo; experience.</p>
<p>Letting variables fail silently just felt a bit like an own-goal.</p>
<h2 id="profiles-can-now-declare-what-they-accept">
  <a class="heading-link" href="#profiles-can-now-declare-what-they-accept">Profiles can now declare what they accept.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Because this whole thing is kind of a love letter to Terraform&rsquo;s parser, the idea behind variable definitions is functionally the same. You declare a <code>variable</code> block, it sits at the top level next to your session and that&rsquo;s the input you&rsquo;re allowed to pass:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">variable</span> <span class="s2">&#34;district&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;the district the gig is themed after&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  type</span>        <span class="o">=</span> <span class="k">string</span>
</span></span><span class="line"><span class="cl"><span class="n">  default</span>     <span class="o">=</span> <span class="s2">&#34;watson&#34;</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">variable</span> <span class="s2">&#34;fixer&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  type</span> <span class="o">=</span> <span class="k">string</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">session</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;gig-${var.district}&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span> <span class="o">=</span> <span class="s2">&#34;${var.fixer}-ops&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;echo ${var.fixer} has the next job&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>First, you reach a variable through <code>var.</code> and <em>only</em> <code>var.</code> now, so it reads exactly like it would in a <code>.tf</code> file. Second, <code>district</code> has a default and <code>fixer</code> doesn&rsquo;t, which turns out to matter quite a lot in a second.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> glaze up --var <span class="nv">fixer</span><span class="o">=</span>wakako <span class="c1"># district falls back to &#34;watson&#34;</span>
</span></span><span class="line"><span class="cl"><span class="gp">$</span> glaze up --var <span class="nv">district</span><span class="o">=</span>arasaka --var <span class="nv">fixer</span><span class="o">=</span>wakako
</span></span></code></pre></div>
</div>
<p>So, here you are using the same templated profile to define two seperate sessions decided entirely by flags.</p>
<h2 id="pass-it-something-it-doesnt-know-and-it-says-so">
  <a class="heading-link" href="#pass-it-something-it-doesnt-know-and-it-says-so">Pass it something it doesn&rsquo;t know and it says so.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>If you pass a <code>--var</code> for a variable the profile never declared, you no longer get a silent shrug. You get a proper error:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Error: Undefined variable
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">A value for &#34;ghost&#34; was passed with --var, but the profile declares no
</span></span><span class="line"><span class="cl">variable &#34;ghost&#34;. Add a variable &#34;ghost&#34; {} block, or remove the flag.</span></span></code></pre></div>
</div>
<p>I went back and forth on this one. Being strict about undeclared flags is the sort of thing that feels pedantic until the day it catches a typo you&rsquo;d have otherwise chased for half an hour. In this case strict won.</p>
<p>A <code>--var</code> you can&rsquo;t explain is almost always a mistake, so Glazier now treats it like one.</p>
<h2 id="no-default-means-you-have-to-mean-it">
  <a class="heading-link" href="#no-default-means-you-have-to-mean-it">No default means you have to mean it.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A variable <em>without</em> a default is required. Leave it unset and <code>up</code> refuses to do anything until you supply it:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Error: Missing required variable
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  on .glaze line 6, in variable &#34;fixer&#34;:
</span></span><span class="line"><span class="cl">   6: variable &#34;fixer&#34; {
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">The variable &#34;fixer&#34; has no default, so a value must be supplied with
</span></span><span class="line"><span class="cl">--var fixer=&lt;value&gt;.</span></span></code></pre></div>
</div>
<p>This is the bit that makes a profile a genuine contract rather than a suggestion. If a layout is useless without a fixer, declare <code>fixer</code> with no default and the tool will make absolutely sure you provided one before it touches tmux.</p>
<h2 id="proper-type-checking">
  <a class="heading-link" href="#proper-type-checking">Proper type-checking.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Every variable declares a <code>type</code>. Use one of the bare keywords <code>string</code>, <code>number</code> or <code>bool</code>. The value you hand over on the command line is coerced into that type and rejected if it doesn&rsquo;t fit:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">variable</span> <span class="s2">&#34;base_index&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  type</span>    <span class="o">=</span> <span class="k">number</span>
</span></span><span class="line"><span class="cl"><span class="n">  default</span> <span class="o">=</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ glaze up --var base_index=two
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Error: Invalid variable value
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  on .glaze line 1, in variable &#34;base_index&#34;:
</span></span><span class="line"><span class="cl">   1: variable &#34;base_index&#34; {
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">The value passed for variable &#34;base_index&#34; with --var cannot be used as
</span></span><span class="line"><span class="cl">number: a number is required.</span></span></code></pre></div>
</div>
<p><code>two</code> is not a number, so you find out <em>before</em> anything launches rather than after tmux has already swallowed a garbage option and started behaving oddly. Booleans get the same treatment: <code>true</code> and <code>false</code> are fine, anything else is shown the door. Unfortunately, I decided not to go with truthy or falsey value coercion.</p>
<p>I kept it to the three primitives on purpose. The moment you add <code>list(string)</code> and friends you&rsquo;re also signing up for coercing comma-soup off the command line into nested types. That&rsquo;s a much bigger can of worms than a tmux helper needs to open today.</p>
<p>Though, obviously, if you disagree you&rsquo;re more than welcome to leave a comment below!</p>
<h2 id="a-namespace-for-everything">
  <a class="heading-link" href="#a-namespace-for-everything">A namespace for everything.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I&rsquo;ll be honest about the consequence here. This is a breaking change and pretending otherwise would be rude. The old flat <code>${region}</code> style is gone. If a value comes from a declared variable, you read it as <code>var.region</code>.</p>
<p>Which raised a genuinely interesting design question I sat with for a while. If variables are only ever used <em>inside</em> a session, why not declare them inside the session block too? It feels more intuitive at first though, but it&rsquo;s also wrong.</p>
<p>However you nest the declaration, the <em>reference</em> is still flat and global; you write <code>var.fixer</code>, never <code>session.var.fixer</code>. Declaring something file-global but reading it block-scoped is incoherent and a variable is really an input to the <em>invocation</em>, sitting conceptually above the session and not a property of it. So they live at the top level, exactly where Terraform puts them. I borrowed both the syntax and the reasoning.</p>
<h2 id="where-did-the-environment-variables-go">
  <a class="heading-link" href="#where-did-the-environment-variables-go">Where did the environment variables go?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>They also got their own namespace. Previously <code>GLAZE_ENV_token</code> showed up as a bare <code>${token}</code>, sharing the same flat namespace as everything else. Now it lives under an <code>env.</code> namespace, which makes the three sources nicely symmetric:</p>
<ul>
<li><code>var.*</code> for variables you declared and passed with <code>--var</code></li>
<li><code>env.*</code> for <code>GLAZE_ENV_*</code> environment variables</li>
<li><code>path.pwd</code> &amp; <code>path.base</code> the unchanged built-ins</li>
</ul>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">session</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;host-${env.box}&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {}
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> <span class="nv">GLAZE_ENV_box</span><span class="o">=</span>nightcity glaze up
</span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">session = host-nightcity</span></span></code></pre></div>
</div>
<p>There&rsquo;s no declaration needed for <code>env.*</code>; the environment is the environment. Only the things you pass with <code>--var</code> have to be declared, because those are the inputs <em>you</em> are claiming the profile accepts.</p>
<h2 id="tearing-down-a-session">
  <a class="heading-link" href="#tearing-down-a-session">Tearing down a session.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>If a required variable now blocks <code>up</code>, doesn&rsquo;t that wreck <code>glaze down</code>? I recently taught the <code>down</code> command to evaluate <em>only</em> the session name so it doesn&rsquo;t need every variable buried deep in the profile just to kill a session. Surely required variables undo that?</p>
<p>They don&rsquo;t and this was the fiddly bit to get right. <code>down</code> resolves variables leniently. A variable used solely in some pane command, far away from the session name, is neither required nor evaluated when you&rsquo;re tearing things down:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">variable</span> <span class="s2">&#34;beep&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  type</span> <span class="o">=</span> <span class="k">string</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">session</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;daemon-run&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;echo ${var.beep}&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> glaze down
</span></span><span class="line"><span class="cl"><span class="go">INF nothing to do; session is not running session=daemon-run
</span></span></span></code></pre></div>
</div>
<p>If <code>beep</code> isn&rsquo;t provided, the command just accepts it and moves on, because <code>down</code> never needed it. <code>up</code> enforces the full contract; <code>down</code> only asks for what the name actually depends on. Obviously, the exception here is if a variable is used to construct the actual session name. Other than that, this is an idempotent no-op.</p>
<h2 id="validation-has-been-updated-too">
  <a class="heading-link" href="#validation-has-been-updated-too">Validation has been updated too.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p><code>glaze format --validate</code> decodes a profile and reports diagnostics without ever touching tmux and it now enforces the <em>entire</em> variable contract. Miss a required variable and validation fails right there:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> glaze format --validate
</span></span><span class="line"><span class="cl"><span class="go">Error: Missing required variable
</span></span></span><span class="line"><span class="cl"><span class="go">  ...
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="gp">$</span> glaze format --validate --var <span class="nv">region</span><span class="o">=</span>us-east-1
</span></span><span class="line"><span class="cl"><span class="gp">#</span> clean. nothing to report.
</span></span></code></pre></div>
</div>
<p>So, you can lint a parameterised profile in CI, or just before committing it and know it holds together before you ever try to bring it up.</p>
<h2 id="this-literally-broke-all-my-current-profiles">
  <a class="heading-link" href="#this-literally-broke-all-my-current-profiles">This literally broke all my current profiles.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>And I regret nothing. The flat-namespace migration touched pretty much everything; sample profiles, the docs, a pile of tests and all the profiles I actually use day to day. Normally this is the part of a post where I&rsquo;d sketch out a careful migration path and apologise profusely.</p>
<p>But, the entire userbase of this tool is almost, as far as I can tell, me. So the migration path is &ldquo;I&rsquo;ll fix all my profiles tomorrow morning over coffee.&rdquo;</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>There is a real freedom in shipping a breaking change to an audience you can fit in a mirror.</p>
  </blockquote>
  <figcaption>
    <cite>it me.</cite>
  </figcaption>
</figure>

<p>To migrate is easy enough, just define your variables and prefix all references with <code>var.</code>. This <em>is</em> still currently in beta after all.</p>
<h2 id="in-closing">
  <a class="heading-link" href="#in-closing">In closing&hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Under the hood this turned into a more interesting refactor than I expected. <code>hcldec</code> wants to decode a whole file body against one spec and it flatly refuses to tolerate a <code>variable</code> block sitting next to the session it doesn&rsquo;t know about. The trick was to stop handing it the whole file, pull the session block out myself and decode <em>just its body</em>, which leaves the variable blocks to be gathered and resolved in their own quiet little pass beforehand. There&rsquo;s a tidy <code>PartialContent</code> implementation in there that was heaps of fun to figure out.</p>
<p>None of which a tmux wrapper strictly needed, of course. But &ldquo;strictly needed&rdquo; stopped being the bar around the time I added a fuzzer. The point was to feel out how Terraform turns a typo into a friendly, located error and now Glazier does a respectable impression of it for variables too.</p>
<p>I&rsquo;m still ironing out all the kinks and ensuring I still have an 80% baseline in testing and code coverage, but I&rsquo;m planning to release this and a few bug fixes and dependency bumps this weekend. Tomorrow-me gets to read this back with fresh eyes and decide how much of it past-me oversold. Very nice.</p>
<p>Until I merge it in, you can play with the latest release <a href="https://github.com/wilhelm-murdoch/glazier">here</a>.</p>
<p>And now I&rsquo;m thinking about adding <code>*.tfvars</code>-like functionality. Surely, I won&rsquo;t fall for this again?</p>
]]></content:encoded></item><item><title>Why I Built Glazier</title><link>https://wilhelm.codes/blog/why-i-built-glazier/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/why-i-built-glazier/</guid><category>go</category><category>tmux</category><category>hcl</category><category>glazier</category><wc:kind>post</wc:kind><description>I live in tmux. I typically have an editor here, a dev server there, logs tailing in the corner or a spare pane for poking at things. The trouble is that this little world is frustratingly ephemeral. Rebooting my machine ( thank you, compuslory MacOS updates ), kill the wrong session or just close the laptop lid for too long and it can all evaporate. Then, I&amp;rsquo;m back to rebuilding the same layout by hand, one split-window at a time, like some kind of animal.</description><content:encoded><![CDATA[<p>I live in <a href="https://github.com/tmux/tmux/wiki">tmux</a>. I typically have an editor here, a dev server there, logs tailing in the corner or a spare pane for poking at things. The trouble is that this little world is frustratingly ephemeral. Rebooting my machine ( thank you, compuslory MacOS updates ), kill the wrong session or just close the laptop lid for too long and it can all evaporate. Then, I&rsquo;m back to rebuilding the same layout by hand, one <code>split-window</code> at a time, like some kind of animal.</p>
<p>So I built <a href="https://github.com/wilhelm-murdoch/glazier">Glazier</a>; a small command-line tool that lets me describe a tmux workspace once and recreate it on demand. Type <code>glaze up</code> and the sessions, windows and panes I described spring back into existence exactly how I left them.</p>
<p>This has been a slow-burning labour of love for the better part of two years and it&rsquo;s finally in a state where I feel comfortable letting other people look at it. So let&rsquo;s talk about why it exists, what else is out there and how this one is different.</p>
<h2 id="its-just-a-config-file-right">
  <a class="heading-link" href="#its-just-a-config-file-right">It&rsquo;s just a config file, right?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>That was the idea, at least. I just wanted to stop rebuilding the same layouts over and over. But, as is tradition, I didn&rsquo;t want to make it <em>too</em> easy for myself.</p>
<p>There was a second, more selfish motivation. As a platform engineer, there isn&rsquo;t a day that goes by where I don&rsquo;t work with <a href="https://www.terraform.io/">Terraform</a>. I&rsquo;ve always been quietly fascinated by how it parses and validates its configuration. That whole experience of getting a precise, friendly error pointing at the exact line you fat-fingered, rather than a stack trace and a 🖕. I wanted to understand how that machinery actually worked.</p>
<p>So, I&rsquo;m a heavy tmux user <em>and</em> I wanted to learn HCL parsing from the inside. These two things lined up a little too perfectly. So Glazier&rsquo;s profiles aren&rsquo;t YAML; they&rsquo;re <a href="https://github.com/hashicorp/hcl">HCL</a>. The same configuration language Terraform uses and parsed with the same underlying library.</p>
<p>Here&rsquo;s a basic profile:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">session</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;daemon-run&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span>   <span class="o">=</span> <span class="s2">&#34;ice-breaker&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    layout</span> <span class="o">=</span> <span class="s2">&#34;main-vertical&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;nvim ./payloads&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;watch -n1 netwatch --target arasaka-mainframe&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Drop that in a file called <code>.glaze</code>, run <code>glaze up</code> next to it and you&rsquo;re jacked in.</p>
<h2 id="im-not-the-first-to-do-this-not-even-the-3rd-or-the-4th">
  <a class="heading-link" href="#im-not-the-first-to-do-this-not-even-the-3rd-or-the-4th">I&rsquo;m not the first to do this; not even the 3rd&hellip; or the 4th.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This is a thoroughly-solved problem and I&rsquo;d be doing you a disservice if I pretended otherwise. There&rsquo;s a whole shelf of mature, battle-tested tools that do effectively the same thing:</p>
<ul>
<li><a href="https://github.com/tmuxinator/tmuxinator">tmuxinator</a> is the one most are familiar with. Written in Ruby with YAML profiles. Probably what most people reach for.</li>
<li><a href="https://github.com/remi/teamocil">teamocil</a> is also written Ruby; also uses YAML.</li>
<li><a href="https://github.com/ivaaaan/smug">smug</a> is written in Go and uses YAML and is the closest in spirit to Glazier if we&rsquo;re being honest.</li>
<li><a href="https://github.com/tmux-python/tmuxp">tmuxp</a> is written in good&rsquo;ole reliable Python and it&rsquo;ll happily eat YAML <em>or</em> JSON.</li>
</ul>
<p>If you already use and trust one of these, I&rsquo;ll be straight with you: there isn&rsquo;t a compelling reason to switch. Keep using what works. I&rsquo;m not here to convince anyone to rip out a tool they&rsquo;re happy with.</p>
<p>But if you&rsquo;re still reading, here&rsquo;s what <em>I</em> like about mine.</p>
<h2 id="so-whats-actually-different">
  <a class="heading-link" href="#so-whats-actually-different">So what&rsquo;s actually different?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<h3 id="the-profile-validates-itself">
  <a class="heading-link" href="#the-profile-validates-itself">The profile validates itself!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>This is the part I set out to build, so it&rsquo;s the part I&rsquo;m fondest of. Because Glazier is built on HCL, it inherits Terraform-style diagnostics for free. Mistype a layout, point a starting directory at somewhere that doesn&rsquo;t exist or forget a required block and you don&rsquo;t get a vague &ldquo;something went wrong fuck you&rdquo;. You get told exactly what and where you messed up:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Error: Invalid layout specified
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  on .glaze line 4, in session.window:
</span></span><span class="line"><span class="cl">   4:     layout = &#34;main-plumbus&#34;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">The layout value of &#34;main-plumbus&#34; is not a supported preset
</span></span><span class="line"><span class="cl">(even-horizontal, even-vertical, main-horizontal, main-vertical,
</span></span><span class="line"><span class="cl">tiled) nor a valid tmux layout string.</span></span></code></pre></div>
</div>
<p>There&rsquo;s a <code>glaze format</code> command that rewrites your profile into a canonical style and, with <code>--validate</code>, reports any of these diagnostics without touching tmux at all. Both of these scratch exactly the itch that started the whole project.</p>
<h3 id="variables-templates-and-string-functions-oh-my">
  <a class="heading-link" href="#variables-templates-and-string-functions-oh-my">Variables, templates and string functions! Oh, my!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>A static layout is useful. A <em>templated</em> one is better. Profiles can reference variables and you can feed those in from <code>--var</code> flags or <code>GLAZE_ENV_*</code> environment variables:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">session</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;ops-${region}&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  starting_directory</span> <span class="o">=</span> <span class="k">path</span><span class="p">.</span><span class="k">pwd</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span> <span class="o">=</span> <span class="k">upper</span><span class="p">(</span><span class="k">region</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;k9s --context ${region}&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> glaze up --var <span class="nv">region</span><span class="o">=</span>ap-southeast-2
</span></span></code></pre></div>
</div>
<p>Look familiar? If you work with Terraform it does!</p>
<p>There&rsquo;s a handful of built-in string functions too. <code>upper</code>, <code>lower</code>, <code>replace</code>, <code>trimspace</code>, <code>join</code> and friends which act as thin wrappers over the same <code>go-cty</code> standard library Terraform uses. Plus, a couple of freebies like <code>path.pwd</code> and <code>path.base</code> so a profile can adapt to wherever it&rsquo;s run from.</p>
<h3 id="it-doesnt-lie-to-you-about-timing">
  <a class="heading-link" href="#it-doesnt-lie-to-you-about-timing">It doesn&rsquo;t lie to you about timing.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>This is the bit of engineering I&rsquo;m quietly proudest of, even though nobody will ever see it. When you fire a sequence of commands into a tmux pane, the naive approach is to blast them in with a <code>sleep</code> between each one and hope the previous command finished. That&rsquo;s flaky and it&rsquo;s how a few other tools handle it.</p>
<p>Glazier instead serialises commands through tmux&rsquo;s own <a href="https://man.openbsd.org/tmux#wait-for"><code>wait-for</code></a> signalling, so each command genuinely waits for the previous one to finish before the next is sent. There are no fixed sleeps and no races. The <em>one</em> exception is the final command in a list, which is sent fire-and-forget. If your last command is a long-running dev server or <code>tail -f</code>, waiting on it would hang <code>up</code> forever.</p>
<p>I learned that last part the hard way.</p>
<h3 id="it-can-mostly-save-a-session">
  <a class="heading-link" href="#it-can-mostly-save-a-session">It can <em>mostly</em> save a session!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Run <code>glaze save</code> inside a live session and it&rsquo;ll capture the structure back out into a profile: windows, panes, names, layouts, focus and starting directories. The &ldquo;mostly&rdquo; is doing some load-bearing work in that sentence and it&rsquo;s a deliberate choice, not a missing feature.</p>
<p><code>save</code> will <strong>not</strong> export your pane commands, environment variables, hooks or tmux options. Why? Because each of those is a footgun:</p>
<ul>
<li><strong>Commands</strong> would re-execute on the next <code>up</code>. A forgotten <code>rm -rf</code> captured from some pane could ruin your whole day on replay.</li>
<li><strong>Environment variables</strong> can only be read as the <em>entire</em> session environment. This means inherited secrets, tokens and keys getting written into a file you might commit. That&rsquo;s a big fat no from me, dawg.</li>
<li><strong>Options</strong> read back as effective state, hopelessly tangling up with your <code>tmux.conf</code> and manual tweaks.</li>
</ul>
<p>So, a saved profile is a scaffold. It gets the geometry right and you add back the commands and config you actually want by hand.</p>
<h2 id="a-real-profile">
  <a class="heading-link" href="#a-real-profile">A real profile.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Enough talk. Here&rsquo;s the profile I actually use when working this website:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">session</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="k">path</span><span class="p">.</span><span class="k">base</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  envs</span> <span class="o">=</span> {
</span></span><span class="line"><span class="cl"><span class="n">    HUGO_GITHUB_TOKEN</span>        <span class="o">=</span> <span class="s2">&#34;${github}&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    HUGO_ACTIVITY_GRAPH_DEMO</span> <span class="o">=</span> <span class="s2">&#34;${demo}&#34;</span>
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span>  <span class="o">=</span> <span class="s2">&#34;editor&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;nvim&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span>  <span class="o">=</span> <span class="s2">&#34;terminal&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    focus</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {}
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span> <span class="o">=</span> <span class="s2">&#34;server&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;hugo server --disableFastRender&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">window</span> {
</span></span><span class="line"><span class="cl"><span class="n">    name</span> <span class="o">=</span> <span class="s2">&#34;git&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">pane</span> {
</span></span><span class="line"><span class="cl"><span class="n">      commands</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;lazygit&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Running <code>glaze up --var github=*** --var demo=true</code> gives me the following windows:</p>
<ul>
<li>My editor of choice; NeoVim.</li>
<li>A dedicated terminal session.</li>
<li>The Hugo server along with some <code>HUGO_</code> specific environment variables; values sourced from the CLI.</li>
<li>Finally, <code>lazygit</code> to manage and commit my changes.</li>
</ul>
<p>Further, the <code>--var</code> arguments aren&rsquo;t really necessary as you could just <code>export GLAZE_var_name</code> as well and Glazier will pick it up automatically.</p>
<p>Anyways, because the session is just a named thing, the rest of the lifecycle is tidy too. List what&rsquo;s running with <code>glaze ls</code>:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> glaze ls
</span></span><span class="line"><span class="cl"><span class="go">NAME      WINDOWS  PATH
</span></span></span><span class="line"><span class="cl"><span class="go">glazier*  2        /home/wilhelm/Development/wilhelm.codes
</span></span></span><span class="line"><span class="cl"><span class="go">scratch   1        /tmp
</span></span></span></code></pre></div>
</div>
<p>The asterisk marks the session I&rsquo;m currently attached to. When I&rsquo;m done, I tear it down by profile:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> glaze down
</span></span></code></pre></div>
</div>
<p><code>down</code> is idempotent and that&rsquo;s on purpose. Bringing down a session that isn&rsquo;t running is a no-op, not an error, so it&rsquo;s safe to drop in scripts without defensive checks.</p>
<h2 id="some-over-engineered-bits-">
  <a class="heading-link" href="#some-over-engineered-bits-">Some over-engineered bits &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>To be frank a tool that shells out to tmux did not strictly <em>need</em> a fuzzed HCL parser, build provenance attestations on its release binaries or a CI pipeline that runs the test suite against multiple Go versions and operating systems.</p>
<p>But, that was never really the point. The point was learning how Terraform&rsquo;s parser ticks, working out how to drive tmux reliably without sleeps and over-engineering the ever-loving-shit out of an already-solved problem because it was <em>fun</em>. Every constraint I imposed upon myself taught me something new. This is ultimately the only metric I actually care about for a hobby project like this.</p>
<h2 id="where-to-get-it">
  <a class="heading-link" href="#where-to-get-it">Where to get it?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>It&rsquo;s <a href="https://github.com/wilhelm-murdoch/glazier">up on GitHub</a> with a shiny MIT license. If you&rsquo;ve got Go installed:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Console</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> go install github.com/wilhelm-murdoch/glazier/cmd/glaze@latest
</span></span></code></pre></div>
</div>
<p>Or, grab a prebuilt binary from the <a href="https://github.com/wilhelm-murdoch/glazier/releases">releases page</a>. I currently build for Linux and macOS on both <code>amd64</code> and <code>arm64</code>. Each one ships with a checksum and a signed provenance attestation, because of course it does.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I would confidently say Glazier has moved on from &ldquo;experimental&rdquo; to &ldquo;stable&rdquo;. It works and I use it every single day. But, there are rough edges and there may be any number of unencountered failure modes. The <code>down</code> and <code>ls</code> commands only landed recently and I&rsquo;m not sure if the latter should remain. I&rsquo;ve also got a running list of ideas I haven&rsquo;t talked myself out of yet. Like, something similar to Terraform&rsquo;s <code>*.tfvars</code> files or defining typed variables.</p>
<p>If you&rsquo;re already happy with Tmuxinator or Smug, then stick with them. But if the idea of a declarative, self-validating, slightly-too-clever tmux profile appeals to you, or you just want to read some Go that wraps tmux in ways it was probably never meant to be wrapped, I&rsquo;d love for you to take it for a spin.</p>
<p>I sincerely hope you find <a href="https://github.com/wilhelm-murdoch/glazier">Glazier</a> as useful as I had fun building it.</p>]]></content:encoded></item><item><title>Let Me Show You My Bits</title><link>https://wilhelm.codes/blog/let-me-show-you-my-bits/</link><pubDate>Thu, 18 Jun 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/let-me-show-you-my-bits/</guid><category>hugo</category><category>css</category><category>tailwindcss</category><category>wilhelm.codes</category><wc:kind>post</wc:kind><description>A while back, in Reading Between the Posts, I wired up the home page so the quiet gaps between articles would narrate themselves - &amp;ldquo;1 bit and 41 changelog events in between&amp;rdquo;. There was just one tiny problem with that sentence. At the time I had written exactly zero bits.</description><content:encoded><![CDATA[<p>A while back, in <a href="https://wilhelm.codes/blog/reading-between-the-posts/">Reading Between the Posts</a>, I wired up the home page so the quiet gaps between articles would narrate themselves - &ldquo;<em>1 bit and 41 changelog events in between</em>&rdquo;. There was just one tiny problem with that sentence. At the time I had written exactly <em>zero</em> bits.</p>
<p>So let me finally show you my bits.</p>
<h2 id="what-even-is-a-bit">
  <a class="heading-link" href="#what-even-is-a-bit">What even is a bit?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A bit is the smallest unit of &ldquo;I made a thing&rdquo; I&rsquo;m willing to commit to. It could be a quote that stuck with me, a link worth keeping, the odd YouTube video, or a one-line thought too small to earn its own long-form entry. Each one is a little markdown file in <code>/bits</code> and  the only thing it really has to declare is what <em>kind</em> of bit it is:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">YAML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">quote</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">date</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-06-17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">source</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Edsger Dijkstra&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;Simplicity is a great virtue but it requires hard work to achieve it.&#34;</span></span></span></code></pre></div>
</div>
<p><code>type</code> is the whole trick that everything else hangs off.</p>
<h2 id="one-shape-per-thought">
  <a class="heading-link" href="#one-shape-per-thought">One shape per thought.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The thing I didn&rsquo;t want was for a pithy quote to render like a bare link or to render like a video. They&rsquo;re different shapes of thought, so they ought to look different.</p>
<p>I&rsquo;d already learned this lesson building the <a href="https://wilhelm.codes/blog/a-changelog-that-builds-itself/">changelog</a>, which leans on one tiny partial per event type. So I stole from myself. There&rsquo;s a single lookup table mapping each <code>type</code> to an icon, an accent colour and a label:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">meta</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">dict</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;quote&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;icon&#34;</span><span class="w"> </span><span class="s">&#34;quote-right&#34;</span><span class="w">    </span><span class="s">&#34;colour&#34;</span><span class="w"> </span><span class="s">&#34;rose&#34;</span><span class="w">    </span><span class="s">&#34;label&#34;</span><span class="w"> </span><span class="s">&#34;quote&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;link&#34;</span><span class="w">  </span><span class="p">(</span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;icon&#34;</span><span class="w"> </span><span class="s">&#34;arrow-up-right&#34;</span><span class="w"> </span><span class="s">&#34;colour&#34;</span><span class="w"> </span><span class="s">&#34;blue&#34;</span><span class="w">    </span><span class="s">&#34;label&#34;</span><span class="w"> </span><span class="s">&#34;link&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;video&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;icon&#34;</span><span class="w"> </span><span class="s">&#34;youtube&#34;</span><span class="w">        </span><span class="s">&#34;colour&#34;</span><span class="w"> </span><span class="s">&#34;red&#34;</span><span class="w">     </span><span class="s">&#34;label&#34;</span><span class="w"> </span><span class="s">&#34;video&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;blurb&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;icon&#34;</span><span class="w"> </span><span class="s">&#34;pen-nib&#34;</span><span class="w">        </span><span class="s">&#34;colour&#34;</span><span class="w"> </span><span class="s">&#34;emerald&#34;</span><span class="w"> </span><span class="s">&#34;label&#34;</span><span class="w"> </span><span class="s">&#34;blurb&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>It then matches a micro-partial per type that decides how the content actually renders. Adding a brand new kind of bit is now &ldquo;add one row, add one tiny template&rdquo;, which is about as many steps as I&rsquo;m willing to tolerate.</p>
<p><img
  src="https://wilhelm.codes/blog/let-me-show-you-my-bits/bits_hu_88740d4c34822745.webp"
  srcset="/blog/let-me-show-you-my-bits/bits_hu_88740d4c34822745.webp 736w, /blog/let-me-show-you-my-bits/bits_hu_7c64b843d8098172.webp 1104w, /blog/let-me-show-you-my-bits/bits_hu_321d663198ba2c25.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="596"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="hugo-why-are-you-like-this">
  <a class="heading-link" href="#hugo-why-are-you-like-this">Hugo, why are you like this?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>My bits started life with two perfectly reasonable front-matter fields: <code>type</code> and <code>url</code>. Hugo took one look and quietly robbed me of both.</p>
<p>It turns out <code>url</code> is a <em>reserved</em> key which Hugo uses to treat as the page&rsquo;s permalink. So, the moment I put a <code>https://...</code> in there, the build fell over with a stern little message about unsupported protocols. <code>type</code> doesn&rsquo;t even have the decency to error. It silently moves out of <code>.Params.type</code> and into <code>.Type</code>, which <em>also</em> hijacks the layout lookup. So my <code>.Params.type</code> was resolving to nothing and every single bit was quietly rendering as the fallback.</p>
<p>Turns out hte fix for this was simply to rename <code>url</code> to <code>link</code> and  read <code>.Type</code> instead of <code>.Params.type</code>. Half an hour of my life dedicated to two words I didn&rsquo;t know were already spoken for.</p>
<p>Coding is my passion.</p>
<h2 id="bits-flapping-in-the-wind">
  <a class="heading-link" href="#bits-flapping-in-the-wind">Bits flapping in the wind.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Rather than dumping bits into the same fold-out list as the GitHub noise, I pulled them out as their own first-class timeline rows. The lower-signal changelog chatter stays tucked behind the &ldquo;<em>N changelog events in between</em>&rdquo; disclosure. So, the home page now reads in three tiers:</p>
<ul>
<li>the loud headline articles</li>
<li>the medium-volume bits</li>
<li>the quiet changelog hum</li>
</ul>
<p>The same interstitial machinery I built last time, but bits just get to stand a little taller than a commit.</p>
<p><img
  src="https://wilhelm.codes/blog/let-me-show-you-my-bits/home_hu_e7f8fe2d00bce999.webp"
  srcset="/blog/let-me-show-you-my-bits/home_hu_e7f8fe2d00bce999.webp 736w, /blog/let-me-show-you-my-bits/home_hu_bf5b90ae24a2b65.webp 1104w, /blog/let-me-show-you-my-bits/home_hu_62fda724f7b72c05.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="310"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="the-tag-that-went-nowhere">
  <a class="heading-link" href="#the-tag-that-went-nowhere">The tag that went nowhere.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Bits are deliberately <code>render: never</code> in Hugo. They live inline on the bits page and never get their own URL. Except &ldquo;no page&rdquo; also quietly means &ldquo;no place in the taxonomy&rdquo;, which means a tag only ever used by a bit has no <code>/tags/...</code> page to point at. If I link it anyway I&rsquo;ve shipped a <code>404</code>.</p>
<p>So, the tags only become links if there&rsquo;s actually somewhere to go:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="nx">site</span><span class="p">.</span><span class="nf">GetPage</span><span class="w"> </span><span class="p">(</span><span class="nx">printf</span><span class="w"> </span><span class="s">&#34;/tags/%s&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">urlize</span><span class="w"> </span><span class="p">.))</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">&lt;</span><span class="nx">a</span><span class="w"> </span><span class="nx">href</span><span class="p">=</span><span class="s">&#34;{{ .RelPermalink }}&#34;</span><span class="p">&gt;</span><span class="err">#</span><span class="p">{{</span><span class="w"> </span><span class="p">.</span><span class="nx">Data</span><span class="p">.</span><span class="nx">Term</span><span class="w"> </span><span class="p">}}&lt;</span><span class="o">/</span><span class="nx">a</span><span class="p">&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">&lt;</span><span class="nx">span</span><span class="p">&gt;</span><span class="err">#</span><span class="p">{{</span><span class="w"> </span><span class="p">.</span><span class="w"> </span><span class="p">}}&lt;</span><span class="o">/</span><span class="nx">span</span><span class="p">&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>A tag with a real page becomes a link and a bit-only tag stays as plain text. No broken links and the nice part is it heals itself the moment a proper article picks up the same tag.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The front page has quietly turned into a proper little stream. Long-form when I&rsquo;ve actually got something to say, bits when I don&rsquo;t and  a <a href="https://wilhelm.codes/blog/a-year-in-circles/">year of squircles</a> up top keeping a tally of it all.</p>
<p>Which is the entire point. The less effort it takes to post a half-formed thought, the more likely I am to actually post it. And the more I post, the more those gaps fill in.</p>
<p>And, as always, you can check everything out for yourself in the <a href="https://github.com/wilhelm-murdoch/wilhelm.codes">repo</a>.</p>]]></content:encoded></item><item><title>Reading Between the Posts</title><link>https://wilhelm.codes/blog/reading-between-the-posts/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/reading-between-the-posts/</guid><category>hugo</category><category>css</category><wc:kind>post</wc:kind><description>The front page of this site has always been a plain list of articles. Which would be fine if I wrote longer-form articles a bit more often. I do, however, make code commits to this site far more frequently. I&amp;rsquo;m also working on implementing a short-form section ( a.k.a. &amp;ldquo;bits&amp;rdquo; ) for links, videos and other small low-stakes thoughts. So the front page doesn&amp;rsquo;t really show the full story. In reality, there&amp;rsquo;s a lot more going on behind the scenes.</description><content:encoded><![CDATA[<p>The front page of this site has always been a plain list of articles. Which would be fine if I wrote longer-form articles a bit more often. I do, however, make code commits to this site far more frequently. I&rsquo;m also working on implementing a short-form section ( a.k.a. &ldquo;bits&rdquo; ) for links, videos and other small low-stakes thoughts. So the front page doesn&rsquo;t really show the full story. In reality, there&rsquo;s a lot more going on behind the scenes.</p>
<p>I want to fill those gaps in and not with more full-fat rows, but with a small line between each pair of articles. Something like &ldquo;<em>1 bit and 41 changelog events in between</em>&rdquo;; a kind of interstitial event timeline for all the work that never quite became a full blog post.</p>
<h2 id="why-cant-i-hold-all-these-events">
  <a class="heading-link" href="#why-cant-i-hold-all-these-events">Why can&rsquo;t I hold all these events?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The trick to merging things that don&rsquo;t look alike&hellip; You&rsquo;re never going to believe this&hellip; is to make them look alike. I already pull my GitHub activity for the <a href="https://wilhelm.codes/blog/a-changelog-that-builds-itself/">changelog</a>, or, at least, the last 100 or so events. So, I reused that, threw the bits in alongside it, and normalised the whole lot into one boring, uniform shape. This data structure doesn&rsquo;t hold all the data for each event, but just enough to cover the highlights.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">events</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="err">$</span><span class="nx">events</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">append</span><span class="w"> </span><span class="p">(</span><span class="nx">dict</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;kind&#34;</span><span class="w">  </span><span class="s">&#34;commit&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;date&#34;</span><span class="w">  </span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">AsTime</span><span class="w"> </span><span class="p">.</span><span class="nx">commit</span><span class="p">.</span><span class="nx">author</span><span class="p">.</span><span class="nx">date</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;title&#34;</span><span class="w"> </span><span class="p">(</span><span class="nf">index</span><span class="w"> </span><span class="p">(</span><span class="nx">split</span><span class="w"> </span><span class="p">.</span><span class="nx">commit</span><span class="p">.</span><span class="nx">message</span><span class="w"> </span><span class="s">&#34;\n&#34;</span><span class="p">)</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;url&#34;</span><span class="w">   </span><span class="p">.</span><span class="nx">html_url</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;icon&#34;</span><span class="w">  </span><span class="s">&#34;code-commit&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;colour&#34;</span><span class="w"> </span><span class="s">&#34;green&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="s">&#34;data&#34;</span><span class="w">  </span><span class="p">.)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>Every piece of content on this site has <em>at the very last</em> these attributes. So created a unifying model is pretty straight-forward. A <code>kind</code>, a <code>date</code>, a <code>title</code>, a link, and enough leftover metadata to do something creative with later if I feel like it. I made a point of keeping the original object tucked away in <code>data</code> too; a gift to future Wilhelm.</p>
<h2 id="minding-the-gaps">
  <a class="heading-link" href="#minding-the-gaps">Minding the gaps.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>With one big sorted pile of events, the rest is just bookkeeping. Walk the articles newest to oldest, and for each one work out the window between it and the article above it. Then, scoop up every event that falls within the relevant dates:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">upper</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="err">$</span><span class="nx">now</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nx">gt</span><span class="w"> </span><span class="err">$</span><span class="nx">i</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">}}{{</span><span class="w"> </span><span class="err">$</span><span class="nx">upper</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">(</span><span class="nx">index</span><span class="w"> </span><span class="err">$</span><span class="nf">articles</span><span class="w"> </span><span class="p">(</span><span class="nx">sub</span><span class="w"> </span><span class="err">$</span><span class="nx">i</span><span class="w"> </span><span class="mi">1</span><span class="p">)).</span><span class="nx">Date</span><span class="w"> </span><span class="p">}}{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="err">$</span><span class="nx">e</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="err">$</span><span class="nx">events</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nf">and</span><span class="w"> </span><span class="p">(</span><span class="err">$</span><span class="nx">e</span><span class="p">.</span><span class="nx">date</span><span class="p">.</span><span class="nx">After</span><span class="w"> </span><span class="err">$</span><span class="nx">article</span><span class="p">.</span><span class="nx">Date</span><span class="p">)</span><span class="w"> </span><span class="p">(</span><span class="nf">not</span><span class="w"> </span><span class="p">(</span><span class="err">$</span><span class="nx">e</span><span class="p">.</span><span class="nx">date</span><span class="p">.</span><span class="nx">After</span><span class="w"> </span><span class="err">$</span><span class="nx">upper</span><span class="p">))</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">group</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="err">$</span><span class="nx">group</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">append</span><span class="w"> </span><span class="err">$</span><span class="nx">e</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>The newest article&rsquo;s &ldquo;window&rdquo; runs all the way up to <em>now</em>, so whatever I&rsquo;ve been doing since the last post shows up at the very top. If there&rsquo;s an empty gap the event list doesn&rsquo;t render.</p>
<p><img
  src="https://wilhelm.codes/blog/reading-between-the-posts/interstitial-events-top_hu_213053be54f9122c.webp"
  srcset="/blog/reading-between-the-posts/interstitial-events-top_hu_213053be54f9122c.webp 736w, /blog/reading-between-the-posts/interstitial-events-top_hu_f4bb42f82050343a.webp 1104w, /blog/reading-between-the-posts/interstitial-events-top_hu_f4fd9da2a1500826.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="155"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="listing-the-interstitial-events">
  <a class="heading-link" href="#listing-the-interstitial-events">Listing the interstitial events.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A summary is nice, but I wanted to be able to actually <em>see</em> the events without leaving the page. Normally that&rsquo;s where a pile of JS shows up. But, just as with the activity graph, we&rsquo;re going to rely on pure HTML and CSS. The <code>&lt;details&gt;</code> element does the entire job for free:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HTML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">details</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;group&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">summary</span><span class="p">&gt;</span>... 41 changelog events in between ...<span class="p">&lt;/</span><span class="nt">summary</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">ul</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="c">&lt;!-- one little row per event, complete with icon and link --&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;/</span><span class="nt">ul</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">details</span><span class="p">&gt;</span></span></span></code></pre></div>
</div>
<p>Click the summary, the gap unfolds into a tidy little list of every commit, PR and bit. Each linked off to wherever it lives. The default disclosure triangle gets binned and replaced with a chevron that flips on open, courtesy of Tailwind&rsquo;s <code>group-open:</code> variant. Still no JS and I&rsquo;ll remain quietly smug about that.</p>
<p><img
  src="https://wilhelm.codes/blog/reading-between-the-posts/interstitial-events-timeline_hu_90a890e91eb37f0a.webp"
  srcset="/blog/reading-between-the-posts/interstitial-events-timeline_hu_90a890e91eb37f0a.webp 736w, /blog/reading-between-the-posts/interstitial-events-timeline_hu_dc4ebb376786fef.webp 1104w, /blog/reading-between-the-posts/interstitial-events-timeline_hu_7197e16fbb375629.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="262"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="saving-myself-a-small-headache">
  <a class="heading-link" href="#saving-myself-a-small-headache">Saving myself a small headache.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Finally, there&rsquo;s the the responsive question. This site&rsquo;s overall design isn&rsquo;t exactly &ldquo;mobile first&rdquo; and viewing it on a small screen strips away a lot of detail already. All these thin dividers and fold-out lists look great on a wide screen and turn into a cramped mess on a phone.</p>
<p>I thought about it for a while and weighed up some clever reflowing. In the end I did the only sensible thing and hid the whole timeline below the <code>sm</code> breakpoint. On a phone you get the clean list of articles, but on a desktop you get the full story between them.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Sometimes the right amount of effort is zero and the discipline is in admitting it.</p>
  </blockquote>
  <figcaption>
    <cite>Me. Wilhelm. I said that.</cite>
  </figcaption>
</figure>

<p>That being said, I do need to return and focus on making the mobile experience a bit more palatable.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>What I like about this is that it turns my worst blogging habit - vanishing for months and then resurfacing in a manic flurry - into something the site can actually narrate. The big rows are the headlines while the quiet lines between them are everything else.</p>
<p>It&rsquo;s also wired up so I can grow it later without unpicking anything. Right now it&rsquo;s counts and lists, but tomorrow it could filters, more details, or do something I haven&rsquo;t thought of yet. All off the same pile of events.</p>
<p>For now, though, the gaps have something interesting in them. Which is a much nicer thing to scroll past than a year of silence.</p>]]></content:encoded></item><item><title>A Year in Circles... I mean Squares. Or, is it Squircles?</title><link>https://wilhelm.codes/blog/a-year-in-circles/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/a-year-in-circles/</guid><category>hugo</category><category>css</category><category>tailwindcss</category><wc:kind>post</wc:kind><description>If you scroll up to the top of the home page, you&amp;rsquo;ll find a pair of little rows of green squircles. It&amp;rsquo;s my own dumbed-down take on GitHub&amp;rsquo;s contribution graph. Effectively, it&amp;rsquo;s the same idea, except instead of a year of days it&amp;rsquo;s a year of weeks. Two rows of twenty-six with one squircle per week, with each squircle coloured a deeper shade of green the busier that week was.</description><content:encoded><![CDATA[<p>If you scroll up to the top of the home page, you&rsquo;ll find a pair of little rows of green squircles. It&rsquo;s my own dumbed-down take on GitHub&rsquo;s contribution graph. Effectively, it&rsquo;s the same idea, except instead of a year of days it&rsquo;s a year of <em>weeks</em>. Two rows of twenty-six with one squircle per week, with each squircle coloured a deeper shade of green the busier that week was.</p>
<p>I wanted something that summed up &ldquo;Has Wilhelm actually been doing anything lately?&rdquo; at a glance, without the density of 365 tiny day cells. Fifty-two squircles felt about right. Coarse enough to read across the room, fine enough to still give a picture.</p>
<h2 id="what-counts-as-a-weeks-worth-of-activity">
  <a class="heading-link" href="#what-counts-as-a-weeks-worth-of-activity">What counts as a &ldquo;week&rsquo;s worth of activity&rdquo;?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The short answer is <em>everything</em>. The <a href="https://wilhelm.codes/blog/a-changelog-that-builds-itself/">changelog</a> already pulls my GitHub activity at build time, so I reuse that exact same data. Then, I throw in the things GitHub doesn&rsquo;t know about; every type of blog post on this site.</p>
<p>Because the changelog fetching already lives in a tidy little partial, sourcing the everything is just a matter of asking for each one and collecting all the publish dates:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">dates</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">slice</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">partial</span><span class="w"> </span><span class="s">&#34;changelog/fetch.html&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;url&#34;</span><span class="w"> </span><span class="err">$</span><span class="nx">commitsUrl</span><span class="w"> </span><span class="s">&#34;fixture&#34;</span><span class="w"> </span><span class="err">$</span><span class="nx">fixture</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">dates</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="err">$</span><span class="nx">dates</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">append</span><span class="w"> </span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">AsTime</span><span class="w"> </span><span class="p">.</span><span class="nx">commit</span><span class="p">.</span><span class="nx">author</span><span class="p">.</span><span class="nx">date</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">where</span><span class="w"> </span><span class="nx">site</span><span class="p">.</span><span class="nx">RegularPages</span><span class="w"> </span><span class="s">&#34;Section&#34;</span><span class="w"> </span><span class="s">&#34;blog&#34;</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">dates</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="err">$</span><span class="nx">dates</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">append</span><span class="w"> </span><span class="p">.</span><span class="nx">Date</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>Nothing crazy going on here. I&rsquo;m just creating a big list of timestamps from wherever I happen to leave a trail.</p>
<h2 id="sorting-a-pile-of-dates-into-weekly-buckets">
  <a class="heading-link" href="#sorting-a-pile-of-dates-into-weekly-buckets">Sorting a pile of dates into weekly buckets.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Each of these dates now need to land in one of fifty-two buckets. Working out which week a given date belongs to is just some boring epoch arithmetic; how many whole weeks ago was it?</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">ago</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="kt">int</span><span class="w"> </span><span class="p">(</span><span class="nf">div</span><span class="w"> </span><span class="p">(</span><span class="nx">sub</span><span class="w"> </span><span class="nx">now</span><span class="p">.</span><span class="nx">Unix</span><span class="w"> </span><span class="err">$</span><span class="nx">d</span><span class="p">.</span><span class="nx">Unix</span><span class="p">)</span><span class="w"> </span><span class="mi">604800</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nf">and</span><span class="w"> </span><span class="p">(</span><span class="nx">ge</span><span class="w"> </span><span class="err">$</span><span class="nx">ago</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="p">(</span><span class="nx">lt</span><span class="w"> </span><span class="err">$</span><span class="nx">ago</span><span class="w"> </span><span class="mi">52</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">counts</span><span class="p">.</span><span class="nf">Add</span><span class="w"> </span><span class="p">(</span><span class="nx">printf</span><span class="w"> </span><span class="s">&#34;%d&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">sub</span><span class="w"> </span><span class="mi">51</span><span class="w"> </span><span class="err">$</span><span class="nx">ago</span><span class="p">))</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>That <code>604800</code> is the number of seconds in a week. And yes, I did have to look that up. Anything older than fifty-two weeks just falls off the back and is quietly ignored.</p>
<h2 id="painting-the-squircles">
  <a class="heading-link" href="#painting-the-squircles">Painting the squircles.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>My favourite part of this is there&rsquo;s no JS involved. The whole thing is a CSS grid of twenty-six columns, and because each squircle is <code>aspect-square</code>, the rows just work and the squircles stretch to fill whatever container I drop the partial into:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HTML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;grid grid-cols-26 gap-1.5&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="c">&lt;!-- 52 of these green little dudes --&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;aspect-square rounded-full bg-emerald-400&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>
</div>
<p>Working out the shade was the fiddly bit. My first attempt simply scaled each week against the busiest one, which sounds sensible right up until you remember my data is basically one enormous week and a whole lot of flat nothing. That single monster week hogged the dark end and squashed everything else into the same pale green. So I did what GitHub does and reached for <a href="https://en.wikipedia.org/wiki/Quartile">quartiles</a> instead. You rank the weeks that actually saw some activity, chop them into four groups, and let a week&rsquo;s colour come from where it lands in the pack rather than from some absolute number. Empty weeks stay a faint <code>emerald-100</code>; the rest climb through four steps of green up to <code>emerald-600</code>. The part I like is that it&rsquo;s relative to <em>me</em>; a busy week is only busy compared to my <em>other</em> weeks.</p>
<p>The little tooltip that pops up when you hover over a squircle is also pure CSS. A <code>group</code> on the wrapper, a <code>group-hover:opacity-100</code> on the popup, a <code>transition</code> is all I needed. Each squircle now gives a little <code>scale</code> on hover too, just because it&rsquo;s nice.</p>
<h2 id="the-graphs-painful-honesty">
  <a class="heading-link" href="#the-graphs-painful-honesty">The graph&rsquo;s painful honesty.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Now on to the more embarrassing part. When I first rendered it with real data, I got <em>one</em> lonely green squircle and fifty-one empty ones. Surely, you&rsquo;ve noticed it on the front page.</p>
<p>For the curious, this is what it <em>would</em> look like if I didn&rsquo;t have commitment issues.
<img
  src="https://wilhelm.codes/blog/a-year-in-circles/activity-graph-demo_hu_ea2ab57358bd9cb8.webp"
  srcset="/blog/a-year-in-circles/activity-graph-demo_hu_ea2ab57358bd9cb8.webp 736w, /blog/a-year-in-circles/activity-graph-demo_hu_ec221f404c400ffe.webp 1104w, /blog/a-year-in-circles/activity-graph-demo_hu_1cd3ec9be12d0e93.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="91"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>At the time I assumed I&rsquo;d done something wrong. But, to my great shame, I hadn&rsquo;t. Turns out that when you vanish from your own website for close to 18 months and then cram an <a href="https://wilhelm.codes/blog/some-long-overdue-housekeeping/">entire renovation</a> into a single week, the graph renders exactly that. A long, flat, pale stretch of road ending with one pathetic little green emerald.</p>
<p>There&rsquo;s an extra little indignity baked into the quartiles, too. They need a <em>spread</em> to rank against, and with exactly one active week there&rsquo;s nothing to compare it to. So my massive renovation-cramming week doesn&rsquo;t even get to be properly dark green. It turns up as a polite, middling shade.</p>
<p>I effectively built a little a tool whose entire job is to hold up a mirror to my own lack of consistency and commitment. Very cool! 😬👌</p>
<h2 id="drop-it-in-anywhere">
  <a class="heading-link" href="#drop-it-in-anywhere">Drop it in anywhere.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This whole thing is a single self-contained partial. It was waaaay easier to build out than I had originally thought. And, thanks to Tailwind I didn&rsquo;t even have to fall back on any JS! I count that as a bonus.</p>
<p>For the moment, it&rsquo;ll live on the home page, but I can now just place this anywhere in my Hugo site and it&rsquo;ll work:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">partial</span><span class="w"> </span><span class="s">&#34;activity-graph.html&#34;</span><span class="w"> </span><span class="p">.</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>I might even build it out a bit more to support different colour schemes or specificy types of targeted site content. So, the more I write, the more those squircles fill in.</p>
<p>Consider yourself warned, <em>me</em>.</p>]]></content:encoded></item><item><title>Some Long Overdue Housekeeping</title><link>https://wilhelm.codes/blog/some-long-overdue-housekeeping/</link><pubDate>Sun, 14 Jun 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/some-long-overdue-housekeeping/</guid><category>hugo</category><category>tailwindcss</category><category>css</category><category>refactoring</category><category>wilhelm.codes</category><wc:kind>post</wc:kind><description>This blog has been quietly chugging along for a while now without me paying it much attention. Which is, I suppose, the whole point of a set-and-forget setup. But &amp;ldquo;set-and-forget&amp;rdquo; has a sneaky way of becoming &amp;ldquo;forgotten&amp;rdquo;, and the longer you leave something untouched, the more it quietly rots behind your back. So I rolled up my sleeves and gave the whole thing a proper tune-up.</description><content:encoded><![CDATA[<p>This blog has been quietly chugging along for a while now without me paying it much attention. Which is, I suppose, the whole point of a <a href="https://wilhelm.codes/blog/my-blog-publishing-setup/">set-and-forget</a> setup. But &ldquo;set-and-forget&rdquo; has a sneaky way of becoming &ldquo;forgotten&rdquo;, and the longer you leave something untouched, the more it quietly rots behind your back. So I rolled up my sleeves and gave the whole thing a proper tune-up.</p>
<h2 id="confession-time">
  <a class="heading-link" href="#confession-time">Confession Time<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>While poking around the build, I discovered something a little embarrassing. My Tailwind setup - the source, the config, and the <em>entire</em> <code>node_modules</code> directory - was living inside Hugo&rsquo;s <code>static/</code> folder.</p>
<p>If you know Hugo, you already know where this is going. Everything in <code>static/</code> gets copied, verbatim, into the final site. Which means I had been cheerfully publishing my whole build toolchain - megabytes of it - to the live site on every single deploy. So, yeah. Production was shipping <code>node_modules</code>. Coding is my passion.</p>
<p>Nobody noticed, nothing broke, and the world kept turning. But it&rsquo;s the kind of thing that, once you see it, you can&rsquo;t <em>un</em>-see. To be fair to myself, when I originally put this Hugo site together, I only learned enough to get something shipped.</p>
<h2 id="letting-hugo-do-the-heavy-lifting">
  <a class="heading-link" href="#letting-hugo-do-the-heavy-lifting">Letting Hugo do the heavy lifting.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The reason that mess existed in the first place was that I&rsquo;d wired up Tailwind as a separate, manual build step that spat out a compiled stylesheet for Hugo to pick up. It worked, but it was a second moving part I had to remember existed.</p>
<p>The good news is that recent versions of Hugo can drive <a href="https://gohugo.io/functions/css/tailwindcss/">Tailwind</a> itself, natively, as part of the normal asset pipeline. Combined with Tailwind v4 - which finally ditches the JavaScript config file in favour of configuring everything in CSS - I got to delete a <em>lot</em> of stuff:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ git diff --shortstat main...modernize
</span></span><span class="line"><span class="cl"><span class="m">64</span> files changed, <span class="m">1872</span> insertions<span class="o">(</span>+<span class="o">)</span>, <span class="m">40197</span> deletions<span class="o">(</span>-<span class="o">)</span></span></span></code></pre></div>
</div>
<p>The whole stylesheet now starts its life as a single entry point:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">CSS</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="s2">&#34;tailwindcss&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="k">plugin</span> <span class="s2">&#34;@tailwindcss/typography&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="k">source</span> <span class="s2">&#34;hugo_stats.json&#34;</span><span class="p">;</span></span></span></code></pre></div>
</div>
<p>That <code>hugo_stats.json</code> bit is the clever part. Hugo writes out a list of every utility class it actually emits, and Tailwind reads <em>that</em> to decide what to generate. No more pointing Tailwind at my templates and hoping it guesses right.</p>
<p>Then a small partial hands it all off to Hugo to compile, minify and fingerprint:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="nf">with</span><span class="w"> </span><span class="p">(</span><span class="nx">templates</span><span class="p">.</span><span class="nf">Defer</span><span class="w"> </span><span class="p">(</span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;key&#34;</span><span class="w"> </span><span class="s">&#34;css&#34;</span><span class="p">))</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="nx">resources</span><span class="p">.</span><span class="nx">Get</span><span class="w"> </span><span class="s">&#34;css/main.css&#34;</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;minify&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">not</span><span class="w"> </span><span class="nx">hugo</span><span class="p">.</span><span class="nx">IsDevelopment</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="p">.</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">css</span><span class="p">.</span><span class="nx">TailwindCSS</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="cm">/* ...do very cool things... */</span><span class="w"> </span><span class="o">-</span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>The <code>templates.Defer</code> wrapper is there because the CSS can&rsquo;t compiled ( transpiled? ) until Hugo has finished rendering every page and knows the full list of classes. So, in a very real way, Hugo solves an annoying 🐔 and 🥚 problem.</p>
<p>This means no more standalone Tailwind config, no committed stylesheet, no <code>node_modules</code> in <code>static/</code>, and no build toolchain leaking onto the live site. Very cool!</p>
<h2 id="so-many-deprecations">
  <a class="heading-link" href="#so-many-deprecations">So. Many. Deprecations.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Of course, nothing that&rsquo;s been left alone for a year comes back to life cleanly. Bumping Hugo to the latest release lit up the console like a Christmas tree.</p>
<p>A few of my templates were leaning on things that have since been politely shown the door:</p>
<ul>
<li><code>resources.GetRemote ... .Err</code> for the <a href="https://wilhelm.codes/changelog/">changelog page</a> - that pattern was removed in favour of a shiny new <code>try</code> keyword.</li>
<li><code>.Language.LanguageCode</code> and <code>.Language.LanguageDirection</code>, both deprecated in favour of <code>.Locale</code> and <code>.Direction</code>.</li>
<li>The <code>_build</code> and <code>cascade._target</code> front matter keys, now just <code>build</code> and <code>cascade.target</code>.</li>
</ul>
<p>None of it was hard to fix, but it&rsquo;s a good reminder that &ldquo;it still builds&rdquo; and &ldquo;it builds <em>without complaints</em>&rdquo; are two very different bars.</p>
<h2 id="a-footgun">
  <a class="heading-link" href="#a-footgun">A Footgun!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Here&rsquo;s a fun one. The changelog page pulls in a Github event fixture file during local development by fetching it over <code>http://localhost:1313</code>. Effectively, from the very dev server that&rsquo;s <em>trying to build the page</em>.</p>
<p>Those of you who are familiar with such things can probably see the problem. Hugo builds the site <em>before</em> it starts listening on that port, so the build sits there waiting for a server that doesn&rsquo;t exist yet. A deadlock of my own making. The obvious fix was to just read the file off disk instead of asking the network nicely.</p>
<h2 id="some-honourable-mentions">
  <a class="heading-link" href="#some-honourable-mentions">Some honourable mentions.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A grab-bag of smaller wins while I had the hood up:</p>
<ul>
<li>Fonts are now served as <code>woff2</code> instead of raw <code>ttf</code>, with <code>font-display: swap</code> so text shows up immediately instead of hanging around invisible. This alone shaved the font payload down by about 60%.</li>
<li>I also deleted a few MB of fluff that wasn&rsquo;t being loaded by anything.</li>
<li>Prettier got a nice version bump and I taught it to sort my Tailwind classes, so I can stop pretending I do that consistently by hand.</li>
</ul>
<h2 id="was-the-juice-worth-the-squeeze">
  <a class="heading-link" href="#was-the-juice-worth-the-squeeze">Was the juice worth the squeeze?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Aside from a few cosmetic updates here and there, the site should look pretty much the same as it did before. Which is, weirdly, the whole point; all of this work was about the parts you can&rsquo;t see. I got a leaner build, faster page loads, and a project I can come back to in another year without wincing; famous last words, etc&hellip;</p>
<p>For now, the house is clean. Very nice.</p>]]></content:encoded></item><item><title>A Changelog That Builds Itself</title><link>https://wilhelm.codes/blog/a-changelog-that-builds-itself/</link><pubDate>Sun, 14 Jun 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/a-changelog-that-builds-itself/</guid><category>hugo</category><category>github</category><category>api</category><wc:kind>post</wc:kind><description>This site has a changelog page. It&amp;rsquo;s not one I write by hand as it builds itself from my activity on GitHub every time the site deploys. I think it&amp;rsquo;s a neat little trick. It also spent a solid afternoon teaching me that &amp;ldquo;works on my machine&amp;rdquo; and &amp;ldquo;works in production&amp;rdquo; are, once again, two very different things.</description><content:encoded><![CDATA[<p>This site has a <a href="https://wilhelm.codes/changelog/">changelog</a> page. It&rsquo;s not one I write by hand as it builds itself from my activity on GitHub every time the site deploys. I think it&rsquo;s a neat little trick. It also spent a solid afternoon teaching me that &ldquo;works on my machine&rdquo; and &ldquo;works in production&rdquo; are, once again, two very different things.</p>
<p>If you use Github, you know it quietly records nearly everything you do as a stream of <a href="https://docs.github.com/en/rest/activity/events">events</a>. Better still, for a public repository, that stream is available over a public, unauthenticated API endpoint through a simple <code>GET</code> request.</p>
<p>So the plan more or less writes itself. We fetch the stream at build time, group the events by day, and let Hugo render them. Hugo even has a tidy <a href="https://gohugo.io/functions/resources/getremote/">little function</a> for pulling in remote resources that works like so:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">url</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="s">&#34;https://api.github.com/repos/wilhelm-murdoch/wilhelm.codes/events&#34;</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="nf">try</span><span class="w"> </span><span class="p">(</span><span class="nx">resources</span><span class="p">.</span><span class="nx">GetRemote</span><span class="w"> </span><span class="err">$</span><span class="nx">url</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">events</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="p">.</span><span class="nx">Value</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">transform</span><span class="p">.</span><span class="nx">Unmarshal</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="cm">/* ...range over them and render... */</span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>Because this all happens at <em>build</em> time, the visitor never waits on GitHub. By the time the page reaches a browser it&rsquo;s just static HTML like everything else. The changelog is always current as of the last deploy, and I never have to think about it.</p>
<p>That&rsquo;s the part that worked. Now for the parts that didn&rsquo;t.</p>
<h2 id="-then-production-happened">
  <a class="heading-link" href="#-then-production-happened">&hellip; Then, production happened.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I&rsquo;ve been more or less absent from this site for close to a year, but this past week I&rsquo;ve decided to breathe a bit more life into it. I made a few changes to <a href="https://wilhelm.codes/blog/some-long-overdue-housekeeping/">modernise</a> the stack, pushed my changes up and waited on Cloudflare to do its thing and&hellip;</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">ERROR error building site: ... error calling GetRemote:
</span></span><span class="line"><span class="cl">failed to fetch remote resource from &#39;https://api.github.com/...&#39;: Forbidden</span></span></code></pre></div>
</div>
<p>I was greeted with a <code>403</code>. Except&hellip; I could paste that exact URL into my browser and get a perfectly happy wall of JSON back. So what gives?</p>
<p>Rate limiting, that&rsquo;s what. GitHub&rsquo;s unauthenticated API is capped at <strong>60 requests per hour, per IP address</strong>. My build doesn&rsquo;t run on <em>my</em> IP. It runs on a shared build machine alongside who-knows-how-many other people&rsquo;s deploys, all of them hammering GitHub from the same handful of addresses. By the time my build rolled around, that bucket was bone dry.</p>
<p>Honestly, a <code>429</code> instead here would have saved me a bit of investigating, but I digress.</p>
<p>The solution was to created a properly scoped Github PAT. Using an authenticated request gets <strong>5000 requests per hour</strong>, so I went about implementing support and handed it to the build as an environment variable, and taught the template to send it along:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">dict</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="nx">os</span><span class="p">.</span><span class="nx">Getenv</span><span class="w"> </span><span class="s">&#34;HUGO_GITHUB_TOKEN&#34;</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;headers&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;Authorization&#34;</span><span class="w"> </span><span class="p">(</span><span class="nx">printf</span><span class="w"> </span><span class="s">&#34;Bearer %s&#34;</span><span class="w"> </span><span class="p">.))</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">remote</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nf">try</span><span class="w"> </span><span class="p">(</span><span class="nx">resources</span><span class="p">.</span><span class="nx">GetRemote</span><span class="w"> </span><span class="err">$</span><span class="nx">url</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>It&rsquo;s worth noting that Hugo won&rsquo;t read just <em>any</em> environment variable. Its security policy only lets <code>os.Getenv</code> see variables prefixed with <code>HUGO_</code>. Name your token <code>GITHUB_TOKEN</code> and you&rsquo;ll get a confusing fistful of nothing; name it <code>HUGO_GITHUB_TOKEN</code> and it works. Ask me how I know.</p>
<h2 id="hark-another-footgun">
  <a class="heading-link" href="#hark-another-footgun">Hark! Another Footgun!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>With the token in place, the fetch succeeded and the build promptly fell over somewhere new:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">error calling len: reflect: call of reflect.Value.Type on zero Value</span></span></code></pre></div>
</div>
<p>This never showed up locally, which tells me I should do myself a favour and do a quick local <em>production</em> deployment as a preflight <em>before</em> I push to Github and trigger an automated build. Originally, in development I rendered the changelog from a single large fixture file stored locally on disk. This was before I implemented the PAT so I wouldn&rsquo;t rate limit myself locally when testing. The live stream, however, contains event shapes my fixture simply didn&rsquo;t. One of them was a <code>push</code> event carrying no <code>commits</code> at all, and my template did this without a second thought:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nf">eq</span><span class="w"> </span><span class="p">(</span><span class="nx">len</span><span class="w"> </span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">commits</span><span class="p">)</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>Call <code>len</code> on something that exists and isn&rsquo;t there and Go has a small panic about it. The fix is boring; stop assuming the field is there. Hugo&rsquo;s <code>with</code> only enters the block when its argument is actually&hellip; something:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">commits</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nf">eq</span><span class="w"> </span><span class="p">(</span><span class="nx">len</span><span class="w"> </span><span class="p">.)</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">{{</span><span class="cm">/* the one-commit case */</span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">{{</span><span class="cm">/* the many-commits case */</span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>Since I&rsquo;ve implemented PAT support, I&rsquo;m not so concerned about being rate-limited during local development, though I now use the fixtures as a fallback if any request fails. This allows me to keep working locally without disruption.</p>
<h2 id="dont-let-someone-elses-server-break-your-build">
  <a class="heading-link" href="#dont-let-someone-elses-server-break-your-build">Don&rsquo;t let someone else&rsquo;s server break your build.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The real personal lesson is the moment your build depends on a third party at build time, you&rsquo;ve effectively ceded control of your deployment to them. GitHub rate-limits you, or has a wobble, or changes a payload shape, and suddenly your perfectly good site won&rsquo;t deploy and you&rsquo;ll have a hard time.</p>
<p>So the changelog no longer treats GitHub as load-bearing. If the fetch fails for <em>any</em> reason it shrugs, logs a warning, and falls back to that saved fixture instead of taking the whole deploy down with it:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">remote</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nf">try</span><span class="w"> </span><span class="p">(</span><span class="nx">resources</span><span class="p">.</span><span class="nx">GetRemote</span><span class="w"> </span><span class="err">$</span><span class="nx">url</span><span class="w"> </span><span class="err">$</span><span class="nx">opts</span><span class="p">)</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="err">$</span><span class="nx">remote</span><span class="p">.</span><span class="nx">Err</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="nx">warnf</span><span class="w"> </span><span class="s">&#34;changelog: GitHub fetch failed (%s); using the fixture&#34;</span><span class="w"> </span><span class="p">.</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">response</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="nx">os</span><span class="p">.</span><span class="nx">ReadFile</span><span class="w"> </span><span class="s">&#34;static/github.json&#34;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">transform</span><span class="p">.</span><span class="nx">Unmarshal</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="nx">with</span><span class="w"> </span><span class="err">$</span><span class="nx">remote</span><span class="p">.</span><span class="nx">Value</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">{{</span><span class="w"> </span><span class="err">$</span><span class="nx">response</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">.</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">transform</span><span class="p">.</span><span class="nx">Unmarshal</span><span class="w"> </span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{{</span><span class="w"> </span><span class="nx">end</span><span class="w"> </span><span class="p">}}</span></span></span></code></pre></div>
</div>
<p>The <code>try</code> keyword is the hero here as it catches the error that <code>GetRemote</code> would otherwise throw and hands it back to me as a value I can actually <em>do</em> something with, rather than a smoking crater where my build used to be. Worst case, the page shows slightly stale history. That&rsquo;s a trade I&rsquo;ll take every single time over a red deploy or disruption to my local flow.</p>
<h2 id="in-conclusion">
  <a class="heading-link" href="#in-conclusion">In conclusion&hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Ironically, this is something I&rsquo;ve dealt with quite frequently at work. Github &amp; AWS API rate limits ( yes, we frequently got rate limited by the VPC API of all things ), <a href="https://yarnpkg.com/">Yarn</a> will throw a <code>5xx</code> and even Sentry will chimp out during source map uploads. All of these things can disrupt deployments.</p>
<p>The point is just assume these things will happen and then build defensively around them. I, ehhhh, just need to apply this wisdom to my personal stuff. 😅</p>
<p>Anyway, the changelog still builds itself. It just doesn&rsquo;t get to take the rest of the site down with it anymore.</p>]]></content:encoded></item><item><title>Printing Ordinal Numbers in Hugo</title><link>https://wilhelm.codes/blog/printing-ordinal-numbers-in-hugo/</link><pubDate>Fri, 17 Jan 2025 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/printing-ordinal-numbers-in-hugo/</guid><category>hugo</category><category>snippets</category><wc:kind>post</wc:kind><description>I&amp;rsquo;ve been having such a good time building up this website and Hugo has been incredibly fun – and relatively simple – to work with. Though, from time to time, I find myself scratching my head at the absence of a few bits and bobs.</description><content:encoded><![CDATA[<p>I&rsquo;ve been having such a good time building up this website and <a href="https://gohugo.io">Hugo</a> has been incredibly fun – and relatively simple – to work with. Though, from time to time, I find myself scratching my head at the absence of a few bits and bobs.</p>
<p>In this case, what I really needed was a simple way to assign an ordinal to an arbitrary number. For instance, if I have a value of <code>2</code>, I might want to tack on a <code>nd</code> as a suffix, eg; <code>1st</code>, <code>540th</code> or  <code>9001st</code> and so on.</p>
<p>Specifically, I&rsquo;d like to use this with dates, but from what I can tell, Hugo doesn&rsquo;t support this out-of-the-box. Luckily, the framework gives you a few options to extend its functionality.</p>
<h2 id="shortcodes">
  <a class="heading-link" href="#shortcodes">Shortcodes<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>With <a href="https://gohugo.io/content-management/shortcodes/">shortcodes</a> you can create snippets that act as functions which can be used to dynamically inject HTML – among other things – directly into your rendered markdown. You can even pass both named and positional arguments through the shortcode to modify their behaviour as needed.</p>
<p>For instance, I have <a href="https://github.com/wilhelm-murdoch/wilhelm.codes/blob/main/layouts/shortcodes/blockquote.html">this custom</a> shortcode used for displaying styled block quotes like:</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Out of all the things I have lost, I miss my mind the most.</p>
  </blockquote>
  <figcaption>
    <cite>Mark Twain</cite>
  </figcaption>
</figure>

<p>I also have <a href="https://github.com/wilhelm-murdoch/wilhelm.codes/blob/main/layouts/shortcodes/callouts.html">this one</a> that let&rsquo;s me print out some fancy callouts:</p>
<div class="callout callout-warning">
  You should probably pay attention.
</div>

<div class="callout callout-info">
  This may be of some small interest.
</div>

<div class="callout callout-error">
  Yeah, so, we&rsquo;re all gonna die.
</div>

<p>Hugo ships with a <a href="https://gohugo.io/content-management/shortcodes/#embedded-shortcodes">default set</a> of shortcodes covering a variety of additional kinds of embeddings.</p>
<p>Unfortunately, for my purposes, I&rsquo;m not planning on using dynamic numeric ordinals in my markdown files. I need this to work with templates and for that, we use&hellip;</p>
<h2 id="partials">
  <a class="heading-link" href="#partials">Partials<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<div class="callout callout-notice">
  &hellip; that while you cannot use partials directly within your markdown files, you <em>can</em> reference them <em>indirectly</em> by embedding them within a shortcode .
</div>

<p>Unlike shortcodes, <a href="">partials</a> are small re-usable HTML components that are typically used to keep code duplication down. They are effectively context-aware templates that can accept arbitrary data which can be used in generating desired output.</p>
<p><a href="https://github.com/wilhelm-murdoch/wilhelm.codes/blob/main/layouts/partials/views/small.html">Here</a> is a small example of how I use partials for this blog. It&rsquo;s a small data card component you might find scattered throughout the site. Partials let you quickly change a UI component in one place while having it propagate everywhere else.</p>
<p>For the purpose of this article, they can also be used to create custom template &ldquo;functions&rdquo; like this which solves my very specific problem:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HTML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl">{{- if and ( eq ( mod . 10 ) 1 ) ( ne ( mod . 100 ) 11 ) -}}
</span></span><span class="line"><span class="cl">    st
</span></span><span class="line"><span class="cl">{{ else if and ( eq ( mod . 10 ) 2 ) ( ne ( mod . 100 ) 12 ) -}}
</span></span><span class="line"><span class="cl">    nd
</span></span><span class="line"><span class="cl">{{ else if and ( eq ( mod . 10 ) 3 ) ( ne ( mod . 100 ) 13 ) -}}
</span></span><span class="line"><span class="cl">    rd
</span></span><span class="line"><span class="cl">{{ else -}}
</span></span><span class="line"><span class="cl">    th
</span></span><span class="line"><span class="cl">{{ end -}}</span></span></code></pre></div>
</div>
<p>This may look a bit unreadable to people who aren&rsquo;t super-familiar with Hugo&rsquo;s template syntax, but it effectively resolves to:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JavaScript</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">number</span> <span class="o">=</span> <span class="mi">10</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">ordinal</span> <span class="o">=</span> <span class="s2">&#34;th&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">number</span> <span class="o">%</span> <span class="mi">10</span> <span class="o">==</span> <span class="mi">1</span> <span class="o">&amp;&amp;</span> <span class="nx">number</span> <span class="o">%</span> <span class="mi">100</span> <span class="o">!=</span> <span class="mi">11</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="nx">ordinal</span> <span class="o">=</span> <span class="s2">&#34;st&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">number</span> <span class="o">%</span> <span class="mi">10</span> <span class="o">==</span> <span class="mi">2</span> <span class="o">&amp;&amp;</span> <span class="nx">number</span> <span class="o">%</span> <span class="mi">100</span> <span class="o">!=</span> <span class="mi">12</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="nx">ordinal</span> <span class="o">=</span> <span class="s2">&#34;nd&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">number</span> <span class="o">%</span> <span class="mi">10</span> <span class="o">==</span> <span class="mi">3</span> <span class="o">&amp;&amp;</span> <span class="nx">number</span> <span class="o">%</span> <span class="mi">100</span> <span class="o">!=</span> <span class="mi">13</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="nx">ordinal</span> <span class="o">=</span> <span class="s2">&#34;rd&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">number</span> <span class="o">+</span> <span class="nx">ordinal</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// prints 10th
</span></span></span></code></pre></div>
</div>
<p>Anyways, I have this saved as <code>partials/functions/ordinal.html</code> and can reference this in any of my templates like so:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HTML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl">{{ partial &#34;functions/ordinal.html&#34; $number }}</span></span></code></pre></div>
</div>
<p>Where <code>$number</code> is any arbitrary number I&rsquo;d like an ordinal suffix for. Take the following example template which generates a random range of numbers and prints them out using my ordinal partial:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HTML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl">{{ range seq 10 }}
</span></span><span class="line"><span class="cl">  {{.}}{{ partial &#34;functions/ordinal.html&#34; . }} 
</span></span><span class="line"><span class="cl">{{ end }}</span></span></code></pre></div>
</div>
<p>Which generates:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">1st 2nd 3rd 4th 5th 6th 7th 8th 9th 10th</span></span></code></pre></div>
</div>
<p>Ground-breaking work! I can finally use ordinals in dates. Not exactly something I expected to write an article about, but here we are. 🤷</p>]]></content:encoded></item><item><title>My Blog Publishing Setup &amp; Workflow</title><link>https://wilhelm.codes/blog/my-blog-publishing-setup/</link><pubDate>Sat, 11 Jan 2025 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/my-blog-publishing-setup/</guid><category>obsidian</category><category>hugo</category><category>bash</category><category>cloudflare</category><wc:kind>post</wc:kind><description>Late last year I parted ways with Hashnode as the platform of choice for my blog. They pivoted a bit too far into the generative AI space which — to me for a blogging platform — made little to no sense. What? You write your content with AI, which then trains their model only to spit out more AI-generated slop? A slop-based Ouroboros, if you will. Good luck with that, but I&amp;rsquo;ll pass thanks.</description><content:encoded><![CDATA[<p>Late last year I parted ways with <a href="https://hashnode.com/">Hashnode</a> as the platform of choice for my blog. They pivoted a bit too far into the generative AI space which —  to me for a blogging platform —  made little to no sense. What? You write your content with AI, which then trains their model only to spit out more AI-generated slop?  A slop-based <a href="https://en.wikipedia.org/wiki/Ouroboros">Ouroboros</a>, if you will. Good luck with that, but I&rsquo;ll pass thanks.</p>
<h2 id="first-a-small-rant">
  <a class="heading-link" href="#first-a-small-rant">First, a Small Rant<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Before you call me a <a href="https://en.wikipedia.org/wiki/Luddite">Luddite</a>, let&rsquo;s be clear here, I don&rsquo;t have an issue with the technology itself. I&rsquo;m an engineer. I think it all has its uses, but force-feeding it into almost every aspect of our lives with no regard to any social and environmental impacts with frequently no chance of being able to easily opt-out does not sit right with me one bit.</p>
<p>We&rsquo;ve seen this happen over the last decade with touchscreens, IoT, blockchain, NFTs, Web3 the list goes on. All promoted by the endless parade of interchangeable, nameless faceless talentless hacks and disingenuous grifters who only care about making &ldquo;BIG LINE GO UP&rdquo;.</p>
<p>It&rsquo;s all so tiring, so perhaps you can understand why I&rsquo;d immediately recoil in disgust and go my own way with an alternative I can control.</p>
<p>Or, to put it in even simpler terms:</p>
<p><img
  src="https://wilhelm.codes/blog/my-blog-publishing-setup/image-1_hu_dbfacd982ca699f4.webp"
  srcset="/blog/my-blog-publishing-setup/image-1_hu_dbfacd982ca699f4.webp 598w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="598"
  height="369"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="the-stack">
  <a class="heading-link" href="#the-stack">The &ldquo;Stack&rdquo;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I suppose it&rsquo;s technically a &ldquo;stack&rdquo;, but it seems a bit funny to use the term when referencing a blog setup. Just as with my <a href="https://plantsm.art"><span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span></a> project, the goal is to automate as much as possible while keeping maintenance requirements and hard- and soft-dollar costs as low as possible. For this, I need only use a handful of tools to keep this space operational.</p>
<p>As far as the question over &ldquo;cost&rdquo;, outside of time spent, it&rsquo;s about ~$100 AUD per year for the domain name. This blog is a very small and inconsequential part of this domain, so the overall cost is absorbed by &ldquo;other stuff&rdquo;.</p>
<p>So, the greatest cost is&hellip; time? I guess 🤷</p>
<h3 id="obsidian">
  <a class="heading-link" href="#obsidian">Obsidian<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>While I won&rsquo;t be going into what <a href="">Obsidian</a> is and how it works, I will say it&rsquo;s been a personal boon in how I keep notes and track of ideas. I have a lot of fleeting / ephemeral thoughts I that would otherwise lose instantly if I didn&rsquo;t immediately jot them down using this app.</p>
<p>So, it makes perfect sense for me to use my personal <a href="https://help.obsidian.md/Getting+started/Create+a+vault">vault</a> to store all my blog content. The fact Obsidian content nothing more than Markdown files makes working with them using other bits of tech that much easier.</p>
<p>Oh, it&rsquo;s free btw.</p>
<h3 id="hugo">
  <a class="heading-link" href="#hugo">Hugo<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p><a href="https://gohugo.io/">This</a> is probably one of the more popular options when it comes to static site generators. It&rsquo;s easy to install, is incredibly extensible and has loads of documentation to help you along. The template syntax sits upon Golang&rsquo;s templating engine, so if you have a strong background as a Go developer like myself, you&rsquo;ll be right at home.</p>
<p>However, knowing Go isn&rsquo;t a hard requirement though it will make getting on top of things quite a bit easier.</p>
<p>What I enjoyed while learning about this project was I got to control every aspect of how I wanted my blog to look and function. Building the current design from the ground up with Hugo was incredibly fun.</p>
<p>Also, free. Very cool.</p>
<h3 id="cloudflare">
  <a class="heading-link" href="#cloudflare">Cloudflare<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I already used Cloudflare to manage this domain&rsquo;s DNS settings, so it only made sense to publish my static site to Cloudflare Pages over, say, Vercel or Github Pages. There&rsquo;s nothing spectacular going on here outside of me just wanting to keep these two things in the same place.</p>
<p>That being said, I have no issues with migrating else where if the need should ever arise. Though, I doubt this blog will hit the 20k file limit of Cloudflare Pages any time soon.</p>
<p>Again, free for the purposes of this article. If you don&rsquo;t have a dedicated personal domain, you will be provided a subdomain attached to the <code>pages.dev</code> apex.</p>
<h2 id="the-good-stuff">
  <a class="heading-link" href="#the-good-stuff">The Good Stuff<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>How is this all put together for day-to-day usage? Luckily, the Cloudflare stuff runs on auto-pilot, so it&rsquo;s pretty much set-and-forget. I suppose you could say the same about the other components, but they&rsquo;re the parts I touch the most.</p>
<p>I have a single vault in Obsidian. It&rsquo;s where all my thoughts go. Stored within is a top-level <code>Blog/</code> directory. Care to hazard a guess as to what it may contain?</p>
<p><img
  src="https://wilhelm.codes/blog/my-blog-publishing-setup/image-2_hu_84ed8786abb466c3.webp"
  srcset="/blog/my-blog-publishing-setup/image-2_hu_84ed8786abb466c3.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="928"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Hugo has the concept of <a href="https://gohugo.io/content-management/page-bundles/">page bundles</a> where you can group all resources associated with your blog articles within a single directory. You&rsquo;ll notice above all directories mirror the blog posts hosted on this site. In my case, we can consider the directory names to be the human-friendly slugs you see in your address bar that point to the associated content. Within these directories, you&rsquo;d see any other resource I may link to; source code, images, etc&hellip; The best thing about this is I only have to perform a relative reference to these resources. If I want to link to <code>image-1.png</code>, I only have to reference it as <code>![](/blog/my-blog-publishing-setup/image-1.png)</code> without worrying about specifying an absolute path; very nice.</p>
<p>When I want to write a new article, I first create a new directory here. I may already have the title of the article in mind, but it can be in flux until I decide to publish it. In the new directory, I create a file called <code>index.md</code>. This file contains all the content of the associated article written using Markdown.</p>
<p>I then use an Obsidian <a href="https://help.obsidian.md/Plugins/Templates">template</a> dedicated to new blog posts and apply it to the new file. This ensures I have all <a href="https://jekyllrb.com/docs/front-matter/">front matter</a> properties ready to go. This makes configuring my posts in the editor that much easier.</p>
<p><img
  src="https://wilhelm.codes/blog/my-blog-publishing-setup/image-3_hu_7267be29c468e715.webp"
  srcset="/blog/my-blog-publishing-setup/image-3_hu_7267be29c468e715.webp 736w, /blog/my-blog-publishing-setup/image-3_hu_1ad568a578b3cf1f.webp 1104w, /blog/my-blog-publishing-setup/image-3_hu_3acce599e1729f38.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="478"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>All that&rsquo;s left is writing my articles! Well, not really. I need a way to synchronise these files with my local Hugo instance. As you can see <a href="https://github.com/wilhelm-murdoch/wilhelm.codes/tree/main/content/blog">here</a>, the content mirrors what I have stored in my Obsidian vault.</p>
<p>This can be easily addressed with 2 small apps:</p>
<ol>
<li><code>fswatch</code> which monitors a specified directory for changes and allows you to trigger subsequent commands for specific file system events. Basically, I want to watch for any changes in my Obsidian vaults <code>Blog/</code> directory.</li>
<li><code>rsync</code> to dynamically keep the target Hugo directory in sync with the Obsidian source directory.</li>
</ol>
<p>I, of course, have this placed in a script that I have running in the background while I write. This is a great help during the drafting phase as I have Hugo dynamically processing and displaying these changes locally. This allows me to easily proof my work visually as I carry on writing.</p>
<p>Here&rsquo;s the full script:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">SOURCE</span><span class="o">=</span><span class="si">${</span><span class="nv">SOURCE</span><span class="p">:=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> SOURCE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">DESTINATION</span><span class="o">=</span><span class="si">${</span><span class="nv">DESTINATION</span><span class="p">:=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">2</span><span class="si">}</span><span class="s2">&#34;</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> DESTINATION
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">fswatch -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SOURCE</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r event<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">    rsync -av --delete <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SOURCE</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">DESTINATION</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>I keep this running whenever I&rsquo;m writing a new article. Obsidian saves in almost realtime, so as I type the changes almost immediately sync across to the local Hugo server so I can review my changes.</p>
<p>You effectively run it as:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ ./sync.sh /path/to/obsidian/blog/ /path/to/hugo/content/</span></span></code></pre></div>
</div>
<p>For <code>rsync</code> to work as intended and keep the destination directory completely in sync with the source directory, remember to add a trailing slash to both directory arguments.</p>
<h3 id="no-auto-commit">
  <a class="heading-link" href="#no-auto-commit">No Auto-Commit?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Nope. Not here. There are a few other peeps with blogs out there that &ldquo;commit on change&rdquo;, but I prefer to have a bit more control over what actually gets published. For that, I simply use plain-old git commands to publish my new article:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ git add -A
</span></span><span class="line"><span class="cl">$ git commit -m <span class="s1">&#39;some banal new bullshit screed&#39;</span>
</span></span><span class="line"><span class="cl">$ git push origin main</span></span></code></pre></div>
</div>
<p>Cloudflare monitors the associated repository, picks up the change and the auto-magic-ally builds and pushes everything to the public eye.</p>
<p>With this flow, I can see my changes locally in near realtime and push to &ldquo;production&rdquo; only when I&rsquo;m happy with the results.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In Closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I&rsquo;m stoked to have my very own place again that I have complete control over. I&rsquo;ve thoroughly enjoyed creating this new blog design and I may even open-source it one day.</p>
<p>That said, I&rsquo;ve made a very loose commitment to post a new article every week this year of our lord 2025 and I&rsquo;m hoping this will make things heaps simpler. I only want to worry about what I&rsquo;m going to write and just get the content out there.</p>
<p>I sincerely hope I can keep this self-imposed weekly commitment. 😬👍</p>]]></content:encoded></item><item><title>Breaking Up Log Output Using Bash</title><link>https://wilhelm.codes/blog/breaking-up-log-output-with-bash/</link><pubDate>Thu, 09 Jan 2025 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/breaking-up-log-output-with-bash/</guid><category>bash</category><category>snippets</category><category>tutorials</category><wc:kind>post</wc:kind><description>I tend to look at a lot of log output throughout the day as part of my role as platform engineer. This obviously extends to any backend, or ops-related, role. One little niggle that always gets to me is tailing output where the lines only change when something interesting happens.</description><content:encoded><![CDATA[<p>I tend to look at a <em>lot</em> of log output throughout the day as part of my role as platform engineer. This obviously extends to any backend, or ops-related, role. One little niggle that always gets to me is tailing output where the lines only change when something interesting happens.</p>
<p>I have no idea if time is actually passing. Obviously, I can eyeball timestamps if available, but there are times when so many lines zip through the buffer that it&rsquo;s easy to lose track or even go cross-eyed.</p>
<p>It can be tricky to notice that lines are still being tailed if the output doesn&rsquo;t drastically change in some meaningful or noticeable way. Something I like to is intercept each line and then output some kind of divider whenever <code>n</code> lines have been added to the buffer.</p>
<p>It&rsquo;s as simple as:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">lines</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span> <span class="nb">read</span> -r line<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">	<span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">line</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">	<span class="o">((</span>lines++<span class="o">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">	<span class="k">if</span> <span class="o">((</span>lines % <span class="nv">5</span> <span class="o">==</span> 0<span class="o">))</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">		<span class="nb">echo</span> <span class="s2">&#34;----------&#34;</span>
</span></span><span class="line"><span class="cl">	<span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>Effectively, all this does is:</p>
<ol>
<li>Intercept each line of output being piped into the script.</li>
<li>Keep a running tally of lines we&rsquo;ve intercepted so far; <code>$lines++</code>.</li>
<li>If the current tally is divisible by <code>n</code>, or in this case <code>5</code>, spit out an additional line containing a divider.</li>
<li>Keep doing this forever until the process is terminated.</li>
</ol>
<p>If you were to save this in a file named as <code>divider.sh</code> and set it to execute with something like <code>chmod a+x</code> you could test it by doing something like:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ <span class="k">while</span> true<span class="p">;</span> <span class="k">do</span> <span class="nb">echo</span> <span class="s2">&#34;emitting a noop&#34;</span><span class="p">;</span> sleep 1<span class="p">;</span> <span class="k">done</span> <span class="p">|</span> ./divider.sh</span></span></code></pre></div>
</div>
<p>And you&rsquo;ll see something like the following:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">----------
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">emitting a noop
</span></span><span class="line"><span class="cl">----------</span></span></code></pre></div>
</div>
<p>This is pretty dumb, but now you can see that output is still being placed in your terminal buffer. I&rsquo;ve set it to every <code>5</code> lines, but you can update the script to make that configurable. You could also play a sound when the script places a divider in the buffer using something like <code>tput bel</code>.</p>
<p>Anyway&hellip; Enjoy.</p>]]></content:encoded></item><item><title>How to Mass-Unfollow Instagram Accounts</title><link>https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/</link><pubDate>Tue, 07 Jan 2025 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/</guid><category>bash</category><category>api</category><category>tutorials</category><category>snippets</category><wc:kind>post</wc:kind><description>I&amp;rsquo;m not super-active on Instagram these days and Threads was a real let down. I have a dormant Mastodon account, but find myself being quite happy on Bluesky at the moment. Who knows how long that will last. Having recently read about Meta creating loads of fake AI-based accounts, I thought it was time to do some spring cleaning.</description><content:encoded><![CDATA[<p>I&rsquo;m not super-active on Instagram these days and Threads was a real let down. I have a dormant Mastodon account, but find myself being quite happy on <a href="https://bsky.app/profile/wilhelm.codes">Bluesky</a> at the moment. Who knows how long that will last. Having recently read about Meta creating loads of <a href="https://www.404media.co/metas-ai-profiles-are-indistinguishable-from-terrible-spam-that-took-over-facebook/">fake AI-based accounts</a>, I thought it was time to do some spring cleaning.</p>
<h2 id="before-we-begin">
  <a class="heading-link" href="#before-we-begin">Before we begin<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Today, I went ahead and made my Instagram profile private. I have no desire to delete my posts just yet, but decided to go ahead and unfollow everyone as a start. There are several ways to do this:</p>
<ul>
<li>API calls using an SDK, which requires a developer account.</li>
<li>Manually deleting &ldquo;handraulically&rdquo; via the native app or browser, but that can quickly become tedious if you have more than a hundred or so follows.</li>
<li>Random &ldquo;GreaseMonkey&rdquo; scripts off the web that may, or may not, work properly if at all.</li>
<li>Reverse-engineering XHR calls from your browser and writing a simple Bash script to automate the process.</li>
</ul>
<p>If you&rsquo;ve read some of my <a href="https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/">previous</a> <a href="https://wilhelm.codes/blog/liberating-custom-slack-emojis/">blog</a> <a href="https://wilhelm.codes/blog/falsifying-github-participation-graphs-for-fun-and-profit/">posts</a>, you probably can guess which one I&rsquo;ll be going with.</p>
<h3 id="requirements">
  <a class="heading-link" href="#requirements">Requirements<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>This guide assumes you have <em>some</em> programming or scripting knowledge.</p>
<p>You will need the following on your machine:</p>
<ul>
<li>A shell terminal with Bash, or ZSH, support.</li>
<li><code>curl</code> to make API calls from the command line.</li>
<li><code>jq</code> to easily parse JSON-based responses from said API calls. This can easily be installed using <a href="https://brew.sh/">Homebrew</a> or some other supported package manager.</li>
<li>A modern browser that supports a developer tools console to intercept XHR calls.</li>
</ul>
<p>It&rsquo;s worth mentioning that I am using a MacBook with the Chrome browser, but other webkit-based browsers should support similar functionality.</p>
<h2 id="fire-up-the-browser">
  <a class="heading-link" href="#fire-up-the-browser">Fire up the browser!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Open up your browser, or new tab, and navigate to your Instagram profile. Open your browser&rsquo;s developer console and navigate to the &ldquo;Network&rdquo; tab. Clear out whatever requests are currently listed and then filter by &ldquo;Fetch/XHR&rdquo;.</p>
<h3 id="your-following-list">
  <a class="heading-link" href="#your-following-list">Your following list<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>We need to intercept an XHR request that returns a JSON body containing some of your followers. Click the &ldquo;following&rdquo; link and monitor the resulting requests that pop in the list on the &ldquo;Network&rdquo; tab.</p>
<p><img
  src="https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/image-1_hu_6f1c3624c92cee84.webp"
  srcset="/blog/how-to-mass-unfollow-instagram-accounts/image-1_hu_6f1c3624c92cee84.webp 736w, /blog/how-to-mass-unfollow-instagram-accounts/image-1_hu_d061d7b50a984152.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="219"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>You <em>should</em> see something like <code>following/count=12...</code> in the list. Right-click that item and select &ldquo;Copy as cURL&rdquo;. Paste the new value in your clipboard into a scratch file for reference later.</p>
<p><img
  src="https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/image-2_hu_eac316580918723e.webp"
  srcset="/blog/how-to-mass-unfollow-instagram-accounts/image-2_hu_eac316580918723e.webp 736w, /blog/how-to-mass-unfollow-instagram-accounts/image-2_hu_5cf36f632fab2260.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="331"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Be aware this command will contain all the headers required to make requests from your terminal on your behalf. Do NOT share these details with anyone. I haven&rsquo;t checked how long until the tokens within the auth headers expire, so assume they are long(ish)-lived and treat them accordingly.</p>
<h3 id="an-unfollow-request">
  <a class="heading-link" href="#an-unfollow-request">An unfollow request<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Next, we need to intercept an XHR request to <em>unfollow</em> someone. Going back to your following list, select a random user and unfollow them while once again keeping an eye on the &ldquo;Network&rdquo; tab.</p>
<p><img
  src="https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/image-3_hu_3c8e9df334aa9ff5.webp"
  srcset="/blog/how-to-mass-unfollow-instagram-accounts/image-3_hu_3c8e9df334aa9ff5.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="508"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>You&rsquo;re going to see something like the following; an XHR event making a <code>POST</code> request to &ldquo;destroy&rdquo; your chosen follower. Once again, you&rsquo;ll right-click and &ldquo;Copy and cURL&rdquo;. Paste this into your scratch file as well for future reference.</p>
<p><img
  src="https://wilhelm.codes/blog/how-to-mass-unfollow-instagram-accounts/image-4_hu_b009422977c7ada3.webp"
  srcset="/blog/how-to-mass-unfollow-instagram-accounts/image-4_hu_b009422977c7ada3.webp 736w, /blog/how-to-mass-unfollow-instagram-accounts/image-4_hu_fbde267ec81cb211.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="285"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="putting-it-all-together">
  <a class="heading-link" href="#putting-it-all-together">Putting it all together<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Now that you have your two types of requests, we&rsquo;ll need to script the following steps:</p>
<ul>
<li>Execute a <code>curl</code> request to fetch a JSON body containing the accounts you follow, one page of results at a time.</li>
<li>Use <code>jq</code> to extract their Instagram IDs.</li>
<li>Store the results in a variable named&hellip; drumroll, pls&hellip; <code>$ids</code>.</li>
<li>Iterate through each <code>$id</code>.</li>
<li>Execute a <code>curl</code> request to unfollow each <code>$id</code>.</li>
</ul>
<p>Here is the meat of the script. I have included only the necessary headers required to successfully make these requests on your Instagram account&rsquo;s behalf. You&rsquo;ll need to swap out the <code>...</code> with your own values.</p>
<p>You may also notice that I&rsquo;ve change the <code>count=...</code> parameter to <code>100</code>. It may be able to go higher, but I haven&rsquo;t tested it.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">ids</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">  curl <span class="s1">&#39;https://www.instagram.com/api/v1/friendships/.../following/?count=100&amp;hl=en&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;cookie: ...&#39;</span>           <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-asbd-id: ...&#39;</span>        <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-csrftoken: ...&#39;</span>      <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-ig-app-id: ...&#39;</span>      <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-ig-www-claim: ...&#39;</span>   <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-requested-with: ...&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-web-session-id: ...&#39;</span> <span class="p">|</span> jq -r <span class="s1">&#39;.users[].id&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span> <span class="nb">read</span> -r id<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> -n <span class="s2">&#34;Unfollowing user &#39;</span><span class="si">${</span><span class="nv">id</span><span class="si">}</span><span class="s2">&#39;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  curl -s -o /dev/null <span class="s2">&#34;https://www.instagram.com/api/v1/friendships/destroy/</span><span class="si">${</span><span class="nv">id</span><span class="si">}</span><span class="s2">/?hl=en&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;cookie: ...&#39;</span>           <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-asbd-id: ...&#39;</span>        <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-csrftoken: ...&#39;</span>      <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-ig-app-id: ...&#39;</span>      <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-ig-www-claim: ...&#39;</span>   <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-requested-with: ...&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    -H <span class="s1">&#39;x-web-session-id: ...&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --data-raw <span class="s2">&#34;container_module=profile&amp;nav_chain=PolarisProfilePostsTabRoot%3AprofilePage%3A1%3Avia_cold_start%2CPolarisProfilePostsTabRoot%3AprofilePage%3A2%3Aunexpected&amp;user_id=</span><span class="si">${</span><span class="nv">id</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39; ... done!&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> <span class="o">&lt;&lt;&lt;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">ids</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Finished&#34;</span></span></span></code></pre></div>
</div>
<p>You can save this as a file and make it executable, but I just paste this bad boy into your terminal. The above script will remove <code>100</code> accounts at a time. Just keep executing the script until you no longer see results like the following:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Unfollowing user &#39;111&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;222&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;333&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;444&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;555&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;666&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;777&#39;... done
</span></span><span class="line"><span class="cl">Unfollowing user &#39;888&#39;... done
</span></span><span class="line"><span class="cl">Finished</span></span></code></pre></div>
</div>
<h3 id="caveats-gotchas--other-things">
  <a class="heading-link" href="#caveats-gotchas--other-things">Caveats, gotchas &amp; other things<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I&rsquo;ve only had a few hundred accounts that I followed, so I didn&rsquo;t feel the need to make this a bullet-proof solution. There&rsquo;s quite a bit missing and things can go wrong. Here are some things to consider off the top of my head :</p>
<ul>
<li>The session tokens will expire eventually, so you will have to refer back to your &ldquo;Network&rdquo; tab in your developer tools console to get new ones.</li>
<li>As this example only unfollows a static amount at a time, re-executing the script may become tedious for very large follow numbers. Perhaps, you can modify this script to add some basic pagination.</li>
<li>There is no error handling here, so keep an eye out for potential rate limiting or expired token issues with the Instagram API.</li>
<li>Constantly updating session header values can be made a bit simpler by resorting to variables instead.</li>
</ul>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Cleaning out old, unused or unwanted social media accounts can be liberating. I&rsquo;ve recently gutted my Twitter presence and I haven&rsquo;t been on Facebook in years, though I still do use Messenger to keep in touch with a handful of people. I&rsquo;d prefer using Signal for this, but trying to convince other people to install yet another messaging app is like pulling teeth.</p>
<p>Anyway, I actually enjoy writing these kinds of posts. What other social networks should I cover? LinkedIn? Threads? What are some ways you can improve and build on the above? Lemme know in the comment section below.</p>]]></content:encoded></item><item><title>Testing Markdown Elements</title><link>https://wilhelm.codes/blog/markdown-elements/</link><pubDate>Wed, 18 Dec 2024 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/markdown-elements/</guid><category>markdown</category><category>hugo</category><wc:kind>post</wc:kind><description>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</description><content:encoded><![CDATA[<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
<h2 id="headings">
  <a class="heading-link" href="#headings">Headings<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Sint sit cillum pariatur eiusmod nulla pariatur ipsum. Sit laborum anim qui mollit tempor pariatur nisi minim dolor. Aliquip et adipisicing sit sit fugiat commodo id sunt. Nostrud enim ad commodo incididunt cupidatat in ullamco ullamco Lorem cupidatat velit enim et Lorem. Ut laborum cillum laboris fugiat culpa sint irure do reprehenderit culpa occaecat. Exercitation esse mollit tempor magna aliqua in occaecat aliquip veniam reprehenderit nisi dolor in laboris dolore velit.</p>
<h2 id="heading-two">
  <a class="heading-link" href="#heading-two">Heading two<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Aute officia nulla deserunt do deserunt cillum velit magna. Officia veniam culpa anim minim dolore labore pariatur voluptate id ad est duis quis velit dolor pariatur enim. Incididunt enim excepteur do veniam consequat culpa do voluptate dolor fugiat ad adipisicing sit. Labore officia est adipisicing dolore proident eiusmod exercitation deserunt ullamco anim do occaecat velit. Elit dolor consectetur proident sunt aliquip est do tempor quis aliqua culpa aute. Duis in tempor exercitation pariatur et adipisicing mollit irure tempor ut enim esse commodo laboris proident. Do excepteur laborum anim esse aliquip eu sit id Lorem incididunt elit irure ea nulla dolor et. Nulla amet fugiat qui minim deserunt enim eu cupidatat aute officia do velit ea reprehenderit.</p>
<h3 id="heading-three">
  <a class="heading-link" href="#heading-three">Heading three<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Voluptate cupidatat cillum elit quis ipsum eu voluptate fugiat consectetur enim. Quis ut voluptate culpa ex anim aute consectetur dolore proident voluptate exercitation eiusmod. Esse in do anim magna minim culpa sint. Adipisicing ipsum consectetur proident ullamco magna sit amet aliqua aute fugiat laborum exercitation duis et.</p>
<h4 id="heading-four">
  <a class="heading-link" href="#heading-four">Heading four<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h4>
<p>Commodo fugiat aliqua minim quis pariatur mollit id tempor. Non occaecat minim esse enim aliqua adipisicing nostrud duis consequat eu adipisicing qui. Minim aliquip sit excepteur ipsum consequat laborum pariatur excepteur. Veniam fugiat et amet ad elit anim laborum duis mollit occaecat et et ipsum et reprehenderit. Occaecat aliquip dolore adipisicing sint labore occaecat officia fugiat. Quis adipisicing exercitation exercitation eu amet est laboris sunt nostrud ipsum reprehenderit ullamco. Enim sint ut consectetur id anim aute voluptate exercitation mollit dolore magna magna est Lorem. Ut adipisicing adipisicing aliqua ullamco voluptate labore nisi tempor esse magna incididunt.</p>
<h5 id="heading-five">
  <a class="heading-link" href="#heading-five">Heading five<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h5>
<p>Veniam enim esse amet veniam deserunt laboris amet enim consequat. Minim nostrud deserunt cillum consectetur commodo eu enim nostrud ullamco occaecat excepteur. Aliquip et ut est commodo enim dolor amet sint excepteur. Amet ad laboris laborum deserunt sint sunt aliqua commodo ex duis deserunt enim est ex labore ut. Duis incididunt velit adipisicing non incididunt adipisicing adipisicing. Ad irure duis nisi tempor eu dolor fugiat magna et consequat tempor eu ex dolore. Mollit esse nisi qui culpa ut nisi ex proident culpa cupidatat cillum culpa occaecat anim. Ut officia sit ea nisi ea excepteur nostrud ipsum et nulla.</p>
<h6 id="heading-six">
  <a class="heading-link" href="#heading-six">Heading six<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h6>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
<h2 id="paragraphs">
  <a class="heading-link" href="#paragraphs">Paragraphs<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Incididunt ex adipisicing ea ullamco consectetur in voluptate proident fugiat tempor deserunt reprehenderit ullamco id dolore laborum. Do laboris laboris minim incididunt qui consectetur exercitation adipisicing dolore et magna consequat magna anim sunt. Officia fugiat Lorem sunt pariatur incididunt Lorem reprehenderit proident irure. Dolore ipsum aliqua mollit ad officia fugiat sit eu aliquip cupidatat ipsum duis laborum laborum fugiat esse. Voluptate anim ex dolore deserunt ea ex eiusmod irure. Occaecat excepteur aliqua exercitation aliquip dolor esse eu eu.</p>
<p>Officia dolore laborum aute incididunt commodo nisi velit est est elit et dolore elit exercitation. Enim aliquip magna id ipsum aliquip consectetur ad nulla quis. Incididunt pariatur dolor consectetur cillum enim velit cupidatat laborum quis ex.</p>
<p>Officia irure in non voluptate adipisicing sit amet tempor duis dolore deserunt enim ut. Reprehenderit incididunt in ad anim et deserunt deserunt Lorem laborum quis. Enim aute anim labore proident laboris voluptate elit excepteur in. Ex labore nulla velit officia ullamco Lorem Lorem id do. Dolore ullamco ipsum magna dolor pariatur voluptate ipsum id occaecat ipsum. Dolore tempor quis duis commodo quis quis enim.</p>
<h2 id="blockquotes">
  <a class="heading-link" href="#blockquotes">Blockquotes<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Ad nisi laborum aute cupidatat magna deserunt eu id laboris id. Aliquip nulla cupidatat sint ex Lorem mollit laborum dolor amet est ut esse aute. Nostrud ex consequat id incididunt proident ipsum minim duis aliqua ut ex et ad quis. Laborum sint esse cillum anim nulla cillum consectetur aliqua sit. Nisi excepteur cillum labore amet excepteur commodo enim occaecat consequat ipsum proident exercitation duis id in.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Ipsum et cupidatat mollit exercitation enim duis sunt irure aliqua reprehenderit mollit. Pariatur Lorem pariatur laboris do culpa do elit irure. Eiusmod amet nulla voluptate velit culpa et aliqua ad reprehenderit sit ut.</p>
  </blockquote>
  <figcaption>
    <cite>Ancient Klingon Proverb ( probably )</cite>
  </figcaption>
</figure>

<p>Labore ea magna Lorem consequat aliquip consectetur cillum duis dolore. Et veniam dolor qui incididunt minim amet laboris sit. Dolore ad esse commodo et dolore amet est velit ut nisi ea. Excepteur ea nulla commodo dolore anim dolore adipisicing eiusmod labore id enim esse quis mollit deserunt est. Minim ea culpa voluptate nostrud commodo proident in duis aliquip minim.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Ipsum et cupidatat mollit exercitation enim duis sunt irure aliqua reprehenderit mollit. Pariatur Lorem pariatur laboris do culpa do elit irure. Eiusmod amet nulla voluptate velit culpa et aliqua ad reprehenderit sit ut.</p>
  </blockquote>
  <figcaption>
    <cite>Ancient Klingon Proverb ( probably )</cite>
  </figcaption>
</figure>

<p>Ipsum excepteur cupidatat sunt minim ad eiusmod tempor sit.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Ipsum et cupidatat mollit exercitation enim duis sunt irure aliqua reprehenderit mollit. Pariatur Lorem pariatur laboris do culpa do elit irure. Eiusmod amet nulla voluptate velit culpa et aliqua ad reprehenderit sit ut.</p>
  </blockquote>
  <figcaption>
    <cite>Ancient Klingon Proverb ( probably )</cite>
  </figcaption>
</figure>

<p>Adipisicing voluptate ipsum culpa voluptate id aute laboris labore esse fugiat veniam ullamco occaecat do ut. Tempor et esse reprehenderit veniam proident ipsum irure sit ullamco et labore ea excepteur nulla labore ut. Ex aute minim quis tempor in eu id id irure ea nostrud dolor esse.</p>
<h2 id="lists">
  <a class="heading-link" href="#lists">Lists<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<h3 id="ordered-list">
  <a class="heading-link" href="#ordered-list">Ordered List<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<ol>
<li>Longan</li>
<li>Lychee</li>
<li>Excepteur ad cupidatat do elit laborum amet cillum reprehenderit consequat quis.
Deserunt officia esse aliquip consectetur duis ut labore laborum commodo aliquip aliquip velit pariatur dolore.</li>
<li>Marionberry</li>
<li>Melon
<ol>
<li>Cantaloupe</li>
<li>Honeydew</li>
<li>Watermelon</li>
</ol>
</li>
<li>Miracle fruit</li>
<li>Mulberry</li>
</ol>
<h3 id="unordered-list">
  <a class="heading-link" href="#unordered-list">Unordered List<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<ul>
<li>Olive</li>
<li>Orange
<ul>
<li>Blood orange</li>
<li>Clementine</li>
</ul>
</li>
<li>Papaya</li>
<li>Ut aute ipsum occaecat nisi culpa Lorem id occaecat cupidatat id id magna laboris ad duis. Fugiat cillum dolore veniam nostrud proident sint consectetur eiusmod irure adipisicing.</li>
<li>Passionfruit</li>
</ul>
<h2 id="horizontal-rule">
  <a class="heading-link" href="#horizontal-rule">Horizontal rule<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>In dolore velit aliquip labore mollit minim tempor veniam eu veniam ad in sint aliquip mollit mollit. Ex occaecat non deserunt elit laborum sunt tempor sint consequat culpa culpa qui sit. Irure ad commodo eu voluptate mollit cillum cupidatat veniam proident amet minim reprehenderit.</p>
<hr>
<p>In laboris eiusmod reprehenderit aliquip sit proident occaecat. Non sit labore anim elit veniam Lorem minim commodo eiusmod irure do minim nisi. Dolor amet cillum excepteur consequat sint non sint.</p>
<h2 id="code">
  <a class="heading-link" href="#code">Code<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<h3 id="inline-code">
  <a class="heading-link" href="#inline-code">Inline code<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Ad amet irure est magna id mollit Lorem in do duis enim. Excepteur velit nisi magna ea pariatur pariatur ullamco fugiat deserunt sint non sint. Duis duis est <code>code in text</code> velit velit aute culpa ex quis pariatur pariatur laborum aute pariatur duis tempor sunt ad. Irure magna voluptate dolore consectetur consectetur irure esse. Anim magna <code>&lt;strong&gt;in culpa qui officia&lt;/strong&gt;</code> dolor eiusmod esse amet aute cupidatat aliqua do id voluptate cupidatat reprehenderit amet labore deserunt.</p>
<h3 id="tables">
  <a class="heading-link" href="#tables">Tables<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<table>
	<thead>
			<tr>
					<th style="text-align: left">Complexity</th>
					<th style="text-align: center">Normalized Unit</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: left">Small</td>
					<td style="text-align: center">1</td>
			</tr>
			<tr>
					<td style="text-align: left">Medium</td>
					<td style="text-align: center">2</td>
			</tr>
			<tr>
					<td style="text-align: left">Large</td>
					<td style="text-align: center">3</td>
			</tr>
	</tbody>
</table>
<h3 id="highlighted">
  <a class="heading-link" href="#highlighted">Highlighted<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Et fugiat ad nisi amet magna labore do cillum fugiat occaecat cillum Lorem proident. In sint dolor ullamco ad do adipisicing amet id excepteur Lorem aliquip sit irure veniam laborum duis cillum. Aliqua occaecat minim cillum deserunt magna sunt laboris do do irure ea nostrud consequat ut voluptate ex.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Go</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kn">package</span><span class="w"> </span><span class="nx">main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">import</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="s">&#34;fmt&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="s">&#34;net/http&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">handler</span><span class="p">(</span><span class="nx">w</span><span class="w"> </span><span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span><span class="w"> </span><span class="nx">r</span><span class="w"> </span><span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;Hi there, I love %s!&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">r</span><span class="p">.</span><span class="nx">URL</span><span class="p">.</span><span class="nx">Path</span><span class="p">[</span><span class="mi">1</span><span class="p">:])</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">main</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">http</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">handler</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">http</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">(</span><span class="s">&#34;:8080&#34;</span><span class="p">,</span><span class="w"> </span><span class="kc">nil</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">tmux <span class="nb">set</span> -g status-right <span class="s2">&#34;#[align=absolute-centre]#[fg=#f8f9f2]#W#[fg=#51cd97]#{?client_prefix,#[fg=#d0689d],#[fg=#51cd97]}.#[align=right]#{?window_bigger,[#{window_offset_x}#,#{window_offset_y}] ,}#[fg=#404040,underscore]#S#[none] &#34;</span>
</span></span><span class="line"><span class="cl">tmux <span class="nb">set</span> -g status-style <span class="s2">&#34;bg=#1f1f1f,fg=#f8f8f2&#34;</span>
</span></span><span class="line"><span class="cl">tmux <span class="nb">set</span> -g window-status-current-format <span class="s2">&#34;#{?client_prefix,#[fg=#d0689d],#[fg=#51cd97]}&#34;</span>
</span></span><span class="line"><span class="cl">tmux <span class="nb">set</span> -g window-status-format <span class="s2">&#34;#[fg=#404040]&#34;</span>
</span></span><span class="line"><span class="cl">tmux <span class="nb">set</span> -g status-left <span class="s1">&#39; &#39;</span>
</span></span><span class="line"><span class="cl">tmux set-window-option -g pane-border-style <span class="s2">&#34;fg=#404040&#34;</span>
</span></span><span class="line"><span class="cl">tmux set-window-option -g pane-active-border-style <span class="s2">&#34;fg=#51cd97&#34;</span></span></span></code></pre></div>
</div>
<p>Ex amet id ex aliquip id do laborum excepteur exercitation elit sint commodo occaecat nostrud est. Nostrud pariatur esse veniam laborum non sint magna sit laboris minim in id. Aliqua pariatur pariatur excepteur adipisicing irure culpa consequat commodo et ex id ad.</p>
<h2 id="diagrams">
  <a class="heading-link" href="#diagrams">Diagrams<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p><a href="https://mermaid.js.org/">Mermaid</a> turns text into diagrams. A fenced code block with the <code>mermaid</code> language renders one, and so does the <code>mermaid</code> shortcode. Each diagram is rendered to an SVG at build time and inlined into the page, so no JavaScript ever ships and the site keeps its no-external-requests rule.</p>
<h3 id="flowchart">
  <a class="heading-link" href="#flowchart">Flowchart<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>The example below is this site&rsquo;s own deployment pipeline, end to end: a <code>git push</code> at the desk, through the homelab, out to the Cloudflare edge and finally into a reader&rsquo;s browser. The full story lives in <a href="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/">My Blog Now Ships From My Homelab!</a>.</p>
<figure class="diagram"><svg id="d-c99b82f2b5cf" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 630.789px; background-color: transparent;" viewBox="0 0 630.7890625 1462" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#d-c99b82f2b5cf{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-c99b82f2b5cf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-c99b82f2b5cf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-c99b82f2b5cf .error-icon{fill:#202225;}#d-c99b82f2b5cf .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-c99b82f2b5cf .edge-thickness-normal{stroke-width:1px;}#d-c99b82f2b5cf .edge-thickness-thick{stroke-width:3.5px;}#d-c99b82f2b5cf .edge-pattern-solid{stroke-dasharray:0;}#d-c99b82f2b5cf .edge-thickness-invisible{stroke-width:0;fill:none;}#d-c99b82f2b5cf .edge-pattern-dashed{stroke-dasharray:3;}#d-c99b82f2b5cf .edge-pattern-dotted{stroke-dasharray:2;}#d-c99b82f2b5cf .marker{fill:#8b8b84;stroke:#8b8b84;}#d-c99b82f2b5cf .marker.cross{stroke:#8b8b84;}#d-c99b82f2b5cf svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-c99b82f2b5cf p{margin:0;}#d-c99b82f2b5cf .label{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;color:#32d74b;}#d-c99b82f2b5cf .cluster-label text{fill:#e3e3de;}#d-c99b82f2b5cf .cluster-label span{color:#e3e3de;}#d-c99b82f2b5cf .cluster-label span p{background-color:transparent;}#d-c99b82f2b5cf .label text,#d-c99b82f2b5cf span{fill:#32d74b;color:#32d74b;}#d-c99b82f2b5cf .node rect,#d-c99b82f2b5cf .node circle,#d-c99b82f2b5cf .node ellipse,#d-c99b82f2b5cf .node polygon,#d-c99b82f2b5cf .node path{fill:#1e1f22;stroke:#3c3f43;stroke-width:1px;}#d-c99b82f2b5cf .rough-node .label text,#d-c99b82f2b5cf .node .label text,#d-c99b82f2b5cf .image-shape .label,#d-c99b82f2b5cf .icon-shape .label{text-anchor:middle;}#d-c99b82f2b5cf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#d-c99b82f2b5cf .rough-node .label,#d-c99b82f2b5cf .node .label,#d-c99b82f2b5cf .image-shape .label,#d-c99b82f2b5cf .icon-shape .label{text-align:center;}#d-c99b82f2b5cf .node.clickable{cursor:pointer;}#d-c99b82f2b5cf .root .anchor path{fill:#8b8b84!important;stroke-width:0;stroke:#8b8b84;}#d-c99b82f2b5cf .arrowheadPath{fill:#dfddda;}#d-c99b82f2b5cf .edgePaths .path{stroke:#8b8b84;stroke-width:1px;}#d-c99b82f2b5cf .flowchart-link{stroke:#8b8b84;fill:none;}#d-c99b82f2b5cf .edgeLabel{background-color:#1e1f22;text-align:center;}#d-c99b82f2b5cf .edgeLabel p{background-color:#1e1f22;}#d-c99b82f2b5cf .edgeLabel rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-c99b82f2b5cf .labelBkg{background-color:rgba(30, 31, 34, 0.5);}#d-c99b82f2b5cf .cluster rect{fill:#202225;stroke:#2c2e31;stroke-width:1px;}#d-c99b82f2b5cf .cluster text{fill:#e3e3de;}#d-c99b82f2b5cf .cluster span{color:#e3e3de;}#d-c99b82f2b5cf .node .collapsed-indicator{fill:#2c2e31;stroke:none;opacity:0.6;}#d-c99b82f2b5cf .node .collapsed-separator{stroke:#2c2e31;stroke-width:0.75px;}#d-c99b82f2b5cf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;background:#202225;border:1px solid hsl(216, 0%, 23.5294117647%);border-radius:2px;pointer-events:none;z-index:100;}#d-c99b82f2b5cf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#32d74b;}#d-c99b82f2b5cf rect.text{fill:none;stroke-width:0;}#d-c99b82f2b5cf .icon-shape,#d-c99b82f2b5cf .image-shape{background-color:#1e1f22;text-align:center;}#d-c99b82f2b5cf .icon-shape p,#d-c99b82f2b5cf .image-shape p{background-color:#1e1f22;padding:2px;}#d-c99b82f2b5cf .icon-shape .label rect,#d-c99b82f2b5cf .image-shape .label rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-c99b82f2b5cf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#d-c99b82f2b5cf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#d-c99b82f2b5cf .node .neo-node{stroke:#3c3f43;}#d-c99b82f2b5cf [data-look="neo"].node rect,#d-c99b82f2b5cf [data-look="neo"].cluster rect,#d-c99b82f2b5cf [data-look="neo"].node polygon{stroke:url(#d-c99b82f2b5cf-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-c99b82f2b5cf [data-look="neo"].swimlane.cluster rect{filter:none;}#d-c99b82f2b5cf [data-look="neo"].node path{stroke:url(#d-c99b82f2b5cf-gradient);stroke-width:1px;}#d-c99b82f2b5cf [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-c99b82f2b5cf [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-c99b82f2b5cf [data-look="neo"].node circle{stroke:url(#d-c99b82f2b5cf-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-c99b82f2b5cf [data-look="neo"].node circle .state-start{fill:#000000;}#d-c99b82f2b5cf [data-look="neo"].icon-shape .icon{fill:url(#d-c99b82f2b5cf-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-c99b82f2b5cf [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-c99b82f2b5cf-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-c99b82f2b5cf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="d-c99b82f2b5cf_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="d-c99b82f2b5cf_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"><g class="cluster" id="d-c99b82f2b5cf-cloud" data-look="classic"><rect style="" x="272.7890625" y="1067" width="330" height="265"/><g class="cluster-label" transform="translate(395.640625, 1067)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel"><p>Cloudflare</p></span></div></foreignObject></g></g><g class="cluster" id="d-c99b82f2b5cf-lab" data-look="classic"><rect style="" x="8" y="172" width="614.7890625" height="803"/><g class="cluster-label" transform="translate(151.02734375, 172)"><foreignObject width="328.734375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel"><p>The homelab ( accepts nothing inbound )</p></span></div></foreignObject></g></g><g class="cluster" id="d-c99b82f2b5cf-pipeline" data-look="classic"><rect style="" x="272.7890625" y="462" width="330" height="488"/><g class="cluster-label" transform="translate(311.3515625, 462)"><foreignObject width="252.875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel"><p>Woodpecker agent, one pipeline</p></span></div></foreignObject></g></g></g><g class="edgePaths"><path d="M247.27,80L234.892,87.667C222.513,95.333,197.757,110.667,185.378,126C173,141.333,173,156.667,173,167.833C173,179,173,186,173,189.5L173,193" id="d-c99b82f2b5cf-L_dev_dns_0" class="edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_dev_dns_0" data-points="W3sieCI6MjQ3LjI3MDEwMjg5NjM0MTQ2LCJ5Ijo4MH0seyJ4IjoxNzMsInkiOjEyNn0seyJ4IjoxNzMsInkiOjE3Mn0seyJ4IjoxNzMsInkiOjE5N31d" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M363.519,80L375.897,87.667C388.276,95.333,413.032,110.667,425.411,126C437.789,141.333,437.789,156.667,437.789,169.583C437.789,182.5,437.789,193,437.789,198.25L437.789,203.5" id="d-c99b82f2b5cf-L_dev_forge_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_dev_forge_0" data-points="W3sieCI6MzYzLjUxODk1OTYwMzY1ODUsInkiOjgwfSx7IngiOjQzNy43ODkwNjI1LCJ5IjoxMjZ9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjE3Mn0seyJ4Ijo0MzcuNzg5MDYyNSwieSI6MjA3LjV9XQ==" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M437.789,258.5L437.789,266.167C437.789,273.833,437.789,289.167,437.789,302.083C437.789,315,437.789,325.5,437.789,330.75L437.789,336" id="d-c99b82f2b5cf-L_forge_server_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_forge_server_0" data-points="W3sieCI6NDM3Ljc4OTA2MjUsInkiOjI1OC41fSx7IngiOjQzNy43ODkwNjI1LCJ5IjozMDQuNX0seyJ4Ijo0MzcuNzg5MDYyNSwieSI6MzQwfV0=" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M437.789,391L437.789,396.917C437.789,402.833,437.789,414.667,437.789,426.5C437.789,438.333,437.789,450.167,437.789,459.583C437.789,469,437.789,476,437.789,479.5L437.789,483" id="d-c99b82f2b5cf-L_server_clone_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_server_clone_0" data-points="W3sieCI6NDM3Ljc4OTA2MjUsInkiOjM5MX0seyJ4Ijo0MzcuNzg5MDYyNSwieSI6NDI2LjV9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjQ2Mn0seyJ4Ijo0MzcuNzg5MDYyNSwieSI6NDg3fV0=" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M437.789,559L437.789,563.167C437.789,567.333,437.789,575.667,437.789,583.333C437.789,591,437.789,598,437.789,601.5L437.789,605" id="d-c99b82f2b5cf-L_clone_changelog_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_clone_changelog_0" data-points="W3sieCI6NDM3Ljc4OTA2MjUsInkiOjU1OX0seyJ4Ijo0MzcuNzg5MDYyNSwieSI6NTg0fSx7IngiOjQzNy43ODkwNjI1LCJ5Ijo2MDl9XQ==" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M437.789,681L437.789,685.167C437.789,689.333,437.789,697.667,437.789,705.333C437.789,713,437.789,720,437.789,723.5L437.789,727" id="d-c99b82f2b5cf-L_changelog_build_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_changelog_build_0" data-points="W3sieCI6NDM3Ljc4OTA2MjUsInkiOjY4MX0seyJ4Ijo0MzcuNzg5MDYyNSwieSI6NzA2fSx7IngiOjQzNy43ODkwNjI1LCJ5Ijo3MzF9XQ==" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M437.789,803L437.789,807.167C437.789,811.333,437.789,819.667,437.789,827.333C437.789,835,437.789,842,437.789,845.5L437.789,849" id="d-c99b82f2b5cf-L_build_deploy_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_build_deploy_0" data-points="W3sieCI6NDM3Ljc4OTA2MjUsInkiOjgwM30seyJ4Ijo0MzcuNzg5MDYyNSwieSI6ODI4fSx7IngiOjQzNy43ODkwNjI1LCJ5Ijo4NTN9XQ==" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M437.789,925L437.789,929.167C437.789,933.333,437.789,941.667,437.789,950C437.789,958.333,437.789,966.667,437.789,978.5C437.789,990.333,437.789,1005.667,437.789,1021C437.789,1036.333,437.789,1051.667,437.789,1062.833C437.789,1074,437.789,1081,437.789,1084.5L437.789,1088" id="d-c99b82f2b5cf-L_deploy_pages_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_deploy_pages_0" data-points="W3sieCI6NDM3Ljc4OTA2MjUsInkiOjkyNX0seyJ4Ijo0MzcuNzg5MDYyNSwieSI6OTUwfSx7IngiOjQzNy43ODkwNjI1LCJ5Ijo5NzV9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjEwMjF9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjEwNjd9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjEwOTJ9XQ==" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M437.789,1185L437.789,1189.167C437.789,1193.333,437.789,1201.667,437.789,1209.333C437.789,1217,437.789,1224,437.789,1227.5L437.789,1231" id="d-c99b82f2b5cf-L_pages_edge_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_pages_edge_0" data-points="W3sieCI6NDM3Ljc4OTA2MjUsInkiOjExODV9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjEyMTB9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjEyMzV9XQ==" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/><path d="M437.789,1307L437.789,1311.167C437.789,1315.333,437.789,1323.667,437.789,1333.75C437.789,1343.833,437.789,1355.667,437.789,1366.833C437.789,1378,437.789,1388.5,437.789,1393.75L437.789,1399" id="d-c99b82f2b5cf-L_edge_reader_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_edge_reader_0" data-points="W3sieCI6NDM3Ljc4OTA2MjUsInkiOjEzMDd9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjEzMzJ9LHsieCI6NDM3Ljc4OTA2MjUsInkiOjEzNjcuNX0seyJ4Ijo0MzcuNzg5MDYyNSwieSI6MTQwM31d" data-look="classic" marker-end="url(#d-c99b82f2b5cf_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(173, 126)"><g class="label" data-id="L_dev_dns_0" transform="translate(-100, -21)"><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="edgeLabel"><p>name lookup, internal answer</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(437.7890625, 126)"><g class="label" data-id="L_dev_forge_0" transform="translate(-12.6484375, -10.5)"><foreignObject width="25.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>ssh</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(437.7890625, 304.5)"><g class="label" data-id="L_forge_server_0" transform="translate(-29.5078125, -10.5)"><foreignObject width="59.015625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>webhook</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(437.7890625, 426.5)"><g class="label" data-id="L_server_clone_0" transform="translate(-59.0078125, -10.5)"><foreignObject width="118.015625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>queues the job</p></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_clone_changelog_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_changelog_build_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_build_deploy_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(437.7890625, 1021)"><g class="label" data-id="L_deploy_pages_0" transform="translate(-100, -21)"><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="edgeLabel"><p>outbound only, token scoped to Pages: Edit</p></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_pages_edge_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(437.7890625, 1367.5)"><g class="label" data-id="L_edge_reader_0" transform="translate(-88.5078125, -10.5)"><foreignObject width="177.015625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>https://wilhelm.codes</p></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default" id="d-c99b82f2b5cf-flowchart-dev-0" data-look="classic" transform="translate(305.39453125, 44)"><rect class="basic label-container" style="" x="-130" y="-36" width="260" height="72"/><g class="label" style="" transform="translate(-100, -21)"><rect/><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>The desk: git push to main</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-dns-1" data-look="classic" transform="translate(173, 233)"><rect class="basic label-container" style="" x="-130" y="-36" width="260" height="72"/><g class="label" style="" transform="translate(-100, -21)"><rect/><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>Technitium DNS x3 ( split-horizon )</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-forge-2" data-look="classic" transform="translate(437.7890625, 233)"><rect class="basic label-container" style="" x="-84.7890625" y="-25.5" width="169.578125" height="51"/><g class="label" style="" transform="translate(-54.7890625, -10.5)"><rect/><foreignObject width="109.578125" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Forgejo forge</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-server-3" data-look="classic" transform="translate(437.7890625, 365.5)"><rect class="basic label-container" style="" x="-101.6484375" y="-25.5" width="203.296875" height="51"/><g class="label" style="" transform="translate(-71.6484375, -10.5)"><rect/><foreignObject width="143.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Woodpecker server</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-clone-4" data-look="classic" transform="translate(437.7890625, 523)"><rect class="basic label-container" style="" x="-130" y="-36" width="260" height="72"/><g class="label" style="" transform="translate(-100, -21)"><rect/><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>Full clone ( partial: false, tags: true )</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-changelog-5" data-look="classic" transform="translate(437.7890625, 645)"><rect class="basic label-container" style="" x="-130" y="-36" width="260" height="72"/><g class="label" style="" transform="translate(-100, -21)"><rect/><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>bin/changelog writes data/changelog.json</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-build-6" data-look="classic" transform="translate(437.7890625, 767)"><rect class="basic label-container" style="" x="-130" y="-36" width="260" height="72"/><g class="label" style="" transform="translate(-100, -21)"><rect/><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>hugo --gc builds public/</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-deploy-7" data-look="classic" transform="translate(437.7890625, 889)"><rect class="basic label-container" style="" x="-130" y="-36" width="260" height="72"/><g class="label" style="" transform="translate(-100, -21)"><rect/><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>npx wrangler pages deploy public</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-pages-8" data-look="classic" transform="translate(437.7890625, 1138.5)"><rect class="basic label-container" style="" x="-130" y="-46.5" width="260" height="93"/><g class="label" style="" transform="translate(-100, -31.5)"><rect/><foreignObject width="200" height="63"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>Pages project ( direct upload, production branch: main )</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-edge-9" data-look="classic" transform="translate(437.7890625, 1271)"><rect class="basic label-container" style="" x="-130" y="-36" width="260" height="72"/><g class="label" style="" transform="translate(-100, -21)"><rect/><foreignObject width="200" height="42"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>Edge cache, serves the last good deploy</p></span></div></foreignObject></g></g><g class="node default" id="d-c99b82f2b5cf-flowchart-reader-10" data-look="classic" transform="translate(437.7890625, 1428.5)"><rect class="basic label-container" style="" x="-110.078125" y="-25.5" width="220.15625" height="51"/><g class="label" style="" transform="translate(-80.078125, -10.5)"><rect/><foreignObject width="160.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>A reader, allegedly</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="d-c99b82f2b5cf-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><defs><filter id="d-c99b82f2b5cf-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><linearGradient id="d-c99b82f2b5cf-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3c3f43" stop-opacity="1"/><stop offset="100%" stop-color="hsl(216, 0%, 23.5294117647%)" stop-opacity="1"/></linearGradient></svg></figure>
<h3 id="sequence">
  <a class="heading-link" href="#sequence">Sequence<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>The same pipeline again, compressed into a handshake. This one is a plain <code>mermaid</code> fence instead of the shortcode. Both forms render the same way.</p>
<figure class="diagram"><svg id="d-fbe8de6d2e78" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="max-width: 865px; background-color: transparent;" viewBox="-50 -10 865 421" role="graphics-document document" aria-roledescription="sequence"><g><rect x="615" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="C" rx="3" ry="3" class="actor actor-bottom"/><text x="690" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="690" dy="0">Cloudflare Pages</tspan></text></g><g><rect x="400" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-bottom"/><text x="475" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="475" dy="0">Woodpecker</tspan></text></g><g><rect x="200" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="F" rx="3" ry="3" class="actor actor-bottom"/><text x="275" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="275" dy="0">Forgejo</tspan></text></g><g><rect x="0" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-bottom"/><text x="75" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">The desk</tspan></text></g><g><line id="actor3" x1="690" y1="65" x2="690" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="C" data-et="life-line" data-id="C"/><g id="root-3" data-et="participant" data-type="participant" data-id="C"><rect x="615" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="C" rx="3" ry="3" class="actor actor-top"/><text x="690" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="690" dy="0">Cloudflare Pages</tspan></text></g></g><g><line id="actor2" x1="475" y1="65" x2="475" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="W" data-et="life-line" data-id="W"/><g id="root-2" data-et="participant" data-type="participant" data-id="W"><rect x="400" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-top"/><text x="475" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="475" dy="0">Woodpecker</tspan></text></g></g><g><line id="actor1" x1="275" y1="65" x2="275" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="F" data-et="life-line" data-id="F"/><g id="root-1" data-et="participant" data-type="participant" data-id="F"><rect x="200" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="F" rx="3" ry="3" class="actor actor-top"/><text x="275" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="275" dy="0">Forgejo</tspan></text></g></g><g><line id="actor0" x1="75" y1="65" x2="75" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="D" data-et="life-line" data-id="D"/><g id="root-0" data-et="participant" data-type="participant" data-id="D"><rect x="0" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-top"/><text x="75" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">The desk</tspan></text></g></g><style>#d-fbe8de6d2e78{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-fbe8de6d2e78 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-fbe8de6d2e78 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-fbe8de6d2e78 .error-icon{fill:#202225;}#d-fbe8de6d2e78 .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-fbe8de6d2e78 .edge-thickness-normal{stroke-width:1px;}#d-fbe8de6d2e78 .edge-thickness-thick{stroke-width:3.5px;}#d-fbe8de6d2e78 .edge-pattern-solid{stroke-dasharray:0;}#d-fbe8de6d2e78 .edge-thickness-invisible{stroke-width:0;fill:none;}#d-fbe8de6d2e78 .edge-pattern-dashed{stroke-dasharray:3;}#d-fbe8de6d2e78 .edge-pattern-dotted{stroke-dasharray:2;}#d-fbe8de6d2e78 .marker{fill:#8b8b84;stroke:#8b8b84;}#d-fbe8de6d2e78 .marker.cross{stroke:#8b8b84;}#d-fbe8de6d2e78 svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-fbe8de6d2e78 p{margin:0;}#d-fbe8de6d2e78 .actor{stroke:#3c3f43;fill:#1e1f22;stroke-width:1;}#d-fbe8de6d2e78 rect.actor.outer-path[data-look="neo"]{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-fbe8de6d2e78 rect.note[data-look="neo"]{stroke:hsl(52.6829268293, 60%, 93.9215686275%);fill:#fff5ad;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-fbe8de6d2e78 text.actor&gt;tspan{fill:#32d74b;stroke:none;}#d-fbe8de6d2e78 .actor-line{stroke:#8b8b84;}#d-fbe8de6d2e78 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#d-fbe8de6d2e78 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#8b8b84;}#d-fbe8de6d2e78 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#8b8b84;}#d-fbe8de6d2e78 [id$="-arrowhead"] path{fill:#8b8b84;stroke:#8b8b84;}#d-fbe8de6d2e78 .sequenceNumber{fill:#74747b;}#d-fbe8de6d2e78 [id$="-sequencenumber"]{fill:#8b8b84;}#d-fbe8de6d2e78 [id$="-crosshead"] path{fill:#8b8b84;stroke:#8b8b84;}#d-fbe8de6d2e78 .messageText{fill:#32d74b;stroke:none;}#d-fbe8de6d2e78 .labelBox{stroke:#3c3f43;fill:#1e1f22;filter:none;}#d-fbe8de6d2e78 .labelText,#d-fbe8de6d2e78 .labelText&gt;tspan{fill:#32d74b;stroke:none;}#d-fbe8de6d2e78 .loopText,#d-fbe8de6d2e78 .loopText&gt;tspan{fill:#32d74b;stroke:none;}#d-fbe8de6d2e78 .sectionTitle,#d-fbe8de6d2e78 .sectionTitle&gt;tspan{fill:#32d74b;stroke:none;}#d-fbe8de6d2e78 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#3c3f43;fill:#3c3f43;}#d-fbe8de6d2e78 .note{stroke:hsl(52.6829268293, 60%, 93.9215686275%);fill:#fff5ad;}#d-fbe8de6d2e78 .noteText,#d-fbe8de6d2e78 .noteText&gt;tspan{fill:#333;stroke:none;font-weight:normal;}#d-fbe8de6d2e78 .activation0{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-fbe8de6d2e78 .activation1{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-fbe8de6d2e78 .activation2{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-fbe8de6d2e78 .actorPopupMenu{position:absolute;}#d-fbe8de6d2e78 .actorPopupMenuPanel{position:absolute;fill:#1e1f22;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#d-fbe8de6d2e78 .actor-man circle,#d-fbe8de6d2e78 line{fill:#1e1f22;stroke-width:2px;}#d-fbe8de6d2e78 g rect.rect{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));stroke:#3c3f43;}#d-fbe8de6d2e78 .node .neo-node{stroke:#3c3f43;}#d-fbe8de6d2e78 [data-look="neo"].node rect,#d-fbe8de6d2e78 [data-look="neo"].cluster rect,#d-fbe8de6d2e78 [data-look="neo"].node polygon{stroke:url(#d-fbe8de6d2e78-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-fbe8de6d2e78 [data-look="neo"].swimlane.cluster rect{filter:none;}#d-fbe8de6d2e78 [data-look="neo"].node path{stroke:url(#d-fbe8de6d2e78-gradient);stroke-width:1px;}#d-fbe8de6d2e78 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-fbe8de6d2e78 [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-fbe8de6d2e78 [data-look="neo"].node circle{stroke:url(#d-fbe8de6d2e78-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-fbe8de6d2e78 [data-look="neo"].node circle .state-start{fill:#000000;}#d-fbe8de6d2e78 [data-look="neo"].icon-shape .icon{fill:url(#d-fbe8de6d2e78-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-fbe8de6d2e78 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-fbe8de6d2e78-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-fbe8de6d2e78 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g/><defs><symbol id="d-fbe8de6d2e78-computer" width="24" height="24"><path transform="scale(.5)" d="M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z"/></symbol></defs><defs><symbol id="d-fbe8de6d2e78-database" fill-rule="evenodd" clip-rule="evenodd"><path transform="scale(.5)" d="M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z"/></symbol></defs><defs><symbol id="d-fbe8de6d2e78-clock" width="24" height="24"><path transform="scale(.5)" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z"/></symbol></defs><defs><marker id="d-fbe8de6d2e78-arrowhead" refX="7.9" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M -1 0 L 10 5 L 0 10 z"/></marker></defs><defs><marker id="d-fbe8de6d2e78-crosshead" markerWidth="15" markerHeight="8" orient="auto" refX="4" refY="4.5"><path fill="none" stroke="#000000" stroke-width="1pt" d="M 1,2 L 6,7 M 6,2 L 1,7" style="stroke-dasharray: 0, 0;"/></marker></defs><defs><marker id="d-fbe8de6d2e78-filled-head" refX="15.5" refY="7" markerWidth="20" markerHeight="28" orient="auto"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker id="d-fbe8de6d2e78-sequencenumber" refX="15" refY="15" markerWidth="60" markerHeight="40" orient="auto"><circle cx="15" cy="15" r="6"/></marker></defs><defs><marker id="d-fbe8de6d2e78-solidTopArrowHead" refX="7.9" refY="7.25" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 8 L 0 8 z"/></marker></defs><defs><marker id="d-fbe8de6d2e78-solidBottomArrowHead" refX="7.9" refY="0.75" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 0 L 0 8 z"/></marker></defs><defs><marker id="d-fbe8de6d2e78-stickTopArrowHead" refX="7.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 7 7" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><defs><marker id="d-fbe8de6d2e78-stickBottomArrowHead" refX="7.5" refY="0" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 7 L 7 0" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><text x="174" y="80" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">git push ( main )</text><line x1="76" y1="109" x2="271" y2="109" class="messageLine0" data-et="message" data-id="i0" data-from="D" data-to="F" stroke-width="2" stroke="none" marker-end="url(#d-fbe8de6d2e78-arrowhead)" style="fill: none;"/><text x="374" y="124" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">webhook</text><line x1="276" y1="153" x2="471" y2="153" class="messageLine0" data-et="message" data-id="i1" data-from="F" data-to="W" stroke-width="2" stroke="none" marker-end="url(#d-fbe8de6d2e78-arrowhead)" style="fill: none;"/><text x="476" y="168" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">clone, changelog, hugo --gc</text><path d="M 476,197 C 536,187 536,227 476,217" class="messageLine0" data-et="message" data-id="i2" data-from="W" data-to="W" stroke-width="2" stroke="none" marker-end="url(#d-fbe8de6d2e78-arrowhead)" style="fill: none;"/><text x="581" y="242" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">wrangler pages deploy</text><line x1="476" y1="271" x2="686" y2="271" class="messageLine0" data-et="message" data-id="i3" data-from="W" data-to="C" stroke-width="2" stroke="none" marker-end="url(#d-fbe8de6d2e78-arrowhead)" style="fill: none;"/><text x="384" y="286" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">the same site, but newer</text><line x1="689" y1="315" x2="79" y2="315" class="messageLine1" data-et="message" data-id="i4" data-from="C" data-to="D" stroke-width="2" stroke="none" marker-end="url(#d-fbe8de6d2e78-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"/></svg></figure><h2 id="inline-elements">
  <a class="heading-link" href="#inline-elements">Inline elements<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Sint ea anim ipsum ad commodo cupidatat do <strong>exercitation</strong> incididunt et minim ad labore sunt. Minim deserunt labore laboris velit nulla incididunt ipsum nulla. Ullamco ad laborum ea qui et anim in laboris exercitation tempor sit officia laborum reprehenderit culpa velit quis. <strong>Consequat commodo</strong> reprehenderit duis <a href="#!">irure</a> esse esse exercitation minim enim Lorem dolore duis irure. Nisi Lorem reprehenderit ea amet excepteur dolor excepteur magna labore proident voluptate ipsum. Reprehenderit ex esse deserunt aliqua ea officia mollit Lorem nulla magna enim. Et ad ipsum labore enim ipsum <strong>cupidatat consequat</strong>. Commodo non ea cupidatat magna deserunt dolore ipsum velit nulla elit veniam nulla eiusmod proident officia.</p>
<p><img
  src="https://wilhelm.codes/blog/markdown/city-above_hu_5649e0e90a2a3de4.webp"
  srcset="/blog/markdown/city-above_hu_5649e0e90a2a3de4.webp 736w, /blog/markdown/city-above_hu_155d0a5ddcc6b38d.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="414"
  alt="A city from above at night"
  
  loading="lazy"
  decoding="async"
/>
</p>
<p><em>Proident sit veniam in est proident officia adipisicing</em> ea tempor cillum non cillum velit deserunt. Voluptate laborum incididunt sit consectetur Lorem irure incididunt voluptate nostrud. Commodo ut eiusmod tempor cupidatat esse enim minim ex anim consequat. Mollit sint culpa qui laboris quis consectetur ad sint esse. Amet anim anim minim ullamco et duis non irure. Sit tempor adipisicing ea laboris <code>culpa ex duis sint</code> anim aute reprehenderit id eu ea. Aute <a href="#!">excepteur proident</a> Lorem minim adipisicing nostrud mollit ad ut voluptate do nulla esse occaecat aliqua sint anim.</p>
<p>Incididunt in culpa cupidatat mollit cillum qui proident sit. In cillum aliquip incididunt voluptate magna amet cupidatat cillum pariatur sint aliqua est <em>enim <strong>anim</strong> voluptate</em>. Magna aliquip proident incididunt id duis pariatur eiusmod incididunt commodo culpa dolore sit. Culpa do nostrud elit ad exercitation anim pariatur non minim nisi <strong>adipisicing sunt <em>officia</em></strong>. Do deserunt magna mollit Lorem commodo ipsum do cupidatat mollit enim ut elit veniam ea voluptate.</p>
<p>Reprehenderit non eu quis in ad elit esse qui aute id <a href="#!">incididunt</a> dolore cillum. Esse laboris consequat dolor anim exercitation tempor aliqua deserunt velit magna laboris. Culpa culpa minim duis amet mollit do quis amet commodo nulla irure.</p>
<h2 id="callouts">
  <a class="heading-link" href="#callouts">Callouts<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Nostrud ex consequat id incididunt proident ipsum minim duis aliqua ut ex et ad quis.</p>
<div class="callout callout-notice">
  A notice. Duis duis est velit velit aute culpa ex quis pariatur laborum aute pariatur.
</div>

<div class="callout callout-info">
  Some information. Excepteur velit nisi magna ea pariatur ullamco fugiat deserunt sint.
</div>

<div class="callout callout-warning">
  A warning. Anim magna dolor eiusmod esse amet aute cupidatat aliqua do id voluptate.
</div>

<div class="callout callout-error">
  An error. Irure magna voluptate dolore consectetur consectetur irure esse.
</div>

<h2 id="images">
  <a class="heading-link" href="#images">Images<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Every image on this page is <a href="https://unsplash.com/">Unsplash</a> stock, hosted here so the site keeps its no-external-requests rule. Click any of them to zoom in. Click again, or press Escape, to put it back.</p>
<h3 id="single">
  <a class="heading-link" href="#single">Single<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>A lone image takes the <code>img</code> shortcode: a figure, an optional attribution underneath and the zoom.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A wet street at night in Tokyo, neon signs reflecting on the pavement">
      <img
  src="https://wilhelm.codes/blog/markdown/tokyo-street_hu_e5c50287801d328c.webp"
  srcset="/blog/markdown/tokyo-street_hu_e5c50287801d328c.webp 736w, /blog/markdown/tokyo-street_hu_ba568f598d59a3f9.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="1103"
  alt="A wet street at night in Tokyo, neon signs reflecting on the pavement"
  loading="lazy"
  decoding="async"
  data-full="/blog/markdown/tokyo-street_hu_784bc02980fe6a3e.webp"
  data-credit="Photo by Valentin BEAUVAIS on Unsplash"
  data-credit-href="https://unsplash.com/photos/wet-street-at-night-in-tokyo-yVUQlyRlJSw"
/>

    </button>
    
      <figcaption>
        
          <a href="https://unsplash.com/photos/wet-street-at-night-in-tokyo-yVUQlyRlJSw" target="_blank" rel="noopener">Photo by Valentin BEAUVAIS on Unsplash</a>
        
      </figcaption>
    
  </figure>
<p>Aute officia nulla deserunt do deserunt cillum velit magna. Officia veniam culpa anim minim dolore labore pariatur voluptate id ad est duis quis velit dolor pariatur enim.</p>
<h3 id="strip">
  <a class="heading-link" href="#strip">Strip<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>A run of images scrolls sideways in the <code>strip</code> shortcode. Each image keeps its own zoom, and its credit follows it into the lightbox.</p>
<div class="strip">
  
  <button type="button" class="zoom" aria-label="Zoom image: Red neon signs reflected in a street puddle">
      <img
    src="https://wilhelm.codes/blog/markdown/neon-puddle_hu_7b6d6f59e5ddfc10.webp"
    srcset="/blog/markdown/neon-puddle_hu_7b6d6f59e5ddfc10.webp 1x, /blog/markdown/neon-puddle_hu_3647ba2eb6e24731.webp 2x"
    
    width="336"
    height="224"
    alt="Red neon signs reflected in a street puddle"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/neon-puddle_hu_ff42f54c862d30e7.webp"
    data-credit="Photo by cheng feng on Unsplash"
    data-credit-href="https://unsplash.com/photos/neon-street-reflection-in-night-puddle-4pTY4ty7jXM"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: A city from above at night">
      <img
    src="https://wilhelm.codes/blog/markdown/city-above_hu_3d2045a0494e4370.webp"
    srcset="/blog/markdown/city-above_hu_3d2045a0494e4370.webp 1x, /blog/markdown/city-above_hu_df60f704bb22bbba.webp 2x"
    
    width="398"
    height="224"
    alt="A city from above at night"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/city-above_hu_56bb6d6a477e88e7.webp"
    data-credit="Photo by Francois Hoang on Unsplash"
    data-credit-href="https://unsplash.com/photos/high-angle-photography-of-city-during-nighttime-MFYlCoSm-0o"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: People walking through a neon walkway at night">
      <img
    src="https://wilhelm.codes/blog/markdown/walkway_hu_b24822e8741b3d78.webp"
    srcset="/blog/markdown/walkway_hu_b24822e8741b3d78.webp 1x, /blog/markdown/walkway_hu_824953d065f58427.webp 2x"
    
    width="336"
    height="224"
    alt="People walking through a neon walkway at night"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/walkway_hu_7016b54a2c5bbc52.webp"
    data-credit="Photo by kevin laminto on Unsplash"
    data-credit-href="https://unsplash.com/photos/people-walking-on-walkway-during-nighttime-7PqRZK6rbaE"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: A crowded street at night">
      <img
    src="https://wilhelm.codes/blog/markdown/night-crowd_hu_e5982ecec0b9b59e.webp"
    srcset="/blog/markdown/night-crowd_hu_e5982ecec0b9b59e.webp 1x, /blog/markdown/night-crowd_hu_2a16cc909794e2ae.webp 2x"
    
    width="149"
    height="224"
    alt="A crowded street at night"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/night-crowd_hu_35abd7f5b2b88018.webp"
    data-credit="Photo by Harrison Qi on Unsplash"
    data-credit-href="https://unsplash.com/photos/people-walking-on-street-during-nighttime-ce-K_rj1P7U"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: Night traffic between high-rise buildings">
      <img
    src="https://wilhelm.codes/blog/markdown/night-traffic_hu_c8b96f20fb90ea41.webp"
    srcset="/blog/markdown/night-traffic_hu_c8b96f20fb90ea41.webp 1x, /blog/markdown/night-traffic_hu_abe8f08cc3fcb6c0.webp 2x"
    
    width="149"
    height="224"
    alt="Night traffic between high-rise buildings"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/night-traffic_hu_c356e8d14ec539d5.webp"
    data-credit="Photo by Stéphan Valentin on Unsplash"
    data-credit-href="https://unsplash.com/photos/vehicles-on-road-near-high-rise-buildings-oqYLdbuJDQU"
  />
  
    </button>

</div>

<p>Labore ea magna Lorem consequat aliquip consectetur cillum duis dolore. Et veniam dolor qui incididunt minim amet laboris sit.</p>
<h3 id="masonry">
  <a class="heading-link" href="#masonry">Masonry<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>The <code>masonry</code> shortcode packs a wall of mixed heights into columns. Same zoom, same credits.</p>
<div class="masonry">
  
  <button type="button" class="zoom" aria-label="Zoom image: A vintage grey game console and joystick on a magenta background">
      <img
    src="https://wilhelm.codes/blog/markdown/console_hu_9f8704775736b8c4.webp"
    srcset="/blog/markdown/console_hu_9f8704775736b8c4.webp 232w, /blog/markdown/console_hu_85d24f50a46dd7b6.webp 464w, /blog/markdown/console_hu_fa24477c2b0f7c11.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="155"
    alt="A vintage grey game console and joystick on a magenta background"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/console_hu_12e3b3a05e270cdb.webp"
    data-credit="Photo by Lorenzo Herrera on Unsplash"
    data-credit-href="https://unsplash.com/photos/vintage-gray-game-console-and-joystick-p0j-mE6mGo4"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: Cabled network switches in a rack">
      <img
    src="https://wilhelm.codes/blog/markdown/server-cables_hu_5a0026c5dfacf58d.webp"
    srcset="/blog/markdown/server-cables_hu_5a0026c5dfacf58d.webp 232w, /blog/markdown/server-cables_hu_96bc67206c0724c3.webp 464w, /blog/markdown/server-cables_hu_93866602d9487e49.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="130"
    alt="Cabled network switches in a rack"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/server-cables_hu_d7489352bd0202f6.webp"
    data-credit="Photo by Taylor Vick on Unsplash"
    data-credit-href="https://unsplash.com/photos/cable-network-M5tzZtFCOfs"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: A dark blue circuit board up close">
      <img
    src="https://wilhelm.codes/blog/markdown/circuit-blue_hu_fb606dd5b10cb137.webp"
    srcset="/blog/markdown/circuit-blue_hu_fb606dd5b10cb137.webp 232w, /blog/markdown/circuit-blue_hu_24e1ca785ff29b94.webp 464w, /blog/markdown/circuit-blue_hu_ce5aa786d6004352.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="174"
    alt="A dark blue circuit board up close"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/circuit-blue_hu_e79783b2bbe48544.webp"
    data-credit="Photo by Vishnu Mohanan on Unsplash"
    data-credit-href="https://unsplash.com/photos/close-up-of-dark-blue-circuit-board-pfR18JNEMv8"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: A stack of assorted CRT monitors">
      <img
    src="https://wilhelm.codes/blog/markdown/crt-stack_hu_e2450c79214f8261.webp"
    srcset="/blog/markdown/crt-stack_hu_e2450c79214f8261.webp 232w, /blog/markdown/crt-stack_hu_d484ff96005f12cb.webp 464w, /blog/markdown/crt-stack_hu_14a7def7bc889fb5.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="154"
    alt="A stack of assorted CRT monitors"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/crt-stack_hu_ec7865487d822f93.webp"
    data-credit="Photo by Rubenz Arizta on Unsplash"
    data-credit-href="https://unsplash.com/photos/assorted-crt-monitor-3uQWtvUPjyg"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: A red and black circuit board">
      <img
    src="https://wilhelm.codes/blog/markdown/circuit-red_hu_8d921bca68ecd16.webp"
    srcset="/blog/markdown/circuit-red_hu_8d921bca68ecd16.webp 232w, /blog/markdown/circuit-red_hu_2d90e29275615c6e.webp 464w, /blog/markdown/circuit-red_hu_7f68810c0c65d632.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="182"
    alt="A red and black circuit board"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/circuit-red_hu_7df031890e235073.webp"
    data-credit="Photo by Michael Dziedzic on Unsplash"
    data-credit-href="https://unsplash.com/photos/red-and-black-circuit-board-aQYgUYwnCsM"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: A green motherboard in tilt-shift focus">
      <img
    src="https://wilhelm.codes/blog/markdown/motherboard_hu_98700506ebffc8ba.webp"
    srcset="/blog/markdown/motherboard_hu_98700506ebffc8ba.webp 232w, /blog/markdown/motherboard_hu_def8aa27e2e417a6.webp 464w, /blog/markdown/motherboard_hu_84f3893b6a0f3cc2.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="155"
    alt="A green motherboard in tilt-shift focus"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/motherboard_hu_3d740513539771e0.webp"
    data-credit="Photo by Chris Ried on Unsplash"
    data-credit-href="https://unsplash.com/photos/tilt-shift-photography-of-green-computer-motherboard-bN5XdU-bap4"
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: A black CRT beside a white keyboard">
      <img
    src="https://wilhelm.codes/blog/markdown/crt-keyboard_hu_b5498c4eeeca1fc4.webp"
    srcset="/blog/markdown/crt-keyboard_hu_b5498c4eeeca1fc4.webp 232w, /blog/markdown/crt-keyboard_hu_8188fb2b95493889.webp 464w, /blog/markdown/crt-keyboard_hu_5b37d15cf9925867.webp 696w"
    sizes="(max-width: 40rem) 50vw, 232px"
    width="232"
    height="155"
    alt="A black CRT beside a white keyboard"
    loading="lazy"
    decoding="async"
    data-full="/blog/markdown/crt-keyboard_hu_a3a7b0cd64792094.webp"
    data-credit="Photo by Senad Palic on Unsplash"
    data-credit-href="https://unsplash.com/photos/black-crt-tv-beside-white-computer-keyboard-js39s4h6dt8"
  />
  
    </button>

</div>

<p>Ex amet id ex aliquip id do laborum excepteur exercitation elit sint commodo occaecat nostrud est.</p>]]></content:encoded></item><item><title>The Longer Something Doesn't Happen, the Sooner it Will</title><link>https://wilhelm.codes/blog/the-longer-something-doesnt-happen-the-sooner-it-will/</link><pubDate>Sat, 27 Jan 2024 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/the-longer-something-doesnt-happen-the-sooner-it-will/</guid><category>devops</category><category>sre</category><category>chaos-engineering</category><wc:kind>post</wc:kind><description>This is often referred to as the &amp;ldquo;Mean Time Between Failures (MTBF)&amp;rdquo; in the context of Site Reliability Engineering. It&amp;rsquo;s a somewhat counterintuitive concept that highlights the fact that failures or incidents tend to occur when you least expect them, especially if you haven&amp;rsquo;t experienced one for a while. While it may sound paradoxical, there is some reasoning behind it.</description><content:encoded><![CDATA[<p>This is often referred to as the &ldquo;<a href="https://en.wikipedia.org/wiki/Mean_time_between_failures">Mean Time Between Failures (MTBF)</a>&rdquo; in the context of Site Reliability Engineering. It&rsquo;s a somewhat counterintuitive concept that highlights the fact that failures or incidents tend to occur when you least expect them, especially if you haven&rsquo;t experienced one for a while. While it may sound paradoxical, there is some reasoning behind it.</p>
<h2 id="accumulation-of-underlying-issues">
  <a class="heading-link" href="#accumulation-of-underlying-issues">Accumulation of Underlying Issues<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Over time, systems and processes can accumulate small issues, technical debt, or unnoticed problems. These issues can build up, leading to a higher likelihood of a significant failure or incident occurring as time goes on.</p>
<h2 id="complacency-and-reduced-vigilance">
  <a class="heading-link" href="#complacency-and-reduced-vigilance">Complacency and Reduced Vigilance<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>When a system or service has been running smoothly for an extended period, teams may become complacent and less vigilant. They might not be as proactive in monitoring, testing, and maintaining the system, which can increase the risk of failure.</p>
<h2 id="evolving-environments">
  <a class="heading-link" href="#evolving-environments">Evolving Environments<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>As technology and business environments evolve — as they inevitably do in our space —, the context in which a system operates also changes. What was once a stable and reliable configuration may no longer be suitable, leading to unexpected issues or failures when the system is finally pushed to its limits.</p>
<h2 id="regression-to-the-mean">
  <a class="heading-link" href="#regression-to-the-mean">Regression to the Mean<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p><a href="https://en.wikipedia.org/wiki/Law_of_large_numbers">The law of large numbers</a> suggests that over time, events tend to revert to their average or &ldquo;mean&rdquo; frequency. If you&rsquo;ve experienced an unusually long period without incidents, statistics may suggest that you&rsquo;re due for one soon, just as a run of heads in a coin toss doesn&rsquo;t make tails any less likely on the next toss.</p>
<h2 id="maintaining-awareness">
  <a class="heading-link" href="#maintaining-awareness">Maintaining Awareness<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>The price of <s>freedom</s> stability is eternal vigilance.</p>
  </blockquote>
  <figcaption>
    <cite>Ancient Klingon Proverb ( probably )</cite>
  </figcaption>
</figure>

<h3 id="mitigation-strategies">
  <a class="heading-link" href="#mitigation-strategies">Mitigation Strategies<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Recognize the importance of proactively addressing issues before they accumulate. This involves regular monitoring, capacity planning, load testing, and maintenance to reduce the likelihood of a sudden failure.</p>
<h3 id="risk-management">
  <a class="heading-link" href="#risk-management">Risk Management<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Focus on identifying and managing risks, even during periods of relative stability. They plan for various failure scenarios and aim to minimize their impact through redundancy, graceful degradation, and fault-tolerant design.</p>
<h3 id="continuous-improvement">
  <a class="heading-link" href="#continuous-improvement">Continuous improvement<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Promote a culture of continuous improvement, encouraging teams to learn from past incidents, conduct post-mortems, even live-fire exercises and apply those lessons to prevent similar issues in the future.</p>
<h3 id="metrics--monitoring">
  <a class="heading-link" href="#metrics--monitoring">Metrics &amp; Monitoring<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Use metrics and monitoring tools to maintain a vigilant eye on system health and performance. They set thresholds and alarms to detect anomalies early, regardless of how long it&rsquo;s been since the last incident.</p>
<p>At any given point your production workloads may be operating under any number of unknown failure modes. Things break; it&rsquo;s inevitable. Adjust your expectations accordingly and build around this fact.</p>
<h2 id="in-conclusion">
  <a class="heading-link" href="#in-conclusion">In Conclusion<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The idea that the longer something doesn&rsquo;t happen, the sooner it will is a <em>reminder</em> of the importance of vigilance, proactive maintenance, and risk management not only in site reliability engineering, but software engineering as a whole.</p>
<p>This may not be a deterministic law as it highlights the tendency for issues to accumulate over time if not addressed, making it crucial to maintain a robust and resilient system.</p>]]></content:encoded></item><item><title>Why I Built Plant Smart</title><link>https://wilhelm.codes/blog/why-i-built-plant-smart/</link><pubDate>Sun, 08 Jan 2023 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/why-i-built-plant-smart/</guid><category>svelte</category><category>plantsm.art</category><wc:kind>post</wc:kind><description>Simply put, I love plants and I love animals. I can&amp;rsquo;t count how many times I&amp;rsquo;ve been to a plant nursery and had to stop and do a Google search to see if something was safe enough to bring home and keep around our little Pandora, or 🐼 for short.</description><content:encoded><![CDATA[<p>Simply put, I love plants and I love animals. I can&rsquo;t count how many times I&rsquo;ve been to a plant nursery and had to stop and do a Google search to see if something was safe enough to bring home and keep around our little Pandora, or 🐼 for short.</p>
<h2 id="its-as-simple-as-that">
  <a class="heading-link" href="#its-as-simple-as-that">It&rsquo;s as simple as that?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>That was the idea, at least. I just wanted a single place that had all the information I needed so I could make informed purchasing decisions. I also figured I couldn&rsquo;t possibly be the only person who has this problem. So, I set off to make this project during the holiday break of 2022.</p>
<p>However, I didn&rsquo;t want to make it <em>too</em> easy for myself&hellip;</p>
<h2 id="a-challenge">
  <a class="heading-link" href="#a-challenge">A challenge!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I work as a weird combination of <a href="https://en.wikipedia.org/wiki/Site_reliability_engineering">SRE</a> and <a href="https://en.wikipedia.org/wiki/DevOps">DevOps engineer</a>. I have plenty of smarts when it comes to the technical side of hosting and running things, but it&rsquo;s been years since I&rsquo;ve done any front-end work. Career-wise, I need to be able to speak the same language as the teams I have to support. So, understanding how far behind that portion of my skillset had become, I thought this would be the perfect opportunity to brush up.</p>
<h3 id="the-requirements">
  <a class="heading-link" href="#the-requirements">The Requirements:<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<ul>
<li>Must keep operational costs as close to zero as possible. The only money I&rsquo;ve put down on this so far is the $20 AUD to purchase the domain name.</li>
<li>Must be hosted on <a href="https://pages.cloudflare.com/">Cloudflare Pages</a>. I could&rsquo;ve chosen <a href="https://pages.github.com/">Github Pages</a> to keep everything in one place, but automated Cloudflare builds &amp; deployments work out of the box with minimal configuration. Besides, I&rsquo;ve already worked with the latter and I wanted the challenge of trying something new.</li>
<li>Must be completely static. There should be no server-side rendering, processing or other explicit backend dependencies to manage. For this, I use the pre-rendering functionality that comes packaged with <a href="https://kit.svelte.dev/">SvelteKit</a>. I&rsquo;ve been working with this for only a few weeks now and I&rsquo;m a full convert.</li>
<li>All data must be served statically as well. The entirety of the data set is contained within a single JSON file, which you can view at <a href="https://plantsm.art/plants.json">/plants.json</a>. This is the source of truth for all derivative data sets and lookup tables used on this site. It&rsquo;s effectively what I call a &ldquo;dumb API&rdquo;. Check out the <a href="http://localhost:5173/api">API documentation</a> if you&rsquo;d like to know more about it.</li>
<li>Must use <a href="https://kit.svelte.dev/">SvelteKit</a>, <a href="https://www.typescriptlang.org/">TypeScript</a>, <a href="https://tailwindcss.com/">TailwindCSS</a> and <a href="https://vitejs.dev/">Vite</a>. I had zero working knowledge of any of these and my frontend peers can&rsquo;t seem to shut up about them. So&hellip; why not?</li>
<li>Must be <em>fast</em>. Everything is static, compressed, cached and sitting behind a world-class CDN. I&rsquo;ve worked in a network performance and load testing SaaS for close to 5 years now. I wouldn&rsquo;t be able to look myself in the mirror if I couldn&rsquo;t easily do this one. 😅</li>
<li>Must be open-source and community-driven. At one point, I would like to take the hands off the wheel and see if other interested parties would like to get involved and help out with managing datasets and fixing bugs. GitHub allows for pretty much all of this. Check how to <a href="http://plantsm.art/contribute">contribute</a> or see the <a href="http://plantsm.art/updates">latest contributions</a>.</li>
</ul>
<p>So far, it&rsquo;s going quite well. I haven&rsquo;t had any issues with meeting any of these self-imposed development constraints.</p>
<h3 id="any-caveats">
  <a class="heading-link" href="#any-caveats">Any Caveats?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Definitely. The most obvious one would be image storage which has a direct effect on not only repository size — which currently clocks in at over 1.5GB — but, also build and deployment speeds. I could shell out $5 - $10 for object storage and image processing, but that would go against the first constraint. For now, image data will live in the GitHub repository as a perfectly reasonable compromise.</p>
<h2 id="where-did-you-source-all-this-data">
  <a class="heading-link" href="#where-did-you-source-all-this-data">Where did you source all this data?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There are quite a few sources I&rsquo;ve collated from, but these are the main ones.</p>
<ol>
<li><a href="https://www.inaturalist.org/">iNaturalist</a> is the best source of high-quality, community-driven creative commons license photography. All images have been sourced from this site along with licensing and attribution data.</li>
<li><a href="https://www.aspca.org/">ASPCA</a> was used to initially prime the first dataset. This is also where I sourced most of the common name and symptom data.</li>
<li><a href="https://en.wikipedia.org/wiki/Plant">Wikipedia</a> is the best source of scientific classification data out there.</li>
</ol>
<p>All of this disparate data was collated and munged together by several processing scripts written in <a href="https://go.dev/">Go</a> as <a href="https://magefile.org/">Magefiles</a>. It got me about 95% there, but it still needs quite a bit of handraulic finessing.</p>
<h2 id="where-to-go-from-here">
  <a class="heading-link" href="#where-to-go-from-here">Where to go from here?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I think I&rsquo;ve met most, if not all, of my requirements. The datasets for the project still need a lot of love and I plan on supporting listings for a variety of other pet species. I already have the data; just need to go through it with a fine-toothed comb. Not to mention responsiveness for smaller screens needs a solid amount of work.</p>
<p>I&rsquo;m having loads of fun at the moment learning new things. I hope I can keep doing this for a while longer. However, I do have other things planned for the future and will be using what I&rsquo;ve learned here as a kind of launching pad.</p>
<p>I sincerely hope you find <a href="https://plantsm.art"><span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span></a> as useful as I had fun making it.</p>]]></content:encoded></item><item><title>Learning Bash Through Pointless Fun</title><link>https://wilhelm.codes/blog/learning-bash-through-pointless-fun/</link><pubDate>Wed, 05 Oct 2022 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/learning-bash-through-pointless-fun/</guid><category>bash</category><wc:kind>post</wc:kind><description>Responding to company chat messages in a mocking and sarcastic tone is one of my favourite past times. Classic engineer snark as it were. We&amp;rsquo;re all in on the joke. However, the artful nuances of snark via online chat tend to get confused by others; Did they mean to sound sarcastic?</description><content:encoded><![CDATA[<p>Responding to company chat messages in a mocking and sarcastic tone is one of my favourite past times. Classic engineer snark as it were. We&rsquo;re all in on the joke. However, the artful nuances of snark via online chat tend to get confused by others; Did they <em>mean</em> to sound sarcastic?</p>
<p>Let&rsquo;s leave out all doubt regarding our god-tier levels of snarkiness.</p>
<p>Today, we&rsquo;re going to write a laughably-simple Bash script that lets our coworkers know <em><a href="https://knowyourmeme.com/memes/mocking-spongebob">exactly</a></em> what we think. Along the way, you&rsquo;ll learn a little bit more about Bash such as:</p>
<ol>
<li>Setting some default flags.</li>
<li>Reading text from <code>stdin</code>.</li>
<li>Parameter expansion.</li>
<li>Looping through each character of a string.</li>
<li>Creating random numbers.</li>
<li>Swapping character types using the <code>tr</code> command.</li>
<li>And, finally, making your long-suffering coworkers&rsquo; eyes roll.</li>
</ol>
<hr>
<h2 id="here-we-go">
  <a class="heading-link" href="#here-we-go">Here We Go!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>First, I&rsquo;m going to just paste the entire script here and then we&rsquo;ll go through all the important bits line-by-line:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -eo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">read</span> text
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="o">((</span> <span class="nv">i</span><span class="o">=</span>0<span class="p">;</span> i &lt; <span class="s2">&#34;</span><span class="si">${#</span><span class="nv">text</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> i++ <span class="o">))</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="k">$((</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">RANDOM</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">%</span> <span class="m">2</span> <span class="k">))</span> -eq <span class="m">0</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> -n <span class="s2">&#34;</span><span class="si">${</span><span class="nv">text</span><span class="p">:</span><span class="si">${</span><span class="nv">i</span><span class="si">}</span><span class="p">:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:lower:]&#39;</span> <span class="s1">&#39;[:upper:]&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> -n <span class="s2">&#34;</span><span class="si">${</span><span class="nv">text</span><span class="p">:</span><span class="si">${</span><span class="nv">i</span><span class="si">}</span><span class="p">:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]&#39;</span> <span class="s1">&#39;[:lower:]&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">echo</span></span></code></pre></div>
</div>
<p>Alright, let&rsquo;s take it from the top. All Bash scripts should start with a <a href="https://en.wikipedia.org/wiki/Shebang_(Unix)">“shebang line”</a>. This tells your terminal which environments and runtimes your script should run under. There are more traditional versions of this — <code>#!/bin/bash</code>, for instance — but, our way is considered to be the most portable overall.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1">#!/usr/bin/env bash</span></span></span></code></pre></div>
</div>
<p>Next, we setup some environmental flags. I use at least the following for <em>all</em> of my personal scripts:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">set</span> -eo pipefail</span></span></code></pre></div>
</div>
<p>Here is what they do:</p>
<ol>
<li><code>set -e</code>: Instructs Bash to immediately exit if any command has a non-zero exit status. This is how it works in most languages, but with Bash, it just keeps on trying to execute subsequent commands. This is generally acceptable on the command line, but not in a script. If we encounter an error, we want to exit immediately.</li>
<li><code>set -o pipefail</code>: This tells Bash <em>not</em> to mask errors that may appear in a pipeline of commands. We want any failed command&rsquo;s exit code in a pipeline to bubble up to the script itself and then exit with that code.</li>
</ol>
<p>Speaking of pipelines, we want to be able to pipe text to this script so we can do something like the following:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;wilhelm, i asked you to patch the server.&#34;</span> <span class="p">|</span> ./spongebob
</span></span><span class="line"><span class="cl">wiLHELm, I AsKEd yOu TO PatCh The seRvEr.</span></span></code></pre></div>
</div>
<p>Let&rsquo;s halt the script and wait for user input and then assign that input to variable <code>text</code>:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">read</span> text</span></span></code></pre></div>
</div>
<p>Now that we have some text, we need to start randomly-swapping between upper and lower case characters. In order to do that, we need to know the number of characters in our string so we can build a nice loop:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">for</span> <span class="o">((</span> <span class="nv">i</span><span class="o">=</span>0<span class="p">;</span> i &lt; <span class="s2">&#34;</span><span class="si">${#</span><span class="nv">text</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> i++ <span class="o">))</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ... sweet code goes here </span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>We could do something in a sub-shell here like <code>$(echo &quot;${text}&quot; | wc -c)</code> to get the character count, but why do that when we could get the same result with <code>&quot;${#text}&quot;</code>. This lovely bit of <a href="https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html">“shell parameter expansion”</a> helps us avoid sub-commands and sub-shells.</p>
<p>Next, we want to be able to randomly swap the capitalisation of each character in the string to get the appropriate effect. A character is either upper- or lower-case, so minimum we need only to swap randomly between 2 values; <code>0</code> and <code>1</code>. We can get this effect in Bash by using the internal <a href="https://tldp.org/LDP/abs/html/randomvar.html"><code>${RANDOM}</code> function</a> like so with the <a href="https://tldp.org/LDP/abs/html/ops.html">modulo</a> ( or “mod” ) operator:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="k">$((</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">RANDOM</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">%</span> <span class="m">2</span> <span class="k">))</span> -eq <span class="m">0</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ... do something</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ... do the opposite</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div>
</div>
<p>We&rsquo;re now at the point where we want to modify the character associated with the <code>for</code> loop&rsquo;s current iteration, but how do we get it from the <code>text</code> variable? Once again we use some parameter expansion in the form of <code>${text:offset:length}</code>. We have the value for “offset” already; it&rsquo;s <code>${i}</code>. We only want a single character returned, so we use <code>1</code> for “length”.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> -n <span class="s2">&#34;</span><span class="si">${</span><span class="nv">text</span><span class="p">:</span><span class="si">${</span><span class="nv">i</span><span class="si">}</span><span class="p">:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span></span></span></code></pre></div>
</div>
<p>This spits out the current character for each iteration of our loop. The <code>-n</code> in the <code>echo</code> statement simply stops Bash from adding a newline to the end of the result. Otherwise, you&rsquo;d get a line per character as output.</p>
<p>Finally, we want to do case swapping. For this, we pipe the output of the above <code>echo</code> command into the <code>tr</code> command. Within the above <code>if</code> statement, if our random number equals <code>0</code>, let&rsquo;s swap a <em>lowercase</em> <code>[:lower:]</code> character with an <em>uppercase</em> <code>[:upper:]</code>:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> -n <span class="s2">&#34;</span><span class="si">${</span><span class="nv">text</span><span class="p">:</span><span class="si">${</span><span class="nv">i</span><span class="si">}</span><span class="p">:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:lower:]&#39;</span> <span class="s1">&#39;[:upper:]&#39;</span></span></span></code></pre></div>
</div>
<p>And, then, we do the opposite for any other result:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> -n <span class="s2">&#34;</span><span class="si">${</span><span class="nv">text</span><span class="p">:</span><span class="si">${</span><span class="nv">i</span><span class="si">}</span><span class="p">:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]&#39;</span> <span class="s1">&#39;[:lower:]&#39;</span></span></span></code></pre></div>
</div>
<p>You&rsquo;ll notice a final <code>echo</code> command at the bottom of the script. Thanks to the final <code>echo -n ...</code> command from the previous <code>for</code> loop, you may find your results prepended to your command prompt. This ensures a newline makes it to the end of your 🧽 output.</p>
<h2 id="testing-time">
  <a class="heading-link" href="#testing-time">Testing Time<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Save the script as <code>spongebob</code> and make it executable with <code>chmod a+x spongebob</code>. That should be it! Here are a few of my results:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ <span class="nb">echo</span> <span class="s2">&#34;abandon all hope, ye who enter here.&#34;</span> <span class="p">|</span> ./spongebob
</span></span><span class="line"><span class="cl">abaNDON ALl Hope, yE wHO EnTer heRE.
</span></span><span class="line"><span class="cl">$ <span class="nb">echo</span> <span class="s2">&#34;Wilhelm, that last deployment failed. Could you roll it back, please?&#34;</span> <span class="p">|</span> ./spongebob
</span></span><span class="line"><span class="cl">WilhelM, THAT LASt dEployMeNT fAILED. coUlD YOu RoLL It BaCk, PlEaSE?
</span></span><span class="line"><span class="cl">$ <span class="nb">echo</span> <span class="s2">&#34;Wilhelm, I am your manager. Please, stop mocking me.&#34;</span> <span class="p">|</span> ./spongebob
</span></span><span class="line"><span class="cl">wIlHElM, i aM yOUr MANagER. PlEasE, sTOP mOCKINg mE.
</span></span><span class="line"><span class="cl">$ <span class="nb">echo</span> <span class="s2">&#34;Wilhelm, should we use Kubernetes for this?&#34;</span> <span class="p">|</span> ./spongebob
</span></span><span class="line"><span class="cl">WilHELM, should We Use KuBerNeTEs <span class="k">for</span> THiS?</span></span></code></pre></div>
</div>
<h2 id="in-conclusion">
  <a class="heading-link" href="#in-conclusion">In Conclusion…<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Ok, obviously this was all a clever ploy to get you to learn a few more Bash things. Definitely do <em>not</em> use this new knowledge to frustrate and annoy your coworkers. Please, be considerate of other people&rsquo;s mental well being.
I mean, what I <em>meant</em> to say was:</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>DefInIteLY dO Not UsE THis nEw knoWleDGE to FRUStRATe aND aNnOY YoUR cOWorkers. pLeAsE, be ConsiDeRAtE oF oThEr peOPle's mEnTal well beiNG.</p>
  </blockquote>
  <figcaption>
    <cite>Me.</cite>
  </figcaption>
</figure>

<p>There are any number of ways you could change this script. Instead of liberal use of <code>echo</code>, you could just build a string assigned to a variable and spit that out at the end. Instead of using <code>else</code> you could use <code>continue</code> to skip the final <code>if</code> fallback. Try a few and see what changes.</p>
<p>For the purposes of this article I felt the above sequence of commands was clear enough for most people to follow along.</p>
<p>I hope you learned something!</p>]]></content:encoded></item><item><title>Filtering Docker Containers with jq</title><link>https://wilhelm.codes/blog/filtering-docker-containers-with-jq/</link><pubDate>Thu, 29 Sep 2022 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/filtering-docker-containers-with-jq/</guid><category>docker</category><category>js</category><category>json</category><category>bash</category><wc:kind>post</wc:kind><description>As is the case with any seasoned DevOps engineer, I have a set of tools in my kit that I simply cannot live without. If I had to distill them into a top-5 list it would be the following.</description><content:encoded><![CDATA[<p>As is the case with any seasoned DevOps engineer, I have a set of tools in my kit that I simply cannot live without. If I had to distill them into a top-5 list it would be the following.</p>
<ol>
<li>Bash for portability.</li>
<li><a href="https://stedolan.github.io/jq/">jq</a> to filter JSON objects from RESTful APIs.</li>
<li>AWS CLI as I cannot stand dealing with the web console.</li>
<li><code>curl</code> ( with <code>wget</code> as a reasonable fallback ) to interact with various APIs.</li>
<li><a href="https://www.terraform.io/">Terraform</a> for <em>most</em> of my IaC needs.</li>
</ol>
<p>It wouldn&rsquo;t be an exaggeration to say I <em>literally</em> use these tools <em>every</em> day. Specifically, the first 3 items. Furthermore, to say that manually sorting through Docker containers is a practice in tedium would be a massive understatement. Because of this, not a day passes where I am not grateful to have found <code>jq</code>.</p>
<p>In this case, my favourite duo <em>is</em> Docker and <code>jq</code>&hellip; which is what this article is about. Let&rsquo;s learn some fun filtering patterns to make your life a bit easier if you&rsquo;re stuck in a terminal all day like myself.</p>
<h2 id="setting-up">
  <a class="heading-link" href="#setting-up">Setting Up<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>First and foremost, you&rsquo;re going to need to install <code>jq</code> in your test environment. If you&rsquo;re on most common Linux distributions:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt-get install jq</span></span></code></pre></div>
</div>
<p>Or, on MacOS:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">brew update <span class="o">&amp;&amp;</span> brew install jq</span></span></code></pre></div>
</div>
<p>I&rsquo;m assuming that if you&rsquo;re reading this you already have Docker installed in your test environment. If not, head out to their website and read their <a href="https://docs.docker.com/get-docker/">installation documentation</a>. It tends to vary wildly depending on your OS.</p>
<p>Let&rsquo;s spin up some containers we can play around with. I use <code>redis</code> for stuff like this:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">for</span> i in <span class="o">{</span>1..5<span class="o">}</span><span class="p">;</span> <span class="k">do</span> docker run -it --rm -d redis<span class="p">;</span> <span class="k">done</span></span></span></code></pre></div>
</div>
<h2 id="environmental-variables">
  <a class="heading-link" href="#environmental-variables">Environmental Variables<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<h3 id="sourceable-blocks">
  <a class="heading-link" href="#sourceable-blocks">Sourceable Blocks<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>There may be times I wish to collate all environmental variables associated with a set of containers and stick them a <code>.env</code> file to source later. Here we create a block for all the <code>redis</code> containers we just spun up:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    .[].Config.Env
</span></span></span><span class="line"><span class="cl"><span class="s1">  | flatten 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .[]
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>This one will generate a lot of duplicates as all our test containers are effectively clones. What if we want a distinct set of instead? We can use the <code>unique</code> filter:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">	[
</span></span></span><span class="line"><span class="cl"><span class="s1">	  .[].Config.Env
</span></span></span><span class="line"><span class="cl"><span class="s1">	] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | flatten 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | unique 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .[]
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>What if I want a set from specific containers?</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    [
</span></span></span><span class="line"><span class="cl"><span class="s1">	    .[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">      | select([.Id] | inside([
</span></span></span><span class="line"><span class="cl"><span class="s1">	      &#34;747c2c92855f88a8e9aa9709dcdf3a01f1677e70bf4c0bf0e520eb38ac502876&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">	      &#34;2cf70261ef62bc36d19aba02f8ef7b8d7aabfcb1e9f4593a919baa17f80aca5b&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">	    ]))
</span></span></span><span class="line"><span class="cl"><span class="s1">      | .Config.Env
</span></span></span><span class="line"><span class="cl"><span class="s1">	] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | flatten 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | unique 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .[]
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>How about filtering by a specific ip address for the default <code>bridge</code> network? Using a different network? Just replace <code>bridge</code> with the alternate network name.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    .[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select(.NetworkSettings.Networks.bridge.IPAddress == &#34;172.17.0.2&#34;) 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .Config.Env 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .[]
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>Multiple ip addresses?</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    .[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select([.NetworkSettings.Networks.bridge.IPAddress] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | inside([
</span></span></span><span class="line"><span class="cl"><span class="s1">      &#34;172.17.0.2&#34;,
</span></span></span><span class="line"><span class="cl"><span class="s1">      &#34;172.17.0.3&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">    ])) 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .Config.Env 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .[]
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<h3 id="as-flags-instead">
  <a class="heading-link" href="#as-flags-instead">As Flags Instead<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Here&rsquo;s a strange one I&rsquo;ve had to do. What if we want to recreate a list of <code>--env</code> flags we could pass to other <code>docker run ...</code> commands?</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    [
</span></span></span><span class="line"><span class="cl"><span class="s1">        [
</span></span></span><span class="line"><span class="cl"><span class="s1">          .[].Config.Env
</span></span></span><span class="line"><span class="cl"><span class="s1">        ] 
</span></span></span><span class="line"><span class="cl"><span class="s1">      | flatten 
</span></span></span><span class="line"><span class="cl"><span class="s1">      | unique 
</span></span></span><span class="line"><span class="cl"><span class="s1">      | &#34;--env=\(.[])&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">    ] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | join(&#34; &#34;)
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>You&rsquo;ll get a string that looks similar to the following.</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">--env=GOSU_VERSION=1.14 --env=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin --env=REDIS_DOWNLOAD_SHA=f0e65fda74c44a3dd4fa9d512d4d4d833dd0939c934e946a5c622a630d057f2f --env=REDIS_DOWNLOAD_URL=http://download.redis.io/releases/redis-7.0.4.tar.gz --env=REDIS_VERSION=7.0.4</span></span></code></pre></div>
</div>
<h2 id="filtering-by-relative-time">
  <a class="heading-link" href="#filtering-by-relative-time">Filtering By Relative Time<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I&rsquo;ve had to use these several times for things like garbage collecting and cleaning up long-running containers. This uses a bit of Bash to help generate the relative timestamps. We use the <code>date</code> command for this, but if you&rsquo;re on a <code>darwin</code>-based OS you&rsquo;ll need to install <code>gdate</code> first via Homebrew with:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">brew install coreutils</span></span></code></pre></div>
</div>
<p>Substitute the following calls to <code>date</code> with <code>gdate</code> below if on MacOS.</p>
<h3 id="newer-than-">
  <a class="heading-link" href="#newer-than-">Newer Than …<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Filtering out recent containers with relative time is fairly straight-forward. The following gives us all containers created in the last hour as JSON output from <code>docker inspect ...</code>:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">threshold</span><span class="o">=</span><span class="k">$(</span>date -d <span class="s2">&#34;1 hour ago&#34;</span> +%s<span class="k">)</span>
</span></span><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq --argjson threshold <span class="s2">&#34;</span><span class="si">${</span><span class="nv">threshold</span><span class="si">}</span><span class="s2">&#34;</span> -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    .[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select((.State.StartedAt | split(&#34;.&#34;)[0] | &#34;\(.)Z&#34; | fromdate) &gt; $threshold)
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>Perhaps you just want the names of the containers instead:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">threshold</span><span class="o">=</span><span class="k">$(</span>date -d <span class="s2">&#34;1 hour ago&#34;</span> +%s<span class="k">)</span>
</span></span><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq --argjson threshold <span class="s2">&#34;</span><span class="si">${</span><span class="nv">threshold</span><span class="si">}</span><span class="s2">&#34;</span> -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    .[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select((.State.StartedAt | split(&#34;.&#34;)[0] | &#34;\(.)Z&#34; | fromdate) &gt; $threshold)
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .Name[1:]
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>Docker likes to put a <code>/</code> at the beginning of each generated container pet name, so we use <code>.Name[1:]</code> to snip it off.</p>
<p>What about just returning the associated ids?</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">threshold</span><span class="o">=</span><span class="k">$(</span>date -d <span class="s2">&#34;1 hour ago&#34;</span> +%s<span class="k">)</span>
</span></span><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq --argjson threshold <span class="s2">&#34;</span><span class="si">${</span><span class="nv">threshold</span><span class="si">}</span><span class="s2">&#34;</span> -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    .[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select((.State.StartedAt | split(&#34;.&#34;)[0] | &#34;\(.)Z&#34; | fromdate) &gt; $threshold)
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .Id
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>Or, ip addresses:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">threshold</span><span class="o">=</span><span class="k">$(</span>date -d <span class="s2">&#34;1 hour ago&#34;</span> +%s<span class="k">)</span>
</span></span><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq --argjson threshold <span class="s2">&#34;</span><span class="si">${</span><span class="nv">threshold</span><span class="si">}</span><span class="s2">&#34;</span> -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    .[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select((.State.StartedAt | split(&#34;.&#34;)[0] | &#34;\(.)Z&#34; | fromdate) &gt; $threshold)
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .NetworkSettings.Networks.bridge.IPAddress
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<h3 id="older-than">
  <a class="heading-link" href="#older-than">Older Than???<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>The same filters apply, but you&rsquo;re just flipping the compare operator from <code>&gt;</code> to <code>&lt;</code>:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">threshold</span><span class="o">=</span><span class="k">$(</span>date -d <span class="s2">&#34;1 hour ago&#34;</span> +%s<span class="k">)</span>
</span></span><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq --argjson threshold <span class="s2">&#34;</span><span class="si">${</span><span class="nv">threshold</span><span class="si">}</span><span class="s2">&#34;</span> -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">    .[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select((.State.StartedAt | split(&#34;.&#34;)[0] | &#34;\(.)Z&#34; | fromdate) &lt; $threshold)
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<h2 id="other-common-patterns">
  <a class="heading-link" href="#other-common-patterns">Other Common Patterns<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There have been times where I need a block of container ip addresses so I can dynamically update some Nginx upstreams somewhere. With <code>jq</code> it&rsquo;s as easy as:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">  .[].NetworkSettings.Networks.bridge.IPAddress
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>The output would look something like this:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">172.17.0.6
</span></span><span class="line"><span class="cl">172.17.0.5
</span></span><span class="line"><span class="cl">172.17.0.4
</span></span><span class="line"><span class="cl">172.17.0.3
</span></span><span class="line"><span class="cl">172.17.0.2</span></span></code></pre></div>
</div>
<p>Perhaps, I want to find a specific container id by it&rsquo;s ip address:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">	.[]
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select(.NetworkSettings.Networks.bridge.IPAddress == &#34;172.17.0.3&#34;)
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .Id
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>Or, just give me the names of all running containers:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">  .[].Name[1:]
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span></span></span></code></pre></div>
</div>
<p>Which would give you something like:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">zealous_hertz
</span></span><span class="line"><span class="cl">fervent_dijkstra
</span></span><span class="line"><span class="cl">focused_galileo
</span></span><span class="line"><span class="cl">zealous_poincare
</span></span><span class="line"><span class="cl">naughty_wescoff</span></span></code></pre></div>
</div>
<h2 id="cleaning-up-">
  <a class="heading-link" href="#cleaning-up-">Cleaning Up …<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>We&rsquo;re considerate people, so let&rsquo;s clean up after ourselves by killing all containers created using the <code>redis</code> image:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">kill</span> <span class="k">$(</span>docker inspect <span class="k">$(</span>docker ps -q<span class="k">)</span> <span class="p">|</span> jq -r <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">	.[] 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | select(.Config.Image == &#34;redis&#34;) 
</span></span></span><span class="line"><span class="cl"><span class="s1">  | .Id
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span><span class="k">)</span></span></span></code></pre></div>
</div>
<p>Keep in mind this will kill <em>all</em> <code>redis</code> containers.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In Closing …<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Having a toolbox filled with utilities you can mix and match together is great if you spend most of your day working in a terminal on a glowing rectangle. Hopefully, I&rsquo;ve made a strong enough case to for Docker and <code>jq</code> as a great combo.</p>
<h2 id="but-wait-theres-more">
  <a class="heading-link" href="#but-wait-theres-more">But, Wait! There&rsquo;s More!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Why copy and paste these commands in your terminal, when you could just <a href="https://github.com/wilhelm-murdoch/dq">download and install</a> a handy little Bash script to do it all for you? <code>dq</code> has all the above built-in as well as heaps more bells and whistles.</p>
<p>Here are a few command examples if you&rsquo;re interested:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dq older-than <span class="m">4</span> months --only-return ips --network pebkac
</span></span><span class="line"><span class="cl">dq newer-than <span class="m">2</span> fortnights --only-return ids
</span></span><span class="line"><span class="cl">dq filter <span class="s1">&#39;.[].Name[1:]&#39;</span>
</span></span><span class="line"><span class="cl">dq find-by-ip-address 172.17.0.2 --only-return names --network foo</span></span></code></pre></div>
</div>]]></content:encoded></item><item><title>Why Can't I Hold All These Slack Emojis?</title><link>https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/</link><pubDate>Wed, 28 Sep 2022 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/</guid><category>slack</category><category>emoji</category><category>bash</category><category>api</category><wc:kind>post</wc:kind><description>Previously, last blog post I wrote covered how to make a quick escape with your precious hoard of custom Slack emojis. It was fairly well-received, but didn&amp;rsquo;t quite cover the next step in the migration process; how do you upload your millions of little images to your new Slack workspace?</description><content:encoded><![CDATA[<p>Previously, <a href="https://wilhelm.codes/liberating-custom-slack-emojis">last blog post</a> I wrote covered how to make a quick escape with your precious hoard of custom Slack emojis. It was fairly well-received, but didn&rsquo;t quite cover the next step in the migration process; how do you upload your millions of little images to your <em>new</em> Slack workspace?</p>
<h2 id="gathering-requirements">
  <a class="heading-link" href="#gathering-requirements">Gathering Requirements<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Since we&rsquo;re doing some destructive operations, we&rsquo;ll need to make sure our Slack user has the appropriate permissions to manage emoji; adding &amp; deleting. This means using a different section of the Slack interface which uses an entirely seperate set of API endpoints.</p>
<p>You&rsquo;ll still be using the following, which can be found using instructions from <a href="https://wilhelm.codes/liberating-custom-slack-emojis#heading-some-investigative-work">the previous article</a>:</p>
<ul>
<li>An API request token.</li>
<li>A session cookie.</li>
<li>A workspace, or team, id.</li>
</ul>
<p>In addition, you will need your workspaces&rsquo;s subdomain, or URL. This can easily be found within the Slack app itself:</p>
<p><img
  src="https://wilhelm.codes/blog/why-cant-i-hold-all-these-slack-emojis/image-1_hu_dacba861f5d90b40.webp"
  srcset="/blog/why-cant-i-hold-all-these-slack-emojis/image-1_hu_dacba861f5d90b40.webp 607w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="607"
  height="490"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="testing-your-findings">
  <a class="heading-link" href="#testing-your-findings">Testing Your Findings<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This is an incredibly straight-forward process as it&rsquo;s quite similar to what we already know. The primary difference is this is a <code>multipart/form-data</code> upload. So, instead of shipping of a JSON payload, it&rsquo;s a form with associated fields.</p>
<p>With the information gathered above, you can upload directly to Slack using a simple cURL command like so:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s --compressed <span class="s2">&#34;https://&lt;domain&gt;.slack.com/api/emoji.add&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s1">&#39;content-type: multipart/form-data&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;cookie: d=&lt;cookie&gt;;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -F <span class="s2">&#34;token=&lt;token&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -F <span class="s2">&#34;name=&lt;name&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -F <span class="nv">mode</span><span class="o">=</span>data <span class="se">\
</span></span></span><span class="line"><span class="cl">  -F <span class="s2">&#34;image=@&lt;local-emoji-path&gt;&#34;</span></span></span></code></pre></div>
</div>
<p>Replace the following:</p>
<ul>
<li><code>&lt;domain&gt;</code> is your workspace&rsquo;s, or team&rsquo;s, private URL.</li>
<li><code>&lt;cookie&gt;</code> is your session cookie value.</li>
<li><code>&lt;token&gt;</code> is your request token.</li>
<li><code>&lt;name&gt;</code> will be the named reference of your new emoji.</li>
<li><code>&lt;path&gt;</code> is the relative, or absolute, local path of your emoji file to upload.</li>
</ul>
<p>The JSON response to this request will have the following structure if all goes well:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span> <span class="nt">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span></span></span></code></pre></div>
</div>
<p>And, if something went wrong:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span> 
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;err_code&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>That&rsquo;s it! Now that we know how to upload from the command line.</p>
<h2 id="the-implementation">
  <a class="heading-link" href="#the-implementation">The Implementation<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>If you&rsquo;re only uploading a handful of emojis, it might make sense to just do it via Slack&rsquo;s UI. However, this can get a bit tedious if you have dozens, or even hundreds, to upload.  We can  automate things even further by:</p>
<ul>
<li>Gathering all supported images from a specific source directory.</li>
<li>Iterating through our findings and bulk-upload them all in one go.</li>
</ul>
<p>So, let&rsquo;s do just that. But, first, let&rsquo;s set up some environmental variables so we can easily configure out script:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_DOMAIN</span><span class="p">:=</span><span class="si">}</span></span></span></code></pre></div>
</div>
<p>Slack only supports <code>png</code>, <code>gif</code> and <code>jpg</code> image formats, so lets see what we can find from our present working directory:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">files</span><span class="o">=</span><span class="k">$(</span>find . -iname <span class="se">\*</span>.gif -o -iname <span class="se">\*</span>.png -o -iname <span class="se">\*</span>.png -maxdepth 1<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">files</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span></span></span></code></pre></div>
</div>
<p>Exit if we can&rsquo;t find any results. No need to continue if we haven&rsquo;t a thing to upload.</p>
<p>Next, we just iterate through our findings using <code>read</code> and some more <a href="https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html">Bash parameter expansion</a> to determine what will be the new emoji&rsquo;s reference name:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">files</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r path<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">  <span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="si">${</span><span class="nv">path</span><span class="p">%.*</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>We can easily parse the response bodies with <code>jq</code> for some error checking and we&rsquo;re good to go:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.ok&#34;</span><span class="k">)</span> <span class="o">==</span> <span class="nb">false</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.error&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div>
</div>
<p>We now have all the information we need to upload each file in bulk. Put it all together and you&rsquo;ve got a working bulk emoji uploader:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -eo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_DOMAIN</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">files</span><span class="o">=</span><span class="k">$(</span>find . -iname <span class="se">\*</span>.gif -o -iname <span class="se">\*</span>.png -o -iname <span class="se">\*</span>.png -maxdepth 1<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">files</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">files</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r path<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">  <span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="si">${</span><span class="nv">path</span><span class="p">%.*</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">result</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">    curl -s --compressed <span class="s2">&#34;https://</span><span class="si">${</span><span class="nv">SLACK_DOMAIN</span><span class="si">}</span><span class="s2">.slack.com/api/emoji.add&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -H <span class="s1">&#39;content-type: multipart/form-data&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -H <span class="s2">&#34;cookie: d=</span><span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="si">}</span><span class="s2">;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -F <span class="s2">&#34;token=</span><span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -F <span class="s2">&#34;name=</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -F <span class="nv">mode</span><span class="o">=</span>data <span class="se">\
</span></span></span><span class="line"><span class="cl">      -F <span class="s2">&#34;image=@</span><span class="si">${</span><span class="nv">path</span><span class="si">}</span><span class="s2">&#34;</span> 
</span></span><span class="line"><span class="cl">  <span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.ok&#34;</span><span class="k">)</span> <span class="o">==</span> <span class="nb">false</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.error&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;uploaded :</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">:!&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>There you go! To test it out yourself, save this as an executable script and make sure your present working directory has some supported images in it. Pass through the your environmental variables when executing and you&rsquo;re off:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ <span class="nv">SLACK_DOMAIN</span><span class="o">=</span>*** <span class="nv">SLACK_COOKIE</span><span class="o">=</span>*** <span class="nv">SLACK_TOKEN</span><span class="o">=</span>*** ./upload.sh
</span></span><span class="line"><span class="cl">uploaded :stonks:!
</span></span><span class="line"><span class="cl">uploaded :boop:!
</span></span><span class="line"><span class="cl">uploaded :derp:!
</span></span><span class="line"><span class="cl">uploaded :booyah:!
</span></span><span class="line"><span class="cl">uploaded :merp-flakes:!</span></span></code></pre></div>
</div>
<p>Nice! 😊</p>
<h2 id="once-again-something-better">
  <a class="heading-link" href="#once-again-something-better">Once Again, Something Better!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>While perfectly functional, there&rsquo;s not a lot of flexibility. No error checking, filtering or confirmation checks. If you&rsquo;re looking for something a bit more fleshed out, <a href="https://github.com/wilhelm-murdoch/slack-emoji-toolkit">look no further</a>!</p>
<h2 id="the-final-piece-">
  <a class="heading-link" href="#the-final-piece-">The Final Piece &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>So far, we&rsquo;ve gone over how to download and upload large sets of emoji, but what if you want to nuke them from orbit? The final article in this series will cover how to bulk delete while using advanced filters to pin-point specific sets of emoji you wish to remove.</p>
<p>Hope you&rsquo;ve learned something useful!</p>]]></content:encoded></item><item><title>Liberating Custom Slack Emojis</title><link>https://wilhelm.codes/blog/liberating-custom-slack-emojis/</link><pubDate>Tue, 20 Sep 2022 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/liberating-custom-slack-emojis/</guid><category>emoji</category><category>api</category><category>bash</category><wc:kind>post</wc:kind><description>As is stupid tradition, whenever I start at a new company, one of the first things I like to do while getting settled in is upload my favourite emojis to whatever real-time messaging platform is in use. I know it&amp;rsquo;s childish, but silly memes and emojis are great ways for you to break the ice with your new coworkers.</description><content:encoded><![CDATA[<p>As is stupid tradition, whenever I start at a new company, one of the first things I like to do while getting settled in is upload my favourite emojis to whatever real-time messaging platform is in use. I know it&rsquo;s childish, but silly memes and emojis are great ways for you to break the ice with your new coworkers.</p>
<p>But, when it comes time to part ways it&rsquo;s only understandable to want to gather your stuff for the next place to start the process again. Over the years one can collect and curate quite a nice hoard of stupid images.</p>
<p>I&rsquo;ve been using Slack prolifically for the past few years. Unfortunately, there is no simple way of exporting your precious collection outside of the old “right-click and save” method. This is effective for smaller collections of a couple dozen or so, but becomes impractical when there are hundreds or even thousands.</p>
<p>And, of course, I&rsquo;m not alone. There have been a non-zero number of times when someone left the company only to hit me up later asking for an emoji archive.</p>
<p>Yes, this really happens and something <em>must</em> be done about it!</p>
<p>What follows is an unnecessarily long dive into a little bit of reverse engineering one of Slack&rsquo;s API endpoints, subverting it for our own use and writing a stupid little tool to help ourselves ( and others ) automate this process for the future.</p>
<h2 id="some-investigative-work">
  <a class="heading-link" href="#some-investigative-work">Some Investigative Work<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There just has to be a straightforward way we can <em>mostly</em> automate this. So, I did some cursory digging around the Chrome Developer Tools console and found this endpoint in the “Network” tab:</p>
<div class="code-block">
  <pre tabindex="0"><code>https://edgeapi.slack.com/cache/T0XXXX/emojis/list?fp=97</code></pre>
</div>
<p>The <code>T0XXXX</code> will be your workspace, or team, id. Make note of it, you&rsquo;ll need it later.</p>
<p>If you intercept one of these requests and take a peak at the “Response” tab in the “Network” panel, you&rsquo;ll see a JSON response with a structure similar to:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;next_marker&#34;</span><span class="p">:</span> <span class="s2">&#34;a-custom-emoji&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;results&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">		<span class="p">{</span>
</span></span><span class="line"><span class="cl">			<span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;another-custom-emoji&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">			<span class="nt">&#34;value&#34;</span><span class="p">:</span> <span class="s2">&#34;https://emoji.slack-edge.com/T0XXXX/another-custom-emoji/xxxxxxx.png&#34;</span>
</span></span><span class="line"><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="cl">		<span class="err">...</span> <span class="err">more</span> <span class="err">emojis</span> <span class="err">...</span>
</span></span><span class="line"><span class="cl">	<span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<ul>
<li><code>ok</code> tells you whether the request was successful or not. If it returns <code>false</code> there will be an additional field named <code>error</code> that should clue you into what went wrong.</li>
<li><code>next_marker</code> tells you where in the list of custom emoji the <em>next</em> page of results should start. We use this as the value of <code>marker</code> in subsequent request payloads ( see below ). This is effectively how you page through large lists of emoji.</li>
<li><code>results</code> should be obvious, but it contains an array of objects with <code>name</code> and <code>value</code> fields.
<ul>
<li><code>name</code> is the alias of the emoji.</li>
<li><code>value</code> is the direct CDN URL to the emoji.</li>
</ul>
</li>
</ul>
<p>You may see other fields, but <code>next_marker</code> and <code>value</code> are the only fields we really care about.</p>
<p>Next, If you take a look at the “Payload” tab in the “Network” panel for the same request, you&rsquo;ll see something similar to:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">JSON</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;token&#34;</span><span class="p">:</span> <span class="s2">&#34;xoxc-xxxx-xxxx-xxxx&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;count&#34;</span><span class="p">:</span> <span class="mi">100</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">	<span class="nt">&#34;marker&#34;</span><span class="p">:</span> <span class="s2">&#34;some-other-emoji&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<ul>
<li><code>token</code> is the API request token the Slack client uses to make the emoji list request on your behalf.</li>
<li><code>count</code> is the amount of emojis to return as a page.</li>
<li><code>marker</code> tells the API where in the emoji list to start paging.</li>
</ul>
<p>The final piece of the puzzle is getting the value of your session cookie. You can grab this by clicking the “Request Headers” section under the “Headers” tab. You should see a header labeled <code>cookie</code>.</p>
<p>There&rsquo;s going to be a fairly large block of text to parse through, but you&rsquo;re looking for the cookie named <code>d</code>. Save everything between the <code>d=</code> and the closing <code>;</code>. You&rsquo;re going to need this to authenticate with the Slack API itself.</p>
<h2 id="testing-your-findings">
  <a class="heading-link" href="#testing-your-findings">Testing Your Findings<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>We now understand the structure of our payload. We also have the endpoint to hit as well as our tokens and session cookie to authenticate. Piecing it all together, you can now use cURL to hit the API directly from your terminal:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ curl --silent https://edgeapi.slack.com/cache/&lt;team&gt;/emojis/list?fp<span class="o">=</span><span class="m">97</span>
</span></span><span class="line"><span class="cl">  -H <span class="s1">&#39;cookie: d=&lt;cookie&gt;;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --data-raw <span class="s1">&#39;{&#34;token&#34;:&#34;&lt;token&gt;&#34;,&#34;count&#34;:10}&#39;</span></span></span></code></pre></div>
</div>
<p>Replace the following:</p>
<ul>
<li><code>&lt;team&gt;</code> is your workspace, or team, id.</li>
<li><code>&lt;cookie&gt;</code> is your session cookie value.</li>
<li><code>&lt;token&gt;</code> is your request token.</li>
</ul>
<p>If everything worked out, you should have successful API response like the one above. If the response has root field named <code>marker_next</code>, make note of the associated value to help page through subsequent results. Keep doing this in a loop until your response no longer returns a <code>marker_next</code> field.</p>
<p>Once that happens, you&rsquo;re at the end of the list.</p>
<p>With a little bit of help from <code>jq</code> to parse and filter the JSON responses, we can grab a simple list of URLs.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ &lt;previous_curl_command&gt; <span class="p">|</span> jq -r <span class="s1">&#39;.results[].value&#39;</span>
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/no/060fca9aa2581a93.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/nods/c7fe54342ab5b8b6.gif
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/nods-back/fbd1b5384cdd0905.gif
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/nomnomnom/612cfc74c785010d.gif
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/octocat/627964d7c9.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/ohyou/6a352df984d9c076.gif
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/one-sec-cooking/e749627cb088859d.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/oof/db94710445cbb206.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/petrol/35ed3db238f795fc.png
</span></span><span class="line"><span class="cl">https://emoji.slack-edge.com/T0XXXX/piggy/b7762ee8cd.png</span></span></code></pre></div>
</div>
<p>We now understand how to collect everything we need, so let&rsquo;s automate this a bit more.</p>
<h2 id="the-implementation">
  <a class="heading-link" href="#the-implementation">The Implementation<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>We&rsquo;re going to write a little script that&rsquo;ll download all the custom emojis from the target Slack workspace. It&rsquo;s going to perform the following steps:</p>
<ol>
<li>Define a starting JSON payload.</li>
<li>Create a run loop.</li>
<li>With each loop we make a request using the API endpoint.</li>
<li>Do some error checking.</li>
<li>Iterate through the results and save them to disk.</li>
<li>If the results contain a <code>next_marker</code> update the JSON payload with a <code>marker</code> field and continue the run loop.</li>
<li>Keep going until the last request no longer provides a <code>next_marker</code>.</li>
</ol>
<p>First, we want to define some environmental variables so we can easily configure the script:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_WORKSPACE_ID</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="p">:=</span><span class="si">}</span></span></span></code></pre></div>
</div>
<p>Now we define our JSON payload object. We use <code>jq</code> here to keep things easy. Not only is it great for parsing and filtering JSON in Bash, it also makes building and manipulating objects simple:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">payload</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">  jq -nc <span class="se">\
</span></span></span><span class="line"><span class="cl">    --arg     token <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --argjson count <span class="s2">&#34;100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">      token: $token, 
</span></span></span><span class="line"><span class="cl"><span class="s1">      count: $count
</span></span></span><span class="line"><span class="cl"><span class="s1">    }&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">)</span></span></span></code></pre></div>
</div>
<p>Next, is the run loop where we make our API requests. This passes through the payload object we just defined while also targeting our workspace:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">while</span> true<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">result</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">    curl -s --compressed <span class="s2">&#34;https://edgeapi.slack.com/cache/</span><span class="si">${</span><span class="nv">SLACK_WORKSPACE_ID</span><span class="si">}</span><span class="s2">/emojis/list?fp=97&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -H <span class="s2">&#34;cookie: d=</span><span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="si">}</span><span class="s2">;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      --data-raw <span class="s2">&#34;</span><span class="si">${</span><span class="nv">payload</span><span class="si">}</span><span class="s2">&#34;</span> 
</span></span><span class="line"><span class="cl">  <span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>We obviously want to do some simple error checking so we know what we did wrong if the requests fail. Grab the error, spit it out and terminate the script.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.ok&#34;</span><span class="k">)</span> <span class="o">==</span> <span class="nb">false</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.error&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div>
</div>
<p>We now start iterating through any of the results we got from the initial request. We really only care about the <code>value</code> field as the URL gives us all the pieces we need to give our emojis a human-readable filename.</p>
<p>Here we treat the resulting URL as a standard file path and use <code>dirname</code> paired with <code>basename</code> to get the name of the parent directory of the file. The parent directory holds the actual name of the emoji, while the file itself is only a random hash. We use <a href="https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html">Bash&rsquo;s parameter expansion</a> feature to construct our filename.</p>
<p>Finally, we download the remote file to the current directory:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span>  <span class="p">|</span> jq -r <span class="s1">&#39;.results[].value&#39;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r url<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl"><span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="si">${</span><span class="nv">url</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">))</span>
</span></span><span class="line"><span class="cl">curl -s -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">.</span><span class="si">${</span><span class="nv">url</span><span class="p">##*.</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">url</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>We need to check if there are more results to page through, so let&rsquo;s look for the <code>marker_next</code> field. If we can&rsquo;t find one, then mission accomplished; all done. However, if we do get a value, we take the previous payload we constructed and <em>add</em> the <code>marker</code> field.</p>
<p>The next loop will pick this change up and pass it on through to the API request telling Slack to give us the next page of precious emojis.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">marker</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.next_marker&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">marker</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;null&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">payload</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">payload</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq --arg marker <span class="s2">&#34;</span><span class="si">${</span><span class="nv">marker</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s1">&#39;. + { marker: $marker }&#39;</span><span class="k">)</span></span></span></code></pre></div>
</div>
<h2 id="the-finished-product">
  <a class="heading-link" href="#the-finished-product">The Finished Product<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -eo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_WORKSPACE_ID</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">: <span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="p">:=</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">payload</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">  jq -nc <span class="se">\
</span></span></span><span class="line"><span class="cl">    --arg     token <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SLACK_TOKEN</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --argjson count <span class="s2">&#34;100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">      token: $token, 
</span></span></span><span class="line"><span class="cl"><span class="s1">      count: $count
</span></span></span><span class="line"><span class="cl"><span class="s1">    }&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">while</span> true<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">result</span><span class="o">=</span><span class="k">$(</span>
</span></span><span class="line"><span class="cl">    curl -s --compressed <span class="s2">&#34;https://edgeapi.slack.com/cache/</span><span class="si">${</span><span class="nv">SLACK_WORKSPACE_ID</span><span class="si">}</span><span class="s2">/emojis/list?fp=97&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      -H <span class="s2">&#34;cookie: d=</span><span class="si">${</span><span class="nv">SLACK_COOKIE</span><span class="si">}</span><span class="s2">;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      --data-raw <span class="s2">&#34;</span><span class="si">${</span><span class="nv">payload</span><span class="si">}</span><span class="s2">&#34;</span> 
</span></span><span class="line"><span class="cl">  <span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.ok&#34;</span><span class="k">)</span> <span class="o">==</span> <span class="nb">false</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.error&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span>  <span class="p">|</span> jq -r <span class="s1">&#39;.results[].value&#39;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r url<span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">    <span class="nv">name</span><span class="o">=</span><span class="k">$(</span>basename <span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="si">${</span><span class="nv">url</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">))</span>
</span></span><span class="line"><span class="cl">    curl -s -o <span class="s2">&#34;</span><span class="si">${</span><span class="nv">name</span><span class="si">}</span><span class="s2">.</span><span class="si">${</span><span class="nv">url</span><span class="p">##*.</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">url</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">done</span> 
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">marker</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">result</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq -r <span class="s2">&#34;.next_marker&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">marker</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;null&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">payload</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">payload</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">|</span> jq --arg marker <span class="s2">&#34;</span><span class="si">${</span><span class="nv">marker</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s1">&#39;. + { marker: $marker }&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>This is pretty much it. A barebones script that downloads all findings to its present working directory. To test it out yourself, make the script executable, define the environmental variables and fire it off like so:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">$ <span class="nv">SLACK_WORKSPACE_ID</span><span class="o">=</span>*** <span class="nv">SLACK_COOKIE</span><span class="o">=</span>*** <span class="nv">SLACK_TOKEN</span><span class="o">=</span>*** ./fetch.sh</span></span></code></pre></div>
</div>
<h2 id="an-even-better-version">
  <a class="heading-link" href="#an-even-better-version">An Even Better Version!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I know I just made you read through all this, but you also could&rsquo;ve just <a href="https://github.com/wilhelm-murdoch/slack-emoji-toolkit">downloaded the tool from here</a>. It&rsquo;s got heaps more bells and whistles ( if you&rsquo;re into that sort of thing):</p>
<h2 id="the-bitter-irony">
  <a class="heading-link" href="#the-bitter-irony">The Bitter Irony<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>In typical bored engineer fashion, I&rsquo;ve spent more time writing this article, documenting and publishing the code than I ever would have personally just “handraulically” doing this from time to time.</p>
<p>Hopefully, you&rsquo;ve learned something new! 🤞</p>
<h2 id="bonus-relevant-xkcd-image">
  <a class="heading-link" href="#bonus-relevant-xkcd-image">Bonus Relevant XKCD Image<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p><img
  src="https://wilhelm.codes/blog/liberating-custom-slack-emojis/image-1_hu_190150482d40845e.webp"
  srcset="/blog/liberating-custom-slack-emojis/image-1_hu_190150482d40845e.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="740"
  alt=""
  
  loading="lazy"
  decoding="async"
/>

source: <a href="https://xkcd.com/1319/">xkcd: automation</a></p>]]></content:encoded></item><item><title>Save Money by Keeping Your AWS Account Clean</title><link>https://wilhelm.codes/blog/save-money-by-keeping-your-aws-account-clean/</link><pubDate>Tue, 06 Sep 2022 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/save-money-by-keeping-your-aws-account-clean/</guid><category>aws</category><category>devops</category><category>security</category><category>cost-optimisation</category><wc:kind>post</wc:kind><description>At a prior role, I managed just under 20 AWS accounts. Their uses varied from production workloads, to dedicated CI/CD environments, sandboxed areas for our engineers to experiment in, log aggregation, the list goes on.</description><content:encoded><![CDATA[<p>At a prior role, I managed just under 20 AWS accounts. Their uses varied from production workloads, to dedicated CI/CD environments, sandboxed areas for our engineers to experiment in, log aggregation, the list goes on.</p>
<p>Because we had so many to manage and not a whole lot of people power ( it was just 2 of us ), it got easy for a lot of hidden costs to pop up here and there if we weren&rsquo;t vigilant enough with housekeeping.</p>
<p>We liked to keep roughly 95% of <em>all</em> static infrastructure nicely tucked away in Terraform, but over the years you get so many small extant changes that the dirt begins to pile up and, along with it, costs. Just a little bit here and there, but the cumulative effect becomes more and more obvious as time moves on.</p>
<p>So, what do you do? Do you manually audit every account? Every <em>region</em> in every account? AWS doesn&rsquo;t give you an easy way to view <em>all</em> resources across the entirety of your org outside of what you can glean from the billing console. So, we do what comes natural; find a way to make the glowing rectangle do the job for you.</p>
<p>The goal here isn&rsquo;t to find a solution that does <em>everything</em>, but just enough to make our jobs easier.</p>
<h2 id="enter-aws-nuke">
  <a class="heading-link" href="#enter-aws-nuke">Enter AWS Nuke<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I found <a href="https://github.com/rebuy-de/aws-nuke">this utility</a> sometime last year when asked to find a way to trim some extra fat off our monthly bill. It is <em>excellent</em> and works precisely as advertised. It wound up only saving us a few hundred bucks per month, which is a drop in the bucket considering our total monthly spend, but saving money isn&rsquo;t the only benefit.</p>
<h3 id="added-security">
  <a class="heading-link" href="#added-security">Added Security<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Cleaning up unused resources is a low touch way to keeping up your security posture. Fewer forgotten things, means a smaller attack surface.</p>
<p>The longer you leave something unchecked, the harder it becomes to manage. Entropy affects everything and while code can be immutable, the world acting upon it is not.</p>
<p>Think back and ask yourself how many times you&rsquo;ve forgotten about a service with ageing dependencies, or an EC2 instance you haven&rsquo;t patched in a while, or some critical, but undocumented, component stored away in some forgotten area of one of your accounts.</p>
<p>If you don&rsquo;t need it, or don&rsquo;t plan on actively maintaining it, find a way to get rid of it.</p>
<h3 id="soft-dollar-cost">
  <a class="heading-link" href="#soft-dollar-cost">Soft-Dollar Cost<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>People tend to forget the hidden cost of maintaining lots of things; it requires human attention. Have a lot of random things you need to manage? Well, the more things there are the more time you spend on them. That literally translates to money spent at the end of the billing period and it won&rsquo;t be showing up on the invoice.</p>
<p>Wouldn&rsquo;t you rather spend your time on more important things at work? Reduce hidden costs by cleaning up after yourself.</p>
<h2 id="ok-lets-nuke-some-stuff">
  <a class="heading-link" href="#ok-lets-nuke-some-stuff">Ok, Let&rsquo;s Nuke Some Stuff<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>First things first, determine the account id you&rsquo;re going to use.</p>
<p>This is a highly-destructive operation, so make sure you&rsquo;re targeting the right account. Get yourself some credentials either through privileged IAM or an SSO user session. Assume from here on out that <code>11111111111</code> is your target account and ensure your user has administrative privileges. You&rsquo;ll need this level of authorization if you&rsquo;re going to be doing this kind of deep cleaning.</p>
<p>Ensure your credentials are associated with the proper account by doing something similar to the following. As a side note, if you&rsquo;re using a credentials sourced by SSO, you&rsquo;re going to need the <code>AWS_SESSION_TOKEN</code>, otherwise it&rsquo;s safe to ignore.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_ACCESS_KEY_ID</span><span class="o">=</span><span class="s2">&#34;***&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_SECRET_ACCESS_KEY</span><span class="o">=</span><span class="s2">&#34;***&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_SESSION_TOKEN</span><span class="o">=</span><span class="s2">&#34;***&#34;</span>
</span></span><span class="line"><span class="cl">aws sts get-caller-identity
</span></span><span class="line"><span class="cl"><span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;UserId&#34;</span>: <span class="s2">&#34;XXXXXXXXXXXXXXXX:DEADBEEF&#34;</span>,
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Account&#34;</span>: <span class="s2">&#34;11111111111&#34;</span>,
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Arn&#34;</span>: <span class="s2">&#34;arn:aws:sts::11111111111:assumed-role/AWSReservedSSO_AdministratorAccess/DEADBEEF&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span></span></span></code></pre></div>
</div>
<h2 id="configuration">
  <a class="heading-link" href="#configuration">Configuration<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There may be resources you wish to keep while cleaning your account. AWS Nuke exposes comprehensive filtering capabilities that allow you to ignore, or target, specific sets of resources. In our case, it would be stuff like GuardDuty, AWS Config, CloudTrail and S3 buckets dedicated to Terraform state storage. For resources we don&rsquo;t want to wipe, the following configuration provides a basic example of how you may bootstrap the cleaning process.</p>
<p>Ultimately, this will be highly specific to your unique use case, but it&rsquo;s a great way to demonstrate how it all works. Let&rsquo;s go through all the important sections:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">YAML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">regions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">global</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">eu-north-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">ap-south-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">eu-west-3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">eu-west-2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">eu-west-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">ap-northeast-3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">ap-northeast-2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">sa-east-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">ca-central-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">ap-southeast-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">ap-southeast-2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">eu-central-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">us-east-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">us-east-2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">us-west-1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">us-west-2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">account-blocklist</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="m">12345678910</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">resource-types</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">excludes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">CloudWatchAlarm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Route53ResolverRule</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Route53HostedZone</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">S3Object</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">S3Bucket</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">GuardDutyDetector</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">SNSSubscription</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">IAMSAMLProvider</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">CloudTrailTrail</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">accounts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">11111111111</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">filters</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">IAMPolicy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">contains</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">AWS-Chatbot-NotificationsOnly-Policy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">IAMRolePolicy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">contains</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">CloudTrailCloudWatchLogsRole</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">contains</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">OrganizationAccountAccessRole</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">IAMRole</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">contains</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">AWSReservedSSO</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">contains</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">CloudWatchAlarmToSlackRole</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="l">CloudTrailCloudWatchLogsRole</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="l">OrganizationAccountAccessRole</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">IAMRolePolicyAttachment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">contains</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">AWSReservedSSO</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">contains</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">CloudWatchAlarmToSlackRole</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SNSTopic</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">contains</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">value</span><span class="p">:</span><span class="w"> </span><span class="l">CloudWatchAlarms</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">CloudWatchLogsLogGroup</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="l">CloudTrail/DefaultLogGroup</span></span></span></code></pre></div>
</div>
<h4 id="regions">
  <a class="heading-link" href="#regions"><code>regions</code><span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h4>
<p>Lists the AWS regions to screen for resources. By default, we allow the tool to cycle through all supported regions. Unfortunately, there is no support for &ldquo;all regions&rdquo;, so this is a list that will have to be manually maintained. Also, depending on the amount of resources, or use-case for this tool, you may not want to scan all regions. Trim this list to suit your purposes.</p>
<p><code>global</code> refers to <em>services</em> which are considered global and not tied to any specific region. For example, IAM users, policies and roles.</p>
<h4 id="account-blocklist">
  <a class="heading-link" href="#account-blocklist"><code>account-blocklist</code><span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h4>
<p>A list of AWS accounts to completely avoid. If you&rsquo;re not confident in your account selections, this is a great place to put accounts that run production workloads as an added failsafe. Remember, be explicit in your selections and use this tool <em>only</em> in accounts you wish to clean.</p>
<h4 id="resource-typesexcludes">
  <a class="heading-link" href="#resource-typesexcludes"><code>resource-types.excludes</code><span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h4>
<p>A list of AWS resources we wish to ignore. For us, these are typically associated with the resources we&rsquo;ve primed using Terraform. In this case, we just skip them completely. Trim this list to suit your purposes, or just manually delete these resources in the AWS console if you&rsquo;re unsure.</p>
<h4 id="accounts">
  <a class="heading-link" href="#accounts"><code>accounts</code><span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h4>
<p>Contains the id of the account you wish to clean. Within this block, you will see filters used to skip certain resources. In our case, the filter almost always contains a list of things we wish to keep in all accounts. Resources like CloudWatch alarms, GuardDuty and AWS Config settings, etc&hellip;</p>
<p>You can read more about configuring <code>aws-nuke</code> in the associated repository&rsquo;s <a href="https://github.com/rebuy-de/aws-nuke/blob/main/README.md">README</a>. Feel free to customise this file locally to suit your own needs. There are a number of ways to filter and target resources with a high degree of precision.</p>
<h2 id="using-the-tool">
  <a class="heading-link" href="#using-the-tool">Using the Tool<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>By default, <code>aws-nuke</code> does not perform any destructive operations. You must explicitly add the <code>--no-dry-run</code> flag with a subsequent run after performing the initial scan of the target account.</p>
<p>You can install the tool locally on your machine, but I prefer using Docker to invoke the tool:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker run --rm -it <span class="se">\
</span></span></span><span class="line"><span class="cl">    -v ~/aws-nuke-config.yml:/home/aws-nuke/config.yml <span class="se">\
</span></span></span><span class="line"><span class="cl">    -e AWS_ACCESS_KEY_ID <span class="se">\
</span></span></span><span class="line"><span class="cl">    -e AWS_SECRET_ACCESS_KEY <span class="se">\
</span></span></span><span class="line"><span class="cl">    -e AWS_SESSION_TOKEN <span class="se">\
</span></span></span><span class="line"><span class="cl">    quay.io/rebuy/aws-nuke:main <span class="se">\
</span></span></span><span class="line"><span class="cl">    --config /home/aws-nuke/config.yml</span></span></code></pre></div>
</div>
<p>You will be asked to manually type the alias of the target account for confirmation. For our purposes, the alias for account <code>11111111111</code> is <code>aws-nuke-account</code>:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">aws-nuke version v2.19.0.15.gb46fbe0 - Wed Oct  5 10:01:13 UTC 2022 - b46fbe0e9f63266f56b5afd9635b4e4d5a3108d4
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Do you really want to nuke the account with the ID 11111111111 and the alias &#39;aws-nuke-account&#39;?
</span></span><span class="line"><span class="cl">Do you want to continue? Enter account alias to continue.
</span></span><span class="line"><span class="cl">&gt; aws-nuke-account</span></span></code></pre></div>
</div>
<p>You will then see a list of AWS resources as <code>aws-nuke</code> cycles through each region:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">global - IAMRole - RDSBucketAccessRole - [Name: &#34;RDSBucketAccessRole&#34;, Path: &#34;/service-role/&#34;] - filtered by config
</span></span><span class="line"><span class="cl">global - IAMRole - S3BucketAccessRole - [Name: &#34;S3BucketAccessRole&#34;, Path: &#34;/&#34;] - would remove
</span></span><span class="line"><span class="cl">global - IAMRole - EBSSnapshotReplicationRole - [Name: &#34;EBSSnapshotReplicationRole&#34;, Path: &#34;/&#34;] - would remove
</span></span><span class="line"><span class="cl">global - IAMRole - VPCFlowLogsRole - [Name: &#34;VPCFlowLogsRole&#34;, Path: &#34;/&#34;] - would remove
</span></span><span class="line"><span class="cl">global - IAMRole - OrganizationAccountAccessRole - [Name: &#34;OrganizationAccountAccessRole&#34;, Path: &#34;/&#34;] - filtered by config
</span></span><span class="line"><span class="cl">us-west-2 - EC2RouteTable - rtb-0000000000 - [tag:ManagedBy: &#34;terraform&#34;, tag:Name: &#34;VPCRouteTablePublic&#34;, tag:Purpose: &#34;vpc-route-table&#34;] - would remove
</span></span><span class="line"><span class="cl">us-west-2 - EC2RouteTable - rtb-1111111111 - [tag:ManagedBy: &#34;terraform&#34;, tag:Name: &#34;VPCRouteTablePrivate&#34;, tag:Purpose: &#34;vpc-route-table&#34;] - would remove
</span></span><span class="line"><span class="cl">Scan complete: 161 total, 83 nukeable, 78 filtered.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">The above resources would be deleted with the supplied configuration. Provide --no-dry-run to actually destroy resources.</span></span></code></pre></div>
</div>
<p>It is important during the dry run that you closely-examine each resource that has been flagged for removal. Here are some common flags you should be paying attention to:</p>
<ol>
<li><code>would remove</code>: these resources will be removed by AWS Nuke on a subsequent run with <code>--no-dry-run</code>.</li>
<li><code>filtered by config</code>: these are resources that have been skipped as a result of your filtering configurations.</li>
<li><code>cannot delete *</code>: marks AWS-managed resources that simply cannot be deleted.</li>
</ol>
<p>If you notice something is marked as <code>would remove</code>, but should be kept, add a filter for it to the configuration file. Take an iterative approach until you&rsquo;re satisfied with the scanning results.</p>
<p>Once you have scanned the list and updated any relevant filters, you can execute the nuking process by passing the <code>--no-dry-run</code> flag. You will be presented with the same steps as above as well as a final confirmation message similar to the initial dry run scan.</p>
<h2 id="in-conclusion-">
  <a class="heading-link" href="#in-conclusion-">In Conclusion &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>This is a very destructive process that will permanently remove selected resources. Always make sure you have backups of selected data sources and they are properly-filtered within your configuration file.</p>
<p>There may also be times when resources just aren&rsquo;t deleted as expected. That&rsquo;s ok as this process isn&rsquo;t 100% perfect. AWS Nuke is still under heavy development and AWS updates APIs around various services constantly. The point isn&rsquo;t for this tool to do 100% of the heavy lifting, but to get you mostly there.</p>
<p>90% complete still means you&rsquo;ve successfully avoided 90% of the work. 😊</p>
<h2 id="100-completion-speed-run">
  <a class="heading-link" href="#100-completion-speed-run">100% Completion Speed-Run<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Just stop paying the bill. Seriously. Just stop paying for the account. Cancel the card. Delete those billing alerts and just walk away. If you stop paying, AWS will eventually just tear down your account with everything in it. All of it. The entire account. Credit score be damned!</p>
<p><em>obviously, do not do this&hellip; Or, go nuts! After all, don&rsquo;t you sometimes wish you could burn it all down and just walk away? j/k</em></p>
<p><img
  src="https://cdn.hashnode.com/res/hashnode/image/upload/v1664982128441/xnlblxy1F.gif"
  
  
  
  
  alt="burn-koala.gif"
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Hope you&rsquo;ve learned something useful!</p>
<p>Behave! (mostly) 🙉🙈🙊</p>]]></content:encoded></item><item><title>Deleting Massive S3 Buckets the Easy Way</title><link>https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/</link><pubDate>Sat, 04 Dec 2021 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/</guid><category>aws</category><category>devops</category><category>cost-optimisation</category><wc:kind>post</wc:kind><description>I&amp;rsquo;m in the middle of decommissioning a service at the moment and I have to do the typical process of performing final snapshots and cleaning up extant resources. It&amp;rsquo;s incredibly tedious, so let&amp;rsquo;s walk through it.</description><content:encoded><![CDATA[<p>I&rsquo;m in the middle of decommissioning a service at the moment and I have to do the typical process of performing final snapshots and cleaning up extant resources. It&rsquo;s incredibly tedious, so let&rsquo;s walk through it.</p>
<p>For this particular service, we have an  <a href="https://en.wikipedia.org/wiki/Extract,_transform,_load">ETL</a>  process that stores raw data points for downstream ingestion by some other&hellip; thing. Anyway, the storage mechanism for this is a cross-region replicated S3 bucket containing just over 10 <em>million</em> objects.</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-1_hu_523c534cb631cb43.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-1_hu_523c534cb631cb43.webp 736w, /blog/deleting-massive-s3-buckets-the-easy-way/image-1_hu_7c8db834d83bb9da.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="105"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>All associated buckets and objects must be deleted. 😬</p>
<p>Have you ever had to delete a bucket with this many objects? Not exactly straight-forward. Even by AWS standards.</p>
<h2 id="whats-the-problem">
  <a class="heading-link" href="#whats-the-problem">What&rsquo;s the problem?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>As you may or may not know, you can&rsquo;t delete a bucket that still contains objects. For smaller buckets, this isn&rsquo;t much of an issue as you can saunter on over to the &ldquo;empty bucket&rdquo; screen. This will take you to a confirmation interstitial where you have to sit and wait until the deletion process finishes. Though, you had better not close that tab unless you want to start the process all over.</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-2_hu_d77a85a743ac841d.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-2_hu_d77a85a743ac841d.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="164"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Not exactly a feasible solution for our use case. No, for larger buckets there really is only one pragmatic solution.</p>
<h2 id="lifecycle-policies">
  <a class="heading-link" href="#lifecycle-policies">Lifecycle Policies!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>You may have noticed the tone of this article as being somewhat snarky. Well, that&rsquo;s because what follows shouldn&rsquo;t be nearly as convoluted as it is. This process should be a single button with a confirmation step where the work is done asynchronously.</p>
<p>That being said, I know better than to question another team&rsquo;s design decisions. Who knows what weirdness they encountered during implementing this functionality. In all fairness, they do provide the mechanisms necessary drop millions, or even billions, of objects. The main concern is it&rsquo;s just not at all intuitive.</p>
<p>So, let&rsquo;s get to it! 🦾</p>
<h3 id="expire-those-objects">
  <a class="heading-link" href="#expire-those-objects">Expire those objects!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Alright, we&rsquo;re going to have to perform the following steps for the first policy:</p>
<ol>
<li>Expire all current objects.</li>
<li>Permanently delete all non-current versions of objects.</li>
<li>Delete expired object delete markers.</li>
</ol>
<p>First and foremost, give this policy and name and confirm your intent:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-3_hu_b6980a9240ff48db.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-3_hu_b6980a9240ff48db.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="381"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Select the following actions. We will need to select the last option in the list, but due to conflicting settings you will have to add it in separate, secondary, lifecycle policy:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-4_hu_43009cfd5d2b0edc.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-4_hu_43009cfd5d2b0edc.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="252"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Set the following option to <code>1</code> day:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-5_hu_e108dbe3089f2a81.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-5_hu_e108dbe3089f2a81.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="183"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Again, set this option to <code>1</code> day as well. Unfortunately, this is the lowest value you can select. You also have the option to specify how many versions of each object to keep. This is only really relevant if you have object versioning activated for this bucket, but you&rsquo;re going do nuke it all, so just go ahead and leave this blank:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-6_hu_65d62bb36ebf1a0c.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-6_hu_65d62bb36ebf1a0c.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="205"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Look at you! All done except looking over your work and saving your new lifecycle policy:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-7_hu_55f5572133868bc9.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-7_hu_55f5572133868bc9.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="385"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h3 id="delete-those-objects">
  <a class="heading-link" href="#delete-those-objects">Delete those objects!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>A secondary policy is necessary to <em>delete</em> the objects you&rsquo;ve just told AWS to expire. Begin the process by clicking the <code>Create lifecycle rule</code> button and performing the first step from the previous policy:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-8_hu_4f4ef432e91449b1.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-8_hu_4f4ef432e91449b1.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="379"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Next, you&rsquo;ll select the last action in the list:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-9_hu_f7f0ef62a2c4791d.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-9_hu_f7f0ef62a2c4791d.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="252"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Select the options to delete all expired objects. Optionally, you can also delete impartial uploads older than <code>1</code> day as well.</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-10_hu_a730d0abc759281f.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-10_hu_a730d0abc759281f.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="332"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>And that&rsquo;s it! Click <code>Save</code> and view the summary screen. It should look similar to this:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-11_hu_9f50e9fa7519c485.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-11_hu_9f50e9fa7519c485.webp 736w, /blog/deleting-massive-s3-buckets-the-easy-way/image-11_hu_3af2d55c3c466359.webp 1104w, /blog/deleting-massive-s3-buckets-the-easy-way/image-11_hu_968d3c77a5146951.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="115"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h2 id="and-now-we-wait">
  <a class="heading-link" href="#and-now-we-wait">And now&hellip; we wait.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>It will probably take an additional day for all pre-existing objects to be marked as &ldquo;expired&rdquo;. Next, AWS will trigger the lifecycle policies at 12am UTC. Depending on the size of your bucket, it could possibly take several days to empty. Just go on an extended ☕️ break.</p>
<p><em>Something to keep in mind is that you will not be charged for storage that has been marked as expired while AWS empties the associated bucket.</em></p>
<h2 id="and-thats-that">
  <a class="heading-link" href="#and-thats-that">And that&rsquo;s that!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Look, this works. But, it isn&rsquo;t exactly intuitive. In my opinion, you shouldn&rsquo;t have to google &ldquo;how to empty large S3 bucket&rdquo; or even go out of your way to create these policies. Ideally, this would all be neatly abstracted away from you and handled with a single button click and a confirmation interstitial ( one that you can walk away from ).</p>
<p>Anyway, it is what it is. I&rsquo;ll keep checking back over the next few days to chase up its progress.</p>
<p>Hope this helped! Happy hacking! 🤘</p>
<h2 id="several-days-later">
  <a class="heading-link" href="#several-days-later">Several days later&hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Ok, it&rsquo;s been a few days and we&rsquo;re finally seeing progress on one of the buckets in question:</p>
<p><img
  src="https://wilhelm.codes/blog/deleting-massive-s3-buckets-the-easy-way/image-12_hu_f859c91a5bb22a7d.webp"
  srcset="/blog/deleting-massive-s3-buckets-the-easy-way/image-12_hu_f859c91a5bb22a7d.webp 736w, /blog/deleting-massive-s3-buckets-the-easy-way/image-12_hu_ebf55a8c550a1d40.webp 1104w, /blog/deleting-massive-s3-buckets-the-easy-way/image-12_hu_1d75d5fcea97b06b.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="159"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>All it takes is a little bit of patience.</p>]]></content:encoded></item><item><title>Messing Around with TXT Records</title><link>https://wilhelm.codes/blog/messing-around-with-txt-records/</link><pubDate>Thu, 11 Nov 2021 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/messing-around-with-txt-records/</guid><category>dns</category><category>devops</category><category>networking</category><wc:kind>post</wc:kind><description>If you&amp;rsquo;ve spent any time in an ops-related position, you&amp;rsquo;ve had to add these records when using custom domain names while integrating with some 3rd-party service. Especially, if you&amp;rsquo;ve ever wanted to configure an email service provider, like Google&amp;rsquo;s Gmail, to appear as if you&amp;rsquo;re sending emails from a personal domain name.</description><content:encoded><![CDATA[<p>If you&rsquo;ve spent any time in an ops-related position, you&rsquo;ve had to add these records when using custom domain names while integrating with some 3rd-party service. Especially, if you&rsquo;ve ever wanted to configure an email service provider, like Google&rsquo;s Gmail, to appear as if you&rsquo;re sending emails from a personal domain name.</p>
<h2 id="wanna-see-a-neat-trick">
  <a class="heading-link" href="#wanna-see-a-neat-trick">Wanna see a neat trick?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Open up your terminal of choice and invoke this <code>dig</code> command:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig txt contact.wilhelm.codes +short</span></span></code></pre></div>
</div>
<p>You should see, hopefully 🤞, the following block of text as a response:</p>
<div class="code-block">
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;twitter: @wilhelm&#34;
</span></span><span class="line"><span class="cl">&#34;github:  wilhelm-murdoch&#34;
</span></span><span class="line"><span class="cl">&#34;keybase: 7F89 5036 2816 06F6&#34;
</span></span><span class="line"><span class="cl">&#34;blog:    https://wilhelm.codes/&#34;</span></span></code></pre></div>
</div>
<h2 id="what-youre-looking-at">
  <a class="heading-link" href="#what-youre-looking-at">What you&rsquo;re looking at.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Simply put, you&rsquo;re looking at my contact details using a simple DNS <code>TXT</code> record lookup. There are <a href="https://dns.google/query?name=contact.wilhelm.codes">heaps</a> <a href="https://toolbox.googleapps.com/apps/dig/#TXT/">of</a> <a href="https://www.diggui.com/">online tools</a> out there to help you directly query DNS records, but <code>dig</code> tends to be the most common utility included included in most Linux distributions as well as MacOS.</p>
<h2 id="why-might-you-do-this">
  <a class="heading-link" href="#why-might-you-do-this">Why might you do this?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Originally, <code>TXT</code> records were meant to allow DNS administrators to attach simple plain text human-readable &ldquo;notes&rdquo; to their zones. A bit later, it became common practice to use this flexible record type as a way to verify ownership of a domain.</p>
<p>If you&rsquo;ve spent any time in an ops-related position, you&rsquo;ve had to add these records when using custom domain names while integrating with some 3rd-party service. Especially, if you&rsquo;ve ever wanted to configure an email service provider, like Google&rsquo;s Gmail, to appear as if you&rsquo;re sending emails from a personal domain name.</p>
<p>So, why not also create personal, human-readable way of declaring ownership over a domain as well? Like a globally-accessible low-tech &ldquo;business card&rdquo;.</p>
<p>DNS is effectively a globally-distributed, (mostly) fault-tolerant, eventually-consistent key / value store. These details can be easily looked up and verified by anyone with an Internet connection.</p>
<p>And as for &ldquo;Why?&rdquo;, why not?</p>
<h2 id="heres-you-would-implement-it">
  <a class="heading-link" href="#heres-you-would-implement-it">Here&rsquo;s you would implement it.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Honestly, there&rsquo;s nothing special about how to implement this. It&rsquo;s just another record. In my case, I ended up creating a separate <code>TXT</code> record <em>per</em> contact detail. Depending on which server software your DNS provider is using, you may or may not be able to create multi-line text blocks. So, your safest bet is just multiple one-liners attached to the same <code>CNAME</code>.</p>
<p>I recently migrated my zone for <code>wilhelm.codes</code> from <a href="https://www.vultr.com/">Vultr</a> to <a href="https://www.cloudflare.com/">CloudFlare</a> and this is pretty much what it all looks like:</p>
<p><img
  src="https://wilhelm.codes/blog/messing-around-with-txt-records/image-1_hu_3936a63f6d16eb12.webp"
  srcset="/blog/messing-around-with-txt-records/image-1_hu_3936a63f6d16eb12.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="284"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>Obviously, this interface will be different if you use a different service, but that&rsquo;s all there is to it!</p>
<h2 id="thats-it">
  <a class="heading-link" href="#thats-it">That&rsquo;s it!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Yep. This isn&rsquo;t groundbreaking stuff and I&rsquo;m surely not the first person to do something like this. Just a bit of (mostly) harmless DNS fun!</p>]]></content:encoded></item><item><title>Falsifying Github Participation Graphs for Fun &amp; Profit</title><link>https://wilhelm.codes/blog/falsifying-github-participation-graphs-for-fun-and-profit/</link><pubDate>Thu, 04 Nov 2021 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/falsifying-github-participation-graphs-for-fun-and-profit/</guid><category>bash</category><category>git</category><wc:kind>post</wc:kind><description>If you&amp;rsquo;ve spent any time job hunting in the tech industry, you&amp;rsquo;ve had to deal with recruiters. I like to joke around with my peers about how they&amp;rsquo;re a necessary evil, but in all seriousness, great recruiters are worth their weight in gold. However &amp;hellip;</description><content:encoded><![CDATA[<p>If you&rsquo;ve spent any time job hunting in the tech industry, you&rsquo;ve had to deal with recruiters. I like to joke around with my peers about how they&rsquo;re a necessary evil, but in all seriousness, great recruiters are worth their weight in gold. However &hellip;</p>
<p>But, good or bad, far too many of them will pass over your resume for the wildest reasons. We&rsquo;re not here to go over all of them. No, today we&rsquo;re going to address the somewhat recent trend of glancing at your public Github profile page and grading you based on how many green squares show up in your participation graph.</p>
<h2 id="whats-your-point">
  <a class="heading-link" href="#whats-your-point">What&rsquo;s your point?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Like basing your performance on LoC, using &ldquo;commits made&rdquo; as a recruiting metric is beyond useless. There can be any number of reasons why your graph isn&rsquo;t as populated as they&rsquo;d like:</p>
<ol>
<li>You work on private repositories and you haven&rsquo;t configured your profile to show anonymised private contributions.</li>
<li>You have an alternate account from your personal one to keep work and private life separate. Or, maybe it&rsquo;s just company policy.</li>
<li>You&rsquo;ve signed an NDA and can&rsquo;t publish your contributions publicly.</li>
<li>You set personal boundaries for your work-life balance and don&rsquo;t care to stare at glowing rectangles in your free time.</li>
<li>Or, maybe you&rsquo;ve just had a slow year? 🤷</li>
</ol>
<p>Personally, I have <em>thousands</em> of commits that will never see the light of day for some of the ☝️ points. If I&rsquo;m out job hunting, I would hate to be passed over because I don&rsquo;t tick some mundane box on an arbitrary checklist.</p>
<h2 id="what-to-do">
  <a class="heading-link" href="#what-to-do">What to do?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Well, we light up that graph like it&rsquo;s a switch board, of course. Make this information utterly useless. What do they care if your profile says you&rsquo;ve had <code>2,345</code> commits in the last week? Maybe you did. If it&rsquo;s 🟩 , they cross it off their checklist and move on to something else.</p>
<h2 id="ok-show-me">
  <a class="heading-link" href="#ok-show-me">Ok, show me.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Crack your knuckles and get ready to do some coding. For this little project, we&rsquo;ll be doing some simple Bash scripting. I&rsquo;m currently working on MacOS, but this code should be fairly portable on other Linux-based distributions.</p>
<p>You will, naturally, need a Github account. Let&rsquo;s start there.</p>
<h3 id="configure-your-participation-graph">
  <a class="heading-link" href="#configure-your-participation-graph">Configure Your Participation Graph<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Did you know you can configure your participation graph to display anonymised private commit statistics? Ensure that setting is ✅ and move on to the next step.</p>
<p><img
  src="https://wilhelm.codes/blog/falsifying-github-participation-graphs-for-fun-and-profit/image-1_hu_a64e6216dfe1bb5e.webp"
  srcset="/blog/falsifying-github-participation-graphs-for-fun-and-profit/image-1_hu_a64e6216dfe1bb5e.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="198"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h3 id="new-private-repository">
  <a class="heading-link" href="#new-private-repository">New Private Repository<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Let&rsquo;s create a new <em>empty</em> repository entitled <code>graph-participation</code> and make sure it&rsquo;s private.</p>
<p><img
  src="https://wilhelm.codes/blog/falsifying-github-participation-graphs-for-fun-and-profit/image-2_hu_b7f3ea1f8aabce13.webp"
  srcset="/blog/falsifying-github-participation-graphs-for-fun-and-profit/image-2_hu_b7f3ea1f8aabce13.webp 736w, /blog/falsifying-github-participation-graphs-for-fun-and-profit/image-2_hu_415764d2f8820b54.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="78"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<h3 id="checkout-local-working-copy">
  <a class="heading-link" href="#checkout-local-working-copy">Checkout Local Working Copy<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Follow the post-setup instructions Github gives you to check out a local working copy of your new 🔒 repository!</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Let&#39;s goooooooo!&#34;</span> &gt;&gt; README.md
</span></span><span class="line"><span class="cl">git init
</span></span><span class="line"><span class="cl">git add README.md
</span></span><span class="line"><span class="cl">git commit -m <span class="s2">&#34;The first and final legitimate commit in this repository ...&#34;</span>
</span></span><span class="line"><span class="cl">git branch -M main
</span></span><span class="line"><span class="cl">git remote add origin git@github.com:&lt;account&gt;/github-participation.git
</span></span><span class="line"><span class="cl">git push -u origin main</span></span></code></pre></div>
</div>
<p>Of course, replace <code>&lt;account&gt;</code> with the name of your Github account.</p>
<h3 id="writing-some-code">
  <a class="heading-link" href="#writing-some-code">Writing Some Code<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Here comes the best part! What, you didn&rsquo;t think you&rsquo;d walk away from this article without writing a bit of code, did you?</p>
<p>This script is super-simple. There&rsquo;s no need for elegance here as we&rsquo;re probably only ever going to run this code once or twice. I&rsquo;ll show you the full snippet and then walk you through the details.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">start</span><span class="o">=</span>2021-05-01
</span></span><span class="line"><span class="cl"><span class="nv">stop</span><span class="o">=</span>2021-05-09
</span></span><span class="line"><span class="cl"><span class="nv">max</span><span class="o">=</span><span class="m">20</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">start</span><span class="si">}</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">stop</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">do</span> 
</span></span><span class="line"><span class="cl">  <span class="nv">start</span><span class="o">=</span><span class="k">$(</span>gdate -I -d <span class="s2">&#34;</span><span class="si">${</span><span class="nv">start</span><span class="si">}</span><span class="s2"> + 1 day&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="o">((</span><span class="nv">run</span><span class="o">=</span>1<span class="p">;</span> run &lt;<span class="o">=</span> <span class="k">$((</span>RANDOM <span class="o">%</span> max <span class="o">+</span> <span class="m">1</span><span class="k">))</span><span class="p">;</span> run++<span class="o">))</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    openssl rand -hex <span class="m">32</span> &gt; touch.txt
</span></span><span class="line"><span class="cl">    git add -A
</span></span><span class="line"><span class="cl">    <span class="nb">export</span> <span class="nv">GIT_AUTHOR_DATE</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">start</span><span class="si">}</span><span class="s2">T00:01&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">export</span> <span class="nv">GIT_COMMITTER_DATE</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">GIT_AUTHOR_DATE</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    git commit -m <span class="s2">&#34;Touch #</span><span class="si">${</span><span class="nv">run</span><span class="si">}</span><span class="s2"> for </span><span class="si">${</span><span class="nv">GIT_AUTHOR_DATE</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>First things first, we only need to know three things to get started:</p>
<ol>
<li><code>start</code>: The day of our first &ldquo;commit&rdquo; in the format of <code>YYYY-MM-DD</code>.</li>
<li><code>stop</code>: The day of our last &ldquo;commit&rdquo; in the format of <code>YYYY-MM-DD</code>.</li>
<li><code>max</code>: We don&rsquo;t want a single commit per day. Otherwise, the graph will be all one colour and not at all random enough to look legitimate. So, to account for this, we randomly create between <code>1</code> and <code>20</code> commits per day.</li>
</ol>
<p>We start a <code>while</code> loop that keeps going until our <code>start</code> and <code>stop</code> strings are no longer different. With each iteration, we add <code>1</code> day to our <code>start</code> variable. Eventually, this date will increment until both <code>start</code> and <code>stop</code> strings are equal, thus ending the loop.</p>
<p>For each day iteration, we create another loop that randomly-iterates anywhere between <code>1</code> and <code>max</code> times. Within each nested iteration we replace the content of the file named <code>touch.txt</code> with a random value making it a unique commit.</p>
<p>We then stage this change and assign a date value to both the <code>GIT_AUTHOR_DATE</code> and <code>GIT_COMMITTER_DATE</code> environmental variables. These are what allow you to easily back date a commit.</p>
<p>Finally, we commit this unique change locally. This keeps happening for every day until the script stops.</p>
<p>Write this to an executable file, or just paste it into your terminal of choice and hit <code>enter</code>. If you see the following kind of log output, you know you&rsquo;re doing the right thing:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="o">[</span>main 2ebb5b9<span class="o">]</span> Touch <span class="c1">#1 2021-05-07T00:01</span>
</span></span><span class="line"><span class="cl"> <span class="m">1</span> file changed, <span class="m">1</span> insertion<span class="o">(</span>+<span class="o">)</span>, <span class="m">1</span> deletion<span class="o">(</span>-<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span>main e104b0a<span class="o">]</span> Touch <span class="c1">#2 2021-05-07T00:01</span>
</span></span><span class="line"><span class="cl"> <span class="m">1</span> file changed, <span class="m">1</span> insertion<span class="o">(</span>+<span class="o">)</span>, <span class="m">1</span> deletion<span class="o">(</span>-<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span>main 873fa39<span class="o">]</span> Touch <span class="c1">#3 2021-05-07T00:01</span>
</span></span><span class="line"><span class="cl"> <span class="m">1</span> file changed, <span class="m">1</span> insertion<span class="o">(</span>+<span class="o">)</span>, <span class="m">1</span> deletion<span class="o">(</span>-<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span>main 9def564<span class="o">]</span> Touch <span class="c1">#4 2021-05-07T00:01</span>
</span></span><span class="line"><span class="cl"> <span class="m">1</span> file changed, <span class="m">1</span> insertion<span class="o">(</span>+<span class="o">)</span>, <span class="m">1</span> deletion<span class="o">(</span>-<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span>main 1b83187<span class="o">]</span> Touch <span class="c1">#1 2021-05-08T00:01</span>
</span></span><span class="line"><span class="cl"> <span class="m">1</span> file changed, <span class="m">1</span> insertion<span class="o">(</span>+<span class="o">)</span>, <span class="m">1</span> deletion<span class="o">(</span>-<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span>main b9f00db<span class="o">]</span> Touch <span class="c1">#2 2021-05-08T00:01</span>
</span></span><span class="line"><span class="cl"> <span class="m">1</span> file changed, <span class="m">1</span> insertion<span class="o">(</span>+<span class="o">)</span>, <span class="m">1</span> deletion<span class="o">(</span>-<span class="o">)</span></span></span></code></pre></div>
</div>
<p>Now, you simply have to push all your changes up to your repository on Github!</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git push origin main</span></span></code></pre></div>
</div>
<p>All you now have to do is sit back and wait for everything to update.</p>
<p>We&rsquo;ve gone from barren landscape:</p>
<p><img
  src="https://wilhelm.codes/blog/falsifying-github-participation-graphs-for-fun-and-profit/image-3_hu_57ee498a4822644c.webp"
  srcset="/blog/falsifying-github-participation-graphs-for-fun-and-profit/image-3_hu_57ee498a4822644c.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="185"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>To a verdant garden paradise:</p>
<p><img
  src="https://wilhelm.codes/blog/falsifying-github-participation-graphs-for-fun-and-profit/image-4_hu_86315f66da62beba.webp"
  srcset="/blog/falsifying-github-participation-graphs-for-fun-and-profit/image-4_hu_86315f66da62beba.webp 736w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="184"
  alt=""
  
  loading="lazy"
  decoding="async"
/>
</p>
<p>And that&rsquo;s it! 🎉</p>
<p>If you&rsquo;ve made a mistake, simply delete your private repository and try again with a fresh one.</p>
<h2 id="any-final-thoughts">
  <a class="heading-link" href="#any-final-thoughts">Any final thoughts?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Job hunting is hard enough. Especially with more and more companies becoming increasingly risk-averse in their interviewing and recruiting processes. It&rsquo;s my hope that this little bit of harmless mischief will make things a bit easier for you!</p>]]></content:encoded></item></channel></rss>