<?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 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="800" height="400"&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>
    <item>
      <title>How this blog was built</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Mon, 13 Apr 2026 18:52:01 +0000</pubDate>
      <link>https://dev.to/sourcier/how-this-blog-was-built-57ej</link>
      <guid>https://dev.to/sourcier/how-this-blog-was-built-57ej</guid>
      <description>&lt;p&gt;When I launched this blog last week I wrote a post called&lt;br&gt;
&lt;a href="https://dev.to/blog/i-should-start-a-blog"&gt;I Should Start a Blog&lt;/a&gt; — the short version of why I'd stopped putting it off.&lt;br&gt;
Part of what I mentioned there was that building the site itself had been an&lt;br&gt;
enjoyable project: a proper excuse to use &lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; in&lt;br&gt;
earnest, deploy serverless functions on Netlify, wire up email delivery through Resend.&lt;/p&gt;

&lt;p&gt;What I didn't write about was &lt;em&gt;how&lt;/em&gt; any of that actually works.&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%2FZ3JhcGggVEIKICAgIHN1YmdyYXBoIHJlcG9zWyJHaXRIdWIgUmVwb3NpdG9yaWVzIl0KICAgICAgICBTSVRFWyJTaXRlIGNvZGVcbnB1YmxpYyByZXBvIl0KICAgICAgICBDT05URU5UWyJCbG9nIHBvc3RzXG5wcml2YXRlIHJlcG8iXQogICAgZW5kCiAgICBzdWJncmFwaCBuZXRsaWZ5WyJOZXRsaWZ5Il0KICAgICAgICBCVUlMRFsiYXN0cm8gYnVpbGQgKyBwYWdlZmluZCJdCiAgICAgICAgQ0ROWyJTdGF0aWMgQ0ROXG5IVE1MIMK3IENTUyDCtyBKUyDCtyBzZWFyY2ggaW5kZXgiXQogICAgICAgIEZOU1siU2VydmVybGVzcyBmdW5jdGlvbnNcbmNvbW1lbnRzIMK3IHN1YnNjcmliZSJdCiAgICAgICAgU0NIRFsiU2NoZWR1bGVkIGZ1bmN0aW9uXG5kYWlseSByZWJ1aWxkIl0KICAgICAgICBFREdFWyJFZGdlIGZ1bmN0aW9uXG5wcmV2aWV3IGF1dGgiXQogICAgZW5kCiAgICBzdWJncmFwaCBhcGlzWyJFeHRlcm5hbCBzZXJ2aWNlcyJdCiAgICAgICAgUkVTRU5EWyJSZXNlbmRcbmVtYWlsICYgc2VnbWVudHMiXQogICAgICAgIFBPU1RIT0dbIlBvc3RIb2dcbmFuYWx5dGljcyJdCiAgICBlbmQKICAgIFNJVEUgLS0-fHB1c2ggdHJpZ2dlcnMgYnVpbGR8IEJVSUxECiAgICBDT05URU5UIC0tPnxjbG9uZWQgYXQgYnVpbGQgdGltZXwgQlVJTEQKICAgIEJVSUxEIC0tPiBDRE4KICAgIEJVSUxEIC0tPiBGTlMKICAgIEJVSUxEIC0tPiBTQ0hECiAgICBCVUlMRCAtLT4gRURHRQogICAgRk5TIC0tPnxzZW5kIGVtYWlsc3wgUkVTRU5ECiAgICBTQ0hEIC0tPnxQT1NUIGJ1aWxkIGhvb2t8IEJVSUxECiAgICBDRE4gLS4tPnxhbmFseXRpY3Mgc25pcHBldHwgUE9TVEhPRw" 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%2FZ3JhcGggVEIKICAgIHN1YmdyYXBoIHJlcG9zWyJHaXRIdWIgUmVwb3NpdG9yaWVzIl0KICAgICAgICBTSVRFWyJTaXRlIGNvZGVcbnB1YmxpYyByZXBvIl0KICAgICAgICBDT05URU5UWyJCbG9nIHBvc3RzXG5wcml2YXRlIHJlcG8iXQogICAgZW5kCiAgICBzdWJncmFwaCBuZXRsaWZ5WyJOZXRsaWZ5Il0KICAgICAgICBCVUlMRFsiYXN0cm8gYnVpbGQgKyBwYWdlZmluZCJdCiAgICAgICAgQ0ROWyJTdGF0aWMgQ0ROXG5IVE1MIMK3IENTUyDCtyBKUyDCtyBzZWFyY2ggaW5kZXgiXQogICAgICAgIEZOU1siU2VydmVybGVzcyBmdW5jdGlvbnNcbmNvbW1lbnRzIMK3IHN1YnNjcmliZSJdCiAgICAgICAgU0NIRFsiU2NoZWR1bGVkIGZ1bmN0aW9uXG5kYWlseSByZWJ1aWxkIl0KICAgICAgICBFREdFWyJFZGdlIGZ1bmN0aW9uXG5wcmV2aWV3IGF1dGgiXQogICAgZW5kCiAgICBzdWJncmFwaCBhcGlzWyJFeHRlcm5hbCBzZXJ2aWNlcyJdCiAgICAgICAgUkVTRU5EWyJSZXNlbmRcbmVtYWlsICYgc2VnbWVudHMiXQogICAgICAgIFBPU1RIT0dbIlBvc3RIb2dcbmFuYWx5dGljcyJdCiAgICBlbmQKICAgIFNJVEUgLS0-fHB1c2ggdHJpZ2dlcnMgYnVpbGR8IEJVSUxECiAgICBDT05URU5UIC0tPnxjbG9uZWQgYXQgYnVpbGQgdGltZXwgQlVJTEQKICAgIEJVSUxEIC0tPiBDRE4KICAgIEJVSUxEIC0tPiBGTlMKICAgIEJVSUxEIC0tPiBTQ0hECiAgICBCVUlMRCAtLT4gRURHRQogICAgRk5TIC0tPnxzZW5kIGVtYWlsc3wgUkVTRU5ECiAgICBTQ0hEIC0tPnxQT1NUIGJ1aWxkIGhvb2t8IEJVSUxECiAgICBDRE4gLS4tPnxhbmFseXRpY3Mgc25pcHBldHwgUE9TVEhPRw" alt="Mermaid diagram" width="1276" height="652"&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/how-this-blog-was-built" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/how-this-blog-was-built&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The series
&lt;/h2&gt;

&lt;p&gt;I've decided to fix that. Starting today, I'm publishing a series of posts that&lt;br&gt;
document how this blog was built — the technical decisions, the design choices,&lt;br&gt;
the problems that needed solving and how I solved them.&lt;/p&gt;

&lt;p&gt;It is, admittedly, a slightly self-referential thing to write a blog about the&lt;br&gt;
blog. But I think there's genuine value in it. These are patterns and decisions&lt;br&gt;
that come up on almost any content-driven site. The answers I landed on aren't&lt;br&gt;
unique to this project — they're worth writing up properly.&lt;/p&gt;

&lt;p&gt;The series covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/blog/choosing-the-tech-stack"&gt;Choosing the tech stack&lt;/a&gt;&lt;/strong&gt; — &lt;span&gt;Live&lt;/span&gt; — the full stack decision: Astro for static output and content collections, Bulma for a no-runtime CSS foundation, Netlify for hosting and serverless functions, Font Awesome, Resend, and the other tools that hold it together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/blog/content-collections-astro"&gt;Typed content collections&lt;/a&gt;&lt;/strong&gt; — &lt;span&gt;Live&lt;/span&gt; — Zod schemas, build-time validation, the &lt;code&gt;draft&lt;/code&gt; flag pattern, and what typed frontmatter actually buys you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/blog/dark-light-theme-toggle"&gt;Dark/light theme toggle&lt;/a&gt;&lt;/strong&gt; — &lt;span&gt;Live&lt;/span&gt; — CSS custom properties, &lt;code&gt;localStorage&lt;/code&gt; persistence, and why not getting this right causes the flash of wrong theme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blog card and post hero design&lt;/strong&gt; — &lt;span&gt;16 April&lt;/span&gt; — typography hierarchy, cover image pipeline, the draft overlay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tag system with weighted clouds&lt;/strong&gt; — &lt;span&gt;21 April&lt;/span&gt; — slug normalisation, the three-tier cloud, a concentric ring layout, paginated tag pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenGraph and SEO metadata&lt;/strong&gt; — &lt;span&gt;23 April&lt;/span&gt; — OG tags, Twitter Cards, canonical URLs, article metadata — all centralised in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSS in Astro&lt;/strong&gt; — &lt;span&gt;28 April&lt;/span&gt; — the &lt;code&gt;@astrojs/rss&lt;/code&gt; package, draft filtering, autodiscovery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breadcrumb navigation with Schema.org markup&lt;/strong&gt; — &lt;span&gt;30 April&lt;/span&gt; — auto-generated crumbs, &lt;code&gt;BreadcrumbList&lt;/code&gt; structured data, accessible &lt;code&gt;aria-current&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A share widget with the Clipboard API&lt;/strong&gt; — &lt;span&gt;5 May&lt;/span&gt; — LinkedIn, email, copy-link with in-place feedback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page history and credits&lt;/strong&gt; — &lt;span&gt;7 May&lt;/span&gt; — transparent revision logs and why attribution is a first-class concern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploying to Netlify&lt;/strong&gt; — &lt;span&gt;12 May&lt;/span&gt; — &lt;code&gt;netlify.toml&lt;/code&gt;, functions, environment variables, deploy previews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post notification emails with Resend&lt;/strong&gt; — &lt;span&gt;14 May&lt;/span&gt; — a Node.js script that reads frontmatter, builds an HTML email, and sends a broadcast to subscribers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled publishing&lt;/strong&gt; — &lt;span&gt;19 May&lt;/span&gt; — a &lt;code&gt;isPublished()&lt;/code&gt; helper that gates on both draft status and pubDate, a Netlify scheduled function, and a daily build hook so future-dated posts go live automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding a mailing list&lt;/strong&gt; — &lt;span&gt;21 May&lt;/span&gt; — Resend's Segments API, a serverless subscribe function, honeypot spam protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comments on a static site&lt;/strong&gt; — &lt;span&gt;26 May&lt;/span&gt; — Netlify Forms as a queue, HMAC-signed approve/delete links, three serverless functions, no database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mermaid diagrams&lt;/strong&gt; — &lt;span&gt;28 May&lt;/span&gt; — bypassing Expressive Code, client-side rendering, theme-aware SVGs, a fullscreen lightbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better code blocks&lt;/strong&gt; — &lt;span&gt;2 June&lt;/span&gt; — syntax highlighting with Expressive Code, dual themes, line numbers, frames, and markers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sticky table of contents&lt;/strong&gt; — &lt;span&gt;4 June&lt;/span&gt; — build-time heading extraction, IntersectionObserver active state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination&lt;/strong&gt; — &lt;span&gt;9 June&lt;/span&gt; — clean URLs, skeleton placeholder cards, no client-side JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A custom 404 page&lt;/strong&gt; — &lt;span&gt;11 June&lt;/span&gt; — outlined text, a ghost effect, and why a dead end is worth designing properly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web analytics on a static Astro blog&lt;/strong&gt; — &lt;span&gt;16 June&lt;/span&gt; — picking PostHog from the free options, loading the snippet only in production, and wiring up credentials with Netlify environment variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moving blog content to a private repository&lt;/strong&gt; — &lt;span&gt;18 June&lt;/span&gt; — splitting Markdown content into a private GitHub repo, a build-time clone with a fine-grained token, and a GitHub Actions workflow to trigger deploys on content changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding search&lt;/strong&gt; — &lt;span&gt;19 June&lt;/span&gt; — Pagefind for static site search, a command-palette header modal with keyboard navigation, and the non-obvious problems with meta elements, asset hashing, and scoped styles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji reactions with Netlify Blobs&lt;/strong&gt; — &lt;span&gt;23 June&lt;/span&gt; — serverless key-value storage, a Netlify Function API, and an optimistic UI with pop animations and localStorage deduplication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automating Dev.to cross-posting&lt;/strong&gt; — &lt;span&gt;26 June&lt;/span&gt; — a Node.js script that reads your frontmatter, normalises tags, makes image URLs absolute, sets the canonical URL, and publishes in one command.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's twenty-five posts rolling out through to late June. Some are short — a single technique worth documenting cleanly. Others go deeper. All of them are grounded in code that actually runs on this site right now. Subscribe below and you'll get each one the morning it drops — no noise, just the article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why write this at all
&lt;/h2&gt;

&lt;p&gt;Two reasons.&lt;/p&gt;

&lt;p&gt;The first is that documenting things properly forces me to understand them better. When I built the comment system I had a rough sense of how the HMAC signing worked. Writing it up properly — in a way that another engineer could follow — required me to be precise about the details. Every single post in this series has taught me something about my own implementation.&lt;/p&gt;

&lt;p&gt;The second is that I genuinely couldn't find a comprehensive write-up for most of these patterns when I was building them. There are fragments — a hint in the Astro docs, a Stack Overflow answer that's half-right, a GitHub issue with a workaround. Writing them up in one place, with enough context to be useful, seems worth doing.&lt;/p&gt;

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

&lt;p&gt;If you're building a content site, a developer blog, or anything along these lines and would rather not figure it all out from scratch — I'm available for consulting. I can help with architecture decisions, implementation, or a straightforward code review.&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>
    <item>
      <title>I Should Start a Blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Mon, 13 Apr 2026 18:51:07 +0000</pubDate>
      <link>https://dev.to/sourcier/i-should-start-a-blog-514l</link>
      <guid>https://dev.to/sourcier/i-should-start-a-blog-514l</guid>
      <description>&lt;p&gt;I've said "I should start a blog" more times than I'd like to admit.&lt;/p&gt;

&lt;p&gt;For years it existed as a vague intention — something I'd get around to once I had more time, once I had the perfect thing to write about, once I'd figured out exactly what my "niche" was. The usual excuses. The usual friction.&lt;/p&gt;

&lt;p&gt;The thing is, I've been doing this job for over twenty years. I've worked across fintech, media, non-profits, and everything in between. I've mentored engineers, taught at bootcamps, led teams through big architectural decisions and painful rewrites. Somewhere along the way I accumulated a lot of opinions, patterns, and hard-won lessons — and almost none of it has been written down anywhere anyone else can read it.&lt;/p&gt;

&lt;p&gt;That bothers me now more than it used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Teaching did it, honestly.&lt;/p&gt;

&lt;p&gt;Running bootcamp sessions for The Jump Digital School forced me to take things I'd internalised and make them legible. When you have to explain &lt;em&gt;why&lt;/em&gt; something works, not just &lt;em&gt;that&lt;/em&gt; it works, you understand it differently. You catch the gaps in your own thinking. You realise how much of what you "know" is actually just vibes and pattern recognition that you've never had to articulate out loud.&lt;/p&gt;

&lt;p&gt;Those sessions reminded me that I like explaining things. I like finding the clean way through a confusing concept. I find it genuinely satisfying when something clicks for someone.&lt;/p&gt;

&lt;p&gt;A blog is that, scaled up slightly.&lt;/p&gt;

&lt;p&gt;The other thing that pushed me over the line is AI. Not because I think it makes writing less relevant — quite the opposite. The more capable these tools become at generating code, the more important it becomes to understand what good code actually looks like, and why. AI amplifies whatever understanding you bring to it. An engineer who can't evaluate the output is just a faster way to ship the wrong thing. The knowledge worth preserving isn't the mechanical kind. It's the judgement. And that has to come from somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want this to be
&lt;/h2&gt;

&lt;p&gt;Not a content calendar. Not a personal brand exercise. Not SEO-optimised articles with H2s engineered for search intent.&lt;/p&gt;

&lt;p&gt;I want this to read like how I actually talk to other engineers — direct, with opinions, willing to say "this is complicated" rather than flattening nuance into a listicle. More like a long conversation than a documentation page.&lt;/p&gt;

&lt;p&gt;The topics will mostly come from wherever my head is at. Engineering. Architecture. Technical leadership. The weird in-between space of being someone who codes and also has to communicate about code to people who don't. Occasionally something completely unrelated if it feels worth writing.&lt;/p&gt;

&lt;p&gt;I won't pretend I'll post on a schedule. That's historically not how I work. But I will actually post, which is already an improvement on the previous arrangement.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this will be different
&lt;/h2&gt;

&lt;p&gt;A lot of engineering blogs stop at the code. Here's the snippet, here's the library, copy and paste. There's nothing wrong with that — but it doesn't explain the thinking that led there.&lt;/p&gt;

&lt;p&gt;I'm more interested in the decisions. The trade-offs. Why this tool over that one, and what you give up either way. What the design is actually trying to communicate, not just how to implement it. When the clean abstraction is worth the added indirection, and when it's just complicating something simple.&lt;/p&gt;

&lt;p&gt;That's because writing clean code is only part of what it means to be a good engineer. The rest is judgement — knowing what to build, how to structure it, when to push back, and how to bring other people along with you. That part rarely gets written down. It tends to stay locked in the heads of people who've been around long enough to have made the same mistakes a few times.&lt;/p&gt;

&lt;p&gt;So that's what I'm aiming for here. Less "here's how to do the thing" and more "here's why this is the thing to do, and here's what it cost us to find that out."&lt;/p&gt;

&lt;h2&gt;
  
  
  The site itself
&lt;/h2&gt;

&lt;p&gt;I built this with &lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; — partly because it's genuinely excellent for content-focused sites, and partly because I wanted an excuse to use it properly. It's fast, it renders mostly static HTML, and the content layer is exactly as flexible as I needed it to be. No regrets there.&lt;/p&gt;

&lt;p&gt;It's also pleasingly simple to maintain. The posts live as Markdown files in a &lt;code&gt;collections/&lt;/code&gt; folder. There's no CMS, no database, no admin panel. Just files, which is how I like it.&lt;/p&gt;




&lt;p&gt;Anyway. Hello. I'm Roger. I've been in software for a long time and I've finally decided to write some of it down.&lt;/p&gt;

&lt;p&gt;Let's see how this goes.&lt;/p&gt;

</description>
      <category>writing</category>
      <category>meta</category>
    </item>
  </channel>
</rss>
