<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Roger Rajaratnam</title>
    <description>The latest articles on DEV Community by Roger Rajaratnam (@sourcier).</description>
    <link>https://dev.to/sourcier</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3877016%2Fe616827e-3f77-4299-a5fe-2503dc341bce.jpeg</url>
      <title>DEV Community: Roger Rajaratnam</title>
      <link>https://dev.to/sourcier</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sourcier"/>
    <language>en</language>
    <item>
      <title>Building a share widget with the Clipboard API</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 05 May 2026 10:59:07 +0000</pubDate>
      <link>https://dev.to/sourcier/building-a-share-widget-with-the-clipboard-api-4hco</link>
      <guid>https://dev.to/sourcier/building-a-share-widget-with-the-clipboard-api-4hco</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/share-post-clipboard" rel="noopener noreferrer"&gt;Building a share widget with the Clipboard API&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sharing a post is one of those interactions that looks trivial to implement, yet has&lt;br&gt;
a few subtle corners once you get into it, particularly around the copy-to-clipboard&lt;br&gt;
flow and URL construction for each channel.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SharePost.astro&lt;/code&gt; is a small component that covers four share targets: LinkedIn,&lt;br&gt;
Reddit, email, and a copy-link button. It has no external dependencies, no JavaScript&lt;br&gt;
frameworks, and no tracking.&lt;/p&gt;
&lt;h2&gt;
  
  
  The component props
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hero&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sidebar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;menu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;url&lt;/code&gt; is the fully qualified canonical URL of the post, passed in from&lt;br&gt;
&lt;code&gt;PageHero.astro&lt;/code&gt; as &lt;code&gt;Astro.url.href&lt;/code&gt;. &lt;code&gt;vertical&lt;/code&gt; flips the layout to a&lt;br&gt;
column stack for sidebar placement. &lt;code&gt;variant&lt;/code&gt; applies a modifier class that&lt;br&gt;
controls spacing and sizing for the different contexts the widget appears in.&lt;/p&gt;
&lt;h2&gt;
  
  
  LinkedIn share URL
&lt;/h2&gt;

&lt;p&gt;LinkedIn's share endpoint accepts a &lt;code&gt;url&lt;/code&gt; parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linkedinUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`https://www.linkedin.com/sharing/share-offsite/?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;encodeURIComponent&lt;/code&gt; is essential here. A raw URL with query parameters would&lt;br&gt;
break the LinkedIn endpoint's own query string parsing. The component doesn't&lt;br&gt;
pass the title separately: LinkedIn scrapes the OG tags from the shared URL and&lt;br&gt;
uses those for the preview. As long as &lt;code&gt;og:title&lt;/code&gt; and &lt;code&gt;og:description&lt;/code&gt; are set&lt;br&gt;
correctly (they are: see the &lt;a href="https://dev.to/blog/opengraph-seo-astro"&gt;OpenGraph post&lt;/a&gt;), the&lt;br&gt;
preview will be accurate.&lt;/p&gt;

&lt;p&gt;The link uses &lt;code&gt;target="_blank"&lt;/code&gt; with &lt;code&gt;rel="noopener noreferrer"&lt;/code&gt;. &lt;code&gt;noopener&lt;/code&gt;&lt;br&gt;
prevents the opened tab from accessing &lt;code&gt;window.opener&lt;/code&gt; (a known XSS vector).&lt;br&gt;
&lt;code&gt;noreferrer&lt;/code&gt; prevents the referrer header from being sent, which also implies&lt;br&gt;
&lt;code&gt;noopener&lt;/code&gt;, but including both is explicit and safe.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reddit share URL
&lt;/h2&gt;

&lt;p&gt;Reddit's submission endpoint accepts both &lt;code&gt;url&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt; parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redditUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`https://www.reddit.com/submit?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;title=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlike LinkedIn, Reddit doesn't scrape OG tags to pre-fill the submission title,&lt;br&gt;
so the title is passed explicitly. The submission form still lets the user edit&lt;br&gt;
both fields before posting.&lt;/p&gt;
&lt;h2&gt;
  
  
  Email share URL
&lt;/h2&gt;

&lt;p&gt;Email sharing uses a &lt;code&gt;mailto:&lt;/code&gt; URI with pre-populated subject and body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`I thought you might find this interesting:\n\n"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`mailto:?subject=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;body=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailBody&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the subject and body are &lt;code&gt;encodeURIComponent&lt;/code&gt;-encoded. Without encoding,&lt;br&gt;
special characters in the title (ampersands, quotes, question marks) would&lt;br&gt;
corrupt the &lt;code&gt;mailto:&lt;/code&gt; URI. The pre-populated body includes a brief framing line,&lt;br&gt;
the title in quotes, and the URL on its own line. This reads naturally when the&lt;br&gt;
recipient receives it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Copy link with the Clipboard API
&lt;/h2&gt;

&lt;p&gt;The copy button uses the asynchronous Clipboard API, which requires a secure&lt;br&gt;
context (HTTPS or localhost):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-copy-link-btn]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bound&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Copy this link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.copy-link-label&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Link copied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Link copied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Copied!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Copy link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Copy link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Copy link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL to copy is stored in &lt;code&gt;data-url&lt;/code&gt; on the button element, set at render time&lt;br&gt;
from the &lt;code&gt;url&lt;/code&gt; prop. Falling back to &lt;code&gt;window.location.href&lt;/code&gt; is a sensible&lt;br&gt;
defensive default.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;data-bound&lt;/code&gt; guard prevents double-binding when the component is rendered&lt;br&gt;
twice on the same page (once in the page hero, once in the sidebar). Without it,&lt;br&gt;
each click would fire two listeners.&lt;/p&gt;

&lt;p&gt;After writing to the clipboard, the label text switches to "Copied!" for two&lt;br&gt;
seconds. This is a deliberate choice over a checkmark icon or a toast notification:&lt;br&gt;
a minimal in-place feedback mechanism that needs no additional UI state or animation&lt;br&gt;
complexity. The two-second timeout is long enough to be noticeable but short enough&lt;br&gt;
to reset before a user might click again.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;aria-label&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt; attributes are updated in sync with the label text so&lt;br&gt;
assistive technology announces the correct state.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;navigator.clipboard.writeText&lt;/code&gt; rejects (insecure context, permission denied),&lt;br&gt;
the catch block falls back to &lt;code&gt;window.prompt&lt;/code&gt;, which pre-fills the URL so the user&lt;br&gt;
can copy it manually. &lt;code&gt;document.execCommand('copy')&lt;/code&gt; is not used as a fallback&lt;br&gt;
because it is deprecated and inconsistently supported across modern browsers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Placement in the post layout
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fntavmnag4eoy29zbfovu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fntavmnag4eoy29zbfovu.png" alt="Share widget wireframe showing default state with LinkedIn, Reddit, email, and copy link buttons alongside the active copied state with in-place text feedback" width="700" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the original SVG: &lt;a href="https://sourcier.uk/blog/share-post-clipboard" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/share-post-clipboard&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The share widget appears twice on a post page. The two placements serve different&lt;br&gt;
reading stages: the hero slot catches readers the moment they arrive, before they&lt;br&gt;
have committed to the article; the sidebar slot catches them while they are reading&lt;br&gt;
or after they finish, without requiring them to scroll back to the top.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;PageHero.astro&lt;/code&gt;, the widget sits horizontally below the post metadata, visible&lt;br&gt;
immediately without scrolling. In &lt;code&gt;MarkdownPostLayout.astro&lt;/code&gt;, a vertical variant&lt;br&gt;
appears in the sidebar, staying in view as the reader moves through the content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;SharePost
  url={Astro.url.href}
  title={frontmatter.title}
  vertical={true}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;vertical&lt;/code&gt; prop simply toggles a CSS modifier class that changes &lt;code&gt;flex-direction&lt;/code&gt;&lt;br&gt;
from &lt;code&gt;row&lt;/code&gt; to &lt;code&gt;column&lt;/code&gt; and adjusts alignment. No logic changes, just layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;Four share targets, no dependencies, and fewer than 120 lines including the styles.&lt;br&gt;
The share URLs follow the same pattern: encode the inputs, assemble the query string,&lt;br&gt;
let the platform handle the rest. The clipboard interaction is the only part that&lt;br&gt;
requires JavaScript, and even that is a single async handler with a &lt;code&gt;window.prompt&lt;/code&gt;&lt;br&gt;
fallback for the rare case where the API is unavailable.&lt;/p&gt;

&lt;p&gt;The subtlest part of the implementation is the &lt;code&gt;data-bound&lt;/code&gt; guard. The widget&lt;br&gt;
appears twice on every post page and the Astro script block runs once per page&lt;br&gt;
load, so without the guard each button would accumulate duplicate listeners on&lt;br&gt;
every render. It is a one-liner that is easy to miss and quietly breaks the UX&lt;br&gt;
if you do.&lt;/p&gt;

&lt;p&gt;Next up: page history and credits, covering transparent revision logs and why&lt;br&gt;
attribution deserves to be a first-class concern rather than an afterthought.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Breadcrumb navigation with Schema.org markup</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Fri, 01 May 2026 13:21:58 +0000</pubDate>
      <link>https://dev.to/sourcier/breadcrumb-navigation-with-schemaorg-markup-5gc8</link>
      <guid>https://dev.to/sourcier/breadcrumb-navigation-with-schemaorg-markup-5gc8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/breadcrumb-schema-astro" rel="noopener noreferrer"&gt;Breadcrumb navigation with Schema.org markup&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Breadcrumbs are one of those components that look simple but have several layers&lt;br&gt;
of correctness: the visual trail, the accessible markup, and the structured data&lt;br&gt;
for search engines. All three matter and only one of them is visible to users.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Breadcrumb.astro&lt;/code&gt; handles all three, and it does so with a fallback auto-generation&lt;br&gt;
system that derives the crumb list from the URL path when no explicit list is provided.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0hpc6j2gnr64gd5paws0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0hpc6j2gnr64gd5paws0.png" alt="Breadcrumb component in two contexts — inverted on the dark post hero and default on a light surface — with annotations for aria-current, BreadcrumbList schema markup, and the SERP breadcrumb trail it produces" width="700" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the original SVG: &lt;a href="https://sourcier.uk/blog/breadcrumb-schema-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/breadcrumb-schema-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The component interface
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Crumb&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;href&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;crumbs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Crumb&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;inverted&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;noContainer&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;crumbs&lt;/code&gt; is an array of label/href pairs. The final crumb in the list should have&lt;br&gt;
no &lt;code&gt;href&lt;/code&gt; — it represents the current page and shouldn't be a link. The &lt;code&gt;inverted&lt;/code&gt;&lt;br&gt;
prop flips the colour scheme for placement on dark backgrounds (like the post hero).&lt;br&gt;
The &lt;code&gt;noContainer&lt;/code&gt; prop skips the &lt;code&gt;.container&lt;/code&gt; wrapper, used when the breadcrumb&lt;br&gt;
sits inside a layout that already handles max-width constraints.&lt;/p&gt;

&lt;p&gt;The component prepends a "Home" crumb automatically so callers don't have to include&lt;br&gt;
it in every usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allCrumbs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;crumbs&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Auto-generation from URL path
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;PageHero.astro&lt;/code&gt;, if no explicit &lt;code&gt;crumbs&lt;/code&gt; prop is passed, the component falls&lt;br&gt;
back to deriving them from the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;autoCrumbs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Crumb&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meaningful&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;meaningful&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\w&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;meaningful&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter strips the literal &lt;code&gt;"page"&lt;/code&gt; segment and any purely numeric segments —&lt;br&gt;
so &lt;code&gt;/blog/page/2&lt;/code&gt; produces crumbs for "Blog" but not "Page" or "2". This is the&lt;br&gt;
right behaviour: pagination is a structural detail, not a meaningful content level.&lt;/p&gt;

&lt;p&gt;Labels are formatted by replacing hyphens with spaces and capitalising each word.&lt;br&gt;
&lt;code&gt;"why-astro"&lt;/code&gt; becomes &lt;code&gt;"Why Astro"&lt;/code&gt;. It's not perfect for every slug — a post&lt;br&gt;
titled "Using MDX" with slug &lt;code&gt;using-mdx&lt;/code&gt; would render as "Using Mdx" — but it's&lt;br&gt;
good enough for this site's naming conventions.&lt;/p&gt;
&lt;h2&gt;
  
  
  Schema.org BreadcrumbList
&lt;/h2&gt;

&lt;p&gt;Search engines use &lt;a href="https://schema.org/BreadcrumbList" rel="noopener noreferrer"&gt;BreadcrumbList structured data&lt;/a&gt;&lt;br&gt;
to display a breadcrumb trail in search results. The markup sits inline in the&lt;br&gt;
rendered HTML using &lt;code&gt;itemscope&lt;/code&gt; and &lt;code&gt;itemprop&lt;/code&gt; attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;ol
  class="breadcrumb__list"
  itemscope
  itemtype="https://schema.org/BreadcrumbList"
&amp;gt;
  {items.map(({ label, href, isLast, position }) =&amp;gt; (
    &amp;lt;li
      class="breadcrumb__item"
      itemprop="itemListElement"
      itemscope
      itemtype="https://schema.org/ListItem"
    &amp;gt;
      {!isLast &amp;amp;&amp;amp; href ? (
        &amp;lt;a href={href} class="breadcrumb__link" itemprop="item"&amp;gt;
          &amp;lt;span itemprop="name"&amp;gt;{label}&amp;lt;/span&amp;gt;
        &amp;lt;/a&amp;gt;
      ) : (
        &amp;lt;span
          class="breadcrumb__current"
          aria-current="page"
          itemprop="name"
        &amp;gt;
          {label}
        &amp;lt;/span&amp;gt;
      )}
      &amp;lt;meta itemprop="position" content={String(position)} /&amp;gt;
    &amp;lt;/li&amp;gt;
  ))}
&amp;lt;/ol&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;meta itemprop="position"&amp;gt;&lt;/code&gt; tag carries the 1-based index for each crumb.&lt;br&gt;
Google uses this to understand the hierarchy — it won't infer the order from&lt;br&gt;
DOM order alone when using microdata.&lt;/p&gt;
&lt;h2&gt;
  
  
  Accessibility
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; element has an &lt;code&gt;aria-label="Breadcrumb"&lt;/code&gt; attribute to distinguish it&lt;br&gt;
from other navigation landmarks on the page (the main navbar also uses &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Breadcrumb"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"breadcrumb-nav"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current page crumb uses &lt;code&gt;aria-current="page"&lt;/code&gt; and is rendered as &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;&lt;br&gt;
rather than &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; — it's the page the user is already on, so making it a link&lt;br&gt;
would be misleading. Screen readers announce &lt;code&gt;aria-current="page"&lt;/code&gt; explicitly,&lt;br&gt;
giving users the context they need.&lt;/p&gt;

&lt;p&gt;The complete component and its integration in &lt;code&gt;PageHero.astro&lt;/code&gt; are in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/sourcier.uk" rel="noopener noreferrer"&gt;sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full code listing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
interface Crumb {
  label: string;
  href?: string;
}

interface Props {
  crumbs: Crumb[];
  inverted?: boolean;
  noContainer?: boolean;
}

const { crumbs, inverted = false, noContainer = false } = Astro.props;
const allCrumbs = [{ label: "Home", href: "/" }, ...crumbs];

const items = allCrumbs.map((crumb, index) =&amp;gt; ({
  ...crumb,
  isLast: index === allCrumbs.length - 1,
  position: index + 1,
}));
---

&amp;lt;nav
  aria-label="Breadcrumb"
  class:list={["breadcrumb-nav", { "breadcrumb-nav--inverted": inverted }]}
&amp;gt;
  {
    noContainer ? (
      &amp;lt;ol
        class="breadcrumb__list"
        itemscope
        itemtype="https://schema.org/BreadcrumbList"
      &amp;gt;
        {items.map(({ label, href, isLast, position }) =&amp;gt; (
          &amp;lt;li
            class="breadcrumb__item"
            itemprop="itemListElement"
            itemscope
            itemtype="https://schema.org/ListItem"
          &amp;gt;
            {!isLast &amp;amp;&amp;amp; href ? (
              &amp;lt;a href={href} class="breadcrumb__link" itemprop="item"&amp;gt;
                &amp;lt;span itemprop="name"&amp;gt;{label}&amp;lt;/span&amp;gt;
              &amp;lt;/a&amp;gt;
            ) : (
              &amp;lt;span
                class="breadcrumb__current"
                aria-current="page"
                itemprop="name"
              &amp;gt;
                {label}
              &amp;lt;/span&amp;gt;
            )}
            &amp;lt;meta itemprop="position" content={String(position)} /&amp;gt;
          &amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ol&amp;gt;
    ) : (
      &amp;lt;div class="container is-max-desktop"&amp;gt;
        &amp;lt;ol
          class="breadcrumb__list"
          itemscope
          itemtype="https://schema.org/BreadcrumbList"
        &amp;gt;
          {items.map(({ label, href, isLast, position }) =&amp;gt; (
            &amp;lt;li
              class="breadcrumb__item"
              itemprop="itemListElement"
              itemscope
              itemtype="https://schema.org/ListItem"
            &amp;gt;
              {!isLast &amp;amp;&amp;amp; href ? (
                &amp;lt;a href={href} class="breadcrumb__link" itemprop="item"&amp;gt;
                  &amp;lt;span itemprop="name"&amp;gt;{label}&amp;lt;/span&amp;gt;
                &amp;lt;/a&amp;gt;
              ) : (
                &amp;lt;span
                  class="breadcrumb__current"
                  aria-current="page"
                  itemprop="name"
                &amp;gt;
                  {label}
                &amp;lt;/span&amp;gt;
              )}
              &amp;lt;meta itemprop="position" content={String(position)} /&amp;gt;
            &amp;lt;/li&amp;gt;
          ))}
        &amp;lt;/ol&amp;gt;
      &amp;lt;/div&amp;gt;
    )
  }
&amp;lt;/nav&amp;gt;

&amp;lt;style lang="scss"&amp;gt;
  .breadcrumb-nav {
    padding: 0.6rem 1.5rem;
    background-color: var(--surface-elevated);
    border-bottom: 1px solid var(--border-subtle);

    &amp;amp;--inverted {
      padding: 0;
      background-color: transparent;
      border-bottom: none;
      margin-bottom: 1.25rem;

      .breadcrumb__link {
        color: var(--text-on-strong-alpha-45);

        &amp;amp;:hover,
        &amp;amp;:focus-visible {
          color: var(--accent-primary);
        }
      }

      .breadcrumb__current {
        color: var(--text-on-strong-alpha-75);
      }

      .breadcrumb__item:not(:last-child)::after {
        color: var(--text-on-strong-alpha-25);
      }
    }
  }

  .breadcrumb__list {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0;
    list-style: none;
    margin: 0;
    padding: 0;

    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.8rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .breadcrumb__item {
    display: flex;
    align-items: center;

    &amp;amp;:not(:last-child)::after {
      content: "›";
      margin: 0 0.4em;
      color: var(--text-muted);
      font-weight: 400;
    }
  }

  .breadcrumb__link {
    color: var(--text-muted);
    text-decoration: none;
    transition: color 0.15s ease;

    &amp;amp;:hover,
    &amp;amp;:focus-visible {
      color: var(--accent-primary);
    }
  }

  .breadcrumb__current {
    color: var(--text-primary);
  }
&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>astro</category>
      <category>engineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Adding an RSS feed to an Astro blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 28 Apr 2026 14:58:32 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-an-rss-feed-to-an-astro-blog-505d</link>
      <guid>https://dev.to/sourcier/adding-an-rss-feed-to-an-astro-blog-505d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/rss-feed-astro" rel="noopener noreferrer"&gt;Adding an RSS feed to an Astro blog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;RSS is the oldest and most reliable way to follow a blog. No algorithm, no&lt;br&gt;
platform dependency, no notification settings. A reader checks the feed URL,&lt;br&gt;
sees new items, shows them. That simplicity is exactly why it's worth supporting.&lt;/p&gt;

&lt;p&gt;Adding an RSS feed to an Astro site is straightforward with the &lt;code&gt;@astrojs/rss&lt;/code&gt;&lt;br&gt;
package. There are a few things to get right: draft filtering, absolute URLs,&lt;br&gt;
and a self-referencing link that validators expect.&lt;/p&gt;
&lt;h2&gt;
  
  
  Installing the package
&lt;/h2&gt;

&lt;p&gt;Astro doesn't ship with RSS support out of the box, but the official integration&lt;br&gt;
adds everything needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @astrojs/rss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The feed endpoint
&lt;/h2&gt;

&lt;p&gt;The feed lives at &lt;code&gt;src/pages/rss.xml.js&lt;/code&gt;. Astro treats any &lt;code&gt;.js&lt;/code&gt; file in&lt;br&gt;
&lt;code&gt;src/pages/&lt;/code&gt; as a route, and a named &lt;code&gt;GET&lt;/code&gt; export marks it as an endpoint that&lt;br&gt;
generates output at build time.&lt;/p&gt;

&lt;p&gt;A few parts of this are easy to get wrong.&lt;/p&gt;
&lt;h3&gt;
  
  
  Draft filtering
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;draft&lt;/code&gt; flag alone isn't enough. A post with &lt;code&gt;draft: false&lt;/code&gt; and a future&lt;br&gt;
&lt;code&gt;pubDate&lt;/code&gt; is &lt;strong&gt;scheduled&lt;/strong&gt;, not live. Filtering on &lt;code&gt;!post.data.draft&lt;/code&gt; would leak&lt;br&gt;
it into the feed before it's published.&lt;/p&gt;

&lt;p&gt;This blog distinguishes three publication states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;draft: true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scheduled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;draft: false&lt;/code&gt;, future &lt;code&gt;pubDate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;published&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;draft: false&lt;/code&gt;, past or current &lt;code&gt;pubDate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;isPubliclyPublished&lt;/code&gt; utility returns &lt;code&gt;true&lt;/code&gt; only for the &lt;code&gt;published&lt;/code&gt; state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPubliclyPublished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you haven't extracted this into a utility, the inline equivalent is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not use &lt;code&gt;import.meta.env.DEV&lt;/code&gt; to conditionally include drafts. The RSS feed&lt;br&gt;
should never expose unpublished content, regardless of the build environment.&lt;/p&gt;
&lt;h3&gt;
  
  
  Sorting
&lt;/h3&gt;

&lt;p&gt;Posts are sorted by &lt;code&gt;pubDate&lt;/code&gt; descending so the most recent item appears first.&lt;br&gt;
Most RSS readers display items in the order they appear in the feed XML, so the&lt;br&gt;
sort order matters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Post links
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;link&lt;/code&gt; value uses &lt;code&gt;post.id&lt;/code&gt;, which in Astro's Content Layer API is the&lt;br&gt;
folder name of each post. For a post at &lt;code&gt;collections/posts/rss-feed-astro/index.md&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;post.id&lt;/code&gt; is &lt;code&gt;rss-feed-astro&lt;/code&gt;. Prefixing it with &lt;code&gt;/blog/&lt;/code&gt; produces the correct&lt;br&gt;
page URL, with no slug manipulation needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Absolute URLs
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;site&lt;/code&gt; property on the &lt;code&gt;rss()&lt;/code&gt; call is &lt;code&gt;context.site&lt;/code&gt;, the value set in&lt;br&gt;
&lt;code&gt;astro.config.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://sourcier.uk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@astrojs/rss&lt;/code&gt; package uses this to resolve relative &lt;code&gt;link&lt;/code&gt; values into&lt;br&gt;
absolute URLs. Without &lt;code&gt;site&lt;/code&gt; configured, feed items would have relative URLs&lt;br&gt;
that most RSS readers can't navigate.&lt;/p&gt;
&lt;h3&gt;
  
  
  atom:link self-reference
&lt;/h3&gt;

&lt;p&gt;RSS validators and some readers expect an &lt;code&gt;&amp;lt;atom:link rel="self"&amp;gt;&lt;/code&gt; element in&lt;br&gt;
the channel, pointing back to the feed's own URL. The &lt;code&gt;atom&lt;/code&gt; namespace must also&lt;br&gt;
be declared on the root &lt;code&gt;&amp;lt;rss&amp;gt;&lt;/code&gt; element, which the &lt;code&gt;@astrojs/rss&lt;/code&gt; package handles&lt;br&gt;
via the &lt;code&gt;xmlns&lt;/code&gt; option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;xmlns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;atom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://www.w3.org/2005/Atom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;language&amp;gt;en-gb&amp;lt;/language&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;atom:link href="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;rss.xml" rel="self" type="application/rss+xml"/&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, the W3C validator flags a "missing atom:link" warning and some&lt;br&gt;
readers cannot determine the canonical feed URL.&lt;/p&gt;
&lt;h2&gt;
  
  
  Validating the feed
&lt;/h2&gt;

&lt;p&gt;Before deploying, it's worth running the built feed through the&lt;br&gt;
&lt;a href="https://validator.w3.org/feed/" rel="noopener noreferrer"&gt;W3C Feed Validation Service&lt;/a&gt; or&lt;br&gt;
&lt;a href="https://www.rssboard.org/rss-validator/" rel="noopener noreferrer"&gt;RSS Board's validator&lt;/a&gt;. Common&lt;br&gt;
mistakes like missing &lt;code&gt;pubDate&lt;/code&gt;, non-absolute &lt;code&gt;link&lt;/code&gt; values, and invalid XML&lt;br&gt;
characters in post content all surface here before they cause problems in readers.&lt;/p&gt;

&lt;p&gt;Build the site locally and check &lt;code&gt;dist/rss.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; open dist/rss.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw XML should be readable in the browser. If the browser shows a parse&lt;br&gt;
error, something in the feed is malformed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Adding the feed autodiscovery link
&lt;/h2&gt;

&lt;p&gt;RSS readers look for a &lt;code&gt;&amp;lt;link rel="alternate"&amp;gt;&lt;/code&gt; tag in the page &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; to&lt;br&gt;
discover the feed URL automatically. Add it to &lt;code&gt;BaseLayout.astro&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt;
  &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt;
  &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/rss+xml"&lt;/span&gt;
  &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Sourcier RSS Feed"&lt;/span&gt;
  &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/rss.xml"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this in place, browsers and readers that support RSS autodiscovery will&lt;br&gt;
surface the feed when a user visits any page on the site.&lt;/p&gt;

&lt;p&gt;The complete feed endpoint and autodiscovery link are in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/sourcier.uk" rel="noopener noreferrer"&gt;sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full code listing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;rss&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@astrojs/rss&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getCollection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isPubliclyPublished&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../utils/drafts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPubliclyPublished&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valueOf&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rss&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sourcier — Blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Practical software engineering writing for people transitioning into tech, engineers growing in confidence, and teams improving engineering practice.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;xmlns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;atom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://www.w3.org/2005/Atom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;`&amp;lt;language&amp;gt;en-gb&amp;lt;/language&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`&amp;lt;atom:link href="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;rss.xml" rel="self" type="application/rss+xml"/&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>astro</category>
      <category>engineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>OpenGraph, Twitter Cards, and article metadata in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 23 Apr 2026 17:24:24 +0000</pubDate>
      <link>https://dev.to/sourcier/opengraph-twitter-cards-and-article-metadata-in-astro-28ob</link>
      <guid>https://dev.to/sourcier/opengraph-twitter-cards-and-article-metadata-in-astro-28ob</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/opengraph-seo-astro" rel="noopener noreferrer"&gt;OpenGraph, Twitter Cards, and article metadata in Astro&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When someone shares a link to a blog post, the card that appears in Slack, LinkedIn,&lt;br&gt;
or iMessage is determined by OpenGraph tags in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Get them wrong and the&lt;br&gt;
shared link is an unformatted URL. Get them right and it shows the post title,&lt;br&gt;
description, and a properly sized cover image.&lt;/p&gt;

&lt;p&gt;This is table stakes for any public-facing blog, but the implementation details&lt;br&gt;
matter — specifically, how to handle different content types (articles vs. pages),&lt;br&gt;
where to put canonical URLs, and how to avoid the common mistake of sharing a&lt;br&gt;
relative image path that produces a broken card.&lt;/p&gt;
&lt;h2&gt;
  
  
  Centralising metadata in BaseLayout
&lt;/h2&gt;

&lt;p&gt;All meta tags are defined once in &lt;code&gt;BaseLayout.astro&lt;/code&gt;. Every page passes what it&lt;br&gt;
needs as props; the layout handles the markup. This avoids duplication and ensures&lt;br&gt;
no page accidentally skips essential tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ogImage&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ogType&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;canonicalUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;author&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Practical software engineering guidance from Roger Rajaratnam for people breaking into tech, engineers growing in confidence, and teams improving engineering practice.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ogImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/og-image.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ogType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;canonicalUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default description covers general pages. The default &lt;code&gt;ogType&lt;/code&gt; is &lt;code&gt;"website"&lt;/code&gt;.&lt;br&gt;
Both are overridden for posts. The default &lt;code&gt;canonicalUrl&lt;/code&gt; is &lt;code&gt;Astro.url.href&lt;/code&gt; —&lt;br&gt;
the fully qualified URL including the site's &lt;code&gt;site&lt;/code&gt; value from &lt;code&gt;astro.config.mjs&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The OpenGraph block
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- OpenGraph --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt;        &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{ogType}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:site_name"&lt;/span&gt;   &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{siteName}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{pageTitle}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{description}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt;         &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{canonicalUrl}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{new&lt;/span&gt; &lt;span class="na"&gt;URL&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ogImage&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Astro.site&lt;/span&gt; &lt;span class="err"&gt;??&lt;/span&gt; &lt;span class="na"&gt;Astro.url.origin&lt;/span&gt;&lt;span class="err"&gt;).&lt;/span&gt;&lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt;  &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:locale"&lt;/span&gt;      &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"en_GB"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;og:image&lt;/code&gt; value deserves attention. A relative path like &lt;code&gt;/og-image.png&lt;/code&gt;&lt;br&gt;
won't work in OG tags — social crawlers need an absolute URL. Constructing it&lt;br&gt;
with &lt;code&gt;new URL(ogImage, Astro.site)&lt;/code&gt; handles both cases: if &lt;code&gt;ogImage&lt;/code&gt; is already&lt;br&gt;
absolute it passes through unchanged; if it's a root-relative path it's resolved&lt;br&gt;
against the site's configured base URL.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;siteName&lt;/code&gt; is a plain constant — &lt;code&gt;const siteName = "Sourcier"&lt;/code&gt; — defined at the&lt;br&gt;
top of &lt;code&gt;BaseLayout.astro&lt;/code&gt;. &lt;code&gt;og:locale&lt;/code&gt; uses the IETF language tag for British&lt;br&gt;
English, which matches the site's target audience.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;1200×630&lt;/code&gt; is the recommended OG image size that renders well across Facebook,&lt;br&gt;
LinkedIn, and Slack.&lt;/p&gt;
&lt;h2&gt;
  
  
  Article-specific tags
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;ogType&lt;/code&gt; is &lt;code&gt;"article"&lt;/code&gt;, the Open Graph protocol defines additional properties&lt;br&gt;
for structured article metadata. These are conditionally rendered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{pubDate &amp;amp;&amp;amp; (
  &amp;lt;meta
    property="article:published_time"
    content={pubDate.toISOString()}
  /&amp;gt;
)}
{author &amp;amp;&amp;amp; &amp;lt;meta property="article:author" content={author} /&amp;gt;}
{tags &amp;amp;&amp;amp; tags.map((tag) =&amp;gt; (
  &amp;lt;meta property="article:tag" content={tag} /&amp;gt;
))}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;article:published_time&lt;/code&gt; uses the ISO 8601 format with timezone — &lt;code&gt;Date.toISOString()&lt;/code&gt;&lt;br&gt;
provides this. &lt;code&gt;article:tag&lt;/code&gt; can appear multiple times, once per tag. Some scrapers&lt;br&gt;
and indexers use these to understand content type and category.&lt;/p&gt;
&lt;h2&gt;
  
  
  Twitter Cards
&lt;/h2&gt;

&lt;p&gt;X (formerly Twitter) has its own metadata system that runs parallel to OpenGraph. The &lt;code&gt;summary_large_image&lt;/code&gt;&lt;br&gt;
card type displays the image at full width above the title and description:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt;        &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:title"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{pageTitle}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{description}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;{new&lt;/span&gt; &lt;span class="na"&gt;URL&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ogImage&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Astro.site&lt;/span&gt; &lt;span class="err"&gt;??&lt;/span&gt; &lt;span class="na"&gt;Astro.url.origin&lt;/span&gt;&lt;span class="err"&gt;).&lt;/span&gt;&lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The property names keep the &lt;code&gt;twitter:&lt;/code&gt; prefix — these are a stable protocol standard and won't change regardless of the platform rebrand. X falls back to OpenGraph values for some properties, but it's more reliable to specify them explicitly. The image URL construction is the same as for OG.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canonical URLs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;{canonicalUrl}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The canonical tag tells search engines which URL is the authoritative version of a&lt;br&gt;
page, which matters if content appears at multiple URLs or is syndicated elsewhere.&lt;br&gt;
&lt;code&gt;canonicalUrl&lt;/code&gt; defaults to &lt;code&gt;Astro.url.href&lt;/code&gt; so it's correct without any manual&lt;br&gt;
input, but can be overridden for pages that need a different canonical (for example,&lt;br&gt;
a paginated page that canonicalises to page 1).&lt;/p&gt;
&lt;h2&gt;
  
  
  Passing metadata from post pages
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MarkdownPostLayout.astro&lt;/code&gt; extracts the relevant frontmatter fields and passes&lt;br&gt;
them to &lt;code&gt;BaseLayout&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const ogImage = frontmatter.cover?.image?.src ?? undefined;

&amp;lt;BaseLayout
  pageTitle={`${frontmatter.title} — Sourcier`}
  description={frontmatter.description}
  ogImage={ogImage}
  ogType="article"
  pubDate={frontmatter.pubDate}
  author={frontmatter.author}
  tags={frontmatter.tags}
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ogImage&lt;/code&gt; falls back to &lt;code&gt;undefined&lt;/code&gt; if no cover is present, which means&lt;br&gt;
&lt;code&gt;BaseLayout&lt;/code&gt; will use the default &lt;code&gt;/og-image.png&lt;/code&gt; for posts without cover images.&lt;/p&gt;

&lt;p&gt;You can browse the rest of the site code in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/web-sourcier.uk" rel="noopener noreferrer"&gt;web-sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Playwright E2E testing AI skills: JavaScript London talk</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Wed, 22 Apr 2026 09:11:56 +0000</pubDate>
      <link>https://dev.to/sourcier/playwright-e2e-testing-ai-skills-javascript-london-talk-1cgl</link>
      <guid>https://dev.to/sourcier/playwright-e2e-testing-ai-skills-javascript-london-talk-1cgl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/playwright-e2e-testing-talk" rel="noopener noreferrer"&gt;Playwright E2E testing AI skills: JavaScript London talk&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is the companion article to my JavaScript London talk,&lt;br&gt;
&lt;a href="https://www.javascriptlondon.com" rel="noopener noreferrer"&gt;Playwright E2E testing AI skills&lt;/a&gt;, hosted in&lt;br&gt;
collaboration with NewDay.&lt;/p&gt;

&lt;p&gt;The meetup is on Wednesday 29 April 2026, from 6:00 PM to 9:00 PM BST, at&lt;br&gt;
NewDay's offices on 7 Handyside Street in King's Cross, London. The evening&lt;br&gt;
also includes talks from &lt;a href="https://www.linkedin.com/in/davidwhitney/" rel="noopener noreferrer"&gt;David Whitney&lt;/a&gt;&lt;br&gt;
and &lt;a href="https://www.linkedin.com/in/elhamkh/" rel="noopener noreferrer"&gt;Elham Khani&lt;/a&gt;, and you can still&lt;br&gt;
&lt;a href="https://www.javascriptlondon.com" rel="noopener noreferrer"&gt;register on the event page&lt;/a&gt; if you plan to&lt;br&gt;
come along.&lt;/p&gt;

&lt;p&gt;If you want the slides alongside the article, you can&lt;br&gt;
&lt;a href="///talks/playwright-e2e-testing-talk.pptx"&gt;download the slide deck (.pptx)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The short version of the talk is this: Playwright is already a strong default&lt;br&gt;
for browser automation. The AI part becomes useful when it helps you explore a&lt;br&gt;
real site, identify the journeys that matter, and turn that exploration into&lt;br&gt;
better tests.&lt;/p&gt;

&lt;p&gt;The shortest useful explanation of E2E testing is still the same: unit tests&lt;br&gt;
tell you whether the parts work. End-to-end tests tell you whether the product&lt;br&gt;
works.&lt;/p&gt;

&lt;p&gt;If a button is hidden behind a cookie banner, a redirect breaks after login, or&lt;br&gt;
the browser submits the wrong payload, your unit tests can still be green. Your&lt;br&gt;
users will still hit the bug.&lt;/p&gt;

&lt;p&gt;That is the gap Playwright helps close.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this talk is framed around AI skills
&lt;/h2&gt;

&lt;p&gt;I am not interested in using AI to hide how tests work.&lt;/p&gt;

&lt;p&gt;I am interested in using it to shorten the boring parts around test creation:&lt;br&gt;
exploration, note-taking, finding likely locators, spotting missing assertions,&lt;br&gt;
and drafting candidate cases for review.&lt;/p&gt;

&lt;p&gt;That is a much better fit for AI than asking it to spray out a huge test suite&lt;br&gt;
and hoping it guessed the right behaviours.&lt;/p&gt;

&lt;p&gt;The useful pattern is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use AI to explore and propose&lt;/li&gt;
&lt;li&gt;use Playwright to automate and verify&lt;/li&gt;
&lt;li&gt;use humans to decide what is worth keeping&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That keeps the browser tests honest.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where E2E fits in the testing pyramid
&lt;/h2&gt;

&lt;p&gt;You do not want to test everything end to end. That is the fastest route to a&lt;br&gt;
slow, noisy, expensive test suite.&lt;/p&gt;

&lt;p&gt;You want a small number of high-value E2E tests that protect the user journeys&lt;br&gt;
that matter most.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRCCiAgVW5pdFsiTWFueSB1bml0IHRlc3RzPGJyLz5GYXN0IGZlZWRiYWNrIG9uIHNtYWxsIHBpZWNlczxici8-TG93ZXN0IGNvc3QgcGVyIHRlc3QiXQogIEludGVncmF0aW9uWyJTb21lIGludGVncmF0aW9uIHRlc3RzPGJyLz5Cb3VuZGFyaWVzIGFuZCBjb250cmFjdHM8YnIvPkNvbXBvc2VkIGJlaGF2aW91ciJdCiAgRTJFWyJGZXcgRTJFIHRlc3RzPGJyLz5Dcml0aWNhbCB1c2VyIGpvdXJuZXlzPGJyLz5IaWdoZXN0IGNvbmZpZGVuY2UgcGVyIHRlc3QiXQoKICBVbml0IC0tPiBJbnRlZ3JhdGlvbiAtLT4gRTJFCgogIGNsYXNzRGVmIHVuaXQgZmlsbDojZjZlY2UxLHN0cm9rZTojZDhjNWFlLGNvbG9yOiMwZjBmMGY7CiAgY2xhc3NEZWYgaW50ZWdyYXRpb24gZmlsbDojZmRlYWYyLHN0cm9rZTojZTgwMDZhLGNvbG9yOiMwZjBmMGY7CiAgY2xhc3NEZWYgZTJlIGZpbGw6I2Y3YzdkYSxzdHJva2U6I2IxMDA0Yixjb2xvcjojMGYwZjBmLHN0cm9rZS13aWR0aDoycHg7CgogIGNsYXNzIFVuaXQgdW5pdDsKICBjbGFzcyBJbnRlZ3JhdGlvbiBpbnRlZ3JhdGlvbjsKICBjbGFzcyBFMkUgZTJlOw" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRCCiAgVW5pdFsiTWFueSB1bml0IHRlc3RzPGJyLz5GYXN0IGZlZWRiYWNrIG9uIHNtYWxsIHBpZWNlczxici8-TG93ZXN0IGNvc3QgcGVyIHRlc3QiXQogIEludGVncmF0aW9uWyJTb21lIGludGVncmF0aW9uIHRlc3RzPGJyLz5Cb3VuZGFyaWVzIGFuZCBjb250cmFjdHM8YnIvPkNvbXBvc2VkIGJlaGF2aW91ciJdCiAgRTJFWyJGZXcgRTJFIHRlc3RzPGJyLz5Dcml0aWNhbCB1c2VyIGpvdXJuZXlzPGJyLz5IaWdoZXN0IGNvbmZpZGVuY2UgcGVyIHRlc3QiXQoKICBVbml0IC0tPiBJbnRlZ3JhdGlvbiAtLT4gRTJFCgogIGNsYXNzRGVmIHVuaXQgZmlsbDojZjZlY2UxLHN0cm9rZTojZDhjNWFlLGNvbG9yOiMwZjBmMGY7CiAgY2xhc3NEZWYgaW50ZWdyYXRpb24gZmlsbDojZmRlYWYyLHN0cm9rZTojZTgwMDZhLGNvbG9yOiMwZjBmMGY7CiAgY2xhc3NEZWYgZTJlIGZpbGw6I2Y3YzdkYSxzdHJva2U6I2IxMDA0Yixjb2xvcjojMGYwZjBmLHN0cm9rZS13aWR0aDoycHg7CgogIGNsYXNzIFVuaXQgdW5pdDsKICBjbGFzcyBJbnRlZ3JhdGlvbiBpbnRlZ3JhdGlvbjsKICBjbGFzcyBFMkUgZTJlOw" alt="Mermaid diagram" width="276" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/playwright-e2e-testing-talk" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/playwright-e2e-testing-talk&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule I like is simple: use E2E tests for risk, not for coverage.&lt;/p&gt;

&lt;p&gt;If a broken flow would hurt users, revenue, or trust, it deserves E2E coverage.&lt;br&gt;
If it is easy to prove with a unit or integration test, it probably does not.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Playwright is still the right foundation
&lt;/h2&gt;

&lt;p&gt;There are other good browser automation tools. Playwright is the one I would&lt;br&gt;
start with today for most web teams because it removes a lot of usual friction.&lt;/p&gt;

&lt;p&gt;It gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chromium, Firefox, and WebKit support out of the box&lt;/li&gt;
&lt;li&gt;a clean TypeScript-first API&lt;/li&gt;
&lt;li&gt;automatic waiting for elements to become actionable&lt;/li&gt;
&lt;li&gt;browser contexts for isolated tests&lt;/li&gt;
&lt;li&gt;a built-in trace viewer for debugging failures in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That matters because AI suggestions are only useful if the underlying tool is&lt;br&gt;
deterministic enough to turn them into repeatable tests.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the playwright-explore-website skill is
&lt;/h2&gt;

&lt;p&gt;In my setup, I use a &lt;code&gt;playwright-explore-website&lt;/code&gt; GitHub Copilot skill backed by&lt;br&gt;
the Playwright MCP server.&lt;/p&gt;

&lt;p&gt;It is a small instruction file that tells Copilot to explore a real site with&lt;br&gt;
Playwright, interact with a handful of important flows, document the relevant&lt;br&gt;
UI elements and expected outcomes, and then propose test cases based on what it&lt;br&gt;
found.&lt;/p&gt;

&lt;p&gt;Its job is not to replace a Playwright test file. Its job is to make the step&lt;br&gt;
before test writing more grounded in the real browser.&lt;/p&gt;

&lt;p&gt;I wrote up the full setup, the original awesome-copilot example I started&lt;br&gt;
from, and the local enhancements in&lt;br&gt;
&lt;a href="https://dev.to/blog/playwright-explore-website"&gt;The playwright-explore-website Copilot skill&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That write-up stays focused on the skill itself. This post is about how I would&lt;br&gt;
use it in a broader Playwright testing workflow.&lt;/p&gt;

&lt;p&gt;That makes it a good fit when you are working with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an unfamiliar product area&lt;/li&gt;
&lt;li&gt;a staging site you need to smoke test quickly&lt;/li&gt;
&lt;li&gt;a bug report that is missing exact reproduction steps&lt;/li&gt;
&lt;li&gt;a flow where you want candidate locators and assertions before coding the test&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A prompt like this is already specific enough to be useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use the playwright-explore-website skill on https://staging.example.com.
Explore sign-in, password reset, and checkout.
For each flow, document the user steps, the likely stable locators,
the expected outcome, and a draft Playwright test case.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value is not the raw prompt. The value is the output: a clearer map of the&lt;br&gt;
journey you are about to automate.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I would use it for E2E testing
&lt;/h2&gt;

&lt;p&gt;My preferred workflow is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBGbG93W1BpY2sgYSByaXNreSB1c2VyIGpvdXJuZXldIC0tPiBFeHBsb3JlW0V4cGxvcmUgaXQgd2l0aCB0aGUgc2tpbGxdCiAgICBFeHBsb3JlIC0tPiBEcmFmdFtEcmFmdCBjYW5kaWRhdGUgYXNzZXJ0aW9ucyBhbmQgbG9jYXRvcnNdCiAgICBEcmFmdCAtLT4gVGVzdFtXcml0ZSBhIGNsZWFuIFBsYXl3cmlnaHQgdGVzdF0KICAgIFRlc3QgLS0-IENJW1J1biBpbiBDSSB3aXRoIHRyYWNlc10KICAgIENJIC0tPiBSZXZpZXdbS2VlcCwgcmVmaW5lLCBvciBkZWxldGUgYmFzZWQgb24gdmFsdWVd" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBGbG93W1BpY2sgYSByaXNreSB1c2VyIGpvdXJuZXldIC0tPiBFeHBsb3JlW0V4cGxvcmUgaXQgd2l0aCB0aGUgc2tpbGxdCiAgICBFeHBsb3JlIC0tPiBEcmFmdFtEcmFmdCBjYW5kaWRhdGUgYXNzZXJ0aW9ucyBhbmQgbG9jYXRvcnNdCiAgICBEcmFmdCAtLT4gVGVzdFtXcml0ZSBhIGNsZWFuIFBsYXl3cmlnaHQgdGVzdF0KICAgIFRlc3QgLS0-IENJW1J1biBpbiBDSSB3aXRoIHRyYWNlc10KICAgIENJIC0tPiBSZXZpZXdbS2VlcCwgcmVmaW5lLCBvciBkZWxldGUgYmFzZWQgb24gdmFsdWVd" alt="Mermaid diagram" width="1704" height="94"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/playwright-e2e-testing-talk" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/playwright-e2e-testing-talk&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is where the AI piece earns its keep.&lt;/p&gt;

&lt;p&gt;Instead of starting from a blank file, you start with a tested path through the&lt;br&gt;
browser, a list of likely selectors, and a set of outcomes worth asserting. You&lt;br&gt;
still need to clean that up into a proper test, but the exploratory work is&lt;br&gt;
faster.&lt;/p&gt;

&lt;p&gt;A good flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick one critical journey.&lt;/li&gt;
&lt;li&gt;Use the skill to explore it and note what the user actually sees.&lt;/li&gt;
&lt;li&gt;Turn the best candidate path into a small Playwright test.&lt;/li&gt;
&lt;li&gt;Replace weak selectors with semantic locators or &lt;code&gt;data-testid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run it in CI with traces and fix the first flaky edge before adding more.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is enough to prove the approach without bloating the suite.&lt;/p&gt;
&lt;h2&gt;
  
  
  What E2E tests are good at
&lt;/h2&gt;

&lt;p&gt;Playwright is excellent at checking the flows where the browser is part of the&lt;br&gt;
problem.&lt;/p&gt;

&lt;p&gt;Good targets for E2E tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sign in, sign out, and session refresh flows&lt;/li&gt;
&lt;li&gt;checkout, booking, or other business-critical user journeys&lt;/li&gt;
&lt;li&gt;form submission paths that depend on real navigation or API responses&lt;/li&gt;
&lt;li&gt;cross-browser regressions&lt;/li&gt;
&lt;li&gt;UI issues that only show up once the page is fully assembled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Poor targets for E2E tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pure business logic&lt;/li&gt;
&lt;li&gt;small validation rules&lt;/li&gt;
&lt;li&gt;isolated component states&lt;/li&gt;
&lt;li&gt;anything a fast unit test can already prove clearly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distinction still matters even when AI is involved. The point is not to&lt;br&gt;
replace the rest of the suite. The point is to protect the seams.&lt;/p&gt;
&lt;h2&gt;
  
  
  Codegen and exploration are different tools
&lt;/h2&gt;

&lt;p&gt;One of the easiest ways to get moving with Playwright is still to record a flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm create playwright@latest
pnpm playwright codegen https://your-app.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Codegen is useful for capturing raw actions quickly.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;playwright-explore-website&lt;/code&gt; skill does a different job. It helps you&lt;br&gt;
understand the flow, identify meaningful assertions, and sketch candidate tests&lt;br&gt;
before you commit to code.&lt;/p&gt;

&lt;p&gt;That distinction matters. Codegen gives you interaction history. Exploration&lt;br&gt;
gives you testing intent.&lt;/p&gt;

&lt;p&gt;You will usually want both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use the skill when you need to map the journey&lt;/li&gt;
&lt;li&gt;use codegen when you need a quick action scaffold&lt;/li&gt;
&lt;li&gt;rewrite the result so the test reads like a real scenario&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Keeping tests reliable
&lt;/h2&gt;

&lt;p&gt;Flaky tests are worse than missing tests.&lt;/p&gt;

&lt;p&gt;Once a team stops trusting the suite, the suite stops being useful.&lt;/p&gt;
&lt;h3&gt;
  
  
  Let Playwright wait for you
&lt;/h3&gt;

&lt;p&gt;Playwright automatically waits for elements to be attached, visible, stable, and&lt;br&gt;
ready for interaction before acting on them.&lt;/p&gt;

&lt;p&gt;That is one of the main reasons its tests feel less fragile than older browser&lt;br&gt;
automation stacks.&lt;/p&gt;
&lt;h3&gt;
  
  
  Use selectors that survive refactoring
&lt;/h3&gt;

&lt;p&gt;Prefer the most human-facing locator you can.&lt;/p&gt;

&lt;p&gt;Good order of preference:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;getByRole&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getByLabel&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getByText&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-testid&lt;/code&gt; when you need a stable testing contract&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you want to avoid is binding tests to styling details like &lt;code&gt;.btn-primary&lt;/code&gt;&lt;br&gt;
or deep CSS paths that change every time the UI gets cleaned up.&lt;/p&gt;
&lt;h3&gt;
  
  
  Treat &lt;code&gt;waitForTimeout&lt;/code&gt; as a smell
&lt;/h3&gt;

&lt;p&gt;If you ever reach for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;assume the test still is not right.&lt;/p&gt;

&lt;p&gt;It may pass on your machine and fail in CI. It may also slow the suite down&lt;br&gt;
while still being unreliable.&lt;/p&gt;

&lt;p&gt;Better choices are explicit signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/confirmation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/api&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;orders/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Order confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Make the await match the business signal
&lt;/h3&gt;

&lt;p&gt;This is the distinction that trips people up.&lt;/p&gt;

&lt;p&gt;Playwright already auto-waits for an element to become actionable before it&lt;br&gt;
clicks, fills, or types. Explicit awaits are for the thing that happens after&lt;br&gt;
the action.&lt;/p&gt;

&lt;p&gt;That means &lt;code&gt;await page.getByRole("button", { name: "Place order" }).click()&lt;/code&gt;&lt;br&gt;
can prove the button was clickable. It does not, on its own, prove the order&lt;br&gt;
was created, the redirect finished, or the confirmation UI appeared.&lt;/p&gt;

&lt;p&gt;The right explicit wait depends on the signal that tells you the step is really&lt;br&gt;
done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;wait for a URL change when the flow navigates&lt;/li&gt;
&lt;li&gt;wait for a response when the backend side effect matters&lt;/li&gt;
&lt;li&gt;wait for a loading state to disappear when the page stays put&lt;/li&gt;
&lt;li&gt;wait for the final visible UI state when that is what the user would notice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the click starts the transition, tie the action and the wait together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/confirmation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Place order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Order confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the page does not navigate, but the server-side effect is the important&lt;br&gt;
part, wait for that response first and then assert the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Place order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Order confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That style is more honest about what the test depends on. Instead of hoping a&lt;br&gt;
pause is long enough, you name the signal that proves the journey completed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Page objects are a scaling tool
&lt;/h2&gt;

&lt;p&gt;I do not start with page objects on day one.&lt;/p&gt;

&lt;p&gt;If a suite has one or two tests, a couple of small helper functions are often&lt;br&gt;
enough. Page objects start paying off when multiple tests share the same screen,&lt;br&gt;
the same setup, or the same selectors.&lt;/p&gt;

&lt;p&gt;The job of a page object is narrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep selectors in one place&lt;/li&gt;
&lt;li&gt;expose repeated user actions in product language&lt;/li&gt;
&lt;li&gt;reduce copy-paste when the UI changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A good page object hides selector plumbing. It should not hide the whole test.&lt;br&gt;
The scenario, the assertions, and the reason the flow matters should usually&lt;br&gt;
stay visible in the test file.&lt;/p&gt;

&lt;p&gt;This is the sort of thing I mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoginPage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;emailField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;passwordField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emailField&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;passwordField&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;login-submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test that uses it can stay focused on the actual journey:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;loginPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;correct horse battery staple&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/dashboard/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off is worth it when the same login flow appears in a few tests.&lt;br&gt;
It is not worth it when every page object becomes a giant wrapper around every&lt;br&gt;
DOM node on the screen.&lt;/p&gt;

&lt;p&gt;My rule of thumb is simple: if two or three tests repeat the same selectors and&lt;br&gt;
actions, extract a small page object. Keep it focused on repeated flows, not on&lt;br&gt;
building a mini framework.&lt;/p&gt;
&lt;h2&gt;
  
  
  Other useful jobs for the same skill
&lt;/h2&gt;

&lt;p&gt;The interesting part of &lt;code&gt;playwright-explore-website&lt;/code&gt; is that it is not limited&lt;br&gt;
to authoring E2E tests.&lt;/p&gt;

&lt;p&gt;It is also useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exploratory QA on a staging or preview deployment&lt;/li&gt;
&lt;li&gt;reproducing vague browser bugs from support tickets&lt;/li&gt;
&lt;li&gt;documenting core flows and their expected outcomes&lt;/li&gt;
&lt;li&gt;checking console errors and visible breakage after a deploy&lt;/li&gt;
&lt;li&gt;validating navigation, forms, and content on a marketing site before release&lt;/li&gt;
&lt;li&gt;identifying which user journeys are worth automating next&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would not use it as a substitute for proper accessibility reviews,&lt;br&gt;
performance testing, or security testing. It is a browser exploration tool, not&lt;br&gt;
a complete quality strategy.&lt;/p&gt;
&lt;h2&gt;
  
  
  Running Playwright in CI and debugging failures
&lt;/h2&gt;

&lt;p&gt;Running the suite in CI is the obvious part.&lt;/p&gt;

&lt;p&gt;The more interesting part is what happens after a failure.&lt;/p&gt;

&lt;p&gt;Playwright's trace viewer is one of the best reasons to use it. When a test&lt;br&gt;
fails, you can capture a trace and inspect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every action&lt;/li&gt;
&lt;li&gt;a timeline of the test&lt;/li&gt;
&lt;li&gt;screenshots at each step&lt;/li&gt;
&lt;li&gt;console output&lt;/li&gt;
&lt;li&gt;network requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm playwright &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on-first-retry
pnpm playwright show-trace trace.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns CI failures from guesswork into evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to write an E2E test
&lt;/h2&gt;

&lt;p&gt;If your E2E suite becomes the default answer to every testing question, it will&lt;br&gt;
become slow, brittle, and expensive to maintain.&lt;/p&gt;

&lt;p&gt;Good reasons not to write an E2E test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the behaviour is already covered clearly in a unit test&lt;/li&gt;
&lt;li&gt;the test would take a long time to set up for very little risk reduction&lt;/li&gt;
&lt;li&gt;the UI state is local and easy to verify at component level&lt;/li&gt;
&lt;li&gt;the failure would not matter much in production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a test takes a long time to run but almost never catches a meaningful bug,&lt;br&gt;
it is probably not earning its place in the suite. Remove it, or replace it&lt;br&gt;
with a cheaper test that gives clearer feedback.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pragmatic place to start
&lt;/h2&gt;

&lt;p&gt;If you are introducing Playwright, or the AI-assisted workflow around it, this&lt;br&gt;
is a sensible first week plan.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick one critical user journey.&lt;/li&gt;
&lt;li&gt;Explore it with &lt;code&gt;playwright-explore-website&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write one clean Playwright test from that exploration.&lt;/li&gt;
&lt;li&gt;Use semantic locators first, then &lt;code&gt;data-testid&lt;/code&gt; where needed.&lt;/li&gt;
&lt;li&gt;Run that test in CI with traces enabled.&lt;/li&gt;
&lt;li&gt;Fix flakiness before adding more coverage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is enough to learn the tool, prove the value, and build trust in the&lt;br&gt;
approach.&lt;/p&gt;

&lt;p&gt;Once that first path is stable, add the next one.&lt;/p&gt;

&lt;p&gt;Not everything needs an end-to-end test. The paths that matter do.&lt;/p&gt;

&lt;p&gt;If you want the official docs after this overview, start here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://playwright.dev/" rel="noopener noreferrer"&gt;Playwright documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.javascriptlondon.com" rel="noopener noreferrer"&gt;JavaScript London event page&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>engineering</category>
      <category>frontend</category>
      <category>tooling</category>
    </item>
    <item>
      <title>The playwright-explore-website Copilot skill</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Wed, 22 Apr 2026 09:11:43 +0000</pubDate>
      <link>https://dev.to/sourcier/the-playwright-explore-website-copilot-skill-n42</link>
      <guid>https://dev.to/sourcier/the-playwright-explore-website-copilot-skill-n42</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/playwright-explore-website" rel="noopener noreferrer"&gt;The playwright-explore-website Copilot skill&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the standalone write-up behind the workflow I referenced in&lt;br&gt;
&lt;a href="https://dev.to/blog/playwright-e2e-testing-talk"&gt;my JavaScript London talk companion post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The short version is that &lt;code&gt;playwright-explore-website&lt;/code&gt; is a small GitHub&lt;br&gt;
Copilot skill that tells Copilot to use the Playwright MCP server to open a&lt;br&gt;
site, explore a few important user flows, document what it found, and suggest&lt;br&gt;
candidate test cases.&lt;/p&gt;

&lt;p&gt;In the latest version, it also treats Playwright as the source of truth for&lt;br&gt;
rendered UI checks. If the task involves a visible change or regression, the&lt;br&gt;
agent should inspect the current UI first, make the change, verify the updated&lt;br&gt;
state afterwards, and clean up temporary screenshots unless the user asked to&lt;br&gt;
keep them.&lt;/p&gt;

&lt;p&gt;I did not invent the starting point from scratch. The initial version was&lt;br&gt;
based on the original&lt;br&gt;
&lt;a href="https://github.com/github/awesome-copilot/blob/main/skills/playwright-explore-website/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;playwright-explore-website&lt;/code&gt; example in awesome-copilot&lt;/a&gt;.&lt;br&gt;
What I changed was the setup guidance and the execution rules, so it behaves&lt;br&gt;
better in real browser work, especially for regression checks and visual QA,&lt;br&gt;
instead of acting like a generic prompt stub.&lt;/p&gt;

&lt;p&gt;This post stays focused on the skill itself: where it came from, how I set it&lt;br&gt;
up locally, and the guardrails I added. The talk companion post covers the&lt;br&gt;
broader Playwright testing workflow around it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBQcm9tcHRbVXNlciBhc2tzIHRvIGV4cGxvcmUgYSB3ZWJzaXRlXSAtLT4gU2tpbGxbcGxheXdyaWdodC1leHBsb3JlLXdlYnNpdGUgc2tpbGxdCiAgICBTa2lsbCAtLT4gTUNQW1BsYXl3cmlnaHQgTUNQIHNlcnZlcl0KICAgIE1DUCAtLT4gQnJvd3NlcltSZWFsIGJyb3dzZXIgc2Vzc2lvbl0KICAgIEJyb3dzZXIgLS0-IEZpbmRpbmdzW0Zsb3dzIMK3IGxvY2F0b3JzIMK3IGV4cGVjdGVkIG91dGNvbWVzXQogICAgRmluZGluZ3MgLS0-IENhc2VzW0NhbmRpZGF0ZSB0ZXN0IGNhc2VzXQ" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBQcm9tcHRbVXNlciBhc2tzIHRvIGV4cGxvcmUgYSB3ZWJzaXRlXSAtLT4gU2tpbGxbcGxheXdyaWdodC1leHBsb3JlLXdlYnNpdGUgc2tpbGxdCiAgICBTa2lsbCAtLT4gTUNQW1BsYXl3cmlnaHQgTUNQIHNlcnZlcl0KICAgIE1DUCAtLT4gQnJvd3NlcltSZWFsIGJyb3dzZXIgc2Vzc2lvbl0KICAgIEJyb3dzZXIgLS0-IEZpbmRpbmdzW0Zsb3dzIMK3IGxvY2F0b3JzIMK3IGV4cGVjdGVkIG91dGNvbWVzXQogICAgRmluZGluZ3MgLS0-IENhc2VzW0NhbmRpZGF0ZSB0ZXN0IGNhc2VzXQ" alt="Mermaid diagram" width="1691" height="94"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/playwright-explore-website" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/playwright-explore-website&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  What the skill is for
&lt;/h2&gt;

&lt;p&gt;This skill sits in the gap between "open the browser and poke around" and&lt;br&gt;
"write the final Playwright test file".&lt;/p&gt;

&lt;p&gt;It is useful when you need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;understand an unfamiliar product area quickly&lt;/li&gt;
&lt;li&gt;smoke test a staging or preview deployment&lt;/li&gt;
&lt;li&gt;reproduce a bug report that is missing exact steps&lt;/li&gt;
&lt;li&gt;verify rendered UI before and after a visible change&lt;/li&gt;
&lt;li&gt;identify candidate locators before writing a Playwright test&lt;/li&gt;
&lt;li&gt;turn exploratory browsing into a short list of candidate scenarios&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why I like it. It makes exploration explicit.&lt;/p&gt;
&lt;h2&gt;
  
  
  The original awesome-copilot example
&lt;/h2&gt;

&lt;p&gt;The original version in awesome-copilot is deliberately small.&lt;/p&gt;

&lt;p&gt;At a high level, it tells Copilot to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;navigate to the provided URL with Playwright MCP&lt;/li&gt;
&lt;li&gt;identify and interact with 3 to 5 core user flows&lt;/li&gt;
&lt;li&gt;document the interactions, locators, and expected outcomes&lt;/li&gt;
&lt;li&gt;close the browser context afterwards&lt;/li&gt;
&lt;li&gt;summarise the findings and propose test cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is already a good baseline because it forces exploration before test&lt;br&gt;
generation. The model has to look at the real site first instead of guessing&lt;br&gt;
what the UI probably looks like.&lt;/p&gt;
&lt;h2&gt;
  
  
  My local setup
&lt;/h2&gt;

&lt;p&gt;I keep this as a personal skill rather than a repo-specific one.&lt;/p&gt;

&lt;p&gt;That means the file lives at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.copilot/skills/playwright-explore-website/SKILL.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prefer that because the same workflow is useful across multiple projects.&lt;br&gt;
Once the skill is there, Copilot can discover it in any repo where browser&lt;br&gt;
exploration makes sense.&lt;/p&gt;

&lt;p&gt;The frontmatter stays intentionally simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;playwright-explore-website&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Website&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exploration&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;testing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;using&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Playwright&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;MCP'&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the title. It is that the description contains the&lt;br&gt;
right trigger words, so Copilot can load it when the task is about website&lt;br&gt;
exploration, Playwright, or browser-based testing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Playwright MCP setup
&lt;/h2&gt;

&lt;p&gt;One of the biggest gaps in the original example is that it assumes the&lt;br&gt;
Playwright MCP tools are already available.&lt;/p&gt;

&lt;p&gt;That is fine once your machine is configured. It is less helpful the first time&lt;br&gt;
you try to use the skill and the tools are missing.&lt;/p&gt;

&lt;p&gt;I added an explicit setup section so the skill can bootstrap the missing piece&lt;br&gt;
instead of failing vaguely.&lt;/p&gt;

&lt;p&gt;The CLI path is the shortest route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;code &lt;span class="nt"&gt;--add-mcp&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"playwright","command":"pnpm","args":["dlx","@playwright/mcp@latest"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you prefer to wire it up in &lt;code&gt;settings.json&lt;/code&gt;, the equivalent config is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"playwright"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pnpm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dlx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@playwright/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, reload VS Code or the Copilot extension and accept the prompt to&lt;br&gt;
start the MCP server.&lt;/p&gt;

&lt;p&gt;This matters because a good skill should not only describe the happy path. It&lt;br&gt;
should also help the agent recover when a prerequisite is missing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The enhancements I added
&lt;/h2&gt;

&lt;p&gt;I kept the core purpose the same, but I tightened how the skill runs.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Self-bootstrapping setup instructions
&lt;/h3&gt;

&lt;p&gt;The added &lt;code&gt;## MCP Server Setup&lt;/code&gt; section gives the agent a concrete fallback when&lt;br&gt;
the Playwright tools are unavailable.&lt;/p&gt;

&lt;p&gt;That turns a dead end into a fixable setup problem.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. A serial exploration rule
&lt;/h3&gt;

&lt;p&gt;I added a rule for multi-page and multi-breakpoint work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;If you need to compare multiple pages or breakpoints, inspect them serially or
in separate tabs. Do not queue parallel navigations and screenshots against the
same Playwright page context.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a practical guardrail. Browser exploration becomes noisy very quickly if&lt;br&gt;
you mix multiple navigations and screenshots in one live context.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Rendered UI review
&lt;/h3&gt;

&lt;p&gt;The newer version also makes the rendered browser state the thing to trust.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Use Playwright to review the rendered UI directly. For implementation or
regression checks, inspect the current state first, then verify the updated
state after changes instead of relying on code inspection alone.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the rule that changed the skill most in practice. CSS, JSX, or Astro&lt;br&gt;
templates are not the final UI. The browser is. If the job is about a visible&lt;br&gt;
change, the skill now pushes Copilot to validate the actual rendered result&lt;br&gt;
before it signs off.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Visual audit prompts
&lt;/h3&gt;

&lt;p&gt;I also added an explicit visual review prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;For visual audits, explicitly note supporting-label readability,
hero-to-first-section spacing, footer divider spacing, and
last-section-to-footer separation when relevant.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That came from real UI review work. I wanted the skill to be useful not just&lt;br&gt;
for functional exploration, but also for browser-based design checks.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Screenshot cleanup
&lt;/h3&gt;

&lt;p&gt;I also added a cleanup rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Delete any temporary screenshots you created during the session unless the
user explicitly asked to keep them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sounds small, but it matters. Exploration and regression review can leave&lt;br&gt;
behind a pile of disposable screenshots very quickly. If the skill creates&lt;br&gt;
artifacts to reason about the UI, it should also leave the workspace tidy when&lt;br&gt;
those artifacts are no longer needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Stronger output requirements
&lt;/h3&gt;

&lt;p&gt;The local version is stricter about what the exploration should produce.&lt;/p&gt;

&lt;p&gt;It does not stop at "I clicked around and it looked fine". It asks for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user interactions that were performed&lt;/li&gt;
&lt;li&gt;the relevant UI elements and likely locators&lt;/li&gt;
&lt;li&gt;the expected outcomes for each flow&lt;/li&gt;
&lt;li&gt;a concise summary of findings&lt;/li&gt;
&lt;li&gt;proposed test cases based on the exploration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That output maps much more cleanly to a future Playwright test file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;This post is intentionally about the skill itself.&lt;/p&gt;

&lt;p&gt;If you want the wider workflow around it, including how I use it to explore a&lt;br&gt;
risky journey, compare it with codegen, and turn the findings into a real test,&lt;br&gt;
that is in &lt;a href="https://dev.to/blog/playwright-e2e-testing-talk"&gt;Playwright E2E testing AI skills: JavaScript London talk&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That boundary is deliberate. The skill helps you explore a real browser session&lt;br&gt;
and capture candidate flows, locators, and outcomes. The Playwright test is&lt;br&gt;
still the final artifact.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>tooling</category>
      <category>automation</category>
    </item>
    <item>
      <title>Building a tag system in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 21 Apr 2026 11:02:24 +0000</pubDate>
      <link>https://dev.to/sourcier/building-a-tag-system-in-astro-24d5</link>
      <guid>https://dev.to/sourcier/building-a-tag-system-in-astro-24d5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/tag-system-astro" rel="noopener noreferrer"&gt;Building a tag system in Astro&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Tags do more than connect related posts. They create a second navigation system.&lt;br&gt;
On this site the same tag data powers the blog archive cloud, the dedicated&lt;br&gt;
&lt;code&gt;/tags&lt;/code&gt; overview, paginated tag archive pages, and a sidebar browser on&lt;br&gt;
individual post pages.&lt;/p&gt;

&lt;p&gt;The key constraint is consistency. If each surface counts tags differently,&lt;br&gt;
builds URLs differently, or decides for itself which posts are visible, the&lt;br&gt;
whole system drifts. The implementation here stays simple by sharing two rules&lt;br&gt;
everywhere: one slug helper and one publication filter.&lt;/p&gt;

&lt;p&gt;A later update added per-tag descriptions, a featured post highlight, related&lt;br&gt;
topics, and a stats bar, all covered in the second half of this post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6oezvxdcc4vvp3uqyfje.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6oezvxdcc4vvp3uqyfje.png" alt="Tag system wireframe showing three views: the blog archive weighted cloud with tier-1/2/3 pill sizes, the /tags page with concentric ring layout, and the post sidebar tag list" width="700" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the original SVG: &lt;a href="https://sourcier.uk/blog/tag-system-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/tag-system-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Slug normalisation
&lt;/h2&gt;

&lt;p&gt;Every tag is displayed as-is, but routed via a URL-safe slug. A small utility in&lt;br&gt;
&lt;code&gt;src/utils/tags.ts&lt;/code&gt; handles that conversion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns &lt;code&gt;"web development"&lt;/code&gt; into &lt;code&gt;"web-development"&lt;/code&gt;, &lt;code&gt;"Node.js"&lt;/code&gt; into&lt;br&gt;
&lt;code&gt;"nodejs"&lt;/code&gt;, and &lt;code&gt;"C#"&lt;/code&gt; into &lt;code&gt;"c"&lt;/code&gt;. It is intentionally small. A more generic&lt;br&gt;
utility might transliterate Unicode or preserve special cases, but the tag&lt;br&gt;
vocabulary on this site is controlled enough that a simple transform is easier&lt;br&gt;
to reason about.&lt;/p&gt;

&lt;p&gt;The important detail is reuse. The cloud component, sidebar, tags index, and tag&lt;br&gt;
archive routes all import the same helper, so every link resolves to the same&lt;br&gt;
path format.&lt;/p&gt;
&lt;h2&gt;
  
  
  Publication-aware tag counts
&lt;/h2&gt;

&lt;p&gt;Counting tags is trivial. Counting the right tags is the subtle part.&lt;/p&gt;

&lt;p&gt;This site does not let each tag surface invent its own visibility rules. It&lt;br&gt;
filters posts through the shared publication helper and only then derives tag&lt;br&gt;
counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isPublished&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../utils/drafts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPublished&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagCounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matters because &lt;code&gt;isPublished()&lt;/code&gt; hides both drafts and future-dated posts&lt;br&gt;
unless &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt;. The weighted cloud, ring layout, tag archives, and&lt;br&gt;
sidebar all stay in sync with the rest of the site because they start from the&lt;br&gt;
same filtered set.&lt;/p&gt;

&lt;p&gt;Right now the counting logic is repeated across several files. That is still a&lt;br&gt;
good trade-off: the logic is tiny and the behaviour stays obvious. If the rules&lt;br&gt;
become more complex later, extracting a shared &lt;code&gt;getTagCounts()&lt;/code&gt; helper would be&lt;br&gt;
the next clean-up step.&lt;/p&gt;
&lt;h2&gt;
  
  
  The weighted cloud on the blog archive
&lt;/h2&gt;

&lt;p&gt;The weighted cloud lives on the blog archive: &lt;code&gt;/blog&lt;/code&gt; and the paginated&lt;br&gt;
&lt;code&gt;/blog/page/[page]&lt;/code&gt; pages both render the same component.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import BlogTagCloud from "../../components/BlogTagCloud.astro";
// ...
---

&amp;lt;BlogTagCloud /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside &lt;code&gt;BlogTagCloud.astro&lt;/code&gt;, counts are converted into three visual tiers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;minCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minCount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.66&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.33&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those tiers map to modifier classes such as&lt;br&gt;
&lt;code&gt;tag-cloud__pill--tier-1&lt;/code&gt;, &lt;code&gt;tag-cloud__pill--tier-2&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;tag-cloud__pill--tier-3&lt;/code&gt;. A stepped system is deliberate here. Continuous&lt;br&gt;
scaling works well on the more expressive &lt;code&gt;/tags&lt;/code&gt; page, but in a compact cloud&lt;br&gt;
of pill links it creates visual noise faster than it adds meaning.&lt;/p&gt;

&lt;p&gt;Each pill also includes a small count badge, so someone hovering or scanning the&lt;br&gt;
cloud sees both the qualitative weight and the exact number. An "All topics"&lt;br&gt;
link pushes people from the compact archive cloud into the more detailed &lt;code&gt;/tags&lt;/code&gt;&lt;br&gt;
overview.&lt;/p&gt;
&lt;h2&gt;
  
  
  The concentric ring layout on &lt;code&gt;/tags&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/tags&lt;/code&gt; page is the more exploratory view. It combines two separate signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Font size scales continuously with tag frequency.&lt;/li&gt;
&lt;li&gt;Tags are placed on concentric rings, with the most common topic in the centre
and less common topics pushed outward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scaling and ring layout are both calculated at build time in the page&lt;br&gt;
frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_REM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_REM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;minCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MIN_REM&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;MAX_REM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;MIN_REM&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;minCount&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAX_REM&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MIN_REM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ringConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radiusPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radiusPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radiusPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radiusPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;startDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After sorting tags by count descending, the page fills the rings from the inside&lt;br&gt;
out and converts each ring slot into percentage coordinates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;angleDeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startDeg&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angleDeg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rad&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;radiusPct&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rad&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produces an SVG-like layout without SVG. Each tag is still a normal anchor,&lt;br&gt;
just absolutely positioned inside a relative container.&lt;/p&gt;

&lt;p&gt;The accessibility model matters here too. The circular cloud is decorative and&lt;br&gt;
marked &lt;code&gt;aria-hidden&lt;/code&gt;, while a plain list remains in the accessibility tree and&lt;br&gt;
is also the visual fallback on small screens. The fallback list is shuffled with&lt;br&gt;
a seeded function so it does not read as a rigid alphabetical index, but it&lt;br&gt;
stays deterministic across builds.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tag archive pages
&lt;/h2&gt;

&lt;p&gt;Each topic gets a clean first-page URL at &lt;code&gt;/tags/[tag]&lt;/code&gt; and subsequent archive&lt;br&gt;
pages at &lt;code&gt;/tags/[tag]/2&lt;/code&gt;, &lt;code&gt;/tags/[tag]/3&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Rather than using Astro's &lt;code&gt;paginate()&lt;/code&gt; helper, the current implementation keeps&lt;br&gt;
two route files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/pages/tags/[tag]/index.astro&lt;/code&gt; handles page 1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/pages/tags/[tag]/[page].astro&lt;/code&gt; emits pages 2 and above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second file builds its paths manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStaticPaths&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPublished&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tagMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;tagMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
      &lt;span class="nx"&gt;tagMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tagMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pageNum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;pageNum&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;pageNum&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageNum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Routes use slugs, but page heroes should still display the human-readable tag&lt;br&gt;
text. Looking up the original label from the first matching post preserves&lt;br&gt;
spaces and casing without a separate lookup table.&lt;/p&gt;

&lt;p&gt;Both route files sort posts newest first and share the same &lt;code&gt;PAGE_SIZE = 9&lt;/code&gt;, so&lt;br&gt;
pagination behaves consistently across every topic.&lt;/p&gt;
&lt;h2&gt;
  
  
  The sidebar browser
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TagsSidebar.astro&lt;/code&gt; is the compact version of the same system. It keeps the same&lt;br&gt;
counting and sorting pattern, but renders a simple vertical list with count&lt;br&gt;
badges rather than a cloud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const tags = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&amp;gt; b - a)
  .map(([tag, count]) =&amp;gt; ({ tag, count, slug: tagSlug(tag) }));
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sidebar only matters because the post layout mounts it alongside the table&lt;br&gt;
of contents and share controls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import TagsSidebar from "../components/TagsSidebar.astro";

&amp;lt;aside class="post__sidebar" aria-label="Post sidebar"&amp;gt;
  {/* other sidebar blocks */}
  &amp;lt;TagsSidebar /&amp;gt;
&amp;lt;/aside&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps topic browsing available even when someone lands deep on an&lt;br&gt;
individual article. The UI copy uses "topics" in the reader-facing labels, but&lt;br&gt;
the routes stay under &lt;code&gt;/tags&lt;/code&gt;, which keeps the underlying implementation short&lt;br&gt;
and stable.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pulling the system together
&lt;/h2&gt;

&lt;p&gt;The interesting part of this tag system is not any single surface. It is the&lt;br&gt;
fact that every surface starts from the same visible post set and uses the same&lt;br&gt;
slug transform. Once those two rules are stable, the same dataset can show up as&lt;br&gt;
weighted pills, a ring cloud, paginated archives, or a sidebar list without the&lt;br&gt;
site disagreeing with itself.&lt;/p&gt;

&lt;span&gt;Added 23 April 2026&lt;/span&gt;
&lt;h2&gt;
  
  
  Improving the tag pages
&lt;/h2&gt;

&lt;p&gt;A comment from &lt;a href="https://dev.to/fyodorio"&gt;Fyodor&lt;/a&gt; after this post was first published put it directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://dev.to/fyodorio/comment/375bb"&gt;Now try to add description to each tag so that a tag page wouldn't look that generically bland, huh?&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair point. I went ahead and added four improvements to address exactly that.&lt;/p&gt;
&lt;h3&gt;
  
  
  Tag descriptions
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;tagDescriptions&lt;/code&gt; map in &lt;code&gt;src/utils/tags.ts&lt;/code&gt; keys a short description to&lt;br&gt;
each tag slug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagDescriptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Everything about building with Astro: content collections, islands architecture, SSG, and the surrounding ecosystem.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;engineering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Software engineering practice — architecture, trade-offs, and the thinking behind building things well.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full map covering all current tags is in the code listings at the end of this post.&lt;/p&gt;

&lt;p&gt;The tag archive page reads the current slug and falls back gracefully if no&lt;br&gt;
description exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tagDescriptions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That value then drives the &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; description, the hero subtitle, and the&lt;br&gt;
panel copy. One source, three uses. New tags that lack an entry get the old&lt;br&gt;
generic fallback, so nothing breaks.&lt;/p&gt;
&lt;h3&gt;
  
  
  Featured post highlight
&lt;/h3&gt;

&lt;p&gt;The most recent post was buried in the grid with no visual prominence. The fix&lt;br&gt;
pulls it out entirely and renders it as a full-width two-column card above&lt;br&gt;
the grid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;featuredPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remainingPosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remainingPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;remainingPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The featured card shows the cover image on the left at desktop size with the&lt;br&gt;
title, description, and a read CTA on the right. &lt;code&gt;totalPages&lt;/code&gt; is derived from&lt;br&gt;
&lt;code&gt;remainingPosts&lt;/code&gt;, not &lt;code&gt;allTagPosts&lt;/code&gt;, so pagination stays honest.&lt;/p&gt;
&lt;h3&gt;
  
  
  Related topics row
&lt;/h3&gt;

&lt;p&gt;Between the featured card and the grid, a compact pill row links to up to five&lt;br&gt;
related tags, specifically ones that most frequently co-occur with the current one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;relatedTagCounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;relatedTagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relatedTagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;relatedTags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;relatedTagCounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs entirely at build time, with no JavaScript in the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stats bar
&lt;/h3&gt;

&lt;p&gt;Inside the topic overview panel, a &lt;code&gt;&amp;lt;dl&amp;gt;&lt;/code&gt; shows three facts: post count,&lt;br&gt;
average reading time per post, and total reading time across the whole archive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;readingTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalReadMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalWords&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avgReadMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalReadMinutes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;allTagPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;200 wpm&lt;/code&gt; divisor is deliberately conservative: it errs on the side of&lt;br&gt;
not under-promising. The stat gives a quick sense of how deep the archive goes&lt;br&gt;
before committing to reading any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond the foundations
&lt;/h2&gt;

&lt;p&gt;All four improvements build on the same two rules without touching them. The slug helper&lt;br&gt;
and publication filter stay unchanged. What changes is only what each tag page&lt;br&gt;
does with the data once it has it.&lt;/p&gt;

&lt;p&gt;That is a useful sign. When enhancements layer on top without revisiting the&lt;br&gt;
core logic, the foundation was right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full code listings
&lt;/h2&gt;

&lt;p&gt;If you want to inspect the finished implementation end to end, expand the&lt;br&gt;
listings below. These are the complete files that make up the tag system&lt;br&gt;
itself. The shared publication helper lives in &lt;code&gt;src/utils/drafts.ts&lt;/code&gt; and is&lt;br&gt;
reused across the rest of the site too.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/utils/tags.ts&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tagSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagDescriptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tracking, measuring, and making sense of how people use the web — from basic page views to real user metrics.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Everything about building with Astro: content collections, islands architecture, SSG, and the surrounding ecosystem.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;automation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Scripts, workflows, and tooling that take the repetitive work off your plate.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;blogging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The craft and mechanics of running a blog — structure, workflow, tooling, and keeping the writing habit going.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cross-posting to DEV.to, syndication strategies, and making the most of the platform.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dotfiles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Shell configs, editor settings, and the art of making a new machine feel like home.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;engineering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Software engineering practice — architecture, trade-offs, and the thinking behind building things well.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HTML, CSS, JavaScript, and everything that shapes what users actually see and interact with.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;learning-in-public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Writing and sharing while you're still figuring things out — the messy, honest, valuable kind.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Posts about the blog itself: how it was built, why decisions were made, and what's changed.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;netlify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Deploying, hosting, and extending sites with Netlify — functions, forms, edge, and scheduled builds.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tooling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Developer tools that sharpen the workflow: linters, formatters, bundlers, and the rest.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tutorial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Step-by-step guides with working code, aimed at getting you from zero to done.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;web-development&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The broader practice of building for the web — spanning frontend, backend, and everything in between.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;writing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reflections on writing itself: finding a voice, staying consistent, and communicating clearly.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/components/BlogTagCloud.astro&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getCollection } from "astro:content";
import { isPublished } from "../utils/drafts";
import { tagSlug } from "../utils/tags";

const allPosts = (await getCollection("posts")).filter(isPublished);

const tagCounts = new Map();
for (const post of allPosts) {
  for (const tag of post.data.tags) {
    tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
  }
}

const counts = [...tagCounts.values()];
const minCount = Math.min(...counts);
const maxCount = Math.max(...counts);

function tier(count: number): number {
  if (maxCount === minCount) return 2;
  const ratio = (count - minCount) / (maxCount - minCount);
  if (ratio &amp;amp;gt;= 0.66) return 3;
  if (ratio &amp;amp;gt;= 0.33) return 2;
  return 1;
}

const tags = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&amp;amp;gt; b - a)
  .map(([tag, count]) =&amp;amp;gt; ({
    tag,
    count,
    slug: tagSlug(tag),
    tier: tier(count),
  }));
---






          &amp;lt;p&amp;gt;Topics&amp;lt;/p&amp;gt;
          &amp;lt;h2 id="tag-cloud-heading"&amp;gt;
            Browse by topic
          &amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;
            Jump straight into the subjects you care about without paging
            through the full archive first.
          &amp;lt;/p&amp;gt;

        &amp;lt;a href="/tags"&amp;gt;
          All topics →
        &amp;lt;/a&amp;gt;

      &amp;lt;ul&amp;gt;
        {
          tags.map(({ tag, count, slug, tier }) =&amp;amp;gt; (
            &amp;lt;li&amp;gt;
              &amp;lt;a href="{`/tags/${slug}`}"&amp;gt;
                {tag}
                &amp;lt;span&amp;gt;
                  {count}
                &amp;lt;/span&amp;gt;
              &amp;lt;/a&amp;gt;
            &amp;lt;/li&amp;gt;
          ))
        }
      &amp;lt;/ul&amp;gt;





  .tag-cloud {
    padding: 0 1.5rem;
  }

  .tag-cloud__panel {
    position: relative;
    overflow: hidden;
    padding: clamp(1.75rem, 3.5vw, 2.35rem);
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
        transparent 34%
      ),
      radial-gradient(
        circle at left bottom,
        var(--accent-primary-alpha-08) 0%,
        transparent 30%
      ),
      linear-gradient(
        145deg,
        color-mix(
          in srgb,
          var(--surface-page) 88%,
          var(--accent-secondary) 12%
        ),
        var(--surface-elevated)
      );
  }

  .tag-cloud__header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    margin-bottom: 1.75rem;
    gap: 1rem;

    @media (max-width: 639px) {
      flex-direction: column;
      align-items: flex-start;
    }
  }

  .tag-cloud__intro {
    min-width: 0;
  }

  .tag-cloud__title {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(1.8rem, 4vw, 2.5rem);
    line-height: 1.05;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .tag-cloud__description {
    margin: 0.5rem 0 0;
    max-width: 48ch;
    line-height: 1.65;
    color: var(--text-muted);
  }

  .tag-cloud__all-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.85rem;
    padding: 0.75rem 1.05rem;
    border: 1px solid
      color-mix(in srgb, var(--accent-secondary) 20%, transparent);
    border-radius: var(--radius-pill);
    background: color-mix(
      in srgb,
      var(--accent-secondary) 9%,
      var(--surface-elevated)
    );
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.75rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-muted);
    text-decoration: none;
    transition:
      color 0.15s ease,
      border-color 0.15s ease,
      transform 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-secondary);
      border-color: var(--accent-secondary-alpha-28);
      transform: translateY(-1px);
    }

    @media (min-width: 640px) {
      align-self: center;
    }
  }

  .tag-cloud__list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
    align-items: center;
  }

  .tag-cloud__pill {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    text-decoration: none;
    font-family: "Barlow Condensed", sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    border: 1.5px solid var(--border-subtle);
    border-radius: var(--radius-pill);
    color: var(--text-muted);
    background-color: color-mix(
      in srgb,
      var(--surface-elevated) 78%,
      transparent
    );
    box-shadow: inset 0 1px 0 var(--text-on-strong-alpha-45);
    transition:
      color 0.15s ease,
      border-color 0.15s ease,
      background-color 0.15s ease,
      transform 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-secondary);
      border-color: var(--accent-secondary-alpha-26);
      background-color: color-mix(
        in srgb,
        var(--accent-secondary) 9%,
        var(--surface-elevated)
      );
      transform: translateY(-1px);
    }

    &amp;amp;amp;--tier-1 {
      font-size: 0.74rem;
      padding: 0.3rem 0.6rem;
    }

    &amp;amp;amp;--tier-2 {
      font-size: 0.9rem;
      padding: 0.35rem 0.72rem;
    }

    &amp;amp;amp;--tier-3 {
      font-size: 1.04rem;
      padding: 0.42rem 0.82rem;
      border-width: 2px;
      color: var(--text-primary);
      border-color: var(--accent-secondary-alpha-18);
    }
  }

  .tag-cloud__count {
    font-size: 0.65em;
    font-weight: 700;
    color: var(--accent-secondary);
    background-color: color-mix(
      in srgb,
      var(--accent-secondary) 12%,
      transparent
    );
    padding: 0.05rem 0.35rem;
    border-radius: var(--radius-pill);
    transition:
      background-color 0.15s ease,
      color 0.15s ease;

    .tag-cloud__pill:hover &amp;amp;amp; {
      background-color: var(--accent-secondary-alpha-16);
      color: var(--accent-secondary);
    }
  }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/components/TagsSidebar.astro&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getCollection } from "astro:content";
import { isPublished } from "../utils/drafts";
import { tagSlug } from "../utils/tags";

const allPosts = (await getCollection("posts")).filter(isPublished);

const tagCounts = new Map();
for (const post of allPosts) {
  for (const tag of post.data.tags) {
    tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
  }
}

const tags = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&amp;amp;gt; b - a)
  .map(([tag, count]) =&amp;amp;gt; ({ tag, count, slug: tagSlug(tag) }));
---


  &amp;lt;p id="tags-sidebar-heading"&amp;gt;Browse topics&amp;lt;/p&amp;gt;
  &amp;lt;ul&amp;gt;
    {
      tags.map(({ tag, count, slug }) =&amp;amp;gt; (
        &amp;lt;li&amp;gt;
          &amp;lt;a href="{`/tags/${slug}`}"&amp;gt;
            &amp;lt;span&amp;gt;{tag}&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;
              {count}
            &amp;lt;/span&amp;gt;
          &amp;lt;/a&amp;gt;
        &amp;lt;/li&amp;gt;
      ))
    }
  &amp;lt;/ul&amp;gt;
  &amp;lt;a href="/tags"&amp;gt;All tags →&amp;lt;/a&amp;gt;



  .tags-sidebar {
    margin-top: 2rem;
    margin-bottom: 2rem;
  }

  .tags-sidebar__heading {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.75rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--accent-primary);
    padding-top: 0.75rem;
    border-top: 2px solid var(--accent-primary);
    margin-bottom: 0.75rem;
  }

  .tags-sidebar__list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0;
  }

  .tags-sidebar__tag {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.45rem 0;
    border-bottom: 1px solid var(--border-subtle);
    text-decoration: none;
    color: var(--text-muted);
    transition:
      color 0.15s ease,
      padding-left 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-primary);
      padding-left: 0.35rem;
    }
  }

  .tags-sidebar__name {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.875rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .tags-sidebar__count {
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.7rem;
    font-weight: 700;
    color: var(--text-muted);
    background-color: var(--border-subtle);
    border-radius: var(--radius-tight);
    padding: 0.1rem 0.4rem;
    min-width: 1.4rem;
    text-align: center;
    transition:
      background-color 0.15s ease,
      color 0.15s ease;

    .tags-sidebar__tag:hover &amp;amp;amp; {
      background-color: rgba(var(--accent-primary-rgb), 0.12);
      color: var(--accent-primary);
    }
  }

  .tags-sidebar__all {
    display: block;
    margin-top: 1rem;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.75rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-muted);
    text-decoration: none;
    transition: color 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-primary);
    }
  }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/pages/tags/index.astro&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getCollection } from "astro:content";
import { isPublished } from "../../utils/drafts";
import BaseLayout from "../../layouts/BaseLayout.astro";
import PageHero from "../../components/PageHero.astro";
import MailingListCTA from "../../components/MailingListCTA.astro";
import { tagSlug } from "../../utils/tags";

const allPosts = (await getCollection("posts")).filter(isPublished);

const tagCounts = new Map();
for (const post of allPosts) {
  for (const tag of post.data.tags) {
    tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
  }
}

const counts = [...tagCounts.values()];
const minCount = Math.min(...counts);
const maxCount = Math.max(...counts);

const MIN_REM = 1.1;
const MAX_REM = 4;

function scale(count: number): number {
  if (maxCount === minCount) return (MIN_REM + MAX_REM) / 2;
  return (
    MIN_REM + ((count - minCount) / (maxCount - minCount)) * (MAX_REM - MIN_REM)
  );
}

const sorted = [...tagCounts.entries()]
  .sort(([, a], [, b]) =&amp;amp;gt; b - a)
  .map(([tag, count]) =&amp;amp;gt; ({
    tag,
    count,
    slug: tagSlug(tag),
    fontSize: scale(count),
  }));

const ringConfig = [
  { radiusPct: 0, max: 1, startDeg: 0 },
  { radiusPct: 18, max: 4, startDeg: -90 },
  { radiusPct: 35, max: 7, startDeg: -50 },
  { radiusPct: 48, max: 12, startDeg: 10 },
];

type TagPos = {
  tag: string;
  count: number;
  slug: string;
  fontSize: number;
  x: number;
  y: number;
  ringIdx: number;
};

const positioned: TagPos[] = [];
let si = 0;

for (let ringIdx = 0; ringIdx &amp;amp;lt; ringConfig.length; ringIdx++) {
  if (si &amp;amp;gt;= sorted.length) break;
  const { radiusPct, max, startDeg } = ringConfig[ringIdx];
  const items = sorted.slice(si, si + max);
  si += items.length;
  items.forEach((item, i) =&amp;amp;gt; {
    const angleDeg = radiusPct === 0 ? 0 : startDeg + (360 * i) / items.length;
    const rad = (angleDeg * Math.PI) / 180;
    const x = radiusPct === 0 ? 50 : 50 + radiusPct * Math.cos(rad);
    const y = radiusPct === 0 ? 50 : 50 + radiusPct * Math.sin(rad);
    positioned.push({ ...item, x, y, ringIdx });
  });
}

function seededShuffle(arr: T[]): T[] {
  const out = [...arr];
  let seed = 42;
  const rand = () =&amp;amp;gt; {
    seed = (seed * 1664525 + 1013904223) &amp;amp;amp; 0xffffffff;
    return (seed &amp;amp;gt;&amp;amp;gt;&amp;amp;gt; 0) / 0xffffffff;
  };
  for (let i = out.length - 1; i &amp;amp;gt; 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}

const shuffled = seededShuffle(sorted);
const totalTags = sorted.length;
const totalPosts = allPosts.length;

const ringStyle = (ringIdx: number) =&amp;amp;gt; {
  if (ringIdx === 0) return "color: var(--accent-primary); opacity: 1";
  if (ringIdx === 1) return "opacity: 1";
  if (ringIdx === 2) return "opacity: 0.85";
  return "opacity: 0.7";
};
---








          &amp;lt;p&amp;gt;Topic map&amp;lt;/p&amp;gt;
          &amp;lt;h2 id="tag-summary-heading"&amp;gt;
            Explore the writing by theme
          &amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;
            Jump straight into the ideas that show up most often across the
            blog, or skim the full map below if you want to browse more broadly.
          &amp;lt;/p&amp;gt;

        &amp;lt;ul&amp;gt;
          &amp;lt;li&amp;gt;
            &amp;lt;span&amp;gt;Published&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;{totalPosts}&amp;lt;/span&amp;gt;
          &amp;lt;/li&amp;gt;
          &amp;lt;li&amp;gt;
            &amp;lt;span&amp;gt;Topics&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;{totalTags}&amp;lt;/span&amp;gt;
          &amp;lt;/li&amp;gt;
        &amp;lt;/ul&amp;gt;








          &amp;lt;p&amp;gt;
            Each topic links to a collection of related posts. Larger tags mean
            I write about that area more often, so the subjects I return to most
            sit closer to the centre.
          &amp;lt;/p&amp;gt;

            &amp;lt;span&amp;gt;fewer posts&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;
            &amp;lt;span&amp;gt;more posts&amp;lt;/span&amp;gt;





            {
              positioned.map(({ tag, slug, fontSize, x, y, ringIdx }) =&amp;amp;gt; (
                &amp;lt;a href="{`/tags/${slug}`}"&amp;gt;
                  {tag}
                &amp;lt;/a&amp;gt;
              ))
            }



        &amp;lt;ul&amp;gt;
          {
            shuffled.map(({ tag, count, slug, fontSize }) =&amp;amp;gt; (
              &amp;lt;li&amp;gt;
                &amp;lt;a href="{`/tags/${slug}`}"&amp;gt;
                  {tag}
                &amp;lt;/a&amp;gt;
              &amp;lt;/li&amp;gt;
            ))
          }
        &amp;lt;/ul&amp;gt;








  .tag-summary {
    padding: 0 1.5rem;
  }

  .tag-summary__panel {
    display: grid;
    gap: 1.25rem;
    border-top-color: var(--accent-secondary);
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
        transparent 34%
      ),
      linear-gradient(
        150deg,
        color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated))
          0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);

    @media (min-width: 768px) {
      grid-template-columns: minmax(0, 1.5fr) auto;
      gap: 2rem;
      align-items: center;
    }
  }

  .tag-summary__eyebrow {
    margin: 0 0 0.65rem;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--accent-secondary);
  }

  .tag-summary__title {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(2rem, 4vw, 2.8rem);
    line-height: 0.98;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .tag-summary__text {
    margin: 0.85rem 0 0;
    max-width: 58ch;
    line-height: 1.7;
    color: var(--text-muted);
  }

  .tag-summary__stats {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    gap: 0.75rem;
  }

  .tag-summary__stat {
    display: grid;
    gap: 0.35rem;
    min-width: 9rem;
    padding: 1rem 1.1rem;
    border: 1px solid var(--accent-secondary-alpha-16);
    border-radius: var(--radius-soft);
    background: color-mix(
      in srgb,
      var(--accent-secondary) 8%,
      var(--surface-elevated)
    );
  }

  .tag-summary__label {
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--text-muted);
  }

  .tag-summary__value {
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(1.55rem, 4vw, 2.1rem);
    font-weight: 800;
    line-height: 1;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .cloud-section {
    padding-top: 0;
    padding-bottom: 0;
  }

  .cloud-surface {
    overflow: hidden;
    padding: clamp(1.5rem, 3vw, 2rem);
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 10%, transparent) 0%,
        transparent 34%
      ),
      linear-gradient(
        150deg,
        color-mix(in srgb, var(--accent-primary) 3%, var(--surface-elevated)) 0%,
        var(--surface-elevated) 66%
      ),
      var(--surface-elevated);
  }

  .cloud-intro {
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
    margin-bottom: 2.5rem;
    padding-bottom: 2rem;
    border-bottom: 1px solid var(--border-subtle);

    @media (min-width: 768px) {
      flex-direction: row;
      align-items: flex-start;
      justify-content: space-between;
      gap: 4rem;
    }
  }

  .cloud-intro__text {
    margin: 0;
    font-size: 1rem;
    line-height: 1.75;
    color: var(--text-muted);
    max-width: 52ch;
  }

  .cloud-legend {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .cloud-legend__arrow {
    color: var(--accent-secondary);
    font-size: 1.25rem;
  }

  .cloud-legend__example {
    font-family: "Barlow Condensed", sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-primary);
  }

  .cloud-legend__example--sm {
    font-size: 0.875rem;
    opacity: 0.45;
  }

  .cloud-legend__example--lg {
    font-size: 2rem;
    color: var(--accent-secondary);
  }

  .cloud-circle-wrap {
    display: none;
    justify-content: center;
    margin: 0.5rem 0 2rem;

    @media (min-width: 640px) {
      display: flex;
    }
  }

  .cloud-circle {
    position: relative;
    width: min(88vw, 580px);
    aspect-ratio: 1;

    &amp;amp;amp;::before {
      content: "";
      position: absolute;
      inset: 0;
      border-radius: 50%;
      border: 1px solid var(--border-subtle);
      pointer-events: none;
    }

    &amp;amp;amp;::after {
      content: "";
      position: absolute;
      inset: 22%;
      border-radius: 50%;
      border: 1px dashed var(--accent-secondary-alpha-18);
      pointer-events: none;
    }
  }

  .cloud-circle__tag {
    position: absolute;
    transform: translate(-50%, -50%) scale(1);
    font-family: "Barlow Condensed", sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    text-decoration: none;
    color: var(--text-primary);
    white-space: nowrap;
    transition:
      color 0.2s ease,
      opacity 0.2s ease,
      transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);

    &amp;amp;amp;:hover {
      color: var(--accent-secondary) !important;
      opacity: 1 !important;
      transform: translate(-50%, -50%) scale(1.18);
      z-index: 1;
    }
  }

  .cloud-circle:has(.cloud-circle__tag:hover) .cloud-circle__tag:not(:hover) {
    opacity: 0.15 !important;
    transform: translate(-50%, -50%) scale(0.95);
  }

  .cloud-list {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    gap: 0.75rem 1.5rem;
    list-style: none;
    margin: 1rem 0;
    padding: 0;

    @media (min-width: 640px) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip: rect(0 0 0 0);
      white-space: nowrap;
    }
  }

  .cloud-list__tag {
    font-family: "Barlow Condensed", sans-serif;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-primary);
    text-decoration: none;
    opacity: 0.75;
    white-space: nowrap;
    transition:
      color 0.15s ease,
      opacity 0.15s ease;

    &amp;amp;amp;:hover {
      color: var(--accent-secondary);
      opacity: 1;
    }
  }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/pages/tags/[tag]/index.astro&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getCollection } from "astro:content";
import { isPublished } from "../../../utils/drafts";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import PageHero from "../../../components/PageHero.astro";
import BlogGrid from "../../../components/BlogGrid.astro";
import MailingListCTA from "../../../components/MailingListCTA.astro";
import { tagSlug } from "../../../utils/tags";

export async function getStaticPaths() {
  const allPosts = (await getCollection("posts")).filter(isPublished);
  const tagMap = new Map();
  for (const post of allPosts) {
    for (const tag of post.data.tags) {
      const slug = tagSlug(tag);
      if (!tagMap.has(slug)) tagMap.set(slug, []);
      tagMap.get(slug)!.push(post);
    }
  }
  return [...tagMap.entries()].map(([slug, posts]) =&amp;amp;gt; {
    const label = posts[0].data.tags.find((t) =&amp;amp;gt; tagSlug(t) === slug) ?? slug;
    return { params: { tag: slug }, props: { label } };
  });
}

const PAGE_SIZE = 9;
const { tag } = Astro.params;
const { label } = Astro.props;

const allTagPosts = (await getCollection("posts"))
  .filter(
    (post) =&amp;amp;gt;
      isPublished(post) &amp;amp;amp;&amp;amp;amp; post.data.tags.some((t) =&amp;amp;gt; tagSlug(t) === tag),
  )
  .sort((a, b) =&amp;amp;gt; b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

const totalPages = Math.ceil(allTagPosts.length / PAGE_SIZE);
const posts = allTagPosts.slice(0, PAGE_SIZE);
const nextUrl = totalPages &amp;amp;gt; 1 ? `/tags/${tag}/2` : null;
const postCountLabel =
  allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`;
---








          &amp;lt;p&amp;gt;Topic archive&amp;lt;/p&amp;gt;
          &amp;lt;h2 id="topic-overview-heading"&amp;gt;
            {postCountLabel} about {label}
          &amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;
            This archive keeps every article filed under this topic in one
            place, newest first, so you can stay with one theme without jumping
            around the rest of the site.
          &amp;lt;/p&amp;gt;


          &amp;lt;a href="/tags"&amp;gt;All topics&amp;lt;/a&amp;gt;
          &amp;lt;a href="/blog"&amp;gt;
            Latest writing
          &amp;lt;/a&amp;gt;





   1 ? `, across ${totalPages} pages.` : "."}`}
  /&amp;amp;gt;





  .topic-overview {
    padding: 0 1.5rem;
  }

  .topic-overview__panel {
    display: grid;
    gap: 1.25rem;
    border-top-color: var(--accent-secondary);
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
        transparent 34%
      ),
      linear-gradient(
        150deg,
        color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated))
          0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);

    @media (min-width: 768px) {
      grid-template-columns: minmax(0, 1.45fr) auto;
      gap: 2rem;
      align-items: center;
    }
  }

  .topic-overview__eyebrow {
    margin: 0 0 0.65rem;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--accent-secondary);
  }

  .topic-overview__title {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(1.9rem, 4vw, 2.7rem);
    line-height: 0.98;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .topic-overview__text {
    margin: 0.85rem 0 0;
    max-width: 58ch;
    line-height: 1.7;
    color: var(--text-muted);
  }

  .topic-overview__actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
  }

  .topic-overview__link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.85rem;
    padding: 0.7rem 1rem;
    border: 1px solid var(--accent-secondary-alpha-18);
    border-radius: var(--radius-pill);
    background: color-mix(
      in srgb,
      var(--accent-secondary) 8%,
      var(--surface-elevated)
    );
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.85rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--accent-secondary);
    text-decoration: none;
    transition:
      border-color 0.15s ease,
      transform 0.15s ease;

    &amp;amp;amp;:hover {
      border-color: var(--accent-secondary-alpha-32);
      transform: translateY(-1px);
    }
  }

  .topic-overview__link--secondary {
    border-color: var(--accent-primary-alpha-22);
    background: color-mix(
      in srgb,
      var(--accent-primary) 8%,
      var(--surface-elevated)
    );
    color: var(--accent-primary);
  }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;src/pages/tags/[tag]/[page].astro&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getCollection } from "astro:content";
import { isPublished } from "../../../utils/drafts";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import PageHero from "../../../components/PageHero.astro";
import BlogGrid from "../../../components/BlogGrid.astro";
import MailingListCTA from "../../../components/MailingListCTA.astro";
import { tagSlug } from "../../../utils/tags";

export async function getStaticPaths() {
  const PAGE_SIZE = 9;
  const allPosts = (await getCollection("posts")).filter(isPublished);
  const tagMap = new Map();
  for (const post of allPosts) {
    for (const tag of post.data.tags) {
      const slug = tagSlug(tag);
      if (!tagMap.has(slug)) tagMap.set(slug, []);
      tagMap.get(slug)!.push(post);
    }
  }
  const paths: object[] = [];
  for (const [slug, posts] of tagMap.entries()) {
    const totalPages = Math.ceil(posts.length / PAGE_SIZE);
    const label = posts[0].data.tags.find((t) =&amp;amp;gt; tagSlug(t) === slug) ?? slug;
    for (let pageNum = 2; pageNum &amp;amp;lt;= totalPages; pageNum++) {
      paths.push({
        params: { tag: slug, page: String(pageNum) },
        props: { label },
      });
    }
  }
  return paths;
}

const PAGE_SIZE = 9;
const { tag, page } = Astro.params;
const { label } = Astro.props;
const currentPage = Number(page);

const allTagPosts = (await getCollection("posts"))
  .filter(
    (post) =&amp;amp;gt;
      isPublished(post) &amp;amp;amp;&amp;amp;amp; post.data.tags.some((t) =&amp;amp;gt; tagSlug(t) === tag),
  )
  .sort((a, b) =&amp;amp;gt; b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

const totalPages = Math.ceil(allTagPosts.length / PAGE_SIZE);
const posts = allTagPosts.slice(
  (currentPage - 1) * PAGE_SIZE,
  currentPage * PAGE_SIZE,
);
const prevUrl =
  currentPage === 2 ? `/tags/${tag}` : `/tags/${tag}/${currentPage - 1}`;
const nextUrl =
  currentPage &amp;amp;lt; totalPages ? `/tags/${tag}/${currentPage + 1}` : null;
const postCountLabel =
  allTagPosts.length === 1 ? "1 post" : `${allTagPosts.length} posts`;
---








          &amp;lt;p&amp;gt;Topic archive&amp;lt;/p&amp;gt;
          &amp;lt;h2 id="topic-overview-heading"&amp;gt;
            Page {currentPage} of {totalPages}
          &amp;lt;/h2&amp;gt;
          &amp;lt;p&amp;gt;
            {postCountLabel} filed under {label}. This page keeps you inside the
            same topic while you move through older entries in the archive.
          &amp;lt;/p&amp;gt;


          &amp;lt;a href="{`/tags/${tag}`}"&amp;gt;
            Topic overview
          &amp;lt;/a&amp;gt;
          &amp;lt;a href="/blog"&amp;gt;
            Latest writing
          &amp;lt;/a&amp;gt;











  .topic-overview {
    padding: 0 1.5rem;
  }

  .topic-overview__panel {
    display: grid;
    gap: 1.25rem;
    border-top-color: var(--accent-secondary);
    background:
      radial-gradient(
        circle at top right,
        color-mix(in srgb, var(--accent-secondary) 14%, transparent) 0%,
        transparent 34%
      ),
      linear-gradient(
        150deg,
        color-mix(in srgb, var(--accent-secondary) 8%, var(--surface-elevated))
          0%,
        var(--surface-elevated) 62%
      ),
      var(--surface-elevated);

    @media (min-width: 768px) {
      grid-template-columns: minmax(0, 1.45fr) auto;
      gap: 2rem;
      align-items: center;
    }
  }

  .topic-overview__eyebrow {
    margin: 0 0 0.65rem;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--accent-secondary);
  }

  .topic-overview__title {
    margin: 0;
    font-family: "Barlow Condensed", sans-serif;
    font-size: clamp(1.9rem, 4vw, 2.7rem);
    line-height: 0.98;
    text-transform: uppercase;
    color: var(--text-primary);
  }

  .topic-overview__text {
    margin: 0.85rem 0 0;
    max-width: 58ch;
    line-height: 1.7;
    color: var(--text-muted);
  }

  .topic-overview__actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
  }

  .topic-overview__link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.85rem;
    padding: 0.7rem 1rem;
    border: 1px solid var(--accent-secondary-alpha-18);
    border-radius: var(--radius-pill);
    background: color-mix(
      in srgb,
      var(--accent-secondary) 8%,
      var(--surface-elevated)
    );
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.85rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--accent-secondary);
    text-decoration: none;
    transition:
      border-color 0.15s ease,
      transform 0.15s ease;

    &amp;amp;amp;:hover {
      border-color: var(--accent-secondary-alpha-32);
      transform: translateY(-1px);
    }
  }

  .topic-overview__link--secondary {
    border-color: var(--accent-primary-alpha-22);
    background: color-mix(
      in srgb,
      var(--accent-primary) 8%,
      var(--surface-elevated)
    );
    color: var(--accent-primary);
  }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;You can browse the rest of the site code in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/web-sourcier.uk" rel="noopener noreferrer"&gt;web-sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Designing the blog card and post hero</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 16 Apr 2026 21:15:22 +0000</pubDate>
      <link>https://dev.to/sourcier/designing-the-blog-card-and-post-hero-877</link>
      <guid>https://dev.to/sourcier/designing-the-blog-card-and-post-hero-877</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/blog-card-hero-design" rel="noopener noreferrer"&gt;Designing the blog card and post hero&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Series: Part of &lt;a href="https://sourcier.uk/blog/how-this-blog-was-built" rel="noopener noreferrer"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The blog listing is the front door to most posts — it's what someone sees when they&lt;br&gt;
land on &lt;code&gt;/blog&lt;/code&gt;, and it should communicate enough about each post that they can&lt;br&gt;
decide whether to click. Getting the card right matters.&lt;/p&gt;

&lt;p&gt;There are also some practical decisions baked into the implementation that are&lt;br&gt;
worth documenting: where reading time is calculated, how cover and thumbnail&lt;br&gt;
assets move through the site, how the card stays visually consistent in a grid,&lt;br&gt;
and how the same content contract scales from the listing card to the full post&lt;br&gt;
hero. Draft handling exists too, but mostly as a development convenience rather&lt;br&gt;
than a core product feature.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scope note: this article stays focused on the contract between two UI&lt;br&gt;
surfaces: the blog card and the page hero. A few supporting features appear&lt;br&gt;
along the way, and they will be covered in later articles in the series:&lt;br&gt;
reading time, tag architecture, Pagefind thumbnails and search&lt;br&gt;
metadata, breadcrumb markup, the share widget, and the draft-to-publish flow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6fpkbjqgblfqh12c3ody.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6fpkbjqgblfqh12c3ody.png" alt="Blog card and post hero wireframe showing cover image, draft badge, reading-time metadata, title/subtitle/description, CTA elements, and the dark hero panel with breadcrumbs, metadata, and share widget" width="700" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the original SVG: &lt;a href="https://sourcier.uk/blog/blog-card-hero-design" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/blog-card-hero-design&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The card component
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;BlogPost.astro&lt;/code&gt; is a pure presentational component, and its contract includes&lt;br&gt;
&lt;code&gt;readingTime&lt;/code&gt; as well as the original card data. The listing pages compute&lt;br&gt;
reading time upstream and pass it in so the card itself stays presentational:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
const {
  description,
  title,
  subTitle,
  url,
  cover,
  pubDate,
  draft,
  readingTime,
} = Astro.props;
const formattedDate = pubDate
  ? new Date(pubDate).toLocaleDateString("en-GB", {
      year: "numeric",
      month: "short",
      day: "numeric",
      timeZone: "Europe/London",
      timeZoneName: "short",
    })
  : null;
---

&amp;lt;a href={url} class={`card card__blog${draft ? " card__blog--draft" : ""}`}&amp;gt;
  {draft &amp;amp;&amp;amp; (
    &amp;lt;span class="card__draft-badge" aria-label="Draft"&amp;gt;Draft&amp;lt;/span&amp;gt;
  )}
  {cover &amp;amp;&amp;amp; (
    &amp;lt;div class="card__cover"&amp;gt;
      &amp;lt;img src={cover.image.src} alt={cover.image.alt} /&amp;gt;
      &amp;lt;div class="card__cover-overlay" aria-hidden="true" /&amp;gt;
    &amp;lt;/div&amp;gt;
  )}
  &amp;lt;div class="card__body"&amp;gt;
    {formattedDate &amp;amp;&amp;amp; (
      &amp;lt;p class="card-meta"&amp;gt;
        {formattedDate}
        {readingTime &amp;amp;&amp;amp; ` · ${readingTime}`}
      &amp;lt;/p&amp;gt;
    )}
    &amp;lt;p class="card__title"&amp;gt;{title}&amp;lt;/p&amp;gt;
    {subTitle &amp;amp;&amp;amp; &amp;lt;p class="card__subtitle"&amp;gt;{subTitle}&amp;lt;/p&amp;gt;}
    &amp;lt;p class="card__description"&amp;gt;{description}&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div class="card__cta"&amp;gt;
    &amp;lt;span&amp;gt;Read Post&amp;lt;/span&amp;gt;
    &amp;lt;span aria-hidden="true"&amp;gt;→&amp;lt;/span&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/a&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entire card is a single &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; — the click target is the full card surface, not&lt;br&gt;
just a button inside it. That keeps the interaction model simple, but it also means&lt;br&gt;
the card should not grow secondary interactive controls inside it. If the design&lt;br&gt;
ever needs a bookmark button, menu, or other separate action, the structure should&lt;br&gt;
change rather than nesting another interactive element inside the link.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;card__cta&lt;/code&gt; at the bottom ("Read Post →") is decorative — it's not needed for&lt;br&gt;
accessibility because the outer &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; carries the destination, but it's a clear&lt;br&gt;
visual affordance that the card is clickable.&lt;/p&gt;

&lt;p&gt;Reading time is deliberately calculated outside the component. &lt;code&gt;BlogGrid.astro&lt;/code&gt;&lt;br&gt;
and the homepage grid both call &lt;code&gt;readingTime(post.body ?? "").text&lt;/code&gt; and pass the&lt;br&gt;
result down. That keeps the card focused on rendering, not content parsing.&lt;/p&gt;

&lt;p&gt;That split is deliberate: date formatting stays in the component because it is&lt;br&gt;
display logic, but reading time is derived from body content and belongs upstream.&lt;/p&gt;

&lt;p&gt;Reading time is one of the places where this article deliberately stops short.&lt;br&gt;
The important part here is the handoff into the card and hero. The word-count&lt;br&gt;
calculation itself, and the decisions behind the exact string formatting, belong&lt;br&gt;
in a separate article about reading time as a site-wide concern.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keeping the grid balanced
&lt;/h2&gt;

&lt;p&gt;Cards in a listing need to feel like they belong to the same system even when the&lt;br&gt;
content varies. Some posts have longer subtitles, some have longer descriptions,&lt;br&gt;
and some have cover images with very different compositions.&lt;/p&gt;

&lt;p&gt;The card layout handles that by fixing the cover area, using a vertical flex&lt;br&gt;
layout for the body, and clamping the description so one verbose post does not&lt;br&gt;
stretch an entire row.&lt;/p&gt;

&lt;p&gt;The description clamp is doing most of the balancing work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card__description&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text-overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ellipsis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;webkit-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;-webkit-line-clamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;-webkit-box-orient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vertical&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps the card heights from drifting too far apart while still letting the&lt;br&gt;
description feel like real copy rather than a one-line stub.&lt;/p&gt;

&lt;p&gt;The hover treatment then adds feedback without changing the structure. The card&lt;br&gt;
lifts, the cover image scales slightly, and the overlay fades in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card.card__blog&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.18s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;box-shadow&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.18s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;border-color&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.18s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-4px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt; &lt;span class="m"&gt;32px&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;232&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;106&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.18&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;color-pink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;.card__cover&lt;/span&gt; &lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="mi"&gt;.04&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.card__cover-overlay&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those details matter because the listing is dense. Small motion and consistent&lt;br&gt;
heights help it read as a curated grid rather than a stack of unrelated links.&lt;/p&gt;
&lt;h2&gt;
  
  
  Passing the same contract into the hero
&lt;/h2&gt;

&lt;p&gt;The post page uses the same content fields as the card, but with different&lt;br&gt;
emphasis. The route computes the reading time and passes it into the layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { getCollection, render } from "astro:content";
import { isPublished } from "../../utils/drafts";
import readingTime from "reading-time";

const { post } = Astro.props;
const { Content, headings } = await render(post);
const postReadingTime = readingTime(post.body ?? "").text;
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MarkdownPostLayout.astro&lt;/code&gt; then forwards the same core metadata into&lt;br&gt;
&lt;code&gt;PageHero.astro&lt;/code&gt; as &lt;code&gt;coverImage&lt;/code&gt;, so the card and the hero stay visually aligned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;PageHero
  title={frontmatter.title}
  subtitle={frontmatter.subTitle}
  coverImage={frontmatter.cover?.image &amp;amp;&amp;amp; {
    src: frontmatter.cover.image.src,
    alt: frontmatter.cover.alt,
  }}
  tags={frontmatter.tags}
  author={frontmatter.author}
  date={formattedDate}
  readingTime={readingTime}
  crumbs={crumbs}
  shareUrl={shareUrl}
  shareTitle={frontmatter.title}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That shared contract is the important architectural decision. The card and the&lt;br&gt;
hero are not two unrelated components that happen to show some of the same data.&lt;br&gt;
They are two views over the same content model.&lt;/p&gt;
&lt;h2&gt;
  
  
  The hero component
&lt;/h2&gt;

&lt;p&gt;Every post page uses &lt;code&gt;PageHero.astro&lt;/code&gt; as its header. For blog posts, the&lt;br&gt;
interesting part is not just that it can show a cover image, but that it can do&lt;br&gt;
that while still handling breadcrumbs, metadata, tags, and sharing without&lt;br&gt;
turning into a one-off page template.&lt;/p&gt;

&lt;p&gt;The prop interface shows the scope clearly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;interface Props {
  kicker?: string;
  title: string;
  subtitle?: string;
  coverImage?: { src: string; alt?: string };
  tags?: string[];
  author?: string;
  date?: string;
  readingTime?: string;
  crumbs?: Crumb[];
  shareUrl?: string;
  shareTitle?: string;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives the hero enough information to work for post pages, listing pages,&lt;br&gt;
and any future landing page that wants the same visual treatment.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;crumbs&lt;/code&gt; is omitted, &lt;code&gt;PageHero.astro&lt;/code&gt; can derive breadcrumbs from the URL. Post&lt;br&gt;
pages pass them explicitly, but the fallback keeps the component self-sufficient&lt;br&gt;
in other contexts.&lt;/p&gt;

&lt;p&gt;The cover image is optional. If it exists, the hero switches into a heavier,&lt;br&gt;
image-led mode with a dark gradient overlay. If not, it still renders as a clean&lt;br&gt;
text-first hero on a dark background.&lt;/p&gt;
&lt;h2&gt;
  
  
  Subtle movement and shared share UI
&lt;/h2&gt;

&lt;p&gt;The hero background gets a small parallax effect when a cover image is present.&lt;br&gt;
This is the entire script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const hero = document.querySelector&amp;lt;HTMLElement&amp;gt;(
  ".page-hero[data-has-cover='true']",
);
if (hero) {
  const update = () =&amp;gt; {
    const offset = window.scrollY;
    hero.style.backgroundPositionY = `calc(50% + ${offset * 0.4}px)`;
  };
  window.addEventListener("scroll", update, { passive: true });
  update();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's enough to stop the hero feeling static, without turning it into a showy&lt;br&gt;
effect that competes with the title.&lt;/p&gt;

&lt;p&gt;The share widget is not a separate hero-only implementation. &lt;code&gt;PageHero.astro&lt;/code&gt;&lt;br&gt;
renders the shared &lt;code&gt;SharePost&lt;/code&gt; component, then restyles it in place so it fits&lt;br&gt;
the darker surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.page-hero__share&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.share-post__btn&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.55&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.18&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.06&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;color-pink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;232&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;106&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;232&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;106&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;kicker&lt;/code&gt; prop is part of the component API, but &lt;code&gt;MarkdownPostLayout.astro&lt;/code&gt;&lt;br&gt;
does not pass it for article pages. It is used in other contexts such as the&lt;br&gt;
blog index hero, which is a good example of why keeping the component contract&lt;br&gt;
slightly broader than any single caller is useful.&lt;/p&gt;

&lt;p&gt;The result is a consistent pair of surfaces: the card does the work of getting a&lt;br&gt;
reader to click, and the hero takes the same ingredients and gives them enough&lt;br&gt;
weight to anchor the full article.&lt;/p&gt;
&lt;h2&gt;
  
  
  Draft badge and overlay for local development
&lt;/h2&gt;

&lt;p&gt;This is the one part of the card system that exists mainly for the author rather&lt;br&gt;
than the reader.&lt;/p&gt;

&lt;p&gt;When a post has &lt;code&gt;draft: true&lt;/code&gt;, two things happen visually. A pink badge appears&lt;br&gt;
in the top-left corner of the card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card__draft-badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.625rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.625rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.2rem&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.55rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;color-pink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Barlow Condensed"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.65rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;700&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text-transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;uppercase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;letter-spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.1em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a semi-transparent overlay covers the entire card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;card__blog--draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;color-paper&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.55&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The overlay is a &lt;code&gt;::after&lt;/code&gt; pseudo-element so it doesn't add DOM noise. &lt;code&gt;pointer-events: none&lt;/code&gt;&lt;br&gt;
ensures it doesn't intercept clicks — the underlying &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; remains clickable.&lt;br&gt;
The &lt;code&gt;z-index: 1&lt;/code&gt; keeps it below the badge (&lt;code&gt;z-index: 2&lt;/code&gt;) so the badge label&lt;br&gt;
stays readable.&lt;/p&gt;

&lt;p&gt;This is really a convenience feature for local development, not a production&lt;br&gt;
content pattern. Draft posts are filtered out by the publishing logic unless&lt;br&gt;
drafts are explicitly enabled, so the badge and overlay mainly exist to make&lt;br&gt;
draft-heavy local sessions easier to scan at a glance.&lt;/p&gt;

&lt;p&gt;That broader publishing logic is out of scope for this article. The &lt;code&gt;draft&lt;/code&gt;&lt;br&gt;
flag itself comes from the content model, and the rules that decide whether a&lt;br&gt;
post is visible belong to the publishing workflow rather than the card design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full component listings
&lt;/h2&gt;

&lt;p&gt;If you want to inspect the finished components end to end, expand the listings&lt;br&gt;
below. They show the complete &lt;code&gt;BlogPost.astro&lt;/code&gt; and &lt;code&gt;PageHero.astro&lt;/code&gt;&lt;br&gt;
implementations in one place while you read.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/components/BlogPost.astro&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
const {
  description,
  title,
  subTitle,
  url,
  cover,
  pubDate,
  draft,
  readingTime,
} = Astro.props;
const formattedDate = pubDate
  ? new Date(pubDate).toLocaleDateString("en-GB", {
      year: "numeric",
      month: "short",
      day: "numeric",
      timeZone: "Europe/London",
      timeZoneName: "short",
    })
  : null;
---

&amp;lt;a href="{url}"&amp;gt;
  {
    draft &amp;amp;amp;&amp;amp;amp; (
      &amp;lt;span&amp;gt;
        Draft
      &amp;lt;/span&amp;gt;
    )
  }
  {
    cover &amp;amp;amp;&amp;amp;amp; (

        &amp;lt;img src="{cover.image.src}" alt="{cover.image.alt}"&amp;gt;


    )
  }

    {
      formattedDate &amp;amp;amp;&amp;amp;amp; (
        &amp;lt;p&amp;gt;
          {formattedDate}
          {readingTime &amp;amp;amp;&amp;amp;amp; ` · ${readingTime}`}
        &amp;lt;/p&amp;gt;
      )
    }
    &amp;lt;p&amp;gt;{title}&amp;lt;/p&amp;gt;
    {subTitle &amp;amp;amp;&amp;amp;amp; &amp;lt;p&amp;gt;{subTitle}&amp;lt;/p&amp;gt;}
    &amp;lt;p&amp;gt;{description}&amp;lt;/p&amp;gt;


    &amp;lt;span&amp;gt;Read Post&amp;lt;/span&amp;gt;
    &amp;lt;span&amp;gt;→&amp;lt;/span&amp;gt;

&amp;lt;/a&amp;gt;


  .card__description {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: initial;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
  }

  .card__draft-badge {
    position: absolute;
    top: 0.625rem;
    left: 0.625rem;
    z-index: 2;
    padding: 0.2rem 0.55rem;
    background-color: var(--color-pink);
    color: #ffffff;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.65rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    line-height: 1;
  }

  .card__blog--draft {
    position: relative;

    &amp;amp;amp;::after {
      content: "";
      position: absolute;
      inset: 0;
      background-color: var(--color-paper);
      opacity: 0.55;
      pointer-events: none;
      z-index: 1;
    }
  }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;code&gt;PageHero.astro&lt;/code&gt; imports &lt;code&gt;Breadcrumb.astro&lt;/code&gt; and &lt;code&gt;SharePost.astro&lt;/code&gt;, so this&lt;br&gt;
listing keeps those supporting components as imports instead of inlining them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/components/PageHero.astro&lt;/code&gt;&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { tagSlug } from "../utils/tags";
import Breadcrumb from "./Breadcrumb.astro";
import SharePost from "./SharePost.astro";

interface Crumb {
  label: string;
  href?: string;
}

interface Props {
  kicker?: string;
  title: string;
  subtitle?: string;
  coverImage?: { src: string; alt?: string };
  tags?: string[];
  author?: string;
  date?: string;
  readingTime?: string;
  crumbs?: Crumb[];
  shareUrl?: string;
  shareTitle?: string;
}

const {
  kicker,
  title,
  subtitle,
  coverImage,
  tags,
  author,
  date,
  readingTime,
  crumbs,
  shareUrl,
  shareTitle,
} = Astro.props;

function autoCrumbs(path: string): Crumb[] {
  const segments = path.split("/").filter(Boolean);
  const meaningful = segments.filter((s) =&amp;amp;gt; s !== "page" &amp;amp;amp;&amp;amp;amp; !/^\d+$/.test(s));
  return meaningful.map((seg, i) =&amp;amp;gt; ({
    label: seg.replace(/-/g, " ").replace(/\b\w/g, (c) =&amp;amp;gt; c.toUpperCase()),
    href: "/" + meaningful.slice(0, i + 1).join("/"),
  }));
}

const resolvedCrumbs: Crumb[] = crumbs ?? autoCrumbs(Astro.url.pathname);
---




      {
        resolvedCrumbs.length &amp;amp;gt; 0 &amp;amp;amp;&amp;amp;amp; (

        )
      }
      {kicker &amp;amp;amp;&amp;amp;amp; &amp;lt;p&amp;gt;{kicker}&amp;lt;/p&amp;gt;}
      &amp;lt;h1&amp;gt;{title}&amp;lt;/h1&amp;gt;
      {subtitle &amp;amp;amp;&amp;amp;amp; &amp;lt;p&amp;gt;{subtitle}&amp;lt;/p&amp;gt;}
      {
        (author || date || readingTime || (tags &amp;amp;amp;&amp;amp;amp; tags.length &amp;amp;gt; 0)) &amp;amp;amp;&amp;amp;amp; (

            {author &amp;amp;amp;&amp;amp;amp; &amp;lt;span&amp;gt;By {author}&amp;lt;/span&amp;gt;}
            {date &amp;amp;amp;&amp;amp;amp; &amp;lt;span&amp;gt;{date}&amp;lt;/span&amp;gt;}
            {readingTime &amp;amp;amp;&amp;amp;amp; (
              &amp;lt;span&amp;gt;{readingTime}&amp;lt;/span&amp;gt;
            )}
            {(date || readingTime) &amp;amp;amp;&amp;amp;amp; tags &amp;amp;amp;&amp;amp;amp; tags.length &amp;amp;gt; 0 &amp;amp;amp;&amp;amp;amp; (
              &amp;lt;span&amp;gt;&amp;lt;/span&amp;gt;
            )}
            {tags &amp;amp;amp;&amp;amp;amp; tags.length &amp;amp;gt; 0 &amp;amp;amp;&amp;amp;amp; (

                {tags.map((tag: string) =&amp;amp;gt; (
                  &amp;lt;a href="{`/tags/${tagSlug(tag)}`}"&amp;gt;
                    {tag}
                  &amp;lt;/a&amp;gt;
                ))}

            )}

        )
      }
      {
        shareUrl &amp;amp;amp;&amp;amp;amp; shareTitle &amp;amp;amp;&amp;amp;amp; (



        )
      }





  const hero = document.querySelector&amp;amp;lt;HTMLElement&amp;amp;gt;(
    ".page-hero[data-has-cover='true']",
  );
  if (hero) {
    const update = () =&amp;amp;gt; {
      const offset = window.scrollY;
      // Move the background at 40% of scroll speed for a subtle parallax
      hero.style.backgroundPositionY = `calc(50% + ${offset * 0.4}px)`;
    };
    window.addEventListener("scroll", update, { passive: true });
    update();
  }



  .page-hero {
    min-height: 320px;
    display: flex;
    align-items: flex-end;
    padding: 5rem 1.5rem 4rem;
    background:
      radial-gradient(
        ellipse 80% 60% at 70% 0%,
        rgba(232, 0, 106, 0.18) 0%,
        transparent 65%
      ),
      #0a0a0a;
    color: #ffffff;
    position: relative;
    isolation: isolate;
    overflow: hidden;

    &amp;amp;amp;::before {
      content: "";
      position: absolute;
      inset: 0;
      z-index: -1;
      background-image: radial-gradient(
        circle,
        rgba(255, 255, 255, 0.18) 1px,
        transparent 1px
      );
      background-size: 28px 28px;
      mask-image: radial-gradient(
        ellipse 90% 90% at 50% 50%,
        black 30%,
        transparent 90%
      );
      -webkit-mask-image: radial-gradient(
        ellipse 90% 90% at 50% 50%,
        black 30%,
        transparent 90%
      );
      pointer-events: none;

      @media (prefers-reduced-motion: no-preference) {
        animation: dot-grid-breathe 4s ease-in-out infinite;
      }
    }

    @keyframes dot-grid-breathe {
      0%,
      100% {
        opacity: 0.5;
      }
      50% {
        opacity: 1;
      }
    }

    &amp;amp;amp;[data-has-cover="true"] {
      min-height: 420px;
      padding: 5rem 1.5rem 4rem;
      background-image: var(--cover-image);
      background-size: cover;
      background-position: center;

      &amp;amp;amp;::after {
        content: "";
        position: absolute;
        inset: 0;
        z-index: -2;
        background: linear-gradient(
          to bottom,
          rgba(0, 0, 0, 0.35) 0%,
          rgba(0, 0, 0, 0.7) 55%,
          rgba(0, 0, 0, 0.92) 100%
        );
        pointer-events: none;
      }

      .page-hero__tag {
        background-color: rgba(255, 255, 255, 0.1);
        border-color: rgba(255, 255, 255, 0.25);
        color: rgba(255, 255, 255, 0.7);

        &amp;amp;amp;:hover {
          background-color: rgba(232, 0, 106, 0.12);
          border-color: rgba(232, 0, 106, 0.5);
          color: var(--color-pink);
        }
      }
    }

    .container {
      position: relative;
      z-index: 1;
      width: 100%;
    }
  }

  .page-hero__inner {
    max-width: 780px;
  }

  .page-hero__title {
    font-size: clamp(2.25rem, 6vw, 4rem);
    font-weight: 800;
    line-height: 1.05;
    letter-spacing: -0.02em;
    margin-bottom: 1rem;
    color: #ffffff;
  }

  .page-hero__subtitle {
    font-size: 1.25rem;
    line-height: 1.5;
    color: rgba(255, 255, 255, 0.65);
    margin-bottom: 1.5rem;
    font-weight: 300;
  }

  .page-hero__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem 1rem;
    font-size: 0.875rem;
    font-family: "Barlow Condensed", sans-serif;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: rgba(255, 255, 255, 0.5);
  }

  .page-hero__author {
    color: rgba(255, 255, 255, 0.85);
  }

  .page-hero__date {
    &amp;amp;amp;::before {
      content: "·";
      margin-right: 1rem;
      color: var(--color-pink);
    }
  }

  .page-hero__divider {
    &amp;amp;amp;::before {
      content: "·";
      color: var(--color-pink);
    }

    @media (max-width: 639px) {
      display: none;
    }
  }

  .page-hero__tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
  }

  .page-hero__tag {
    display: inline-block;
    font-family: "Barlow Condensed", sans-serif;
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: rgba(255, 255, 255, 0.55);
    border: 1px solid rgba(255, 255, 255, 0.18);
    border-radius: 4px;
    padding: 0.25rem 0.6rem;

    @media (max-width: 639px) {
      font-size: 0.8rem;
      padding: 0.4rem 0.75rem;
    }
    transition:
      background-color 0.15s ease,
      color 0.15s ease,
      border-color 0.15s ease;

    &amp;amp;amp;:hover {
      background-color: rgba(232, 0, 106, 0.12);
      border-color: rgba(232, 0, 106, 0.5);
      color: var(--color-pink);
    }
  }

  .hero-kicker {
    display: inline-block;
    font-size: 0.8125rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.12em;
    color: var(--color-pink);
    margin-bottom: 0.75rem;
  }

  .page-hero__share {
    margin-top: 1.5rem;

    :global(.share-post) {
      border-color: rgba(255, 255, 255, 0.15);
      border-bottom: none;
      padding: 1.25rem 0 0;
      margin: 0;
    }

    :global(.share-post__label) {
      color: rgba(255, 255, 255, 0.4);
    }

    :global(.share-post__btn) {
      color: rgba(255, 255, 255, 0.55);
      border-color: rgba(255, 255, 255, 0.18);
      background-color: rgba(255, 255, 255, 0.06);

      &amp;amp;amp;:hover {
        color: var(--color-pink);
        border-color: rgba(232, 0, 106, 0.5);
        background-color: rgba(232, 0, 106, 0.12);
      }
    }
  }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;You can browse the rest of the site code in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/web-sourcier.uk" rel="noopener noreferrer"&gt;web-sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Building a dark/light theme toggle in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 14 Apr 2026 10:04:45 +0000</pubDate>
      <link>https://dev.to/sourcier/building-a-darklight-theme-toggle-in-astro-5c7l</link>
      <guid>https://dev.to/sourcier/building-a-darklight-theme-toggle-in-astro-5c7l</guid>
      <description>&lt;p&gt;A dark/light toggle is one of those features that sounds trivial and isn't. Get it&lt;br&gt;
wrong and you ship the flash of wrong theme — the page loads light, then flickers&lt;br&gt;
dark if that was the user's stored preference. Or you ship a toggle that forgets&lt;br&gt;
its state on every page load.&lt;/p&gt;

&lt;p&gt;This blog's implementation avoids all of those. There's also a third option that&lt;br&gt;
most implementations skip: a "System" mode that tracks the OS preference in&lt;br&gt;
real time, without touching &lt;code&gt;localStorage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsourcier.uk%2Fpost-images%2Fdark-light-theme-toggle%2Fdark-light-theme-toggle-wireframe.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsourcier.uk%2Fpost-images%2Fdark-light-theme-toggle%2Fdark-light-theme-toggle-wireframe.svg" alt="Wireframe mockup of the navbar with the theme toggle icon in the social icon group, and the page body below showing where the inline script fires before first paint" width="700" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBzdWJncmFwaCBsb2FkWyJQYWdlIGxvYWQg4oCUIGlubGluZSBzY3JpcHQgaW4gaGVhZCJdCiAgICAgICAgTFNbIlJlYWQgbG9jYWxTdG9yYWdlICd0aGVtZScga2V5Il0gLS0-IENIRUNLeyJTdG9yZWQgdmFsdWU_In0KICAgICAgICBDSEVDSyAtLT58IidkYXJrJyBvciAnbGlnaHQnInwgU0VUWyJTZXQgZGF0YS10aGVtZSBvbiBodG1sIGVsZW1lbnQiXQogICAgICAgIENIRUNLIC0tPnwibWlzc2luZyAvIGludmFsaWQifCBPU1siQ2hlY2sgcHJlZmVycy1jb2xvci1zY2hlbWUiXQogICAgICAgIE9TIC0tPnwiZGFyayJ8IFNFVERBUktbImRhdGEtdGhlbWU9J2RhcmsnIl0KICAgICAgICBPUyAtLT58ImxpZ2h0IC8gbm8gbWF0Y2gifCBTRVRMSUdIVFsiZGF0YS10aGVtZT0nbGlnaHQnIl0KICAgIGVuZAogICAgc3ViZ3JhcGggdG9nZ2xlWyJVc2VyIGNsaWNrcyBTeXN0ZW0gLyBMaWdodCAvIERhcmsgYnV0dG9uIl0KICAgICAgICBNT0RFeyJNb2RlIHNlbGVjdGVkIn0gLS0-fCJsaWdodCBvciBkYXJrInwgU1RPUkVbIldyaXRlIHRvIGxvY2FsU3RvcmFnZVxuU2V0IGRhdGEtdGhlbWUiXQogICAgICAgIE1PREUgLS0-fCJzeXN0ZW0ifCBSRU1PVkVbIlJlbW92ZSBmcm9tIGxvY2FsU3RvcmFnZVxuU2V0IGRhdGEtdGhlbWUgZnJvbSBtYXRjaE1lZGlhIl0KICAgICAgICBTVE9SRSAtLT4gUFJFU1NbIlVwZGF0ZSBhcmlhLXByZXNzZWQgb24gYnV0dG9ucyJdCiAgICAgICAgUkVNT1ZFIC0tPiBQUkVTUwogICAgZW5kCiAgICBTRVQgLS0-IFBBSU5UWyJQYWdlIHJlbmRlcnMgY29ycmVjdCB0aGVtZSDigJQgbm8gZmxhc2giXQogICAgU0VUREFSSyAtLT4gUEFJTlQKICAgIFNFVExJR0hUIC0tPiBQQUlOVA" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBzdWJncmFwaCBsb2FkWyJQYWdlIGxvYWQg4oCUIGlubGluZSBzY3JpcHQgaW4gaGVhZCJdCiAgICAgICAgTFNbIlJlYWQgbG9jYWxTdG9yYWdlICd0aGVtZScga2V5Il0gLS0-IENIRUNLeyJTdG9yZWQgdmFsdWU_In0KICAgICAgICBDSEVDSyAtLT58IidkYXJrJyBvciAnbGlnaHQnInwgU0VUWyJTZXQgZGF0YS10aGVtZSBvbiBodG1sIGVsZW1lbnQiXQogICAgICAgIENIRUNLIC0tPnwibWlzc2luZyAvIGludmFsaWQifCBPU1siQ2hlY2sgcHJlZmVycy1jb2xvci1zY2hlbWUiXQogICAgICAgIE9TIC0tPnwiZGFyayJ8IFNFVERBUktbImRhdGEtdGhlbWU9J2RhcmsnIl0KICAgICAgICBPUyAtLT58ImxpZ2h0IC8gbm8gbWF0Y2gifCBTRVRMSUdIVFsiZGF0YS10aGVtZT0nbGlnaHQnIl0KICAgIGVuZAogICAgc3ViZ3JhcGggdG9nZ2xlWyJVc2VyIGNsaWNrcyBTeXN0ZW0gLyBMaWdodCAvIERhcmsgYnV0dG9uIl0KICAgICAgICBNT0RFeyJNb2RlIHNlbGVjdGVkIn0gLS0-fCJsaWdodCBvciBkYXJrInwgU1RPUkVbIldyaXRlIHRvIGxvY2FsU3RvcmFnZVxuU2V0IGRhdGEtdGhlbWUiXQogICAgICAgIE1PREUgLS0-fCJzeXN0ZW0ifCBSRU1PVkVbIlJlbW92ZSBmcm9tIGxvY2FsU3RvcmFnZVxuU2V0IGRhdGEtdGhlbWUgZnJvbSBtYXRjaE1lZGlhIl0KICAgICAgICBTVE9SRSAtLT4gUFJFU1NbIlVwZGF0ZSBhcmlhLXByZXNzZWQgb24gYnV0dG9ucyJdCiAgICAgICAgUkVNT1ZFIC0tPiBQUkVTUwogICAgZW5kCiAgICBTRVQgLS0-IFBBSU5UWyJQYWdlIHJlbmRlcnMgY29ycmVjdCB0aGVtZSDigJQgbm8gZmxhc2giXQogICAgU0VUREFSSyAtLT4gUEFJTlQKICAgIFNFVExJR0hUIC0tPiBQQUlOVA" alt="Mermaid diagram" width="1859" height="1035"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/dark-light-theme-toggle" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/dark-light-theme-toggle&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The colour system
&lt;/h2&gt;

&lt;p&gt;All colours are defined as CSS custom properties on &lt;code&gt;:root&lt;/code&gt; and overridden under&lt;br&gt;
&lt;code&gt;[data-theme='dark']&lt;/code&gt;. There's no separate dark-mode stylesheet, no class-swapping&lt;br&gt;
on individual elements — just two variable sets and one attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;--color-pink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#e8006a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#0f0f0f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#6b6b6b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'dark'&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;--color-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#f0f0f0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#111111&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#999999&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#1c1c1c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;--color-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Switching between themes is a single &lt;code&gt;setAttribute&lt;/code&gt; call on &lt;code&gt;document.documentElement&lt;/code&gt;.&lt;br&gt;
Every element on the page that uses a custom property updates instantly.&lt;/p&gt;

&lt;p&gt;The pink accent (&lt;code&gt;--color-pink&lt;/code&gt;) doesn't change between themes — it's a&lt;br&gt;
fixed identity colour, not a semantic one.&lt;/p&gt;
&lt;h2&gt;
  
  
  The toggle component
&lt;/h2&gt;

&lt;p&gt;The toggle lives in the navbar's social icon group inside a dedicated &lt;code&gt;ThemeToggle.astro&lt;/code&gt;&lt;br&gt;
component. It's a single icon button that opens a small dropdown menu with System,&lt;br&gt;
Light, and Dark options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div class="theme-toggle"&amp;gt;
  &amp;lt;button
    class="theme-toggle__trigger social-icon"
    aria-label="Theme preference"
    aria-expanded="false"
    aria-haspopup="true"
  &amp;gt;
    &amp;lt;span class="theme-toggle__trigger-icon theme-toggle__trigger-icon--system"&amp;gt;
      &amp;lt;!-- half-stroke circle icon --&amp;gt;
    &amp;lt;/span&amp;gt;
    &amp;lt;span class="theme-toggle__trigger-icon theme-toggle__trigger-icon--light"&amp;gt;
      &amp;lt;!-- sun icon --&amp;gt;
    &amp;lt;/span&amp;gt;
    &amp;lt;span class="theme-toggle__trigger-icon theme-toggle__trigger-icon--dark"&amp;gt;
      &amp;lt;!-- moon icon --&amp;gt;
    &amp;lt;/span&amp;gt;
  &amp;lt;/button&amp;gt;

  &amp;lt;div class="theme-toggle__dropdown" role="menu" aria-label="Theme preference" hidden&amp;gt;
    &amp;lt;button class="theme-toggle__option" data-theme-select="system"
            role="menuitem" aria-pressed="true"&amp;gt;
      &amp;lt;!-- half-stroke circle --&amp;gt; System
    &amp;lt;/button&amp;gt;
    &amp;lt;button class="theme-toggle__option" data-theme-select="light"
            role="menuitem" aria-pressed="false"&amp;gt;
      &amp;lt;!-- sun --&amp;gt; Light
    &amp;lt;/button&amp;gt;
    &amp;lt;button class="theme-toggle__option" data-theme-select="dark"
            role="menuitem" aria-pressed="false"&amp;gt;
      &amp;lt;!-- moon --&amp;gt; Dark
    &amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trigger carries &lt;code&gt;aria-haspopup="true"&lt;/code&gt; and &lt;code&gt;aria-expanded&lt;/code&gt; (toggled by the&lt;br&gt;
script). The dropdown starts &lt;code&gt;hidden&lt;/code&gt;; the script removes that attribute to reveal it.&lt;/p&gt;

&lt;p&gt;Three icon spans live inside the trigger — only one visible at a time. CSS targets&lt;br&gt;
&lt;code&gt;data-theme-current&lt;/code&gt; on the wrapper div (set by the script) to show the right icon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.theme-toggle__trigger-icon&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.theme-toggle&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme-current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"system"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;.theme-toggle__trigger-icon--system&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.theme-toggle&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme-current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"light"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;  &lt;span class="nc"&gt;.theme-toggle__trigger-icon--light&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.theme-toggle&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme-current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"dark"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;   &lt;span class="nc"&gt;.theme-toggle__trigger-icon--dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes the trigger reflect the user's &lt;em&gt;chosen&lt;/em&gt; preference, not the resolved&lt;br&gt;
OS theme. If someone selects System and their OS is dark, the trigger shows the&lt;br&gt;
half-stroke circle — not the moon.&lt;/p&gt;
&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;

&lt;p&gt;The toggle script handles two concerns: dropdown open/close state, and theme&lt;br&gt;
selection. Both live inside the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block in &lt;code&gt;ThemeToggle.astro&lt;/code&gt;. Astro&lt;br&gt;
bundles component scripts automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wrapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.theme-toggle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLButtonElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.theme-toggle__trigger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dropdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.theme-toggle__dropdown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;querySelectorAll&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLButtonElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-theme-select]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;themeCurrent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-pressed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;themeSelect&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(prefers-color-scheme: dark)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openMenu&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dropdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-expanded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;closeMenu&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dropdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-expanded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dropdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;openMenu&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;closeMenu&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;closeMenu&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Escape&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;closeMenu&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;themeSelect&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;closeMenu&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;setTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(prefers-color-scheme: dark)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;setTheme&lt;/code&gt; is the single source of truth — it updates &lt;code&gt;data-theme-current&lt;/code&gt; on the wrapper (for trigger icon visibility), &lt;code&gt;aria-pressed&lt;/code&gt; on each option, and &lt;code&gt;localStorage&lt;/code&gt; and &lt;code&gt;data-theme&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;openMenu&lt;/code&gt;/&lt;code&gt;closeMenu&lt;/code&gt; keep the &lt;code&gt;hidden&lt;/code&gt; attribute and &lt;code&gt;aria-expanded&lt;/code&gt; in sync. Using the HTML &lt;code&gt;hidden&lt;/code&gt; attribute (rather than a CSS class) means the closed state works even before styles load.&lt;/li&gt;
&lt;li&gt;Clicking outside the wrapper or pressing Escape closes the menu — standard dropdown behaviour users expect.&lt;/li&gt;
&lt;li&gt;System mode &lt;em&gt;removes&lt;/em&gt; the &lt;code&gt;localStorage&lt;/code&gt; key rather than storing &lt;code&gt;"system"&lt;/code&gt;. Only &lt;code&gt;"dark"&lt;/code&gt; or &lt;code&gt;"light"&lt;/code&gt; are ever written.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;matchMedia&lt;/code&gt; change listener fires when the OS theme switches while the page is open. It only acts when no explicit preference is stored — i.e. the user is in System mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Preventing the flash of wrong theme
&lt;/h2&gt;

&lt;p&gt;If you read &lt;code&gt;localStorage&lt;/code&gt; in a script that loads after the page renders, you'll&lt;br&gt;
see the default theme briefly before the script applies the stored preference. The&lt;br&gt;
fix is to run the theme-reading code synchronously in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, before the body&lt;br&gt;
is parsed. In &lt;code&gt;BaseLayout.astro&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;data-theme=&lt;/span&gt;&lt;span class="s"&gt;"light"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- ... meta, links ... --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;is:inline&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(prefers-color-scheme: dark)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-theme&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The priority order is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stored explicit preference (&lt;code&gt;"dark"&lt;/code&gt; or &lt;code&gt;"light"&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;OS &lt;code&gt;prefers-color-scheme: dark&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Fallback — the &lt;code&gt;data-theme="light"&lt;/code&gt; already on the &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; tag&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;is:inline&lt;/code&gt; directive tells Astro not to bundle or defer this script — it&lt;br&gt;
stays as a literal inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag and runs immediately, before the browser&lt;br&gt;
paints anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  Expressive Code alignment
&lt;/h2&gt;

&lt;p&gt;Expressive Code — the syntax highlighting library — needs to know to follow the&lt;br&gt;
&lt;code&gt;data-theme&lt;/code&gt; attribute rather than the OS &lt;code&gt;prefers-color-scheme&lt;/code&gt; media query.&lt;br&gt;
Without this, code blocks would follow the system preference even when the user&lt;br&gt;
has picked an explicit theme — they'd be out of sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expressiveCode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;useDarkModeMediaQuery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;themeCssSelector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-theme="dark"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:root:not([data-theme="dark"])&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useDarkModeMediaQuery: false&lt;/code&gt; disables the default &lt;code&gt;@media (prefers-color-scheme)&lt;/code&gt;&lt;br&gt;
approach. &lt;code&gt;themeCssSelector&lt;/code&gt; maps each Expressive Code theme variant to a CSS&lt;br&gt;
selector that matches the &lt;code&gt;data-theme&lt;/code&gt; attribute instead.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Typed content collections in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Mon, 13 Apr 2026 19:19:13 +0000</pubDate>
      <link>https://dev.to/sourcier/typed-content-collections-in-astro-3ma7</link>
      <guid>https://dev.to/sourcier/typed-content-collections-in-astro-3ma7</guid>
      <description>&lt;p&gt;&lt;span&gt;Series&lt;/span&gt;&lt;span&gt;Part of &lt;a href="/blog/how-this-blog-was-built"&gt;How this blog was built&lt;/a&gt; — documenting every decision that shaped this site.&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;One of the things I wanted to get right early on this blog was the content model.&lt;br&gt;
Markdown is flexible to the point of being dangerous — nothing stops you from&lt;br&gt;
publishing a post with a missing &lt;code&gt;title&lt;/code&gt;, a malformed date, or a cover image path&lt;br&gt;
that leads nowhere. On a small site this sounds manageable. In practice, these&lt;br&gt;
problems compound.&lt;/p&gt;

&lt;p&gt;Astro content collections solve this with Zod schema validation at build time.&lt;br&gt;
If the content doesn't match the schema, the build fails loudly instead of&lt;br&gt;
deploying silently broken content.&lt;/p&gt;
&lt;h2&gt;
  
  
  How the collection is defined
&lt;/h2&gt;

&lt;p&gt;Everything lives in &lt;code&gt;src/content.config.ts&lt;/code&gt;. The &lt;code&gt;defineCollection&lt;/code&gt; call takes a&lt;br&gt;
loader (which describes where to find the files) and a schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCollection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;glob&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astro/loaders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astro/zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;!README.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./collections/posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;subTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;image&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
          &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
      &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
          &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
          &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;z.coerce.date()&lt;/code&gt; means a YAML string like &lt;code&gt;2026-03-26T00:00:00&lt;/code&gt; is
automatically coerced to a JavaScript &lt;code&gt;Date&lt;/code&gt; object. You get proper date
comparison and formatting in templates without any manual parsing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image()&lt;/code&gt; is a special helper provided by Astro's schema context. It validates
that the referenced file exists on disk and returns a typed object with the
processed &lt;code&gt;src&lt;/code&gt;. Astro passes this through its image optimisation pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;draft: z.boolean().default(false)&lt;/code&gt; means the &lt;code&gt;draft&lt;/code&gt; field is optional in
frontmatter — omitting it defaults to &lt;code&gt;false&lt;/code&gt;, so only posts that explicitly
declare &lt;code&gt;draft: true&lt;/code&gt; need the field.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;credits&lt;/code&gt; URLs use &lt;code&gt;z.string().url()&lt;/code&gt;, so a malformed URL will fail the build
rather than silently render a broken link.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the posts live
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;glob&lt;/code&gt; loader uses a &lt;code&gt;base&lt;/code&gt; path outside &lt;code&gt;src/&lt;/code&gt; — the posts are in&lt;br&gt;
&lt;code&gt;collections/posts/&lt;/code&gt; at the project root. Each post gets its own directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;collections/
  posts/
    why-astro/
      index.md
    comments-system/
      index.md
      comments-system-cover.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Co-locating the cover image with the Markdown file is simpler than managing a&lt;br&gt;
separate &lt;code&gt;public/&lt;/code&gt; directory for post images. Astro's image pipeline picks them up&lt;br&gt;
automatically when the schema uses &lt;code&gt;image()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The post &lt;code&gt;id&lt;/code&gt; that Astro assigns is derived from the directory structure — &lt;code&gt;why-astro&lt;/code&gt;&lt;br&gt;
for the post above. That becomes the URL slug via the &lt;code&gt;[id].astro&lt;/code&gt; dynamic route.&lt;/p&gt;
&lt;h2&gt;
  
  
  Querying the collection
&lt;/h2&gt;

&lt;p&gt;In any &lt;code&gt;.astro&lt;/code&gt; file or API route, &lt;code&gt;getCollection("posts")&lt;/code&gt; returns a typed array&lt;br&gt;
of posts whose &lt;code&gt;data&lt;/code&gt; property matches the schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getCollection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every access to &lt;code&gt;post.data.title&lt;/code&gt;, &lt;code&gt;post.data.pubDate&lt;/code&gt;, or &lt;code&gt;post.data.tags&lt;/code&gt; is&lt;br&gt;
fully typed. If the schema changes, TypeScript catches every reference that no&lt;br&gt;
longer lines up.&lt;/p&gt;
&lt;h2&gt;
  
  
  The draft flag and scheduled publishing
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;draft&lt;/code&gt; field alone isn't the full picture. The blog uses a small utility&lt;br&gt;
in &lt;code&gt;src/utils/drafts.ts&lt;/code&gt; that combines draft status, publish date, and an&lt;br&gt;
environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;showDrafts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOW_DRAFTS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPublished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;showDrafts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every collection query that feeds the public site passes posts through&lt;br&gt;
&lt;code&gt;isPublished()&lt;/code&gt; rather than a bare &lt;code&gt;draft&lt;/code&gt; check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getCollection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isPublished&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../utils/drafts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allPosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPublished&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives three distinct states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;draft: true&lt;/code&gt; — never visible on the public site, regardless of &lt;code&gt;pubDate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;draft: false&lt;/code&gt;, future &lt;code&gt;pubDate&lt;/code&gt; — not yet published; filtered out until the
date passes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;draft: false&lt;/code&gt;, past &lt;code&gt;pubDate&lt;/code&gt; — live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt; environment variable short-circuits the filter entirely,&lt;br&gt;
which is how the &lt;code&gt;preview&lt;/code&gt; branch deploy works — it shows everything, including&lt;br&gt;
drafts and scheduled posts, behind a passcode wall.&lt;/p&gt;

&lt;p&gt;Posts filtered out by &lt;code&gt;isPublished()&lt;/code&gt; are excluded from listings, tag pages, the&lt;br&gt;
RSS feed, and &lt;code&gt;getStaticPaths()&lt;/code&gt; — so their URLs return a 404 in production even&lt;br&gt;
if someone guesses the slug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional fields and TypeScript
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;cover&lt;/code&gt;, &lt;code&gt;history&lt;/code&gt;, and &lt;code&gt;credits&lt;/code&gt; fields are all &lt;code&gt;.optional()&lt;/code&gt;. In templates&lt;br&gt;
this means you get types like &lt;code&gt;({ image: ImageMetadata; alt: string } | undefined)&lt;/code&gt;.&lt;br&gt;
TypeScript will refuse to let you access &lt;code&gt;cover.image.src&lt;/code&gt; without first checking&lt;br&gt;
that &lt;code&gt;cover&lt;/code&gt; exists.&lt;/p&gt;

&lt;p&gt;That's the intended behaviour — it forces every template that uses these fields to&lt;br&gt;
handle the case where they're absent, which is exactly the kind of bug that slips&lt;br&gt;
through without a type system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this gives you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build-time validation.&lt;/strong&gt; A malformed date, a missing required field, or a cover&lt;br&gt;
image pointing to a nonexistent file stops the build immediately. You find content&lt;br&gt;
errors locally, not after deploying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full TypeScript coverage across templates.&lt;/strong&gt; Every &lt;code&gt;.astro&lt;/code&gt; component that&lt;br&gt;
touches &lt;code&gt;post.data&lt;/code&gt; gets accurate types. Rename a field in the schema and&lt;br&gt;
TypeScript surfaces every broken reference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled publishing without a CMS.&lt;/strong&gt; Because &lt;code&gt;pubDate&lt;/code&gt; is a typed &lt;code&gt;Date&lt;/code&gt; and&lt;br&gt;
the &lt;code&gt;isPublished()&lt;/code&gt; filter compares it to the current time, setting a future date&lt;br&gt;
is enough to schedule a post. The daily build picks it up automatically. There's&lt;br&gt;
a dedicated post on how this works coming &lt;span&gt;8 May&lt;/span&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drafts with a preview workflow.&lt;/strong&gt; &lt;code&gt;draft: true&lt;/code&gt; keeps work-in-progress content&lt;br&gt;
off the live site, while the &lt;code&gt;SHOW_DRAFTS&lt;/code&gt; flag lets you review it fully rendered&lt;br&gt;
on a separate deploy before it goes public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working on something similar?
&lt;/h2&gt;

&lt;p&gt;If you're setting up a content pipeline, designing a typed content model, or&lt;br&gt;
building publish workflows into your Astro site — I'm available for consulting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/contact"&gt;Get in touch via the contact page&lt;/a&gt; and tell me what you're working on.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Keeping your zsh config out of Copilot's terminals</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Mon, 13 Apr 2026 18:57:36 +0000</pubDate>
      <link>https://dev.to/sourcier/keeping-your-zsh-config-out-of-copilots-terminals-4h7a</link>
      <guid>https://dev.to/sourcier/keeping-your-zsh-config-out-of-copilots-terminals-4h7a</guid>
      <description>&lt;p&gt;If you use GitHub Copilot in VS Code and have a heavily configured zsh — Powerlevel10k,&lt;br&gt;
a bunch of aliases, &lt;code&gt;nvm&lt;/code&gt; or &lt;code&gt;pyenv&lt;/code&gt; hooks — you've probably noticed the agent making a&lt;br&gt;
mess of its terminal output, or occasionally failing because something in your&lt;br&gt;
&lt;code&gt;.zshrc&lt;/code&gt; conflicts with what it's trying to run.&lt;/p&gt;

&lt;p&gt;The problem is simple: Copilot spins up non-interactive shells to execute commands.&lt;br&gt;
Your &lt;code&gt;.zshrc&lt;/code&gt; loads anyway, because that's what &lt;code&gt;.zshrc&lt;/code&gt; does. The agent doesn't&lt;br&gt;
need your prompt theme; it just needs to run &lt;code&gt;git status&lt;/code&gt; and get on with its life.&lt;br&gt;
Your customisations are noise at best, a source of failures at worst.&lt;/p&gt;

&lt;p&gt;Suppressing them without also killing them in your regular VS Code terminal is the&lt;br&gt;
tricky part.&lt;/p&gt;
&lt;h2&gt;
  
  
  The naive fix and why it breaks things
&lt;/h2&gt;

&lt;p&gt;The obvious approach is to skip customisations for all VS Code terminals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TERM_PROGRAM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"vscode"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="c"&gt;# load powerlevel10k, aliases, nvm, etc.&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VS Code sets &lt;code&gt;TERM_PROGRAM=vscode&lt;/code&gt; in every terminal it opens — including the ones&lt;br&gt;
you open yourself. So this guard works for the agent, but it also strips your prompt&lt;br&gt;
and aliases from your regular integrated terminal. Not what you want.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why PATH isn't a reliable signal
&lt;/h2&gt;

&lt;p&gt;The next idea is to detect the Copilot agent via &lt;code&gt;$PATH&lt;/code&gt;, since the extension injects&lt;br&gt;
its CLI tools into it. The problem: VS Code injects those PATH entries into &lt;em&gt;all&lt;/em&gt;&lt;br&gt;
terminals — integrated and agent alike. You can't use it to tell them apart.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: a custom env var
&lt;/h2&gt;

&lt;p&gt;VS Code has a per-platform setting that injects environment variables into terminals&lt;br&gt;
the user opens — &lt;code&gt;terminal.integrated.env.osx&lt;/code&gt;, &lt;code&gt;terminal.integrated.env.linux&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;terminal.integrated.env.windows&lt;/code&gt;. Critically, these settings do &lt;strong&gt;not&lt;/strong&gt; apply to&lt;br&gt;
terminals that extensions spin up programmatically. This is the clean signal we need.&lt;/p&gt;

&lt;p&gt;In VS Code &lt;code&gt;settings.json&lt;/code&gt;, add whichever keys match your platform(s):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"terminal.integrated.env.osx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"VSCODE_USER_TERMINAL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"terminal.integrated.env.linux"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"VSCODE_USER_TERMINAL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"terminal.integrated.env.windows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"VSCODE_USER_TERMINAL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;.zshrc&lt;/code&gt;, update the guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TERM_PROGRAM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"vscode"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VSCODE_USER_TERMINAL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="c"&gt;# load powerlevel10k, aliases, nvm, etc.&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic reads: load customisations if this is &lt;em&gt;not&lt;/em&gt; a VS Code terminal, or if it is&lt;br&gt;
one that the user opened (as confirmed by the injected var).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Terminal&lt;/th&gt;
&lt;th&gt;&lt;code&gt;TERM_PROGRAM&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;VSCODE_USER_TERMINAL&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Customisations load?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Any terminal outside VS Code&lt;/td&gt;
&lt;td&gt;app-specific or unset&lt;/td&gt;
&lt;td&gt;unset&lt;/td&gt;
&lt;td&gt;Yes — first condition passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code integrated terminal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vscode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes — second condition passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copilot agent terminal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vscode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unset&lt;/td&gt;
&lt;td&gt;No — both conditions fail&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBTVEFSVFsiVGVybWluYWwgc3Bhd25lZCJdIC0tPiBWU0NPREV7IlRFUk1fUFJPR1JBTSA9PSAndnNjb2RlJz8ifQogICAgVlNDT0RFIC0tPnwiTm8g4oCUIGV4dGVybmFsIGFwcCJ8IExPQURbIkxvYWQgenNoIGN1c3RvbWlzYXRpb25zXG5Qcm9tcHQgwrcgYWxpYXNlcyDCtyBudm0gZXRjLiJdCiAgICBWU0NPREUgLS0-fCJZZXMifCBVU0VSeyJWU0NPREVfVVNFUl9URVJNSU5BTCBpcyBzZXQ_In0KICAgIFVTRVIgLS0-fCJZZXMg4oCUIHVzZXItb3BlbmVkIHRlcm1pbmFsInwgTE9BRAogICAgVVNFUiAtLT58Ik5vIOKAlCBDb3BpbG90IGFnZW50IHRlcm1pbmFsInwgU0tJUFsiU2tpcCB6c2ggY3VzdG9taXNhdGlvbnNcbkNsZWFuIGVudmlyb25tZW50IGZvciBhZ2VudCJd" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBTVEFSVFsiVGVybWluYWwgc3Bhd25lZCJdIC0tPiBWU0NPREV7IlRFUk1fUFJPR1JBTSA9PSAndnNjb2RlJz8ifQogICAgVlNDT0RFIC0tPnwiTm8g4oCUIGV4dGVybmFsIGFwcCJ8IExPQURbIkxvYWQgenNoIGN1c3RvbWlzYXRpb25zXG5Qcm9tcHQgwrcgYWxpYXNlcyDCtyBudm0gZXRjLiJdCiAgICBWU0NPREUgLS0-fCJZZXMifCBVU0VSeyJWU0NPREVfVVNFUl9URVJNSU5BTCBpcyBzZXQ_In0KICAgIFVTRVIgLS0-fCJZZXMg4oCUIHVzZXItb3BlbmVkIHRlcm1pbmFsInwgTE9BRAogICAgVVNFUiAtLT58Ik5vIOKAlCBDb3BpbG90IGFnZW50IHRlcm1pbmFsInwgU0tJUFsiU2tpcCB6c2ggY3VzdG9taXNhdGlvbnNcbkNsZWFuIGVudmlyb25tZW50IGZvciBhZ2VudCJd" alt="Mermaid diagram" width="665" height="965"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/vscode-copilot-terminal-zsh" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/vscode-copilot-terminal-zsh&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The key is that &lt;code&gt;terminal.integrated.env.*&lt;/code&gt; is a VS Code UI concern — it only kicks&lt;br&gt;
in when a human is on the other end of the terminal. Agent terminals bypass it&lt;br&gt;
entirely, giving you a reliable, low-friction way to distinguish the two situations.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's it
&lt;/h2&gt;

&lt;p&gt;The guard is a single &lt;code&gt;if&lt;/code&gt; condition and the env var is harmless to everything else&lt;br&gt;
in your environment. Add only the platform keys you need — if you only ever work on&lt;br&gt;
macOS, one line is enough.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>tooling</category>
      <category>dotfiles</category>
    </item>
    <item>
      <title>Choosing the tech stack</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Mon, 13 Apr 2026 18:57:19 +0000</pubDate>
      <link>https://dev.to/sourcier/choosing-the-tech-stack-2fb5</link>
      <guid>https://dev.to/sourcier/choosing-the-tech-stack-2fb5</guid>
      <description>&lt;p&gt;&lt;span&gt;Series&lt;/span&gt;&lt;span&gt;Part of &lt;a href="/blog/how-this-blog-was-built"&gt;How this blog was built&lt;/a&gt; — twenty posts on every decision that shaped this site.&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;Before writing a single line of code for this blog, I spent some time thinking&lt;br&gt;
about the stack. It is a personal site — nobody is paying me to make good&lt;br&gt;
architectural decisions here — but that is exactly when it is worth being honest&lt;br&gt;
about what you actually need rather than defaulting to whatever you already know.&lt;/p&gt;

&lt;p&gt;The requirements were simple: write posts in Markdown, serve fast static HTML,&lt;br&gt;
add the odd interactive feature without shipping a full JavaScript runtime, and&lt;br&gt;
host it somewhere cheap with minimal ongoing maintenance. No CMS, no database,&lt;br&gt;
no server.&lt;/p&gt;

&lt;p&gt;Here is what I landed on, and the thinking behind each choice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBW0NvbnRlbnQgZmlsZXNdIC0tPiBCW0J1aWxkXQogICAgQiAtLT4gQ1tTdGF0aWMgSFRNTCwgQ1NTLCBKU10KICAgIEMgLS0-IERbQ0ROXQogICAgRCAtLT4gSFtCcm93c2VyXQogICAgRCAtLT4gSVtTZXJ2ZXJsZXNzIGZ1bmN0aW9uc10KICAgIEkgLS0-IEpbRW1haWwgQVBJXQ" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBW0NvbnRlbnQgZmlsZXNdIC0tPiBCW0J1aWxkXQogICAgQiAtLT4gQ1tTdGF0aWMgSFRNTCwgQ1NTLCBKU10KICAgIEMgLS0-IERbQ0ROXQogICAgRCAtLT4gSFtCcm93c2VyXQogICAgRCAtLT4gSVtTZXJ2ZXJsZXNzIGZ1bmN0aW9uc10KICAgIEkgLS0-IEpbRW1haWwgQVBJXQ" alt="Mermaid diagram" width="389" height="590"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Diagram fallback for Dev.to. View the canonical article for the full version: &lt;a href="https://sourcier.uk/blog/choosing-the-tech-stack" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/choosing-the-tech-stack&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Tech
&lt;/h2&gt;

&lt;p&gt;The technical side covers the framework, the language, the build pipeline, the&lt;br&gt;
hosting, and the services that handle email.&lt;/p&gt;
&lt;h3&gt;
  
  
  TypeScript
&lt;/h3&gt;

&lt;p&gt;Before going into each tool: one choice runs through all of them. Everything&lt;br&gt;
here is TypeScript. For a solo project where there are no code reviews to catch&lt;br&gt;
mistakes, having the compiler surface errors early is genuinely useful — types&lt;br&gt;
flow through the codebase, refactoring is safe, and mistakes that would otherwise&lt;br&gt;
show up at runtime get caught at build time instead.&lt;/p&gt;

&lt;p&gt;All the tools below have first-class TypeScript support, which made it a&lt;br&gt;
non-decision.&lt;/p&gt;
&lt;h3&gt;
  
  
  Astro
&lt;/h3&gt;

&lt;p&gt;Astro describes itself as "the web framework for content-driven websites" — which&lt;br&gt;
is a fair summary. You write &lt;code&gt;.astro&lt;/code&gt; components with a template syntax that sits&lt;br&gt;
close to HTML, with a TypeScript frontmatter block at the top for any logic. The&lt;br&gt;
output is static HTML by default. JavaScript only reaches the browser when you&lt;br&gt;
explicitly include a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block or use a client directive.&lt;/p&gt;

&lt;p&gt;That trade-off suited this project well. Most of the pages here are articles. They&lt;br&gt;
do not need a JavaScript runtime — they need to load quickly and be readable.&lt;/p&gt;
&lt;h4&gt;
  
  
  Content collections
&lt;/h4&gt;

&lt;p&gt;Content collections were the feature that made Astro the obvious choice. You&lt;br&gt;
define a collection in &lt;code&gt;src/content.config.ts&lt;/code&gt;, give it a Zod schema, and point&lt;br&gt;
it at a directory of Markdown files. Every piece of frontmatter is then typed,&lt;br&gt;
validated at build time, and queryable via &lt;code&gt;getCollection()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./collections/posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
      &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A missing &lt;code&gt;title&lt;/code&gt; or malformed &lt;code&gt;pubDate&lt;/code&gt; fails the build rather than silently&lt;br&gt;
producing a broken page. That is the right behaviour for a content site.&lt;/p&gt;
&lt;h4&gt;
  
  
  Zero JS by default
&lt;/h4&gt;

&lt;p&gt;The features I wanted — syntax-highlighted code blocks, a dark/light toggle, a&lt;br&gt;
scroll-tracked table of contents, Mermaid diagrams — do not require a framework&lt;br&gt;
runtime. In Astro, each is a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block in a component, bundled at build&lt;br&gt;
time and only shipped when the component is used. The Mermaid library is the&lt;br&gt;
heaviest dependency on the page, and even that only loads on posts that actually&lt;br&gt;
contain a diagram.&lt;/p&gt;
&lt;h4&gt;
  
  
  The build pipeline
&lt;/h4&gt;

&lt;p&gt;Plugging in Expressive Code for syntax highlighting, a custom remark plugin for&lt;br&gt;
Mermaid, and emoji support was a few lines in &lt;code&gt;astro.config.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://sourcier.uk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;expressiveCode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt;
  &lt;span class="na"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;remarkPlugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;remarkMermaid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emoticon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}]],&lt;/span&gt;
    &lt;span class="na"&gt;syntaxHighlight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;syntaxHighlight: false&lt;/code&gt; disables Astro's built-in Shiki so it doesn't conflict&lt;br&gt;
with Expressive Code, which runs its own highlighting pipeline.&lt;/p&gt;
&lt;h3&gt;
  
  
  Remark Emoji
&lt;/h3&gt;

&lt;p&gt;Posts on this blog support emoji shortcodes — writing &lt;code&gt;:rocket:&lt;/code&gt; in Markdown&lt;br&gt;
produces 🚀, and emoticons like &lt;code&gt;:-)&lt;/code&gt; are converted too. This is handled&lt;br&gt;
by &lt;a href="https://github.com/rhysd/remark-emoji" rel="noopener noreferrer"&gt;remark-emoji&lt;/a&gt;, a remark plugin that&lt;br&gt;
runs as part of the Astro markdown pipeline.&lt;/p&gt;

&lt;p&gt;It is a small thing, but it means emoji work consistently across editors and&lt;br&gt;
operating systems without relying on platform-specific input methods. The&lt;br&gt;
shortcode syntax is also easier to read in raw Markdown than pasting a Unicode&lt;br&gt;
character directly.&lt;/p&gt;

&lt;p&gt;The plugin takes two options worth knowing about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emoticon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;emoticon: true&lt;/code&gt; enables the ASCII emoticon conversion. &lt;code&gt;accessible: true&lt;/code&gt; wraps&lt;br&gt;
each emoji in a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; with a &lt;code&gt;role="img"&lt;/code&gt; and &lt;code&gt;aria-label&lt;/code&gt;, so screen readers&lt;br&gt;
announce them rather than reading out the raw Unicode character name.&lt;/p&gt;
&lt;h3&gt;
  
  
  Netlify
&lt;/h3&gt;

&lt;p&gt;The blog has two requirements that go beyond static files: a comment system and a&lt;br&gt;
mailing list. Both need server-side logic. Netlify handles this through serverless&lt;br&gt;
functions — Node.js handlers in &lt;code&gt;netlify/functions/&lt;/code&gt; that run without a&lt;br&gt;
provisioned server. The comment approval flow, subscriber handling, and&lt;br&gt;
transactional email via &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; all run this way.&lt;/p&gt;

&lt;p&gt;The rest of what Netlify provides is straightforward: &lt;code&gt;git push&lt;/code&gt; triggers a build,&lt;br&gt;
the output lands on a CDN, and every pull request gets a preview URL. For a site&lt;br&gt;
like this, the free tier covers everything comfortably.&lt;/p&gt;
&lt;h3&gt;
  
  
  Resend
&lt;/h3&gt;

&lt;p&gt;The mailing list and comment notifications both send transactional email.&lt;br&gt;
&lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; handles that.&lt;/p&gt;

&lt;p&gt;The main alternatives were SendGrid, Postmark, and AWS SES. All of them work, but&lt;br&gt;
they each carry some friction — verbose SDKs, legacy dashboard UIs, or IAM&lt;br&gt;
configuration in the case of SES. Resend is newer and has been built with&lt;br&gt;
developers in mind: the API is simple, the SDK is small, and the free tier (3,000&lt;br&gt;
emails per month) is generous enough for a personal site.&lt;/p&gt;

&lt;p&gt;The integration is a few lines in a Netlify function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Resend&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sourcier &amp;lt;hello@sourcier.uk&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;subscriber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New post: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;emailBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing worth noting: Resend requires a verified sending domain. That means&lt;br&gt;
adding DNS records and waiting for propagation, which is a slightly annoying&lt;br&gt;
one-time setup. After that it is transparent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Design
&lt;/h2&gt;

&lt;p&gt;The visual side is handled by three tools: a CSS framework for layout and&lt;br&gt;
components, an icon library, and a photography source for cover images.&lt;/p&gt;
&lt;h3&gt;
  
  
  Bulma CSS
&lt;/h3&gt;

&lt;p&gt;I wanted a CSS framework that would give me a reasonable baseline — grid, spacing,&lt;br&gt;
components — without requiring a JavaScript runtime, a PostCSS configuration, or&lt;br&gt;
a purge step. Bulma fits that description. It is a pure CSS framework with no&lt;br&gt;
JavaScript at all.&lt;/p&gt;

&lt;p&gt;I import it once and override what I need in &lt;code&gt;global.scss&lt;/code&gt; using CSS custom&lt;br&gt;
properties. The visual layer is entirely predictable at build time, which keeps&lt;br&gt;
things simple. Bulma's modifier class convention (&lt;code&gt;is-*&lt;/code&gt;, &lt;code&gt;has-*&lt;/code&gt;) also composes&lt;br&gt;
well with Astro's scoped component styles — global tokens in one file, component&lt;br&gt;
styles in the component.&lt;/p&gt;

&lt;p&gt;It is not the most fashionable choice in 2026, but it does the job without getting&lt;br&gt;
in the way.&lt;/p&gt;
&lt;h3&gt;
  
  
  Font Awesome
&lt;/h3&gt;

&lt;p&gt;Icons on the site — the theme toggle, social links, the navbar burger, share&lt;br&gt;
buttons, and the tech stack grid on the about page — all come from&lt;br&gt;
&lt;a href="https://fontawesome.com" rel="noopener noreferrer"&gt;Font Awesome&lt;/a&gt;. The free tier covers everything used here.&lt;/p&gt;

&lt;p&gt;The integration is through the SVG core package rather than a web font or a&lt;br&gt;
&lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;faGithub&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@fortawesome/free-brands-svg-icons&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;faMoon&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@fortawesome/free-solid-svg-icons&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each icon is an array of metadata — dimensions, path data — that a small helper&lt;br&gt;
converts to an inline SVG string. That string is then passed to Astro's&lt;br&gt;
&lt;code&gt;set:html&lt;/code&gt; directive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;span set:html={faIcon(faMoon, { size: 18 })} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason for this approach over a web font or CDN-loaded script is that no&lt;br&gt;
extra network request is needed and no characters-as-glyphs trick is involved.&lt;br&gt;
The SVG paths are tree-shaken at build time — only the icons actually imported&lt;br&gt;
end up in the output. On a page that uses three icons, three icons ship.&lt;/p&gt;

&lt;p&gt;The one downside is that it is slightly more verbose than dropping in an &lt;code&gt;&amp;lt;i&amp;gt;&lt;/code&gt;&lt;br&gt;
tag. For a component-based setup that is a reasonable trade.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unsplash
&lt;/h3&gt;

&lt;p&gt;Photography on this blog comes from &lt;a href="https://unsplash.com" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt; — a library&lt;br&gt;
of freely licensed photography. The licence allows use in commercial and&lt;br&gt;
non-commercial projects without attribution, though I include credits anyway as a&lt;br&gt;
matter of courtesy to the photographers.&lt;/p&gt;

&lt;p&gt;The practical reason for Unsplash over commissioning or sourcing images elsewhere&lt;br&gt;
is straightforward: the selection is large, the quality is high, and there is no&lt;br&gt;
licensing friction. For a personal blog, that is the right trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;Testing is a deliberate omission from this stack description — not because it isn't important, but because it deserves its own treatment. A content-driven Astro site has different testing concerns to a standard web application: build-time validation through Zod schemas catches a category of errors before they reach production, but there is still meaningful ground to cover around unit testing utilities, integration testing serverless functions, and end-to-end testing the rendered output.&lt;/p&gt;

&lt;p&gt;That will be the subject of a dedicated series. This series focuses on building the thing — the testing series will focus on verifying it.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI
&lt;/h2&gt;

&lt;p&gt;AI is also absent from this stack description — and for the same reason: it deserves its own series, not a footnote.&lt;/p&gt;

&lt;p&gt;The tooling is moving fast enough that anything specific I write today would be out of date within months. What I can say is that AI-assisted development is not going away, and treating it as a gimmick at this point is a choice with real consequences. A dedicated series on how to work with AI effectively — in code review, in architecture, in the day-to-day mechanics of building software — is coming.&lt;/p&gt;

&lt;p&gt;What I do want to say here is this: the rise of AI makes human judgement more important, not less. The engineers who will get the most out of these tools are the ones who already understand what good looks like — who can read generated code and spot the subtle wrong, who know when an abstraction is heading somewhere problematic, who understand the trade-offs well enough to push back when the tool confidently picks the wrong one. AI amplifies whatever understanding you bring to it. It does not replace the need to actually understand.&lt;/p&gt;

&lt;p&gt;That's part of why this blog exists. The skills worth preserving aren't the mechanical ones — those are exactly what AI is good at. The skills worth preserving are the ones that require experience to develop: knowing what to build, knowing why, and knowing when not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this stack falls short
&lt;/h2&gt;

&lt;p&gt;For a project with complex client-side state, a real-time feed, or a heavily&lt;br&gt;
interactive UI, this stack would be the wrong choice. Astro is not set up for&lt;br&gt;
that, and Netlify Functions are not a substitute for a proper backend. Those&lt;br&gt;
projects are better served by a framework with client-side routing and a dedicated&lt;br&gt;
API layer.&lt;/p&gt;

&lt;p&gt;But for a blog, it is a good fit. The build is fast, the output is simple, and&lt;br&gt;
there is very little to maintain. That is roughly what I was after.&lt;/p&gt;

&lt;p&gt;The rest of this series goes into each part in more detail — starting with&lt;br&gt;
typed content collections and working through everything from dark mode to the comment system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Need help choosing your stack?
&lt;/h2&gt;

&lt;p&gt;If you're at the early stages of a project and want a second opinion on the&lt;br&gt;
architecture — or you've already built something and want a review — I'm&lt;br&gt;
available for consulting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/contact"&gt;Get in touch via the contact page&lt;/a&gt; and tell me what you're working on.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>meta</category>
    </item>
  </channel>
</rss>
