https://kristianfreeman.com kristianfreeman.com 2024-10-18T04:01:06.747750+00:00 kristian hidden python-feedgen Hey! I'm Kristian Freeman. Thanks for stopping by. I'm currently a Senior Developer Advocate at . I get to teach developers how to build apps on the Cloud... https://kristianfreeman.com/sleepy-time-tea/ Sleepy time tea 2024-10-11T19:04:41.744497+00:00 kristian hidden <p>My current recipe for tea at nighttime. It tastes good, and knocks me out about 30-60min after I drink it.</p> <p>Anecdotally, this has also seemed to increase my time in deep sleep <a href='/the-whoop-is-quite-cool'>via my WHOOP</a>, but I haven't tracked it well enough to know for sure. The "you had extra deep sleep last night!" notification seems to be firing pretty regularly since I started making this recipe about a week and a half ago. Great!</p> <h2 id=recipe>Recipe</h2><ul> <li>1 chamomile tea bag (<a href='https://amzn.to/3ZO7Suc'>link</a>)</li> <li>5g glycine (<a href='https://amzn.to/47QvHDw'>link</a>)</li> <li>3.5g magnesium glycinate (<a href='https://purebulk.com/products/magnesium-glycinate'>link</a>)</li> <li>A2 whole milk</li> <li>Raw honey, to taste (<a href='https://amzn.to/3ZRK9t7'>link</a>)</li> </ul> <h2 id=update>Update</h2><p>This is working! My WHOOP score has definitely improved. Still the same recipe I wrote about in the original post.</p> <blockquote class="twitter-tweet" data-theme="dark"><p lang="en" dir="ltr">The sleepy time tea is a winner<br><br>Tracked as “magnesium supplement” in WHOOP because they don’t allow arbitrary stat tracking<br><br>Recovery is up 5% and I’m hitting 100% sleep most nights (blank night from when I lost my charger for a day!)<a href="https://t.co/K5uXOWNA51">https://t.co/K5uXOWNA51</a> <a href="https://t.co/04D7Hb2AIt">pic.twitter.com/04D7Hb2AIt</a></p>&mdash; Kristian Freeman (@kristianf_) <a href="https://twitter.com/kristianf_/status/1844764426391458227?ref_src=twsrc%5Etfw">October 11, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 2024-10-11T19:04:00+00:00 https://kristianfreeman.com/building-your-own-radio-network-with-liquidsoap/ Building your own radio network with Liquidsoap 2024-10-10T15:00:13.350584+00:00 kristian hidden <p>With a ton of archived music and podcasts sitting on my NAS, I wanted to do something fun and run my own radio network - a collection of radio stations like "Business Podcasts", "Metal", and whatever else I might want to listen to. Then, I can tune into it at any point of time via my phone or on my Sonos speakers.</p> <p>You can build this with <a href='https://www.liquidsoap.info/'>Liquidsoap</a> - described as "a powerful and flexible language for describing audio and video streams".<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup></p> <p>I've created an open-source repository <a href='https://github.com/kristianfreeman/radio'>kristianfreeman/radio</a> that will help you do this.</p> <p>First, you can set up a <code>config.yaml</code>:</p> <div class="highlight"><pre><span></span><span class="nt">stations</span><span class="p">:</span> <span class="w"> </span><span class="c1"># Podcasts</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">comedy</span> <span class="w"> </span><span class="nt">directories</span><span class="p">:</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="s">&quot;/podcasts/comedy-podcast&quot;</span> <span class="w"> </span><span class="nt">shuffle</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">true</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">business</span> <span class="w"> </span><span class="nt">directories</span><span class="p">:</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="s">&quot;/podcasts/business-podcast&quot;</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="s">&quot;/podcasts/business-podcast2&quot;</span> <span class="w"> </span><span class="nt">shuffle</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">true</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">business_focus</span> <span class="w"> </span><span class="nt">directories</span><span class="p">:</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="s">&quot;/podcasts/business-podcast&quot;</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="s">&quot;/podcasts/business-podcast2&quot;</span> <span class="w"> </span><span class="nt">music_directories</span><span class="p">:</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="s">&quot;/music/bgm&quot;</span> <span class="w"> </span><span class="nt">podcast_volume</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">0.7</span> <span class="w"> </span><span class="nt">music_volume</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">0.3</span> <span class="w"> </span><span class="nt">shuffle</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">true</span> <span class="w"> </span><span class="c1"># Music</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">edm</span> <span class="w"> </span><span class="nt">directories</span><span class="p">:</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="s">&quot;/music/techno&quot;</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">metal</span> <span class="w"> </span><span class="nt">directories</span><span class="p">:</span> <span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="s">&quot;/music/metal&quot;</span> </pre></div> <p>This example shows a few varieties of stations. You can name each station, and pass a directory of audio files to load. Liquidsoap will recursively search the directories and add the files inside to the station (and shuffle them, via the <code>shuffle</code> param).</p> <p>My favorite is the hybrid music/podcast station. It basically muxes music and podcasts together on the fly - so you can build fun mixes. For instance, I have a bunch of chill vaporwave/focus music mixed with business podcasts.</p> <p>With your <code>config.yaml</code> set up, you can run <code>make</code> to run the build script. It's a Python script that parses the config and outputs a <code>.liq</code> file for Liquidsoap. That file gets executed by Liquidsoap, and your radio stations will begin!</p> <p>The Liquidsoap server runs a server called "Harbor" that outputs all the stations on a single URL structure:</p> <ul> <li><code>localhost:8000/business</code></li> <li><code>localhost:8000/comedy</code></li> <li><code>localhost:8000/metal</code></li> </ul> <p>With those URLs, you can plug them into VLC, iTunes, or add them on your Sonos app, and start streaming!</p> <section class="footnotes"> <ol> <li id="fn-1"><p>Liquidsoap is <em>really</em> funky. This post isn't a tutorial on it. I used ChatGPT to help me write the code.<a href="#fnref-1" class="footnote">&#8617;</a></p></li> </ol> </section> 2024-10-10T14:59:00+00:00 https://kristianfreeman.com/my-morning-coffee/ My morning coffee 2024-10-09T15:00:23.180186+00:00 kristian hidden <p>My morning coffee:</p> <ol> <li>Freshly ground <a href='https://rutamayacoffee.com/'>Medium roast Ruta Maya coffee</a><sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup> brewed with Aeropress<sup class="footnote-ref" id="fnref-2"><a href="#fn-2">2</a></sup></li> <li>1 scoop collagen peptides<sup class="footnote-ref" id="fnref-3"><a href="#fn-3">3</a></sup></li> <li>~5-10g creatine</li> <li>~300-500mg pure L-theanine<sup class="footnote-ref" id="fnref-4"><a href="#fn-4">4</a></sup> powder</li> <li>1000 IU vitamin D3 + 200 micrograms vitamin K2<sup class="footnote-ref" id="fnref-5"><a href="#fn-5">5</a></sup></li> </ol> <p>This is a follow-up to my <a href='/sleepy-time-tea'>sleepy time tea recipe</a>!</p> <section class="footnotes"> <ol> <li id="fn-1"><p>I emailed the address on the website to ask about testing, particularly around mycotoxins. The email bounced 💀<a href="#fnref-1" class="footnote">&#8617;</a></p></li> <li id="fn-2"><p>No special process here. There is a whole world of Aeropress brewing, but I just stick with the normal process as laid out in the instructions.<a href="#fnref-2" class="footnote">&#8617;</a></p></li> <li id="fn-3"><p>Before we were more conscious of ingredients, we had an Amazon subscription for some randomly sourced collagen. At some point, when we run out of our supply of collagen, I'll switch to a more legit/high-quality source.<a href="#fnref-3" class="footnote">&#8617;</a></p></li> <li id="fn-4"><p>This is new to my recipe, but the interactions between L-theanine and caffeine are well-defined: less jitters, pure caffeine buzz. It's great!<a href="#fnref-4" class="footnote">&#8617;</a></p></li> <li id="fn-5"><p>I have an Athletics Green-provided supply of this that came with my old AG1 supply. It's a droplet that is completely easy to over-dose - bad design. Another "I'll use it until I run out of it" thing.<a href="#fnref-5" class="footnote">&#8617;</a></p></li> </ol> </section> 2024-10-09T15:00:23.170828+00:00 https://kristianfreeman.com/defer-loading-css-files-with-one-line-of-code/ Defer loading CSS files with one line of code 2024-10-08T21:16:19.798580+00:00 kristian hidden <p>Here's how to defer CSS loading with one line of code, no extra dependencies needed:</p> <div class="highlight"><pre><span></span><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&quot;stylesheet&quot;</span> <span class="na">href</span><span class="o">=</span><span class="s">&quot;/path/to/my.css&quot;</span> <span class="na">media</span><span class="o">=</span><span class="s">&quot;print&quot;</span> <span class="na">onload</span><span class="o">=</span><span class="s">&quot;this.media=&#39;all&#39;&quot;</span><span class="p">&gt;</span> </pre></div> <p>We're setting the <code>media</code> property here to "print", meaning that it's only relevant when <em>printing</em> this page. But when <code>onload</code> fires, we change the <code>media</code> property to all - meaning it's relevant to the page again. The browser will then load the CSS. It's a tricky but effective way of deferring CSS until after page load.</p> 2024-10-08T21:16:19.789112+00:00 https://kristianfreeman.com/increasing-lighthouse-score-to-100-how-i-did-it-on-my-blog/ Increasing Lighthouse score to 100 - how I did it on my blog 2024-10-08T21:14:50.571396+00:00 kristian hidden <p>I know, I know - on a static site, a blog. It's not an incredibly demanding site, by any means. But the same strategy to improving perf applies on larger sites. Here's how I hit 100 on Performance stat for this site.</p> <p><img src="https://bear-images.sfo2.cdn.digitaloceanspaces.com/kristian/142x.webp" alt="Perfect Lighthouse score" /></p> <p>If you have direct control over the scripts and CSS you run on your site, you're in a good spot. It's pretty likely that Chrome's <a href='https://developer.chrome.com/docs/lighthouse/performance/render-blocking-resources'>"Eliminate render-blocking resources" guide</a> is going to be your best friend here.</p> <p>On this blog, I had a few scripts and stylesheets running: particularly, <a href='https://github.com/goblindegook/littlefoot'>littlefoot</a> for footnotes, and <a href='https://instant.page/'>instant.page</a> to allow instant page loads. Adding <code>async</code> to these script tags and deferring the CSS load was the bulk of changes I needed to make directly on the site.</p> <p>The print media trick for CSS works well here:</p> <div class="highlight"><pre><span></span><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&quot;stylesheet&quot;</span> <span class="na">href</span><span class="o">=</span><span class="s">&quot;/path/to/my.css&quot;</span> <span class="na">media</span><span class="o">=</span><span class="s">&quot;print&quot;</span> <span class="na">onload</span><span class="o">=</span><span class="s">&quot;this.media=&#39;all&#39;&quot;</span><span class="p">&gt;</span> </pre></div> <p>We're setting the <code>media</code> property here to "print", meaning that it's only relevant when printing this page. But when <code>onload</code> fires, we change the <code>media</code> property to all - meaning it's relevant to the page again. The browser will then load the CSS. It's a tricky but effective way of deferring CSS until after page load.</p> <p>I also did some work on my Cloudflare config for this site to improve time to paint metrics. You can see in the below chart when I turned on caching at the Cloudflare level for my site:</p> <p><img src="https://bear-images.sfo2.cdn.digitaloceanspaces.com/kristian/092x.webp" alt="Cloudflare cache analytics" /></p> <p>I created a rule that is literally just "Cache Everything"<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup>, with a 60 second time-to-live setting. That means that my site is basically on a rolling sixty second cache, <em>at the edge server level</em>, not at the browser. bearblog doesn't seem to have any caching headers included, so instead of trying to lean on it for any sort of knowledge about when and why to cache, this implementation is simple. When a page is hit, it refreshes the cache, then serves out of it for a minute. After a minute, the cache is restarted. I have Cloudflare's "stale while revalidate" param set to true, so it serves stale content while it refreshes the cache.<sup class="footnote-ref" id="fnref-2"><a href="#fn-2">2</a></sup></p> <hr /> <p>There's more work to do with SEO, but the site feels <em>super</em> fast. As more traffic comes here from my <a href='/domain-ranking-experiment'>SEO experiments</a>, it will be satisfying to know that the site is really fast, no matter where you're accessing it from.</p> <section class="footnotes"> <ol> <li id="fn-1"><p>Cloudflare, having a massive CDN after all, has a ton of docs around caching. <a href='https://developers.cloudflare.com/cache/how-to/cache-rules/'>Available here</a>.<a href="#fnref-1" class="footnote">&#8617;</a></p></li> <li id="fn-2"><p>It's a blog! It's not a big deal to serve stale content. YMMV.<a href="#fnref-2" class="footnote">&#8617;</a></p></li> </ol> </section> 2024-10-08T21:14:50.544951+00:00 https://kristianfreeman.com/understanding-astros-getstaticpaths-function/ Understanding Astro's getStaticPaths function 2024-10-08T21:30:17.964882+00:00 kristian hidden <p><code>getStaticPaths</code> is the secret sauce for one of <a href='/an-introduction-to-astros-content-system/'>Astro's</a> main tricks: generating <em>static pages</em> for <em>dynamic routes</em>.</p> <p>Imagine we have a blog with three posts (slugs defined below):</p> <ol> <li><code>hello-corgi</code></li> <li><code>building-chatgpt-for-corgis</code></li> <li><code>addressing-the-haters</code></li> </ol> <p>Given an index page to list all of our blog posts (<code>/blog</code>), we have four routes in total.</p> <p>In Astro, we would define two page files in <code>src/pages</code> to handle these:</p> <ol> <li><code>src/pages/blog.astro</code> - render the <code>/blog</code> page</li> <li><code>src/pages/blog/[slug].astro</code> - render a blog post, at <code>/blog/:slug</code></li> </ol> <p>By default, the blog post page is <em>dynamic</em>. If your Astro app gets a request to <code>/blog/hello-corgi</code>, it will look through the routes it knows about, and generate the blog post page.</p> <p>That's great! File-based routing is incredibly easy to implement.</p> <p>But what about if we want to know about those pages ahead of time? We already have <code>src/content/blog/hello-corgi.md</code> and so on - we know these routes will <em>always</em> exist. Couldn't we optimize by making those pages <em>static</em>?</p> <p>This is where the <code>getStaticPaths</code> comes in handy. First, we'll indicate to Astro that we want this blog post page to be static, by setting the <code>prerender</code> attribute to <code>true</code>. Second, we'll get <em>all</em> of the blog posts, and export a list of static paths, based on the slug:</p> <pre class="shiki-highlight" data-language="astro">--- export const prerender = true import { getCollection } from "astro:content"; export async function getStaticPaths() { const blogEntries = await getCollection("blog"); return blogEntries.map((entry) => ({ params: { slug: entry.slug }, props: { entry }, })); } // Do other stuff to render the blog post ---</pre> <p>By implementing static paths, the Astro build engine can generate these pages ahead of time.</p> <p>This gets even cooler when you introduce headless CMS tools like <a href='https://sanity.io'>Sanity.io</a>. You can have all of your blog posts live in a CMS, but when it comes time to build the site, you can make an API call at build time, grab X number of posts, and build X number of static pages.</p> <p>Another place this can be helpful is with building <a href='https://docs.astro.build/en/guides/integrations-guide/sitemap'>sitemaps</a>. When I first implemented a sitemap for <a href='https://gangsheet.app'>Gangsheet</a>, I noticed it had every page... but my blog posts. Because these were dynamically generated and rendered, they weren't able to be added onto the sitemap.<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup> By adding <code>getStaticPaths</code> to my blog post page, and prerendering it, I was able to see it show up <a href='https://gangsheet.app/sitemap-0.xml'>on the Gangsheet sitemap</a>.</p> <section class="footnotes"> <ol> <li id="fn-1"><p>Most crawlers are pretty smart nowadays. If the blog post is linked somewhere on the site, the average Google/whatever search engine crawler can <em>still</em> render JS apps and grab the URLs as needed. But I'm old-school!<a href="#fnref-1" class="footnote">&#8617;</a></p></li> </ol> </section> 2024-10-08T19:25:00+00:00 https://kristianfreeman.com/bearblog-chrome-extension/ Releasing my bearblog Chrome extension 2024-10-08T16:30:13.729472+00:00 kristian hidden <p>I wrote a quick Chrome extension<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup> that makes it easier to navigate when working with bearblog.</p> <p>Features:</p> <ul> <li>Switcher for navigating between multiple bearblogs</li> <li>Quick shortcuts for navigating to some of the most busy pages in the admin (Nav, Posts, Theme, etc.)</li> <li>Auto-loading toolbar <em>on your custom domain</em> - think the WordPress navbar that shows up when you're authed on your WordPress site, and looking at the frontend</li> </ul> <p><img src="https://bear-images.sfo2.cdn.digitaloceanspaces.com/kristian/302x.webp" alt="The dev toolbar" /></p> <p><em>The dev toolbar that shows up on both bearblog.dev _and</em> your custom domain._</p> <p><img alt="Settings" src="https://bear-images.sfo2.cdn.digitaloceanspaces.com/kristian/352x.webp" style="max-width: 280px" /></p> <p><em>The settings popup that you can use to set up your sites.</em></p> <p>You can find the release <a href='https://github.com/kristianfreeman/bearblog-dev-toolbar/releases/tag/v1.0.1'>here</a>. Grab the ZIP and "Load unpacked" in your Chrome extension settings.</p> <section class="footnotes"> <ol> <li id="fn-1"><p>At least, it <em>should</em> have been quick. I tried to get it working on Safari, which was a terrible experience. The bulk of the code was quick to write.<a href="#fnref-1" class="footnote">&#8617;</a></p></li> </ol> </section> 2024-10-08T16:28:00+00:00 https://kristianfreeman.com/how-to-add-cloudflare-turnstile-to-your-ruby-on-rails-application/ How to add Cloudflare Turnstile to your Ruby on Rails application 2024-10-08T19:27:17.911646+00:00 kristian hidden <p>tl;dr - the <code>rails-cloudflare-turnstile</code> gem<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup> (<a href='https://github.com/instrumentl/rails-cloudflare-turnstile'>GitHub link</a>) is a great way to add <a href='https://developers.cloudflare.com/turnstile'>Cloudflare Turnstile</a> to your Rails app. Let's learn how to use it!</p> <h2 id=setup>Setup</h2><p>First, install the gem:</p> <div class="highlight"><pre><span></span><span class="n">bundle</span><span class="w"> </span><span class="n">add</span><span class="w"> </span><span class="n">rails</span><span class="o">-</span><span class="n">cloudflare</span><span class="o">-</span><span class="n">turnstile</span> </pre></div> <p>If you haven't enabled Turnstile yet in your Cloudflare account, follow the <a href='https://developers.cloudflare.com/turnstile/get-started/'>"Get Started" guide</a>. You'll need a sitekey and secret key. Make sure to associate it with your domain too - for instance, <code>kristianfreeman.com</code> - in the Turnstile settings.</p> <p>Add your sitekey and secret key to your Rails app - I like to use <code>rails credentials:edit</code>:</p> <div class="highlight"><pre><span></span>$<span class="w"> </span>rails<span class="w"> </span>credentials:edit<span class="w"> </span>-e<span class="w"> </span>development $<span class="w"> </span>rails<span class="w"> </span>credentials:edit<span class="w"> </span>-e<span class="w"> </span>production </pre></div> <p>My credentials are structured like this:</p> <div class="highlight"><pre><span></span><span class="nt">cloudflare_turnstile</span><span class="p">:</span> <span class="w"> </span><span class="nt">site_key</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">foo</span> <span class="w"> </span><span class="nt">secret_key</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">bar</span> </pre></div> <p>Create an initializer file, called <code>config/initializers/turnstile.rb</code>:</p> <div class="highlight"><pre><span></span><span class="no">RailsCloudflareTurnstile</span><span class="o">.</span><span class="n">configure</span><span class="w"> </span><span class="k">do</span><span class="w"> </span><span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="w"> </span><span class="n">c</span><span class="o">.</span><span class="n">site_key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">credentials</span><span class="o">.</span><span class="n">cloudflare_turnstile</span><span class="o">[</span><span class="ss">:site_key</span><span class="o">]</span> <span class="w"> </span><span class="n">c</span><span class="o">.</span><span class="n">secret_key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">credentials</span><span class="o">.</span><span class="n">cloudflare_turnstile</span><span class="o">[</span><span class="ss">:secret_key</span><span class="o">]</span> <span class="w"> </span><span class="n">c</span><span class="o">.</span><span class="n">fail_open</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kp">false</span> <span class="k">end</span> </pre></div> <h2 id=usage>Usage</h2><p>First, we'll add the Turnstile JS script into an application layout file. If you are super performance-sensitive, you may want to do this <em>specifically</em> on the pages you're going to use Turnstile. Here, I'll just add it in <code>app/views/layouts/application.html.erb</code>:</p> <div class="highlight"><pre><span></span><span class="x">&lt;head&gt;</span> <span class="x"> </span><span class="cp">&lt;%=</span><span class="w"> </span><span class="n">cloudflare_turnstile_script_tag</span><span class="w"> </span><span class="cp">%&gt;</span> <span class="x">&lt;/head&gt;</span> </pre></div> <p>In your forms, you can use the <code>&lt;%= cloudflare_turnstile %&gt;</code> partial to embed the Turnstile UI element right into your form. For instance, on a signup page:</p> <div class="highlight"><pre><span></span><span class="x">&lt;div&gt;</span> <span class="x"> </span><span class="cp">&lt;%=</span><span class="w"> </span><span class="n">cloudflare_turnstile</span><span class="w"> </span><span class="cp">%&gt;</span> <span class="x"> </span><span class="cp">&lt;%=</span><span class="w"> </span><span class="n">f</span><span class="o">.</span><span class="n">submit</span><span class="w"> </span><span class="n">t</span><span class="p">(</span><span class="s2">&quot;passwordless.sessions.new.submit&quot;</span><span class="p">),</span><span class="w"> </span><span class="ss">class</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;btn&quot;</span><span class="w"> </span><span class="cp">%&gt;</span> <span class="x">&lt;/div&gt;</span> </pre></div> <p><strong>Importantly, you also need to validate on the server-side!</strong> Speaking as a CF employee who has talked to the Turnstile team, there is a lot of people implementing Turnstile... without the server-side validation.</p> <p>Users are created in my app in <code>UsersController#create</code>. Let's validate the Turnstile data before calling that method:</p> <div class="highlight"><pre><span></span><span class="k">class</span><span class="w"> </span><span class="nc">UsersController</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="no">ApplicationController</span> <span class="w"> </span><span class="n">before_action</span><span class="w"> </span><span class="ss">:validate_cloudflare_turnstile</span><span class="p">,</span><span class="w"> </span><span class="ss">only</span><span class="p">:</span><span class="w"> </span><span class="o">[</span><span class="ss">:create</span><span class="o">]</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="no">Rails</span><span class="o">.</span><span class="n">env</span><span class="o">.</span><span class="n">production?</span> <span class="w"> </span><span class="n">rescue_from</span><span class="w"> </span><span class="no">RailsCloudflareTurnstile</span><span class="o">::</span><span class="no">Forbidden</span><span class="p">,</span><span class="w"> </span><span class="ss">with</span><span class="p">:</span><span class="w"> </span><span class="ss">:forbidden_turnstile</span> <span class="w"> </span><span class="k">def</span><span class="w"> </span><span class="nf">create</span> <span class="w"> </span><span class="c1"># Implementation of creating users</span> <span class="w"> </span><span class="k">end</span> <span class="w"> </span><span class="kp">private</span> <span class="w"> </span><span class="k">def</span><span class="w"> </span><span class="nf">forbidden_turnstile</span> <span class="w"> </span><span class="n">flash</span><span class="o">[</span><span class="ss">:error</span><span class="o">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">&quot;We had a problem creating your account.&quot;</span> <span class="w"> </span><span class="n">redirect_to</span><span class="w"> </span><span class="n">root_path</span> <span class="w"> </span><span class="k">end</span> </pre></div> <p>If the validation fails, the gem will throw an <code>RailsCloudflareTurnstile::Forbidden</code> exception. You need to <code>rescue_from</code> that exception in the controller, and do something with that failure. I prefer not to tell users - they're probably spammers - that their validation against Turnstile's rules fail.</p> <p>Turnstile is great - really easy to configure, and literally saving me money by reducing the strain on my servers and ancillary analytics/marketing products, by reducing the number of junk users being added to my app. It only took ~15 minutes to get this implemented on one of my Rails apps, and I'm already seeing results.</p> <section class="footnotes"> <ol> <li id="fn-1"><p>Not my gem! But it's great - thanks to Instrumentl for building it.<a href="#fnref-1" class="footnote">&#8617;</a></p></li> </ol> </section> 2024-10-07T19:51:00+00:00 https://kristianfreeman.com/the-big-bearblog-syntax-highlighting-hack/ The big bearblog syntax highlighting hack 2024-10-07T19:03:58.169750+00:00 kristian hidden <p>While writing my post about <a href='/an-introduction-to-astros-content-system'>Astro's content system</a>, I realized that <a href='https://bearblog.dev'>bearblog</a> doesn't have syntax highlighting support for a lot of newer languages and frameworks.</p> <p>For a few hours, I had actually added a notice:</p> <p><img src="https://bear-images.sfo2.cdn.digitaloceanspaces.com/kristian/572x.webp" alt="CleanShot 2024-10-07 at 13" /></p> <p>An Astro code block (raw version, seen below) doesn't work as expected:</p> <pre class="shiki-highlight" data-language="markdown">```astro --- const { slug } = Astro.params --- <p>The current slug is {slug}</p></pre> <p>It renders, but it doesn't have any highlighting.</p> <p>bearblog uses <a href='https://pygmentify.com'>pygmentity</a>, which is serviceable, but missing a lot of modern stuff.</p> <p>I wanted to see if I could get custom syntax highlighting working on my site - by side-stepping pygmentify entirely and doing it by hand on the client-side.</p> <p>And it worked! In fact, the above Markdown snippet is rendered with my custom solution. Let's look at how it works.</p> <h2 id=shiki>Shiki</h2><p><a href='https://shiki.style'>Shiki</a> is a modern syntax highlighting JS library that I first noticed in Astro's docs. It looks great. And it has theme support, meaning I can use my beloved <a href='https://catppuccin.com'>Catppuccin Mocha</a> without having to come up with a bunch of custom CSS selectors.</p> <p>It has the ability to be used via CDN, too. Here's the example they give on the site:</p> <div class="highlight"><pre><span></span><span class="p">&lt;</span><span class="nt">body</span><span class="p">&gt;</span> <span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&quot;foo&quot;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span> <span class="p">&lt;</span><span class="nt">script</span> <span class="na">type</span><span class="o">=</span><span class="s">&quot;module&quot;</span><span class="p">&gt;</span> <span class="w"> </span><span class="c1">// be sure to specify the exact version</span> <span class="w"> </span><span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">codeToHtml</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s1">&#39;https://esm.sh/shiki@1.0.0&#39;</span> <span class="w"> </span><span class="c1">// or</span> <span class="w"> </span><span class="c1">// import { codeToHtml } from &#39;https://esm.run/shiki@1.0.0&#39;</span> <span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">foo</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;foo&#39;</span><span class="p">)</span> <span class="w"> </span><span class="nx">foo</span><span class="p">.</span><span class="nx">innerHTML</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="nx">codeToHtml</span><span class="p">(</span><span class="s1">&#39;console.log(&quot;Hi, Shiki on CDN :)&quot;)&#39;</span><span class="p">,</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">lang</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;js&#39;</span><span class="p">,</span> <span class="w"> </span><span class="nx">theme</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;rose-pine&#39;</span> <span class="w"> </span><span class="p">})</span> <span class="w"> </span><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span> <span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span> </pre></div> <p>Basically, you can import it as an ESM, grab a given element, and replace the contents of it with transformed code passed through Shiki.</p> <p>This gives us a great starting point. We need to grab any code in our blog post on page load, pass it through Shiki, and then replace the code with the new syntax highlighted version.</p> <h2 id=implementation>Implementation</h2><p>Two problems quickly emerged as I tried to implement this:</p> <ol> <li>The code blocks generated by bearblog have no language attached to them. If you specify a code block as "markdown", any indication that the code <em>was</em> Markdown is stripped away by the time the client loads the page.</li> <li>The highlighted content doesn't "look" like the original code. A python snippet isn't Python anymore, it's HTML styled to look like Python.</li> </ol> <p>That means we have to sidestep the entire code highlighting part of bearblog. Which is annoying, but certainly doable.</p> <p>Instead, we can just provide a <code>pre</code> element inside of HTML, and give it a data attribute of <code>language</code>. <code>pre</code> elements retain the spacing of the text content inside, so it looks like code, and gets parsed by Shiki like it too!</p> <p>Here's a very meta example of the first snippet in this post - how it actually appears in the raw post content. It's a <code>pre</code> with class <code>shiki-highlight</code>, and <code>data-language</code> set to Markdown:</p> <div class="highlight"><pre><span></span><span class="p">&lt;</span><span class="nt">pre</span> <span class="na">class</span><span class="o">=</span><span class="s">&quot;shiki-highlight&quot;</span> <span class="na">data-language</span><span class="o">=</span><span class="s">&quot;markdown&quot;</span><span class="p">&gt;</span>```astro --- const { slug } = Astro.params --- <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>The current slug is {slug}<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;&lt;/</span><span class="nt">pre</span><span class="p">&gt;</span> </pre></div> <p>Now, we<sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup> can write some custom JavaScript to find all instances of <code>pre.shiki-highlight</code>, and do the following:</p> <ol> <li>Transform the content through Shiki, and put it in a new <code>pre</code> tag with all the right formatting.</li> <li>Take the original content and wrap it with <code>noscript</code> - that way, if someone has JS disabled, they still see code.</li> </ol> <p>The full snippet, including comments, is included below:</p> <div class="highlight"><pre><span></span><span class="p">&lt;</span><span class="nt">script</span> <span class="na">type</span><span class="o">=</span><span class="s">&quot;module&quot;</span><span class="p">&gt;</span> <span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">codeToHtml</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s1">&#39;https://esm.sh/shiki@1.0.0&#39;</span><span class="p">;</span> <span class="kd">const</span><span class="w"> </span><span class="nx">codeBlocks</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s2">&quot;pre.shiki-highlight&quot;</span><span class="p">);</span> <span class="nb">Array</span><span class="p">.</span><span class="kr">from</span><span class="p">(</span><span class="nx">codeBlocks</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="k">async</span><span class="w"> </span><span class="nx">el</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="c1">// Create a noscript element</span> <span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">noscript</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;noscript&#39;</span><span class="p">);</span> <span class="w"> </span> <span class="w"> </span><span class="c1">// Clone the original div into the noscript element as a fallback</span> <span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">clone</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">cloneNode</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span> <span class="w"> </span><span class="nx">noscript</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">clone</span><span class="p">);</span> <span class="w"> </span> <span class="w"> </span><span class="c1">// Insert noscript after the current element</span> <span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">parentNode</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">noscript</span><span class="p">,</span><span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">nextSibling</span><span class="p">);</span> <span class="w"> </span> <span class="w"> </span><span class="c1">// Generate rendered HTML (which might already include a &lt;pre&gt; from Shiki)</span> <span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">transformedHtml</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="nx">codeToHtml</span><span class="p">(</span><span class="nx">el</span><span class="p">.</span><span class="nx">innerText</span><span class="p">,</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">lang</span><span class="o">:</span><span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">language</span><span class="p">,</span> <span class="w"> </span><span class="nx">theme</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;catppuccin-mocha&#39;</span><span class="p">,</span> <span class="w"> </span><span class="p">});</span> <span class="w"> </span> <span class="w"> </span><span class="c1">// Parse the resulting HTML string into a DOM node (to get the inner &lt;pre&gt;)</span> <span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">tempContainer</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</span><span class="p">);</span> <span class="w"> </span><span class="nx">tempContainer</span><span class="p">.</span><span class="nx">innerHTML</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">transformedHtml</span><span class="p">;</span> <span class="w"> </span> <span class="w"> </span><span class="c1">// Grab the &lt;pre&gt; from the generated HTML (Shiki returns &lt;pre&gt; with code already)</span> <span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">generatedPre</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">tempContainer</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;pre&#39;</span><span class="p">);</span> <span class="w"> </span> <span class="w"> </span><span class="c1">// Insert the new &lt;pre&gt; with its styles and classes after the noscript element</span> <span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nx">generatedPre</span><span class="p">)</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="nx">generatedPre</span><span class="p">.</span><span class="nx">style</span><span class="p">[</span><span class="s2">&quot;background-color&quot;</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">&quot;var(--code-background-color)&quot;</span><span class="p">;</span> <span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">parentNode</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">generatedPre</span><span class="p">,</span><span class="w"> </span><span class="nx">noscript</span><span class="p">.</span><span class="nx">nextSibling</span><span class="p">);</span> <span class="w"> </span><span class="p">}</span> <span class="w"> </span> <span class="w"> </span><span class="c1">// Hide the original pre</span> <span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;none&#39;</span><span class="p">;</span> <span class="p">});</span> <span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span> </pre></div> <h2 id=issues>Issues</h2><p>The main issue with this approach is that <strong>HTML still has to be escaped.</strong> <code>.astro</code> files are a combination of JavaScript and HTML. Any HTML <em>inside</em> of a <code>pre</code> tag gets evaluated <em>as</em> HTML, meaning it can quickly spiral out of control and try and render that HTML <em>inside of your blog post</em>! Uh-oh. Instead, we have to escape any HTML in the raw Markdown of our post before it even gets to Shiki on the client.</p> <p>The second issue: this is a lot of work, and pretty brittle. 99% of the code samples on this site are pushed through Pygmentify with little issue, and the GPT-generated replacements I worked up for Pygmentify to render Catppuccin Mocha colors are <em>good enough</em>.</p> <p>For newer file formats, this hack is a solution. But it would be better to have better support on bearblog's server for other highlighting solutions. <a href='https://bear.nolt.io/309'>I opened a feature suggestion</a> to begin the convo about improving the syntax highlighting situation in bearblog. It would be even cooler if we could just get Shiki or another newer engine built into Bearblog, with the ability to switch to it with a single click.</p> <p>I have noticed that Shiki has the ability to <a href='https://shiki.style/guide/install#cloudflare-workers'>run inside of Cloudflare Workers</a>, meaning that there could be a cool solution where the client doesn't even know or care about Shiki - the code to transform the HTML and syntax highlight it could happen on the edge, on the way to the reader. I don't see any particular advantage to implementing it there <em>yet</em> - but if we could turn off syntax highlighting and pass the raw Markdown block through the server to the client, this could be an interesting solution.</p> <section class="footnotes"> <ol> <li id="fn-1"><p>OK, it was me and ChatGPT 🤷<a href="#fnref-1" class="footnote">&#8617;</a></p></li> </ol> </section> 2024-10-07T19:02:00+00:00 https://kristianfreeman.com/an-introduction-to-astros-content-system/ An introduction to Astro's content system 2024-10-08T19:27:07.050507+00:00 kristian hidden <p><a href='https://astro.build'>Astro</a> is excellent for building blogs and documentation sites.</p> <p>Over the weekend, I implemented <a href='https://gangsheet.app/blog'>Gangsheet.app’s blog</a> using the built-in <a href='https://docs.astro.build/en/guides/content-collections/'>content collection system</a><sup class="footnote-ref" id="fnref-1"><a href="#fn-1">1</a></sup>, and I was impressed at how easy it was. In this blog post, I’ll lay out how I implemented, with real code samples.</p> <h2 id=astros-content-collection-system>Astro’s Content Collection system</h2><p>Astro has a built-in way to import <code>.md</code>, <code>.mdx</code>, <code>.yaml</code>, and <code>.json</code> files, and use them to generate pages for your site. These files are parsed and strictly typed using <a href='https://zod.dev'>Zod</a>.</p> <p>I’m not tuned in to Astro’s development cycle, but I can speak as a developer who’s been in the static site/Jamstack space for a long time - Astro’s content layer is <em>excellent</em>. In a past life, I completely abused Gatsby’s content generation system to great effect. For instance, generating programatic SEO pages for a frontend development job board by generating massive amounts of combinatorial category pages<sup class="footnote-ref" id="fnref-2"><a href="#fn-2">2</a></sup>.</p> <p>With that history, I’m pretty well-versed in what the space looks like for using local and remote data to generate static-first sites. The example I lay out in this post - generating a blog - is very straightforward. The number of pages generated is N+1, where N is the number of blog posts (plus one index page). But some of the things that Astro does - no doubt due to better tooling, and experience seeing where some Jamstack sites have fallen short - have me very optimistic that this is a good platform to build on.</p> <h2 id=define-a-content-collection>Define a content collection</h2><p>For <a href='https://gangsheet.app'>Gangsheet</a>, I wanted a blog. The posts are authored in Markdown, stored in <code>src/content/blog/*.md</code>. These posts should be parsed, and then rendered at both <code>/blog</code> (the index of <em>all</em> posts), and <code>/blog/:slug</code> (the page for each individual post).</p> <p>First, we’ll create the folder <code>src/content/blog</code>, and then fill in <code>src/content/config.ts</code>, which configures all content collections for our app:</p> <div class="highlight"><pre><span></span><span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">defineCollection</span><span class="p">,</span><span class="w"> </span><span class="nx">reference</span><span class="p">,</span><span class="w"> </span><span class="nx">z</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s1">&#39;astro:content&#39;</span><span class="p">;</span> <span class="kd">const</span><span class="w"> </span><span class="nx">blogCollection</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">defineCollection</span><span class="p">({</span> <span class="w"> </span><span class="kr">type</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;content&#39;</span><span class="p">,</span> <span class="w"> </span><span class="nx">schema</span><span class="o">:</span><span class="w"> </span><span class="kt">z.object</span><span class="p">({</span> <span class="w"> </span><span class="nx">title</span><span class="o">:</span><span class="w"> </span><span class="kt">z.string</span><span class="p">(),</span> <span class="w"> </span><span class="nx">excerpt</span><span class="o">:</span><span class="w"> </span><span class="kt">z.string</span><span class="p">(),</span> <span class="w"> </span><span class="nx">author</span><span class="o">:</span><span class="w"> </span><span class="kt">z.object</span><span class="p">({</span> <span class="w"> </span><span class="nx">name</span><span class="o">:</span><span class="w"> </span><span class="kt">z.string</span><span class="p">(),</span> <span class="w"> </span><span class="nx">x</span><span class="o">:</span><span class="w"> </span><span class="kt">z.string</span><span class="p">(),</span> <span class="w"> </span><span class="p">}),</span> <span class="w"> </span><span class="nx">publishedAt</span><span class="o">:</span><span class="w"> </span><span class="kt">z.date</span><span class="p">(),</span> <span class="w"> </span><span class="nx">related</span><span class="o">:</span><span class="w"> </span><span class="kt">z.array</span><span class="p">(</span><span class="nx">reference</span><span class="p">(</span><span class="s1">&#39;blog&#39;</span><span class="p">)),</span> <span class="w"> </span><span class="p">}),</span> <span class="p">});</span> <span class="k">export</span><span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">collections</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span> <span class="w"> </span><span class="s1">&#39;blog&#39;</span><span class="o">:</span><span class="w"> </span><span class="nx">blogCollection</span><span class="p">,</span> <span class="p">};</span> </pre></div> <p>A blog post has:</p> <ul> <li>A title</li> <li>An excerpt</li> <li>An author, with a name and 𝕏 @username</li> <li>A publishedAt date</li> <li>Related posts</li> </ul> <p>A few interesting things to note here:</p> <ol> <li>No slug! The slug is generated automatically for each post, based on the filename (e.g. <code>src/content/blog/hello-world.mdx</code> is, of course, <code>/blog/hello-world</code>).<sup class="footnote-ref" id="fnref-3"><a href="#fn-3">3</a></sup></li> <li>Related posts - woah! You can reference other types, or the <em>same</em> type. We’ll see how easy this is when authoring, in just a second.<sup class="footnote-ref" id="fnref-4"><a href="#fn-4">4</a></sup></li> </ol> <h2 id=add-a-blog-post>Add a blog post</h2><p>We can generate a blog post by creating a new Markdown file in <code>src/content/blog</code>. I’ll create <code>src/content/hello-world.md</code>, below:</p> <pre class="shiki-highlight" data-language="markdown">--- title: Hello world! publishedAt: 2024-10-04 excerpt: My first blog post on my new Astro blog. author: name: Kristian Freeman x: kristianf_ related: - the-history-of-hello-world --- Hello world! This is my new blog post.</pre> <p>Lots of interesting things to dig into here - luckily, it’s all pretty straightforward. <code>title</code>, <code>publishedAt</code>, and <code>excerpt</code> are simple string/date fields. <code>author</code> is an object (maybe a dictionary technically?) with nested fields.</p> <p><code>related</code> is a collection of other blog posts, based on the <code>slug</code> parameter (again, defined by the filename). We’ll look at how to access the related blog posts in the blog post page, later on.</p> <p>Anything after the frontmatter is, of course, the content itself. Astro supports MDX, so you should be able to do fancy React component stuff here, too. I haven’t found a need for that yet, but if you want to see an example of how it works, check out Astro’s <a href='https://docs.astro.build/en/recipes/reading-time/'>“Add reading time” recipe</a>.</p> <h2 id=implement-page-generation>Implement page generation</h2><p>Now, we have a content collection, living at <code>src/content/blog</code> - how do we use it?</p> <p>First, let’s briefly review Astro’s “page” functionality:</p> <ol> <li>Pages live inside <code>src/pages</code></li> <li>Pages use the <code>.astro</code> extension, which executes JavaScript and allows React or other front-end composition</li> <li>They use file-based routing: <code>src/pages</code></li> </ol> <p>We’ll create two pages:</p> <ol> <li><code>src/pages/blog.astro</code> - the blog index.</li> <li><code>src/pages/blog/[slug].astro</code> - the template for each individual blog post.</li> </ol> <h3 id=blog-index>Blog index</h3><p>Defining the blog index page involves two steps - first, getting the collection using <code>getCollection</code> from the <code>astro:content</code> import. Then, we can render the blog posts using HTML:</p> <pre class="shiki-highlight" data-language="astro">--- import { getCollection } from &quot;astro:content&quot;; const posts = await getCollection(&quot;blog&quot;); const sortedPosts = posts.sort( (a, b) =&gt; b.data.publishedAt.getTime() - a.data.publishedAt.getTime(), ); --- &lt;div&gt; {sortedPosts.map((post) =&gt; ( &lt;div&gt; &lt;h2&gt; &lt;a href={`/blog/${post.slug}`}&gt; {post.data.title} &lt;/a&gt; &lt;/h2&gt; &lt;p&gt; Published at {post.data.publishedAt} &lt;/p&gt; &lt;p&gt;{post.data.excerpt}&lt;/p&gt; &lt;/div&gt; ))} &lt;/div&gt;</pre> <p>It won’t be indicated in the above code sample, but each <code>post</code> here is strongly typed. That means that <code>post.slug</code> and <code>post.data</code>, as well as everything <em>inside</em> <code>data</code>, have the benefit of TypeScript magic in your editor. If <code>excerpt</code>, for instance, was optional, we would be encouraged, via our editor and Astro’s build workflow, to handle the null case better.</p> <h3 id=post-page>Post page</h3><pre class="shiki-highlight" data-language="astro">--- import { getEntry, getEntries } from &quot;astro:content&quot;; const { slug } = Astro.params; if (!slug) return Astro.redirect(&quot;/blog&quot;); const post = await getEntry(&quot;blog&quot;, slug); if (!post) return Astro.redirect(&quot;/blog&quot;); const { Content, headings } = await post.render(); const relatedPosts = await getEntries(post.data.related); --- &lt;div&gt; &lt;article&gt; &lt;h1&gt;{post.data.title}&lt;/h1&gt; &lt;div&gt; &lt;time datetime={post.data.publishedAt.toString()}&gt;&lt;/time&gt; &lt;/div&gt; &lt;div&gt; &lt;p&gt;{post.data.author.name}&lt;/p&gt; &lt;/div&gt; &lt;div id=&quot;content&quot;&gt;&lt;Content /&gt;&lt;/div&gt; &lt;/article&gt; &lt;section&gt; &lt;h2&gt;Related Posts&lt;/h2&gt; {relatedPosts.map((relatedPost) =&gt; ( &lt;div&gt; &lt;h3&gt;&lt;a href={`/blog/${relatedPost.slug}`}&gt;{relatedPost.data.title}&lt;/a&gt;&lt;/h3&gt; &lt;/div&gt; ))} &lt;/section&gt; &lt;/div&gt;</pre> <p>First, we grab the <code>slug</code> param from <code>Astro.params</code>. Then we use it to grab the specific post for this page - <code>getEntry(‘blog’, slug)</code>. <code>post.render()</code> pushes the Markdown through Astro’s MDX compiler, and returns a <code>Content</code> component that can be rendered on the page, as well as an array representing all the <code>headers</code> (<code>h2</code>, <code>h3</code>, etc.) for the content<sup class="footnote-ref" id="fnref-5"><a href="#fn-5">5</a></sup>.</p> <p>The rendering is similar to what we did on the index page. <code>post.data</code> contains everything inside of the frontmatter for the post, so you can pull <code>title</code>, <code>excerpt</code>, <code>author</code>, etc. out and reference it wherever you need it in the HTML.</p> <p>When we need to load related posts, we can call <code>getEntries</code> (note <em>plural</em>, not singular) to load all of the posts specified in <code>post.data.related</code>. We get an array back of related posts - still strictly parsed + typed; basically identical to the <code>posts</code> array we had on the index page. This is <em>super powerful</em>. I love this implementation!</p> <h2 id=conclusion>Conclusion</h2><p>I’m really happy I invested time in learning Astro’s content collection system. I haven’t yet had the chance to use Astro’s <a href='https://5-0-0-beta.docs.astro.build/en/guides/upgrade-to/v5/'>new system (in beta)</a>, but when Astro v5 is properly released, I’ll do a follow up blog post on what’s changed.</p> <p>I <a href='https://kristianfreeman.com/deploying-astro-applications-to-cloudflare/'>wrote last week</a> about investing in learning Astro. This continues to pay off. I’ve been able to build a lot of <a href='https://x.com/kristianf_/status/1842344138131288118'>complex functionality</a> in it, and most of the issues I’ve run into have been totally solvable - even the hard stuff, like auth, dynamic data loading, etc.</p> <p>What I’m excited about most with the content collection system is that it feels like it lives inside of <a href='https://gangsheet.app'>my app</a>, which is quite complex, without resorting to hacks. It fits into the rest of the app in a way that makes sense. For instance, if I wanted to pin the most recent blog post as an “announcement” banner on my dashboard page - I wouldn’t have to make a crazy GraphQL query and combine dynamic and static pages in a way that feels bad. I can just call <code>getCollection(“blog”)</code> on any Astro page and render it out. No hacks needed!</p> <section class="footnotes"> <ol> <li id="fn-1"><p>I didn’t use Astro’s new v5 beta, which has apparently rewritten this system. I’m interested to see how it changed - maybe that will be a future post.<a href="#fnref-1" class="footnote">&#8617;</a></p></li> <li id="fn-2"><p>Given X number of location categories, Y number of framework/language categories, and Z number of “experience”/skill-level categories, generate <code>X*Y*Z</code> number of SEO-optimized pages, like “senior React.js jobs in the United States”. I was generating thousands of pages and running up against the container my site was building in - with Netlify at the time - running out of memory. Fun times!<a href="#fnref-2" class="footnote">&#8617;</a></p></li> <li id="fn-3"><p>You can also manually override the slug in the front-matter of the blog post.<a href="#fnref-3" class="footnote">&#8617;</a></p></li> <li id="fn-4"><p>As I’m writing this blog post, I’m realizing that <em>author</em> could be an awesome win here in terms of referencing. Instead of putting the author name/𝕏 username on <em>every post</em>, I could set up "src/content/authors/kristian.md" and just pass that reference in every blog post.<a href="#fnref-4" class="footnote">&#8617;</a></p></li> <li id="fn-5"><p>You can use this to generate a table of contents. See an example on <a href='https://gangsheet.app/blog/announcing-gangsheet'>a blog post</a> from Gangsheet’s blog.<a href="#fnref-5" class="footnote">&#8617;</a></p></li> </ol> </section> 2024-10-07T16:07:00+00:00