<?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>Improving code blocks in Astro</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 09 Jun 2026 10:00:10 +0000</pubDate>
      <link>https://dev.to/sourcier/improving-code-blocks-in-astro-65m</link>
      <guid>https://dev.to/sourcier/improving-code-blocks-in-astro-65m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/improving-code-blocks-astro" rel="noopener noreferrer"&gt;Improving code blocks in Astro&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Astro ships with built-in syntax highlighting through Shiki, and for the most part it&lt;br&gt;
does the job. But out of the box you get highlighted code and not much else: no copy&lt;br&gt;
button, no language badge, no way to mark specific lines or highlight a changed word,&lt;br&gt;
no framing to distinguish a terminal command from a config file. For a blog that is&lt;br&gt;
primarily about code, those gaps show up constantly. I wanted blocks that added&lt;br&gt;
context at a glance without requiring custom CSS for every new feature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://expressive-code.com/" rel="noopener noreferrer"&gt;Expressive Code&lt;/a&gt; is an Astro integration that replaces&lt;br&gt;
the default code fence renderer with polished, accessible components — syntax&lt;br&gt;
highlighting, dual themes, a copy button, language labels, editor and terminal frames,&lt;br&gt;
and line/text markers, all driven by code fence attributes. No custom CSS or&lt;br&gt;
JavaScript required.&lt;/p&gt;

&lt;p&gt;The alternative is building it yourself: a custom rehype plugin to transform code&lt;br&gt;
nodes, hand-rolled CSS for every theme variant, client-side JavaScript for the copy&lt;br&gt;
button, and your own logic for diff markers and line highlighting. I looked at that&lt;br&gt;
route and decided the maintenance surface was not worth it. Expressive Code solves the&lt;br&gt;
whole problem in a single integration, the feature set is well ahead of anything I&lt;br&gt;
would build in a reasonable time, and the API maps cleanly to what you already write&lt;br&gt;
in a code fence.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Install the integration and the optional line numbers plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add astro-expressive-code @expressive-code/plugin-line-numbers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This site uses a manual &lt;code&gt;data-theme&lt;/code&gt; toggle rather than &lt;code&gt;prefers-color-scheme&lt;/code&gt;, so&lt;br&gt;
&lt;code&gt;useDarkModeMediaQuery&lt;/code&gt; is disabled and &lt;code&gt;themeCssSelector&lt;/code&gt; maps theme variants to&lt;br&gt;
that attribute:&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;defineConfig&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="s1"&gt;astro/config&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="nx"&gt;expressiveCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-expressive-code&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;pluginLineNumbers&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="s1"&gt;@expressive-code/plugin-line-numbers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;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;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="na"&gt;themes&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="s1"&gt;one-light&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;one-dark-pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;pluginLineNumbers&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
      &lt;span class="na"&gt;defaultProps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;showLineNumbers&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;wrap&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;overridesByLang&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="s1"&gt;bash,sh,zsh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;preserveIndent&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;span class="na"&gt;styleOverrides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;codePaddingInline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.5rem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;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="s1"&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;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;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; hands all code fence processing over to Expressive Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Themes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;themes&lt;/code&gt; takes an array of Shiki theme names — first is the light variant, second is&lt;br&gt;
dark. Expressive Code emits scoped CSS variables for both and activates each via the&lt;br&gt;
selector returned by &lt;code&gt;themeCssSelector&lt;/code&gt;. Any pair from&lt;br&gt;
&lt;a href="https://shiki.style/themes" rel="noopener noreferrer"&gt;the Shiki catalogue&lt;/a&gt; works.&lt;/p&gt;
&lt;h2&gt;
  
  
  Frames
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy132ykbxuqdthnf5yryl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy132ykbxuqdthnf5yryl.png" alt="Code block variants wireframe showing plain code, editor frame with file tab, terminal frame with traffic lights, and line highlights with diff markers" width="700" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Every code block is wrapped in a frame. The frame type — &lt;strong&gt;editor&lt;/strong&gt; or &lt;strong&gt;terminal&lt;/strong&gt; —&lt;br&gt;
is detected automatically from the language identifier, but can be overridden.&lt;/p&gt;
&lt;h3&gt;
  
  
  Editor frames
&lt;/h3&gt;

&lt;p&gt;There are two ways to set the tab title — a &lt;code&gt;title&lt;/code&gt; attribute on the fence, or a&lt;br&gt;
file name comment in the first four lines of the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js title="src/utils/format.js"&lt;br&gt;
export function formatDate(date) {&lt;br&gt;
  return new Intl.DateTimeFormat('en-GB').format(date);&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js&lt;br&gt;
// src/utils/format.js&lt;br&gt;
export function formatDate(date) {&lt;br&gt;
  return new Intl.DateTimeFormat('en-GB').format(date);&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;title&lt;/code&gt; attribute — the tab label is set directly:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;File name comment — extracted as the tab title and removed from the rendered output:&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="c1"&gt;// src/utils/format.js&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;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;h3&gt;
  
  
  Terminal frames
&lt;/h3&gt;

&lt;p&gt;Shell languages (&lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;zsh&lt;/code&gt;, &lt;code&gt;ps1&lt;/code&gt;, etc.) are automatically rendered as&lt;br&gt;
terminal frames. A title is optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No title — still a terminal frame"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Overriding frame type
&lt;/h3&gt;

&lt;p&gt;Force a specific type with the &lt;code&gt;frame&lt;/code&gt; attribute. Useful when a shell script should&lt;br&gt;
look like an editor tab, or when you want to strip all chrome from a block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
ps frame="code" title="PowerShell Profile.ps1"&lt;br&gt;
function Watch-Tail { Get-Content -Tail 20 -Wait $args }&lt;br&gt;
New-Alias tail Watch-Tail&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight postscript"&gt;&lt;code&gt;&lt;span class="nf"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Watch-Tail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;Get-Content&lt;/span&gt; &lt;span class="nf"&gt;-Tail&lt;/span&gt; &lt;span class="mf"&gt;20&lt;/span&gt; &lt;span class="nf"&gt;-Wait&lt;/span&gt; &lt;span class="nf"&gt;$args&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;New-Alias&lt;/span&gt; &lt;span class="nf"&gt;tail&lt;/span&gt; &lt;span class="nf"&gt;Watch-Tail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sh frame="none"&lt;br&gt;
echo "No frame at all"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No frame at all"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Line numbers
&lt;/h2&gt;

&lt;p&gt;Enabled globally via &lt;code&gt;defaultProps: { showLineNumbers: true }&lt;/code&gt;. Both props can be&lt;br&gt;
overridden per block — turn them off entirely, or start the counter at an arbitrary&lt;br&gt;
number when showing a file excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js showLineNumbers=false&lt;br&gt;
export function formatDate(date) {&lt;br&gt;
  return new Intl.DateTimeFormat('en-GB').format(date);&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js startLineNumber=42&lt;br&gt;
export function formatDate(date) {&lt;br&gt;
  return new Intl.DateTimeFormat('en-GB').format(date);&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;showLineNumbers=false&lt;/code&gt; — line numbers hidden:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;&lt;code&gt;startLineNumber=42&lt;/code&gt; — counter starts at 42, useful for excerpts:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;h2&gt;
  
  
  Word wrap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wrap: true&lt;/code&gt; enables soft wrapping globally. Long lines fold visually to the next&lt;br&gt;
line rather than causing a horizontal scrollbar. &lt;code&gt;preserveIndent&lt;/code&gt; (default: &lt;code&gt;true&lt;/code&gt;)&lt;br&gt;
keeps wrapped lines aligned with their original indentation — useful for code.&lt;br&gt;
Setting it to &lt;code&gt;false&lt;/code&gt; makes wrapped lines start at column 1, which suits terminal&lt;br&gt;
output, so the config uses &lt;code&gt;overridesByLang&lt;/code&gt; to apply that for &lt;code&gt;bash,sh,zsh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both can be overridden per block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js wrap=true&lt;br&gt;
const result = await fetch('&lt;a href="https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2'" rel="noopener noreferrer"&gt;https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2'&lt;/a&gt;);&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js wrap=false&lt;br&gt;
const result = await fetch('&lt;a href="https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2'" rel="noopener noreferrer"&gt;https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2'&lt;/a&gt;);&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wrap=true&lt;/code&gt; — long line folds to the next line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2&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;&lt;code&gt;wrap=false&lt;/code&gt; — long line causes a horizontal scrollbar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com/v1/users?filter=active&amp;amp;sort=createdAt&amp;amp;order=desc&amp;amp;limit=100&amp;amp;page=2&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;h2&gt;
  
  
  Line markers
&lt;/h2&gt;

&lt;p&gt;Draw attention to specific lines or ranges using &lt;code&gt;mark&lt;/code&gt;, &lt;code&gt;ins&lt;/code&gt;, and &lt;code&gt;del&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mark={N}&lt;/code&gt; — neutral highlight&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ins={N}&lt;/code&gt; — green "added" highlight with a &lt;code&gt;+&lt;/code&gt; indicator&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;del={N}&lt;/code&gt; — red "removed" highlight with a &lt;code&gt;-&lt;/code&gt; indicator
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js mark={1} ins={3-5} del={7}&lt;br&gt;
import { defineConfig } from 'astro/config';&lt;/p&gt;

&lt;p&gt;import expressiveCode from 'astro-expressive-code';&lt;br&gt;
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';&lt;br&gt;
import emoji from 'remark-emoji';&lt;/p&gt;

&lt;p&gt;import { oldPlugin } from './old-plugin';&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;defineConfig&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="s1"&gt;astro/config&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="nx"&gt;expressiveCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-expressive-code&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;pluginLineNumbers&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="s1"&gt;@expressive-code/plugin-line-numbers&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="nx"&gt;emoji&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remark-emoji&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;oldPlugin&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="s1"&gt;./old-plugin&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;Combine multiple ranges in one attribute: &lt;code&gt;ins={1-2, 5, 8-10}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Labels can be added to any marked range — wrap the value in &lt;code&gt;{"label:": range}&lt;/code&gt;&lt;br&gt;
and a coloured badge appears at the start of the highlighted block. The label&lt;br&gt;
string must end with a colon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js ins={"1":3-5} del={"2":7}&lt;br&gt;
import { defineConfig } from 'astro/config';&lt;/p&gt;

&lt;p&gt;import expressiveCode from 'astro-expressive-code';&lt;br&gt;
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';&lt;br&gt;
import emoji from 'remark-emoji';&lt;/p&gt;

&lt;p&gt;import { oldPlugin } from './old-plugin';&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;defineConfig&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="s1"&gt;astro/config&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="nx"&gt;expressiveCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-expressive-code&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;pluginLineNumbers&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="s1"&gt;@expressive-code/plugin-line-numbers&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="nx"&gt;emoji&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remark-emoji&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;oldPlugin&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="s1"&gt;./old-plugin&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;h3&gt;
  
  
  Using diff syntax
&lt;/h3&gt;

&lt;p&gt;Set the language to &lt;code&gt;diff&lt;/code&gt; and prefix lines with &lt;code&gt;+&lt;/code&gt; or &lt;code&gt;-&lt;/code&gt;. Add &lt;code&gt;lang="..."&lt;/code&gt; to&lt;br&gt;
keep syntax highlighting for the actual language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
diff lang="js"&lt;br&gt;
  export default defineConfig({&lt;br&gt;
    integrations: [&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;    shikiConfig({ themes: { light: 'one-light', dark: 'one-dark-pro' } }),&lt;/li&gt;
&lt;li&gt;    expressiveCode({ themes: ['one-light', 'one-dark-pro'] }),
],
});
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  export default defineConfig({
    integrations: [
&lt;span class="gd"&gt;-     shikiConfig({ themes: { light: 'one-light', dark: 'one-dark-pro' } }),
&lt;/span&gt;&lt;span class="gi"&gt;+     expressiveCode({ themes: ['one-light', 'one-dark-pro'] }),
&lt;/span&gt;    ],
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Text markers
&lt;/h2&gt;

&lt;p&gt;Mark arbitrary text within lines using the same &lt;code&gt;mark&lt;/code&gt;, &lt;code&gt;ins&lt;/code&gt;, or &lt;code&gt;del&lt;/code&gt; attributes&lt;br&gt;
with a quoted string value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
js ins="expressiveCode" del="shikiConfig" mark="themes"&lt;br&gt;
import expressiveCode from 'astro-expressive-code';&lt;/p&gt;

&lt;p&gt;export default defineConfig({&lt;br&gt;
  integrations: [expressiveCode({ themes: ['one-light', 'one-dark-pro'] })],&lt;br&gt;
  markdown: { shikiConfig: { themes: { light: 'one-light' } } },&lt;br&gt;
});&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;expressiveCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro-expressive-code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;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;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="na"&gt;themes&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="s1"&gt;one-light&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;one-dark-pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt;
  &lt;span class="na"&gt;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;shikiConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;themes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;light&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one-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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a &lt;code&gt;/regex/&lt;/code&gt; for pattern-based matching, or repeat the attribute for multiple&lt;br&gt;
values: &lt;code&gt;ins="foo" ins="bar"&lt;/code&gt;. Capture groups narrow the match to a sub-expression:&lt;br&gt;
&lt;code&gt;/import (expressiveCode)/&lt;/code&gt; marks only the identifier, not the whole import statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full picture
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;astro-expressive-code&lt;/code&gt; in place, a single config block handles syntax&lt;br&gt;
highlighting, dual themes, line numbers, word wrap, copy buttons, and language labels.&lt;br&gt;
Editor and terminal frames add context without extra markup. Line and text markers let&lt;br&gt;
you direct the reader's attention precisely — all driven by code fence attributes that&lt;br&gt;
read naturally in the source.&lt;/p&gt;

&lt;p&gt;If you are setting this up on your own Astro site, or have a different approach to&lt;br&gt;
code block styling, &lt;a href="https://dev.to/contact"&gt;I'd like to hear about it&lt;/a&gt;. The rest of the series&lt;br&gt;
covers the table of contents, pagination, search, and more — sign up to the mailing.&lt;br&gt;
list below to get each post the morning it drops.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Running Local AI Models on macOS</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 04 Jun 2026 10:34:15 +0000</pubDate>
      <link>https://dev.to/sourcier/running-local-ai-models-on-macos-41bk</link>
      <guid>https://dev.to/sourcier/running-local-ai-models-on-macos-41bk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/local-ai-ollama-setup" rel="noopener noreferrer"&gt;Running Local AI Models on macOS&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I use GitHub Copilot at work and Claude for personal projects. Both switched to usage-based billing this month, dropping the flat subscription model. For anyone using these tools heavily across multiple projects, that shift makes the monthly cost unpredictable. Running models locally removes that variable entirely: no usage bills, no rate limits, and everything stays on your machine.&lt;/p&gt;

&lt;p&gt;The quality gap has closed enough that local models are a realistic daily driver now, not just an experiment.&lt;/p&gt;

&lt;p&gt;This guide covers the first-time setup on a Mac with Apple Silicon. I run this on an M1 MacBook Pro with 16 GB of unified memory. The default settings are tuned for that hardware, but each relevant section also covers what to change if you have more RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgICBBW1ZTIENvZGUgQ29waWxvdF0gLS0-fEhUVFAgQVBJfCBCW09sbGFtYSBzZXJ2ZXJdCiAgICBCIC0tPnxsb2FkcyBtb2RlbHwgQ1tVbmlmaWVkIG1lbW9yeV0KICAgIEMgLS0-fHJlYWRzIGZyb218IERbTW9kZWwgZmlsZXMgb24gZGlza10" 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%2FZmxvd2NoYXJ0IFRECiAgICBBW1ZTIENvZGUgQ29waWxvdF0gLS0-fEhUVFAgQVBJfCBCW09sbGFtYSBzZXJ2ZXJdCiAgICBCIC0tPnxsb2FkcyBtb2RlbHwgQ1tVbmlmaWVkIG1lbW9yeV0KICAgIEMgLS0-fHJlYWRzIGZyb218IERbTW9kZWwgZmlsZXMgb24gZGlza10" alt="Mermaid diagram" width="208" height="454"&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/local-ai-ollama-setup" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/local-ai-ollama-setup&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;macOS with Apple Silicon (M series)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://brew.sh" rel="noopener noreferrer"&gt;Homebrew&lt;/a&gt; installed&lt;/li&gt;
&lt;li&gt;A few GB of free disk space per model (most 7–8B models need 4–5 GB each)&lt;/li&gt;
&lt;li&gt;VS Code, for the integration sections at the end; a GitHub Copilot subscription is needed to use cloud models, but the local Ollama integration works without one&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install Ollama
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; is the runtime that downloads, manages, and serves local models. Install it via Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alternatively, download the installer directly from &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;ollama.com&lt;/a&gt;. Once launched, Ollama places an icon in the menu bar and starts the API server at &lt;code&gt;http://localhost:11434&lt;/code&gt;. Confirm it is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:11434
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response should be &lt;code&gt;Ollama is running&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory and performance settings
&lt;/h2&gt;

&lt;p&gt;Running a language model is fundamentally a memory operation, not a compute one. A model's weights are the billions of numerical parameters that encode its behaviour, and they must be loaded entirely into RAM before a single token can be generated. A 7B model in Q4_K_M quantisation takes around 4–5 GB; an 8B model is similar. If those weights do not fit and the system starts paging to disk, inference slows to a near halt regardless of how fast your CPU is.&lt;/p&gt;

&lt;p&gt;On Apple Silicon this matters more than on a typical machine: the CPU, Metal GPU, and every running application share a single pool of unified memory. VS Code, a dev server, a browser, and Ollama are all drawing from the same 16 GB.&lt;/p&gt;

&lt;p&gt;Ollama's defaults are generous with memory, which compounds these pressures. Without tuning, the runtime may load multiple models simultaneously, allocate a context window far larger than needed, and leave your other tools fighting for RAM.&lt;/p&gt;

&lt;p&gt;Add these variables to &lt;code&gt;~/.zshrc&lt;/code&gt; or &lt;code&gt;~/.zprofile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Limit concurrency — one model at a time on 16 GB&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_MAX_LOADED_MODELS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_NUM_PARALLEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# Keep the model warm between requests — avoids cold-start latency in VS Code&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_KEEP_ALIVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30m

&lt;span class="c"&gt;# Default context window&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_CONTEXT_LENGTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4096

&lt;span class="c"&gt;# Apple Silicon optimisations — the highest-impact pair for 16 GB&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_FLASH_ATTENTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_KV_CACHE_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;q8_0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then apply them without restarting your shell:&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="nb"&gt;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_MAX_LOADED_MODELS=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prevents multiple models competing for the same 16 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_NUM_PARALLEL=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Explicit default; prevents accidental concurrent loads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_FLASH_ATTENTION=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reduces peak activation memory on M1 Metal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_KV_CACHE_TYPE=q8_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Halves KV cache RAM compared to the default &lt;code&gt;f16&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_KEEP_ALIVE=30m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Model stays loaded between requests, no cold-start delay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;OLLAMA_FLASH_ATTENTION&lt;/code&gt; and &lt;code&gt;OLLAMA_KV_CACHE_TYPE=q8_0&lt;/code&gt; together free around 1–2 GB of effective headroom. That is enough to run 8B parameter models comfortably on 16 GB when they would otherwise be marginal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adjusting for more RAM
&lt;/h3&gt;

&lt;p&gt;The settings above are conservative, tuned for 16 GB. On machines with more unified memory you can relax the concurrency limits and drop the KV cache compression:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;16 GB (M1/M2)&lt;/th&gt;
&lt;th&gt;32 GB (M2 Pro/M3 Pro)&lt;/th&gt;
&lt;th&gt;64 GB+ (M3 Max/Ultra)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_MAX_LOADED_MODELS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3&lt;/code&gt; or more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_NUM_PARALLEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OLLAMA_KV_CACHE_TYPE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q8_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;q8_0&lt;/code&gt; or omit&lt;/td&gt;
&lt;td&gt;Omit: use default &lt;code&gt;f16&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;OLLAMA_FLASH_ATTENTION=1&lt;/code&gt; is still worth keeping on any Apple Silicon machine: it reduces peak activation memory regardless of total RAM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Staying fully local
&lt;/h3&gt;

&lt;p&gt;Ollama does not send your prompts anywhere by default. If you are working with sensitive data and want a hard guarantee, add this flag too:&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="c"&gt;# Optional — disables remote inference and web search entirely&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_NO_CLOUD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Choosing a model
&lt;/h2&gt;

&lt;p&gt;Every model has a name and a size tag. The number in the tag reflects how many billion parameters it contains, which determines both output quality and how much RAM it needs to load. Use the table below to pick the right fit for your hardware and use case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model reference
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Vision&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemma3:4b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~2.5 GB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Fast chat, vision, light tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen3:8b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~4.5 GB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Best all-rounder, strong reasoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5vl:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~4.5 GB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Vision and text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5vl:3b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~2 GB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Lightweight vision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5-coder:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~4.3 GB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Code generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mistral-nemo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~7 GB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Long documents, 32K context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemma3:12b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~8 GB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Higher quality, viable with flash attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nomic-embed-text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~0.3 GB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Embeddings and RAG pipelines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On &lt;strong&gt;16 GB&lt;/strong&gt;, avoid 13B models and larger. They will page to swap and feel sluggish under any real workload. On &lt;strong&gt;32 GB&lt;/strong&gt; you can run 13B and 14B models comfortably, and &lt;code&gt;gemma3:12b&lt;/code&gt; and &lt;code&gt;qwen3:14b&lt;/code&gt; become reliable daily drivers. On &lt;strong&gt;64 GB or more&lt;/strong&gt;, 27B and 32B models are viable. Check &lt;a href="https://ollama.com/library" rel="noopener noreferrer"&gt;ollama.com/library&lt;/a&gt; for the full catalogue.&lt;/p&gt;

&lt;p&gt;Prefer &lt;strong&gt;Q4_K_M quantised&lt;/strong&gt; variants when available. They offer the best speed-to-quality tradeoff regardless of hardware tier.&lt;/p&gt;

&lt;p&gt;When you want to attach an image to a conversation, switch to a vision model like &lt;code&gt;qwen2.5vl:7b&lt;/code&gt; or &lt;code&gt;gemma3:4b&lt;/code&gt;. Text-only models reject image input.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pulling and running a model
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;ollama pull&lt;/code&gt; to download a model and &lt;code&gt;ollama run&lt;/code&gt; to test it interactively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull qwen3:8b
ollama run qwen3:8b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first pull downloads several gigabytes, so run this on a decent connection. After that, the model lives on disk at &lt;code&gt;~/.ollama/models/&lt;/code&gt; and launches instantly.&lt;/p&gt;

&lt;p&gt;A good starting set for most daily-use scenarios:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull qwen3:8b           &lt;span class="c"&gt;# daily driver — best all-rounder&lt;/span&gt;
ollama pull qwen2.5-coder:7b   &lt;span class="c"&gt;# coding tasks&lt;/span&gt;
ollama pull qwen2.5vl:7b       &lt;span class="c"&gt;# vision and text&lt;/span&gt;
ollama pull gemma3:4b          &lt;span class="c"&gt;# lightweight vision alternative&lt;/span&gt;
ollama pull nomic-embed-text   &lt;span class="c"&gt;# embeddings and RAG&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  VS Code Copilot integration
&lt;/h2&gt;

&lt;p&gt;VS Code Copilot can use a local Ollama server as a model provider. The setup is straightforward, but there is one catch: Copilot reads each model's maximum reported context size and may allocate the full window upfront.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Reported max context&lt;/th&gt;
&lt;th&gt;KV cache cost at max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen3:8b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;41K tokens&lt;/td&gt;
&lt;td&gt;~4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5vl:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;128K tokens&lt;/td&gt;
&lt;td&gt;~16 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5-coder:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;33K tokens&lt;/td&gt;
&lt;td&gt;~3 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;OLLAMA_CONTEXT_LENGTH=4096&lt;/code&gt; sets a global default, but Copilot does not always respect it in API requests. The reliable fix is a &lt;strong&gt;Modelfile&lt;/strong&gt;: a small config file that bakes a capped context window into a named model variant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create capped model variants
&lt;/h3&gt;

&lt;p&gt;A Modelfile is a plain text file that tells Ollama how to build a named variant from an existing base. The two fields that matter here are &lt;code&gt;FROM&lt;/code&gt; (the base model to derive from) and &lt;code&gt;PARAMETER num_ctx&lt;/code&gt; (the context window to enforce):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM qwen3:8b
PARAMETER num_ctx 4096
PARAMETER temperature 0.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;temperature&lt;/code&gt; controls how much variation the model introduces when generating a response. &lt;code&gt;0.7&lt;/code&gt; is a reasonable general-purpose default: creative enough to avoid repetitive output, focused enough to stay on topic. The coder variant uses &lt;code&gt;0.2&lt;/code&gt; because code generation benefits from deterministic output. There is usually one right answer, not several equally valid variations.&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;ollama create &amp;lt;name&amp;gt; -f &amp;lt;Modelfile&amp;gt;&lt;/code&gt; registers that file as a new named model. No additional data is downloaded: Ollama references the base model already on disk with the specified parameters baked in.&lt;/p&gt;

&lt;p&gt;The following creates all four variants in one pass:&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="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/ollama-models

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM qwen3:8b\nPARAMETER num_ctx 4096\nPARAMETER temperature 0.7\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/ollama-models/Modelfile.qwen3-fast

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM qwen2.5-coder:7b\nPARAMETER num_ctx 4096\nPARAMETER temperature 0.2\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/ollama-models/Modelfile.coder-fast

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM qwen2.5vl:7b\nPARAMETER num_ctx 4096\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/ollama-models/Modelfile.vision-fast

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM gemma3:4b\nPARAMETER num_ctx 4096\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/ollama-models/Modelfile.gemma-fast

ollama create qwen3-fast  &lt;span class="nt"&gt;-f&lt;/span&gt; ~/ollama-models/Modelfile.qwen3-fast
ollama create coder-fast  &lt;span class="nt"&gt;-f&lt;/span&gt; ~/ollama-models/Modelfile.coder-fast
ollama create vision-fast &lt;span class="nt"&gt;-f&lt;/span&gt; ~/ollama-models/Modelfile.vision-fast
ollama create gemma-fast  &lt;span class="nt"&gt;-f&lt;/span&gt; ~/ollama-models/Modelfile.gemma-fast
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Connect to Ollama and select a model
&lt;/h3&gt;

&lt;p&gt;To wire VS Code Copilot to a local Ollama server, add this to your VS Code &lt;code&gt;settings.json&lt;/code&gt;:&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;"github.copilot.chat.ollama.endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:11434"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also do this through the UI: open Copilot Chat (Cmd+Shift+I on macOS), click the model picker dropdown at the top of the chat panel, and choose "Manage Models". VS Code discovers all models running on &lt;code&gt;localhost:11434&lt;/code&gt; automatically once Ollama is running.&lt;/p&gt;

&lt;p&gt;Once connected, the capped variants appear in the picker alongside any cloud models. Switch to &lt;code&gt;qwen3-fast&lt;/code&gt;, &lt;code&gt;coder-fast&lt;/code&gt;, &lt;code&gt;vision-fast&lt;/code&gt;, or &lt;code&gt;gemma-fast&lt;/code&gt; depending on the task. After starting a conversation, confirm the model loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Copilot CLI
&lt;/h3&gt;

&lt;p&gt;GitHub Copilot has a standalone CLI for the terminal. Install it via Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;copilot-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once installed, run &lt;code&gt;copilot&lt;/code&gt; from any project directory. On first launch it asks you to trust the folder and log in to GitHub. You type prompts directly in the terminal and Copilot can read, modify, and run files in the current directory. It supports plan mode (Shift+Tab to toggle), custom agents, and MCP servers.&lt;/p&gt;

&lt;p&gt;You can point it at Ollama to use local models rather than GitHub's cloud. The quickest way is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama launch copilot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This opens a model selector populated from Ollama's library. To specify a model directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama launch copilot &lt;span class="nt"&gt;--model&lt;/span&gt; qwen3:8b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For manual wiring, set the Ollama endpoint via environment variables before running &lt;code&gt;copilot&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_PROVIDER_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:11434/v1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_PROVIDER_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_PROVIDER_WIRE_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;responses
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;qwen3:8b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: Copilot CLI works best with a generous context window. The Ollama docs recommend at least 64K tokens, so the 4K capped variants created above are too small for it. Use the base models directly and raise &lt;code&gt;OLLAMA_CONTEXT_LENGTH&lt;/code&gt; to &lt;code&gt;32768&lt;/code&gt; or higher when running Copilot CLI sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;This covers the full stack: Ollama installed and tuned, a model set selected for different use cases, VS Code Copilot wired to local variants, and the standalone Copilot CLI pointed at Ollama. On 16 GB the memory settings and capped context variants make local inference genuinely practical for everyday coding and chat work, not just a curiosity.&lt;/p&gt;

&lt;p&gt;For tasks that fit in a 4K context window a local model handles them without touching any external service. For longer context, heavier reasoning, or the times a cloud model simply performs better, the paid providers are still there. The difference is that reaching for them is now a deliberate choice rather than the default.&lt;/p&gt;

&lt;p&gt;Keeping up with new model releases is a single &lt;code&gt;ollama pull&lt;/code&gt; command. Ollama fetches only changed layers, so updates stay fast even at multi-GB model sizes.&lt;/p&gt;

&lt;p&gt;I'm also working on a dedicated machine for local AI inference: custom hardware that removes the unified memory constraint entirely. I'll write that up once it's running. If you're building something similar or have a setup you're happy with, drop a comment below or subscribe via the form at the end of this page to catch that post when it lands.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ollama</category>
      <category>macos</category>
      <category>tooling</category>
    </item>
    <item>
      <title>GitHub Copilot for Engineers: Getting Better Results</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 02 Jun 2026 09:50:19 +0000</pubDate>
      <link>https://dev.to/sourcier/github-copilot-for-engineers-getting-better-results-41l2</link>
      <guid>https://dev.to/sourcier/github-copilot-for-engineers-getting-better-results-41l2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/github-copilot-for-engineers" rel="noopener noreferrer"&gt;GitHub Copilot for Engineers: Getting Better Results&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;GitHub Copilot moved to usage-based billing in June 2026, dropping the flat subscription model that made monthly costs predictable. For teams using it heavily across multiple projects, that shift puts a premium on being deliberate: reaching for the right model, keeping prompts focused, and building a configuration that produces good results without a lot of back-and-forth iteration.&lt;/p&gt;

&lt;p&gt;Many of us install the extension, start with the defaults, and only tune settings later. The defaults are a reasonable starting point, but they are not a full configuration. A small investment in setup changes how much you get out of every request on an ordinary working day, and that matters more now that each request has a cost attached.&lt;/p&gt;

&lt;p&gt;This guide covers the full path: getting the tooling in place, choosing models with cost in mind, layering global and project-level rules, and building out instructions, agents, and skills that make Copilot predictable across different kinds of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtQcm9tcHRzIGluIFZTIENvZGUgb3IgQ0xJXSAtLT4gQltJbnN0cnVjdGlvbnMgbGF5ZXJdCiAgQiAtLT4gQ1tBZ2VudCBzZWxlY3Rpb24gYW5kIG1vZGVsXQogIEMgLS0-IERbU2tpbGxzIHdvcmtmbG93XQogIEQgLS0-IEVbVG9vbHM6IENMSSBmaXJzdCwgTUNQIHdoZW4gbmVlZGVkXQogIEUgLS0-IEZbUmV2aWV3IGFuZCB2YWxpZGF0ZSBvdXRwdXRd" 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%2FZmxvd2NoYXJ0IFRECiAgQVtQcm9tcHRzIGluIFZTIENvZGUgb3IgQ0xJXSAtLT4gQltJbnN0cnVjdGlvbnMgbGF5ZXJdCiAgQiAtLT4gQ1tBZ2VudCBzZWxlY3Rpb24gYW5kIG1vZGVsXQogIEMgLS0-IERbU2tpbGxzIHdvcmtmbG93XQogIEQgLS0-IEVbVG9vbHM6IENMSSBmaXJzdCwgTUNQIHdoZW4gbmVlZGVkXQogIEUgLS0-IEZbUmV2aWV3IGFuZCB2YWxpZGF0ZSBvdXRwdXRd" alt="Mermaid diagram" width="276" height="614"&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/github-copilot-for-engineers" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/github-copilot-for-engineers&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Subscription and VS Code extension
&lt;/h3&gt;

&lt;p&gt;You need an active GitHub Copilot subscription. Plans are available at individual, business, and enterprise tiers at &lt;a href="https://github.com/features/copilot" rel="noopener noreferrer"&gt;github.com/features/copilot&lt;/a&gt;. Once active, all tools use your GitHub account credentials.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot" rel="noopener noreferrer"&gt;GitHub Copilot extension for VS Code&lt;/a&gt; is the primary day-to-day interface. Install it from the Extensions panel or via the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;code &lt;span class="nt"&gt;--install-extension&lt;/span&gt; GitHub.copilot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extension provides inline completions as you type, Copilot Chat in the sidebar, inline chat on any selection via &lt;code&gt;Cmd+I&lt;/code&gt; / &lt;code&gt;Ctrl+I&lt;/code&gt;, agent mode for multi-step tasks, and multi-file edits with a single review step.&lt;/p&gt;

&lt;p&gt;Defaults keep improving, so avoid cargo-culting old setting lists. Focus on non-default tweaks that improve signal quality and control usage:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github.copilot.nextEditSuggestions.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Surfaces likely next edits proactively during implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github.copilot.chat.codesearch.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Improves answers on larger repos by pulling semantic code context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Copilot CLI
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli" rel="noopener noreferrer"&gt;GitHub Copilot CLI&lt;/a&gt; is a standalone AI agent for the terminal.&lt;/p&gt;

&lt;p&gt;Install via Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;copilot-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or via pnpm, if you prefer Node.js tooling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add &lt;span class="nt"&gt;-g&lt;/span&gt; @github/copilot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On first launch, authenticate with your GitHub account by following the &lt;code&gt;/login&lt;/code&gt; prompt. The CLI has two modes: an interactive session where you have a back-and-forth conversation while Copilot reads and modifies files in the current directory, and a programmatic mode where you pass a single prompt with &lt;code&gt;-p&lt;/code&gt; and the CLI executes and exits.&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="c"&gt;# Start an interactive session&lt;/span&gt;
copilot

&lt;span class="c"&gt;# One-shot task with explicit tool approval&lt;/span&gt;
copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Show me this week's commits and summarise them"&lt;/span&gt; &lt;span class="nt"&gt;--allow-tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'shell(git)'&lt;/span&gt;

&lt;span class="c"&gt;# Open a PR with the changes&lt;/span&gt;
copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Refactor the auth module to use async/await and open a PR"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  awesome-copilot
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/github/awesome-copilot" rel="noopener noreferrer"&gt;github.com/github/awesome-copilot&lt;/a&gt; is the official community-curated repository of Copilot instructions, agents, skills, and prompts. Before writing any configuration from scratch, check here first. It is far faster to adapt a battle-tested instruction file than to start from a blank page. The catalogue covers common engineering workflows: security review, frontend, documentation, and more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose models by task
&lt;/h2&gt;

&lt;p&gt;Model choice should match task shape. GitHub Copilot subscriptions give you access to a range of models. Here is how to map them to real work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Recommended model&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quick edits and inline completions&lt;/td&gt;
&lt;td&gt;GPT-5.4&lt;/td&gt;
&lt;td&gt;Strong quality at lower effort per prompt, reducing rework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;General feature work and refactoring&lt;/td&gt;
&lt;td&gt;GPT-5.4 or Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;Strong reasoning with a good speed and cost balance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex architecture and deep analysis&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;Extended reasoning and long context without premium pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security review&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;Strong policy alignment and risk detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation and standards&lt;/td&gt;
&lt;td&gt;GPT-5.4 or Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;Consistent structure, clear prose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent and multi-step repo operations&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.5 or GPT-5.4&lt;/td&gt;
&lt;td&gt;Reliable tool-calling across many steps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A practical pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with your default model.&lt;/li&gt;
&lt;li&gt;If the output is shallow, switch to a stronger reasoning model.&lt;/li&gt;
&lt;li&gt;If the output is too slow for a simple task, switch back to a faster model.&lt;/li&gt;
&lt;li&gt;Keep one model per agent role for consistency: engineering, security, UX, docs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Agents (covered below) let you pin a model in their frontmatter, so the same task always runs with the same capability profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer global and project settings
&lt;/h2&gt;

&lt;p&gt;The most reliable setup is layered: global defaults for how you work everywhere, project-level rules for what is unique to a specific repo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Global settings
&lt;/h3&gt;

&lt;p&gt;Global Copilot configuration lives under your home directory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.copilot/instructions/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Always-on rules applied across all repos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.copilot/agents/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reusable agent definitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.copilot/skills/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reusable skill playbooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.copilot/mcp-config.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Global MCP server connections&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Managing these in a dotfiles repo and syncing them to &lt;code&gt;$HOME&lt;/code&gt; on each machine gives you a consistent baseline without reconfiguring per project. Good candidates for global rules include workflow behaviour and tool preferences, secure coding defaults, code-commenting standards, and framework-specific instructions for React, TypeScript, and similar.&lt;/p&gt;

&lt;p&gt;If you want a concrete reference, this is my setup: &lt;a href="https://github.com/sourcier/dotfiles" rel="noopener noreferrer"&gt;github.com/sourcier/dotfiles&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project-level settings
&lt;/h3&gt;

&lt;p&gt;For project-specific behaviour, add a &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; to the repo root, or place &lt;code&gt;.instructions.md&lt;/code&gt; files in &lt;code&gt;.github/instructions/&lt;/code&gt;. VS Code picks these up automatically when you open the project.&lt;/p&gt;

&lt;p&gt;Use project-level rules for naming conventions unique to this repo, folder architecture and module boundaries, test and QA requirements, and build and deployment constraints. Use global rules for communication style, security baseline, preferred CLIs, package manager choices, and repeatable personal workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instructions, agents, and skills
&lt;/h2&gt;

&lt;p&gt;These three tools serve different purposes. Treating them as interchangeable leads to a setup that is noisy and hard to maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Instructions: always-on policy
&lt;/h3&gt;

&lt;p&gt;Instructions are Markdown files with YAML frontmatter that Copilot reads automatically whenever the &lt;code&gt;applyTo&lt;/code&gt; glob matches the file you are working in. Use them for stable guardrails and standards that should apply silently in the background.&lt;/p&gt;

&lt;p&gt;Two patterns work well: broad instructions with &lt;code&gt;applyTo: '*'&lt;/code&gt; for universal rules, and targeted instructions with file-type globs for framework-specific rules.&lt;/p&gt;

&lt;p&gt;Minimal template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**/*.ts'&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;TypeScript&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;service-layer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;standards'&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Service Standards&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Use strict typing
&lt;span class="p"&gt;-&lt;/span&gt; Return typed errors
&lt;span class="p"&gt;-&lt;/span&gt; Validate external input at boundaries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Agents: task-specific operators
&lt;/h3&gt;

&lt;p&gt;Agents are &lt;code&gt;.agent.md&lt;/code&gt; files that define a reusable persona with a pinned model, a tool budget, and a system prompt. Use them when the same class of work needs consistent behaviour every time.&lt;/p&gt;

&lt;p&gt;An agent definition includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;name&lt;/code&gt;: shown in the VS Code agent picker&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;description&lt;/code&gt;: used for routing; Copilot reads this to decide which agent fits a request&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;model&lt;/code&gt;: pinned model for this workflow&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools&lt;/code&gt;: explicit list of tools the agent is allowed to use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Good starting agents: a Software Engineer for general implementation and refactoring, a Security Reviewer for OWASP-focused code review, an Expert Frontend Engineer for React and TypeScript work, and a Tech Writer for documentation and READMEs.&lt;/p&gt;

&lt;p&gt;Create a new agent when a task requires a distinct review lens, needs a stable model and tool profile, or when you want consistent output style for that workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skills: reusable playbooks
&lt;/h3&gt;

&lt;p&gt;Skills are &lt;code&gt;SKILL.md&lt;/code&gt; files that contain detailed step-by-step procedures for a specific domain. The agent reads the skill file at invocation time rather than keeping it in context permanently, which means skills can be as long and detailed as needed without bloating every conversation.&lt;/p&gt;

&lt;p&gt;Good candidates: a premium frontend UI craftsmanship checklist, a workflow for creating &lt;code&gt;AGENTS.md&lt;/code&gt; files for a new repo, or a Playwright website exploration procedure.&lt;/p&gt;

&lt;p&gt;The rule of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instructions&lt;/strong&gt; = default behaviour&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; = who does the work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills&lt;/strong&gt; = how specialised work gets executed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  MCP servers
&lt;/h2&gt;

&lt;p&gt;MCP (Model Context Protocol) extends Copilot with external capabilities: browsers, issue trackers, cloud providers, databases, and more. Servers are configured in &lt;code&gt;~/.copilot/mcp-config.json&lt;/code&gt; for global access, or &lt;code&gt;.vscode/mcp.json&lt;/code&gt; for project scope.&lt;/p&gt;

&lt;p&gt;Three common patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local command server&lt;/strong&gt;: runs a pre-installed binary on your machine (preferred)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote HTTP server&lt;/strong&gt;: connects to an external service over HTTPS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npx/uvx on-demand&lt;/strong&gt;: package runner launches the server each time; avoid for servers you use regularly as the cold boot adds latency on every invocation&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Installing common servers
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Playwright MCP&lt;/strong&gt;: browser automation and visual QA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;playwright-mcp
&lt;span class="c"&gt;# or via pnpm&lt;/span&gt;
pnpm add &lt;span class="nt"&gt;-g&lt;/span&gt; @playwright/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Azure MCP Server&lt;/strong&gt;: Azure resource inspection and management:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap azure/azure-cli
brew &lt;span class="nb"&gt;install &lt;/span&gt;azmcp
&lt;span class="c"&gt;# Authenticate before first use&lt;/span&gt;
az login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Example config
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"playwright"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"playwright-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--headless"&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;"atlassian/atlassian-mcp-server"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.atlassian.com/v1/mcp/"&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;"Azure MCP Server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"azmcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer a globally installed binary via Homebrew or pnpm for any server you use regularly. Reserve &lt;code&gt;npx&lt;/code&gt; for one-off evaluation of a new server before committing to a permanent install.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to reach for MCP
&lt;/h3&gt;

&lt;p&gt;Follow this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Native CLI first: &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;pnpm&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt;, cloud CLIs.&lt;/li&gt;
&lt;li&gt;Use MCP when no good CLI path exists or when MCP adds capability the CLI cannot.&lt;/li&gt;
&lt;li&gt;Keep the MCP list minimal and intentional.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Security checklist
&lt;/h3&gt;

&lt;p&gt;Before adding any MCP server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never hardcode tokens in config files. Use environment variables or a secret store.&lt;/li&gt;
&lt;li&gt;Prefer least-privilege credentials.&lt;/li&gt;
&lt;li&gt;Use trusted hosts only for remote HTTP servers.&lt;/li&gt;
&lt;li&gt;Disable or remove servers you are not actively using.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rolling it out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Individual setup
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Install the VS Code extension and Copilot CLI.&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;~/.copilot/instructions/&lt;/code&gt; and add one global instruction file. Workflow preferences and a security baseline are the highest-value starting points.&lt;/li&gt;
&lt;li&gt;Browse &lt;a href="https://github.com/github/awesome-copilot" rel="noopener noreferrer"&gt;awesome-copilot&lt;/a&gt; and copy two or three instruction files relevant to your stack.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; to your primary repo with project-specific conventions.&lt;/li&gt;
&lt;li&gt;Try agent mode for a non-trivial task to get a feel for how it behaves.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Team rollout
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Define a shared baseline in a dotfiles or inner-source repo covering instructions, agents, skills, and MCP config.&lt;/li&gt;
&lt;li&gt;Add two to four high-value project instructions per repo.&lt;/li&gt;
&lt;li&gt;Create three core agents: engineering, security, and documentation review.&lt;/li&gt;
&lt;li&gt;Add skills only for repeated, specialised workflows.&lt;/li&gt;
&lt;li&gt;Review output quality every two weeks and refine rules and prompts based on what you observe.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Keeping it improving
&lt;/h2&gt;

&lt;p&gt;Results degrade when instructions conflict, when prompts are vague, or when agents are used for everything regardless of fit. A simple quality loop prevents that drift:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Be explicit in prompts: goal, constraints, relevant files, and done criteria.&lt;/li&gt;
&lt;li&gt;Keep instructions concise and non-conflicting.&lt;/li&gt;
&lt;li&gt;Scope rules with &lt;code&gt;applyTo&lt;/code&gt; so they trigger only where needed.&lt;/li&gt;
&lt;li&gt;Use specialised agents for repeated high-value workflows.&lt;/li&gt;
&lt;li&gt;Validate with tests and lint, and feed failures back into instructions.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Quick audit checklist
&lt;/h3&gt;

&lt;p&gt;When the setup is in place, use this to evaluate it quickly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Model selection is intentional by task type.&lt;/li&gt;
&lt;li&gt;Global rules exist for workflow and security.&lt;/li&gt;
&lt;li&gt;Project rules exist for repo-specific conventions.&lt;/li&gt;
&lt;li&gt;Agent catalogue maps to real team workflows.&lt;/li&gt;
&lt;li&gt;Skills exist only for deep, repeated procedures.&lt;/li&gt;
&lt;li&gt;MCP servers are minimal, secure, and actively used.&lt;/li&gt;
&lt;li&gt;Prompts include explicit success criteria.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/github/awesome-copilot" rel="noopener noreferrer"&gt;awesome-copilot&lt;/a&gt;: community instructions, agents, skills, and prompts&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/copilot" rel="noopener noreferrer"&gt;GitHub Copilot documentation&lt;/a&gt;: official reference for all features&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli" rel="noopener noreferrer"&gt;Copilot CLI documentation&lt;/a&gt;: about the CLI, modes, and security considerations&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://marketplace.visualstudio.com/items?itemName=GitHub.copilot" rel="noopener noreferrer"&gt;VS Code Copilot extension&lt;/a&gt;: extension page with changelog and settings reference&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap up
&lt;/h2&gt;

&lt;p&gt;The model table in this guide covers the cloud providers available through a Copilot subscription. If you want to cut cloud costs further or keep sensitive work entirely on your machine, running models locally is worth exploring. My next post covers exactly that: installing and tuning Ollama on Apple Silicon, choosing models by use case, and wiring local inference into VS Code Copilot and the CLI. It goes live June 4.&lt;/p&gt;

&lt;p&gt;If this guide helped, use it as a practical checklist this week: pick one primary model, tighten your instruction layers, and remove one source of prompt churn from your workflow. Then share what changed for you in the comments, or subscribe via the form at the end of this page to get the local AI follow-up as soon as it publishes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>githubcopilot</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Adding comments to a static Astro blog with Netlify Forms</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Wed, 27 May 2026 11:48:06 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-comments-to-a-static-astro-blog-with-netlify-forms-b7d</link>
      <guid>https://dev.to/sourcier/adding-comments-to-a-static-astro-blog-with-netlify-forms-b7d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/comments-netlify-forms-astro" rel="noopener noreferrer"&gt;Adding comments to a static Astro blog with Netlify Forms&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Comments on a static site are one of those problems that sounds simple until you&lt;br&gt;
actually sit down to solve it. You've got a few options.&lt;/p&gt;

&lt;p&gt;You can reach for a third-party widget: Disqus, Commento, or Giscus. They all work,&lt;br&gt;
and Giscus in particular is clever if your readers are likely to have GitHub&lt;br&gt;
accounts. But they all introduce an external dependency you don't control, and&lt;br&gt;
most of them inject JavaScript you didn't write.&lt;/p&gt;

&lt;p&gt;You can build a full backend: a database, an API, authentication for moderation.&lt;br&gt;
That's a lot of infrastructure for what is, on a personal blog, a fairly low-volume&lt;br&gt;
use case.&lt;/p&gt;

&lt;p&gt;Or you can use what you already have. If you're hosting on Netlify, you've already&lt;br&gt;
got &lt;a href="https://docs.netlify.com/forms/setup/" rel="noopener noreferrer"&gt;Netlify Forms&lt;/a&gt; and serverless&lt;br&gt;
Functions available. The approach I settled on uses both, inspired by&lt;br&gt;
&lt;a href="https://github.com/philhawksworth/jamstack-comments-engine" rel="noopener noreferrer"&gt;Phil Hawksworth's jamstack-comments-engine&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The approach
&lt;/h2&gt;

&lt;p&gt;The system runs in four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A visitor submits the comment form. Netlify intercepts the POST and stores it
in its Forms queue. No backend code needed.&lt;/li&gt;
&lt;li&gt;A webhook triggers &lt;code&gt;comment-handler&lt;/code&gt;, which sends an email with HMAC-signed
approve and delete links.&lt;/li&gt;
&lt;li&gt;Clicking &lt;strong&gt;Approve&lt;/strong&gt; calls &lt;code&gt;approve-comment&lt;/code&gt;, which re-posts the comment data
to a second form (&lt;code&gt;approved-comments&lt;/code&gt;) and removes it from the queue.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get-comments&lt;/code&gt; reads only from &lt;code&gt;approved-comments&lt;/code&gt;, so only reviewed content
ever reaches readers.&lt;/li&gt;
&lt;/ol&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%2Fc2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBWaXNpdG9yCiAgICBwYXJ0aWNpcGFudCBFZGdlIGFzIE5ldGxpZnkgRWRnZQogICAgcGFydGljaXBhbnQgSGFuZGxlciBhcyBjb21tZW50LWhhbmRsZXIKICAgIHBhcnRpY2lwYW50IEluYm94IGFzIE15IEluYm94CiAgICBwYXJ0aWNpcGFudCBBcHByb3ZlIGFzIGFwcHJvdmUtY29tbWVudAogICAgcGFydGljaXBhbnQgR2V0Q29tbWVudHMgYXMgZ2V0LWNvbW1lbnRzCgogICAgVmlzaXRvci0-PkVkZ2U6IFBPU1QgY29tbWVudAogICAgRWRnZS0-PkVkZ2U6IFN0b3JlIGluIGJsb2ctY29tbWVudHMgcXVldWUKICAgIEVkZ2UtPj5IYW5kbGVyOiBXZWJob29rIHRyaWdnZXIKICAgIEhhbmRsZXItPj5JbmJveDogRW1haWwgd2l0aCBBcHByb3ZlIC8gRGVsZXRlIGxpbmtzCiAgICBJbmJveC0-PkFwcHJvdmU6IENsaWNrIEFwcHJvdmUKICAgIEFwcHJvdmUtPj5FZGdlOiBQT1NUIHRvIGFwcHJvdmVkLWNvbW1lbnRzCiAgICBBcHByb3ZlLT4-RWRnZTogREVMRVRFIGZyb20gcXVldWUKICAgIFZpc2l0b3ItPj5HZXRDb21tZW50czogR0VUIC9nZXQtY29tbWVudHMKICAgIEdldENvbW1lbnRzLT4-RWRnZTogRmV0Y2ggYXBwcm92ZWQtY29tbWVudHMgQVBJCiAgICBHZXRDb21tZW50cy0-PlZpc2l0b3I6IFJldHVybiBhcHByb3ZlZCBjb21tZW50cw" 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%2Fc2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBWaXNpdG9yCiAgICBwYXJ0aWNpcGFudCBFZGdlIGFzIE5ldGxpZnkgRWRnZQogICAgcGFydGljaXBhbnQgSGFuZGxlciBhcyBjb21tZW50LWhhbmRsZXIKICAgIHBhcnRpY2lwYW50IEluYm94IGFzIE15IEluYm94CiAgICBwYXJ0aWNpcGFudCBBcHByb3ZlIGFzIGFwcHJvdmUtY29tbWVudAogICAgcGFydGljaXBhbnQgR2V0Q29tbWVudHMgYXMgZ2V0LWNvbW1lbnRzCgogICAgVmlzaXRvci0-PkVkZ2U6IFBPU1QgY29tbWVudAogICAgRWRnZS0-PkVkZ2U6IFN0b3JlIGluIGJsb2ctY29tbWVudHMgcXVldWUKICAgIEVkZ2UtPj5IYW5kbGVyOiBXZWJob29rIHRyaWdnZXIKICAgIEhhbmRsZXItPj5JbmJveDogRW1haWwgd2l0aCBBcHByb3ZlIC8gRGVsZXRlIGxpbmtzCiAgICBJbmJveC0-PkFwcHJvdmU6IENsaWNrIEFwcHJvdmUKICAgIEFwcHJvdmUtPj5FZGdlOiBQT1NUIHRvIGFwcHJvdmVkLWNvbW1lbnRzCiAgICBBcHByb3ZlLT4-RWRnZTogREVMRVRFIGZyb20gcXVldWUKICAgIFZpc2l0b3ItPj5HZXRDb21tZW50czogR0VUIC9nZXQtY29tbWVudHMKICAgIEdldENvbW1lbnRzLT4-RWRnZTogRmV0Y2ggYXBwcm92ZWQtY29tbWVudHMgQVBJCiAgICBHZXRDb21tZW50cy0-PlZpc2l0b3I6IFJldHVybiBhcHByb3ZlZCBjb21tZW50cw" alt="Mermaid diagram" width="1339" height="659"&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/comments-netlify-forms-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/comments-netlify-forms-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's no database to provision, no moderation dashboard to watch, and no&lt;br&gt;
third-party script on the page. Comments don't go live until I explicitly approve&lt;br&gt;
them from my inbox.&lt;/p&gt;
&lt;h2&gt;
  
  
  The form
&lt;/h2&gt;

&lt;p&gt;Netlify detects forms at build time by scanning the static HTML for &lt;code&gt;data-netlify="true"&lt;/code&gt;.&lt;br&gt;
Because this is an Astro site, the form is a server-rendered &lt;code&gt;.astro&lt;/code&gt; component, which&lt;br&gt;
means it appears in the built HTML and Netlify registers it automatically on first deploy.&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;form&lt;/span&gt;
  &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"blog-comments"&lt;/span&gt;
  &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;
  &lt;span class="na"&gt;data-netlify=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
  &lt;span class="na"&gt;netlify-honeypot=&lt;/span&gt;&lt;span class="s"&gt;"bot-field"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"form-name"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"blog-comments"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"postSlug"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;{postId}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- honeypot --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"bot-field"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display:none"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- fields: name, email (optional), comment --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;form-name&lt;/code&gt; hidden field is required when submitting via &lt;code&gt;fetch&lt;/code&gt; rather than a
native form POST. Netlify uses it to route the payload to the right form bucket.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;postSlug&lt;/code&gt; stores the post identifier. When reading comments back, this is what ties
each submission to its post.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;netlify-honeypot="bot-field"&lt;/code&gt; attribute tells Netlify to silently drop any
submission that fills in the &lt;code&gt;bot-field&lt;/code&gt; input. Real users don't see it; bots
typically fill every field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The form submits via &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;Content-Type: application/x-www-form-urlencoded&lt;/code&gt;&lt;br&gt;
to the current page URL; Netlify intercepts those requests before they hit the origin.&lt;/p&gt;
&lt;h2&gt;
  
  
  The functions
&lt;/h2&gt;

&lt;p&gt;There are three Netlify Functions in total.&lt;/p&gt;
&lt;h3&gt;
  
  
  get-comments
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;netlify/functions/get-comments.js&lt;/code&gt; takes a &lt;code&gt;?slug=&lt;/code&gt; query param and fetches&lt;br&gt;
submissions from the &lt;code&gt;approved-comments&lt;/code&gt; form via the Netlify API, filtered by slug.&lt;/p&gt;

&lt;p&gt;Email addresses are hashed server-side before the response leaves the function; the&lt;br&gt;
raw address is never sent to the browser:&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="nx"&gt;crypto&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;node:crypto&lt;/span&gt;&lt;span class="dl"&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;gravatarHash&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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;const&lt;/span&gt; &lt;span class="nx"&gt;comments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;submissions&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;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="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;postSlug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Use the original submission date, not the approval date&lt;/span&gt;
    &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;originalDate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;emailHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="nf"&gt;gravatarHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MD5 is the hash format Gravatar's API requires; hashing also means the raw email&lt;br&gt;
address never leaves the server.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;originalDate&lt;/code&gt; rather than &lt;code&gt;created_at&lt;/code&gt; matters here: &lt;code&gt;created_at&lt;/code&gt; on an&lt;br&gt;
approved submission reflects the moment it was approved, not when the visitor&lt;br&gt;
wrote it. The approval function stamps the original queue date into &lt;code&gt;originalDate&lt;/code&gt;&lt;br&gt;
when it copies the submission across.&lt;/p&gt;

&lt;p&gt;The access token lives in an environment variable; it never touches the browser.&lt;br&gt;
The function returns an empty array if the variables aren't set, so the site&lt;br&gt;
degrades gracefully in local dev.&lt;/p&gt;
&lt;h3&gt;
  
  
  comment-handler
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;netlify/functions/comment-handler.js&lt;/code&gt; is triggered by a Netlify outgoing webhook&lt;br&gt;
whenever a new submission hits the &lt;code&gt;blog-comments&lt;/code&gt; queue. It sends an HTML email&lt;br&gt;
via &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; (the same delivery layer used for &lt;a href="https://dev.to/blog/new-post-notifications-resend"&gt;new post notifications&lt;/a&gt;) containing the comment text and two&lt;br&gt;
HMAC-SHA256-signed action links:&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="nx"&gt;crypto&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;node:crypto&lt;/span&gt;&lt;span class="dl"&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;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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;const&lt;/span&gt; &lt;span class="nx"&gt;approveToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approve&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&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;deleteToken&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;secret&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;approveUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.netlify/functions/approve-comment`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="s2"&gt;`?action=approve&amp;amp;id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;approveToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each token encodes both the submission ID and the intended action, so an approve&lt;br&gt;
token can't be replayed as a delete, and tokens for one submission don't work on&lt;br&gt;
another.&lt;/p&gt;
&lt;h3&gt;
  
  
  approve-comment
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;netlify/functions/approve-comment.js&lt;/code&gt; handles the link clicks. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verifies the HMAC token with &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; to prevent timing attacks&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;approve&lt;/strong&gt;: fetches the submission from the Netlify API, re-posts it to
&lt;code&gt;approved-comments&lt;/code&gt; with an &lt;code&gt;originalDate&lt;/code&gt; field, then deletes the pending entry&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;delete&lt;/strong&gt;: deletes the pending submission directly&lt;/li&gt;
&lt;li&gt;Returns a minimal HTML confirmation page either way
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submissionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&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;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The approve step posts to the site's own URL; Netlify's edge intercepts it and&lt;br&gt;
stores it in the &lt;code&gt;approved-comments&lt;/code&gt; bucket, exactly as it does for visitor&lt;br&gt;
submissions. No direct Netlify API write is needed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rendering comments
&lt;/h2&gt;

&lt;p&gt;Client-side JavaScript calls &lt;code&gt;/.netlify/functions/get-comments?slug={postId}&lt;/code&gt; on page&lt;br&gt;
load and renders whatever comes back.&lt;/p&gt;

&lt;p&gt;One discipline worth keeping here: never use &lt;code&gt;innerHTML&lt;/code&gt; with raw user data. Because&lt;br&gt;
the comment cards are built as an HTML template string, &lt;code&gt;innerHTML&lt;/code&gt; is unavoidable for&lt;br&gt;
inserting the full card structure, but all user-supplied values are passed through&lt;br&gt;
&lt;code&gt;escapeHtml&lt;/code&gt; before they touch the template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;#39;&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;No raw user content ever reaches the HTML parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gravatar avatars
&lt;/h2&gt;

&lt;p&gt;Each commenter gets an avatar. If they provided an email address, the &lt;code&gt;emailHash&lt;/code&gt; from&lt;br&gt;
the function is used to fetch their Gravatar. If they didn't, or if no Gravatar is&lt;br&gt;
registered, a pink circle with their initial is shown instead.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;d=404&lt;/code&gt; parameter tells Gravatar to return a 404 rather than a default image.&lt;br&gt;
&lt;code&gt;onerror&lt;/code&gt; hides the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; and the initial shows through. &lt;code&gt;onload&lt;/code&gt; hides the initial&lt;br&gt;
when a real Gravatar loads successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avatarInner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailHash&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;img src="https://www.gravatar.com/avatar/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailHash&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?s=72&amp;amp;d=404"
         onload="this.nextElementSibling.style.display='none'"
         onerror="this.style.display='none'" /&amp;gt;
     &amp;lt;span class="comment__avatar-initial"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;`&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;span class="comment__avatar-initial"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; is always in the DOM behind the image, so the fallback requires no extra&lt;br&gt;
logic.&lt;/p&gt;
&lt;h2&gt;
  
  
  Styling dynamically injected content in Astro
&lt;/h2&gt;

&lt;p&gt;This tripped me up. Astro's scoped CSS works by adding a unique attribute&lt;br&gt;
(e.g. &lt;code&gt;data-astro-cid-xxx&lt;/code&gt;) to every element it renders, and then qualifying all the&lt;br&gt;
CSS selectors with that attribute. That means the styles only match elements that were&lt;br&gt;
rendered at build time.&lt;/p&gt;

&lt;p&gt;Comment cards are injected via &lt;code&gt;innerHTML&lt;/code&gt; at runtime; they never get the scoping&lt;br&gt;
attribute. The fix is to wrap those selectors in &lt;code&gt;:global()&lt;/code&gt;:&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="cm"&gt;/* scoped — applies to server-rendered elements */&lt;/span&gt;
&lt;span class="nc"&gt;.comments__heading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* global — applies to runtime-injected elements */&lt;/span&gt;
&lt;span class="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.comment&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.comment__avatar&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything that's server-rendered stays scoped. Only the comment card classes need to&lt;br&gt;
escape scoping.&lt;/p&gt;
&lt;h2&gt;
  
  
  Registering the approved-comments form
&lt;/h2&gt;

&lt;p&gt;Netlify discovers forms by scanning built HTML at deploy time. The &lt;code&gt;blog-comments&lt;/code&gt;&lt;br&gt;
form lives in the &lt;code&gt;Comments.astro&lt;/code&gt; component, so it's found automatically. The&lt;br&gt;
&lt;code&gt;approved-comments&lt;/code&gt; form is never rendered on a page; it only receives programmatic&lt;br&gt;
POSTs from &lt;code&gt;approve-comment&lt;/code&gt;. Without an explicit registration it would never be&lt;br&gt;
created in the Netlify dashboard.&lt;/p&gt;

&lt;p&gt;The fix is a hidden placeholder form in &lt;code&gt;Comments.astro&lt;/code&gt;, alongside the visible&lt;br&gt;
&lt;code&gt;blog-comments&lt;/code&gt; form that visitors submit:&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;form&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"approved-comments"&lt;/span&gt; &lt;span class="na"&gt;data-netlify=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"postSlug"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"comment"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"originalDate"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Netlify only needs to find a form in one built page to register it. Since&lt;br&gt;
&lt;code&gt;Comments.astro&lt;/code&gt; is rendered on every blog post, the form is present in every post&lt;br&gt;
page's HTML and will be picked up on the first deploy. The &lt;code&gt;hidden&lt;/code&gt; attribute keeps&lt;br&gt;
it invisible; &lt;code&gt;aria-hidden="true"&lt;/code&gt; removes it from the accessibility tree.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting it up on Netlify
&lt;/h2&gt;

&lt;p&gt;After the first deploy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get a personal access token&lt;/strong&gt;: Netlify → User settings → Applications →
Personal access tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up a Resend account&lt;/strong&gt; and verify a sender domain (their free tier covers
3,000 emails per month, more than enough). If you already followed the
&lt;a href="https://dev.to/blog/mailing-list-astro"&gt;mailing list post&lt;/a&gt;, your Resend account and sender
domain are already configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add these environment variables&lt;/strong&gt; in Netlify → Site configuration →
Environment variables:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NETLIFY_PAT&lt;/code&gt;: personal access token from step 1. Avoid the name &lt;code&gt;NETLIFY_ACCESS_TOKEN&lt;/code&gt;; Netlify auto-overwrites it at runtime with a limited machine token&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;APPROVAL_SECRET&lt;/code&gt;: a random secret for HMAC signing
(&lt;code&gt;openssl rand -hex 32&lt;/code&gt; works well)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SITE_URL&lt;/code&gt;: the public URL, e.g. &lt;code&gt;https://sourcier.uk&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESEND_API_KEY&lt;/code&gt;: Resend API key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NOTIFY_FROM_EMAIL&lt;/code&gt;: verified Resend sender address&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NOTIFY_EMAIL&lt;/code&gt;: where to receive approval emails&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a webhook&lt;/strong&gt;: Netlify → Forms → &lt;code&gt;blog-comments&lt;/code&gt; → Form notifications →
Add notification → Outgoing webhook →
URL: &lt;code&gt;https://your-site/.netlify/functions/comment-handler&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submit a test comment&lt;/strong&gt; to create the first &lt;code&gt;approved-comments&lt;/code&gt; entry, then
copy its Form ID from the Netlify Forms dashboard URL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the final variable&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;APPROVED_COMMENTS_FORM_ID&lt;/code&gt;: form ID from step 5&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Trigger a redeploy&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After that, every new comment fires a notification email. Approve or delete it&lt;br&gt;
by clicking the link. No dashboard visit required.&lt;/p&gt;
&lt;h2&gt;
  
  
  Refreshing the list after submission
&lt;/h2&gt;

&lt;p&gt;After a successful POST, &lt;code&gt;loadComments()&lt;/code&gt; is called a second time so the list&lt;br&gt;
reflects whatever the server currently holds. Because Netlify Forms requires manual&lt;br&gt;
approval before submissions appear via the API, the newly posted comment won't show&lt;br&gt;
up immediately, but any comments approved in the meantime will, and the list stays&lt;br&gt;
in sync rather than going stale.&lt;/p&gt;

&lt;p&gt;To make the refresh feel intentional rather than jarring, &lt;code&gt;renderComments&lt;/code&gt; accepts&lt;br&gt;
an &lt;code&gt;animate&lt;/code&gt; flag. When set, the list container fades out, swaps its HTML, then&lt;br&gt;
fades back in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderComments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;animate&lt;/span&gt; &lt;span class="o"&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="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="nf"&gt;buildCommentsHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comments&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;animate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-fading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;listEl&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;animationend&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="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-fading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-entering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;listEl&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;animationend&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="nx"&gt;listEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-entering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The initial page-load call passes no flag, so the first render is instant with no&lt;br&gt;
flash. The post-submission refresh passes &lt;code&gt;animate = true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two CSS keyframes are defined in the component's scoped styles:&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="k"&gt;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;comments-fade-out&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-6px&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;@keyframes&lt;/span&gt; &lt;span class="nt"&gt;comments-fade-in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&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="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;#comments-list&lt;/span&gt;&lt;span class="nc"&gt;.is-fading&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;comments-fade-out&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.2s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt; &lt;span class="n"&gt;forwards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;:global&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;#comments-list&lt;/span&gt;&lt;span class="nc"&gt;.is-entering&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;comments-fade-in&lt;/span&gt;  &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.25s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt; &lt;span class="n"&gt;forwards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selectors need &lt;code&gt;:global()&lt;/code&gt; for the same reason comment card styles do; the list&lt;br&gt;
element is in the server-rendered HTML but the classes are toggled at runtime by&lt;br&gt;
JavaScript, so Astro's scoped-CSS attribute won't be present on the selector when the&lt;br&gt;
animation fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;The main remaining limitation is that comments don't appear immediately after&lt;br&gt;
submission; the visitor sees a "submitted for review" message and has to come&lt;br&gt;
back later to see it live. The list refreshes after submission, but an unapproved&lt;br&gt;
comment can't show up in that refresh.&lt;/p&gt;

&lt;p&gt;The cleanest fix would be to optimistically insert the pending comment into the&lt;br&gt;
DOM immediately, marked visually as "awaiting approval", and then confirm or&lt;br&gt;
remove it on the next real fetch. That adds state management I haven't needed&lt;br&gt;
yet; volume is low enough that the current UX is fine for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The full implementation spans four files: &lt;code&gt;Comments.astro&lt;/code&gt; and the three serverless&lt;br&gt;
functions. Netlify Forms handles the queue and webhook delivery, Resend sends the&lt;br&gt;
notification email, and the HMAC signing keeps approve and delete actions&lt;br&gt;
tamper-proof. Nothing goes live until I've clicked a link from my inbox, with no&lt;br&gt;
database to provision and no third-party script on the page.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Comments.astro&lt;/code&gt; component and all three functions are in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/sourcier.uk" rel="noopener noreferrer"&gt;sourcier.uk repository&lt;/a&gt; if you want to&lt;br&gt;
use them as a starting point.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Adding a mailing list to a static Astro blog with Resend</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 21 May 2026 09:48:48 +0000</pubDate>
      <link>https://dev.to/sourcier/adding-a-mailing-list-to-a-static-astro-blog-with-resend-16lb</link>
      <guid>https://dev.to/sourcier/adding-a-mailing-list-to-a-static-astro-blog-with-resend-16lb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/mailing-list-astro" rel="noopener noreferrer"&gt;Adding a mailing list to a static Astro blog with Resend&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Adding a mailing list to a static site is one of those features that looks like&lt;br&gt;
it needs a whole backend — a database of subscribers, a queue, an unsubscribe&lt;br&gt;
flow. In practice, if you're already on Netlify and already using&lt;br&gt;
&lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; for transactional email, you can bolt on a working&lt;br&gt;
subscription form in an afternoon.&lt;/p&gt;

&lt;p&gt;Here's exactly how I did it on this site.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;MailingListCTA&lt;/code&gt; Astro component that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Renders an email input and a subscribe button&lt;/li&gt;
&lt;li&gt;Submits via &lt;code&gt;fetch&lt;/code&gt; to a Netlify Function&lt;/li&gt;
&lt;li&gt;Shows inline success or error feedback without a page reload&lt;/li&gt;
&lt;li&gt;Includes a honeypot field to block bot submissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Netlify Function:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validates the email server-side&lt;/li&gt;
&lt;li&gt;Silently discards bot submissions (honeypot check)&lt;/li&gt;
&lt;li&gt;Calls the Resend Segments API to add the contact&lt;/li&gt;
&lt;/ul&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%2Fc2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBVc2VyCiAgICBwYXJ0aWNpcGFudCBGb3JtIGFzIE1haWxpbmdMaXN0Q1RBCiAgICBwYXJ0aWNpcGFudCBGbiBhcyBzdWJzY3JpYmUgZnVuY3Rpb24KICAgIHBhcnRpY2lwYW50IFJlc2VuZCBhcyBSZXNlbmQgQVBJCiAgICBVc2VyLT4-Rm9ybTogRW50ZXIgZW1haWwsIGNsaWNrIFN1YnNjcmliZQogICAgRm9ybS0-PkZuOiBQT1NUIHtlbWFpbCwgd2Vic2l0ZX0KICAgIGFsdCBIb25leXBvdCBmaWVsZCBmaWxsZWQKICAgICAgICBGbi0tPj5Gb3JtOiAyMDAgT0sgKHNpbGVudGx5IGRpc2NhcmQpCiAgICBlbHNlIEludmFsaWQgZW1haWwgZm9ybWF0CiAgICAgICAgRm4tLT4-Rm9ybTogNDAwIHtlcnJvcn0KICAgICAgICBGb3JtLT4-VXNlcjogSW5saW5lIGVycm9yIG1lc3NhZ2UKICAgIGVsc2UgVmFsaWQgZW1haWwKICAgICAgICBGbi0-PlJlc2VuZDogUE9TVCAvY29udGFjdHMgd2l0aCBzZWdtZW50IElECiAgICAgICAgUmVzZW5kLS0-PkZuOiAyMDEgQ3JlYXRlZAogICAgICAgIEZuLT4-UmVzZW5kOiBQT1NUIC9lbWFpbHMgKHdlbGNvbWUgZW1haWwpCiAgICAgICAgRm4tLT4-Rm9ybTogMjAwIHtzdWNjZXNzOiB0cnVlfQogICAgICAgIEZvcm0tPj5Vc2VyOiAiWW91J3JlIHN1YnNjcmliZWQhIgogICAgZW5k" 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%2Fc2VxdWVuY2VEaWFncmFtCiAgICBhY3RvciBVc2VyCiAgICBwYXJ0aWNpcGFudCBGb3JtIGFzIE1haWxpbmdMaXN0Q1RBCiAgICBwYXJ0aWNpcGFudCBGbiBhcyBzdWJzY3JpYmUgZnVuY3Rpb24KICAgIHBhcnRpY2lwYW50IFJlc2VuZCBhcyBSZXNlbmQgQVBJCiAgICBVc2VyLT4-Rm9ybTogRW50ZXIgZW1haWwsIGNsaWNrIFN1YnNjcmliZQogICAgRm9ybS0-PkZuOiBQT1NUIHtlbWFpbCwgd2Vic2l0ZX0KICAgIGFsdCBIb25leXBvdCBmaWVsZCBmaWxsZWQKICAgICAgICBGbi0tPj5Gb3JtOiAyMDAgT0sgKHNpbGVudGx5IGRpc2NhcmQpCiAgICBlbHNlIEludmFsaWQgZW1haWwgZm9ybWF0CiAgICAgICAgRm4tLT4-Rm9ybTogNDAwIHtlcnJvcn0KICAgICAgICBGb3JtLT4-VXNlcjogSW5saW5lIGVycm9yIG1lc3NhZ2UKICAgIGVsc2UgVmFsaWQgZW1haWwKICAgICAgICBGbi0-PlJlc2VuZDogUE9TVCAvY29udGFjdHMgd2l0aCBzZWdtZW50IElECiAgICAgICAgUmVzZW5kLS0-PkZuOiAyMDEgQ3JlYXRlZAogICAgICAgIEZuLT4-UmVzZW5kOiBQT1NUIC9lbWFpbHMgKHdlbGNvbWUgZW1haWwpCiAgICAgICAgRm4tLT4-Rm9ybTogMjAwIHtzdWNjZXNzOiB0cnVlfQogICAgICAgIEZvcm0tPj5Vc2VyOiAiWW91J3JlIHN1YnNjcmliZWQhIgogICAgZW5k" alt="Mermaid diagram" width="1013" height="764"&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/mailing-list-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/mailing-list-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Setting up Resend Segments
&lt;/h2&gt;

&lt;p&gt;Resend recently migrated from Audiences to&lt;br&gt;
&lt;a href="https://resend.com/docs/api-reference/segments/create-segment" rel="noopener noreferrer"&gt;Segments&lt;/a&gt; — Audiences&lt;br&gt;
still work but are deprecated and will be removed. The concept is the same: a&lt;br&gt;
named list of contacts you can send broadcasts to.&lt;/p&gt;

&lt;p&gt;Create a segment in the Resend dashboard. Once created, copy the segment ID —&lt;br&gt;
you'll need it as an environment variable.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Netlify Function
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;netlify/functions/subscribe.ts&lt;/code&gt;. The function receives a &lt;code&gt;POST&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;{ email, website }&lt;/code&gt; in the body. The &lt;code&gt;website&lt;/code&gt; field is the honeypot.&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="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;HandlerEvent&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;@netlify/functions&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;ALLOWED_ORIGIN&lt;/span&gt; &lt;span class="o"&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;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/&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="o"&gt;??&lt;/span&gt; &lt;span class="dl"&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;isValidEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[^\s&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+@&lt;/span&gt;&lt;span class="se"&gt;[^\s&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.[^\s&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&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;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HandlerEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="o"&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;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&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;POST, OPTIONS&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;Access-Control-Allow-Headers&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;Content-Type&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OPTIONS&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Method not allowed&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segmentId&lt;/span&gt; &lt;span class="o"&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_SEGMENT_ID&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;segmentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscribe: RESEND_API_KEY or RESEND_SEGMENT_ID is not set&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Server configuration error&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="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&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="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;website&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid request body&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;honeypot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt; &lt;span class="o"&gt;??&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;honeypot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&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="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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isValidEmail&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A valid email address is required&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.resend.com/contacts`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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;unsubscribed&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;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;segmentId&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;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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;errorBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`subscribe: Resend API error &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;errorBody&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not subscribe. Please try again later.&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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="nx"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;success&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="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;strong&gt;No Resend SDK&lt;/strong&gt; — calling the REST API directly with &lt;code&gt;fetch&lt;/code&gt; keeps the
function dependency-free and fast to cold-start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS headers&lt;/strong&gt; — the function sets &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; to
&lt;code&gt;SITE_URL&lt;/code&gt; from environment, with an &lt;code&gt;OPTIONS&lt;/code&gt; preflight handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honeypot is silently accepted&lt;/strong&gt; — returning &lt;code&gt;200&lt;/code&gt; when the honeypot is
filled means bots get no signal that they were caught. Returning &lt;code&gt;400&lt;/code&gt; would
tell them to try again without the field.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Welcome email
&lt;/h3&gt;

&lt;p&gt;After the contact is successfully added, the function sends a welcome email&lt;br&gt;
using &lt;code&gt;POST /emails&lt;/code&gt;. The send is done with &lt;code&gt;.catch()&lt;/code&gt; so a failure doesn't&lt;br&gt;
break the subscription response.&lt;/p&gt;

&lt;p&gt;Rather than embedding HTML directly in the function, the welcome email is stored&lt;br&gt;
as a &lt;a href="https://resend.com/docs/dashboard/templates/introduction" rel="noopener noreferrer"&gt;Resend template&lt;/a&gt;.&lt;br&gt;
This means you can edit the email copy in the Resend dashboard without touching&lt;br&gt;
or redeploying the function.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;RESEND_WELCOME_TEMPLATE_ID&lt;/code&gt; is set, the function sends via the template.&lt;br&gt;
Otherwise it falls back to inline HTML, so the function keeps working before&lt;br&gt;
you've set up the template.&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;emailPayload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;welcomeTemplateId&lt;/span&gt;
  &lt;span class="p"&gt;?&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="s2"&gt;`Sourcier &amp;lt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fromEmail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;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="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;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;welcomeTemplateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;BLOG_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="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="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Sourcier &amp;lt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fromEmail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;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="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;You're subscribed to Sourcier&lt;/span&gt;&lt;span class="dl"&gt;"&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="s2"&gt;`&amp;lt;div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:560px;margin:0 auto;padding:2rem 1.5rem;color:#0f0f0f"&amp;gt;
  &amp;lt;p style="font-size:1.5rem;font-weight:800;text-transform:uppercase;letter-spacing:0.02em;margin:0 0 1rem"&amp;gt;Welcome to Sourcier&amp;lt;/p&amp;gt;
  &amp;lt;p style="margin:0 0 1rem;line-height:1.6"&amp;gt;Thanks for signing up. You'll get an email whenever I publish something new — engineering deep-dives, lessons from the field, and the occasional opinion.&amp;lt;/p&amp;gt;
  &amp;lt;p style="margin:0 0 1.5rem;line-height:1.6"&amp;gt;In the meantime, browse the &amp;lt;a href="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog" style="color:#e8006a"&amp;gt;blog&amp;lt;/a&amp;gt; to see what's already there.&amp;lt;/p&amp;gt;
  &amp;lt;p style="margin:0;color:#6b6b6b;font-size:0.875rem"&amp;gt;You can unsubscribe at any time by replying to this email.&amp;lt;/p&amp;gt;
&amp;lt;/div&amp;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;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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://api.resend.com/emails&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailPayload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscribe: welcome email failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;template&lt;/code&gt; and &lt;code&gt;html&lt;/code&gt; are mutually exclusive — Resend returns a&lt;br&gt;
validation error if you include both. The template must also be &lt;strong&gt;published&lt;/strong&gt; in&lt;br&gt;
the Resend dashboard before it can be used; draft templates won't send.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;fromEmail&lt;/code&gt; guard means the function still works in local dev without&lt;br&gt;
&lt;code&gt;NOTIFY_FROM_EMAIL&lt;/code&gt; set — it simply skips the welcome email.&lt;/p&gt;
&lt;h3&gt;
  
  
  Creating the template with a script
&lt;/h3&gt;

&lt;p&gt;Rather than manually creating the template in the Resend dashboard, the repo&lt;br&gt;
includes a setup script at &lt;code&gt;scripts/create-welcome-template.js&lt;/code&gt;. Run it once&lt;br&gt;
after cloning:&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="nv"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;re_xxx node scripts/create-welcome-template.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checks whether a template with the alias &lt;code&gt;sourcier-welcome&lt;/code&gt; already exists&lt;/li&gt;
&lt;li&gt;If it does — updates it with &lt;code&gt;PATCH /templates/:id&lt;/code&gt; and re-publishes&lt;/li&gt;
&lt;li&gt;If it doesn't — creates it with &lt;code&gt;POST /templates&lt;/code&gt; and publishes&lt;/li&gt;
&lt;li&gt;Prints the template ID to copy into your Netlify env vars&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The alias acts as a stable lookup key, so running the script again on future&lt;br&gt;
edits updates the template in-place rather than creating duplicates. After&lt;br&gt;
publishing via the script, email sends using the template will immediately use&lt;br&gt;
the updated version.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Astro component
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5xwll61ylprcrblif6wf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5xwll61ylprcrblif6wf.png" alt="Mailing list subscribe form wireframe showing four states side by side: default with email input and Subscribe button, loading with spinner, success with checkmark, and error with inline message" width="700" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Click the expand icon to view it fullscreen.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;MailingListCTA&lt;/code&gt; component is a dark card that sits at content width on any&lt;br&gt;
page. The submit logic lives in a shared &lt;code&gt;subscribeForm.ts&lt;/code&gt; utility so both the&lt;br&gt;
full-width card and the sidebar component use the same behaviour without&lt;br&gt;
duplicating code.&lt;/p&gt;
&lt;h3&gt;
  
  
  Honeypot field
&lt;/h3&gt;

&lt;p&gt;The honeypot is a text input that is visually hidden using CSS — positioned&lt;br&gt;
off-screen, not just &lt;code&gt;display: none&lt;/code&gt;, because some bots skip fields hidden that&lt;br&gt;
way.&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;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mailing-cta__honeypot"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"mailing-cta-website"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Leave this blank&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"mailing-cta-website"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"website"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.mailing-cta__honeypot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-9999px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tabindex="-1"&lt;/code&gt; ensures keyboard users and screen readers can't reach it.&lt;br&gt;
&lt;code&gt;aria-hidden="true"&lt;/code&gt; on the wrapper removes it from the accessibility tree&lt;br&gt;
entirely.&lt;/p&gt;
&lt;h3&gt;
  
  
  Shared form utility
&lt;/h3&gt;

&lt;p&gt;The submit handler lives in &lt;code&gt;src/utils/subscribeForm.ts&lt;/code&gt;. Both &lt;code&gt;MailingListCTA&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;MailingListCTASidebar&lt;/code&gt; call &lt;code&gt;bindSubscribeForm()&lt;/code&gt; with a config object that&lt;br&gt;
maps DOM IDs to CSS class names and copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SubscribeFormConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;formId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;emailId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;feedbackClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;feedbackSuccessClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;feedbackErrorClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;successLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;defaultButtonLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;source&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bindSubscribeForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SubscribeFormConfig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&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;form&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="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;formId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLFormElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&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;[aria-live]&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="kc"&gt;null&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;input&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="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;form&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;form&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;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&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;button[type=submit]&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Subscribing…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbackClass&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/.netlify/functions/subscribe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&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;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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;website&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;namedItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&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="p"&gt;});&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;successLabel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbackSuccessClass&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&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;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You're in&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;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbackErrorClass&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;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultButtonLabel&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedbackErrorClass&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;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultButtonLabel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&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;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;strong&gt;&lt;code&gt;feedback.className&lt;/code&gt; is reset&lt;/strong&gt; on each submission so a previous success or
error class doesn't carry over if the user submits again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;btn.textContent = "You're in"&lt;/code&gt;&lt;/strong&gt; on success locks the button with a
confirmation label so the user knows the action was recorded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source&lt;/code&gt;&lt;/strong&gt; is an optional field passed through to the function body, giving
a hook for tracking which page the subscriber came from.&lt;/li&gt;
&lt;li&gt;The feedback element has &lt;code&gt;aria-live="polite"&lt;/code&gt; so screen readers announce the
outcome. It starts &lt;code&gt;hidden&lt;/code&gt; so it takes up no space until there's something to show.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;Add these in the Netlify dashboard under &lt;strong&gt;Site configuration → Environment&lt;br&gt;
variables&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESEND_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Resend API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESEND_SEGMENT_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The segment ID from the Resend dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOTIFY_FROM_EMAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verified sender address, e.g. &lt;code&gt;hello@sourcier.uk&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SITE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your public site URL, e.g. &lt;code&gt;https://sourcier.uk&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESEND_WELCOME_TEMPLATE_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Template ID printed by &lt;code&gt;scripts/create-welcome-template.js&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESEND_TOPIC_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional — scopes broadcasts to a specific topic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;RESEND_API_KEY&lt;/code&gt; is likely already set if you're using Resend for other&lt;br&gt;
notifications on the same site. &lt;code&gt;RESEND_WELCOME_TEMPLATE_ID&lt;/code&gt; and &lt;code&gt;RESEND_TOPIC_ID&lt;/code&gt;&lt;br&gt;
are optional — the function falls back to inline HTML if the template ID is absent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Adding the component to pages
&lt;/h2&gt;

&lt;p&gt;Import and drop the component wherever you want the CTA to appear:&lt;br&gt;
&lt;/p&gt;

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

&amp;lt;!-- rest of page --&amp;gt;
&amp;lt;MailingListCTA /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added it to blog posts, guide pages, tag pages, and the standalone pages —&lt;br&gt;
home, blog index, about, and contact.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sidebar variant
&lt;/h2&gt;

&lt;p&gt;Blog post pages have a sticky sidebar that shows the table of contents. A&lt;br&gt;
full-width card below the article felt like too much repetition, so I also built&lt;br&gt;
a compact &lt;code&gt;MailingListCTASidebar&lt;/code&gt; component that sits below the ToC and shares&lt;br&gt;
the same Netlify Function.&lt;/p&gt;

&lt;p&gt;The sidebar variant is a self-contained dark card with the same form logic,&lt;br&gt;
but uses &lt;code&gt;display: block; width: 100%&lt;/code&gt; for the input and button rather than a&lt;br&gt;
side-by-side layout.&lt;br&gt;
&lt;/p&gt;

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

&amp;lt;aside class="post__sidebar"&amp;gt;
  &amp;lt;nav class="toc"&amp;gt;&amp;lt;!-- ... --&amp;gt;&amp;lt;/nav&amp;gt;
  &amp;lt;MailingListCTASidebar /&amp;gt;
&amp;lt;/aside&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Dark mode theming
&lt;/h2&gt;

&lt;p&gt;The card background is hardcoded to &lt;code&gt;#0f0f0f&lt;/code&gt; rather than&lt;br&gt;
&lt;code&gt;var(--color-ink)&lt;/code&gt;. This is intentional — &lt;code&gt;--color-ink&lt;/code&gt; flips to &lt;code&gt;#f0f0f0&lt;/code&gt; in&lt;br&gt;
dark mode (it's the text colour token), so using it for a background produces a&lt;br&gt;
near-white card. The footer on this site has the same issue and uses the same&lt;br&gt;
fix.&lt;/p&gt;

&lt;p&gt;To make the card visible in dark mode where the page background is &lt;code&gt;#111111&lt;/code&gt;, I&lt;br&gt;
added a pink top border and a subtle edge border:&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;.mailing-cta__card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#0f0f0f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="mi"&gt;.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;color-pink&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.06&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.06&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="mi"&gt;.06&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pink top border serves as the primary visual anchor in both modes. In light&lt;br&gt;
mode the contrast between in the dark card and white page does the work; in dark&lt;br&gt;
mode the subtle borders define the card edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Resend handles for you
&lt;/h2&gt;

&lt;p&gt;Once a contact is in your audience, Resend takes care of the rest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate contacts&lt;/strong&gt; — adding the same email again updates the existing
record rather than creating a duplicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsubscribe management&lt;/strong&gt; — you can send broadcasts with unsubscribe links
built in, and Resend updates the contact's &lt;code&gt;unsubscribed&lt;/code&gt; flag automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broadcasts&lt;/strong&gt; — send to the full audience from the Resend dashboard or via the
&lt;code&gt;POST /broadcasts&lt;/code&gt; API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The free tier covers 3,000 emails per month and 100 contacts in audiences, which&lt;br&gt;
is plenty for a personal blog getting started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The full implementation is around 200 lines across three files: &lt;code&gt;subscribe.ts&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;subscribeForm.ts&lt;/code&gt;, and the two Astro components. Resend handles deduplication,&lt;br&gt;
unsubscribe management, and broadcast delivery, keeping the site code lean.&lt;/p&gt;

&lt;p&gt;If you're already using Resend for comment notifications, the only new piece is&lt;br&gt;
&lt;code&gt;subscribe.ts&lt;/code&gt;. The welcome email script is a one-off setup, and the components&lt;br&gt;
drop in wherever a CTA makes sense.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Scheduled publishing in Astro on Netlify</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 21 May 2026 09:48:39 +0000</pubDate>
      <link>https://dev.to/sourcier/scheduled-publishing-in-astro-on-netlify-52oc</link>
      <guid>https://dev.to/sourcier/scheduled-publishing-in-astro-on-netlify-52oc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/scheduled-publishing-astro" rel="noopener noreferrer"&gt;Scheduled publishing in Astro on Netlify&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Static sites have an elegant deployment story right up until you need to publish&lt;br&gt;
something on a specific date. A CMS solves this with a "schedule" button. A&lt;br&gt;
database-backed blog solves this with a query clause. A static site rebuilds once&lt;br&gt;
at deploy time — after that, nothing changes until the next deploy.&lt;/p&gt;

&lt;p&gt;For most personal blogs that's fine. Mine has posts queued weeks ahead with&lt;br&gt;
deliberate publish dates, so letting it drift wasn't an option.&lt;/p&gt;

&lt;p&gt;The solution has three parts: a helper that knows whether a post is visible &lt;em&gt;right&lt;br&gt;
now&lt;/em&gt;, a scheduled function that triggers a daily rebuild, and a cron expression&lt;br&gt;
chosen so the build always fires before 9am UK time. None of it requires a CMS or&lt;br&gt;
a database.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem with &lt;code&gt;draft: false&lt;/code&gt; alone
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;draft&lt;/code&gt; field already keeps in-progress posts off the live site. But &lt;code&gt;draft&lt;/code&gt;&lt;br&gt;
is a binary flag set at write time — you have to remember to flip it, and the post&lt;br&gt;
goes live on the next deploy, not at a predictable time.&lt;/p&gt;

&lt;p&gt;What's needed is a second condition: the post's &lt;code&gt;pubDate&lt;/code&gt; must be in the past&lt;br&gt;
before it appears. The build already has access to the current time, so this is a&lt;br&gt;
straightforward filter.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;code&gt;isPublished()&lt;/code&gt; — one filter to rule them all
&lt;/h2&gt;

&lt;p&gt;Every page and component that calls &lt;code&gt;getCollection("posts")&lt;/code&gt; needs to apply the&lt;br&gt;
same logic. The cleanest way to enforce this is a shared helper in&lt;br&gt;
&lt;code&gt;src/utils/drafts.ts&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="c1"&gt;// Drafts are hidden by default. `pnpm dev` enables them locally via SHOW_DRAFTS=true.&lt;/span&gt;
&lt;span class="c1"&gt;// Also enabled in production when SHOW_DRAFTS=true (used by the preview branch deploy).&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;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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt; &lt;span class="o"&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PublicationStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scheduled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;published&lt;/span&gt;&lt;span class="dl"&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;getPublicationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;input&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="nx"&gt;PublicationData&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;input&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;input&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;function&lt;/span&gt; &lt;span class="nf"&gt;getPublicationStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;input&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="nx"&gt;PublicationData&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;PublicationData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PublicationStatus&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getPublicationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&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;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;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scheduled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPubliclyPublished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getPublicationStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;published&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="c1"&gt;// Returns true for posts that should be visible at build/request time.&lt;/span&gt;
&lt;span class="c1"&gt;// Hides drafts (unless showDrafts) and posts whose pubDate is in the future.&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;return&lt;/span&gt; &lt;span class="nf"&gt;isPubliclyPublished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isPublished&lt;/code&gt; replaces every inline draft check across the codebase. Before&lt;br&gt;
this, each call site had a slightly different spelling of the same test — and&lt;br&gt;
none of them checked the date:&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="c1"&gt;// Before — only checked draft, missed pubDate entirely&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// After — consistent and date-aware&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;The call sites appear in pages, paginated routes, tag pages, and sidebar&lt;br&gt;
components — nine files in total. Replacing them all at once means there is no&lt;br&gt;
path through the build where a future-dated post can slip through.&lt;/p&gt;

&lt;p&gt;Two functions in &lt;code&gt;drafts.ts&lt;/code&gt; are worth keeping straight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isPublished&lt;/code&gt;&lt;/strong&gt; — use this for rendering post lists. When &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt;
(set by default when you run &lt;code&gt;pnpm dev&lt;/code&gt;), it passes through drafts and scheduled
posts so you can preview queued content locally. On production builds it hides
both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isPubliclyPublished&lt;/code&gt;&lt;/strong&gt; — use this anywhere that must reflect strict public
state regardless of preview mode: RSS feeds, post counts, sitemaps. It always
behaves as if &lt;code&gt;SHOW_DRAFTS&lt;/code&gt; is off.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Setting &lt;code&gt;pubDate&lt;/code&gt; values
&lt;/h2&gt;

&lt;p&gt;For the filter to work predictably, &lt;code&gt;pubDate&lt;/code&gt; values need to be straightforward&lt;br&gt;
UTC timestamps with no offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-13T00:00:00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A date like &lt;code&gt;2026-04-13T09:00:00+01:00&lt;/code&gt; evaluates to &lt;code&gt;08:00 UTC&lt;/code&gt;. If the build&lt;br&gt;
fires at &lt;code&gt;07:45 UTC&lt;/code&gt;, the post will not appear until the following day's build —&lt;br&gt;
one day late and silently wrong. Midnight UTC removes this class of error entirely.&lt;/p&gt;

&lt;p&gt;If two posts share the same date and you care about their sort order, a short&lt;br&gt;
offset keeps them before the build window and in the intended sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Appears first in descending sort (higher timestamp)&lt;/span&gt;
&lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-03-30T00:10:00&lt;/span&gt;

&lt;span class="c1"&gt;# Appears second&lt;/span&gt;
&lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-03-30T00:00:00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The scheduled Netlify function
&lt;/h2&gt;

&lt;p&gt;Astro builds the site once at deploy time. To have it pick up newly-eligible posts&lt;br&gt;
each day, we need to trigger a fresh deploy on a schedule.&lt;/p&gt;

&lt;p&gt;Netlify supports this natively: a function declared with a &lt;code&gt;schedule&lt;/code&gt; in&lt;br&gt;
&lt;code&gt;netlify.toml&lt;/code&gt; runs as a cron job. Our function's only job is to call the Netlify&lt;br&gt;
build hook API:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&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;hookId&lt;/span&gt; &lt;span class="o"&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;BUILD_HOOK_ID&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;hookId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUILD_HOOK_ID is not set — skipping scheduled build.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.netlify.com/build_hooks/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hookId&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Scheduled build triggered successfully.&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed to trigger build: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusText&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No npm packages needed — the function uses the standard &lt;code&gt;fetch&lt;/code&gt; and an environment&lt;br&gt;
variable for the hook ID.&lt;/p&gt;
&lt;h2&gt;
  
  
  netlify.toml configuration
&lt;/h2&gt;

&lt;p&gt;The schedule is declared alongside the function configuration in &lt;code&gt;netlify.toml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[functions]&lt;/span&gt;
  &lt;span class="py"&gt;directory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"netlify/functions"&lt;/span&gt;
  &lt;span class="py"&gt;node_bundler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"esbuild"&lt;/span&gt;

&lt;span class="c"&gt;# Rebuild daily so future-dated posts go live automatically.&lt;/span&gt;
&lt;span class="c"&gt;# Requires BUILD_HOOK_ID env var — see netlify/functions/scheduled-build.mjs.&lt;/span&gt;
&lt;span class="nn"&gt;[functions."scheduled-build"]&lt;/span&gt;
  &lt;span class="py"&gt;schedule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"45 7 * * *"&lt;/span&gt; &lt;span class="c"&gt;# always before 09:00 UK time: 07:45 GMT in winter, 08:45 BST in summer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Netlify's cron syntax is always UTC. &lt;code&gt;45 7 * * *&lt;/code&gt; fires at 07:45 UTC — before&lt;br&gt;
09:00 in both BST (UTC+1) and GMT (UTC+0). If you'd rather guarantee 08:45 BST&lt;br&gt;
and accept 07:45 GMT in winter, the expression is the same — there is no&lt;br&gt;
timezone-aware option in cron, so you pick the UTC value that satisfies your&lt;br&gt;
worst case.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dashboard setup
&lt;/h2&gt;

&lt;p&gt;One step in the Netlify dashboard is required:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Site configuration → Build &amp;amp; deploy → Build hooks&lt;/strong&gt; — create a hook named
"Scheduled publish". Netlify generates a URL ending in a unique ID.&lt;/li&gt;
&lt;li&gt;Copy just the ID from the URL (the path segment after &lt;code&gt;/build_hooks/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site configuration → Environment variables&lt;/strong&gt; — add a new variable:

&lt;ul&gt;
&lt;li&gt;Key: &lt;code&gt;BUILD_HOOK_ID&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Value: the ID you copied&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;BUILD_HOOK_ID&lt;/code&gt; to your local &lt;code&gt;.env.example&lt;/code&gt; (without a value) so it's
documented for anyone cloning the repository.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The function reads this variable and constructs the full URL itself, so the secret&lt;br&gt;
is never hardcoded in the repository.&lt;/p&gt;
&lt;h2&gt;
  
  
  Verifying the setup
&lt;/h2&gt;

&lt;p&gt;Before waiting for the next scheduled run, confirm everything is wired up&lt;br&gt;
correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger a build manually.&lt;/strong&gt; POST to the hook URL directly from your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.netlify.com/build_hooks/YOUR_HOOK_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;YOUR_HOOK_ID&lt;/code&gt; with the ID you copied. Netlify responds with &lt;code&gt;{}&lt;/code&gt; and a&lt;br&gt;
200 — check the Deploys tab in the dashboard to confirm a build starts within a&lt;br&gt;
few seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check function logs after a scheduled run.&lt;/strong&gt; Once the cron fires, Netlify logs&lt;br&gt;
the function's output under &lt;strong&gt;Functions&lt;/strong&gt; in the dashboard. Select&lt;br&gt;
&lt;code&gt;scheduled-build&lt;/code&gt; and look for &lt;code&gt;Scheduled build triggered successfully.&lt;/code&gt; in the&lt;br&gt;
invocation log. If &lt;code&gt;BUILD_HOOK_ID&lt;/code&gt; is missing or misconfigured, the error message&lt;br&gt;
from the early return will appear there instead.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;

&lt;p&gt;A post ready to publish looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;next&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;post"&lt;/span&gt;
&lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-20T00:00:00&lt;/span&gt;
&lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push to &lt;code&gt;main&lt;/code&gt;. Netlify deploys immediately — because &lt;code&gt;pubDate&lt;/code&gt; is in the future,&lt;br&gt;
&lt;code&gt;isPublished&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; and the post is excluded from every page. On the&lt;br&gt;
morning of April 20th, the &lt;code&gt;scheduled-build&lt;/code&gt; function fires at 07:45 UTC, triggers&lt;br&gt;
a new deploy, and &lt;code&gt;isPublished&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;. The post goes live without any&lt;br&gt;
manual intervention.&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%2FZmxvd2NoYXJ0IExSCiAgICBDUk9OWyJDcm9uIHRyaWdnZXJcbjA3OjQ1IFVUQyBkYWlseSJdIC0tPiBGTlsic2NoZWR1bGVkLWJ1aWxkXG5OZXRsaWZ5IGZ1bmN0aW9uIl0KICAgIEZOIC0tPiBIT09LWyJQT1NUIGJ1aWxkIGhvb2tcbk5ldGxpZnkgQVBJIl0KICAgIEhPT0sgLS0-IEJVSUxEWyJOZXRsaWZ5IHJlYnVpbGRcbmFzdHJvIGJ1aWxkIl0KICAgIEJVSUxEIC0tPiBGSUxURVJbImlzUHVibGlzaGVkKClcbmRyYWZ0OiBmYWxzZSBBTkQgcHViRGF0ZSA8PSBub3ciXQogICAgRklMVEVSIC0tPnx0cnVlfCBMSVZFWyJQb3N0IGFwcGVhcnNcbm9uIENETiJdCiAgICBGSUxURVIgLS0-fGZhbHNlfCBXQUlUWyJQb3N0IGhpZGRlblxudW50aWwgbmV4dCBidWlsZCJd" 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%2FZmxvd2NoYXJ0IExSCiAgICBDUk9OWyJDcm9uIHRyaWdnZXJcbjA3OjQ1IFVUQyBkYWlseSJdIC0tPiBGTlsic2NoZWR1bGVkLWJ1aWxkXG5OZXRsaWZ5IGZ1bmN0aW9uIl0KICAgIEZOIC0tPiBIT09LWyJQT1NUIGJ1aWxkIGhvb2tcbk5ldGxpZnkgQVBJIl0KICAgIEhPT0sgLS0-IEJVSUxEWyJOZXRsaWZ5IHJlYnVpbGRcbmFzdHJvIGJ1aWxkIl0KICAgIEJVSUxEIC0tPiBGSUxURVJbImlzUHVibGlzaGVkKClcbmRyYWZ0OiBmYWxzZSBBTkQgcHViRGF0ZSA8PSBub3ciXQogICAgRklMVEVSIC0tPnx0cnVlfCBMSVZFWyJQb3N0IGFwcGVhcnNcbm9uIENETiJdCiAgICBGSUxURVIgLS0-fGZhbHNlfCBXQUlUWyJQb3N0IGhpZGRlblxudW50aWwgbmV4dCBidWlsZCJd" alt="Mermaid diagram" width="1844" height="198"&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/scheduled-publishing-astro" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/scheduled-publishing-astro&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where &lt;code&gt;draft&lt;/code&gt; still fits in
&lt;/h2&gt;

&lt;p&gt;With scheduled publishing in place, &lt;code&gt;draft&lt;/code&gt; and &lt;code&gt;pubDate&lt;/code&gt; serve two distinct roles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;draft: true&lt;/code&gt;&lt;/strong&gt; means the post isn't ready — you're still writing it, it might&lt;br&gt;
be half-finished, and you don't want it visible even in a deploy preview. It hides&lt;br&gt;
the post indefinitely regardless of its date. Running &lt;code&gt;pnpm dev&lt;/code&gt; reveals it locally&lt;br&gt;
(&lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt; is set by default in the dev script). Nothing goes live until&lt;br&gt;
you explicitly flip the flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;draft: false&lt;/code&gt; with a future &lt;code&gt;pubDate&lt;/code&gt;&lt;/strong&gt; means the post is complete and queued.&lt;br&gt;
You're done writing, you're happy with it, and you want it to go live on a specific&lt;br&gt;
date without any further action from you.&lt;/p&gt;

&lt;p&gt;The practical workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start writing → &lt;code&gt;draft: true&lt;/code&gt;, no &lt;code&gt;pubDate&lt;/code&gt; needed yet&lt;/li&gt;
&lt;li&gt;Finish writing → &lt;code&gt;draft: false&lt;/code&gt;, set a future &lt;code&gt;pubDate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Push → the post sits invisibly in the repository until its date arrives&lt;/li&gt;
&lt;li&gt;Morning of the publish date → the scheduled build picks it up automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The only thing to be careful about: if you push &lt;code&gt;draft: false&lt;/code&gt; with a &lt;em&gt;past&lt;/em&gt;&lt;br&gt;
&lt;code&gt;pubDate&lt;/code&gt;, the post goes live immediately on that deploy rather than waiting for&lt;br&gt;
the next scheduled build. Past dates are treated as "already due", not scheduled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Minute-precision timing.&lt;/strong&gt; Builds take a minute or two, so "publish on April&lt;br&gt;
20th" means "publish sometime between 07:45 and ~08:00 UTC on April 20th". For a&lt;br&gt;
personal blog that's entirely fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build deduplication.&lt;/strong&gt; If you push a code change on the same morning, Netlify&lt;br&gt;
may queue two builds back to back. Both would produce the correct result — the&lt;br&gt;
second one is just redundant. You could add a check in the function to skip the&lt;br&gt;
trigger if a recent deploy already exists, but it's rarely worth the complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unpublishing.&lt;/strong&gt; Moving a post's &lt;code&gt;pubDate&lt;/code&gt; forward while keeping &lt;code&gt;draft: false&lt;/code&gt;&lt;br&gt;
will not remove it from the live site because Netlify serves the last successful&lt;br&gt;
build until a new one is deployed. Drafts (&lt;code&gt;draft: true&lt;/code&gt;) are the right tool for&lt;br&gt;
keeping content off the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;With the three pieces in place, scheduled publishing runs without any manual&lt;br&gt;
intervention. &lt;code&gt;drafts.ts&lt;/code&gt; gives you &lt;code&gt;isPublished&lt;/code&gt; for filtering post lists and&lt;br&gt;
&lt;code&gt;isPubliclyPublished&lt;/code&gt; for feeds and counts. The scheduled function fires daily&lt;br&gt;
at the time you configured and triggers a fresh build. The build hook in the&lt;br&gt;
Netlify dashboard is the only setup step that lives outside the repository.&lt;/p&gt;

&lt;p&gt;The authoring workflow reduces to: write the post, set &lt;code&gt;draft: false&lt;/code&gt; with a&lt;br&gt;
future &lt;code&gt;pubDate&lt;/code&gt;, push, and walk away. The scheduled build on publish day takes&lt;br&gt;
care of the rest.&lt;/p&gt;

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

&lt;p&gt;If you're building a content pipeline, a scheduled job, or anything that needs&lt;br&gt;
reliable deploy automation — I'm available for consulting. &lt;a href="https://dev.to/contact"&gt;Get in touch via the&lt;br&gt;
contact page&lt;/a&gt; and tell me what you're working on.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Sending new post notifications with Resend</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Fri, 15 May 2026 13:48:00 +0000</pubDate>
      <link>https://dev.to/sourcier/sending-new-post-notifications-with-resend-5ghp</link>
      <guid>https://dev.to/sourcier/sending-new-post-notifications-with-resend-5ghp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/new-post-notifications-resend" rel="noopener noreferrer"&gt;Sending new post notifications with Resend&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;When a new post goes live, I want subscribers to know about it. The mailing list&lt;br&gt;
runs through &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; — subscribers are stored in a Resend&lt;br&gt;
Segment, and broadcasting to them means calling the Resend Broadcasts API. The&lt;br&gt;
question was: how do I trigger that broadcast as part of the publish flow, without&lt;br&gt;
adding complexity to the build pipeline?&lt;/p&gt;

&lt;p&gt;The answer is a standalone Node.js script — &lt;code&gt;scripts/notify-new-post.js&lt;/code&gt; — that&lt;br&gt;
runs manually after publishing. It reads frontmatter directly, builds an HTML email,&lt;br&gt;
previews it in the terminal, and asks for confirmation before sending.&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%2FZmxvd2NoYXJ0IFRECiAgICBBWyJub2RlIHNjcmlwdHMvbm90aWZ5LW5ldy1wb3N0LmpzIl0gLS0-IEJbIlNjYW4gcG9zdCBkaXJlY3Rvcmllc1xucGFyc2UgZnJvbnRtYXR0ZXIiXQogICAgQiAtLT4gQ1siRmlsdGVyOiBkcmFmdCA9PSBmYWxzZVxucHViRGF0ZSBpbiBwYXN0IHdlZWsiXQogICAgQyAtLT4gRFsiUHJvbXB0OiBzZWxlY3QgcG9zdCJdCiAgICBEIC0tPiBFWyJCdWlsZCBIVE1MICsgcGxhaW4gdGV4dFxuaW5saW5lIENTUyBzdHlsZXMiXQogICAgRSAtLT4gRlsiUHJpbnQgcHJldmlldyB0byB0ZXJtaW5hbCJdCiAgICBGIC0tPiBHeyJDb25maXJtIHNlbmQ_IHkvTiJ9CiAgICBHIC0tPnx5fCBIWyJQT1NUIC9icm9hZGNhc3RzXG5DcmVhdGUgYnJvYWRjYXN0Il0KICAgIEcgLS0-fE58IElbIkFib3J0ZWQg4oCUIG5vIGVtYWlsIHNlbnQiXQogICAgSCAtLT4gSlsiUE9TVCAvYnJvYWRjYXN0cy97aWR9L3NlbmRcbkRpc3BhdGNoIHRvIHNlZ21lbnQiXQogICAgSiAtLT4gS1siRW1haWwgZGVsaXZlcmVkXG50byBhbGwgc2VnbWVudCBzdWJzY3JpYmVycyJd" 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%2FZmxvd2NoYXJ0IFRECiAgICBBWyJub2RlIHNjcmlwdHMvbm90aWZ5LW5ldy1wb3N0LmpzIl0gLS0-IEJbIlNjYW4gcG9zdCBkaXJlY3Rvcmllc1xucGFyc2UgZnJvbnRtYXR0ZXIiXQogICAgQiAtLT4gQ1siRmlsdGVyOiBkcmFmdCA9PSBmYWxzZVxucHViRGF0ZSBpbiBwYXN0IHdlZWsiXQogICAgQyAtLT4gRFsiUHJvbXB0OiBzZWxlY3QgcG9zdCJdCiAgICBEIC0tPiBFWyJCdWlsZCBIVE1MICsgcGxhaW4gdGV4dFxuaW5saW5lIENTUyBzdHlsZXMiXQogICAgRSAtLT4gRlsiUHJpbnQgcHJldmlldyB0byB0ZXJtaW5hbCJdCiAgICBGIC0tPiBHeyJDb25maXJtIHNlbmQ_IHkvTiJ9CiAgICBHIC0tPnx5fCBIWyJQT1NUIC9icm9hZGNhc3RzXG5DcmVhdGUgYnJvYWRjYXN0Il0KICAgIEcgLS0-fE58IElbIkFib3J0ZWQg4oCUIG5vIGVtYWlsIHNlbnQiXQogICAgSCAtLT4gSlsiUE9TVCAvYnJvYWRjYXN0cy97aWR9L3NlbmRcbkRpc3BhdGNoIHRvIHNlZ21lbnQiXQogICAgSiAtLT4gS1siRW1haWwgZGVsaXZlcmVkXG50byBhbGwgc2VnbWVudCBzdWJzY3JpYmVycyJd" alt="Mermaid diagram" width="577" height="1402"&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/new-post-notifications-resend" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/new-post-notifications-resend&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Why a script rather than a build hook
&lt;/h2&gt;

&lt;p&gt;There are a few reasons.&lt;/p&gt;

&lt;p&gt;A build hook would run on every deploy, including deploys for unrelated changes like&lt;br&gt;
CSS fixes or draft work. Broadcasts should only happen for new public posts —&lt;br&gt;
triggering them from the build process would require additional logic to detect&lt;br&gt;
whether anything post-worthy had actually changed, which gets complicated quickly.&lt;/p&gt;

&lt;p&gt;Running the script manually is intentional friction. It forces a moment of review&lt;br&gt;
before an email goes out to every subscriber. That's the right default.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dependencies
&lt;/h2&gt;

&lt;p&gt;The script uses one external dependency: &lt;a href="https://github.com/SBoudrias/Inquirer.js" rel="noopener noreferrer"&gt;&lt;code&gt;@inquirer/prompts&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
for interactive select and confirmation prompts. Everything else is Node.js built-ins —&lt;br&gt;
&lt;code&gt;readFileSync&lt;/code&gt;, &lt;code&gt;readdirSync&lt;/code&gt;, path utilities. No Astro, no Zod, no content collections.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading frontmatter without a build
&lt;/h2&gt;

&lt;p&gt;The script includes a minimal frontmatter parser rather than pulling in a YAML library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseFrontmatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&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;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^---&lt;/span&gt;&lt;span class="se"&gt;\r?\n([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)\r?\n&lt;/span&gt;&lt;span class="sr"&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;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yaml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]?(&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;?)[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]?\s&lt;/span&gt;&lt;span class="sr"&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;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Handle YAML block scalar for description (&amp;gt;- or &amp;gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;descBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^description:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;-&lt;/span&gt;&lt;span class="se"&gt;?\r?\n((?:[&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;\t]&lt;/span&gt;&lt;span class="sr"&gt;+.+&lt;/span&gt;&lt;span class="se"&gt;\r?\n?)&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/m&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;descBlock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;descBlock&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&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;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&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 only handles simple &lt;code&gt;key: value&lt;/code&gt; lines and the &lt;code&gt;&amp;gt;-&lt;/code&gt; block scalar for&lt;br&gt;
&lt;code&gt;description&lt;/code&gt;. It's intentionally minimal — the script doesn't need to parse&lt;br&gt;
the full YAML AST.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;listPostIds&lt;/code&gt; function finds posts published in the past week that aren't drafts.&lt;br&gt;
Each directory read is wrapped in a try/catch so unreadable or malformed posts are&lt;br&gt;
silently skipped rather than crashing the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;listPostIds&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;postsDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&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&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;posts&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;oneWeekAgo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postsDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withFileTypes&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="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;d&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&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;try&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;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postsDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index.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;utf8&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;fm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFrontmatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&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;pubDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="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="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDraft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isDraft&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDraft&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;oneWeekAgo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;The script reads secrets from a &lt;code&gt;.env&lt;/code&gt; file in the project root (using Node 20.12's&lt;br&gt;
&lt;code&gt;process.loadEnvFile&lt;/code&gt;) or from shell environment variables — shell variables take&lt;br&gt;
precedence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;envFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.env&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="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envFile&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="k"&gt;typeof&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;loadEnvFile&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadEnvFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envFile&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;Required variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RESEND_API_KEY&lt;/code&gt; — Resend API key with broadcast send permissions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESEND_SEGMENT_ID&lt;/code&gt; — the Segment ID to broadcast to&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SITE_URL&lt;/code&gt; — base URL used to construct post links (defaults to &lt;code&gt;https://sourcier.uk&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optional variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOTIFY_FROM_EMAIL&lt;/code&gt; — the &lt;code&gt;From:&lt;/code&gt; address in the broadcast (defaults to &lt;code&gt;Roger @ Sourcier &amp;lt;hello@sourcier.uk&amp;gt;&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RESEND_TOPIC_ID&lt;/code&gt; — if set, attaches a topic to the broadcast for unsubscribe granularity&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The email content
&lt;/h2&gt;

&lt;p&gt;The broadcast is sent with both an HTML body and a plain-text fallback. Email clients&lt;br&gt;
that can't render HTML receive the plain-text version; everything else gets the styled one.&lt;/p&gt;

&lt;p&gt;The HTML is built as an inline-styled string. Email clients don't support external&lt;br&gt;
stylesheets or CSS custom properties — everything needs to be inline and use safe font stacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildHtml&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`
&amp;lt;div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:560px;margin:0 auto;padding:2rem 1.5rem;color:#0f0f0f"&amp;gt;
  &amp;lt;p style="margin:0 0 1.5rem;line-height:1.6"&amp;gt;Hi — I just published something new on Sourcier.&amp;lt;/p&amp;gt;
  &amp;lt;p style="font-size:1.5rem;font-weight:800;letter-spacing:-0.01em;margin:0 0 1rem;line-height:1.2"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;p style="margin:0 0 1.5rem;line-height:1.6;color:#444"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;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="s2"&gt;
  &amp;lt;a href="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" style="display:inline-block;background:#e8006a;color:#fff;text-decoration:none;padding:0.65rem 1.5rem;font-weight:700;font-size:0.875rem;letter-spacing:0.04em;text-transform:uppercase"&amp;gt;Read the post →&amp;lt;/a&amp;gt;
  &amp;lt;p style="margin:1.5rem 0 0;line-height:1.6;color:#444"&amp;gt;If it sparks any thoughts, I'd love to hear them — there's a comments section at the bottom of the post.&amp;lt;/p&amp;gt;
  &amp;lt;p style="margin:1rem 0 0;line-height:1.6"&amp;gt;— Roger&amp;lt;/p&amp;gt;
  &amp;lt;hr style="margin:2rem 0;border:none;border-top:1px solid #e5e5e5"&amp;gt;
  &amp;lt;p style="margin:0;color:#999;font-size:0.8125rem;line-height:1.5"&amp;gt;
    You're receiving this because you subscribed at sourcier.uk.&amp;lt;br&amp;gt;
    &amp;lt;a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#999"&amp;gt;Unsubscribe&amp;lt;/a&amp;gt;
  &amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{{{RESEND_UNSUBSCRIBE_URL}}}&lt;/code&gt; placeholder is Resend's broadcast template&lt;br&gt;
variable — it's replaced at send time with a personalised unsubscribe link for each&lt;br&gt;
recipient. It's required by anti-spam regulations (CAN-SPAM, GDPR).&lt;/p&gt;
&lt;h2&gt;
  
  
  Confirmation before sending
&lt;/h2&gt;

&lt;p&gt;The script uses &lt;code&gt;@inquirer/prompts&lt;/code&gt; for both the post selection and the send confirmation.&lt;br&gt;
After printing the preview, it asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shouldSend&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;confirm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Send this to all subscribers?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&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="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;shouldSend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Aborted — nothing was sent.&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default is &lt;code&gt;false&lt;/code&gt;, so pressing Enter without typing &lt;code&gt;y&lt;/code&gt; aborts safely.&lt;/p&gt;

&lt;p&gt;If the post has &lt;code&gt;draft: true&lt;/code&gt; in its frontmatter, the script surfaces a warning&lt;br&gt;
and asks a second confirmation before continuing. It doesn't block sending outright —&lt;br&gt;
there are legitimate reasons to test-send a draft — but it makes the state explicit.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two-step API call
&lt;/h2&gt;

&lt;p&gt;Sending a broadcast is two separate calls to the Resend API. The first creates the&lt;br&gt;
broadcast and returns an ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createRes&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/broadcasts`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;title&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="nx"&gt;FROM&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="nx"&gt;SUBJECT&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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;segment_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;segmentId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;createRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second dispatches it to the segment:&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;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/broadcasts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/send`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Separating creation from dispatch is useful — it means the broadcast exists in the&lt;br&gt;
Resend dashboard before it's sent, so you can inspect or cancel it if something looks wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node scripts/notify-new-post.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script lists posts published in the past week, prompts to select one, shows a&lt;br&gt;
preview, and waits for confirmation. Pass &lt;code&gt;--debug&lt;/code&gt; to log the full API request&lt;br&gt;
payload and response status to the terminal without sending anything.&lt;/p&gt;

&lt;p&gt;Total runtime is a few seconds.&lt;/p&gt;




&lt;p&gt;The approach here is deliberately low-tech: no build integration, no CI step, no&lt;br&gt;
webhook. A standalone script with a confirmation prompt is the right level of&lt;br&gt;
automation for something that goes out to every subscriber. The friction is the&lt;br&gt;
feature.&lt;/p&gt;

&lt;p&gt;The full script is in the&lt;br&gt;
&lt;a href="https://github.com/sourcier/sourcier.uk/blob/main/scripts/notify-new-post.js" rel="noopener noreferrer"&gt;sourcier.uk repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>resend</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Deploying an Astro blog to Netlify</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Thu, 14 May 2026 09:03:24 +0000</pubDate>
      <link>https://dev.to/sourcier/deploying-an-astro-blog-to-netlify-1190</link>
      <guid>https://dev.to/sourcier/deploying-an-astro-blog-to-netlify-1190</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/deploying-astro-netlify" rel="noopener noreferrer"&gt;Deploying an Astro blog to Netlify&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;This blog runs entirely on Netlify's free tier. Static HTML goes out over the CDN,&lt;br&gt;
serverless functions handle comments and the mailing list, and an edge function gates&lt;br&gt;
deploy previews — all without any infrastructure to manage.&lt;/p&gt;

&lt;p&gt;This post covers the configuration details: what goes in &lt;code&gt;netlify.toml&lt;/code&gt;, how&lt;br&gt;
functions are set up, which environment variables are required, and how the deploy&lt;br&gt;
preview workflow integrates with the draft post system.&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%2FZmxvd2NoYXJ0IFRECiAgICBHSVRbIkdpdCBwdXNoIHRvIG1haW4iXSAtLT4gQlVJTERbIk5ldGxpZnlcbmFzdHJvIGJ1aWxkIl0KICAgIEJVSUxEIC0tPiBDRE5bIlN0YXRpYyBhc3NldHNcbk5ldGxpZnkgQ0ROIl0KICAgIEJVSUxEIC0tPiBGRElSWyJuZXRsaWZ5L2Z1bmN0aW9ucy8iXQogICAgRkRJUiAtLT4gRjFbImNvbW1lbnQtaGFuZGxlciJdCiAgICBGRElSIC0tPiBGMlsiYXBwcm92ZS1jb21tZW50Il0KICAgIEZESVIgLS0-IEYzWyJnZXQtY29tbWVudHMiXQogICAgRkRJUiAtLT4gRjRbInN1YnNjcmliZSJdCiAgICBCVUlMRCAtLT4gRUZESVJbIm5ldGxpZnkvZWRnZS1mdW5jdGlvbnMvIl0KICAgIEVGRElSIC0tPiBFRjFbInByZXZpZXctYXV0aFxuZ2F0ZXMgYWxsIHJvdXRlcyJdCiAgICBFTlZCWyJCdWlsZC10aW1lIGVudiB2YXJzXG5QVUJMSUNfKiArIFNIT1dfRFJBRlRTIl0gLS4tPnxiYWtlZCBpbnRvIEhUTUx8IEJVSUxECiAgICBFTlZSWyJSdW50aW1lIGVudiB2YXJzXG5zZWNyZXRzICsgdG9rZW5zIl0gLS4tPnxpbmplY3RlZCBhdCByZXF1ZXN0IHRpbWV8IEZESVIKICAgIEVOVlIgLS4tPnxQUkVWSUVXX1BBU1NDT0RFfCBFRjEKICAgIEdJVDJbIkJyYW5jaCBwdXNoIC8gUFIiXSAtLT4gUFJFVklFV1siRGVwbG95IHByZXZpZXdcbmh0dHBzOi8vZGVwbG95LXByZXZpZXctTi0tc2l0ZS5uZXRsaWZ5LmFwcCJdCiAgICBFRjEgLS4tPnxwYXNzY29kZSBnYXRlfCBQUkVWSUVX" 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%2FZmxvd2NoYXJ0IFRECiAgICBHSVRbIkdpdCBwdXNoIHRvIG1haW4iXSAtLT4gQlVJTERbIk5ldGxpZnlcbmFzdHJvIGJ1aWxkIl0KICAgIEJVSUxEIC0tPiBDRE5bIlN0YXRpYyBhc3NldHNcbk5ldGxpZnkgQ0ROIl0KICAgIEJVSUxEIC0tPiBGRElSWyJuZXRsaWZ5L2Z1bmN0aW9ucy8iXQogICAgRkRJUiAtLT4gRjFbImNvbW1lbnQtaGFuZGxlciJdCiAgICBGRElSIC0tPiBGMlsiYXBwcm92ZS1jb21tZW50Il0KICAgIEZESVIgLS0-IEYzWyJnZXQtY29tbWVudHMiXQogICAgRkRJUiAtLT4gRjRbInN1YnNjcmliZSJdCiAgICBCVUlMRCAtLT4gRUZESVJbIm5ldGxpZnkvZWRnZS1mdW5jdGlvbnMvIl0KICAgIEVGRElSIC0tPiBFRjFbInByZXZpZXctYXV0aFxuZ2F0ZXMgYWxsIHJvdXRlcyJdCiAgICBFTlZCWyJCdWlsZC10aW1lIGVudiB2YXJzXG5QVUJMSUNfKiArIFNIT1dfRFJBRlRTIl0gLS4tPnxiYWtlZCBpbnRvIEhUTUx8IEJVSUxECiAgICBFTlZSWyJSdW50aW1lIGVudiB2YXJzXG5zZWNyZXRzICsgdG9rZW5zIl0gLS4tPnxpbmplY3RlZCBhdCByZXF1ZXN0IHRpbWV8IEZESVIKICAgIEVOVlIgLS4tPnxQUkVWSUVXX1BBU1NDT0RFfCBFRjEKICAgIEdJVDJbIkJyYW5jaCBwdXNoIC8gUFIiXSAtLT4gUFJFVklFV1siRGVwbG95IHByZXZpZXdcbmh0dHBzOi8vZGVwbG95LXByZXZpZXctTi0tc2l0ZS5uZXRsaWZ5LmFwcCJdCiAgICBFRjEgLS4tPnxwYXNzY29kZSBnYXRlfCBQUkVWSUVX" alt="Mermaid diagram" width="1393" height="702"&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/deploying-astro-netlify" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/deploying-astro-netlify&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  netlify.toml
&lt;/h2&gt;

&lt;p&gt;Everything Netlify needs to know about building and running the site is in&lt;br&gt;
&lt;code&gt;netlify.toml&lt;/code&gt; at the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dev]&lt;/span&gt;
  &lt;span class="py"&gt;framework&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"astro"&lt;/span&gt;
  &lt;span class="py"&gt;command&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"astro dev"&lt;/span&gt;
  &lt;span class="py"&gt;targetPort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4321&lt;/span&gt;
  &lt;span class="py"&gt;autoLaunch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="nn"&gt;[build]&lt;/span&gt;
  &lt;span class="py"&gt;command&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"astro build"&lt;/span&gt;
  &lt;span class="py"&gt;publish&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dist"&lt;/span&gt;

&lt;span class="nn"&gt;[functions]&lt;/span&gt;
  &lt;span class="py"&gt;directory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"netlify/functions"&lt;/span&gt;
  &lt;span class="py"&gt;node_bundler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"esbuild"&lt;/span&gt;

&lt;span class="nn"&gt;[[headers]]&lt;/span&gt;
  &lt;span class="py"&gt;for&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/*"&lt;/span&gt;
  &lt;span class="nn"&gt;[headers.values]&lt;/span&gt;
    &lt;span class="py"&gt;Cache-Control&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public, max-age=0, must-revalidate"&lt;/span&gt;

&lt;span class="nn"&gt;[[headers]]&lt;/span&gt;
  &lt;span class="py"&gt;for&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/_astro/*"&lt;/span&gt;
  &lt;span class="nn"&gt;[headers.values]&lt;/span&gt;
    &lt;span class="py"&gt;Cache-Control&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public, max-age=31536000, immutable"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;[dev]&lt;/code&gt; section configures Netlify Dev — &lt;code&gt;netlify dev&lt;/code&gt; in the terminal starts&lt;br&gt;
both the Astro dev server and the function runtime together, so you can test&lt;br&gt;
serverless functions against the local site. &lt;code&gt;autoLaunch = false&lt;/code&gt; prevents Netlify&lt;br&gt;
Dev from opening a browser tab automatically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[build]&lt;/code&gt; points to the Astro build command and the output directory. Astro outputs&lt;br&gt;
to &lt;code&gt;dist/&lt;/code&gt; by default.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[functions]&lt;/code&gt; tells Netlify where to find the serverless functions and which&lt;br&gt;
bundler to use. &lt;code&gt;esbuild&lt;/code&gt; is significantly faster than webpack for bundling Node.js&lt;br&gt;
functions and handles ES module imports correctly.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cache headers
&lt;/h3&gt;

&lt;p&gt;The two &lt;code&gt;[[headers]]&lt;/code&gt; blocks implement a split caching strategy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/*&lt;/code&gt; — HTML pages get &lt;code&gt;max-age=0, must-revalidate&lt;/code&gt;. The browser caches the response but revalidates on every request. When a new deploy lands, Netlify invalidates the CDN edge cache, so clients pick up the new version immediately.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/_astro/*&lt;/code&gt; — Astro outputs hashed filenames for all JS and CSS bundles (e.g. &lt;code&gt;_astro/index.B1fJkLmN.js&lt;/code&gt;). Because the hash changes whenever the content changes, these assets can be cached indefinitely with &lt;code&gt;max-age=31536000, immutable&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without the second rule, browsers would re-fetch unchanged bundles on every page load. Without the first, stale HTML pages could reference bundle URLs that no longer exist.&lt;/p&gt;
&lt;h2&gt;
  
  
  The functions directory
&lt;/h2&gt;

&lt;p&gt;Netlify Functions are TypeScript files in &lt;code&gt;netlify/functions/&lt;/code&gt;. Each file is a&lt;br&gt;
separate function, accessible at &lt;code&gt;/.netlify/functions/{filename}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;netlify/
  functions/
    approve-comment.ts   → /.netlify/functions/approve-comment
    comment-handler.ts   → /.netlify/functions/comment-handler
    get-comments.ts      → /.netlify/functions/get-comments
    subscribe.ts         → /.netlify/functions/subscribe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Functions are not bundled with the site — Netlify deploys them separately. The&lt;br&gt;
&lt;code&gt;node_bundler = "esbuild"&lt;/code&gt; setting handles tree-shaking and resolves &lt;code&gt;import&lt;/code&gt;&lt;br&gt;
statements so each function file can use npm packages.&lt;/p&gt;
&lt;h2&gt;
  
  
  Edge functions
&lt;/h2&gt;

&lt;p&gt;Edge functions run at Netlify's CDN edge — before the response is served — rather&lt;br&gt;
than as on-demand Lambda invocations. They live in &lt;code&gt;netlify/edge-functions/&lt;/code&gt; and&lt;br&gt;
are configured through the exported &lt;code&gt;config&lt;/code&gt; object in each file:&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;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;preview-auth.ts&lt;/code&gt; runs on every request. It reads the &lt;code&gt;PREVIEW_PASSCODE&lt;/code&gt;&lt;br&gt;
environment variable. When no passcode is configured (production), the function&lt;br&gt;
calls &lt;code&gt;context.next()&lt;/code&gt; immediately and is a transparent pass-through. When a&lt;br&gt;
passcode is set (preview deploys), it gates the entire site behind a passcode form&lt;br&gt;
and sets an &lt;code&gt;HttpOnly; Secure; SameSite=Strict&lt;/code&gt; session cookie on success.&lt;/p&gt;

&lt;p&gt;This is how draft posts are safely visible on deploy previews without being&lt;br&gt;
publicly accessible. The &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt; build variable makes the Astro build&lt;br&gt;
include draft posts; the edge function ensures only someone with the passcode can&lt;br&gt;
reach them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Environment variables
&lt;/h2&gt;

&lt;p&gt;None of the secrets are stored in &lt;code&gt;netlify.toml&lt;/code&gt;. Environment variables split into&lt;br&gt;
two groups depending on when they are consumed.&lt;/p&gt;
&lt;h3&gt;
  
  
  Build-time variables
&lt;/h3&gt;

&lt;p&gt;These are read by &lt;code&gt;astro build&lt;/code&gt; and baked into the generated HTML. Any variable&lt;br&gt;
referenced via &lt;code&gt;import.meta.env&lt;/code&gt; falls into this category and must be present when&lt;br&gt;
the build runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SHOW_DRAFTS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set to &lt;code&gt;"true"&lt;/code&gt; on preview branch deploys to include draft and scheduled posts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Runtime variables
&lt;/h3&gt;

&lt;p&gt;These are read by serverless and edge functions at request time and are never&lt;br&gt;
embedded in the built HTML. Keep them in the Netlify dashboard only&lt;br&gt;
(Site configuration → Environment variables):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PREVIEW_PASSCODE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;preview-auth.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Passcode protecting deploy previews — leave unset in production&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For local development, copy these into a &lt;code&gt;.env&lt;/code&gt; file in the project root. The&lt;br&gt;
functions read them via &lt;code&gt;process.env&lt;/code&gt;. Never commit &lt;code&gt;.env&lt;/code&gt; — add it to &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;PREVIEW_PASSCODE&lt;/code&gt; note:&lt;/strong&gt; Leave this unset in the production site context. When&lt;br&gt;
unset, &lt;code&gt;preview-auth.ts&lt;/code&gt; is a transparent pass-through and adds no overhead.&lt;br&gt;
Generate a strong value with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deploy previews and draft posts
&lt;/h2&gt;

&lt;p&gt;Netlify automatically generates a deploy preview URL for every pull request and&lt;br&gt;
branch push. The URL takes the form &lt;code&gt;https://deploy-preview-{n}--{site-name}.netlify.app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Draft posts are hidden by default. The &lt;code&gt;isPublished()&lt;/code&gt; helper in&lt;br&gt;
&lt;code&gt;src/utils/drafts.ts&lt;/code&gt; reads the &lt;code&gt;SHOW_DRAFTS&lt;/code&gt; build-time 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;return&lt;/span&gt; &lt;span class="nf"&gt;isPubliclyPublished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;SHOW_DRAFTS=true&lt;/code&gt; on the preview branch context in the Netlify dashboard&lt;br&gt;
makes the build include draft and scheduled posts. The &lt;code&gt;preview-auth&lt;/code&gt; edge function&lt;br&gt;
then gates that deploy behind a passcode, so the preview URL is not publicly accessible.&lt;/p&gt;

&lt;p&gt;This is more reliable than temporarily setting &lt;code&gt;draft: false&lt;/code&gt; in frontmatter and&lt;br&gt;
remembering to reset it before merging. There is no risk of accidentally publishing&lt;br&gt;
a post that was only meant to be previewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One-time Netlify dashboard setup for comments
&lt;/h2&gt;

&lt;p&gt;The comments webhook isn't in &lt;code&gt;netlify.toml&lt;/code&gt; — it's a one-time setup in the&lt;br&gt;
Netlify dashboard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Forms&lt;/strong&gt; → &lt;code&gt;blog-comments&lt;/code&gt; → &lt;strong&gt;Form notifications&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Add notification → &lt;strong&gt;Outgoing webhook&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;https://your-site.netlify.app/.netlify/functions/comment-handler&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This wires up the webhook that triggers the moderation email whenever a new comment&lt;br&gt;
arrives. It only needs to be configured once per site, which is why it's not in the&lt;br&gt;
TOML file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;netlify.toml&lt;/code&gt; in place, the deployment configuration is declarative and version-controlled alongside the site code. The split caching strategy — aggressive immutable caching for hashed assets, revalidate-always for HTML — keeps the site fast without ever serving stale pages after a deploy.&lt;/p&gt;

&lt;p&gt;The functions and edge-functions directories draw a clear line between work that happens at request time on the server and at the CDN edge. &lt;code&gt;preview-auth&lt;/code&gt; in particular is what makes safe draft previewing possible — &lt;code&gt;SHOW_DRAFTS&lt;/code&gt; controls what gets built, and the passcode gate controls who can see it.&lt;/p&gt;

&lt;p&gt;All the secrets stay in the Netlify dashboard, nothing sensitive is in the repository, and a fresh deploy of the whole setup is reproducible from the TOML file and the environment variable list above.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>netlify</category>
      <category>engineering</category>
    </item>
    <item>
      <title>Page history and credits on a static blog</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Wed, 13 May 2026 19:13:56 +0000</pubDate>
      <link>https://dev.to/sourcier/page-history-and-credits-on-a-static-blog-1a6m</link>
      <guid>https://dev.to/sourcier/page-history-and-credits-on-a-static-blog-1a6m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/page-history-credits" rel="noopener noreferrer"&gt;Page history and credits on a static blog&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Most blog posts operate on an implicit contract: once published, they don't change.&lt;br&gt;
Or if they do change, the change is invisible. This is fine for minor edits, but&lt;br&gt;
when you correct something meaningful — a wrong date, a misattributed quote, broken&lt;br&gt;
code — readers who've already seen the post have no way of knowing.&lt;/p&gt;

&lt;p&gt;This blog has two optional features that address this: a page history log and a&lt;br&gt;
credits section. Both are schema-validated fields in the content collection and&lt;br&gt;
rendered at the bottom of post pages.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtNYXJrZG93biBmcm9udG1hdHRlclxuY29sbGVjdGlvbnMvcG9zdHMvPHNsdWc-L2luZGV4Lm1kXSAtLT4gQltDb250ZW50IHNjaGVtYVxuc3JjL2NvbnRlbnQuY29uZmlnLnRzXQogIEIgLS0-IENbImdldENvbGxlY3Rpb24oJ3Bvc3RzJykiXQogIEMgLS0-IERbTWFya2Rvd25Qb3N0TGF5b3V0LmFzdHJvXQogIEQgLS0-IEVbUGFnZUhpc3RvcnkuYXN0cm9cbnJlbmRlcnMgaGlzdG9yeSBlbnRyaWVzXQogIEQgLS0-IEZbUGFnZUNyZWRpdHMuYXN0cm9cbnJlbmRlcnMgY3JlZGl0IGVudHJpZXNdCiAgRSAtLT4gR1tSZW5kZXJlZCBwb3N0IHBhZ2VdCiAgRiAtLT4gRw" 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%2FZmxvd2NoYXJ0IFRECiAgQVtNYXJrZG93biBmcm9udG1hdHRlclxuY29sbGVjdGlvbnMvcG9zdHMvPHNsdWc-L2luZGV4Lm1kXSAtLT4gQltDb250ZW50IHNjaGVtYVxuc3JjL2NvbnRlbnQuY29uZmlnLnRzXQogIEIgLS0-IENbImdldENvbGxlY3Rpb24oJ3Bvc3RzJykiXQogIEMgLS0-IERbTWFya2Rvd25Qb3N0TGF5b3V0LmFzdHJvXQogIEQgLS0-IEVbUGFnZUhpc3RvcnkuYXN0cm9cbnJlbmRlcnMgaGlzdG9yeSBlbnRyaWVzXQogIEQgLS0-IEZbUGFnZUNyZWRpdHMuYXN0cm9cbnJlbmRlcnMgY3JlZGl0IGVudHJpZXNdCiAgRSAtLT4gR1tSZW5kZXJlZCBwb3N0IHBhZ2VdCiAgRiAtLT4gRw" alt="Mermaid diagram" width="586" height="662"&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/page-history-credits" rel="noopener noreferrer"&gt;https://sourcier.uk/blog/page-history-credits&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  UI mockup
&lt;/h2&gt;

&lt;p&gt;The wireframe below shows the presentation intent for both metadata surfaces: a&lt;br&gt;
timeline-style history block and compact, pill-style credits.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxbm8xjtvl23s7m7hajv5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxbm8xjtvl23s7m7hajv5.png" alt="Wireframe mockup showing the page history timeline and credits chip list rendered below a blog post, including annotation callouts for semantic tags and styling intent" width="700" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;The &lt;code&gt;history&lt;/code&gt; field in a post's frontmatter is an optional array of revision entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-03-26T00:00:00&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Initial publish.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-03-27T12:00:00&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;Corrected the HMAC algorithm description — it&amp;amp;#39;s SHA-256, not SHA-1.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry has a &lt;code&gt;datetime&lt;/code&gt; (coerced to a &lt;code&gt;Date&lt;/code&gt; by Zod) and a &lt;code&gt;note&lt;/code&gt; string.&lt;br&gt;
Notes support inline HTML, so links to related pages and emphasis are possible.&lt;/p&gt;

&lt;p&gt;The Zod schema definition:&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="nx"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PageHistory.astro&lt;/code&gt; renders the entries as an &lt;code&gt;&amp;lt;ol&amp;gt;&lt;/code&gt; — a chronological list where&lt;br&gt;
each &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt; pairs a &lt;code&gt;&amp;lt;time&amp;gt;&lt;/code&gt; element with a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; for the note:&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="page-history"&amp;gt;
  &amp;lt;p class="page-history__heading"&amp;gt;Page history&amp;lt;/p&amp;gt;
  &amp;lt;ol class="page-history__log"&amp;gt;
    {entries.map((entry) =&amp;gt; (
      &amp;lt;li class="page-history__entry"&amp;gt;
        &amp;lt;time
          class="page-history__time"
          datetime={entry.datetime.toISOString()}
        &amp;gt;
          {formatDatetime(entry.datetime)}
        &amp;lt;/time&amp;gt;
        &amp;lt;span class="page-history__note" set:html={entry.note} /&amp;gt;
      &amp;lt;/li&amp;gt;
    ))}
  &amp;lt;/ol&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;time&amp;gt;&lt;/code&gt; element carries the machine-readable ISO 8601 datetime in its&lt;br&gt;
&lt;code&gt;datetime&lt;/code&gt; attribute. The human-readable text is formatted with &lt;code&gt;toLocaleDateString&lt;/code&gt;&lt;br&gt;
using the &lt;code&gt;en-GB&lt;/code&gt; locale.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set:html&lt;/code&gt; is used for the note rather than &lt;code&gt;{entry.note}&lt;/code&gt; because notes can&lt;br&gt;
contain inline HTML. This is an intentional tradeoff — the content is&lt;br&gt;
author-controlled in a static repository, not user-submitted, so the XSS risk&lt;br&gt;
is the same as any other HTML in the site.&lt;/p&gt;

&lt;p&gt;Visually, the history block is rendered at reduced opacity (0.65) and with&lt;br&gt;
a left border — it's clearly secondary information, present for transparency&lt;br&gt;
rather than as a primary content element.&lt;/p&gt;
&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;credits&lt;/code&gt; field follows the same pattern — an optional array, validated by Zod,&lt;br&gt;
with label, text, and an optional URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cover image&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kelly Sikkema on Unsplash&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://unsplash.com/@kellysikkema&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Diagram library&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Mermaid&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mermaid.js.org/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PageCredits.astro&lt;/code&gt; renders them as a &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;. Each &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt; pairs the label with&lt;br&gt;
either an anchor or a plain &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; depending on whether a URL is present. URLs&lt;br&gt;
use &lt;code&gt;target="_blank"&lt;/code&gt; with &lt;code&gt;rel="noopener noreferrer"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Attribution is a first-class concern here, not an afterthought. Every Unsplash cover&lt;br&gt;
image has its photographer credited. Libraries and tools that made a feature possible&lt;br&gt;
are listed. When a post is directly inspired by another person's work, that's&lt;br&gt;
acknowledged explicitly. This isn't just good etiquette — it's consistent with how&lt;br&gt;
I'd want my own work credited.&lt;/p&gt;

&lt;p&gt;Both components share the same visual treatment: muted, compact, below the main&lt;br&gt;
content and the share widget. They're there for the reader who cares about the&lt;br&gt;
detail, invisible to the reader who doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why both belong in the schema
&lt;/h2&gt;

&lt;p&gt;It would be easy to treat history and credits as presentational concerns — markdown&lt;br&gt;
at the bottom of a post, maintained by hand. Putting them in the schema instead&lt;br&gt;
means they're validated on every build, available to any component or page that&lt;br&gt;
needs them, and impossible to malform silently. The discipline of typing them enforces&lt;br&gt;
consistency: every credit has a label, every history entry has a datetime.&lt;/p&gt;

&lt;p&gt;Neither field is required. A post with no meaningful revision history doesn't need&lt;br&gt;
a history block. A post with no external sources doesn't need credits. The optionality&lt;br&gt;
is intentional — adding boilerplate entries just to fill a section would dilute the&lt;br&gt;
signal these features are meant to carry.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>engineering</category>
      <category>meta</category>
    </item>
    <item>
      <title>Building a share widget with the Clipboard API</title>
      <dc:creator>Roger Rajaratnam</dc:creator>
      <pubDate>Tue, 05 May 2026 10:59:07 +0000</pubDate>
      <link>https://dev.to/sourcier/building-a-share-widget-with-the-clipboard-api-4hco</link>
      <guid>https://dev.to/sourcier/building-a-share-widget-with-the-clipboard-api-4hco</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Original post: &lt;a href="https://sourcier.uk/blog/share-post-clipboard" rel="noopener noreferrer"&gt;Building a share widget with the Clipboard API&lt;/a&gt;&lt;/p&gt;

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

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

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


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

&lt;/div&gt;


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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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


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

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

&lt;/div&gt;


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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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



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

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

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

&lt;/div&gt;



</description>
      <category>astro</category>
      <category>engineering</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
