💾 Archived View for gmi.runtimeterror.dev › feed.xml captured on 2024-05-26 at 14:49:36.
⬅️ Previous capture (2024-05-10)
-=-=-=-=-=-=-
<?xml version="1.0" encoding="utf-8" standalone="yes"?> <?xml-stylesheet type="text/xsl" href="xml/feed.xsl" media="all"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"> <channel> <title>runtimeterror</title> <link>https://runtimeterror.dev/</link> <description>Recent content on runtimeterror</description> <generator>Hugo -- gohugo.io</generator> <language>en</language> <copyright>© 2024 John Bowdre</copyright> <lastBuildDate>Tue, 30 Apr 2024 00:00:00 +0000</lastBuildDate><atom:link href="https://runtimeterror.dev/feed.xml" rel="self" type="application/rss+xml" /><image> <url>https://runtimeterror.dev/images/broken-computer.png</url> <title>runtimeterror</title> <link>https://runtimeterror.dev/</link> </image> <item> <title>Prettify Hugo RSS Feeds with XSLT</title> <link>https://runtimeterror.dev/prettify-hugo-rss-feed-xslt/</link> <pubDate>Tue, 30 Apr 2024 00:00:00 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>hugo</category> <category>meta</category> <guid>https://runtimeterror.dev/prettify-hugo-rss-feed-xslt/</guid><description><p>I put in some work several months back making my sure my site's RSS would work well in a feed reader. This meant making a <em>lot</em> of modifications to the <a href="https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/embedded/templates/_default/rss.xml">default Hugo RSS template</a>. I made it load the full article text rather than just the summary, present correctly-formatted code blocks with no loss of important whitespace, include inline images, and even pass online validation checks:</p> <p><a href="http://validator.w3.org/feed/check.cgi?url=https%3A//runtimeterror.dev/feed.xml"><img src="https://runtimeterror.dev/prettify-hugo-rss-feed-xslt/valid-rss-rogers.png" alt="Validate my RSS feed"></a></p> <p>But while the feed looks great when rendered by a reader, the browser presentation left some to be desired...</p> <p><img src="https://runtimeterror.dev/prettify-hugo-rss-feed-xslt/ugly-rss.png" alt="Ugly RSS rendered without styling"></p> <p>It feels like there should be a friendlier way to present a feed &quot;landing page&quot; to help users new to RSS figure out what they need to do in order to follow a blog - and there absolutely is. In much the same way that you can prettify plain HTML with the inclusion of a CSS stylesheet, you can also style boring XML using <a href="https://www.w3schools.com/xml/xsl_intro.asp">eXtensible Stylesheet Language Transformations (XSLT)</a>.</p> <p>This post will quickly cover how I used XSLT to style my blog's RSS feed and made it look like this:</p> <p><img src="https://runtimeterror.dev/prettify-hugo-rss-feed-xslt/pretty-feed.png" alt="Much more attractive RSS feed with styling to fit the site's theme"></p> <h3 id="starting-point">Starting Point</h3> <p>The <a href="https://gohugo.io/templates/rss/">RSS Templates</a> page from the Hugo documentation site provides some basic information about how to generate (and customize) an RSS feed for a Hugo-powered site. The basic steps are to <a href="https://github.com/jbowdre/runtimeterror/blob/871be9794234177c1bfa0b1c470873bde8f046be/config/_default/hugo.toml#L19-L30">enable the RSS output in <code>hugo.toml</code></a>, include a link to the generated feed inside the <code>&lt;head&gt;</code> element of the site template (I added it to <a href="https://github.com/jbowdre/runtimeterror/blob/871be9794234177c1bfa0b1c470873bde8f046be/layouts/partials/head.html#L8-L11"><code>layouts/partials/head.html</code></a>), and (optionally) include a customized RSS template to influence how the output gets rendered.</p> <p>Here's the content of my <code>layouts/_default/rss.xml</code>, before adding the XSLT styling:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span># torchlight! {&#34;lineNumbers&#34;: true} </span></span><span style="display:flex;"><span>{{- $pctx := . -}} </span></span><span style="display:flex;"><span>{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}} </span></span><span style="display:flex;"><span>{{- $pages := slice -}} </span></span><span style="display:flex;"><span>{{- if or $.IsHome $.IsSection -}} </span></span><span style="display:flex;"><span>{{- $pages = (where $pctx.RegularPages &#34;Type&#34; &#34;in&#34; site.Params.mainSections) -}} </span></span><span style="display:flex;"><span>{{- else -}} </span></span><span style="display:flex;"><span>{{- $pages = (where $pctx.Pages &#34;Type&#34; &#34;in&#34; site.Params.mainSections) -}} </span></span><span style="display:flex;"><span>{{- end -}} </span></span><span style="display:flex;"><span>{{- $limit := .Site.Config.Services.RSS.Limit -}} </span></span><span style="display:flex;"><span>{{- if ge $limit 1 -}} </span></span><span style="display:flex;"><span>{{- $pages = $pages | first $limit -}} </span></span><span style="display:flex;"><span>{{- end -}} </span></span><span style="display:flex;"><span>{{- printf &#34;<span style="color:#75715e">&lt;?xml version=\&#34;1.0\&#34; encoding=\&#34;utf-8\&#34; standalone=\&#34;yes\&#34;?&gt;</span>&#34; | safeHTML }} </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;rss</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;2.0&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">xmlns:atom=</span><span style="color:#e6db74">&#34;http://www.w3.org/2005/Atom&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">xmlns:content=</span><span style="color:#e6db74">&#34;http://purl.org/rss/1.0/modules/content/&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">xmlns:dc=</span><span style="color:#e6db74">&#34;http://purl.org/dc/elements/1.1/&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;channel&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;title&gt;</span>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }}<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;link&gt;</span>{{ .Permalink }}<span style="color:#f92672">&lt;/link&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;description&gt;</span>Recent content {{ if ne .Title .Site.Title }}{{ with .Title }}in {{.}} {{ end }}{{ end }}on {{ .Site.Title }}<span style="color:#f92672">&lt;/description&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;generator&gt;</span>Hugo -- gohugo.io<span style="color:#f92672">&lt;/generator&gt;</span>{{ with .Site.LanguageCode }} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;language&gt;</span>{{.}}<span style="color:#f92672">&lt;/language&gt;</span>{{end}}{{ with .Site.Copyright }} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;copyright&gt;</span>{{.}}<span style="color:#f92672">&lt;/copyright&gt;</span>{{end}}{{ if not .Date.IsZero }} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;lastBuildDate&gt;</span>{{ .Date.Format &#34;Mon, 02 Jan 2006 15:04:05 -0700&#34; | safeHTML }}<span style="color:#f92672">&lt;/lastBuildDate&gt;</span>{{ end }} </span></span><span style="display:flex;"><span> {{- with .OutputFormats.Get &#34;RSS&#34; -}} </span></span><span style="display:flex;"><span> {{ printf &#34;<span style="color:#f92672">&lt;atom:link</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">%q</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">\&#34;self\&#34;</span> <span style="color:#a6e22e">type=</span><span style="color:#e6db74">%q</span> <span style="color:#f92672">/&gt;</span>&#34; .Permalink .MediaType | safeHTML }} </span></span><span style="display:flex;"><span> {{- end -}} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;image&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;url&gt;</span>{{ .Site.Params.fallBackOgImage | absURL }}<span style="color:#f92672">&lt;/url&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;title&gt;</span>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }}<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;link&gt;</span>{{ .Permalink }}<span style="color:#f92672">&lt;/link&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;/image&gt;</span> </span></span><span style="display:flex;"><span> {{ range $pages }} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;item&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;title&gt;</span>{{ .Title | plainify }}<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;link&gt;</span>{{ .Permalink }}<span style="color:#f92672">&lt;/link&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;pubDate&gt;</span>{{ .Date.Format &#34;Mon, 02 Jan 2006 15:04:05 -0700&#34; | safeHTML }}<span style="color:#f92672">&lt;/pubDate&gt;</span> </span></span><span style="display:flex;"><span> {{ with .Site.Params.Author.name }}<span style="color:#f92672">&lt;dc:creator&gt;</span>{{.}}<span style="color:#f92672">&lt;/dc:creator&gt;</span>{{ end }} </span></span><span style="display:flex;"><span> {{ with .Params.series }}<span style="color:#f92672">&lt;category&gt;</span>{{ . | lower }}<span style="color:#f92672">&lt;/category&gt;</span>{{ end }} </span></span><span style="display:flex;"><span> {{ range (.GetTerms &#34;tags&#34;) }} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;category&gt;</span>{{ .LinkTitle }}<span style="color:#f92672">&lt;/category&gt;</span>{{ end }} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;guid&gt;</span>{{ .Permalink }}<span style="color:#f92672">&lt;/guid&gt;</span> </span></span><span style="display:flex;"><span> {{- $content := replaceRE &#34;a href=\&#34;(#.*?)\&#34;&#34; (printf &#34;%s%s%s&#34; &#34;a href=\&#34;&#34; .Permalink &#34;$1\&#34;&#34;) .Content -}} </span></span><span style="display:flex;"><span> {{- $content = replaceRE &#34;img src=\&#34;(.*?)\&#34;&#34; (printf &#34;%s%s%s&#34; &#34;img src=\&#34;&#34; .Permalink &#34;$1\&#34;&#34;) $content -}} </span></span><span style="display:flex;"><span> {{- $content = replaceRE &#34;<span style="color:#f92672">&lt;svg.</span><span style="color:#960050;background-color:#1e0010">*&lt;/svg</span><span style="color:#f92672">&gt;</span>&#34; &#34;&#34; $content -}} </span></span><span style="display:flex;"><span> {{- $content = replaceRE `-moz-tab-size:\d;-o-tab-size:\d;tab-size:\d;?` &#34;&#34; $content -}} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;description&gt;</span>{{ $content | html }}<span style="color:#f92672">&lt;/description&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;/item&gt;</span> </span></span><span style="display:flex;"><span> {{ end }} </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;/channel&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/rss&gt;</span> </span></span></code></pre></div><p>There's a lot going on here, but much of it is conditional logic so that Hugo can use the same template to render feeds for individual tags, categories, or the entire site. It then loops through the <code>range</code> of pages of that type to generate the data for each post. It also uses the <a href="https://gohugo.io/functions/strings/replacere/">Hugo <code>strings.ReplaceRE</code> function</a> to replace relative image and anchor links with the full paths so those references will work correctly in readers, and to clean up some potentially-problematic HTML markup that was causing validation failures.</p> <p>All I really need to do to get this XML ready to be styled is just link in a style sheet, and I'll do that by inserting a <code>&lt;?xml-stylesheet /&gt;</code> element directly below the top-level <code>&lt;?xml /&gt;</code> one:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span># torchlight! {&#34;lineNumbers&#34;: true, &#34;lineNumbersStart&#34;: 10} </span></span><span style="display:flex;"><span>{{- if ge $limit 1 -}} </span></span><span style="display:flex;"><span>{{- $pages = $pages | first $limit -}} </span></span><span style="display:flex;"><span>{{- end -}} </span></span><span style="display:flex;"><span>{{- printf &#34;<span style="color:#75715e">&lt;?xml version=\&#34;1.0\&#34; encoding=\&#34;utf-8\&#34; standalone=\&#34;yes\&#34;?&gt;</span>&#34; | safeHTML }} </span></span><span style="display:flex;"><span>{{ printf &#34;<span style="color:#75715e">&lt;?xml-stylesheet type=\&#34;text/xsl\&#34; href=\&#34;xml/feed.xsl\&#34; media=\&#34;all\&#34;?&gt;</span>&#34; | safeHTML }} <span style="color:#75715e">&lt;!-- [tl! ++ ] --&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;rss</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;2.0&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">xmlns:atom=</span><span style="color:#e6db74">&#34;http://www.w3.org/2005/Atom&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">xmlns:content=</span><span style="color:#e6db74">&#34;http://purl.org/rss/1.0/modules/content/&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">xmlns:dc=</span><span style="color:#e6db74">&#34;http://purl.org/dc/elements/1.1/&#34;</span><span style="color:#f92672">&gt;</span> </span></span></code></pre></div><p>I'll put the stylesheet in <code>static/xml/feed.xsl</code> so Hugo will make it available at the appropriate path.</p> <h3 id="creating-the-style">Creating the Style</h3> <p>While trying to figure out how I could dress up my RSS XML, I came across the <a href="https://github.com/getnikola/nikola/blob/master/nikola/data/themes/base/assets/xml/rss.xsl">default XSL file</a> provided with the <a href="https://getnikola.com/">Nikola SSG</a>, and I thought it looked like a pretty good starting point.</p> <p>Here's Nikola's default XSL:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span># torchlight! {&#34;lineNumbers&#34;: true} </span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;xsl:stylesheet</span> <span style="color:#a6e22e">xmlns:xsl=</span><span style="color:#e6db74">&#34;http://www.w3.org/1999/XSL/Transform&#34;</span> <span style="color:#a6e22e">xmlns:atom=</span><span style="color:#e6db74">&#34;http://www.w3.org/2005/Atom&#34;</span> <span style="color:#a6e22e">xmlns:dc=</span><span style="color:#e6db74">&#34;http://purl.org/dc/elements/1.1/&#34;</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;1.0&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;xsl:output</span> <span style="color:#a6e22e">method=</span><span style="color:#e6db74">&#34;xml&#34;</span><span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;xsl:template</span> <span style="color:#a6e22e">match=</span><span style="color:#e6db74">&#34;/&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;html</span> <span style="color:#a6e22e">xmlns=</span><span style="color:#e6db74">&#34;http://www.w3.org/1999/xhtml&#34;</span> <span style="color:#a6e22e">lang=</span><span style="color:#e6db74">&#34;en&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;head&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;meta</span> <span style="color:#a6e22e">charset=</span><span style="color:#e6db74">&#34;UTF-8&#34;</span><span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;meta</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;viewport&#34;</span> <span style="color:#a6e22e">content=</span><span style="color:#e6db74">&#34;width=device-width&#34;</span><span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;title&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/title&#34;</span><span style="color:#f92672">/&gt;</span> (RSS)<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;style&gt;</span><span style="color:#75715e">&lt;![CDATA[html{margin:0;padding:0;}body{color:hsl(180,1%,31%);font-family:Helvetica,Arial,sans-serif;font-size:17px;line-height:1.4;margin:5%;max-width:35rem;padding:0;}input{min-width:20rem;margin-left:.2rem;padding-left:.2rem;padding-right:.2rem;}ol{list-style-type:disc;padding-left:1rem;}h2{font-size:22px;font-weight:inherit;}]]&gt;</span><span style="color:#f92672">&lt;/style&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/head&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;body&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;h1&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/title&#34;</span><span style="color:#f92672">/&gt;</span> (RSS)<span style="color:#f92672">&lt;/h1&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;p&gt;</span>This is an <span style="color:#f92672">&lt;abbr</span> <span style="color:#a6e22e">title=</span><span style="color:#e6db74">&#34;Really Simple Syndication&#34;</span><span style="color:#f92672">&gt;</span>RSS<span style="color:#f92672">&lt;/abbr&gt;</span> feed. To subscribe to it, copy its address and paste it when your feed reader asks for it. It will be updated periodically in your reader. New to feeds? <span style="color:#f92672">&lt;a</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;https://duckduckgo.com/?q=how+to+get+started+with+rss+feeds&#34;</span> <span style="color:#a6e22e">title=</span><span style="color:#e6db74">&#34;Search on the web to learn more&#34;</span><span style="color:#f92672">&gt;</span>Learn more<span style="color:#f92672">&lt;/a&gt;</span>.<span style="color:#f92672">&lt;/p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;label</span> <span style="color:#a6e22e">for=</span><span style="color:#e6db74">&#34;address&#34;</span><span style="color:#f92672">&gt;</span>RSS address:<span style="color:#f92672">&lt;/label&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;input&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;type&#34;</span><span style="color:#f92672">&gt;</span>url<span style="color:#f92672">&lt;/xsl:attribute&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;id&#34;</span><span style="color:#f92672">&gt;</span>address<span style="color:#f92672">&lt;/xsl:attribute&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;spellcheck&#34;</span><span style="color:#f92672">&gt;</span>false<span style="color:#f92672">&lt;/xsl:attribute&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;value&#34;</span><span style="color:#f92672">&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/atom:link[@rel=&#39;self&#39;]/@href&#34;</span><span style="color:#f92672">/&gt;&lt;/xsl:attribute&gt;&lt;/input&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;p&gt;</span>Preview of the feed’s current headlines:<span style="color:#f92672">&lt;/p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;ol&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;xsl:for-each</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/item&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;li&gt;&lt;h2&gt;&lt;a&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;href&#34;</span><span style="color:#f92672">&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;link&#34;</span><span style="color:#f92672">/&gt;&lt;/xsl:attribute&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;title&#34;</span><span style="color:#f92672">/&gt;&lt;/a&gt;&lt;/h2&gt;&lt;/li&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/xsl:for-each&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/ol&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/body&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/html&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/xsl:template&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/xsl:stylesheet&gt;</span> </span></span></code></pre></div><p>If I just plug that in at <code>static/xml/feed.xml</code>, I do successfully get a styled (though <em>very</em> white) RSS page:</p> <p><img src="https://runtimeterror.dev/prettify-hugo-rss-feed-xslt/very-white-feed.png" alt="A very bright white (but styled) RSS page"></p> <p>I'd like this to inherit the same styling as the rest of the site so that it looks like it belongs. I can go a long way toward that by bringing in the CSS stylesheets that are used on every page, and I'll also tweak the existing <code>&lt;style /&gt;</code> element to remove some conflicts with my preferred styling:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span># torchlight! {&#34;lineNumbers&#34;: true, &#34;lineNumbersStart&#34;: 10} </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;title&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/title&#34;</span><span style="color:#f92672">/&gt;</span> (RSS)<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;style&gt;</span><span style="color:#75715e">&lt;![CDATA[html{margin:0;padding:0;}body{color:hsl(180,1%,31%);font-family:Helvetica,Arial,sans-serif;font-size:17px;line-height:1.4;margin:5%;max-width:35rem;padding:0;}input{min-width:20rem;margin-left:.2rem;padding-left:.2rem;padding-right:.2rem;}ol{list-style-type:disc;padding-left:1rem;}h2{font-size:22px;font-weight:inherit;}]]&gt;</span><span style="color:#f92672">&lt;/style&gt;&lt;title&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/title&#34;</span><span style="color:#f92672">/&gt;</span> (RSS)<span style="color:#f92672">&lt;/title&gt;</span> <span style="color:#75715e">&lt;!-- [tl! remove ] --&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;style&gt;</span><span style="color:#75715e">&lt;![CDATA[html{margin:0;padding:0;}body{color:font-size:1.1rem;line-height:1.4;margin:5%;max-width:35rem;padding:0;}input{min-width:20rem;margin-left:.2rem;padding-left:.2rem;padding-right:.2rem;}h2{font-size:22px;font-weight:inherit;}h2:before{content:&#34;&#34; !important;}]]&gt;</span><span style="color:#f92672">&lt;/style&gt;</span> <span style="color:#75715e">&lt;!-- [tl! ++:3 reindex(-1) ] --&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/palettes/runtimeterror.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/risotto.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/custom.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/head&gt;</span> </span></span></code></pre></div><p>While I'm at it, I'll also go on and add in some favicons:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span># torchlight! {&#34;lineNumbers&#34;: true, &#34;lineNumbersStart&#34;: 10} </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;title&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/title&#34;</span><span style="color:#f92672">/&gt;</span> (RSS)<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;style&gt;</span><span style="color:#75715e">&lt;![CDATA[html{margin:0;padding:0;}body{color:font-size:1.1rem;line-height:1.4;margin:5%;max-width:35rem;padding:0;}input{min-width:20rem;margin-left:.2rem;padding-left:.2rem;padding-right:.2rem;}h2{font-size:22px;font-weight:inherit;}h2:before{content:&#34;&#34; !important;}]]&gt;</span><span style="color:#f92672">&lt;/style&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;apple-touch-icon&#34;</span> <span style="color:#a6e22e">sizes=</span><span style="color:#e6db74">&#34;180x180&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/apple-touch-icon.png&#34;</span> <span style="color:#f92672">/&gt;</span> <span style="color:#75715e">&lt;!-- [tl! ++:5] --&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;icon&#34;</span> <span style="color:#a6e22e">type=</span><span style="color:#e6db74">&#34;image/png&#34;</span> <span style="color:#a6e22e">sizes=</span><span style="color:#e6db74">&#34;32x32&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/favicon-32x32.png&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;icon&#34;</span> <span style="color:#a6e22e">type=</span><span style="color:#e6db74">&#34;image/png&#34;</span> <span style="color:#a6e22e">sizes=</span><span style="color:#e6db74">&#34;16x16&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/favicon-16x16.png&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;manifest&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/site.webmanifest&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;mask-icon&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/safari-pinned-tab.svg&#34;</span> <span style="color:#a6e22e">color=</span><span style="color:#e6db74">&#34;#5bbad5&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;shortcut icon&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/favicon.ico&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/palettes/runtimeterror.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/risotto.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/custom.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span></code></pre></div><p>That's getting there:</p> <p><img src="https://runtimeterror.dev/prettify-hugo-rss-feed-xslt/getting-there-feed.png" alt="A darker styled RSS page"></p> <p>Including those CSS styles means that the rendered page now uses my color palette and the <a href="https://runtimeterror.dev/using-custom-font-hugo/">font I worked so hard to integrate</a>. I'm just going to make a few more tweaks to change some of the formatting, put the <code>New to feeds?</code> bit on its own line, and point to my <a href="https://scribbles.jbowdre.lol/post/self-hosting-a-search-engine-iyjdlk6y">self-hosted instance of the SearXNG metasearch engine</a> instead of DDG.</p> <p>Here's my final (for now) <code>static/xml/feed.xsl</code> file:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span># torchlight! {&#34;lineNumbers&#34;: true} </span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;!-- adapted from https://github.com/getnikola/nikola/blob/master/nikola/data/themes/base/assets/xml/rss.xsl --&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;xsl:stylesheet</span> <span style="color:#a6e22e">xmlns:xsl=</span><span style="color:#e6db74">&#34;http://www.w3.org/1999/XSL/Transform&#34;</span> <span style="color:#a6e22e">xmlns:atom=</span><span style="color:#e6db74">&#34;http://www.w3.org/2005/Atom&#34;</span> <span style="color:#a6e22e">xmlns:dc=</span><span style="color:#e6db74">&#34;http://purl.org/dc/elements/1.1/&#34;</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;1.0&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;xsl:output</span> <span style="color:#a6e22e">method=</span><span style="color:#e6db74">&#34;xml&#34;</span><span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;xsl:template</span> <span style="color:#a6e22e">match=</span><span style="color:#e6db74">&#34;/&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;html</span> <span style="color:#a6e22e">xmlns=</span><span style="color:#e6db74">&#34;http://www.w3.org/1999/xhtml&#34;</span> <span style="color:#a6e22e">lang=</span><span style="color:#e6db74">&#34;en&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;head&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;meta</span> <span style="color:#a6e22e">charset=</span><span style="color:#e6db74">&#34;UTF-8&#34;</span><span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;meta</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;viewport&#34;</span> <span style="color:#a6e22e">content=</span><span style="color:#e6db74">&#34;width=device-width&#34;</span><span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;title&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/title&#34;</span><span style="color:#f92672">/&gt;</span> (RSS)<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;style&gt;</span><span style="color:#75715e">&lt;![CDATA[html{margin:0;padding:0;}body{color:font-size:1.1rem;line-height:1.4;margin:5%;max-width:35rem;padding:0;}input{min-width:20rem;margin-left:.2rem;padding-left:.2rem;padding-right:.2rem;}h2{font-size:22px;font-weight:inherit;}h3:before{content:&#34;&#34; !important;}]]&gt;</span><span style="color:#f92672">&lt;/style&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;apple-touch-icon&#34;</span> <span style="color:#a6e22e">sizes=</span><span style="color:#e6db74">&#34;180x180&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/apple-touch-icon.png&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;icon&#34;</span> <span style="color:#a6e22e">type=</span><span style="color:#e6db74">&#34;image/png&#34;</span> <span style="color:#a6e22e">sizes=</span><span style="color:#e6db74">&#34;32x32&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/favicon-32x32.png&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;icon&#34;</span> <span style="color:#a6e22e">type=</span><span style="color:#e6db74">&#34;image/png&#34;</span> <span style="color:#a6e22e">sizes=</span><span style="color:#e6db74">&#34;16x16&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/favicon-16x16.png&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;manifest&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/site.webmanifest&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;mask-icon&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/safari-pinned-tab.svg&#34;</span> <span style="color:#a6e22e">color=</span><span style="color:#e6db74">&#34;#5bbad5&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;shortcut icon&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/icons/favicon.ico&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/palettes/runtimeterror.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/risotto.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;/css/custom.css&#34;</span> <span style="color:#f92672">/&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/head&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;body&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;h1&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/title&#34;</span><span style="color:#f92672">/&gt;</span> (RSS)<span style="color:#f92672">&lt;/h1&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;p&gt;</span>This is an <span style="color:#f92672">&lt;abbr</span> <span style="color:#a6e22e">title=</span><span style="color:#e6db74">&#34;Really Simple Syndication&#34;</span><span style="color:#f92672">&gt;</span>RSS<span style="color:#f92672">&lt;/abbr&gt;</span> feed. To subscribe to it, copy its address and paste it when your feed reader asks for it. It will be updated periodically in your reader.<span style="color:#f92672">&lt;/p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;p&gt;</span>New to feeds? <span style="color:#f92672">&lt;a</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;https://grep.vpota.to/search?q=how+to+get+started+with+rss+feeds&#34;</span> <span style="color:#a6e22e">title=</span><span style="color:#e6db74">&#34;Search on the web to learn more&#34;</span><span style="color:#f92672">&gt;</span>Learn more<span style="color:#f92672">&lt;/a&gt;</span>.<span style="color:#f92672">&lt;/p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;label</span> <span style="color:#a6e22e">for=</span><span style="color:#e6db74">&#34;address&#34;</span><span style="color:#f92672">&gt;</span>RSS address:<span style="color:#f92672">&lt;/label&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;input&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;type&#34;</span><span style="color:#f92672">&gt;</span>url<span style="color:#f92672">&lt;/xsl:attribute&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;id&#34;</span><span style="color:#f92672">&gt;</span>address<span style="color:#f92672">&lt;/xsl:attribute&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;spellcheck&#34;</span><span style="color:#f92672">&gt;</span>false<span style="color:#f92672">&lt;/xsl:attribute&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;value&#34;</span><span style="color:#f92672">&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/atom:link[@rel=&#39;self&#39;]/@href&#34;</span><span style="color:#f92672">/&gt;&lt;/xsl:attribute&gt;&lt;/input&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;p&gt;&lt;h2&gt;</span>Recent posts:<span style="color:#f92672">&lt;/h2&gt;&lt;/p&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;ul&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;xsl:for-each</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;rss/channel/item&#34;</span><span style="color:#f92672">&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;li&gt;&lt;h3&gt;&lt;a&gt;&lt;xsl:attribute</span> <span style="color:#a6e22e">name=</span><span style="color:#e6db74">&#34;href&#34;</span><span style="color:#f92672">&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;link&#34;</span><span style="color:#f92672">/&gt;&lt;/xsl:attribute&gt;&lt;xsl:value-of</span> <span style="color:#a6e22e">select=</span><span style="color:#e6db74">&#34;title&#34;</span><span style="color:#f92672">/&gt;&lt;/a&gt;&lt;/h3&gt;&lt;/li&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/xsl:for-each&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/ul&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/body&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/html&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/xsl:template&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/xsl:stylesheet&gt;</span> </span></span></code></pre></div><p>I'm pretty pleased with <a href="https://runtimeterror.dev/feed.xml">that result</a>!</p> </description> </item> <item> <title>Using a Custom Font with Hugo</title> <link>https://runtimeterror.dev/using-custom-font-hugo/</link> <pubDate>Sun, 28 Apr 2024 00:00:00 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>bunny</category> <category>cloudflare</category> <category>hugo</category> <category>meta</category> <category>tailscale</category> <guid>https://runtimeterror.dev/using-custom-font-hugo/</guid><description><p>Last week, I came across and immediately fell in love with a delightfully-retro monospace font called <a href="https://berkeleygraphics.com/typefaces/berkeley-mono/">Berkeley Mono</a>. I promptly purchased a &quot;personal developer&quot; license and set to work <a href="https://scribbles.jbowdre.lol/post/trying-tabby-terminal">applying the font in my IDE and terminal</a>. I didn't want to stop there, though; the license also permits me to use the font on my personal site, and Berkeley Mono will fit in beautifully with the whole runtimeterror aesthetic.</p> <p>Well, you're looking at the slick new font here, and I'm about to tell you how I added the font both to the site itself and to the <a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/">dynamically-generated OpenGraph share images</a> setup. It wasn't terribly hard to implement, but the Hugo documentation is a bit light on how to do it (and I'm kind of inept at this whole web development thing).</p> <h3 id="web-font">Web Font</h3> <p>This site's styling is based on the <a href="https://github.com/joeroe/risotto/tree/main">risotto theme for Hugo</a>. Risotto uses the CSS variable <code>--font-monospace</code> in <code>themes/risotto/static/css/typography.css</code> to define the font face, and then that variable is inserted wherever the font may need to be set:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#75715e">/* torchlight! {&#34;lineNumbers&#34;:true} */</span> </span></span><span style="display:flex;"><span><span style="color:#75715e">/* Fonts */</span> </span></span><span style="display:flex;"><span>:<span style="color:#a6e22e">root</span> { </span></span><span style="display:flex;"><span> --font-monospace: <span style="color:#e6db74">&#34;Fira Mono&#34;</span>, <span style="color:#66d9ef">monospace</span>; <span style="color:#75715e">/* [tl! **] */</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">body</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">font-family</span>: <span style="color:#a6e22e">var</span>(<span style="color:#f92672">--</span>font<span style="color:#f92672">-</span><span style="color:#66d9ef">monospace</span>); <span style="color:#75715e">/* [tl! **] */</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">font-size</span>: <span style="color:#ae81ff">16</span><span style="color:#66d9ef">px</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">line-height</span>: <span style="color:#ae81ff">1.5</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>This makes it easy to override the theme's font by inserting my preferred font in <code>static/custom.css</code>:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#75715e">/* font overrides */</span> </span></span><span style="display:flex;"><span>:<span style="color:#a6e22e">root</span> { </span></span><span style="display:flex;"><span> --font-monospace: <span style="color:#e6db74">&#39;Berkeley Mono&#39;</span>, <span style="color:#e6db74">&#39;Fira Mono&#39;</span>, <span style="color:#66d9ef">monospace</span>; <span style="color:#75715e">/* [tl! **] */</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>And that would be the end of things if I could expect that everyone who visited my site already had the Berkeley Mono font installed; if they don't, though, the site will fallback to either the Fira Mono font or whatever generic monospace font is on the system. So maybe I'll add a few other monospace fonts just for good measure:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#75715e">/* font overrides */</span> </span></span><span style="display:flex;"><span>:<span style="color:#a6e22e">root</span> { </span></span><span style="display:flex;"><span> --font-monospace: <span style="color:#e6db74">&#39;Berkeley Mono&#39;</span>, <span style="color:#e6db74">&#39;IBM Plex Mono&#39;</span>, <span style="color:#e6db74">&#39;Cascadia Mono&#39;</span>, <span style="color:#e6db74">&#39;Roboto Mono&#39;</span>, <span style="color:#e6db74">&#39;Source Code Pro&#39;</span>, <span style="color:#e6db74">&#39;Fira Mono&#39;</span>, <span style="color:#e6db74">&#39;Courier New&#39;</span>, <span style="color:#66d9ef">monospace</span>; <span style="color:#75715e">/* [tl! **] */</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>That provides a few more options to fall back to if the preferred font isn't available. But let's see about making that font available.</p> <h4 id="hosted-locally">Hosted Locally</h4> <p>I can use a <code>@font-face</code> rule to tell the browser how to find the <code>.woff2</code> file for my preferred web font, and I could just set the <code>src: url</code> parameter to point to a local path in my Hugo environment:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#75715e">/* load preferred font */</span> </span></span><span style="display:flex;"><span>@<span style="color:#66d9ef">font-face</span> { </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-family</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Berkeley Mono&#39;</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-style</span><span style="color:#f92672">:</span> <span style="color:#f92672">normal</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-weight</span><span style="color:#f92672">:</span> <span style="color:#f92672">400</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">/* use the installed font with this name if it&#39;s there... */</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">src</span><span style="color:#f92672">:</span> <span style="color:#f92672">local</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;Berkeley Mono&#39;</span><span style="color:#f92672">),</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">/* otherwise look at these paths */</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">url</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;/fonts/BerkeleyMono.woff2&#39;</span><span style="color:#f92672">)</span> <span style="color:#f92672">format</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;woff2&#39;</span><span style="color:#f92672">),</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><div></div><div class="notice note"> <p class="first notice-title"><span class="icon-notice baseline"></span>WOFF2 vs WOFF(1)</p><p>A previous version of this post also included the <code>.woff</code> file in addition to <code>.woff2</code>. A kind reader let me know that <a href="https://caniuse.com/?search=woff2">basically everything</a> supports <code>.woff2</code>, and since <code>.woff2</code> offers much better compression than first-generation <code>.woff</code> there <em>really</em> isn't any reason to offer a font in <code>.woff</code> format in this modern age. I can just offer <code>.woff2</code> on its own.</p> <p>I've updated this post, my CSS, and the contents of my CDN storage accordingly.</p></div> <p>And that would work just fine... but it <em>would</em> require storing those web font files in the (public) <a href="https://github.com/jbowdre/runtimeterror">GitHub repo</a> which powers my site, and I'd rather not store any paid font files there.</p> <p>So instead, I opted to try using a <a href="https://en.wikipedia.org/wiki/Content_delivery_network">Content Delivery Network (CDN)</a> to host the font files. This would allow for some degree of access control, help me learn more about a web technology I hadn't played with much, and make use of a cool <code>cdn.*</code> subdomain in the process.</p> <div class="notice note"> <p class="first notice-title"><span class="icon-notice baseline"></span>Double the CDN, double the fun</p><p>Of course, while writing this post I gave in to my impulsive nature and <a href="https://scribbles.jbowdre.lol/post/i-just-hopped-to-bunny-net">migrated the site from Cloudflare to Bunny.net</a>. Rather than scrap the content I'd already written, I'll go ahead and describe how I set this up first on <a href="https://www.cloudflare.com/developer-platform/r2/">Cloudflare R2</a> and later on <a href="https://bunny.net/storage/">Bunny Storage</a>.</p></div> <h4 id="cloudflare-r2">Cloudflare R2</h4> <p>Getting started with R2 was really easy; I just <a href="https://developers.cloudflare.com/r2/buckets/create-buckets/">created a new R2 bucket</a> called <code>runtimeterror</code> and <a href="https://developers.cloudflare.com/r2/buckets/public-buckets/#connect-a-bucket-to-a-custom-domain">connected it to the custom domain</a> <code>cdn.runtimeterror.dev</code>. I put the two web font files in a folder titled <code>fonts</code> and uploaded them to the bucket so that they can be accessed under <code>https://cdn.runtimeterror.dev/fonts/</code>.</p> <p>I could then employ a <a href="https://developers.cloudflare.com/r2/buckets/cors/">Cross-Origin Resource Sharing (CORS)</a> policy to ensure the fonts hosted on my fledgling CDN can only be loaded on my site. I configured the policy to also allow access from my <code>localhost</code> Hugo build environment as well as a preview Neocities environment I use for testing such major changes:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>[ </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;AllowedOrigins&#34;</span>: [ </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;http://localhost:1313&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;https://secret--runtimeterror--preview.neocities.org&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;https://runtimeterror.dev&#34;</span> </span></span><span style="display:flex;"><span> ], </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;AllowedMethods&#34;</span>: [ </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;GET&#34;</span> </span></span><span style="display:flex;"><span> ] </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>] </span></span></code></pre></div><p>Then I just needed to update the <code>@font-face</code> rule accordingly:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#75715e">/* load preferred font */</span> </span></span><span style="display:flex;"><span>@<span style="color:#66d9ef">font-face</span> { </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-family</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Berkeley Mono&#39;</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-style</span><span style="color:#f92672">:</span> <span style="color:#f92672">normal</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-weight</span><span style="color:#f92672">:</span> <span style="color:#f92672">400</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-display</span><span style="color:#f92672">:</span> <span style="color:#f92672">fallback</span><span style="color:#f92672">;</span> <span style="color:#75715e">/* [tl! ++] */</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">src</span><span style="color:#f92672">:</span> <span style="color:#f92672">local</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;Berkeley Mono&#39;</span><span style="color:#f92672">),</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">url</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;/fonts/BerkeleyMono.woff2&#39;</span><span style="color:#f92672">)</span> <span style="color:#f92672">format</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;woff2&#39;</span><span style="color:#f92672">),</span> <span style="color:#75715e">/* [tl! --] */</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">url</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;https://cdn.runtimeterror.dev/fonts/BerkeleyMono.woff2&#39;</span><span style="color:#f92672">)</span> <span style="color:#f92672">format</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;woff2&#39;</span><span style="color:#f92672">),</span> <span style="color:#75715e">/* [tl! ++] */</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>I added in the <code>font-display: fallback;</code> descriptor to address the fact that the site will now be loading a remote font. Rather than blocking and not rendering text until the preferred font is loaded, it will show text in one of the available fallback fonts. If the preferred font loads quickly enough, it will be swapped in; otherwise, it will just show up on the next page load. I figured this was a good middle-ground between wanting the site to load quickly while also looking the way I want it to.</p> <p>To test my work, I ran <code>hugo server</code> to build and serve the site locally on <code>http://localhost:1313</code>... and promptly encountered a cascade of CORS-related errors. I kept tweaking the policy and trying to learn more about what I'm doing (reminder: I'm bad at this), but just couldn't figure out what was preventing the font from being loaded.</p> <p>I <em>eventually</em> discovered that sometimes you need to clear Cloudflare's cache so that new policy changes will take immediate effect. Once I <a href="https://developers.cloudflare.com/cache/how-to/purge-cache/purge-everything/">purged everything</a>, the errors went away and the font loaded successfully.</p> <h3 id="bunny-storage">Bunny Storage</h3> <p>After migrating my domain to Bunny.net, the CDN font setup was pretty similar - but also different enough that it's worth mentioning. I started by creating a new Storage Zone named <code>runtimeterror-storage</code>, and selecting an appropriate-seeming set of replication regions. I then uploaded the same <code>fonts/</code> folder as before.</p> <p>To be able to access the files in Bunny Storage, I connected a new Pull Zone (called <code>runtimeterror-pull</code>) and linked that Pull Zone with the <code>cdn.runtimeterror.dev</code> hostname. I also made sure to enable the option to automatically generate a certificate for this host.</p> <p>Rather than needing me to understand CORS and craft a viable policy file, Bunny provides a clean UI with easy-to-understand options for configuring the pull zone security. I enabled the options to block root path access, block <code>POST</code> requests, block direct file access, and also added the same trusted referrers as before:</p> <p><img src="https://runtimeterror.dev/using-custom-font-hugo/bunny-cdn-security.png" alt="Bunny CDN security configuration"></p> <p>I made sure to use the same paths as I had on Cloudflare so I didn't need to update the Hugo config at all even after changing CDNs. That same CSS from before still works:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#75715e">/* load preferred font */</span> </span></span><span style="display:flex;"><span>@<span style="color:#66d9ef">font-face</span> { </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-family</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;Berkeley Mono&#39;</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-style</span><span style="color:#f92672">:</span> <span style="color:#f92672">normal</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-weight</span><span style="color:#f92672">:</span> <span style="color:#f92672">400</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">font-display</span><span style="color:#f92672">:</span> <span style="color:#f92672">fallback</span><span style="color:#f92672">;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">src</span><span style="color:#f92672">:</span> <span style="color:#f92672">local</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;Berkeley Mono&#39;</span><span style="color:#f92672">),</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">url</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;https://cdn.runtimeterror.dev/fonts/BerkeleyMono.woff2&#39;</span><span style="color:#f92672">)</span> <span style="color:#f92672">format</span><span style="color:#f92672">(</span><span style="color:#e6db74">&#39;woff2&#39;</span><span style="color:#f92672">),</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>I again tested locally with <code>hugo server</code> and confirmed that the font loaded from Bunny CDN without any CORS or other errors.</p> <p>So that's the web font for the web site sorted (twice); now let's tackle the font in the OpenGraph share images.</p> <h3 id="image-filter-text">Image Filter Text</h3> <p>My <a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/">setup for generating the share images</a> leverages the Hugo <a href="https://gohugo.io/functions/images/text/">images.Text</a> function to overlay text onto a background image, and it needs a TrueType font in order to work. I was previously just storing the required font directly in my GitHub repo so that it would be available during the site build, but I definitely don't want to do that with a paid TrueType font file. So I needed to come up with some way to provide the TTF file to the GitHub runner without making it publicly available.</p> <p>I recently figured out how I could <a href="https://runtimeterror.dev/gemini-capsule-gempost-github-actions/#publish-github-actions:~:text=name%3A%20Connect%20to%20Tailscale">use a GitHub Action to easily connect the runner to my Tailscale environment</a>, and I figured I could re-use that idea here - only instead of pushing something to my tailnet, I'll be pulling something out.</p> <h4 id="tailscale-setup">Tailscale Setup</h4> <p>So I SSH'd to the cloud server I'm already using for hosting my Gemini capsule, created a folder to hold the font file (<code>/opt/fonts/</code>), and copied the TTF file into there. And then I used <a href="https://runtimeterror.dev/tailscale-ssh-serve-funnel/#tailscale-serve">Tailscale Serve</a> to publish that folder internally to my tailnet:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sudo tailscale serve --bg --set-path /fonts /opt/fonts/ <span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># [tl! .nocopy:4]</span> </span></span><span style="display:flex;"><span>Available within your tailnet: </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>https://node.tailnet-name.ts.net/fonts/ </span></span><span style="display:flex;"><span>|-- path /opt/fonts </span></span></code></pre></div><p>The <code>--bg</code> flag will run the share in the background and automatically start it with the system (like a daemon-mode setup).</p> <p>When I set up Tailscale for the Gemini capsule workflow, I configured the Tailscale ACL so that the GitHub runner (<code>tag:gh-bld</code>) could talk to my server (<code>tag:gh-srv</code>) over SSH:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-json" data-lang="json"><span style="display:flex;"><span> <span style="color:#e6db74">&#34;acls&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [ </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> <span style="color:#75715e">// github runner can talk to the deployment target </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">&#34;action&#34;</span>: <span style="color:#e6db74">&#34;accept&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;users&#34;</span>: [<span style="color:#e6db74">&#34;tag:gh-bld&#34;</span>], </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;ports&#34;</span>: [ </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;tag:gh-srv:22&#34;</span> </span></span><span style="display:flex;"><span> ], </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> ]<span style="color:#960050;background-color:#1e0010">,</span> </span></span></code></pre></div><p>I needed to update that ACL to allow communication over HTTPS as well:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-json" data-lang="json"><span style="display:flex;"><span> <span style="color:#e6db74">&#34;acls&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [ </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> <span style="color:#75715e">// github runner can talk to the deployment target </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">&#34;action&#34;</span>: <span style="color:#e6db74">&#34;accept&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;users&#34;</span>: [<span style="color:#e6db74">&#34;tag:gh-bld&#34;</span>], </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;ports&#34;</span>: [ </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;tag:gh-srv:22&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;tag:gh-srv:443&#34;</span> <span style="color:#75715e">// [tl! ++] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> ], </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> ]<span style="color:#960050;background-color:#1e0010">,</span> </span></span></code></pre></div><p>I then logged into the Tailscale admin panel to follow the same steps as last time to generate a unique <a href="https://tailscale.com/kb/1215/oauth-clients">OAuth client</a> tied to the <code>tag:gh-bld</code> tag. I stored the ID, secret, and tags as repository secrets named <code>TS_API_CLIENT_ID</code>, <code>TS_API_CLIENT_SECRET</code>, and <code>TS_TAG</code>.</p> <p>I also created a <code>REMOTE_FONT_PATH</code> secret which will be used to tell Hugo where to find the required TTF file (<code>https://node.tailnet-name.ts.net/fonts/BerkeleyMono.ttf</code>).</p> <h4 id="hugo-setup">Hugo Setup</h4> <p>Here's the image-related code that I was previously using in <code>layouts/partials/opengraph</code> to create the OpenGraph images:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{ $img := resources.Get &#34;og_base.png&#34; }} {{ $font := resources.Get &#34;/FiraMono-Regular.ttf&#34; }} {{ $text := &#34;&#34; }} {{- if .IsHome }} {{ $text = .Site.Params.Description }} {{- end }} {{- if .IsPage }} {{ $text = .Page.Title }} {{ end }} {{- with .Params.thumbnail }} {{ $thumbnail := $.Resources.Get . }} {{ with $thumbnail }} {{ $img = $img.Filter (images.Overlay (.Process &#34;fit 300x250&#34;) 875 38 )}} {{ end }} {{ end }} {{ $img = $img.Filter (images.Text $text (dict &#34;color&#34; &#34;#d8d8d8&#34; &#34;size&#34; 64 &#34;linespacing&#34; 2 &#34;x&#34; 40 &#34;y&#34; 300 &#34;font&#34; $font ))}} {{ $img = resources.Copy (path.Join $.Page.RelPermalink &#34;og.png&#34;) $img }} </code></pre><p>All I need to do is get it to pull the font resource from a web address rather than the local file system, and I'll do that by loading an environment variable instead of hardcoding the path here.</p> <div class="notice note"> <p class="first notice-title"><span class="icon-notice baseline"></span>Hugo Environent Variable Access</p><p>By default, Hugo's <code>os.Getenv</code> function only has access to environment variables which start with <code>HUGO_</code>. You can <a href="https://gohugo.io/functions/os/getenv/#security">adjust the security configuration</a> to alter this restriction if needed, but I figured I could work just fine within the provided constraints.</p></div> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{ $img := resources.Get &#34;og_base.png&#34; }} {{ $font := resources.Get &#34;/FiraMono-Regular.ttf&#34; }} &lt;!-- [tl! -- ] --&gt; {{ $text := &#34;&#34; }} {{ $font := &#34;&#34; }} &lt;!-- [tl! ++:10 **:10 ]&gt; {{ $path := os.Getenv &#34;HUGO_REMOTE_FONT_PATH&#34; }} {{ with resources.GetRemote $path }} {{ with .Err }} {{ errorf &#34;%s&#34; . }} {{ else }} {{ $font = . }} {{ end }} {{ else }} {{ errorf &#34;Unable to get resource %q&#34; $path }} {{ end }} {{- if .IsHome }} {{ $text = .Site.Params.Description }} {{- end }} &lt;!-- [tl! collapse:start ] --&gt; {{- if .IsPage }} {{ $text = .Page.Title }} {{ end }} {{- with .Params.thumbnail }} {{ $thumbnail := $.Resources.Get . }} {{ with $thumbnail }} {{ $img = $img.Filter (images.Overlay (.Process &#34;fit 300x250&#34;) 875 38 )}} {{ end }} {{ end }} {{ $img = $img.Filter (images.Text $text (dict &#34;color&#34; &#34;#d8d8d8&#34; &#34;size&#34; 64 &#34;linespacing&#34; 2 &#34;x&#34; 40 &#34;y&#34; 300 &#34;font&#34; $font ))}} {{ $img = resources.Copy (path.Join $.Page.RelPermalink &#34;og.png&#34;) $img }} &lt;!-- [tl! collapse:end ] --&gt; </code></pre><p>I can test that this works by running a build locally from a system with access to my tailnet. I'm not going to start a web server with this build; I'll just review the contents of the <code>public/</code> folder once it's complete to see if the OpenGraph images got rendered correctly.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>HUGO_REMOTE_FONT_PATH<span style="color:#f92672">=</span>https://node.tailnet-name.ts.net/fonts/BerkeleyMono.ttf hugo </span></span></code></pre></div><p>Neat, it worked!</p> <p><img src="https://runtimeterror.dev/using-custom-font-hugo/og-sample.png" alt="OpenGraph share image for this post"></p> <h4 id="github-action">GitHub Action</h4> <p>All that's left is to update the GitHub Actions workflow I use for <a href="https://runtimeterror.dev/deploy-hugo-neocities-github-actions/">building and deploying my site to Neocities</a> to automate things:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># .github/workflows/deploy-to-neocities.yml</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">Deploy to Neocities</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># [tl! collapse:start]</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">on</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">schedule</span>: </span></span><span style="display:flex;"><span> - <span style="color:#f92672">cron</span>: <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">13</span> * * * </span></span><span style="display:flex;"><span> <span style="color:#f92672">push</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">branches</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">main</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">concurrency</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">group</span>: <span style="color:#ae81ff">deploy-to-neocities</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">cancel-in-progress</span>: <span style="color:#66d9ef">true</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">defaults</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">shell</span>: <span style="color:#ae81ff">bash</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># [tl! collapse:end]</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">jobs</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">deploy</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Build and deploy Hugo site</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">runs-on</span>: <span style="color:#ae81ff">ubuntu-latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">steps</span>: </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Hugo setup</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">peaceiris/actions-hugo@v2.6.0</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">hugo-version</span>: <span style="color:#e6db74">&#39;0.121.1&#39;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">extended</span>: <span style="color:#66d9ef">true</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Checkout</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/checkout@v4</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">submodules</span>: <span style="color:#ae81ff">recursive</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Connect to Tailscale</span> <span style="color:#75715e"># [tl! ++:10 **:10]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">tailscale/github-action@v2</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">oauth-client-id</span>: <span style="color:#ae81ff">${{ secrets.TS_API_CLIENT_ID }}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">oauth-secret</span>: <span style="color:#ae81ff">${{ secrets.TS_API_CLIENT_SECRET }}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">tags</span>: <span style="color:#ae81ff">${{ secrets.TS_TAG }}</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Build with Hugo</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: <span style="color:#ae81ff">hugo --minify</span> <span style="color:#75715e"># [tl! -- **]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: <span style="color:#ae81ff">HUGO_REMOTE_FONT_PATH=${{ secrets.REMOTE_FONT_PATH }} hugo --minify</span> <span style="color:#75715e"># [tl! ++ ** reindex(-1) ]</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Insert 404 page</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: |<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> cp public/404/index.html public/not_found.html</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Highlight with Torchlight</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: |<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> npm i @torchlight-api/torchlight-cli </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> npx torchlight</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Deploy to Neocities</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">bcomnes/deploy-to-neocities@v1</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">api_token</span>: <span style="color:#ae81ff">${{ secrets.NEOCITIES_API_TOKEN }}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">cleanup</span>: <span style="color:#66d9ef">true</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">dist_dir</span>: <span style="color:#ae81ff">public</span> </span></span></code></pre></div><p>This uses the <a href="https://github.com/tailscale/github-action">Tailscale GitHub Action</a> to connect the runner to my tailnet using the credentials I created earlier, and passes the <code>REMOTE_FONT_PATH</code> secret as an environment variable to the Hugo command line. Hugo will then be able to retrieve and use the TTF font during the build process.</p> <h3 id="conclusion">Conclusion</h3> <p>Configuring and using a custom font in my Hugo-generated site wasn't hard to do, but I had to figure some things out on my own to get started in the right direction. I learned a lot about how fonts are managed in CSS along the way, and I love the way the new font looks on this site!</p> <p>This little project also gave me an excuse to play with first Cloudflare R2 and then Bunny Storage, and I came away seriously impressed by Bunny (and have since moved more of my domains to bunny.net). Expect me to write more about cool Bunny stuff in the future.</p> </description> </item> <item> <title>Blocking AI Crawlers</title> <link>https://runtimeterror.dev/blocking-ai-crawlers/</link> <pubDate>Fri, 12 Apr 2024 00:00:00 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>cloud</category> <category>cloudflare</category> <category>hugo</category> <category>meta</category> <category>selfhosting</category> <guid>https://runtimeterror.dev/blocking-ai-crawlers/</guid><description><p>I've seen some recent posts from folks like <a href="https://coryd.dev/posts/2024/go-ahead-and-block-ai-web-crawlers/">Cory Dransfeldt</a> and <a href="https://ethanmarcotte.com/wrote/blockin-bots/">Ethan Marcotte</a> about how (and <em>why</em>) to prevent your personal website from being slurped up by the crawlers that AI companies use to <a href="https://boehs.org/node/llms-destroying-internet">actively enshittify the internet</a>. I figured it was past time for me to hop on board with this, so here we are.</p> <p>My initial approach was to use <a href="https://gohugo.io/templates/robots/">Hugo's robots.txt templating</a> to generate a <code>robots.txt</code> file based on a list of bad bots I got from <a href="https://github.com/ai-robots-txt/ai.robots.txt">ai.robots.txt on GitHub</a>.</p> <p>I dumped that list into my <code>config/params.toml</code> file, <em>above</em> any of the nested elements (since toml is kind of picky about that...).</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-toml" data-lang="toml"><span style="display:flex;"><span><span style="color:#a6e22e">robots</span> = [ </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;AdsBot-Google&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;Amazonbot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;anthropic-ai&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;Applebot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;AwarioRssBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;AwarioSmartBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;Bytespider&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;CCBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;ChatGPT&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;ChatGPT-User&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;Claude-Web&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;ClaudeBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;cohere-ai&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;DataForSeoBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;Diffbot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;FacebookBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;Google-Extended&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;GPTBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;ImagesiftBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;magpie-crawler&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;omgili&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;Omgilibot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;peer39_crawler&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;PerplexityBot&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;YouBot&#34;</span> </span></span><span style="display:flex;"><span>] </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>[<span style="color:#a6e22e">author</span>] </span></span><span style="display:flex;"><span><span style="color:#a6e22e">name</span> = <span style="color:#e6db74">&#34;John Bowdre&#34;</span> </span></span></code></pre></div><p>I then created a new template in <code>layouts/robots.txt</code>:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Sitemap: {{ .Site.BaseURL }}/sitemap.xml </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>User-agent: * </span></span><span style="display:flex;"><span>Disallow: </span></span><span style="display:flex;"><span>{{ range .Site.Params.robots }} </span></span><span style="display:flex;"><span>User-agent: {{ . }} </span></span><span style="display:flex;"><span>{{- end }} </span></span><span style="display:flex;"><span>Disallow: / </span></span></code></pre></div><p>And enabled the template processing for this in my <code>config/hugo.toml</code> file:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-toml" data-lang="toml"><span style="display:flex;"><span><span style="color:#a6e22e">enableRobotsTXT</span> = <span style="color:#66d9ef">true</span> </span></span></code></pre></div><p>Now Hugo will generate the following <code>robots.txt</code> file for me:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Sitemap: https://runtimeterror.dev//sitemap.xml </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>User-agent: * </span></span><span style="display:flex;"><span>Disallow: </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>User-agent: AdsBot-Google </span></span><span style="display:flex;"><span>User-agent: Amazonbot </span></span><span style="display:flex;"><span>User-agent: anthropic-ai </span></span><span style="display:flex;"><span>User-agent: Applebot </span></span><span style="display:flex;"><span>User-agent: AwarioRssBot </span></span><span style="display:flex;"><span>User-agent: AwarioSmartBot </span></span><span style="display:flex;"><span>User-agent: Bytespider </span></span><span style="display:flex;"><span>User-agent: CCBot </span></span><span style="display:flex;"><span>User-agent: ChatGPT </span></span><span style="display:flex;"><span>User-agent: ChatGPT-User </span></span><span style="display:flex;"><span>User-agent: Claude-Web </span></span><span style="display:flex;"><span>User-agent: ClaudeBot </span></span><span style="display:flex;"><span>User-agent: cohere-ai </span></span><span style="display:flex;"><span>User-agent: DataForSeoBot </span></span><span style="display:flex;"><span>User-agent: Diffbot </span></span><span style="display:flex;"><span>User-agent: FacebookBot </span></span><span style="display:flex;"><span>User-agent: Google-Extended </span></span><span style="display:flex;"><span>User-agent: GPTBot </span></span><span style="display:flex;"><span>User-agent: ImagesiftBot </span></span><span style="display:flex;"><span>User-agent: magpie-crawler </span></span><span style="display:flex;"><span>User-agent: omgili </span></span><span style="display:flex;"><span>User-agent: Omgilibot </span></span><span style="display:flex;"><span>User-agent: peer39_crawler </span></span><span style="display:flex;"><span>User-agent: PerplexityBot </span></span><span style="display:flex;"><span>User-agent: YouBot </span></span><span style="display:flex;"><span>Disallow: / </span></span></code></pre></div><p>Cool!</p> <p>I also dropped the following into <code>static/ai.txt</code> for <a href="https://site.spawning.ai/spawning-ai-txt">good measure</a>:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-text" data-lang="text"><span style="display:flex;"><span># Spawning AI </span></span><span style="display:flex;"><span># Prevent datasets from using the following file types </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>User-Agent: * </span></span><span style="display:flex;"><span>Disallow: / </span></span><span style="display:flex;"><span>Disallow: * </span></span></code></pre></div><p>That's all well and good, but these files carry all the weight and authority of a &quot;No Soliciting&quot; sign. Do I <em>really</em> trust these bots to honor it?</p> <p>I'm hosting this site <a href="https://runtimeterror.dev/deploy-hugo-neocities-github-actions/">on Neocities</a>, and Neocities unfortunately (though perhaps wisely) doesn't give me control of the web server there. But the site is fronted by Cloudflare, and that does give me a lot of options for blocking stuff I don't want.</p> <p>So I added a <a href="https://developers.cloudflare.com/waf/custom-rules/">WAF Custom Rule</a> to block those unwanted bots. (I could have used their <a href="https://developers.cloudflare.com/waf/tools/user-agent-blocking">User Agent Blocking</a> to accomplish the same, but you can only set 10 of those on the free tier. I can put all the user agents together in a single WAF Custom Rule.)</p> <p>Here's the expression I'm using:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>(http.user_agent contains &#34;AdsBot-Google&#34;) or (http.user_agent contains &#34;Amazonbot&#34;) or (http.user_agent contains &#34;anthropic-ai&#34;) or (http.user_agent contains &#34;Applebot&#34;) or (http.user_agent contains &#34;AwarioRssBot&#34;) or (http.user_agent contains &#34;AwarioSmartBot&#34;) or (http.user_agent contains &#34;Bytespider&#34;) or (http.user_agent contains &#34;CCBot&#34;) or (http.user_agent contains &#34;ChatGPT-User&#34;) or (http.user_agent contains &#34;ClaudeBot&#34;) or (http.user_agent contains &#34;Claude-Web&#34;) or (http.user_agent contains &#34;cohere-ai&#34;) or (http.user_agent contains &#34;DataForSeoBot&#34;) or (http.user_agent contains &#34;FacebookBot&#34;) or (http.user_agent contains &#34;Google-Extended&#34;) or (http.user_agent contains &#34;GoogleOther&#34;) or (http.user_agent contains &#34;GPTBot&#34;) or (http.user_agent contains &#34;ImagesiftBot&#34;) or (http.user_agent contains &#34;magpie-crawler&#34;) or (http.user_agent contains &#34;Meltwater&#34;) or (http.user_agent contains &#34;omgili&#34;) or (http.user_agent contains &#34;omgilibot&#34;) or (http.user_agent contains &#34;peer39_crawler&#34;) or (http.user_agent contains &#34;peer39_crawler/1.0&#34;) or (http.user_agent contains &#34;PerplexityBot&#34;) or (http.user_agent contains &#34;Seekr&#34;) or (http.user_agent contains &#34;YouBot&#34;) </span></span></code></pre></div><p><img src="https://runtimeterror.dev/blocking-ai-crawlers/cloudflare-waf-rule.png" alt="Creating a custom WAF rule in Cloudflare's web UI"></p> <p>And checking on that rule ~24 hours later, I can see that it's doing some good:</p> <p><img src="https://runtimeterror.dev/blocking-ai-crawlers/cloudflare-waf-status.png" alt="It's blocked 102 bot hits already"></p> <p>See ya, AI bots!</p> </description> </item> <item> <title>Self-Hosted Gemini Capsule with gempost and GitHub Actions</title> <link>https://runtimeterror.dev/gemini-capsule-gempost-github-actions/</link> <pubDate>Sat, 23 Mar 2024 21:33:19 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>caddy</category> <category>cicd</category> <category>docker</category> <category>selfhosting</category> <category>tailscale</category> <guid>https://runtimeterror.dev/gemini-capsule-gempost-github-actions/</guid><description><p>I've recently been exploring some indieweb/smolweb technologies, and one of the most interesting things I've come across is <a href="https://geminiprotocol.net/">Project Gemini</a>:</p> <blockquote> <p>Gemini is a new internet technology supporting an electronic library of interconnected text documents. That's not a new idea, but it's not old fashioned either. It's timeless, and deserves tools which treat it as a first class concept, not a vestigial corner case. Gemini isn't about innovation or disruption, it's about providing some respite for those who feel the internet has been disrupted enough already. We're not out to change the world or destroy other technologies. We are out to build a lightweight online space where documents are just documents, in the interests of every reader's privacy, attention and bandwidth.</p> </blockquote> <p>I thought it was an interesting idea, so after a bit of experimentation with various hosted options I created a self-hosted <a href="https://capsule.jbowdre.lol/gemlog/2024-03-05-hello-gemini.gmi">Gemini capsule (Gemini for &quot;web site&quot;) to host a lightweight text-focused Gemlog (&quot;weblog&quot;)</a>. After further tinkering, I arranged to serve the capsule both on the Gemini network as well as the traditional HTTP-based web, and I set up a GitHub Actions workflow to handle posting updates. This post will describe how I did that.</p> <h3 id="gemini-server-agate">Gemini Server: Agate</h3> <p>There are a number of different <a href="https://github.com/kr1sp1n/awesome-gemini?tab=readme-ov-file#servers">Gemini server applications</a> to choose from. I decided to use <a href="https://github.com/mbrubeck/agate">Agate</a>, not just because it was at the top of the Awesome Gemini list but also because seems to be widely recommended, regularly updated, and easy to use. Plus it will automatically generates certs for me, which is nice since Gemini <em>requires</em> valid certificates for all connections.</p> <p>I wasn't able to find a pre-built Docker image for Agate (at least not one which seemed to be actively maintained), but I found that I could easily make my own by just installing Agate on top of the standard Rust image. So I came up with this <code>Dockerfile</code>:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-Dockerfile" data-lang="Dockerfile"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">FROM</span><span style="color:#e6db74"> rust:latest</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">RUN</span> cargo install agate<span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">WORKDIR</span><span style="color:#e6db74"> /var/agate</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">ENTRYPOINT</span> [<span style="color:#e6db74">&#34;agate&#34;</span>]<span style="color:#960050;background-color:#1e0010"> </span></span></span></code></pre></div><p>This very simply uses the <a href="https://doc.rust-lang.org/cargo/">Rust package manager</a> to install <code>agate</code>, change to an appropriate working directory, and start the <code>agate</code> executable.</p> <p>And then I can create a basic <code>docker-compose.yaml</code> for it:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">agate</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">always</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">build</span>: <span style="color:#ae81ff">.</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">agate</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">volumes</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./content:/var/agate/content</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./certs:/var/agate/certs</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">ports</span>: </span></span><span style="display:flex;"><span> - <span style="color:#e6db74">&#34;1965:1965&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">command</span>: &gt;<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> --content content --certs certs --addr 0.0.0.0:1965 </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> --hostname capsule.jbowdre.lol --lang en-US</span> </span></span></code></pre></div><p>This mounts local directories <code>./content</code> and <code>./certs</code> into the <code>/var/agate/</code> working directory, passes arguments specifying those directories as well as the hostname to the <code>agate</code> executable, and exposes the service on port <code>1965</code> (commemorating the <a href="https://en.wikipedia.org/wiki/Project_Gemini#Missions">first manned Gemini missions</a> - I love the space nerd influences here!).</p> <p>Now I'll throw some quick <a href="https://geminiprotocol.net/docs/gemtext.gmi">Gemtext</a> into a file in the <code>./content</code> directory:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>mkdir -p content <span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span>cat <span style="color:#f92672">&lt;&lt;&lt;</span> EOF &gt; content/hello-gemini.gmi <span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># This is a test of the Gemini broadcast system.</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>* This is only a test. </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">=</span>&gt; gemini://geminiprotocol.net/history/ Gemini History </span></span><span style="display:flex;"><span>EOF </span></span></code></pre></div><p>After configuring the <code>capsule.jbowdre.lol</code> DNS record and opening port <code>1965</code> on my server's firewall, I can use <code>docker compose up -d</code> to spawn the server and begin serving my mostly-empty capsule at <code>gemini://capsule.jbowdre.lol/hello-gemini.gmi</code>. I can then check it out in a popular Gemini client called <a href="https://github.com/skyjake/lagrange">Lagrange</a>:</p> <p><img src="https://runtimeterror.dev/gemini-capsule-gempost-github-actions/hello-gemini.png" alt="A sparse sample page proclaiming &quot;This is just a test&quot;"></p> <p>Hooray, I have an outpost in Geminispace! Let's dress it up a little bit now.</p> <h3 id="gemini-content-gempost">Gemini Content: gempost</h3> <p>A &quot;hello world&quot; post will only hold someone's interest for so long. I wanted to start using my capsule for lightweight blogging tasks, but I didn't want to have to manually keep track of individual posts or manage consistent formatting. After a bit of questing, I found <a href="https://github.com/justlark/gempost">gempost</a>, a static site generator for gemlogs (Gemini blogs). It makes it easy to template out index pages and insert headers/footers, and supports tracking post metadata in a yaml sidecar for each post (handy since gemtext doesn't support front matter or other inline meta properties).</p> <p>gempost is installed as a Rust package, so I <em>could</em> have installed Rust and then used <code>cargo install gempost</code> to install gempost. But I've been really getting into using project-specific development shells powered by <a href="https://nixos.wiki/wiki/Flakes">Nix flakes</a> and <a href="https://github.com/direnv/direnv/wiki/Nix">direnv</a> to automatically load/unload packages as I traverse the directory tree. So I put together <a href="https://github.com/jbowdre/capsule/blob/main/flake.nix">this flake</a> to activate Agate, Rust, and gempost when I change into the directory where I want to build my capsule content:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span>{ </span></span><span style="display:flex;"><span> description <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;gemsite build environment&#34;</span>; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> inputs <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> nixpkgs<span style="color:#f92672">.</span>url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;github:nixos/nixpkgs/nixos-unstable&#34;</span>; </span></span><span style="display:flex;"><span> }; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> outputs <span style="color:#f92672">=</span> { self<span style="color:#f92672">,</span> nixpkgs }: </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> </span></span><span style="display:flex;"><span> pkgs <span style="color:#f92672">=</span> <span style="color:#f92672">import</span> nixpkgs { system <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;x86_64-linux&#34;</span>; }; </span></span><span style="display:flex;"><span> gempost <span style="color:#f92672">=</span> pkgs<span style="color:#f92672">.</span>rustPlatform<span style="color:#f92672">.</span>buildRustPackage <span style="color:#66d9ef">rec</span> { </span></span><span style="display:flex;"><span> pname <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;gempost&#34;</span>; </span></span><span style="display:flex;"><span> version <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;v0.3.0&#34;</span>; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> src <span style="color:#f92672">=</span> pkgs<span style="color:#f92672">.</span>fetchFromGitHub { </span></span><span style="display:flex;"><span> owner <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;justlark&#34;</span>; </span></span><span style="display:flex;"><span> repo <span style="color:#f92672">=</span> pname; </span></span><span style="display:flex;"><span> rev <span style="color:#f92672">=</span> version; </span></span><span style="display:flex;"><span> hash <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;sha256-T6CP1blKXik4AzkgNJakrJyZDYoCIU5yaxjDvK3p01U=&#34;</span>; </span></span><span style="display:flex;"><span> }; </span></span><span style="display:flex;"><span> cargoHash <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;sha256-jG/G/gmaCGpqXeRQX4IqV/Gv6i/yYUpoTC0f7fMs4pQ=&#34;</span>; </span></span><span style="display:flex;"><span> }; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">in</span> </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> devShells<span style="color:#f92672">.</span>x86_64-linux<span style="color:#f92672">.</span>default <span style="color:#f92672">=</span> pkgs<span style="color:#f92672">.</span>mkShell { </span></span><span style="display:flex;"><span> packages <span style="color:#f92672">=</span> <span style="color:#66d9ef">with</span> pkgs; [ </span></span><span style="display:flex;"><span> agate </span></span><span style="display:flex;"><span> gempost </span></span><span style="display:flex;"><span> ]; </span></span><span style="display:flex;"><span> }; </span></span><span style="display:flex;"><span> }; </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>Then I just need to tell direnv to load the flake, by dropping this into <code>.envrc</code>:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env direnv</span> </span></span><span style="display:flex;"><span>use flake . </span></span></code></pre></div><p>Now when I <code>cd</code> into the directory where I'm managing the content for my capsule, the appropriate environment gets loaded automagically:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>which gempost <span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span>cd capsule/ <span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span>direnv: loading ~/projects/capsule/.envrc <span style="color:#75715e"># [tl! .nocopy:3]</span> </span></span><span style="display:flex;"><span>direnv: using flake . </span></span><span style="display:flex;"><span>direnv: nix-direnv: using cached dev shell <span style="color:#75715e"># [tl! collapse:1]</span> </span></span><span style="display:flex;"><span>direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_BUILD_CORES +NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_CFLAGS_COMPILE +NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE +NIX_LDFLAGS +NIX_STORE +NM +OBJCOPY +OBJDUMP +RANLIB +READELF +SIZE +SOURCE_DATE_EPOCH +STRINGS +STRIP +__structuredAttrs +buildInputs +buildPhase +builder +cmakeFlags +configureFlags +depsBuildBuild +depsBuildBuildPropagated +depsBuildTarget +depsBuildTargetPropagated +depsHostHost +depsHostHostPropagated +depsTargetTarget +depsTargetTargetPropagated +doCheck +doInstallCheck +dontAddDisableDepTrack +mesonFlags +name +nativeBuildInputs +out +outputs +patches +phases +preferLocalBuild +propagatedBuildInputs +propagatedNativeBuildInputs +shell +shellHook +stdenv +strictDeps +system ~PATH ~XDG_DATA_DIRS </span></span><span style="display:flex;"><span>which gempost <span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span>/nix/store/1jbfsb0gm1pr1ijc8304jqak7iamjybi-gempost-v0.3.0/bin/gempost <span style="color:#75715e"># [tl! .nodopy]</span> </span></span></code></pre></div><p>Neat!</p> <p>I'm not going to go into too much detail on how I configured/use gempost; the <a href="https://github.com/justlark/gempost/blob/main/README.md">readme</a> does a great job of explaining what's needed to get started, and the <a href="https://github.com/justlark/gempost/tree/main/examples">examples directory</a> shows some of the flexibility and configurable options. Perhaps the biggest change I made is using <code>gemlog/</code> as the gemlog directory (instead of <code>posts/</code>). You can check out my <a href="https://github.com/jbowdre/capsule/blob/main/gempost.yaml">gempost config file</a> and/or <a href="https://github.com/jbowdre/capsule/tree/main/templates">gempost templates</a> for the full details.</p> <p>In any case, after gempost is configured and I've written some posts, I can build the capsule with <code>gempost build</code>. It spits out the &quot;rendered&quot; gemtext (basically generating index pages and the Atom feed from the posts and templates) in the <code>public/</code> directory.</p> <p>If I copy the contents of that <code>public/</code> directory into my Agate <code>content/</code> directory, I'll be serving this new-and-improved Gemini capsule. But who wants to be manually running <code>gempost build</code> and copying files around to publish them? Not this guy. I'll set up a GitHub Actions workflow to take care of that for me.</p> <p>But first, let's do a little bit more work to make this capsule available on the traditional HTTP-powered world wide web.</p> <h3 id="web-proxy-kineto">Web Proxy: kineto</h3> <p>I looked at a few different options for making Gemini content available on the web. I thought about finding/writing a script to convert geminitext to HTML and just serving that directly. I saw a few alternative Gemini servers which seemed able to cross-host content. But the simplest solution I saw (and one that I've seen used on quite a few other sites) is a Gemini-to-HTTP proxy called <a href="https://sr.ht/~sircmpwn/kineto/">kineto</a>.</p> <p>kineto is distributed as a golang package so that's pretty easy to build and install in a Docker image:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-Dockerfile" data-lang="Dockerfile"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">FROM</span><span style="color:#e6db74"> golang:1.22 as build</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">WORKDIR</span><span style="color:#e6db74"> /build</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">RUN</span> git clone https://git.sr.ht/~sircmpwn/kineto<span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">WORKDIR</span><span style="color:#e6db74"> /build/kineto</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">RUN</span> go mod download <span style="color:#ae81ff">\ </span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> <span style="color:#f92672">&amp;&amp;</span> CGO_ENABLED<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span> GOOS<span style="color:#f92672">=</span>linux go build -o kineto<span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">FROM</span><span style="color:#e6db74"> alpine:3.19</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">WORKDIR</span><span style="color:#e6db74"> /app</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">COPY</span> --from<span style="color:#f92672">=</span>build /build/kineto/kineto /app/kineto<span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">ENTRYPOINT</span> [<span style="color:#e6db74">&#34;/app/kineto&#34;</span>]<span style="color:#960050;background-color:#1e0010"> </span></span></span></code></pre></div><p>I created a slightly-tweaked <code>style.css</code> based on kineto's <a href="https://git.sr.ht/~sircmpwn/kineto/tree/857f8c97ebc5724f4c34931ba497425e7653894e/item/main.go#L257">default styling</a> to tailor the appearance a bit more to my liking. (I've got some more work to do to make it look &quot;good&quot;, but I barely know how to spell CSS so I'll get to that later.)</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-css" data-lang="css"><span style="display:flex;"><span><span style="color:#75715e">/* torchlight! {&#34;lineNumbers&#34;:true} */</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">html</span> { <span style="color:#75715e">/* [tl! collapse:start] */</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">font-family</span>: <span style="color:#66d9ef">sans-serif</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">color</span>: <span style="color:#ae81ff">#080808</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">body</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">max-width</span>: <span style="color:#ae81ff">920</span><span style="color:#66d9ef">px</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">margin</span>: <span style="color:#ae81ff">0</span> <span style="color:#66d9ef">auto</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">padding</span>: <span style="color:#ae81ff">1</span><span style="color:#66d9ef">rem</span> <span style="color:#ae81ff">2</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">blockquote</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">background-color</span>: <span style="color:#ae81ff">#eee</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">border-left</span>: <span style="color:#ae81ff">3</span><span style="color:#66d9ef">px</span> <span style="color:#66d9ef">solid</span> <span style="color:#ae81ff">#444</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">margin</span>: <span style="color:#ae81ff">1</span><span style="color:#66d9ef">rem</span> <span style="color:#ae81ff">-1</span><span style="color:#66d9ef">rem</span> <span style="color:#ae81ff">1</span><span style="color:#66d9ef">rem</span> calc(<span style="color:#ae81ff">-1</span><span style="color:#66d9ef">rem</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">3</span><span style="color:#66d9ef">px</span>); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">padding</span>: <span style="color:#ae81ff">1</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">ul</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">margin-left</span>: <span style="color:#ae81ff">0</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">padding</span>: <span style="color:#ae81ff">0</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">li</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">padding</span>: <span style="color:#ae81ff">0</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">li</span>:<span style="color:#a6e22e">not</span><span style="color:#f92672">(</span>:<span style="color:#a6e22e">last-child</span><span style="color:#f92672">)</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">margin-bottom</span>: <span style="color:#ae81ff">0.5</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span>} <span style="color:#75715e">/* [tl! collapse:end] */</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">a</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">position</span>: <span style="color:#66d9ef">relative</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">text-decoration</span>: <span style="color:#66d9ef">dotted</span> <span style="color:#66d9ef">underline</span>; <span style="color:#75715e">/* [tl! ++:start] */</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">a</span>:<span style="color:#a6e22e">hover</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">text-decoration</span>: <span style="color:#66d9ef">solid</span> <span style="color:#66d9ef">underline</span>; <span style="color:#75715e">/* [tl! ++:end] */</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#75715e">/* [tl! collapse:start] */</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">a</span>:<span style="color:#a6e22e">before</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">content</span>: <span style="color:#e6db74">&#39;⇒&#39;</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">color</span>: <span style="color:#ae81ff">#999</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">text-decoration</span>: <span style="color:#66d9ef">none</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">font-weight</span>: <span style="color:#66d9ef">bold</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">position</span>: <span style="color:#66d9ef">absolute</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">left</span>: <span style="color:#ae81ff">-1.25</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">pre</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">background-color</span>: <span style="color:#ae81ff">#eee</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">margin</span>: <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">-1</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">padding</span>: <span style="color:#ae81ff">1</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">overflow-x</span>: <span style="color:#66d9ef">auto</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">details</span>:<span style="color:#a6e22e">not</span><span style="color:#f92672">([</span><span style="color:#f92672">open</span><span style="color:#f92672">])</span> <span style="color:#f92672">summary</span><span style="color:#f92672">,</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">details</span>:<span style="color:#a6e22e">not</span><span style="color:#f92672">([</span><span style="color:#f92672">open</span><span style="color:#f92672">])</span> <span style="color:#f92672">summary</span> <span style="color:#f92672">a</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">color</span>: <span style="color:#66d9ef">gray</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">details</span> <span style="color:#f92672">summary</span> <span style="color:#f92672">a</span>:<span style="color:#a6e22e">before</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">display</span>: <span style="color:#66d9ef">none</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">dl</span> <span style="color:#f92672">dt</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">font-weight</span>: <span style="color:#66d9ef">bold</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">dl</span> <span style="color:#f92672">dt</span>:<span style="color:#a6e22e">not</span><span style="color:#f92672">(</span>:<span style="color:#a6e22e">first-child</span><span style="color:#f92672">)</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">margin-top</span>: <span style="color:#ae81ff">0.5</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span>} <span style="color:#75715e">/* [tl! collapse:end] */</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>@<span style="color:#66d9ef">media</span><span style="color:#f92672">(</span><span style="color:#f92672">prefers-color-scheme</span>:<span style="color:#a6e22e">dark</span><span style="color:#f92672">)</span> { <span style="color:#75715e">/* [tl! collapse:start] */</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">html</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">background-color</span>: <span style="color:#ae81ff">#111</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">color</span>: <span style="color:#ae81ff">#eee</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">blockquote</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">background-color</span>: <span style="color:#ae81ff">#000</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">pre</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">background-color</span>: <span style="color:#ae81ff">#222</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">a</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">color</span>: <span style="color:#ae81ff">#0087BD</span>; </span></span><span style="display:flex;"><span> } <span style="color:#75715e">/* [tl! collapse:end] */</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">a</span>:<span style="color:#a6e22e">visited</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">color</span>: <span style="color:#ae81ff">#333399</span>; <span style="color:#75715e">/* [tl! --] */</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">color</span>: <span style="color:#ae81ff">#006ebd</span>; <span style="color:#75715e">/* [tl! ++ reindex(-1)] */</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#75715e">/* [tl! collapse:start] */</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">label</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">display</span>: <span style="color:#66d9ef">block</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">font-weight</span>: <span style="color:#66d9ef">bold</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">margin-bottom</span>: <span style="color:#ae81ff">0.5</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">input</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">display</span>: <span style="color:#66d9ef">block</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">border</span>: <span style="color:#ae81ff">1</span><span style="color:#66d9ef">px</span> <span style="color:#66d9ef">solid</span> <span style="color:#ae81ff">#888</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">padding</span>: <span style="color:#ae81ff">.375</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">line-height</span>: <span style="color:#ae81ff">1.25</span><span style="color:#66d9ef">rem</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">transition</span>: <span style="color:#66d9ef">border-color</span> <span style="color:#ae81ff">.15</span><span style="color:#66d9ef">s</span> <span style="color:#66d9ef">ease-in-out</span>,<span style="color:#66d9ef">box-shadow</span> <span style="color:#ae81ff">.15</span><span style="color:#66d9ef">s</span> <span style="color:#66d9ef">ease-in-out</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">width</span>: <span style="color:#ae81ff">100</span><span style="color:#66d9ef">%</span>; </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">input</span>:<span style="color:#a6e22e">focus</span> { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">outline</span>: <span style="color:#ae81ff">0</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">border-color</span>: <span style="color:#ae81ff">#80bdff</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">box-shadow</span>: <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">0.2</span><span style="color:#66d9ef">rem</span> rgba(<span style="color:#ae81ff">0</span>,<span style="color:#ae81ff">123</span>,<span style="color:#ae81ff">255</span>,<span style="color:#ae81ff">.25</span>); </span></span><span style="display:flex;"><span>} <span style="color:#75715e">/* [tl! collapse:end] */</span> </span></span></code></pre></div><p>And then I crafted a <code>docker-compose.yaml</code> for my kineto instance:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">kineto</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">always</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">build</span>: <span style="color:#ae81ff">.</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">kineto</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">ports</span>: </span></span><span style="display:flex;"><span> - <span style="color:#e6db74">&#34;8081:8080&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">volumes</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./style.css:/app/style.css</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">command</span>: -<span style="color:#ae81ff">s style.css gemini://capsule.jbowdre.lol</span> </span></span></code></pre></div><p>Port <code>8080</code> was already in use for another service on this system, so I had to expose this on <code>8081</code> instead. And I used the <code>-s</code> argument to <code>kineto</code> to load my customized CSS, and then pointed the proxy at the capsule's Gemini address.</p> <p>I started this container with <code>docker compose up -d</code>... but the job wasn't <em>quite</em> done. This server is already using <a href="https://caddyserver.com/">Caddy webserver</a> for serving some stuff, so I'll use that for fronting kineto as well. That way, the content can be served over the typical HTTPS port and Caddy can manage the certificate for me too.</p> <p>So I added this to my <code>/etc/caddy/Caddyfile</code>:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>capsule.jbowdre.lol { </span></span><span style="display:flex;"><span> reverse_proxy localhost:8081 </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p>And bounced the Caddy service.</p> <p>Now my capsule is available at both <code>gemini://capsule.jbowdre.lol</code> and <code>https://capsule.jbowdre.lol</code>. Agate handles generating the Gemini content, and kineto is able to convert that to web-friendly HTML on the fly. It even handles embedded <code>gemini://</code> links pointing to other capsules, allowing legacy web users to explore bits of Geminispace from the comfort of their old-school browsers.</p> <p>One thing that kineto <em>doesn't</em> translate, though, are the contents of the atom feed at <code>public/gemlog/atom.xml</code>. Whether it's served via Gemini or HTTPS, the contents are static. For instance:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;entry&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;id&gt;</span>urn:uuid:a751b018-cda5-4c03-bd9d-16bdc1506050<span style="color:#f92672">&lt;/id&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;title&gt;</span>Hello Gemini<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;summary&gt;</span>I decided to check out Geminispace. You&#39;re looking at my first exploration.<span style="color:#f92672">&lt;/summary&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;published&gt;</span>2024-03-05T17:00:00-06:00<span style="color:#f92672">&lt;/published&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;updated&gt;</span>2024-03-06T07:45:00-06:00<span style="color:#f92672">&lt;/updated&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;alternate&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;gemini://capsule.jbowdre.lol/gemlog/2024-03-05-hello-gemini.gmi&#34;</span><span style="color:#f92672">/&gt;</span> <span style="color:#75715e">&lt;!-- [tl! **] --&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/entry&gt;</span> </span></span></code></pre></div><p>No worries, I can throw together a quick script that will generate a web-friendly feed:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env bash</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># generate-web-feed.sh</span> </span></span><span style="display:flex;"><span>set -eu </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>INFEED<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;public/gemlog/atom.xml&#34;</span> </span></span><span style="display:flex;"><span>OUTFEED<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;public/gemlog/atom-web.xml&#34;</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># read INFEED, replace gemini:// with https:// and write to OUTFEED</span> </span></span><span style="display:flex;"><span>sed <span style="color:#e6db74">&#39;s/gemini:\/\//https:\/\//g&#39;</span> $INFEED &gt; $OUTFEED </span></span><span style="display:flex;"><span><span style="color:#75715e"># fix self url</span> </span></span><span style="display:flex;"><span>sed -i <span style="color:#e6db74">&#39;s/atom\.xml/atom-web\.xml/g&#39;</span> $OUTFEED </span></span></code></pre></div><p>After running that, I have a <code>public/gemlog/atom-web.xml</code> file that I can serve for web visitors, and it correctly points to the <code>https://</code> endpoint so that feed readers won't get confused:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;entry&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;id&gt;</span>urn:uuid:a751b018-cda5-4c03-bd9d-16bdc1506050<span style="color:#f92672">&lt;/id&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;title&gt;</span>Hello Gemini<span style="color:#f92672">&lt;/title&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;summary&gt;</span>I decided to check out Geminispace. You&#39;re looking at my first exploration.<span style="color:#f92672">&lt;/summary&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;published&gt;</span>2024-03-05T17:00:00-06:00<span style="color:#f92672">&lt;/published&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;updated&gt;</span>2024-03-06T07:45:00-06:00<span style="color:#f92672">&lt;/updated&gt;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">&lt;link</span> <span style="color:#a6e22e">rel=</span><span style="color:#e6db74">&#34;alternate&#34;</span> <span style="color:#a6e22e">href=</span><span style="color:#e6db74">&#34;https://capsule.jbowdre.lol/gemlog/2024-03-05-hello-gemini.gmi&#34;</span> <span style="color:#f92672">/&gt;</span> <span style="color:#75715e">&lt;!-- [tl! ** ] --&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/entry&gt;</span> </span></span></code></pre></div><p>Let's sort out an automated build process now...</p> <h3 id="publish-github-actions">Publish: GitHub Actions</h3> <p>To avoid having to manually publish my capsule by running <code>gempost build</code> and copying the result into the server's <code>content/</code> directory, I'm going to automate that process with a GitHub Actions workflow. It will execute the build process in a GitHub runner and then shift the result to my server. I'll make use of <a href="https://tailscale.com/">Tailscale</a> to easily establish an rsync-ssh connection between the runner and the server without having to open up any ports publicly.</p> <p>I'll start by creating a new GitHub repo for managing my capsule content; let's call it <a href="https://github.com/jbowdre/capsule">github.com/jbowdre/capsule</a>. I'll clone it locally so I can work in it on my laptop and then push changes upstream when I'm ready. So I'll copy/move over all the gempost files I've worked on so far, along with a copy of the Docker configurations for Agate and kineto just so I've got those visible in one place:</p> <pre tabindex="0"><code>. ├── .certificates ├── .direnv ├── agate ├── gemlog ├── kineto ├── public ├── static ├── templates ├── .envrc ├── .gitignore ├── flake.lock ├── flake.nix ├── gempost.yaml └── generate-web-feed.sh </code></pre><p>I don't need to sync <code>.direnv/</code> (which holds the local direnv state), <code>public/</code> (which holds the gempost output - I'll be building that in GitHub shortly), or <code>.certificates/</code> (which was automatically created when testing agate locally with <code>agate --content public --hostname localhost</code>), so I'll add those to <code>.gitignore</code>:</p> <pre tabindex="0"><code>/.certificates/ /.direnv/ /public/ </code></pre><p>Then I can create <code>.github/workflows/deploy-gemini.yml</code> to manage the deployment, and I start that by defining when it should be triggered:</p> <ul> <li>only on pushes to the <code>main</code> branch,</li> <li>and only when one of the gempost-related files/directories are included in that push.</li> </ul> <p>It will also set some other sane defaults for the workflow:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">Deploy Gemini Capsule</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># only run on changes to main</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">on</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">workflow_dispatch</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">push</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">branches</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">main</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">paths</span>: </span></span><span style="display:flex;"><span> - <span style="color:#e6db74">&#39;gemlog/**&#39;</span> </span></span><span style="display:flex;"><span> - <span style="color:#e6db74">&#39;static/**&#39;</span> </span></span><span style="display:flex;"><span> - <span style="color:#e6db74">&#39;templates/**&#39;</span> </span></span><span style="display:flex;"><span> - <span style="color:#e6db74">&#39;gempost.yaml&#39;</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">concurrency</span>: <span style="color:#75715e"># prevent concurrent deploys doing strange things</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">group</span>: <span style="color:#ae81ff">deploy-gemini-capsule</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">cancel-in-progress</span>: <span style="color:#66d9ef">true</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># Default to bash</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">defaults</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">shell</span>: <span style="color:#ae81ff">bash</span> </span></span></code></pre></div><p>I'll then define my first couple of jobs to:</p> <ul> <li>use the <a href="https://github.com/baptiste0928/cargo-install/tree/v3.0.0/">cargo-install Action</a> to install gempost,</li> <li>checkout the current copy of my capsule repo,</li> <li>run the <code>gempost build</code> process,</li> <li>and then call my <code>generate-web-feed.sh</code> script.</li> </ul> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">jobs</span>: <span style="color:#75715e"># [tl! reindex(24)]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">deploy</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Deploy Gemini Capsule</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">runs-on</span>: <span style="color:#ae81ff">ubuntu-latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">steps</span>: </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Install gempost</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">baptiste0928/cargo-install@v3.0.0</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">crate</span>: <span style="color:#ae81ff">gempost</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Checkout</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/checkout@v4</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Gempost build</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: <span style="color:#ae81ff">gempost build</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Generate web feed</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: <span style="color:#ae81ff">./generate-web-feed.sh</span> </span></span></code></pre></div><p>Next, I'll use the <a href="https://github.com/tailscale/github-action/tree/v2/">Tailscale Action</a> to let the runner connect to my Tailnet. It will use an <a href="https://tailscale.com/kb/1215/oauth-clients">OAuth client</a> to authenticate the new node, and will apply an <a href="https://tailscale.com/kb/1068/acl-tags">ACL tag</a> which grants access <em>only</em> to my web server.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Connect to Tailscale</span> <span style="color:#75715e"># [tl! reindex(39)]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">tailscale/github-action@v2</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">oauth-client-id</span>: <span style="color:#ae81ff">${{ secrets.TS_API_CLIENT_ID }}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">oauth-secret</span>: <span style="color:#ae81ff">${{ secrets.TS_API_CLIENT_SECRET }}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">tags</span>: <span style="color:#ae81ff">${{ secrets.TS_TAG }}</span> </span></span></code></pre></div><p>I'll let <a href="https://tailscale.com/kb/1193/tailscale-ssh">Tailscale SSH</a> facilitate the authentication for the rsync-ssh login, but I need to pre-stage <code>~/.ssh/known_hosts</code> on the runner so that the client will know it can trust the server:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Configure SSH known hosts</span> <span style="color:#75715e"># [tl! reindex(45)]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: |<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> mkdir -p ~/.ssh </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> echo &#34;${{ secrets.SSH_KNOWN_HOSTS }}&#34; &gt; ~/.ssh/known_hosts </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> chmod 644 ~/.ssh/known_hosts</span> </span></span></code></pre></div><p>And finally, I'll use <code>rsync</code> to transfer the contents of the gempost-produced <code>public/</code> folder to the Agate <code>content/</code> folder on the remote server:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Deploy GMI to Agate</span> <span style="color:#75715e"># [tl! reindex(50)]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: |<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> rsync -avz --delete -e ssh public/ deploy@${{ secrets.GMI_HOST }}:${{ secrets.GMI_CONTENT_PATH }}</span> </span></span></code></pre></div><p>You can view the workflow in its entirety on <a href="https://github.com/jbowdre/capsule/blob/main/.github/workflows/deploy-gemini.yml">GitHub</a>. But before I can actually use it, I'll need to configure Tailscale, set up the server, and safely stash those <code>secrets.*</code> values in the repo.</p> <h4 id="tailscale-configuration">Tailscale configuration</h4> <p>I want to use a pair of ACL tags to identify the GitHub runner and the server, and ensure that the runner can connect to the server over port 22. To create a new tag in a Tailscale ACL, I need to assign the permission, so I'll add this to my ACL file:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-json" data-lang="json"><span style="display:flex;"><span> <span style="color:#e6db74">&#34;tagOwners&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;tag:gh-bld&#34;</span>: [<span style="color:#e6db74">&#34;group:admins&#34;</span>], <span style="color:#75715e">// github builder </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">&#34;tag:gh-srv&#34;</span>: [<span style="color:#e6db74">&#34;group:admins&#34;</span>], <span style="color:#75715e">// server it can deploy to </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> }<span style="color:#960050;background-color:#1e0010">,</span> </span></span></code></pre></div><p>Then I'll add this to the <code>acls</code> block:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-json" data-lang="json"><span style="display:flex;"><span> <span style="color:#e6db74">&#34;acls&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [ </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> <span style="color:#75715e">// github runner can talk to the deployment target </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">&#34;action&#34;</span>: <span style="color:#e6db74">&#34;accept&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;users&#34;</span>: [<span style="color:#e6db74">&#34;tag:gh-bld&#34;</span>], </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;ports&#34;</span>: [ </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;tag:gh-srv:22&#34;</span> </span></span><span style="display:flex;"><span> ], </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> ]<span style="color:#960050;background-color:#1e0010">,</span> </span></span></code></pre></div><p>And I'll use this to configure <a href="https://tailscale.com/kb/1193/tailscale-ssh">Tailscale SSH</a> to define that the runner can log in to the web server as the <code>deploy</code> user using a keypair automagically managed by Tailscale:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-json" data-lang="json"><span style="display:flex;"><span> <span style="color:#e6db74">&#34;ssh&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> [ </span></span><span style="display:flex;"><span> { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;action&#34;</span>: <span style="color:#e6db74">&#34;accept&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;src&#34;</span>: [<span style="color:#e6db74">&#34;tag:gh-bld&#34;</span>], </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;dst&#34;</span>: [<span style="color:#e6db74">&#34;tag:gh-srv&#34;</span>], </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;users&#34;</span>: [<span style="color:#e6db74">&#34;deploy&#34;</span>], </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> ]<span style="color:#960050;background-color:#1e0010">,</span> </span></span></code></pre></div><p>To generate the OAuth client, I'll head to <a href="https://login.tailscale.com/admin/settings/oauth">Tailscale Admin Console &gt; Settings &gt; OAuth Clients</a> and click the <strong>Generate OAuth Client</strong> button. The new client only needs to have the ability to read and write new devices, and I set it to assign the <code>tag:gh-bld</code> tag:</p> <p><img src="https://runtimeterror.dev/gemini-capsule-gempost-github-actions/oauth-client-generation.png" alt="OAuth Client generation screen showing Read and Write privileges enabled for the Devices scope and the tag:gh-bld tag being applied."></p> <p>I take the Client ID and Client Secret store those in the GitHub Repo as Actions Repository Secrets named <code>TS_API_CLIENT_ID</code> and <code>TS_API_CLIENT_SECRET</code>. I also save the tag as <code>TS_TAG</code>.</p> <h4 id="server-configuration">Server configuration</h4> <p>From my previous manual experimentation, I have the Docker configuration for the Agate server hanging out at <code>/opt/gemini/</code>, and kineto is on the same server at <code>/opt/kineto/</code> (though it doesn't really matter where kineto actually lives - it can proxy any <code>gemini://</code> address from anywhere).</p> <p>I'm actually using <a href="https://github.com/mbrubeck/agate?tab=readme-ov-file#virtual-hosts">Agate's support for Virtual Hosts</a> to serve both the capsule I've been discussing in this post as well as a version of <em>this very site</em> available in Geminispace at <code>gemini://gmi.runtimeterror.dev</code>. By passing multiple hostnames to the <code>agate</code> command line, it looks for hostname-specific subdirs inside of the <code>content</code> and <code>certs</code> folders. So my production setup looks like this:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>. </span></span><span style="display:flex;"><span>├── certs </span></span><span style="display:flex;"><span>│ ├── capsule.jbowdre.lol </span></span><span style="display:flex;"><span>│ └── gmi.runtimeterror.dev </span></span><span style="display:flex;"><span>├── content </span></span><span style="display:flex;"><span>│ ├── capsule.jbowdre.lol </span></span><span style="display:flex;"><span>│ └── gmi.runtimeterror.dev </span></span><span style="display:flex;"><span>├── docker-compose.yaml </span></span><span style="display:flex;"><span>└── Dockerfile </span></span></code></pre></div><p>And the <code>command:</code> line of <code>docker-compose.yaml</code> looks like this:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span> <span style="color:#f92672">command</span>: &gt;<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> --content content --certs certs --addr 0.0.0.0:1965 </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> --hostname gmi.runtimeterror.dev </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> --hostname capsule.jbowdre.lol </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> --lang en-US</span> </span></span></code></pre></div><p>I told the Tailscale ACL that the runner should be able to log in to the server as the <code>deploy</code> user, so I guess I'd better create that account on the server:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sudo useradd -U -m -s /usr/bin/bash deploy <span style="color:#75715e"># [tl! .cmd]</span> </span></span></code></pre></div><p>And I want to make that user the owner of the content directories so that it can use <code>rsync</code> to overwrite them when changes are made:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sudo chown -R deploy:deploy /opt/gemini/content/capsule.jbowdre.lol <span style="color:#75715e"># [tl! .cmd:1]</span> </span></span><span style="display:flex;"><span>sudo chown -R deploy:deploy /opt/gemini/content/gmi.runtimeterror.dev </span></span></code></pre></div><p>I should also make sure that the server bears the appropriate Tailscale tag and that it has Tailscale SSH enabled:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sudo tailscale up --advertise-tags<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;tag:gh-srv&#34;</span> --ssh </span></span></code></pre></div><p>I can use the <code>ssh-keyscan</code> utility <em>from another system</em> to retrieve the server's SSH public keys so they can be added to the runner's <code>known_hosts</code> file:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>ssh-keyscan -H $servername <span style="color:#75715e"># [tl! .cmd]</span> </span></span></code></pre></div><p>I'll copy the entirety of that output and save it as a repository secret named <code>SSH_KNOWN_HOSTS</code>. I'll also save the Tailnet node name as <code>GMI_HOST</code> and the full path to the capsule's content directory (<code>/opt/gemini/content/capsule.jbowdre.lol/</code>) as <code>GMI_CONTENT_PATH</code>.</p> <h3 id="deploy">Deploy!</h3> <p>All the pieces are in place, and all that's left is to <code>git commit</code> and <code>git push</code> the repo I've been working on. With a bit of luck (and a lot of <del>failures</del> <em>lessons learned</em>), GitHub shows a functioning deployment!</p> <p><img src="https://runtimeterror.dev/gemini-capsule-gempost-github-actions/deploy-success.png" alt="GitHub Actions reporting a successful workflow execution"></p> <p>And the capsule is live at both <code>https://capsule.jbowdre.lol</code> and <code>gemini://capsule.jbowdre.lol</code>:</p> <p><img src="https://runtimeterror.dev/gemini-capsule-gempost-github-actions/http-capsule.png" alt="Gemini capsule served over https://"></p> <p><img src="https://runtimeterror.dev/gemini-capsule-gempost-github-actions/gemini-capsule.png" alt="Gemini capsule served over gemini://"></p> <p>Come check it out!</p> <ul> <li><a href="gemini://capsule.jbowdre.lol">My Capsule on Gemini</a></li> <li><a href="https://capsule.jbowdre.lol">My Capsule on the web</a></li> </ul> </description> </item> <item> <title>Dynamically Generating OpenGraph Images With Hugo</title> <link>https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/</link> <pubDate>Mon, 19 Feb 2024 04:12:27 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>hugo</category> <category>cicd</category> <category>meta</category> <category>selfhosting</category> <guid>https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/</guid><description><p>I've lately seen some folks on <a href="https://social.lol">social.lol</a> posting about their various strategies for automatically generating <a href="https://ogp.me/">Open Graph images</a> for their <a href="https://11ty.dev">Eleventy</a> sites. So this weekend I started exploring how I could do that for my <a href="https://gohugo.io">Hugo</a> site<sup id="fnref:1"><a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>.</p> <p>During my search, I came across a few different approaches using external services or additional scripts to run at build time, but I was hoping for a way to do this with Hugo's built-in tooling. I eventually came across a tremendously helpful post from <a href="https://aarol.dev/about/">Aaro</a> titled <a href="https://aarol.dev/posts/hugo-og-image/">Generating OpenGraph images with Hugo</a>. This solution was exactly what I was after, as it uses Hugo's <a href="https://gohugo.io/functions/images/filter/">image functions</a> to dynamically create a share image for each page.</p> <p>I ended up borrowing heavily from Aaro's approach while adding a few small variations for my OpenGraph images.</p> <ul> <li>When sharing the home page, the image includes the site description.</li> <li>When sharing a post, the image includes the post title.</li> <li>... and if the post has a thumbnail<sup id="fnref:2"><a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> listed in the front matter, that gets overlaid in the corner.</li> </ul> <p>Here's how I did it.</p> <h3 id="new-resources">New resources</h3> <p>Based on Aaro's suggestions, I used <a href="https://www.gimp.org/">GIMP</a> to create a 1200x600 image for the base. I'm not a graphic designer<sup id="fnref:3"><a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup> so I kept it simple while trying to match the site's theme.</p> <p>I had to install the Fira Mono font <a href="https://github.com/mozilla/Fira/blob/master/ttf/FiraMono-Regular.ttf">Fira Mono <code>.ttf</code></a> to my <code>~/.fonts/</code> folder so I could use it in GIMP, and I wound up with a decent recreation of the little &quot;logo&quot; at the top of the page.</p> <p><img src="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/og_base.png" alt="Red background with a command prompt displaying &quot;[runtimeterror.dev] {body}amp;amp;quot; in white and red font."></p> <p>That fits with the vibe of the site, and leaves plenty of room for text to be added to the image.</p> <p>I also wanted to use that font later for the text overlay, so I stashed both of those resources in my <code>assets/</code> folder:</p> <p><img src="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/new_resources.png" alt="File explorer window showing a directory structure with folders such as '.github/workflows', 'archetypes', 'assets' with subfolders 'css', 'js', and files 'FiraMono-Regular.ttf', 'og_base.png' under 'RUNTIMETERROR'."></p> <h3 id="opengraph-partial">OpenGraph partial</h3> <p>Hugo uses an <a href="https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/embedded/templates/opengraph.html">internal template</a> for rendering OpenGraph properties by default. I needed to import that as a partial so that I could override its behavior. So I dropped the following in <code>layouts/partials/opengraph.html</code> as a starting point:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">// torchlight! {&#34;lineNumbers&#34;: true} &lt;meta property=&#34;og:title&#34; content=&#34;{{ .Title }}&#34; /&gt; &lt;meta property=&#34;og:description&#34; content=&#34;{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end }}&#34; /&gt; &lt;meta property=&#34;og:type&#34; content=&#34;{{ if .IsPage }}article{{ else }}website{{ end }}&#34; /&gt; &lt;meta property=&#34;og:url&#34; content=&#34;{{ .Permalink }}&#34; /&gt; &lt;meta property=&#34;og:locale&#34; content=&#34;{{ .Lang }}&#34; /&gt; {{- if .IsPage }} {{- $iso8601 := &#34;2006-01-02T15:04:05-07:00&#34; -}} &lt;meta property=&#34;article:section&#34; content=&#34;{{ .Section }}&#34; /&gt; {{ with .PublishDate }}&lt;meta property=&#34;article:published_time&#34; {{ .Format $iso8601 | printf &#34;content=%q&#34; | safeHTMLAttr }} /&gt;{{ end }} {{ with .Lastmod }}&lt;meta property=&#34;article:modified_time&#34; {{ .Format $iso8601 | printf &#34;content=%q&#34; | safeHTMLAttr }} /&gt;{{ end }} {{- end -}} {{- with .Params.audio }}&lt;meta property=&#34;og:audio&#34; content=&#34;{{ . }}&#34; /&gt;{{ end }} {{- with .Params.locale }}&lt;meta property=&#34;og:locale&#34; content=&#34;{{ . }}&#34; /&gt;{{ end }} {{- with .Site.Params.title }}&lt;meta property=&#34;og:site_name&#34; content=&#34;{{ . }}&#34; /&gt;{{ end }} {{- with .Params.videos }}{{- range . }} &lt;meta property=&#34;og:video&#34; content=&#34;{{ . | absURL }}&#34; /&gt; {{ end }}{{ end }} </code></pre><p>To use this new partial, I added it to my <code>layouts/partials/head.html</code>:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{ partial &#34;opengraph&#34; . }} </code></pre><p>which is in turn loaded by <code>layouts/_defaults/baseof.html</code>:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html"> &lt;head&gt; {{- partial &#34;head.html&#34; . -}} &lt;/head&gt; </code></pre><p>So now the customized OpenGraph content will be loaded for each page.</p> <h3 id="aaros-og-image-generation">Aaro's OG image generation</h3> <p><a href="https://aarol.dev/posts/hugo-og-image/">Aaro's code</a> provided the base functionality for what I needed:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{/* Generate opengraph image */}} {{- if .IsPage -}} {{ $base := resources.Get &#34;og_base.png&#34; }} {{ $boldFont := resources.Get &#34;/Inter-SemiBold.ttf&#34;}} {{ $mediumFont := resources.Get &#34;/Inter-Medium.ttf&#34;}} {{ $img := $base.Filter (images.Text .Site.Title (dict &#34;color&#34; &#34;#ffffff&#34; &#34;size&#34; 52 &#34;linespacing&#34; 2 &#34;x&#34; 141 &#34;y&#34; 117 &#34;font&#34; $boldFont ))}} {{ $img = $img.Filter (images.Text .Page.Title (dict &#34;color&#34; &#34;#ffffff&#34; &#34;size&#34; 64 &#34;linespacing&#34; 2 &#34;x&#34; 141 &#34;y&#34; 291 &#34;font&#34; $mediumFont ))}} {{ $img = resources.Copy (path.Join .Page.RelPermalink &#34;og.png&#34;) $img }} &lt;meta property=&#34;og:image&#34; content=&#34;{{$img.Permalink}}&#34;&gt; &lt;meta property=&#34;og:image:width&#34; content=&#34;{{$img.Width}}&#34; /&gt; &lt;meta property=&#34;og:image:height&#34; content=&#34;{{$img.Height}}&#34; /&gt; &lt;!-- Twitter metadata (used by other websites as well) --&gt; &lt;meta name=&#34;twitter:card&#34; content=&#34;summary_large_image&#34; /&gt; &lt;meta name=&#34;twitter:title&#34; content=&#34;{{ .Title }}&#34; /&gt; &lt;meta name=&#34;twitter:description&#34; content=&#34;{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end -}}&#34;/&gt; &lt;meta name=&#34;twitter:image&#34; content=&#34;{{$img.Permalink}}&#34; /&gt; {{ end }} </code></pre><p>The <a href="https://gohugo.io/functions/resources/get/"><code>resources.Get</code></a> bits import the image and font resources to make them available to the <a href="https://gohugo.io/functions/images/text/"><code>images.Text</code></a> functions, which add the site and page title texts to the image using the designated color, size, placement, and font.</p> <p>The <code>resources.Copy</code> line moves the generated OG image alongside the post itself and gives it a clean <code>og.png</code> name rather than the very-long randomly-generated name it would have by default.</p> <p>And then the <code>&lt;meta /&gt;</code> lines insert the generated image into the page's <code>&lt;head&gt;</code> block so it can be rendered when the link is shared on sites which support OpenGraph.</p> <p>This is a great starting point for what I wanted to accomplish, but I made some changes to my <code>opengraph.html</code> partial to tailor it to my needs.</p> <h3 id="my-tweaks">My tweaks</h3> <p>As I mentioned earlier, I wanted to have three slightly-different recipes for baking my OG images: one for the homepage, one for standard posts, and one for posts with an associated thumbnail. They all use the same basic code, though, so I wanted to be sure that my setup didn't repeat itself too much.</p> <p>My code starts with fetching my resources up front, and initializing an empty <code>$text</code> variable to hold either the site description <em>or</em> post title:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{ $img := resources.Get &#34;og_base.png&#34; }} {{ $font := resources.Get &#34;/FiraMono-Regular.ttf&#34; }} {{ $text := &#34;&#34; }} </code></pre><p>For the site homepage, I set <code>$text</code> to hold the site description:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{- if .IsHome }} {{ $text = .Site.Params.Description }} {{- end }} </code></pre><p>On standard post pages, I used the page title instead:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{- if .IsPage }} {{ $text = .Page.Title }} {{ end }} </code></pre><p>If the page has a <code>thumbnail</code> parameter defined in the front matter, Hugo will use <code>.Resources.Get</code> to grab the image.</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{- with .Params.thumbnail }} {{ $thumbnail := $.Resources.Get . }} </code></pre><div></div><div class="notice note"> <p class="first notice-title"><span class="icon-notice baseline"></span>Resources vs resources</p><p>The <a href="https://gohugo.io/functions/resources/get/"><code>resources.Get</code> function</a> (little r) I used earlier works on <em>global</em> resources, like the image and font stored in the site's <code>assets/</code> directory. On the other hand, the <a href="https://gohugo.io/methods/page/resources/"><code>Resources.Get</code> method</a> (big R) is used for loading <em>page</em> resources, like the file indicated by the page's <code>thumbnail</code> parameter.</p></div> <p>Since I'm calling this method from inside a <code>with</code> block I use a <code>{body}amp;lt;/code> in front of the method name to get the <a href="https://gohugo.io/functions/go-template/with/#understanding-context">parent context</a>. Otherwise, the leading <code>.</code> would refer directly to the <code>thumbnail</code> parameter (which isn't a page and so doesn't have the method available<sup id="fnref:4"><a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup>).</p> <p>Anyhoo, after the thumbnail is loaded, I use the <a href="https://gohugo.io/content-management/image-processing/#fit"><code>Fit</code> image processing method</a> to scale down the thumbnail. It is then passed to the <a href="https://gohugo.io/functions/images/overlay/"><code>images.Overlay</code> function</a> to <em>overlay</em> it near the top right corner of the <code>og_base.png</code> image<sup id="fnref:5"><a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup>.</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html"> {{ with $thumbnail }} {{ $img = $img.Filter (images.Overlay (.Process &#34;fit 300x250&#34;) 875 38 )}} {{ end }} {{ end }} </code></pre><p>Then I insert the desired text:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">{{ $img = $img.Filter (images.Text $text (dict &#34;color&#34; &#34;#d8d8d8&#34; &#34;size&#34; 64 &#34;linespacing&#34; 2 &#34;x&#34; 40 &#34;y&#34; 300 &#34;font&#34; $font ))}} {{ $img = resources.Copy (path.Join $.Page.RelPermalink &#34;og.png&#34;) $img }} </code></pre><h3 id="all-together-now">All together now</h3> <p>After merging my code in with the existing <code>layouts/partials/opengraph.html</code>, here's what the whole file looks like:</p> <pre tabindex="0"><code class="language-jinja-html" data-lang="jinja-html">// torchlight! {&#34;lineNumbers&#34;: true} {{ $img := resources.Get &#34;og_base.png&#34; }} &lt;!-- [tl! **:2] --&gt; {{ $font := resources.Get &#34;/FiraMono-Regular.ttf&#34; }} {{ $text := &#34;&#34; }} &lt;meta property=&#34;og:title&#34; content=&#34;{{ .Title }}&#34; /&gt; &lt;meta property=&#34;og:description&#34; content=&#34;{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end }}&#34; /&gt; &lt;meta property=&#34;og:type&#34; content=&#34;{{ if .IsPage }}article{{ else }}website{{ end }}&#34; /&gt; &lt;meta property=&#34;og:url&#34; content=&#34;{{ .Permalink }}&#34; /&gt; &lt;meta property=&#34;og:locale&#34; content=&#34;{{ .Lang }}&#34; /&gt; {{- if .IsHome }} &lt;!-- [tl! **:2] --&gt; {{ $text = .Site.Params.Description }} {{- end }} {{- if .IsPage }} {{- $iso8601 := &#34;2006-01-02T15:04:05-07:00&#34; -}} &lt;meta property=&#34;article:section&#34; content=&#34;{{ .Section }}&#34; /&gt; {{ with .PublishDate }}&lt;meta property=&#34;article:published_time&#34; {{ .Format $iso8601 | printf &#34;content=%q&#34; | safeHTMLAttr }} /&gt;{{ end }} {{ with .Lastmod }}&lt;meta property=&#34;article:modified_time&#34; {{ .Format $iso8601 | printf &#34;content=%q&#34; | safeHTMLAttr }} /&gt;{{ end }} {{ $text = .Page.Title }} &lt;!-- [tl! ** ] --&gt; {{ end }} {{- with .Params.thumbnail }} &lt;!-- [tl! **:start] --&gt; {{ $thumbnail := $.Resources.Get . }} {{ with $thumbnail }} {{ $img = $img.Filter (images.Overlay (.Process &#34;fit 300x250&#34;) 875 38 )}} {{ end }} {{ end }} {{ $img = $img.Filter (images.Text $text (dict &#34;color&#34; &#34;#d8d8d8&#34; &#34;size&#34; 64 &#34;linespacing&#34; 2 &#34;x&#34; 40 &#34;y&#34; 300 &#34;font&#34; $font ))}} {{ $img = resources.Copy (path.Join $.Page.RelPermalink &#34;og.png&#34;) $img }} &lt;!-- [tl! **:end] --&gt; &lt;meta property=&#34;og:image&#34; content=&#34;{{$img.Permalink}}&#34;&gt; &lt;meta property=&#34;og:image:width&#34; content=&#34;{{$img.Width}}&#34; /&gt; &lt;meta property=&#34;og:image:height&#34; content=&#34;{{$img.Height}}&#34; /&gt; &lt;meta name=&#34;twitter:card&#34; content=&#34;summary_large_image&#34; /&gt; &lt;meta name=&#34;twitter:title&#34; content=&#34;{{ .Title }}&#34; /&gt; &lt;meta name=&#34;twitter:description&#34; content=&#34;{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end -}}&#34;/&gt; &lt;meta name=&#34;twitter:image&#34; content=&#34;{{$img.Permalink}}&#34; /&gt; {{- with .Params.audio }}&lt;meta property=&#34;og:audio&#34; content=&#34;{{ . }}&#34; /&gt;{{ end }} {{- with .Site.Params.title }}&lt;meta property=&#34;og:site_name&#34; content=&#34;{{ . }}&#34; /&gt;{{ end }} {{- with .Params.videos }}{{- range . }} &lt;meta property=&#34;og:video&#34; content=&#34;{{ . | absURL }}&#34; /&gt; {{ end }}{{ end }} </code></pre><p>And it works! <img src="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/og-demo.png" alt="Black background with text &quot;Dynamic Opengraph Images With Hugo&quot;, a command prompt &quot;[runtimeterror.dev] {body}amp;amp;quot;, and colorful hexagon shapes with &quot;HUGO&quot; letters."></p> <p>I'm sure this could be further optimized by someone who knows what they're doing<sup id="fnref:6"><a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fn:6" class="footnote-ref" role="doc-noteref">6</a></sup>. I'd really like to find a better way of positioning the thumbnail overlay to better account for different heights and widths. But for now, I'm pretty happy with how it works, and I enjoyed learning more about Hugo along the way.</p> <div class="footnotes" role="doc-endnotes"> <hr> <ol> <li id="fn:1"> <p>You're looking at it.&#160;<a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:2"> <p>My current theme doesn't make use of the thumbnails, but a previous theme did so I've got a bunch of posts with thumbnails still assigned. And now I've got a use for them again!&#160;<a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:3"> <p>Or a web designer, if I'm being honest.&#160;<a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:4"> <p>Hugo scoping is kind of wild.&#160;<a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:5"> <p>The overlay is placed using absolute X and Y coordinates. There's probably a way to tell it &quot;offset the top-right corner of the overlay 20x20 from the top right of the base image&quot; but I ran out of caffeine to figure that out at this time. Let me know if you know a trick!&#160;<a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:6"> <p>Like Future John, perhaps? Past John loves leaving stuff for that guy to figure out.&#160;<a href="https://runtimeterror.dev/dynamic-opengraph-images-with-hugo/#fnref:6" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> </ol> </div> </description> </item> <item> <title>Displaying Data from a Tempest Weather Station on a Static Site</title> <link>https://runtimeterror.dev/display-tempest-weather-static-site/</link> <pubDate>Sun, 11 Feb 2024 20:48:49 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>api</category> <category>cicd</category> <category>javascript</category> <category>meta</category> <category>serverless</category> <guid>https://runtimeterror.dev/display-tempest-weather-static-site/</guid><description><p>As I covered briefly <a href="https://scribbles.jbowdre.lol/post/near-realtime-weather-on-profile-lol-ku4yq-zr">in a recent Scribble</a>, I was inspired by the way <a href="https://kris.omg.lol/">Kris's omg.lol page</a> displays realtime data from his <a href="https://shop.weatherflow.com/products/tempest">Weatherflow Tempest weather station</a>. I thought that was really neat and wanted to do the same on <a href="https://jbowdre.lol">my omg.lol page</a> with data from my own Tempest, but I wanted to find a way to do it without needing to include an authenticated API call in the client-side JavaScript.</p> <p>I realized I could use a GitHub Actions workflow to retrieve the data from the authenticated Tempest API, post it somewhere publicly accessible, and then have the client-side code fetch the data from there without needing any authentication. After a few days of tinkering, I came up with a presentation I'm happy with.</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/finished-product.png" alt="Glowing green text on a dark grey background showing weather data like conditions, temperature, and pressure, with a note that the data is 2 minutes old"></p> <p>This post will cover how I did it.</p> <h3 id="retrieve-weather-data-from-tempest-api">Retrieve Weather Data from Tempest API</h3> <p>To start, I want to play with the API a bit to see what the responses look like. Before I can talk to the API, though, I need to generate a new token for my account at <code>https://tempestwx.com/settings/tokens</code>. I also make a note of my station ID, and store both of those values in my shell for easier use.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>read wx_token <span style="color:#75715e"># [tl! .cmd:1]</span> </span></span><span style="display:flex;"><span>read wx_station </span></span></code></pre></div><p>After browsing the Tempest API Explorer a little, it seems to me like the <a href="https://weatherflow.github.io/Tempest/api/swagger/#!/forecast/getBetterForecast"><code>/better_forecast</code> endpoint</a> will probably be the easiest to work with, particularly since it lets the user choose which units will be used in the response. That will keep me from having to do metric-to-imperial conversions for many of the data.</p> <p>So I start out calling the API like this:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>curl -sL <span style="color:#e6db74">&#34;https://swd.weatherflow.com/swd/rest/better_forecast?station_id=</span>$wx_station<span style="color:#e6db74">&amp;token=</span>$wx_token<span style="color:#e6db74">&amp;units_temp=f&amp;units_wind=mph&amp;units_pressure=inhg&amp;units_precip=in&amp;units_distance=mi&#34;</span> <span style="color:#ae81ff">\ </span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>| jq <span style="color:#75715e"># [tl! .cmd:-1]</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">{</span> <span style="color:#75715e"># [tl! .nocopy:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;current_conditions&#34;</span>: <span style="color:#f92672">{</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;air_density&#34;</span>: 1.2, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;air_temperature&#34;</span>: 59.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;brightness&#34;</span>: 1, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;conditions&#34;</span>: <span style="color:#e6db74">&#34;Rain Possible&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;delta_t&#34;</span>: 2.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;dew_point&#34;</span>: 55.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;feels_like&#34;</span>: 59.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;icon&#34;</span>: <span style="color:#e6db74">&#34;possibly-rainy-night&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;is_precip_local_day_rain_check&#34;</span>: true, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;is_precip_local_yesterday_rain_check&#34;</span>: true, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_count_last_1hr&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_count_last_3hr&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_last_distance&#34;</span>: 12, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_last_distance_msg&#34;</span>: <span style="color:#e6db74">&#34;11 - 13 mi&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_last_epoch&#34;</span>: 1706394852, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_accum_local_day&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_accum_local_yesterday&#34;</span>: 0.05, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_minutes_local_day&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_minutes_local_yesterday&#34;</span>: 28, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;pressure_trend&#34;</span>: <span style="color:#e6db74">&#34;falling&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;relative_humidity&#34;</span>: 88, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;sea_level_pressure&#34;</span>: 29.89, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;solar_radiation&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;station_pressure&#34;</span>: 29.25, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;time&#34;</span>: 1707618643, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;uv&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wet_bulb_globe_temperature&#34;</span>: 57.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wet_bulb_temperature&#34;</span>: 56.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_avg&#34;</span>: 2.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_direction&#34;</span>: 244, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_direction_cardinal&#34;</span>: <span style="color:#e6db74">&#34;WSW&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_gust&#34;</span>: 2.0 </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;forecast&#34;</span>: <span style="color:#f92672">{</span> <span style="color:#75715e"># [tl! collapse:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;daily&#34;</span>: <span style="color:#f92672">[</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">{</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">[</span>...<span style="color:#f92672">]</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;day_num&#34;</span>: 10, </span></span><span style="display:flex;"><span> <span style="color:#f92672">[</span>...<span style="color:#f92672">]</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">{</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">[</span>...<span style="color:#f92672">]</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;day_num&#34;</span>: 11, </span></span><span style="display:flex;"><span> <span style="color:#f92672">[</span>...<span style="color:#f92672">]</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">{</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">[</span>...<span style="color:#f92672">]</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;day_num&#34;</span>: 12, </span></span><span style="display:flex;"><span> <span style="color:#f92672">[</span>...<span style="color:#f92672">]</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">}</span> <span style="color:#75715e"># [tl! collapse:end .nocopy:end]</span> </span></span></code></pre></div><p>So that validates that the endpoint will give me what I want, but I don't <em>really</em> need the extra 10-day forecast since I'm only interested in showing the current conditions. I can start working some <code>jq</code> magic to filter down to just what I'm interested in. And, while I'm at it, I'll stick the API URL in a variable to make that easier to work with.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>endpoint<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;https://swd.weatherflow.com/swd/rest/better_forecast?station_id=</span>$wx_station<span style="color:#e6db74">&amp;token=</span>$wx_token<span style="color:#e6db74">&amp;units_temp=f&amp;units_wind=mph&amp;units_pressure=inhg&amp;units_precip=in&amp;units_distance=mi&#34;</span> <span style="color:#75715e"># [tl! .cmd:1]</span> </span></span><span style="display:flex;"><span>curl -sL <span style="color:#e6db74">&#34;</span>$endpoint<span style="color:#e6db74">&#34;</span> | jq <span style="color:#e6db74">&#39;.current_conditions&#39;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">{</span> <span style="color:#75715e"># [tl! .nocopy:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;air_density&#34;</span>: 1.2, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;air_temperature&#34;</span>: 59.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;brightness&#34;</span>: 1, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;conditions&#34;</span>: <span style="color:#e6db74">&#34;Light Rain&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;delta_t&#34;</span>: 2.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;dew_point&#34;</span>: 55.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;feels_like&#34;</span>: 59.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;icon&#34;</span>: <span style="color:#e6db74">&#34;rainy&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;is_precip_local_day_rain_check&#34;</span>: true, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;is_precip_local_yesterday_rain_check&#34;</span>: true, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_count_last_1hr&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_count_last_3hr&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_last_distance&#34;</span>: 12, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_last_distance_msg&#34;</span>: <span style="color:#e6db74">&#34;11 - 13 mi&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;lightning_strike_last_epoch&#34;</span>: 1706394852, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_accum_local_day&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_accum_local_yesterday&#34;</span>: 0.05, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_description&#34;</span>: <span style="color:#e6db74">&#34;Light Rain&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_minutes_local_day&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;precip_minutes_local_yesterday&#34;</span>: 28, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;pressure_trend&#34;</span>: <span style="color:#e6db74">&#34;falling&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;relative_humidity&#34;</span>: 88, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;sea_level_pressure&#34;</span>: 29.899, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;solar_radiation&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;station_pressure&#34;</span>: 29.258, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;time&#34;</span>: 1707618703, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;uv&#34;</span>: 0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wet_bulb_globe_temperature&#34;</span>: 57.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wet_bulb_temperature&#34;</span>: 56.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_avg&#34;</span>: 1.0, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_direction&#34;</span>: 230, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_direction_cardinal&#34;</span>: <span style="color:#e6db74">&#34;SW&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_gust&#34;</span>: 2.0 </span></span><span style="display:flex;"><span><span style="color:#f92672">}</span> <span style="color:#75715e"># [tl! .nocopy:end]</span> </span></span></code></pre></div><p>Piping the response through <code>jq '.current_conditions'</code> works well to select that objects, but I'm still not going to want to display all of that information. After some thought, these are the fields I want to hold on to:</p> <ul> <li><code>air_temperature</code></li> <li><code>conditions</code></li> <li><code>feels_like</code> (apparent air temperature)</li> <li><code>icon</code></li> <li><code>precip_accum_local_day</code> (rainfall total for the day)</li> <li><code>pressure_trend</code> (rising, falling, or steady)</li> <li><code>relative_humidity</code></li> <li><code>sea_level_pressure</code> (the pressure recorded by the station, adjusted for altitude)</li> <li><code>time</code> (<a href="https://en.wikipedia.org/wiki/Unix_time">epoch</a> timestamp of the report)</li> <li><code>wind_direction_cardinal</code> (which way the wind is blowing <em>from</em>)</li> <li><code>wind_gust</code></li> </ul> <p>I can use more <code>jq</code> wizardry to grab only those fields, and I'll also rename a few of the more cumbersome ones and round some of the values where I don't need full decimal precision:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>curl -sL <span style="color:#e6db74">&#34;</span>$endpoint<span style="color:#e6db74">&#34;</span> | jq <span style="color:#e6db74">&#39;.current_conditions | {temperature: (.air_temperature | round), conditions, </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> feels_like: (.feels_like | round), icon, rain_today: .precip_accum_local_day, pressure_trend, </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> humidity: .relative_humidity, pressure: ((.sea_level_pressure * 100) | round | . / 100), time, </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> wind_direction: .wind_direction_cardinal, wind_gust}&#39;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">{</span> <span style="color:#75715e"># [tl! .cmd:-4,1 .nocopy:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;temperature&#34;</span>: 58, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;conditions&#34;</span>: <span style="color:#e6db74">&#34;Very Light Rain&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;feels_like&#34;</span>: 58, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;icon&#34;</span>: <span style="color:#e6db74">&#34;rainy&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;rain_today&#34;</span>: 0.01, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;pressure_trend&#34;</span>: <span style="color:#e6db74">&#34;steady&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;humidity&#34;</span>: 91, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;pressure&#34;</span>: 29.9, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;time&#34;</span>: 1707620142, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_direction&#34;</span>: <span style="color:#e6db74">&#34;W&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;wind_gust&#34;</span>: 0.0 </span></span><span style="display:flex;"><span><span style="color:#f92672">}</span> <span style="color:#75715e"># [tl! .nocopy:end]</span> </span></span></code></pre></div><p>Now I'm just grabbing the specific data points that I plan to use, and I'm renaming messy names like <code>precip_accum_local_day</code> to things like <code>rain_today</code> to make them a bit less unwieldy. I'm also rounding the temperatures to whole numbers<sup id="fnref:1"><a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>, and reducing the pressure from three decimal points to just two.</p> <p>Now that I've got the data I want, I'll just stash it in a local file for safe keeping:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>curl -sL <span style="color:#e6db74">&#34;</span>$endpoint<span style="color:#e6db74">&#34;</span> | jq <span style="color:#e6db74">&#39;.current_conditions | {temperature: (.air_temperature | round), conditions, </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> feels_like: (.feels_like | round), icon, rain_today: .precip_accum_local_day, pressure_trend, </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> humidity: .relative_humidity, pressure: ((.sea_level_pressure * 100) | round | . / 100), time, </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> wind_direction: .wind_direction_cardinal, wind_gust}&#39;</span> <span style="color:#ae81ff">\ </span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> &gt; tempest.json <span style="color:#75715e"># [tl! .cmd:-4,1 **]</span> </span></span></code></pre></div><h3 id="post-to-pastelol">Post to paste.lol</h3> <p>I've been using <a href="https://home.omg.lol/">omg.lol</a> for a couple of months now, and I'm constantly discovering new uses for the bundled services. I thought that the <a href="https://paste.lol/">paste.lol</a> service would be a great fit for this project. For one it was easy to tie it to a custom domain<sup id="fnref:2"><a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>, and it's got an <a href="https://api.omg.lol/#token-post-pastebin-create-or-update-a-paste-in-a-pastebin">easy API</a> that I can use for automating this.</p> <p>To use the API, I'll of course need a token. I can find that at the bottom of my <a href="https://home.omg.lol/account">omg.lol Account</a> page, and I'll once again store that as an environment variable. I can then test out the API by creating a new paste:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>curl -L --request POST --header <span style="color:#e6db74">&#34;Authorization: Bearer </span>$omg_token<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\ </span><span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;https://api.omg.lol/address/jbowdre/pastebin/&#34;</span> <span style="color:#ae81ff">\ </span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> --data <span style="color:#e6db74">&#39;{&#34;title&#34;: &#34;paste-test&#34;, &#34;content&#34;: &#34;Tastes like paste.&#34;}&#39;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">{</span> <span style="color:#75715e"># [tl! .nocopy:9]</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;request&#34;</span>: <span style="color:#f92672">{</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;status_code&#34;</span>: 200, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;success&#34;</span>: true </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;response&#34;</span>: <span style="color:#f92672">{</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;message&#34;</span>: <span style="color:#e6db74">&#34;OK, your paste has been saved. &lt;a href=\&#34;https:\/\/paste.lol\/jbowdre\/paste-test\&#34; target=\&#34;_blank\&#34;&gt;View it live&lt;\/a&gt;.&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;title&#34;</span>: <span style="color:#e6db74">&#34;paste-test&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">}</span> </span></span></code></pre></div><p>And, sure enough, I can view it at my slick custom domain for my pastes, <code>https://paste.jbowdre.lol/paste-test</code></p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/paste-test.png" alt="Simple webpage with the message, Tastes like paste."></p> <p>That page is simple enough, but I'll really want to be sure I can store and retrieve the raw JSON that I captured from the Tempest API. There's a handy button the webpage for that, or I can just append <code>/raw</code> to the URL:</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/raw-paste.png" alt="Plaintext page with the same message"></p> <p>Yep, looks like that will do the trick. One small hurdle, though: I have to send the <code>--data</code> as a JSON object. I already have the JSON file that I pulled from the Tempest API, but I'll need to wrap that inside another layer of JSON. Fortunately, <code>jq</code> can come to the rescue once more.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>request_body<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;{&#34;title&#34;: &#34;tempest.json&#34;, &#34;content&#34;: &#39;</span><span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>jq -Rsa . tempest.json<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">&#39;}&#39;</span> <span style="color:#75715e"># [tl! .cmd]</span> </span></span></code></pre></div><p>The <code>jq</code> command here reads the <code>tempest.json</code> file as plaintext (not as a JSON object), and then formats it as a JSON string so that it can be wrapped in the request body JSON:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>jq -Rsa <span style="color:#e6db74">&#39;.&#39;</span> tempest.json <span style="color:#75715e"># [tl! .cmd .nocopy:1]</span> </span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;{\n \&#34;temperature\&#34;: 58,\n \&#34;conditions\&#34;: \&#34;Heavy Rain\&#34;,\n \&#34;feels_like\&#34;: 58,\n \&#34;icon\&#34;: \&#34;rainy\&#34;,\n \&#34;rain_today\&#34;: 0.05,\n \&#34;pressure_trend\&#34;: \&#34;steady\&#34;,\n \&#34;humidity\&#34;: 93,\n \&#34;pressure\&#34;: 29.89,\n \&#34;time\&#34;: 1707620863,\n \&#34;wind_direction\&#34;: \&#34;S\&#34;,\n \&#34;wind_gust\&#34;: 1.0\n}\n&#34;</span> </span></span></code></pre></div><p>So then I can repeat the earlier <code>curl</code> but this time pass in <code>$request_body</code> to include the file contents:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>curl -L --request POST --header <span style="color:#e6db74">&#34;Authorization: Bearer </span>$omg_token<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\ </span><span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;https://api.omg.lol/address/jbowdre/pastebin/&#34;</span> <span style="color:#ae81ff">\ </span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span> --data <span style="color:#e6db74">&#34;</span>$request_body<span style="color:#e6db74">&#34;</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">{</span> <span style="color:#75715e"># [tl! .nocopy:9]</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;request&#34;</span>: <span style="color:#f92672">{</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;status_code&#34;</span>: 200, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;success&#34;</span>: true </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;response&#34;</span>: <span style="color:#f92672">{</span> </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;message&#34;</span>: <span style="color:#e6db74">&#34;OK, your paste has been saved. &lt;a href=\&#34;https:\/\/paste.lol\/jbowdre\/tempest.json\&#34; target=\&#34;_blank\&#34;&gt;View it live&lt;\/a&gt;.&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#34;title&#34;</span>: <span style="color:#e6db74">&#34;tempest.json&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">}</span> </span></span></code></pre></div><p>And there it is, at <code>https://paste.jbowdre.lol/tempest.json/raw</code>:</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/raw-tempest.png" alt="Plaintext weather data in JSON format"></p> <h3 id="automate-with-github-actions">Automate with GitHub Actions</h3> <p>At this point, I know the commands needed to retrieve weather data from the Tempest API, and I know what will be needed to post it to the omg.lol pastebin. The process works, but now it's time to automate it. And I'll do that with a simple <a href="https://docs.github.com/en/actions">GitHub Actions workflow</a>.</p> <p>I create a <a href="https://github.com/jbowdre/lolz">new GitHub repo</a> to store this (and future?) omg.lol sorcery, and navigate to <strong>Settings &gt; Secrets and variables &gt; Actions</strong>. I'll create four new repository secrets to hold my variables:</p> <ul> <li><code>OMG_ADDR</code></li> <li><code>OMG_TOKEN</code></li> <li><code>TEMPEST_STATION</code></li> <li><code>TEMPEST_TOKEN</code></li> </ul> <p>And I'll create a new file at <code>.github/workflows/tempest.yml</code> to define my new workflow. Here's the start:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">Tempest Update</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">on</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">schedule</span>: </span></span><span style="display:flex;"><span> - <span style="color:#f92672">cron</span>: <span style="color:#e6db74">&#34;*/5 * * * *&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">workflow_dispatch</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">push</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">branches</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">main</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">defaults</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">shell</span>: <span style="color:#ae81ff">bash</span> </span></span></code></pre></div><p>The <code>on</code> block defines when the workflow will run:</p> <ol> <li>On a schedule which fires (roughly<sup id="fnref:3"><a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>) every five minutes</li> <li>On a <code>workflow_dispatch</code> event (so I can start it manually if I want)</li> <li>When changes are pushed to the <code>main</code> branch</li> </ol> <p>And the <code>defaults</code> block makes sure that the following scripts will be run in <code>bash</code>:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">Tempest Update</span> <span style="color:#75715e"># [tl! collapse:start]</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">on</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">schedule</span>: </span></span><span style="display:flex;"><span> - <span style="color:#f92672">cron</span>: <span style="color:#e6db74">&#34;*/5 * * * *&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">workflow_dispatch</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">push</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">branches</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">main</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">defaults</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">shell</span>: <span style="color:#ae81ff">bash</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># [tl! collapse:end]</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">jobs</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">fetch-and-post-tempest</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">runs-on</span>: <span style="color:#ae81ff">ubuntu-latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">steps</span>: </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Fetch Tempest API data</span> <span style="color:#75715e"># [tl! **:2,3]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: |<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> curl -sL &#34;https://swd.weatherflow.com/swd/rest/better_forecast?station_id=${{ secrets.TEMPEST_STATION }}&amp;token=${{ secrets.TEMPEST_TOKEN }}&amp;units_temp=f&amp;units_wind=mph&amp;units_pressure=inhg&amp;units_precip=in&amp;units_distance=mi&#34; \ </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> | jq &#39;.current_conditions | {temperature: (.air_temperature | round), conditions, feels_like: (.feels_like | round), icon, rain_today: .precip_accum_local_day, pressure_trend, humidity: .relative_humidity, pressure: ((.sea_level_pressure * 100) | round | . / 100), time, wind_direction: .wind_direction_cardinal, wind_gust}&#39; \ </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> &gt; tempest.json</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">POST to paste.lol</span> <span style="color:#75715e"># [tl! **:2,4]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: |<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> request_body=&#39;{&#34;title&#34;: &#34;tempest.json&#34;, &#34;content&#34;: &#39;&#34;$(jq -Rsa . tempest.json)&#34;&#39;}&#39; </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> curl --location --request POST --header &#34;Authorization: Bearer ${{ secrets.OMG_TOKEN }}&#34; \ </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> &#34;https://api.omg.lol/address/${{ secrets.OMG_ADDR }}/pastebin/&#34; \ </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> --data &#34;$request_body&#34;</span> </span></span></code></pre></div><p>Each step in the <code>jobs</code> section should look pretty familiar since those are almost exactly the commands that I used earlier. The only real difference is that they now use the format <code>${{ secrets.SECRET_NAME }}</code> to pull in the repository secrets I just created.</p> <p>Once I save, commit, and push this new file to the repo, it will automatically execute, and I can go to the <a href="https://github.com/jbowdre/lolz/actions">Actions</a> tab to confirm that the run was succesful. I can also check <code>https://paste.jbowdre.lol/tempest.json</code> to confirm that the contents have updated.</p> <h3 id="show-weather-on-homepage">Show Weather on Homepage</h3> <p>By this point, I've fetched the weather data from Tempest, filtered it for just the fields I want to see, and posted the condensed JSON to a publicly-accessible pastebin. Now I just need to figure out how to make it show up on my omg.lol homepage. Rather than implementing these changes on the public site right away, I'll start with a barebones <code>tempest.html</code> that I can test with locally. Here's the initial structure that I'll be building from:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-html" data-lang="html"><span style="display:flex;"><span><span style="color:#75715e">&lt;!-- torchlight! {&#34;lineNumbers&#34;: true} --&gt;</span> </span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;!DOCTYPE html&gt;</span> </span></span><span style="display:flex;"><span>&lt;<span style="color:#f92672">html</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">head</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">meta</span> <span style="color:#a6e22e">charset</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;utf-8&#34;</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">link</span> <span style="color:#a6e22e">rel</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;stylesheet&#34;</span> <span style="color:#a6e22e">href</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.2/css/all.min.css&#34;</span> <span style="color:#a6e22e">integrity</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;sha512-1sCRPdkRXhBV2PBLUdRb4tMg1w2YPf37qatUFeS7zlBy7jJI8Lf4VHwWfZZfpXtYSLy85pkm9GaYVYMfw5BC1A==&#34;</span> <span style="color:#a6e22e">crossorigin</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;anonymous&#34;</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">title</span>&gt;Weather Test&lt;/<span style="color:#f92672">title</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">script</span>&gt; </span></span><span style="display:flex;"><span> <span style="color:#75715e">// TODO </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> &lt;/<span style="color:#f92672">script</span>&gt; </span></span><span style="display:flex;"><span> &lt;/<span style="color:#f92672">head</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">body</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">h1</span>&gt;Local Weather&lt;/<span style="color:#f92672">h1</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">ul</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">li</span>&gt;Conditions: &lt;<span style="color:#f92672">i</span> <span style="color:#a6e22e">class</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#39;fa-solid fa-cloud-sun-rain&#39;</span>&gt;&lt;/<span style="color:#f92672">i</span>&gt;&lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;conditions&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;&lt;/<span style="color:#f92672">li</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">li</span>&gt;Temperature: &lt;<span style="color:#f92672">i</span> <span style="color:#a6e22e">class</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#39;fa-solid fa-temperature-half&#39;</span>&gt;&lt;/<span style="color:#f92672">i</span>&gt; &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;temp&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;&lt;/<span style="color:#f92672">li</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">li</span>&gt;Humidity: &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;humidity&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;&lt;/<span style="color:#f92672">li</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">li</span>&gt;Wind: &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;wind&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;&lt;/<span style="color:#f92672">li</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">li</span>&gt;Precipitation: &lt;<span style="color:#f92672">i</span> <span style="color:#a6e22e">class</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#39;fa-solid fa-droplet-slash&#39;</span>&gt;&lt;/<span style="color:#f92672">i</span>&gt;&lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;rainToday&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;&lt;/<span style="color:#f92672">li</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">li</span>&gt;Pressure: &lt;<span style="color:#f92672">i</span> <span style="color:#a6e22e">class</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#39;fa-solid fa-arrow-right-long&#39;</span>&gt;&lt;/<span style="color:#f92672">i</span>&gt;&lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;pressure&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;&lt;/<span style="color:#f92672">li</span>&gt; </span></span><span style="display:flex;"><span> &lt;<span style="color:#f92672">li</span>&gt;&lt;<span style="color:#f92672">i</span>&gt;Last Update: &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;time&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;&lt;/<span style="color:#f92672">i</span>&gt;&lt;/<span style="color:#f92672">li</span>&gt; </span></span><span style="display:flex;"><span> &lt;/<span style="color:#f92672">body</span>&gt; </span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">html</span>&gt; </span></span></code></pre></div><p>In the <code>&lt;head&gt;</code>, I'm specifying the character set so that I won't get any weird reactions when using exotic symbols like <code>°</code>, and I'm also pulling in the <a href="https://fontawesome.com">Font Awesome</a> stylesheet so I can easily use those icons in the weather display. The <code>&lt;body&gt;</code> just holds empty <code>&lt;span&gt;</code>s that will later be populated with data via JavaScript.</p> <p>I only included a few icons here. I'm going to try to make those icons dynamic so that they'll change depending on how hot/cold it is or what the pressure trend is doing. I don't plan to toy with the humidity, wind, or last update icons (yet) so I'm not going to bother with testing those locally.</p> <p>With that framework in place, I can use <code>python3 -m http.server 8080</code> to serve the page at <code>http://localhost:8080/tempest.html</code>.</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/local-test-blank.png" alt="Simple local web page showing placeholders for weather information"></p> <p>It's rather sparse, but it works. I won't be tinkering with any of that HTML from this point on, I'll just focus on the (currently-empty) <code>&lt;script&gt;</code> block. I don't think I'm super great with JavaScript, so I leaned heavily upon the code on <a href="https://kris.omg.lol">Kris's page</a> to get started and then put my own spin on it as I went along.</p> <p>Before I can do much of anything, though, I'll need to start by retrieving the JSON data from my paste.lol endpoint and parsing the data:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#75715e">// torchlight! {&#34;lineNumbers&#34;: true} </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">wx_endpoint</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;https://paste.jbowdre.lol/tempest.json/raw&#39;</span>; <span style="color:#75715e">// [tl! reindex(8)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e">// fetch data from pastebin </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">wx_endpoint</span>) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">res</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">json</span>()) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#66d9ef">function</span>(<span style="color:#a6e22e">res</span>){ </span></span><span style="display:flex;"><span> <span style="color:#75715e">// parse data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">conditions</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">conditions</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">humidity</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">humidity</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">wind</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressure</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure</span>; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// display data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;conditions&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">conditions</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;temp&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">temp</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;humidity&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">humidity</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;wind&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">wind</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;rainToday&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">rainToday</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;pressure&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">pressure</span>; </span></span><span style="display:flex;"><span> }); </span></span></code></pre></div><p>So this is fetching the data from the pastebin, mapping variables to the JSON fields, and using <code>document.getElementById()</code> to select the placeholder <code>&lt;span&gt;</code>s by their id and replace the corresponding <code>innerHTML</code> with the appropriate values.</p> <p>The result isn't very pretty, but it's a start:</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/local-test-raw.png" alt="Simple local web page showing basic weather information without any units, and the timestamp is in Unix format showing the number of seconds since January 1, 1970"></p> <p>That <a href="https://en.wikipedia.org/wiki/Unix_time">Unix epoch timestamp</a> stands out as being particularly unwieldy. Instead of the exact timestamp, I'd prefer to show that as the relative time since the last update. I'll do that with the <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat"><code>Intl.RelativeTimeFormat</code> object</a>.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#75715e">// torchlight! {&#34;lineNumbers&#34;: true} </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">wx_endpoint</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;https://paste.jbowdre.lol/tempest.json/raw&#39;</span>; <span style="color:#75715e">// [tl! reindex(8)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e">// get ready to calculate relative time [tl! ++:start **:start] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">units</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">year</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">month</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span><span style="color:#f92672">/</span><span style="color:#ae81ff">12</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">day</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">hour</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">minute</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">second</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1000</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rtf</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Intl</span>.<span style="color:#a6e22e">RelativeTimeFormat</span>(<span style="color:#e6db74">&#39;en&#39;</span>, { <span style="color:#a6e22e">numeric</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;auto&#39;</span> }) </span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">getRelativeTime</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">d1</span>, <span style="color:#a6e22e">d2</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date()) =&gt; { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">elapsed</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">d1</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">d2</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">u</span> <span style="color:#66d9ef">in</span> <span style="color:#a6e22e">units</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (Math.<span style="color:#a6e22e">abs</span>(<span style="color:#a6e22e">elapsed</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>] <span style="color:#f92672">||</span> <span style="color:#a6e22e">u</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;second&#39;</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">rtf</span>.<span style="color:#a6e22e">format</span>(Math.<span style="color:#a6e22e">round</span>(<span style="color:#a6e22e">elapsed</span><span style="color:#f92672">/</span><span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>]), <span style="color:#a6e22e">u</span>) </span></span><span style="display:flex;"><span>} <span style="color:#75715e">// [tl! ++:end **:end] </span></span></span><span style="display:flex;"><span><span style="color:#75715e">// fetch data from pastebin </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">wx_endpoint</span>) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">res</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">json</span>()) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#66d9ef">function</span>(<span style="color:#a6e22e">res</span>){ </span></span><span style="display:flex;"><span> <span style="color:#75715e">// calculate age of last update [tl! ++:start **:start] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> parseInt(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1000</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateAge</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">getRelativeTime</span>(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// parse data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">conditions</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">conditions</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">humidity</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">humidity</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">wind</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressure</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure</span>; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// display data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateAge</span>; <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;conditions&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">conditions</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;temp&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">temp</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;humidity&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">humidity</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;wind&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">wind</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;rainToday&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">rainToday</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;pressure&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">pressure</span>; </span></span><span style="display:flex;"><span> }); </span></span></code></pre></div><p>That's a bit more friendly:</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/local-test-reltime.png" alt="The same simple weather display but the last update is now &quot;2 minutes ago&quot;"></p> <p>Alright, let's massage some of the other fields a little. I'll add units and labels, translate temperature and speed values to metric for my international friends, and combine a few additional fields for more context. I'm also going to make sure everything is lowercase to match the, uh, <em>aesthetic</em> of my omg page.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#75715e">// torchlight! {&#34;lineNumbers&#34;: true} </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">wx_endpoint</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;https://paste.jbowdre.lol/tempest.json/raw&#39;</span>; <span style="color:#75715e">// [tl! reindex(8) collapse:start] </span></span></span><span style="display:flex;"><span><span style="color:#75715e">// get ready to calculate relative time </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">units</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">year</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">month</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span><span style="color:#f92672">/</span><span style="color:#ae81ff">12</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">day</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">hour</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">minute</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">second</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1000</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rtf</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Intl</span>.<span style="color:#a6e22e">RelativeTimeFormat</span>(<span style="color:#e6db74">&#39;en&#39;</span>, { <span style="color:#a6e22e">numeric</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;auto&#39;</span> }) </span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">getRelativeTime</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">d1</span>, <span style="color:#a6e22e">d2</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date()) =&gt; { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">elapsed</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">d1</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">d2</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">u</span> <span style="color:#66d9ef">in</span> <span style="color:#a6e22e">units</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (Math.<span style="color:#a6e22e">abs</span>(<span style="color:#a6e22e">elapsed</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>] <span style="color:#f92672">||</span> <span style="color:#a6e22e">u</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;second&#39;</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">rtf</span>.<span style="color:#a6e22e">format</span>(Math.<span style="color:#a6e22e">round</span>(<span style="color:#a6e22e">elapsed</span><span style="color:#f92672">/</span><span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>]), <span style="color:#a6e22e">u</span>) </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#75715e">// fetch data from pastebin </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">wx_endpoint</span>) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">res</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">json</span>()) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#66d9ef">function</span>(<span style="color:#a6e22e">res</span>){ </span></span><span style="display:flex;"><span> <span style="color:#75715e">// calculate age of last update </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> parseInt(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1000</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateAge</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">getRelativeTime</span>(<span style="color:#a6e22e">updateTime</span>); <span style="color:#75715e">// [tl! collapse:end] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// parse data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">conditions</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">conditions</span>).<span style="color:#a6e22e">toLowerCase</span>(); <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">conditions</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">conditions</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span><span style="color:#e6db74">}</span><span style="color:#e6db74">°f (</span><span style="color:#e6db74">${</span>(((<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">32</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">5</span>) <span style="color:#f92672">/</span> <span style="color:#ae81ff">9</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">°c)`</span>; <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">humidity</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">humidity</span><span style="color:#e6db74">}</span><span style="color:#e6db74">% humidity`</span>; <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">humidity</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">humidity</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">wind</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span><span style="color:#e6db74">}</span><span style="color:#e6db74">mph (</span><span style="color:#e6db74">${</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1.609344</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">kph) from </span><span style="color:#e6db74">${</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_direction</span>).<span style="color:#a6e22e">toLowerCase</span>()<span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>; <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">wind</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34; rain today`</span>; <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressureTrend</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure_trend</span>; <span style="color:#75715e">// [tl! ++:1 **:1] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressure</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure</span><span style="color:#e6db74">}</span><span style="color:#e6db74">inhg and </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">pressureTrend</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressure</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// display data [tl! collapse:start] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateAge</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;conditions&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">conditions</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;temp&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">temp</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;humidity&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">humidity</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;wind&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">wind</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;rainToday&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">rainToday</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;pressure&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">pressure</span>; </span></span><span style="display:flex;"><span> }); <span style="color:#75715e">// [tl! collapse:end] </span></span></span></code></pre></div><p>That's a bit better:</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/local-test-strings.png" alt="The sample webpage showing formatted strings for each weather item"></p> <p>Now let's add some conditionals into the mix. I'd like to display the <code>feels_like</code> temperature but only if it's five or more degrees away from the actual air temperature. And if there hasn't been any rain, the page should show <code>no rain today</code> instead of <code>0.00&quot; rain today</code>.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#75715e">// torchlight! {&#34;lineNumbers&#34;: true} </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">wx_endpoint</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;https://paste.jbowdre.lol/tempest.json/raw&#39;</span>; <span style="color:#75715e">// [tl! reindex(8) collapse:start] </span></span></span><span style="display:flex;"><span><span style="color:#75715e">// get ready to calculate relative time </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">units</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">year</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">month</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span><span style="color:#f92672">/</span><span style="color:#ae81ff">12</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">day</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">hour</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">minute</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">second</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1000</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rtf</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Intl</span>.<span style="color:#a6e22e">RelativeTimeFormat</span>(<span style="color:#e6db74">&#39;en&#39;</span>, { <span style="color:#a6e22e">numeric</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;auto&#39;</span> }) </span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">getRelativeTime</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">d1</span>, <span style="color:#a6e22e">d2</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date()) =&gt; { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">elapsed</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">d1</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">d2</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">u</span> <span style="color:#66d9ef">in</span> <span style="color:#a6e22e">units</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (Math.<span style="color:#a6e22e">abs</span>(<span style="color:#a6e22e">elapsed</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>] <span style="color:#f92672">||</span> <span style="color:#a6e22e">u</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;second&#39;</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">rtf</span>.<span style="color:#a6e22e">format</span>(Math.<span style="color:#a6e22e">round</span>(<span style="color:#a6e22e">elapsed</span><span style="color:#f92672">/</span><span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>]), <span style="color:#a6e22e">u</span>) </span></span><span style="display:flex;"><span>} <span style="color:#75715e">// </span></span></span><span style="display:flex;"><span><span style="color:#75715e">// fetch data from pastebin </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">wx_endpoint</span>) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">res</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">json</span>()) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#66d9ef">function</span>(<span style="color:#a6e22e">res</span>){ </span></span><span style="display:flex;"><span> <span style="color:#75715e">// calculate age of last update </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> parseInt(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1000</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateAge</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">getRelativeTime</span>(<span style="color:#a6e22e">updateTime</span>); <span style="color:#75715e">// [tl! collapse:end] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// parse data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">conditions</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">conditions</span>).<span style="color:#a6e22e">toLowerCase</span>(); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">tempDiff</span> <span style="color:#f92672">=</span> Math.<span style="color:#a6e22e">abs</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span>); <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span><span style="color:#e6db74">}</span><span style="color:#e6db74">°f (</span><span style="color:#e6db74">${</span>(((<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">32</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">5</span>) <span style="color:#f92672">/</span> <span style="color:#ae81ff">9</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">°c)`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">tempDiff</span> <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">5</span>) { <span style="color:#75715e">// [tl! ++:2 **:2] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">+=</span> <span style="color:#e6db74">`, feels </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span><span style="color:#e6db74">}</span><span style="color:#e6db74">°f (</span><span style="color:#e6db74">${</span>(((<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">32</span>) <span style="color:#f92672">*</span><span style="color:#ae81ff">5</span>) <span style="color:#f92672">/</span> <span style="color:#ae81ff">9</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">°c)`</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">humidity</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">humidity</span><span style="color:#e6db74">}</span><span style="color:#e6db74">% humidity`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">wind</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span><span style="color:#e6db74">}</span><span style="color:#e6db74">mph (</span><span style="color:#e6db74">${</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1.609344</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">kph) from </span><span style="color:#e6db74">${</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_direction</span>).<span style="color:#a6e22e">toLowerCase</span>()<span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span>; <span style="color:#75715e">// [tl! ++:5 **:5] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">0</span>) { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;no rain today&#39;</span>; </span></span><span style="display:flex;"><span> } <span style="color:#66d9ef">else</span> { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34; rain today`</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34; rain today`</span>; <span style="color:#75715e">// [tl! -- reindex(-1)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressureTrend</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure_trend</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressure</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure</span><span style="color:#e6db74">}</span><span style="color:#e6db74">inhg and </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">pressureTrend</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// display data [tl! collapse:start] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateAge</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;conditions&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">conditions</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;temp&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">temp</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;humidity&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">humidity</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;wind&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">wind</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;rainToday&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">rainToday</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;pressure&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">pressure</span>; </span></span><span style="display:flex;"><span> }); <span style="color:#75715e">// [tl! collapse:end] </span></span></span></code></pre></div><p>For testing this, I can manually alter<sup id="fnref:4"><a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup> the contents of the pastebin to ensure the values are what I need:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#75715e">// torchlight! {&#34;lineNumbers&#34;: true} </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>{ </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;temperature&#34;</span>: <span style="color:#ae81ff">57</span>, <span style="color:#75715e">// [tl! **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">&#34;conditions&#34;</span>: <span style="color:#e6db74">&#34;Rain Likely&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;feels_like&#34;</span>: <span style="color:#ae81ff">67</span>, <span style="color:#75715e">// [tl! **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">&#34;icon&#34;</span>: <span style="color:#e6db74">&#34;rainy&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;rain_today&#34;</span>: <span style="color:#ae81ff">0</span>, <span style="color:#75715e">// [tl! **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#f92672">&#34;pressure_trend&#34;</span>: <span style="color:#e6db74">&#34;steady&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;humidity&#34;</span>: <span style="color:#ae81ff">92</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;pressure&#34;</span>: <span style="color:#ae81ff">29.91</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;time&#34;</span>: <span style="color:#ae81ff">1707676590</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;wind_direction&#34;</span>: <span style="color:#e6db74">&#34;WSW&#34;</span>, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;wind_gust&#34;</span>: <span style="color:#ae81ff">2</span> </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/local-test-conditional.png" alt="Local test page reflecting &quot;57°f (13.9°c), feels 67°f (19.4°c)&quot; and &quot;no rain today&quot;"></p> <p>The final touch will be to change the icons depending on the values of certain fields:</p> <ul> <li>The conditions icon will vary from <i class='fa-solid fa-cloud-sun'></i> to <i class='fa-solid fa-cloud-showers-heavy'></i>, roughly corresponding to the <code>icon</code> value from the Tempest API.</li> <li>The temperature icon will range from <i class='fa-solid fa-thermometer-empty'></i> to <i class='fa-solid fa-thermometer-full'></i> depending on the temperature.</li> <li>The precipitation icon will be <i class='fa-solid fa-droplet-slash'></i> for no precipitation, and progress through <i class='fa-solid fa-glass-water-droplet'></i> and <i class='fa-solid fa-glass-water'></i> for a bit more rain, and cap out at <i class='fa-solid fa-bucket'></i> for lots of rain<sup id="fnref:5"><a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup>.</li> <li>The pressure icon should change based on the trend: <i class='fa-solid fa-arrow-trend-up'></i>, <i class='fa-solid fa-arrow-right-long'></i>, and <i class='fa-solid fa-arrow-trend-down'></i>.</li> </ul> <p>I'll set up a couple of constants to define the ranges, and a few more for mapping the values to icons. Then I'll use the <code>Array.prototype.find</code> method to select the appropriate <code>upper</code> value from each range. And then I leverage <code>document.getElementsByClassName</code> to match the placeholder icon classes and dynamically replace them.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#75715e">// torchlight! {&#34;lineNumbers&#34;: true} </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">wx_endpoint</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;https://paste.jbowdre.lol/tempest.json/raw&#39;</span>; <span style="color:#75715e">// [tl! reindex(8) collapse:start] </span></span></span><span style="display:flex;"><span><span style="color:#75715e">// get ready to calculate relative time </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">units</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">year</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">month</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span><span style="color:#f92672">/</span><span style="color:#ae81ff">12</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">day</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">hour</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">minute</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">second</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1000</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rtf</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Intl</span>.<span style="color:#a6e22e">RelativeTimeFormat</span>(<span style="color:#e6db74">&#39;en&#39;</span>, { <span style="color:#a6e22e">numeric</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;auto&#39;</span> }) </span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#a6e22e">getRelativeTime</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">d1</span>, <span style="color:#a6e22e">d2</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date()) =&gt; { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">elapsed</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">d1</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">d2</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">u</span> <span style="color:#66d9ef">in</span> <span style="color:#a6e22e">units</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (Math.<span style="color:#a6e22e">abs</span>(<span style="color:#a6e22e">elapsed</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>] <span style="color:#f92672">||</span> <span style="color:#a6e22e">u</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;second&#39;</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">rtf</span>.<span style="color:#a6e22e">format</span>(Math.<span style="color:#a6e22e">round</span>(<span style="color:#a6e22e">elapsed</span><span style="color:#f92672">/</span><span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>]), <span style="color:#a6e22e">u</span>) </span></span><span style="display:flex;"><span>} <span style="color:#75715e">// [tl! collapse:end] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> </span></span><span style="display:flex;"><span><span style="color:#75715e">// set up temperature and rain ranges [tl! ++:start **:start] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">tempRanges</span> <span style="color:#f92672">=</span> [ </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">32</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;cold&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;cool&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">80</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;warm&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">Infinity</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;hot&#39;</span> } </span></span><span style="display:flex;"><span>]; </span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">rainRanges</span> <span style="color:#f92672">=</span> [ </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0.02</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;none&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0.2</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;light&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1.4</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;moderate&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">Infinity</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;heavy&#39;</span> } </span></span><span style="display:flex;"><span>] </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#75715e">// maps for selecting icons </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">CLASS_MAP_PRESS</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;steady&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-arrow-right-long&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;rising&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-arrow-trend-up&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;falling&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-arrow-trend-down&#39;</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">CLASS_MAP_RAIN</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;none&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-droplet-slash&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;light&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-glass-water-droplet&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;moderate&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-glass-water&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;heavy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-bucket&#39;</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">CLASS_MAP_TEMP</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;hot&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-thermometer-full&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;warm&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-thermometer-half&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;cool&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-thermometer-quarter&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;cold&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-thermometer-empty&#39;</span> </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">CLASS_MAP_WX</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;clear-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-sun&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;clear-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-moon&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;cloudy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;foggy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-showers-smog&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;partly-cloudy-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-sun&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;partly-cloudy-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-moon&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-rainy-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-sun-rain&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-rainy-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-moon-rain&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-sleet-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-meatball&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-sleet-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-moon-rain&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-snow-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-snowflake&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-snow-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-snowflake&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-thunderstorm-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-bolt&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-thunderstorm-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-bolt&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;rainy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-showers-heavy&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;sleet&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-rain&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;snow&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-snowflake&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;thunderstorm&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-bolt&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;windy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-wind&#39;</span>, </span></span><span style="display:flex;"><span>} <span style="color:#75715e">// [tl! ++:end **:end] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> </span></span><span style="display:flex;"><span><span style="color:#75715e">// fetch data from pastebin [tl! collapse:10] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">wx_endpoint</span>) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">res</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">json</span>()) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#66d9ef">function</span>(<span style="color:#a6e22e">res</span>){ </span></span><span style="display:flex;"><span> <span style="color:#75715e">// calculate age of last update </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> parseInt(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1000</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateAge</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">getRelativeTime</span>(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// parse data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">conditions</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">conditions</span>).<span style="color:#a6e22e">toLowerCase</span>(); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">tempDiff</span> <span style="color:#f92672">=</span> Math.<span style="color:#a6e22e">abs</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span>); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span><span style="color:#e6db74">}</span><span style="color:#e6db74">°f (</span><span style="color:#e6db74">${</span>(((<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">32</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">5</span>) <span style="color:#f92672">/</span> <span style="color:#ae81ff">9</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">°c)`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">tempDiff</span> <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">5</span>) { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">+=</span> <span style="color:#e6db74">`, feels </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span><span style="color:#e6db74">}</span><span style="color:#e6db74">°f (</span><span style="color:#e6db74">${</span>(((<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">32</span>) <span style="color:#f92672">*</span><span style="color:#ae81ff">5</span>) <span style="color:#f92672">/</span> <span style="color:#ae81ff">9</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">°c)`</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">tempLabel</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">tempRanges</span>.<span style="color:#a6e22e">find</span>(<span style="color:#a6e22e">range</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span> <span style="color:#f92672">&lt;</span> <span style="color:#a6e22e">range</span>.<span style="color:#a6e22e">upper</span>)).<span style="color:#a6e22e">label</span>; <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">humidity</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">humidity</span><span style="color:#e6db74">}</span><span style="color:#e6db74">% humidity`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">wind</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span><span style="color:#e6db74">}</span><span style="color:#e6db74">mph (</span><span style="color:#e6db74">${</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1.609344</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">kph) from </span><span style="color:#e6db74">${</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_direction</span>).<span style="color:#a6e22e">toLowerCase</span>()<span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainLabel</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">rainRanges</span>.<span style="color:#a6e22e">find</span>(<span style="color:#a6e22e">range</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span> <span style="color:#f92672">&lt;</span> <span style="color:#a6e22e">range</span>.<span style="color:#a6e22e">upper</span>)).<span style="color:#a6e22e">label</span>; <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">0</span>) { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;no rain today&#39;</span>; </span></span><span style="display:flex;"><span> } <span style="color:#66d9ef">else</span> { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34; rain today`</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34; rain today`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressureTrend</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure_trend</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressure</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure</span><span style="color:#e6db74">}</span><span style="color:#e6db74">inhg and </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">pressureTrend</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">icon</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">icon</span>; <span style="color:#75715e">// [tl! ++ **] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// display data [tl! collapse:8] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateAge</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;conditions&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">conditions</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;temp&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">temp</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;humidity&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">humidity</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;wind&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">wind</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;rainToday&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">rainToday</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;pressure&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">pressure</span>; </span></span><span style="display:flex;"><span> <span style="color:#75715e">// update icons [tl! ++:4 **:4] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementsByClassName</span>(<span style="color:#e6db74">&#39;fa-cloud-sun-rain&#39;</span>)[<span style="color:#ae81ff">0</span>].<span style="color:#a6e22e">classList</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">CLASS_MAP_WX</span>[<span style="color:#a6e22e">icon</span>]; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementsByClassName</span>(<span style="color:#e6db74">&#39;fa-temperature-half&#39;</span>)[<span style="color:#ae81ff">0</span>].<span style="color:#a6e22e">classList</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">CLASS_MAP_TEMP</span>[<span style="color:#a6e22e">tempLabel</span>]; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementsByClassName</span>(<span style="color:#e6db74">&#39;fa-droplet-slash&#39;</span>)[<span style="color:#ae81ff">0</span>].<span style="color:#a6e22e">classList</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">CLASS_MAP_RAIN</span>[<span style="color:#a6e22e">rainLabel</span>]; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementsByClassName</span>(<span style="color:#e6db74">&#39;fa-arrow-right-long&#39;</span>)[<span style="color:#ae81ff">0</span>].<span style="color:#a6e22e">classList</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">CLASS_MAP_PRESS</span>[<span style="color:#a6e22e">pressureTrend</span>]; </span></span><span style="display:flex;"><span> }); </span></span></code></pre></div><p>And I can see that the icons do indeed match the conditions:</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/local-test-dynamic-icons.png" alt="Local test page with matching icons"></p> <h3 id="omg-lol-lets-do-it">OMG, LOL, Let's Do It</h3> <p>Now that all that prep work is out of the way, transposing the code into the live omg.lol homepage doesn't really take much effort.</p> <p>I'll just use the online editor to drop this at the bottom of my web page:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-markdown" data-lang="markdown"><span style="display:flex;"><span>// torchlight! {&#34;lineNumbers&#34;:true} </span></span><span style="display:flex;"><span># /proc/wx &lt;!-- [tl! reindex(47)] --&gt; </span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;conditions&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt; {cloud-sun-rain} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;temp&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt; {temperature-half} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;humidity&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt; {droplet} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;wind&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt; {wind} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;rainToday&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt; {droplet-slash} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;pressure&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt; {arrow-right-long} </span></span><span style="display:flex;"><span><span style="color:#66d9ef">-</span> &lt;<span style="color:#f92672">i</span>&gt;as of &lt;<span style="color:#f92672">span</span> <span style="color:#a6e22e">id</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;time&#34;</span>&gt;&lt;/<span style="color:#f92672">span</span>&gt;&lt;/<span style="color:#f92672">i</span>&gt; {clock} </span></span></code></pre></div><p>A bit further down the editor page, there's a box for inserting additional content into the page's <code>&lt;head&gt;</code> block. That's a great place to past in the entirety of my script:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>// torchlight! {&#34;lineNumbers&#34;:true} </span></span><span style="display:flex;"><span>&lt;<span style="color:#f92672">script</span>&gt; <span style="color:#75715e">// [tl! reindex(3)] </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">wx_endpoint</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;https://paste.jbowdre.lol/tempest.json/raw&#39;</span>; </span></span><span style="display:flex;"><span> <span style="color:#75715e">// get ready to calculate relative time </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">units</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">year</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">month</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">365</span><span style="color:#f92672">/</span><span style="color:#ae81ff">12</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">day</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">24</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">hour</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">minute</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1000</span>, </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">second</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1000</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rtf</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Intl</span>.<span style="color:#a6e22e">RelativeTimeFormat</span>(<span style="color:#e6db74">&#39;en&#39;</span>, { <span style="color:#a6e22e">numeric</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;auto&#39;</span> }) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">getRelativeTime</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">d1</span>, <span style="color:#a6e22e">d2</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date()) =&gt; { </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">elapsed</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">d1</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">d2</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">var</span> <span style="color:#a6e22e">u</span> <span style="color:#66d9ef">in</span> <span style="color:#a6e22e">units</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (Math.<span style="color:#a6e22e">abs</span>(<span style="color:#a6e22e">elapsed</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>] <span style="color:#f92672">||</span> <span style="color:#a6e22e">u</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;second&#39;</span>) </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">rtf</span>.<span style="color:#a6e22e">format</span>(Math.<span style="color:#a6e22e">round</span>(<span style="color:#a6e22e">elapsed</span><span style="color:#f92672">/</span><span style="color:#a6e22e">units</span>[<span style="color:#a6e22e">u</span>]), <span style="color:#a6e22e">u</span>) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// set up temperature and rain ranges </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">tempRanges</span> <span style="color:#f92672">=</span> [ </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">32</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;cold&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">60</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;cool&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">80</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;warm&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">Infinity</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;hot&#39;</span> } </span></span><span style="display:flex;"><span> ]; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">rainRanges</span> <span style="color:#f92672">=</span> [ </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0.02</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;none&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0.2</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;light&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1.4</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;moderate&#39;</span> }, </span></span><span style="display:flex;"><span> { <span style="color:#a6e22e">upper</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">Infinity</span>, <span style="color:#a6e22e">label</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;heavy&#39;</span> } </span></span><span style="display:flex;"><span> ] </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// maps for selecting icons </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">CLASS_MAP_PRESS</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;steady&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-arrow-right-long&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;rising&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-arrow-trend-up&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;falling&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-arrow-trend-down&#39;</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">CLASS_MAP_RAIN</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;none&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-droplet-slash&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;light&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-glass-water-droplet&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;moderate&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-glass-water&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;heavy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-bucket&#39;</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">CLASS_MAP_TEMP</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;hot&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-thermometer-full&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;warm&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-thermometer-half&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;cool&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-thermometer-quarter&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;cold&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-thermometer-empty&#39;</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">CLASS_MAP_WX</span> <span style="color:#f92672">=</span> { </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;clear-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-sun&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;clear-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-moon&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;cloudy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;foggy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-showers-smog&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;partly-cloudy-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-clouds-sun&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;partly-cloudy-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-clouds-moon&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-rainy-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-sun-rain&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-rainy-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-moon-rain&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-sleet-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-meatball&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-sleet-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-moon-rain&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-snow-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-snowflake&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-snow-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-snowflake&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-thunderstorm-day&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-bolt&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;possibly-thunderstorm-night&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-bolt&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;rainy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-showers-heavy&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;sleet&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-rain&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;snow&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-snowflake&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;thunderstorm&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-cloud-bolt&#39;</span>, </span></span><span style="display:flex;"><span> <span style="color:#e6db74">&#39;windy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;fa-solid fa-wind&#39;</span>, </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// fetch data from pastebin </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">wx_endpoint</span>) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#a6e22e">res</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">json</span>()) </span></span><span style="display:flex;"><span> .<span style="color:#a6e22e">then</span>(<span style="color:#66d9ef">function</span>(<span style="color:#a6e22e">res</span>){ </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// calculate age of last update </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">time</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> parseInt(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateTime</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1000</span>; </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">updateTime</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Date(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">updateAge</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">getRelativeTime</span>(<span style="color:#a6e22e">updateTime</span>); </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// parse data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">conditions</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">conditions</span>).<span style="color:#a6e22e">toLowerCase</span>(); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">tempDiff</span> <span style="color:#f92672">=</span> Math.<span style="color:#a6e22e">abs</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span>); </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span><span style="color:#e6db74">}</span><span style="color:#e6db74">°f (</span><span style="color:#e6db74">${</span>(((<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">temperature</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">32</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">5</span>) <span style="color:#f92672">/</span> <span style="color:#ae81ff">9</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">°c)`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">tempDiff</span> <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">5</span>) { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">temp</span> <span style="color:#f92672">+=</span> <span style="color:#e6db74">`, feels </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span><span style="color:#e6db74">}</span><span style="color:#e6db74">°f (</span><span style="color:#e6db74">${</span>(((<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span> <span style="color:#f92672">-</span> <span style="color:#ae81ff">32</span>) <span style="color:#f92672">*</span><span style="color:#ae81ff">5</span>) <span style="color:#f92672">/</span> <span style="color:#ae81ff">9</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">°c)`</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">tempLabel</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">tempRanges</span>.<span style="color:#a6e22e">find</span>(<span style="color:#a6e22e">range</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">feels_like</span> <span style="color:#f92672">&lt;</span> <span style="color:#a6e22e">range</span>.<span style="color:#a6e22e">upper</span>)).<span style="color:#a6e22e">label</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">humidity</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">humidity</span><span style="color:#e6db74">}</span><span style="color:#e6db74">% humidity`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">wind</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span><span style="color:#e6db74">}</span><span style="color:#e6db74">mph (</span><span style="color:#e6db74">${</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_gust</span><span style="color:#f92672">*</span><span style="color:#ae81ff">1.609344</span>).<span style="color:#a6e22e">toFixed</span>(<span style="color:#ae81ff">1</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">kph) from </span><span style="color:#e6db74">${</span>(<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">wind_direction</span>).<span style="color:#a6e22e">toLowerCase</span>()<span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainLabel</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">rainRanges</span>.<span style="color:#a6e22e">find</span>(<span style="color:#a6e22e">range</span> =&gt; <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span> <span style="color:#f92672">&lt;</span> <span style="color:#a6e22e">range</span>.<span style="color:#a6e22e">upper</span>)).<span style="color:#a6e22e">label</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">rainToday</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">0</span>) { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;no rain today&#39;</span>; </span></span><span style="display:flex;"><span> } <span style="color:#66d9ef">else</span> { </span></span><span style="display:flex;"><span> <span style="color:#a6e22e">rainToday</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">rain_today</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34; rain today`</span>; </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressureTrend</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure_trend</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">pressure</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">pressure</span><span style="color:#e6db74">}</span><span style="color:#e6db74">inhg and </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">pressureTrend</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>; </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">icon</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">icon</span>; </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#75715e">// display data </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;time&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">updateAge</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;conditions&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">conditions</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;temp&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">temp</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;humidity&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">humidity</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;wind&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">wind</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;rainToday&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">rainToday</span>; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementById</span>(<span style="color:#e6db74">&#39;pressure&#39;</span>).<span style="color:#a6e22e">innerHTML</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">pressure</span>; </span></span><span style="display:flex;"><span> <span style="color:#75715e">// update icons </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> document.<span style="color:#a6e22e">getElementsByClassName</span>(<span style="color:#e6db74">&#39;fa-cloud-sun-rain&#39;</span>)[<span style="color:#ae81ff">0</span>].<span style="color:#a6e22e">classList</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">CLASS_MAP_WX</span>[<span style="color:#a6e22e">icon</span>]; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementsByClassName</span>(<span style="color:#e6db74">&#39;fa-temperature-half&#39;</span>)[<span style="color:#ae81ff">0</span>].<span style="color:#a6e22e">classList</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">CLASS_MAP_TEMP</span>[<span style="color:#a6e22e">tempLabel</span>]; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementsByClassName</span>(<span style="color:#e6db74">&#39;fa-droplet-slash&#39;</span>)[<span style="color:#ae81ff">0</span>].<span style="color:#a6e22e">classList</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">CLASS_MAP_RAIN</span>[<span style="color:#a6e22e">rainLabel</span>]; </span></span><span style="display:flex;"><span> document.<span style="color:#a6e22e">getElementsByClassName</span>(<span style="color:#e6db74">&#39;fa-arrow-right-long&#39;</span>)[<span style="color:#ae81ff">0</span>].<span style="color:#a6e22e">classList</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">CLASS_MAP_PRESS</span>[<span style="color:#a6e22e">pressureTrend</span>]; </span></span><span style="display:flex;"><span> }); </span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">script</span>&gt; </span></span></code></pre></div><p>With that, I hit the happy little <strong>Save &amp; Publish</strong> button in the editor, and then revel in seeing my new weather display at the bottom of my <a href="https://jbowdre.lol">omg.lol page</a>:</p> <p><img src="https://runtimeterror.dev/display-tempest-weather-static-site/its-alive.png" alt="Mobile screenshot of an omg.lol homepage with a (near) realtime weather display at the bottom"></p> <p>I'm quite pleased with how this turned out, and I had fun learning a few JavaScript tricks in the process. I stashed the code for this little project in my <a href="https://github.com/jbowdre/lolz">lolz repo</a> in case you'd like to take a look. I have some ideas for other APIs I might want to integrate in a similar way in the future so stay tuned.</p> <div class="footnotes" role="doc-endnotes"> <hr> <ol> <li id="fn:1"> <p>These are degrees Fahrenheit, after all. If I needed precision I'd be using better units.&#160;<a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:2"> <p>I'm such a sucker for basically <em>anything</em> that I can tie one of my domains to.&#160;<a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:3"> <p>Per <a href="https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule">the docs</a>, runs can run <em>at most</em> once every five minutes, and they may be delayed (or even canceled) if a runner isn't available due to heavy load.&#160;<a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:4"> <p>My dishonest values will just get corrected the next time the workflow is triggered.&#160;<a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:5"> <p>Okay, admittedly this progression isn't perfect, but it's the best I could come up with within the free FA icon set.&#160;<a href="https://runtimeterror.dev/display-tempest-weather-static-site/#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> </ol> </div> </description> </item> <item> <title>Deploying a Hugo Site to Neocities with GitHub Actions</title> <link>https://runtimeterror.dev/deploy-hugo-neocities-github-actions/</link> <pubDate>Sun, 21 Jan 2024 00:00:00 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>cicd</category> <category>hugo</category> <category>meta</category> <category>serverless</category> <guid>https://runtimeterror.dev/deploy-hugo-neocities-github-actions/</guid><description><p>I came across <a href="https://neocities.org">Neocities</a> many months ago, and got really excited by the premise: a free web host with the mission to bring back the <em>&quot;fun, creativity and independence that made the web great.&quot;</em> I spent a while scrolling through the <a href="https://neocities.org/browse">gallery</a> of personal sites and was amazed by both the nostalgic vibes and the creativity on display. It's like a portal back to when the web was fun. Neocities seemed like something I wanted to be a part of so I signed up for an account... and soon realized that I didn't <em>really</em> want to go back to crafting artisinal HTML by hand like I did in the early '00s. I didn't see an easy way to leverage my preferred static site generator<sup id="fnref:1"><a href="https://runtimeterror.dev/deploy-hugo-neocities-github-actions/#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> so I filed it away and moved on.</p> <p>Until yesterday, when I saw a post from <a href="https://social.lol/@sophie">Sophie</a> on <a href="https://localghost.dev/blog/how-i-deploy-my-eleventy-site-to-neocities/">How I deploy my Eleventy site to Neocities</a>. I hadn't realized that Neocities had an <a href="https://neocities.org/api">API</a>, or that there was a <a href="https://github.com/bcomnes/deploy-to-neocities">deploy-to-neocities</a> GitHub Action which uses that API to push content to Neocities. With that new-to-me information, I thought I'd give Neocities another try - a real one this time.</p> <p>I had been hosting this site on Netlify's free plan <a href="https://runtimeterror.dev/hello-hugo/">for a couple of years</a> and haven't really encountered any problems. But I saw Neocities as a better vision of the internet, and I wanted to be a part of that<sup id="fnref:2"><a href="https://runtimeterror.dev/deploy-hugo-neocities-github-actions/#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>. So last night I upgraded to the $5/month <a href="https://neocities.org/supporter">Neocities Supporter</a> plan which would let me use a custom domain for my site (along with higher storage and bandwidth limits).</p> <p>I knew I'd need to make some changes to Sophie's workflow since my site is built with Hugo rather than Eleventy. I did some poking around and found <a href="https://github.com/peaceiris/actions-hugo">GitHub Actions for Hugo</a> which would take care of installing Hugo for me. Then I'd just need to render the HTML with <code>hugo --minify</code> and use the <a href="https://runtimeterror.dev/spotlight-on-torchlight/">Torchlight</a> CLI to mark up the code blocks. Along the way, I also discovered that I'd need to overwrite <code>/not_found.html</code> to insert my custom 404 page so I included an extra step to do that. After that, I'll finally be ready to push the results to Neocities.</p> <p>It took a bit of trial and error, but I eventually adapted this workflow which does the trick:</p> <h3 id="the-workflow">The Workflow</h3> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># .github/workflows/deploy-to-neocities.yml</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">Deploy to Neocities</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">on</span>: </span></span><span style="display:flex;"><span> <span style="color:#75715e"># Daily build to catch any future-dated posts</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">schedule</span>: </span></span><span style="display:flex;"><span> - <span style="color:#f92672">cron</span>: <span style="color:#ae81ff">0</span> <span style="color:#ae81ff">13</span> * * * </span></span><span style="display:flex;"><span> <span style="color:#75715e"># Build on pushes to the main branch only</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">push</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">branches</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">main</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">concurrency</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">group</span>: <span style="color:#ae81ff">deploy-to-neocities</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">cancel-in-progress</span>: <span style="color:#66d9ef">true</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">defaults</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">shell</span>: <span style="color:#ae81ff">bash</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span><span style="color:#f92672">jobs</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">deploy</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Build and deploy Hugo site</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">runs-on</span>: <span style="color:#ae81ff">ubuntu-latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">steps</span>: </span></span><span style="display:flex;"><span> <span style="color:#75715e"># Install Hugo in the runner</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Hugo setup</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">peaceiris/actions-hugo@v2.6.0</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">hugo-version</span>: <span style="color:#e6db74">&#39;0.121.1&#39;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">extended</span>: <span style="color:#66d9ef">true</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e"># Check out the source for the site</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Checkout</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">actions/checkout@v4</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">submodules</span>: <span style="color:#ae81ff">recursive</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e"># Build the site with Hugo</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Build with Hugo</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: <span style="color:#ae81ff">hugo --minify</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e"># Copy my custom 404 page to not_found.html so it</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e"># will be picked up by Neocities</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Insert 404 page</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: |<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> cp public/404/index.html public/not_found.html</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e"># Highlight code blocks with the Torchlight CLI</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Highlight with Torchlight</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">run</span>: |<span style="color:#e6db74"> </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> npm i @torchlight-api/torchlight-cli </span></span></span><span style="display:flex;"><span><span style="color:#e6db74"> npx torchlight</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e"># Push the rendered site to Neocities and</span> </span></span><span style="display:flex;"><span> <span style="color:#75715e"># clean up any orphaned files</span> </span></span><span style="display:flex;"><span> - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">Deploy to Neocities</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">uses</span>: <span style="color:#ae81ff">bcomnes/deploy-to-neocities@v1</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">with</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">api_token</span>: <span style="color:#ae81ff">${{ secrets.NEOCITIES_API_TOKEN }}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">cleanup</span>: <span style="color:#66d9ef">true</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">dist_dir</span>: <span style="color:#ae81ff">public</span> </span></span></code></pre></div><p>I'm thrilled with how well this works, and happy to have learned a bit more about GitHub Actions in the process. Big thanks to Sophie for pointing me in the right direction!</p> <div class="footnotes" role="doc-endnotes"> <hr> <ol> <li id="fn:1"> <p>Also I'm kind of lazy, and not actually very good at web design anyway. I mean, you've seen my work.&#160;<a href="https://runtimeterror.dev/deploy-hugo-neocities-github-actions/#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:2"> <p>Plus I love supporting passion projects.&#160;<a href="https://runtimeterror.dev/deploy-hugo-neocities-github-actions/#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> </ol> </div> </description> </item> <item> <title>Enabling FIPS Compliance Fixes Aria Lifecycle 8.14</title> <link>https://runtimeterror.dev/enable-fips-fix-aria-lifecycle/</link> <pubDate>Fri, 19 Jan 2024 00:00:00 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>vmware</category> <guid>https://runtimeterror.dev/enable-fips-fix-aria-lifecycle/</guid><description><p>This week, VMware posted <a href="https://www.vmware.com/security/advisories/VMSA-2024-0001.html">VMSA-2024-0001</a> which details a critical (9.9/10) vulnerability in <s>vRealize</s> <em>Aria</em> Automation. While working to get our environment patched, I ran into an interesting error on our Aria Lifecycle appliance:</p> <pre tabindex="0"><code class="language-log" data-lang="log">Error Code: LCMVRAVACONFIG590024 VMware Aria Automation hostname is not valid or unable to run the product specific commands via SSH on the host. Check if VMware Aria Automation is up and running. VMware Aria Automation hostname is not valid or unable to run the product specific commands via SSH on the host. Check if VMware Aria Automation is up and running. com.vmware.vrealize.lcm.drivers.vra80.exception.VraVaProductNotFoundException: Either provided hostname: &lt;VMwareAriaAutomationFQDN&gt; is not a valid VMware Aria Automation hostname or unable to run the product specific commands via SSH on the host. at com.vmware.vrealize.lcm.drivers.vra80.helpers.VraPreludeInstallHelper.getVraFullVersion(VraPreludeInstallHelper.java:970) at com.vmware.vrealize.lcm.drivers.vra80.helpers.VraPreludeInstallHelper.checkVraApplianceAndVersion(VraPreludeInstallHelper.java:978) at com.vmware.vrealize.lcm.drivers.vra80.helpers.VraPreludeInstallHelper.getVraProductDetails(VraPreludeInstallHelper.java:754) at com.vmware.vrealize.lcm.plugin.core.vra80.task.VraVaImportEnvironmentTask.execute(VraVaImportEnvironmentTask.java:145) at com.vmware.vrealize.lcm.platform.automata.service.Task.retry(Task.java:158) at com.vmware.vrealize.lcm.automata.core.TaskThread.run(TaskThread.java:60) at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) at java.base/java.lang.Thread.run(Unknown Source) </code></pre><p>Digging further into the appliance logs revealed some more details:</p> <pre tabindex="0"><code class="language-log" data-lang="log">Session.connect: java.security.spec.InvalidKeySpecException: key spec not recognized </code></pre><p>That seems like a much more insightful error than &quot;the hostname is not valid, dummy.&quot;</p> <p>Anyhoo, searching for the error took me to a VMware KB on the subject:</p> <ul> <li><a href="https://kb.vmware.com/s/article/96243">VMware Aria Suite Lifecycle 8.14 Patch 1 Day 2 operations fail for VMware Aria Automation with error code LCMVRAVACONFIG590024 (96243)</a></li> </ul> <blockquote> <p>After applying VMware Aria Suite Lifecycle 8.14 Patch 1, you may encounter deployment and day-2 operation failures, attributed to the elimination of weak algorithms in Suite Lifecycle. To prevent such issues, it is recommended to either turn on FIPS in VMware Aria Suite Lifecycle or implement the specified workarounds on other VMware Aria Products, as outlined in the article Steps for Removing SHA1 weak Algorithms/Ciphers from all VMware Aria Products.</p> </blockquote> <p>That's right. According to the KB, the solution for the untrusted encryption algorithms is to <em>enable</em> FIPS compliance. I was skeptical: I've never seen FIPS enforcement fix problems, it always causes them.</p> <p>But I gave it a shot, and <em>holy crap it actually worked!</em> Enabling FIPS compliance on the Aria Lifecycle appliance got things going again.</p> <p>I feel like I've seen everything now.</p> </description> </item> <item> <title>Publish Services with Cloudflare Tunnel</title> <link>https://runtimeterror.dev/publish-services-cloudflare-tunnel/</link> <pubDate>Mon, 15 Jan 2024 00:00:00 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>cloudflare</category> <category>cloud</category> <category>containers</category> <category>docker</category> <category>networking</category> <category>selfhosting</category> <guid>https://runtimeterror.dev/publish-services-cloudflare-tunnel/</guid><description><p>I've written a bit lately about how handy <a href="https://runtimeterror.dev/tailscale-ssh-serve-funnel/">Tailscale Serve and Funnel</a> can be, and I continue to get a lot of great use out of those features. But not <em>every</em> networking nail is best handled with a Tailscale-shaped hammer. Funnel has two limitations that might make it less than ideal for certain situations.</p> <p>First, sites served with Funnel can only have a hostname in the form of <code>server.tailnet-name.ts.net</code>. You can't use a custom domain for this, but you might not always want to advertise that a service is shared via Tailscale. Second, Funnel connections have an undisclosed bandwidth limit, which could cause problems if you're hoping to serve media through the Funnel.</p> <p>For instance, I've started using <a href="https://immich.app/">Immich</a> as a self-hosted alternative to Google Photos. Using Tailscale Serve to make my Immich server available on my Tailnet works beautifully, and I initially set up a Funnel connection to use for when I wanted to share certain photos, videos, and albums externally. I quickly realized that it took <em>f o r e v e r</em> to load the page when those links were shared publicly. I probably won't share a lot of those public links but I'd like them to be a bit more responsive when I do.</p> <p>I went looking for another solution, and I found one in a suite of products I already use.</p> <h3 id="overview">Overview</h3> <p>I've been using <a href="https://www.cloudflare.com/plans/free/">Cloudflare's generious free plan</a> for DNS, content caching, page/domain redirects, email forwarding, and DDoS mitigation<sup id="fnref:1"><a href="https://runtimeterror.dev/publish-services-cloudflare-tunnel/#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> across my dozen or so domains. In addition to these &quot;basic&quot; services and features, Cloudflare also offers a selection of <a href="https://www.cloudflare.com/products/zero-trust/zero-trust-network-access/">Zero Trust Network Access</a> products, and one of those is <a href="https://www.cloudflare.com/products/tunnel/">Cloudflare Tunnel</a> - also available with a generous free plan.</p> <p>In some ways, Cloudflare Tunnel is quite similar to Tailscale Funnel. Both provide a secure way to publish a resource on the internet without requiring a public IP address, port forwarding, or firewall configuration. Both use a lightweight agent on your server to establish an encrypted outbound tunnel, and inbound traffic gets routed into that tunnel through the provider's network. And both solutions automatically provision trusted SSL certificates to keep traffic safe and browsers happy.</p> <p>Tailscale Funnel is very easy to set up, and it doesn't require any additional infrastructure, not even a domain name. There aren't a lot of controls available with Funnel - it's basically on or off, and bound to one of three port numbers. You don't get to pick the domain name where it's served, just the hostname of the Tailscale node - and if you want to share multiple resources on the same host you'll <a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/">need to get creative</a>. I think this approach is really ideal for quick development and testing scenarios.</p> <p>For longer-term, more production-like use, Cloudflare Tunnels is a pretty great fit. It ties in well with existing Cloudflare services, doesn't enforce a reduced bandwidth limit, and provides a bit more flexibility for how your resource will be presented to the web. It can also integrate tightly with the rest of Cloudflare's Zero Trust offerings to easily provide access controls to further protect your resource. It does, however, require a custom domain managed with Cloudflare DNS in order to work<sup id="fnref:2"><a href="https://runtimeterror.dev/publish-services-cloudflare-tunnel/#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>.</p> <p>For my specific Immich use case, I decided to share my instance via Tailscale Serve for internal access and Cloudflare Tunnel for public shares, and I used a similar sidecar approach to make it work without too much fuss. For the purposes of this blog post, though, I'm going to run through a less complicated example<sup id="fnref:3"><a href="https://runtimeterror.dev/publish-services-cloudflare-tunnel/#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>.</p> <h3 id="speedtest-demo">Speedtest Demo</h3> <p>I'm going to deploy a quick <a href="https://github.com/openspeedtest/Speed-Test">SpeedTest by OpenSpeedTest</a> container, and proxy it with both Tailscale Funnel and Cloudflare Tunnel so that I can compare the bandwidth of the two tunnel solutions directly.</p> <p>I'll start with a <em>very</em> basic Docker Compose definition for just the Speedtest container:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># docker-compose.yml</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">speedtest</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">openspeedtest/latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">speedtest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span></code></pre></div><h4 id="tailscale-funnel">Tailscale Funnel</h4> <p>And, as in <a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/">my last post</a> I'll add in my Tailscale sidecar to enable funnel:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># docker-compose.yml</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">speedtest</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">openspeedtest/latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">speedtest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">network_mode</span>: <span style="color:#ae81ff">service:tailscale</span> <span style="color:#75715e"># [tl! ++:start focus:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">tailscale</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/jbowdre/tailscale-docker:latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">speedtest-tailscaled</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_AUTHKEY</span>: <span style="color:#ae81ff">${TS_AUTHKEY:?err}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_HOSTNAME</span>: <span style="color:#ae81ff">${TS_HOSTNAME:-tailscale-sidecar}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_STATE_DIR</span>: <span style="color:#e6db74">&#34;/var/lib/tailscale/&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_EXTRA_ARGS:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_SERVE_PORT</span>: <span style="color:#ae81ff">${TS_SERVE_PORT:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_FUNNEL</span>: <span style="color:#ae81ff">${TS_FUNNEL:-}</span> <span style="color:#75715e"># [tl! ++:end focus:end]</span> </span></span></code></pre></div><div></div><div class="notice note"> <p class="first notice-title"><span class="icon-notice baseline"></span>Network Mode</p><p>I set <code>network_mode: service:tailscale</code> on the <code>speedtest</code> container so that it will share its network interface with the <code>tailscale</code> container. This allows Tailscale Serve/Funnel to proxy <code>speedtest</code> at <code>http://localhost:3000</code>, which is nice since Tailscale doesn't currently/officially support proxying remote hosts.</p></div> <p>I'll set up a new auth key in the <a href="https://login.tailscale.com/admin/settings/keys">Tailscale Admin Portal</a>, and insert that (along with hostname, port, and funnel configs) into my <code>.env</code> file:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># .env</span> </span></span><span style="display:flex;"><span>TS_AUTHKEY<span style="color:#f92672">=</span>tskey-auth-somestring-somelongerstring </span></span><span style="display:flex;"><span>TS_HOSTNAME<span style="color:#f92672">=</span>speedtest </span></span><span style="display:flex;"><span>TS_EXTRA_ARGS<span style="color:#f92672">=</span>--ssh </span></span><span style="display:flex;"><span>TS_SERVE_PORT<span style="color:#f92672">=</span><span style="color:#ae81ff">3000</span> <span style="color:#75715e"># the port the speedtest runs on by default</span> </span></span><span style="display:flex;"><span>TS_FUNNEL<span style="color:#f92672">=</span>true </span></span></code></pre></div><p>A quick <code>docker compose up -d</code> and my new speedtest is alive!</p> <p>First I'll hit it at <code>http://speedtest.tailnet-name.ts.net:3000</code> to access it purely inside of my Tailnet: <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/speedtest-tailnet.png" alt="Speedtest from within the tailnet"></p> <p>Not bad! Now let's see what happens when I disable Tailscale on my laptop and hit the public Funnel endpoint at <code>https://speedtest.tailnet-name.ts.net</code>: <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/speedtest-funnel.png" alt="Speedtest from funnel"></p> <p>Oof. Routing traffic through the Funnel dropped the download by ~25% and the upload by <strong>~90%</strong>, not to mention the significant ping increase.</p> <h4 id="cloudflare-tunnel">Cloudflare Tunnel</h4> <p>Alright, let's throw a Cloudflare Tunnel on there and see what happens.</p> <p>To start that process, I'll log into my <a href="https://dash.cloudflare.com">Cloudflare dashboard</a> and then use the side navigation to make my way to the <strong>Zero Trust</strong> (AKA &quot;Cloudflare One&quot;) area. From there, I'll drill down through <strong>Access -&gt; Tunnels</strong> and click on <strong>+ Create a tunnel</strong>. I'll give it an appropriate name like <code>speedtest</code> and then click <strong>Save tunnel</strong>.</p> <p>Now Cloudflare helpfully provides installation instructions for a variety of different platforms. I'm doing that Docker thing so I'll click the appropriate button and review that command snippet: <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/install-connector.png" alt="Tunnel installation instructions"></p> <p>I can easily adapt that and add it to my Docker Compose setup<sup id="fnref:4"><a href="https://runtimeterror.dev/publish-services-cloudflare-tunnel/#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup>:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;:true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e"># docker-compose.yml</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">speedtest</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">openspeedtest/latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">speedtest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">network_mode</span>: <span style="color:#ae81ff">service:tailscale</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">tailscale</span>: <span style="color:#75715e"># [tl! collapse:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/jbowdre/tailscale-docker:latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">speedtest-tailscaled</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_AUTHKEY</span>: <span style="color:#ae81ff">${TS_AUTHKEY:?err}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_HOSTNAME</span>: <span style="color:#ae81ff">${TS_HOSTNAME:-tailscale}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_STATE_DIR</span>: <span style="color:#e6db74">&#34;/var/lib/tailscale/&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_EXTRA_ARGS:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_SERVE_PORT</span>: <span style="color:#ae81ff">${TS_SERVE_PORT:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_FUNNEL</span>: <span style="color:#ae81ff">${TS_FUNNEL:-}</span> <span style="color:#75715e"># [tl! collapse:end]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">cloudflared</span>: <span style="color:#75715e"># [tl! ++:start focus:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">cloudflare/cloudflared</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">speedtest-cloudflared</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">command</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">tunnel</span> </span></span><span style="display:flex;"><span> - --<span style="color:#66d9ef">no</span>-<span style="color:#ae81ff">autoupdate</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">run</span> </span></span><span style="display:flex;"><span> - --<span style="color:#ae81ff">token</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">${CLOUDFLARED_TOKEN}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">network_mode</span>: <span style="color:#ae81ff">service:tailscale</span> <span style="color:#75715e"># [tl! ++:end focus:end]</span> </span></span></code></pre></div><p>After dropping the value for <code>CLOUDFLARED_TOKEN</code> into my <code>.env</code> file, I can do another <code>docker compose up -d</code> to bring this online - and that status will be reflected back on the config page as well: <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/connector-online.png" alt="Connector is alive!"></p> <p>I'll click <strong>Next</strong> and proceed with the rest of the configuration, which consists of picking a public hostname for the frontend and defining the private service for the backend: <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/tunnel-configuration.png" alt="Tunnel configuration"></p> <p>I can click <strong>Save tunnel</strong> and... that's it. My tunnel is live, and I can now reach my speedtest at <code>https://speedtest.runtimeterror.dev</code>. Let's see how it does: <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/speedtest-cloudflared.png" alt="Cloudflare Tunnel speedtest"></p> <p>So that's <em>much</em> faster than Tailscale Funnel, and even faster than a direct transfer within the Tailnet. Cloudflare Tunnel should work quite nicely for sharing photos publicly from my Immich instance.</p> <h4 id="bonus-access-control">Bonus: Access Control</h4> <p>But what if I don't want <em>just anyone</em> to be able to use my new speedtest (or access my Immich instance)? Defining an application in Cloudflare One will let me set some limits.</p> <p>So I'll go to <strong>Access -&gt; Applications</strong> and select that I'm adding a <strong>Self-hosted</strong> application. I can then do the basic configuration, basically just telling Cloudflare that I'd like to protect the <code>https://speedtest.runtimeterror.dev</code> app: <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/define-application.png" alt="Defining the application"></p> <p>I can leave the rest of that page with the default selections so I'll scroll down and click <strong>Next</strong>.</p> <p>Now I need to create a policy to apply to this application. I'm going to be simple and just say that anyone with an <code>@runtimeterror.dev</code> email address should be able to use my speedtest: <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/create-policy.png" alt="Creating a policy"></p> <p>Without any external identity providers connected, Cloudflare will default to requiring authentication via a one-time PIN sent to an input email address. That's pretty easy, and it pairs well with allowing access based on email address attributes. There are a bunch of other options I could configure if I wanted... but my needs are simple so I'll just click through and save this new application config.</p> <p>Now, if I try to visit my speedtest with a new session I'll get automatically routed to the Cloudflare Access challenge which will prompt for my email address. <img src="https://runtimeterror.dev/publish-services-cloudflare-tunnel/access-challenge.png" alt="Access challenge"></p> <p>If my email is on the approved list (that is, if it ends with <code>@runtimeterror.dev</code>), I'll get emailed a code which I can then use to log in and access the speedtest. If not, I won't get in. And since this thing is served through a Cloudflare Tunnel (rather than a public IP address merely advertised via DNS) there isn't any way to bypass Cloudflare's authentication challenge.</p> <h3 id="conclusion">Conclusion</h3> <p>This has been a quick demo of how easy it is to configure a Cloudflare Tunnel to securely publish resources on the web. I really like being able to share a service publicly without having to manage DNS, port-forwarding, or firewall configurations, and the ability to offload authentication and authorization to Cloudflare is a big plus. I still don't think Tailscale can be beat for sharing stuff internally, but I think Cloudflare Tunnels make more sense for long-term public sharing. And it's awesome that I can run both solutions side-by-side to really get the best of both when I need it.</p> <div class="footnotes" role="doc-endnotes"> <hr> <ol> <li id="fn:1"> <p>And a ton of other things I'm forgetting right now.&#160;<a href="https://runtimeterror.dev/publish-services-cloudflare-tunnel/#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:2"> <p>Cloudflare Tunnel lets you choose what hostname and domain name should be used for fronting your tunnel, and it even takes care of configuring the required DNS record automagically.&#160;<a href="https://runtimeterror.dev/publish-services-cloudflare-tunnel/#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:3"> <p>My Immich stack is using ~10 containers and I don't really feel like documenting that all here - not yet, at least.&#160;<a href="https://runtimeterror.dev/publish-services-cloudflare-tunnel/#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:4"> <p>Setting the <code>network_mode</code> isn't strictly necessary for the <code>cloudflared</code> container since Cloudflare Tunnel <em>does</em> support proxying remote hosts, but I'll just stick with it here for consistency.&#160;<a href="https://runtimeterror.dev/publish-services-cloudflare-tunnel/#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> </ol> </div> </description> </item> <item> <title>Tailscale Serve in a Docker Compose Sidecar</title> <link>https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/</link> <pubDate>Sat, 30 Dec 2023 00:00:00 +0000</pubDate> <dc:creator>John Bowdre</dc:creator> <category>containers</category> <category>docker</category> <category>selfhosting</category> <category>tailscale</category> <guid>https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/</guid><description><p>Hi, and welcome back to what has become my <a href="https://runtimeterror.dev/tags/tailscale/">Tailscale blog</a>.</p> <p>I have a few servers that I use for running multiple container workloads. My approach in the past had been to use <a href="https://caddyserver.com/">Caddy webserver</a> on the host to proxy the various containers. With this setup, each app would have its own DNS record, and Caddy would be configured to route traffic to the appropriate internal port based on that. For instance:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-text" data-lang="text"><span style="display:flex;"><span># torchlight! {&#34;lineNumbers&#34;: true} </span></span><span style="display:flex;"><span>cyberchef.runtimeterror.dev { </span></span><span style="display:flex;"><span> reverse_proxy localhost:8000 </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>ntfy.runtimeterror.dev, http://ntfy.runtimeterror.dev { </span></span><span style="display:flex;"><span> reverse_proxy localhost:8080 </span></span><span style="display:flex;"><span> @httpget { </span></span><span style="display:flex;"><span> protocol http </span></span><span style="display:flex;"><span> method GET </span></span><span style="display:flex;"><span> path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/) </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> redir @httpget https://{host}{uri} </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>uptime.runtimeterror.dev { </span></span><span style="display:flex;"><span> reverse_proxy localhost:3001 </span></span><span style="display:flex;"><span>} </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>miniflux.runtimeterror.dev { </span></span><span style="display:flex;"><span> reverse_proxy localhost:8080 </span></span><span style="display:flex;"><span>} </span></span></code></pre></div><p><em>and so on...</em> You get the idea. This approach works well for services I want/need to be public, but it does require me to manage those DNS records and keep track of which app is on which port. That can be kind of tedious.</p> <p>And I don't really need all of these services to be public. Not because they're particularly sensitive, but I just don't really have a reason to share my personal <a href="https://github.com/miniflux/v2">Miniflux</a> or <a href="https://github.com/gchq/CyberChef">CyberChef</a> instances with the world at large. Those would be great candidates to proxy with <a href="https://runtimeterror.dev/tailscale-ssh-serve-funnel#tailscale-serve">Tailscale Serve</a> so they'd only be available on my tailnet. Of course, with that setup I'd then have to differentiate the services based on external port numbers since they'd all be served with the same hostname. That's not ideal either.</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sudo tailscale serve --bg --https <span style="color:#ae81ff">8443</span> <span style="color:#ae81ff">8180</span> <span style="color:#75715e"># [tl! .cmd]</span> </span></span><span style="display:flex;"><span>Available within your tailnet: <span style="color:#75715e"># [tl! .nocopy:6]</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>https://tsdemo.tailnet-name.ts.net/ </span></span><span style="display:flex;"><span>|-- proxy http://127.0.0.1:8000 </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span>https://tsdemo.tailnet-name.ts.net:8443/ </span></span><span style="display:flex;"><span>|-- proxy http://127.0.0.1:8080 </span></span></code></pre></div><p>It would be really great if I could directly attach each container to my tailnet and then access the apps with addresses like <code>https://miniflux.tailnet-name.ts.net</code> or <code>https://cyber.tailnet-name.ts.net</code>. Tailscale does have an <a href="https://hub.docker.com/r/tailscale/tailscale">official Docker image</a>, and at first glance it seems like that would solve my needs pretty directly. Unfortunately, it looks like trying to leverage that container image directly would still require me to configure Tailscale Serve interactively.<sup id="fnref:1"><a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>.</p> <div></div><div class="notice note"> <p class="first notice-title"><span class="icon-notice baseline"></span>Update: 2024-02-07</p><p>Tailscale <a href="https://tailscale.com/blog/docker-tailscale-guide">just published a blog post</a> which shares some details about how to configure Funnel and Serve within the official image. The short version is that the <code>TS_SERVE_CONFIG</code> variable should point to a <code>serve-config.json</code> file. The name of the file doesn't actually matter, but the contents do - and you can generate a config by running <code>tailscale serve status -json</code> on a functioning system... or just copy-pasta'ing this example I just made for the <a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#cyberchef">Cyberchef</a> setup I describe later in this post:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#75715e">// torchlight! {&#34;lineNumbers&#34;: true} </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>{ </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;TCP&#34;</span>: { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;443&#34;</span>: { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;HTTPS&#34;</span>: <span style="color:#66d9ef">true</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> }, </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;Web&#34;</span>: { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;cyber.tailnet-name.ts.net:443&#34;</span>: { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;Handlers&#34;</span>: { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;/&#34;</span>: { </span></span><span style="display:flex;"><span> <span style="color:#f92672">&#34;Proxy&#34;</span>: <span style="color:#e6db74">&#34;http://127.0.0.1:8000&#34;</span> </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> } </span></span><span style="display:flex;"><span> }<span style="color:#75715e">//, uncomment to enable funnel </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#75715e">// &#34;AllowFunnel&#34;: { </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#75715e">// &#34;cyber.tailnet-name.ts.net:443&#34;: true </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span> <span style="color:#75715e">// } </span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>} </span></span></code></pre></div><p>Replace the ports and protocols and hostnames and such, and you'll be good to go.</p> <p>A compose config using this setup might look something like this:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">tailscale</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">tailscale/tailscale:latest</span> <span style="color:#75715e"># [tl! highlight]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">cyberchef-tailscale</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_AUTHKEY</span>: <span style="color:#ae81ff">${TS_AUTHKEY:?err}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_HOSTNAME</span>: <span style="color:#ae81ff">${TS_HOSTNAME:-ts-docker}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_EXTRA_ARGS:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_STATE_DIR</span>: <span style="color:#ae81ff">/var/lib/tailscale/</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_SERVE_CONFIG</span>: <span style="color:#ae81ff">/config/serve-config.json</span> <span style="color:#75715e"># [tl! highlight]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">volumes</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./ts_data:/var/lib/tailscale/</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./serve-config.json:/config/serve-config.json</span> <span style="color:#75715e"># [tl! highlight]</span> </span></span><span style="display:flex;"><span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">cyberchef</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">cyberchef</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">mpepping/cyberchef:latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">network_mode</span>: <span style="color:#ae81ff">service:tailscale</span> </span></span></code></pre></div><p>That's a bit cleaner than the workaround I'd put together, but you're totally welcome to keep on reading if you want to see how it compares.</p></div> <p>And then I came across <a href="https://asselin.engineer/tailscale-docker">Louis-Philippe Asselin's post</a> about how he set up Tailscale in Docker Compose. When he wrote his post, there was even less documentation on how to do this stuff, so he used a <a href="https://github.com/lpasselin/tailscale-docker">modified Tailscale docker image</a> which loads a <a href="https://github.com/lpasselin/tailscale-docker/blob/c6f8d75b5e1235b8dbeee849df9321f515c526e5/images/tailscale/start.sh">startup script</a> to handle some of the configuration steps. His repo also includes a <a href="https://github.com/lpasselin/tailscale-docker/blob/c6f8d75b5e1235b8dbeee849df9321f515c526e5/docker-compose/stateful-example/docker-compose.yml">helpful docker-compose example</a> of how to connect it together.</p> <p>I quickly realized I could modify his startup script to take care of my Tailscale Serve need. So here's how I did it.</p> <h3 id="docker-image">Docker Image</h3> <p>My image starts out basically the same as Louis-Philippe's, with just pulling in the official image and then adding the customized script:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-Dockerfile" data-lang="Dockerfile"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">FROM</span><span style="color:#e6db74"> tailscale/tailscale:v1.56.1</span><span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">COPY</span> start.sh /usr/bin/start.sh<span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">RUN</span> chmod +x /usr/bin/start.sh<span style="color:#960050;background-color:#1e0010"> </span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span><span style="color:#66d9ef">CMD</span> [<span style="color:#e6db74">&#34;/usr/bin/start.sh&#34;</span>]<span style="color:#960050;background-color:#1e0010"> </span></span></span></code></pre></div><p>My <code>start.sh</code> script has a few tweaks for brevity/clarity, and also adds a block for conditionally enabling a basic Tailscale Serve (or Funnel) configuration:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#75715e">#!/bin/ash</span> </span></span><span style="display:flex;"><span>trap <span style="color:#e6db74">&#39;kill -TERM $PID&#39;</span> TERM INT </span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;Starting Tailscale daemon&#34;</span> </span></span><span style="display:flex;"><span>tailscaled --tun<span style="color:#f92672">=</span>userspace-networking --statedir<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_STATE_DIR<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#e6db74">${</span>TS_TAILSCALED_EXTRA_ARGS<span style="color:#e6db74">}</span> &amp; </span></span><span style="display:flex;"><span>PID<span style="color:#f92672">=</span>$! </span></span><span style="display:flex;"><span><span style="color:#66d9ef">until</span> tailscale up --authkey<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_AUTHKEY<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> --hostname<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_HOSTNAME<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#e6db74">${</span>TS_EXTRA_ARGS<span style="color:#e6db74">}</span>; <span style="color:#66d9ef">do</span> </span></span><span style="display:flex;"><span> sleep 0.1 </span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span> </span></span><span style="display:flex;"><span>tailscale status </span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> -n <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_SERVE_PORT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span> <span style="color:#75715e"># [tl! ++:10]</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> -n <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_FUNNEL<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> ! tailscale funnel status | grep -q -A1 <span style="color:#e6db74">&#39;(Funnel on)&#39;</span> | grep -q <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_SERVE_PORT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>; <span style="color:#66d9ef">then</span> </span></span><span style="display:flex;"><span> tailscale funnel --bg <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_SERVE_PORT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">fi</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">else</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> ! tailscale serve status | grep -q <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_SERVE_PORT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>; <span style="color:#66d9ef">then</span> </span></span><span style="display:flex;"><span> tailscale serve --bg <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>TS_SERVE_PORT<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">fi</span> </span></span><span style="display:flex;"><span> <span style="color:#66d9ef">fi</span> </span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span> </span></span><span style="display:flex;"><span>wait <span style="color:#e6db74">${</span>PID<span style="color:#e6db74">}</span> </span></span></code></pre></div><p>This script starts the <code>tailscaled</code> daemon in userspace mode, and it tells the daemon to store its state in a user-defined location. It then uses a supplied <a href="https://tailscale.com/kb/1085/auth-keys">pre-auth key</a> to bring up the new Tailscale node and set the hostname.</p> <p>If both <code>TS_SERVE_PORT</code> and <code>TS_FUNNEL</code> are set, the script will publicly proxy the designated port with Tailscale Funnel. If only <code>TS_SERVE_PORT</code> is set, it will just proxy it internal to the tailnet with Tailscale Serve.<sup id="fnref:2"><a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup></p> <p>I'm using <a href="https://github.com/jbowdre/tailscale-docker/">this git repo</a> to track my work on this, and it automatically builds my <a href="https://github.com/jbowdre/tailscale-docker/pkgs/container/tailscale-docker">tailscale-docker</a> image. So now I can can simply reference <code>ghcr.io/jbowdre/tailscale-docker</code> in my Docker configurations.</p> <p>On that note...</p> <h3 id="compose-configuration">Compose Configuration</h3> <p>There's also a <a href="https://github.com/jbowdre/tailscale-docker/blob/a54e45ca717023a45d6b1d0aac7143902b02cb0b/docker-compose-example/docker-compose.yml">sample <code>docker-compose.yml</code></a> in the repo to show how to use the image:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">tailscale</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/jbowdre/tailscale-docker:latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">tailscale</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_AUTHKEY</span>: <span style="color:#ae81ff">${TS_AUTHKEY:?err}</span> <span style="color:#75715e"># from https://login.tailscale.com/admin/settings/authkeys</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_HOSTNAME</span>: <span style="color:#ae81ff">${TS_HOSTNAME:-ts-docker}</span> <span style="color:#75715e"># optional hostname to use for this node</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_STATE_DIR</span>: <span style="color:#e6db74">&#34;/var/lib/tailscale/&#34;</span> <span style="color:#75715e"># store ts state in a local volume</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_TAILSCALED_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_TAILSCALED_EXTRA_ARGS:-}</span> <span style="color:#75715e"># optional extra args to pass to tailscaled</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_EXTRA_ARGS:-}</span> <span style="color:#75715e"># optional extra flags to pass to tailscale up</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_SERVE_PORT</span>: <span style="color:#ae81ff">${TS_SERVE_PORT:-}</span> <span style="color:#75715e"># optional port to proxy with tailscale serve (ex: &#39;80&#39;)</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_FUNNEL</span>: <span style="color:#ae81ff">${TS_FUNNEL:-}</span> <span style="color:#75715e"># if set, serve publicly with tailscale funnel</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">volumes</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./ts_data:/var/lib/tailscale/ </span> <span style="color:#75715e"># the mount point should match TS_STATE_DIR</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">myservice</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">nginxdemos/hello</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">network_mode</span>: <span style="color:#e6db74">&#34;service:tailscale&#34;</span> <span style="color:#75715e"># use the tailscale network service&#39;s network</span> </span></span></code></pre></div><p>You'll note that most of those environment variables aren't actually defined in this YAML. Instead, they'll be inherited from the environment used for spawning the containers. This provides a few benefits. First, it lets the <code>tailscale</code> service definition block function as a template to allow copying it into other Compose files without having to modify. Second, it avoids holding sensitive data in the YAML itself. And third, it allows us to set default values for undefined variables (if <code>TS_HOSTNAME</code> is empty it will be automatically replaced with <code>ts-docker</code>) or throw an error if a required value isn't set (an empty <code>TS_AUTHKEY</code> will throw an error and abort).</p> <p>You can create the required variables by exporting them at the command line (<code>export TS_HOSTNAME=ts-docker</code>) - but that runs the risk of having sensitive values like an authkey stored in your shell history. It's not a great habit.</p> <p>Perhaps a better approach is to set the variables in a <code>.env</code> file stored alongside the <code>docker-compose.yaml</code> but with stricter permissions. This file can be owned and only readable by root (or the defined Docker user), while the Compose file can be owned by your own user or the <code>docker</code> group.</p> <p>Here's how the <code>.env</code> for this setup might look:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span>TS_AUTHKEY<span style="color:#f92672">=</span>tskey-auth-somestring-somelongerstring </span></span><span style="display:flex;"><span>TS_HOSTNAME<span style="color:#f92672">=</span>tsdemo </span></span><span style="display:flex;"><span>TS_TAILSCALED_EXTRA_ARGS<span style="color:#f92672">=</span>--verbose<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> </span></span><span style="display:flex;"><span>TS_EXTRA_ARGS<span style="color:#f92672">=</span>--ssh </span></span><span style="display:flex;"><span>TS_SERVE_PORT<span style="color:#f92672">=</span><span style="color:#ae81ff">8080</span> </span></span><span style="display:flex;"><span>TS_FUNNEL<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> </span></span></code></pre></div><table> <thead> <tr> <th>Variable Name</th> <th>Example</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td><code>TS_AUTHKEY</code></td> <td><code>tskey-auth-somestring-somelongerstring</code></td> <td>used for unattended auth of the new node, get one <a href="https://login.tailscale.com/admin/settings/keys">here</a></td> </tr> <tr> <td><code>TS_HOSTNAME</code></td> <td><code>tsdemo</code></td> <td>optional Tailscale hostname for the new node<sup id="fnref:3"><a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup></td> </tr> <tr> <td><code>TS_STATE_DIR</code></td> <td><code>/var/lib/tailscale/</code></td> <td>required directory for storing Tailscale state, this should be mounted to the container for persistence</td> </tr> <tr> <td><code>TS_TAILSCALED_EXTRA_ARGS</code></td> <td><code>--verbose=1</code><sup id="fnref:4"><a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup></td> <td>optional additional <a href="https://tailscale.com/kb/1278/tailscaled#flags-to-tailscaled">flags</a> for <code>tailscaled</code></td> </tr> <tr> <td><code>TS_EXTRA_ARGS</code></td> <td><code>--ssh</code><sup id="fnref:5"><a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup></td> <td>optional additional <a href="https://tailscale.com/kb/1241/tailscale-up">flags</a> for <code>tailscale up</code></td> </tr> <tr> <td><code>TS_SERVE_PORT</code></td> <td><code>8080</code></td> <td>optional application port to expose with <a href="https://tailscale.com/kb/1312/serve">Tailscale Serve</a></td> </tr> <tr> <td><code>TS_FUNNEL</code></td> <td><code>1</code></td> <td>if set (to anything), will proxy <code>TS_SERVE_PORT</code> <strong>publicly</strong> with <a href="https://tailscale.com/kb/1223/funnel">Tailscale Funnel</a></td> </tr> </tbody> </table> <p>A few implementation notes:</p> <ul> <li>If you want to use Funnel with this configuration, it might be a good idea to associate the <a href="https://tailscale.com/kb/1223/funnel#tailnet-policy-file-requirement">Funnel ACL policy</a> with a tag (like <code>tag:funnel</code>), as I discussed a bit <a href="https://runtimeterror.dev/tailscale-ssh-serve-funnel/#tailscale-funnel">here</a>. And then when you create the <a href="https://tailscale.com/kb/1085/auth-keys">pre-auth key</a>, you can set it to automatically apply the tag so it can enable Funnel.</li> <li>It's very important that the path designated by <code>TS_STATE_DIR</code> is a volume mounted into the container. Otherwise, the container will lose its Tailscale configuration when it stops. That could be inconvenient.</li> <li>Linking <code>network_mode</code> on the application container back to the <code>service:tailscale</code> definition is <a href="https://docs.docker.com/compose/compose-file/05-services/#network_mode">the magic</a> that lets the sidecar proxy traffic for the app. This way the two containers effectively share the same network interface, allowing them to share the same ports. So port <code>8080</code> on the app container is available on the tailscale container, and that enables <code>tailscale serve --bg 8080</code> to work.</li> </ul> <h3 id="usage-examples">Usage Examples</h3> <p>To tie this all together, I'm going to quickly run through the steps I took to create and publish two container-based services without having to do any interactive configuration.</p> <h4 id="cyberchef">CyberChef</h4> <p>I'll start with my <a href="https://github.com/gchq/CyberChef">CyberChef</a> instance.</p> <blockquote> <p><em>CyberChef is a simple, intuitive web app for carrying out all manner of &quot;cyber&quot; operations within a web browser. These operations include simple encoding like XOR and Base64, more complex encryption like AES, DES and Blowfish, creating binary and hexdumps, compression and decompression of data, calculating hashes and checksums, IPv6 and X.509 parsing, changing character encodings, and much more.</em></p> </blockquote> <p>This will be served publicly with Funnel so that my friends can use this instance if they need it.</p> <p>I'll need a pre-auth key so that the Tailscale container can authenticate to my Tailnet. I can get that by going to the <a href="https://login.tailscale.com/admin/settings/keys">Tailscale Admin Portal</a> and generating a new auth key. I gave it a description, ticked the option to pre-approve whatever device authenticates with this key (since I have <a href="https://tailscale.com/kb/1099/device-approval">Device Approval</a> enabled on my tailnet). I also used the option to auto-apply the <code>tag:internal</code> tag I used for grouping my on-prem systems as well as the <code>tag:funnel</code> tag I use for approving Funnel devices in the ACL.</p> <p><img src="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/authkey1.png" alt="authkey creation"></p> <p>That gives me a new single-use authkey:</p> <p><img src="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/authkey2.png" alt="new authkey"></p> <p>I'll use that new key as well as the knowledge that CyberChef is served by default on port <code>8000</code> to create an appropriate <code>.env</code> file:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span>TS_AUTHKEY<span style="color:#f92672">=</span>tskey-auth-somestring-somelongerstring </span></span><span style="display:flex;"><span>TS_HOSTNAME<span style="color:#f92672">=</span>cyber </span></span><span style="display:flex;"><span>TS_EXTRA_ARGS<span style="color:#f92672">=</span>--ssh </span></span><span style="display:flex;"><span>TS_SERVE_PORT<span style="color:#f92672">=</span><span style="color:#ae81ff">8000</span> </span></span><span style="display:flex;"><span>TS_FUNNEL<span style="color:#f92672">=</span>true </span></span></code></pre></div><p>And I can add the corresponding <code>docker-compose.yml</code> to go with it:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">tailscale</span>: <span style="color:#75715e"># [tl! focus:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/jbowdre/tailscale-docker:latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">cyberchef-tailscale</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_AUTHKEY</span>: <span style="color:#ae81ff">${TS_AUTHKEY:?err}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_HOSTNAME</span>: <span style="color:#ae81ff">${TS_HOSTNAME:-ts-docker}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_STATE_DIR</span>: <span style="color:#e6db74">&#34;/var/lib/tailscale/&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_TAILSCALED_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_TAILSCALED_EXTRA_ARGS:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_EXTRA_ARGS:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_SERVE_PORT</span>: <span style="color:#ae81ff">${TS_SERVE_PORT:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_FUNNEL</span>: <span style="color:#ae81ff">${TS_FUNNEL:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">volumes</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./ts_data:/var/lib/tailscale/</span> <span style="color:#75715e"># [tl! focus:end]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">cyberchef</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">cyberchef</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">mpepping/cyberchef:latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">network_mode</span>: <span style="color:#ae81ff">service:tailscale</span> <span style="color:#75715e"># [tl! focus]</span> </span></span></code></pre></div><p>I can just bring it online like so:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>docker compose up -d <span style="color:#75715e"># [tl! .cmd .nocopy:1,4]</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">[</span>+<span style="color:#f92672">]</span> Running 3/3 </span></span><span style="display:flex;"><span> ✔ Network cyberchef_default Created </span></span><span style="display:flex;"><span> ✔ Container cyberchef-tailscale Started </span></span><span style="display:flex;"><span> ✔ Container cyberchef Started </span></span></code></pre></div><p>I can review the logs for the <code>tailscale</code> service to confirm that the Funnel configuration was applied:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>docker compose logs tailscale <span style="color:#75715e"># [tl! .cmd .nocopy:1,12 focus]</span> </span></span><span style="display:flex;"><span>cyberchef-tailscale | <span style="color:#75715e"># Health check:</span> </span></span><span style="display:flex;"><span>cyberchef-tailscale | <span style="color:#75715e"># - not connected to home DERP region 12</span> </span></span><span style="display:flex;"><span>cyberchef-tailscale | <span style="color:#75715e"># - Some peers are advertising routes but --accept-routes is false</span> </span></span><span style="display:flex;"><span>cyberchef-tailscale | 2023/12/30 17:44:48 serve: creating a new proxy handler <span style="color:#66d9ef">for</span> http://127.0.0.1:8000 </span></span><span style="display:flex;"><span>cyberchef-tailscale | 2023/12/30 17:44:48 Hostinfo.WireIngress changed to true </span></span><span style="display:flex;"><span>cyberchef-tailscale | Available on the internet: <span style="color:#75715e"># [tl! focus:6]</span> </span></span><span style="display:flex;"><span>cyberchef-tailscale | </span></span><span style="display:flex;"><span>cyberchef-tailscale | https://cyber.tailnet-name.ts.net/ </span></span><span style="display:flex;"><span>cyberchef-tailscale | |-- proxy http://127.0.0.1:8000 </span></span><span style="display:flex;"><span>cyberchef-tailscale | </span></span><span style="display:flex;"><span>cyberchef-tailscale | Funnel started and running in the background. </span></span><span style="display:flex;"><span>cyberchef-tailscale | To disable the proxy, run: tailscale funnel --https<span style="color:#f92672">=</span><span style="color:#ae81ff">443</span> off </span></span></code></pre></div><p>And after ~10 minutes or so (it sometimes takes a bit longer for the DNS and SSL to start working outside the tailnet), I'll be able to hit the instance at <code>https://cyber.tailnet-name.ts.net</code> from anywhere on the web.</p> <p><img src="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/cyberchef.png" alt="cyberchef"></p> <h4 id="miniflux">Miniflux</h4> <p>I've lately been playing quite a bit with <a href="https://jbowdre.omg.lol/">my omg.lol address</a> and <a href="https://home.omg.lol/referred-by/jbowdre">associated services</a>, and that's inspired me to <a href="https://rknight.me/blog/the-web-is-fantastic/">revisit the world</a> of curating RSS feeds instead of relying on algorithms to keep me informed. Through that experience, I recently found <a href="https://github.com/miniflux/v2">Miniflux</a>, a &quot;Minimalist and opinionated feed reader&quot;. It's written in Go, is fast and lightweight, and works really well as a PWA installed on mobile devices, too.</p> <p>It will be great for keeping track of my feeds, but I need to expose this service publicly. So I'll serve it up inside my tailnet with Tailscale Serve.</p> <p>Here's the <code>.env</code> that I'll use:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span>DB_USER<span style="color:#f92672">=</span>db-username </span></span><span style="display:flex;"><span>DB_PASS<span style="color:#f92672">=</span>db-passw0rd </span></span><span style="display:flex;"><span>ADMIN_USER<span style="color:#f92672">=</span>sysadmin </span></span><span style="display:flex;"><span>ADMIN_PASS<span style="color:#f92672">=</span>hunter2 </span></span><span style="display:flex;"><span>TS_AUTHKEY<span style="color:#f92672">=</span>tskey-auth-somestring-somelongerstring </span></span><span style="display:flex;"><span>TS_HOSTNAME<span style="color:#f92672">=</span>miniflux </span></span><span style="display:flex;"><span>TS_EXTRA_ARGS<span style="color:#f92672">=</span>--ssh </span></span><span style="display:flex;"><span>TS_SERVE_PORT<span style="color:#f92672">=</span><span style="color:#ae81ff">8080</span> </span></span></code></pre></div><p>Funnel will not be configured for this since <code>TS_FUNNEL</code> was not defined.</p> <p>I adapted the <a href="https://miniflux.app/docs/dacker.html#docker-compose">example <code>docker-compose.yml</code></a> from Miniflux to add in my Tailscale bits:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># torchlight! {&#34;lineNumbers&#34;: true}</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">tailscale</span>: <span style="color:#75715e"># [tl! focus:start]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/jbowdre/tailscale-docker:latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">miniflux-tailscale</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_AUTHKEY</span>: <span style="color:#ae81ff">${TS_AUTHKEY:?err}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_HOSTNAME</span>: <span style="color:#ae81ff">${TS_HOSTNAME:-ts-docker}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_STATE_DIR</span>: <span style="color:#e6db74">&#34;/var/lib/tailscale/&#34;</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_TAILSCALED_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_TAILSCALED_EXTRA_ARGS:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_EXTRA_ARGS</span>: <span style="color:#ae81ff">${TS_EXTRA_ARGS:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_SERVE_PORT</span>: <span style="color:#ae81ff">${TS_SERVE_PORT:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">TS_FUNNEL</span>: <span style="color:#ae81ff">${TS_FUNNEL:-}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">volumes</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./ts_data:/var/lib/tailscale/</span> <span style="color:#75715e"># [tl! focus:end]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">miniflux</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">miniflux/miniflux:latest</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">miniflux</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">depends_on</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">db</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">condition</span>: <span style="color:#ae81ff">service_healthy</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@db/miniflux?sslmode=disable</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">RUN_MIGRATIONS=1</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">CREATE_ADMIN=1</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">ADMIN_USERNAME=${ADMIN_USER}</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">ADMIN_PASSWORD=${ADMIN_PASS}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">network_mode</span>: <span style="color:#e6db74">&#34;service:tailscale&#34;</span> <span style="color:#75715e"># [tl! focus]</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">db</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">image</span>: <span style="color:#ae81ff">postgres:15</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">miniflux-db</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">environment</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">POSTGRES_USER=${DB_USER}</span> </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">POSTGRES_PASSWORD=${DB_PASS}</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">volumes</span>: </span></span><span style="display:flex;"><span> - <span style="color:#ae81ff">./mf_data:/var/lib/postgresql/data</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">healthcheck</span>: </span></span><span style="display:flex;"><span> <span style="color:#f92672">test</span>: [<span style="color:#e6db74">&#34;CMD&#34;</span>, <span style="color:#e6db74">&#34;pg_isready&#34;</span>, <span style="color:#e6db74">&#34;-U&#34;</span>, <span style="color:#e6db74">&#34;${DB_USER}&#34;</span>] </span></span><span style="display:flex;"><span> <span style="color:#f92672">interval</span>: <span style="color:#ae81ff">10s</span> </span></span><span style="display:flex;"><span> <span style="color:#f92672">start_period</span>: <span style="color:#ae81ff">30s</span> </span></span></code></pre></div><p>I can bring it up with:</p> <div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>docker compose up -d <span style="color:#75715e"># [tl! .cmd .nocopy:1,5]</span> </span></span><span style="display:flex;"><span><span style="color:#f92672">[</span>+<span style="color:#f92672">]</span> Running 4/4 </span></span><span style="display:flex;"><span> ✔ Network miniflux_default Created </span></span><span style="display:flex;"><span> ✔ Container miniflux-db Started </span></span><span style="display:flex;"><span> ✔ Container miniflux-tailscale Started </span></span><span style="display:flex;"><span> ✔ Container miniflux Created </span></span></code></pre></div><p>And I can hit it at <code>https://miniflux.tailnet-name.ts.net</code> from within my tailnet:</p> <p><img src="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/miniflux.png" alt="miniflux"></p> <p>Nice, right? Now to just convert all of my other containerized apps that don't really need to be public. Fortunately that shouldn't take too long since I've got this nice, portable, repeatable Docker Compose setup I can use.</p> <p>Maybe I'll write about something <em>other</em> than Tailscale soon. Stay tuned!</p> <div class="footnotes" role="doc-endnotes"> <hr> <ol> <li id="fn:1"> <p>While not documented for the image itself, the <code>containerboot</code> binary seems like it should accept a <a href="https://github.com/tailscale/tailscale/blob/5812093d31c8a7f9c5e3a455f0fd20dcc011d8cd/cmd/containerboot/main.go#L43"><code>TS_SERVE_CONFIG</code> argument</a> to designate the file path of the <code>ipn.ServeConfig</code>... but I couldn't find any information on how to actually configure that.&#160;<a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:2"> <p>If <em>neither</em> variable is set, the script just brings up Tailscale like normal... in which case you might as well just use the official image.&#160;<a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:3"> <p>This hostname will determine the fully-qualified domain name where the resource will be served: <code>https://[hostname].[tailnet-name].ts.net</code>. So you'll want to make sure it's a good one for what you're trying to do.&#160;<a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:4"> <p>Passing the <code>--verbose</code> flag to <code>tailscaled</code> increases the logging verbosity, which can be helpful if you need to troubleshoot.&#160;<a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> <li id="fn:5"> <p>The <code>--ssh</code> flag to <code>tailscale up</code> will enable Tailscale SSH and (ACLs permitting) allow you to easily SSH directly into the <em>Tailscale</em> container without having to talk to the Docker host and spawn a shell from there.&#160;<a href="https://runtimeterror.dev/tailscale-serve-docker-compose-sidecar/#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p> </li> </ol> </div> </description> </item> </channel> </rss>