<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://mcgarrah.org/feed.xml" rel="self" type="application/atom+xml" /><link href="https://mcgarrah.org/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-05-09T10:58:30+00:00</updated><id>https://mcgarrah.org/feed.xml</id><title type="html">McGarrah Technical Blog</title><subtitle>Technical blog covering homelab infrastructure, Proxmox/Ceph, Dell Wyse 3040, Jekyll development, and networking. Deep dives into virtualization, storage clustering, and system administration.</subtitle><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><entry><title type="html">Upgrading from a Basic Favicon to a Modern Device Set in Jekyll</title><link href="https://mcgarrah.org/jekyll-modern-favicon-set/" rel="alternate" type="text/html" title="Upgrading from a Basic Favicon to a Modern Device Set in Jekyll" /><published>2026-05-09T00:00:00+00:00</published><updated>2026-05-09T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-modern-favicon-set</id><content type="html" xml:base="https://mcgarrah.org/jekyll-modern-favicon-set/"><![CDATA[<p>In my recent article on <a href="/jekyll-small-things-polish-features/">The Small Things: Polish Features That Make a Jekyll Blog Feel Professional</a>, I confessed that my favicon implementation was the bare minimum: a single resolution <code class="language-plaintext highlighter-rouge">favicon.ico</code> file dropped in the site root. It barely qualifies as solving the problem.</p>

<p>While that prevents the standard 404 error when a desktop browser requests the icon, it completely ignores the modern web. Mobile devices, tablets, and modern browsers expect high-resolution PNGs, Apple Touch icons, and web manifests. Without them, users bookmarking the site to their home screens get a generic, ugly letter block instead of a proper logo.</p>

<p>It was sitting on my internal <code class="language-plaintext highlighter-rouge">TODO.md</code> as a “Quick Win” for far too long. Here is how I finally fixed it and implemented a modern favicon set.</p>

<blockquote>
  <p>“It’s the small things that matter. The details.” — The Twelfth Doctor</p>
</blockquote>

<!-- excerpt-end -->

<h2 id="the-problem-with-just-faviconico">The Problem with Just <code class="language-plaintext highlighter-rouge">favicon.ico</code></h2>

<p>If you check your web server logs, you’ll likely see a stream of 404 errors for files you never created:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">/apple-touch-icon-precomposed.png</code></li>
  <li><code class="language-plaintext highlighter-rouge">/apple-touch-icon.png</code></li>
</ul>

<p>iOS devices automatically request these files when a user adds your site to their home screen. Android devices look for an Android Chrome manifest. If they aren’t there, you get degraded UX and messy logs.</p>

<p>Here is the source image I started with — a TARDIS icon at 1067x1067 pixels with a solid white background:</p>

<p><img src="/assets/images/favicon/tardis-icon-1067x1067px.png" alt="Original TARDIS icon with white background" width="256" /></p>

<h2 id="step-1-generate-the-assets">Step 1: Generate the Assets</h2>

<p>You have two choices here. I went with the CLI route because I wanted control over the transparency — the web generators do not offer that option — and because I am a UNIX guy of old who is perfectly comfortable in a terminal. That said, if you just want a zip file of correctly sized icons without thinking about it, the web generator is the faster path.</p>

<h3 id="option-a-the-fast-track-web-generator">Option A: The Fast Track (Web Generator)</h3>

<p>If you do not need transparency or custom processing, this is the easiest route.</p>

<ol>
  <li>Start with a high-resolution version of your logo (ideally a square PNG or SVG, at least 260x260 pixels).</li>
  <li>Go to <a href="https://realfavicongenerator.net/">RealFaviconGenerator.net</a>.</li>
  <li>Upload your image and configure the exact padding, background colors, and styling for iOS, Android, Windows Metro, and macOS Safari pinned tabs.</li>
  <li>Generate and download the resulting <code class="language-plaintext highlighter-rouge">.zip</code> package.</li>
</ol>

<h3 id="option-b-the-local-cli-route-imagemagick">Option B: The Local CLI Route (ImageMagick)</h3>

<p>This is the route I took. If you want full control over the output — especially transparency — ImageMagick gives you that. Assuming your source image is <code class="language-plaintext highlighter-rouge">logo.png</code>:</p>

<p><strong>Optional: Make the background transparent</strong></p>

<p>If your source image has a solid background (like white) and you want it transparent, you can strip it out first. This is the main reason I chose the CLI route — none of the web generators I found offered this.</p>

<p>The trick is the fuzz factor. A naive <code class="language-plaintext highlighter-rouge">-transparent white</code> only removes exact <code class="language-plaintext highlighter-rouge">#FFFFFF</code> pixels and misses the slightly blended, anti-aliased pixels at the edges of the logo, leaving a visible white halo. I started at 5% and it still had noticeable halo artifacts, so I bumped it 5% at a time until 10% produced a clean result:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>convert logo.png <span class="nt">-fuzz</span> 10% <span class="nt">-transparent</span> white transparent-logo.png
</code></pre></div></div>

<p>Here is the result — the same icon after stripping the white background at 10% fuzz. The anti-aliased edges are clean with no visible halo:</p>

<p><img src="/assets/images/favicon/tardis-icon-transparent.png" alt="TARDIS icon after transparency processing" width="256" style="background: #ccc; padding: 8px;" /></p>

<p><em>(If you do this, just substitute <code class="language-plaintext highlighter-rouge">transparent-logo.png</code> for <code class="language-plaintext highlighter-rouge">logo.png</code> in the sizing commands below.)</em></p>

<p><strong>Convert files</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Generate the Apple Touch icon</span>
convert logo.png <span class="nt">-resize</span> 180x180 apple-touch-icon.png

<span class="c"># Generate the standard PNG favicons</span>
convert logo.png <span class="nt">-resize</span> 32x32 favicon-32x32.png
convert logo.png <span class="nt">-resize</span> 16x16 favicon-16x16.png

<span class="c"># Combine them into a multi-resolution favicon.ico</span>
convert favicon-16x16.png favicon-32x32.png favicon.ico

<span class="c"># Generate Android Chrome icons for the manifest</span>
convert logo.png <span class="nt">-resize</span> 192x192 favicon-192x192.png
convert logo.png <span class="nt">-resize</span> 512x512 favicon-512x512.png
</code></pre></div></div>

<p>You will also need to manually create the <code class="language-plaintext highlighter-rouge">site.webmanifest</code> JSON file to register your Android icons:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"McGarrah Technical Blog"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"short_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"McGarrah"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"icons"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"src"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/favicon-192x192.png"</span><span class="p">,</span><span class="w"> </span><span class="nl">"sizes"</span><span class="p">:</span><span class="w"> </span><span class="s2">"192x192"</span><span class="p">,</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"image/png"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"src"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/favicon-512x512.png"</span><span class="p">,</span><span class="w"> </span><span class="nl">"sizes"</span><span class="p">:</span><span class="w"> </span><span class="s2">"512x512"</span><span class="p">,</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"image/png"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"theme_color"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#ffffff"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"background_color"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#ffffff"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"display"</span><span class="p">:</span><span class="w"> </span><span class="s2">"standalone"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="step-2-add-files-to-the-jekyll-root">Step 2: Add Files to the Jekyll Root</h2>

<p>If you used the web generator, extract the <code class="language-plaintext highlighter-rouge">.zip</code> directly into your Jekyll project’s root directory. If you used ImageMagick, move the generated files there along with your hand-written <code class="language-plaintext highlighter-rouge">site.webmanifest</code>. Either way, you should end up with a collection of files including:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">apple-touch-icon.png</code> (180x180)</li>
  <li><code class="language-plaintext highlighter-rouge">favicon-32x32.png</code></li>
  <li><code class="language-plaintext highlighter-rouge">favicon-16x16.png</code></li>
  <li><code class="language-plaintext highlighter-rouge">site.webmanifest</code></li>
  <li><code class="language-plaintext highlighter-rouge">favicon.ico</code></li>
</ul>

<p><em>Note:</em> It’s important to keep these at the root level (<code class="language-plaintext highlighter-rouge">/</code>), as many tools and legacy browsers request them from the root by default without parsing your HTML.</p>

<h2 id="step-3-update-the-html-head">Step 3: Update the HTML Head</h2>

<p>The web generator provides a block of HTML to paste into your site. In a standard Jekyll setup, this goes into <code class="language-plaintext highlighter-rouge">_includes/head.html</code> or directly inside the <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> block of <code class="language-plaintext highlighter-rouge">_layouts/default.html</code>. If you took the ImageMagick route, the block below is what you need.</p>

<p>To ensure the links work regardless of environment or subdirectories, use Jekyll’s <code class="language-plaintext highlighter-rouge">relative_url</code> filter. I added this block just before the closing <code class="language-plaintext highlighter-rouge">&lt;/head&gt;</code> tag:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Favicons --&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"apple-touch-icon"</span> <span class="na">sizes=</span><span class="s">"180x180"</span> <span class="na">href=</span><span class="s">"{{ '/apple-touch-icon.png' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"icon"</span> <span class="na">type=</span><span class="s">"image/png"</span> <span class="na">sizes=</span><span class="s">"32x32"</span> <span class="na">href=</span><span class="s">"{{ '/favicon-32x32.png' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"icon"</span> <span class="na">type=</span><span class="s">"image/png"</span> <span class="na">sizes=</span><span class="s">"16x16"</span> <span class="na">href=</span><span class="s">"{{ '/favicon-16x16.png' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"manifest"</span> <span class="na">href=</span><span class="s">"{{ '/site.webmanifest' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"shortcut icon"</span> <span class="na">href=</span><span class="s">"{{ '/favicon.ico' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"theme-color"</span> <span class="na">content=</span><span class="s">"#ffffff"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Using <code class="language-plaintext highlighter-rouge">relative_url</code> ensures that if I ever preview the site in a sub-path or change domains, the asset links won’t break.</p>

<h2 id="testing-locally-before-pushing">Testing Locally Before Pushing</h2>

<p>Before pushing any of this to GitHub Pages, I tested it locally with <code class="language-plaintext highlighter-rouge">bundle exec jekyll serve</code> several times. Favicons are notoriously sticky in browser caches, so I made a habit of hard-refreshing (<code class="language-plaintext highlighter-rouge">Ctrl+Shift+R</code>) and checking the Network tab in DevTools to confirm the browser was actually fetching the new assets instead of serving stale ones.</p>

<p>Beyond the icons themselves, I verified that the HTML changes in <code class="language-plaintext highlighter-rouge">_layouts/default.html</code> were rendering correctly by viewing the page source and confirming all six <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tags appeared in the <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code>. I also hit <code class="language-plaintext highlighter-rouge">http://localhost:4000/site.webmanifest</code> directly in the browser to make sure Jekyll was serving the manifest file and that the JSON was valid with the correct icon paths. A missing or malformed manifest is silent — no errors in the console, just a broken “Add to Home Screen” experience that you would never notice without checking.</p>

<p>The transparency fuzz factor was the main thing I iterated on locally. Each time I regenerated the PNGs with a different percentage, I restarted Jekyll and checked the Apple Touch icon at 180x180 to see if the halo was gone. That feedback loop — regenerate, restart, hard-refresh — is much faster against a local server than waiting for a GitHub Pages deploy.</p>

<p>Once everything looked right locally, I pushed to GitHub and verified on the live site at <a href="https://mcgarrah.org">mcgarrah.org</a>. The deploy picked it up on the next build and the icons rendered identically to what I saw in local testing.</p>

<h2 id="the-result">The Result</h2>

<p>The whole process took about thirty minutes of actual work, spread across an hour of wall time because I was multitasking. Most of that was the local iteration on ImageMagick transparency — the favicon generation and HTML changes themselves were genuinely quick. After deploying, the stream of <code class="language-plaintext highlighter-rouge">/apple-touch-icon.png</code> 404 errors in my server logs disappeared immediately. More importantly, bookmarking the site to an iPhone home screen now shows my actual logo instead of a generic gray letter “M” in a rounded square.</p>

<p>Here is the final set of generated icons at their actual sizes, from the 512x512 Android Chrome icon down to the 16x16 browser tab favicon:</p>

<table>
  <thead>
    <tr>
      <th>Icon</th>
      <th>Size</th>
      <th>Preview</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>favicon-512x512.png</td>
      <td>512x512</td>
      <td><img src="/favicon-512x512.png" alt="512x512 favicon" width="128" /></td>
    </tr>
    <tr>
      <td>favicon-192x192.png</td>
      <td>192x192</td>
      <td><img src="/favicon-192x192.png" alt="192x192 favicon" width="96" /></td>
    </tr>
    <tr>
      <td>apple-touch-icon.png</td>
      <td>180x180</td>
      <td><img src="/apple-touch-icon.png" alt="Apple Touch icon" width="90" /></td>
    </tr>
    <tr>
      <td>favicon-32x32.png</td>
      <td>32x32</td>
      <td><img src="/favicon-32x32.png" alt="32x32 favicon" /></td>
    </tr>
    <tr>
      <td>favicon-16x16.png</td>
      <td>16x16</td>
      <td><img src="/favicon-16x16.png" alt="16x16 favicon" /></td>
    </tr>
  </tbody>
</table>

<p>If you have a Jekyll site with just a bare <code class="language-plaintext highlighter-rouge">favicon.ico</code>, check your logs — you are almost certainly serving 404s for assets that mobile devices expect to exist. A few generated PNGs and six lines of HTML in your layout is all it takes to fix it.</p>

<p>My <a href="https://www.mcgarrah.org/resume/">resume site</a> runs as a sub-path off the same domain, so it picks up the root-level favicon assets automatically. Its Jekyll theme has its own <code class="language-plaintext highlighter-rouge">_includes/head.html</code>, which needed the same <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> block added, but the image files are shared. If you run multiple Jekyll projects under one domain, that is one less thing to duplicate.</p>

<blockquote>
  <p>“Never ignore the little things. In the whole wide universe, the little things are the most important.” — The Eleventh Doctor</p>
</blockquote>

<p><img src="/assets/images/gallifreyan-script.png" alt="Gallifreyan script" /></p>

<p><em>Gallifreyan script generated with the <a href="https://mightyfrong.github.io/gallifreyan-translation-helper/">Gallifreyan Translation Helper</a>.</em></p>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="favicon" /><category term="ux" /><category term="github-pages" /><category term="html" /><category term="imagemagick" /><summary type="html"><![CDATA[How to properly implement a modern favicon set in a Jekyll blog on GitHub Pages. Covers using RealFaviconGenerator for the fast track, or ImageMagick for local CLI generation, plus the necessary HTML head tags.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-modern-favicon-set.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-modern-favicon-set.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The CI/CD Pipeline Behind This Jekyll Blog</title><link href="https://mcgarrah.org/jekyll-github-actions-cicd-pipeline/" rel="alternate" type="text/html" title="The CI/CD Pipeline Behind This Jekyll Blog" /><published>2026-05-08T00:00:00+00:00</published><updated>2026-05-08T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-github-actions-cicd-pipeline</id><content type="html" xml:base="https://mcgarrah.org/jekyll-github-actions-cicd-pipeline/"><![CDATA[<p>Any content platform that publishes on a schedule, validates quality automatically, and scans for security regressions needs a CI/CD pipeline — even if the “platform” is a personal blog. This site runs three GitHub Actions workflows that form a feedback loop: build and deploy with scheduled future-post publishing, CodeQL security scanning of the workflow YAML itself, and an SEO health check that validates sitemaps, canonical URLs, structured data, and accessibility on every content push. Dependabot closes the loop on dependency hygiene across three package ecosystems.</p>

<p>Most Jekyll blogs on GitHub Pages use the default build. Push to main, GitHub builds it, done. That worked for me too — until I needed custom plugins, scheduled future posts, and wanted to stop deploying broken sitemaps.</p>

<p>Here’s how it all fits together.</p>

<!-- excerpt-end -->

<h2 id="the-pipeline-at-a-glance">The Pipeline at a Glance</h2>

<pre><code class="language-mermaid">graph LR
    A[git push to main] --&gt; B[Jekyll Build &amp; Deploy]
    A --&gt; C[CodeQL Security Scan]
    A --&gt; D[SEO Health Check]
    E[Daily 10:05 UTC] --&gt; B
    F[Weekly Friday 02:43 UTC] --&gt; C
    G[Weekly Monday 06:00 UTC] --&gt; D
    H[Dependabot] --&gt; I[PRs for dependency updates]
    I --&gt; A
</code></pre>

<table>
  <thead>
    <tr>
      <th>Workflow</th>
      <th>File</th>
      <th>Triggers</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Build &amp; Deploy</td>
      <td><code class="language-plaintext highlighter-rouge">jekyll.yml</code></td>
      <td>Push, daily cron, manual</td>
      <td>Build site, validate sitemap, deploy to GitHub Pages</td>
    </tr>
    <tr>
      <td>CodeQL</td>
      <td><code class="language-plaintext highlighter-rouge">codeql.yml</code></td>
      <td>Push, PR, weekly cron</td>
      <td>Security scanning of GitHub Actions workflows</td>
    </tr>
    <tr>
      <td>SEO Health Check</td>
      <td><code class="language-plaintext highlighter-rouge">seo-health-check.yml</code></td>
      <td>Push (path-filtered), weekly cron, manual</td>
      <td>Lighthouse CI, link checking, SEO validation</td>
    </tr>
    <tr>
      <td>Dependabot</td>
      <td><code class="language-plaintext highlighter-rouge">dependabot.yml</code></td>
      <td>Daily (Actions, Bundler), weekly (npm)</td>
      <td>Dependency update PRs</td>
    </tr>
  </tbody>
</table>

<h2 id="workflow-1-build-and-deploy">Workflow 1: Build and Deploy</h2>

<p>This is the core workflow. It replaced the default GitHub Pages build in August 2024 when I needed custom plugins that aren’t in the <a href="https://pages.github.com/versions/">GitHub Pages whitelist</a>.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Jekyll site to Pages</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">main"</span><span class="pi">]</span>
  <span class="na">schedule</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">5</span><span class="nv"> </span><span class="s">10</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*'</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span>

<span class="na">permissions</span><span class="pi">:</span>
  <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
  <span class="na">pages</span><span class="pi">:</span> <span class="s">write</span>
  <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>

<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s2">"</span><span class="s">pages"</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="kc">false</span>
</code></pre></div></div>

<h3 id="why-a-custom-build">Why a Custom Build?</h3>

<p>The default GitHub Pages Jekyll build is convenient but limiting:</p>

<ul>
  <li><strong>No custom plugins</strong> — Only <a href="https://pages.github.com/versions/">whitelisted gems</a> run. My <a href="/jekyll-tag-category-generator-plugin/">tag/category generator</a> and <a href="/jekyll-pandoc-exports-plugin/">Pandoc exports plugin</a> need a custom build.</li>
  <li><strong>No Ruby version pinning</strong> — GitHub controls the Ruby version. I pin to 3.2.6 for reproducibility.</li>
  <li><strong>No build validation</strong> — The default build deploys whatever Jekyll produces. I validate the sitemap before deploying.</li>
</ul>

<h3 id="scheduled-builds-for-future-posts">Scheduled Builds for Future Posts</h3>

<p>The <code class="language-plaintext highlighter-rouge">schedule</code> trigger is the key feature that makes future-dated posts work:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">schedule</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">5</span><span class="nv"> </span><span class="s">10</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*'</span>  <span class="c1"># 10:05 UTC daily (6:05 AM EDT)</span>
</code></pre></div></div>

<p>See <a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule">GitHub’s scheduled events documentation</a> for cron syntax details.</p>

<p>Jekyll’s <code class="language-plaintext highlighter-rouge">future: false</code> setting (the default) excludes posts with dates in the future from the build output. When a post’s date arrives, the next build picks it up. The daily cron at 10:05 UTC (6:05 AM EDT) means a post dated <code class="language-plaintext highlighter-rouge">2026-05-15</code> will go live within 24 hours of that date — close enough for a blog.</p>

<p>Without this, I’d have to manually push a commit or trigger a build on the day I want a post to go live.</p>

<h3 id="the---future-flag-gotcha">The <code class="language-plaintext highlighter-rouge">--future</code> Flag Gotcha</h3>

<p>During local development, <code class="language-plaintext highlighter-rouge">bundle exec jekyll serve</code> also respects <code class="language-plaintext highlighter-rouge">future: false</code> — future-dated posts won’t render locally unless you add the <code class="language-plaintext highlighter-rouge">--future</code> flag:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">exec </span>jekyll serve <span class="nt">--future</span>
</code></pre></div></div>

<p>This confused me early on. I’d write a post with tomorrow’s date, run the local server, and the post wouldn’t appear. The <code class="language-plaintext highlighter-rouge">future: false</code> config setting only controls the <em>build output</em>, not whether the file is recognized. The <code class="language-plaintext highlighter-rouge">--future</code> flag overrides it for local previewing. In production, the daily cron handles it — you never need <code class="language-plaintext highlighter-rouge">future: true</code> in <code class="language-plaintext highlighter-rouge">_config.yml</code>.</p>

<h3 id="sitemap-validation">Sitemap Validation</h3>

<p>After discovering that a bad build once deployed a sitemap full of <code class="language-plaintext highlighter-rouge">localhost</code> URLs, I added a pre-deploy validation step:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Validate sitemap URLs</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">if grep -q 'localhost' ./_site/sitemap.xml; then</span>
      <span class="s">echo "::error::sitemap.xml contains localhost URLs"</span>
      <span class="s">grep 'localhost' ./_site/sitemap.xml | head -5</span>
      <span class="s">exit 1</span>
    <span class="s">fi</span>
    <span class="s">echo "Sitemap OK: all URLs use production domain"</span>
</code></pre></div></div>

<p>This is a cheap check that has saved me at least once. The <code class="language-plaintext highlighter-rouge">JEKYLL_ENV: production</code> environment variable is also critical — without it, Jekyll may use development URLs.</p>

<h3 id="build-steps">Build Steps</h3>

<p>The full build job:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v6</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1.300.0</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">3.2.6'</span>
          <span class="na">bundler-cache</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">cache-version</span><span class="pi">:</span> <span class="m">1</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/configure-pages@v6</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build with Jekyll</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">JEKYLL_ENV</span><span class="pi">:</span> <span class="s">production</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Validate sitemap URLs</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">if grep -q 'localhost' ./_site/sitemap.xml; then</span>
            <span class="s">echo "::error::sitemap.xml contains localhost URLs"</span>
            <span class="s">exit 1</span>
          <span class="s">fi</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-pages-artifact@v4</span>
</code></pre></div></div>

<p>Key details:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">bundler-cache: true</code></strong> — Caches installed gems between runs. Cuts build time significantly.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">cache-version: 1</code></strong> — Increment this to force a fresh gem install if the cache gets corrupted.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">configure-pages</code></strong> — Sets the base path for GitHub Pages. Required for correct URL generation.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">cancel-in-progress: false</code></strong> — Don’t cancel a running deployment if a new push arrives. Let it finish.</li>
</ul>

<h3 id="evolution">Evolution</h3>

<p>The build workflow has been through nine commits since August 2024:</p>

<ol>
  <li><strong>Aug 2024</strong> — Initial creation, replacing default GitHub Pages build</li>
  <li><strong>Jan 2025</strong> — Ruby version updates and deploy comments</li>
  <li><strong>May 2025</strong> — Fix caching issues during build</li>
  <li><strong>Sep 2025</strong> — Add scheduled daily builds for future posts</li>
  <li><strong>Sep 2025</strong> — Dependabot bumps for checkout and upload-pages-artifact</li>
  <li><strong>Apr 2026</strong> — Add sitemap localhost validation</li>
  <li><strong>Apr 2026</strong> — Update all actions to Node 24 compatible versions</li>
</ol>

<h2 id="workflow-2-codeql-security-scanning">Workflow 2: CodeQL Security Scanning</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">CodeQL</span><span class="nv"> </span><span class="s">Advanced"</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">main"</span><span class="pi">]</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">main"</span><span class="pi">]</span>
  <span class="na">schedule</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">43</span><span class="nv"> </span><span class="s">2</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">5'</span>  <span class="c1"># Weekly Friday at 02:43 UTC</span>
</code></pre></div></div>

<h3 id="what-does-codeql-scan-on-a-jekyll-blog">What Does CodeQL Scan on a Jekyll Blog?</h3>

<p>Not much, honestly. The initial setup in September 2025 included Ruby and JavaScript language analysis, but those were removed the same day — CodeQL’s Ruby analysis isn’t useful for Jekyll plugins (they’re too simple), and the JavaScript is mostly CDN-loaded.</p>

<p>What remains is <strong>GitHub Actions workflow analysis</strong> (<code class="language-plaintext highlighter-rouge">language: actions</code>), which scans the workflow YAML files themselves for security issues like:</p>

<ul>
  <li>Untrusted input in <code class="language-plaintext highlighter-rouge">run</code> steps</li>
  <li>Missing permission restrictions</li>
  <li>Vulnerable action versions</li>
  <li>Script injection via <code class="language-plaintext highlighter-rouge">${{ }}</code> expressions</li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">strategy</span><span class="pi">:</span>
  <span class="na">fail-fast</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">matrix</span><span class="pi">:</span>
    <span class="na">include</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">language</span><span class="pi">:</span> <span class="s">actions</span>
      <span class="na">build-mode</span><span class="pi">:</span> <span class="s">none</span>
</code></pre></div></div>

<h3 id="is-it-worth-it">Is It Worth It?</h3>

<p>For a static blog? Marginally. The Actions language scanner has caught zero issues so far. But it’s free, runs weekly, and takes under a minute. The real value is that it’s already configured — if I add more complex JavaScript or Ruby in the future, I can re-enable those language scanners with one line change.</p>

<h2 id="workflow-3-seo-health-check">Workflow 3: SEO Health Check</h2>

<p>This is the most complex workflow and has its own <a href="/jekyll-seo-health-checks/">dedicated article</a>. Here’s the summary of what it validates on every content push and weekly:</p>

<h3 id="triggers">Triggers</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">on</span><span class="pi">:</span>
  <span class="na">schedule</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0</span><span class="nv"> </span><span class="s">6</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">1'</span>  <span class="c1"># Weekly Monday at 6 AM UTC</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_config.yml'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_layouts/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_includes/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_plugins/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_posts/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_sass/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">assets/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">robots.txt'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.github/workflows/seo-health-check.yml'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.lighthouserc.json'</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">paths</code> filter is important — this workflow only runs on pushes that change content or configuration, not on README edits or draft changes. This saves CI minutes.</p>

<h3 id="what-it-checks">What It Checks</h3>

<p>The workflow builds the site, serves it locally, then runs a gauntlet of checks:</p>

<ol>
  <li><strong>Lighthouse CI</strong> — Performance (≥0.8), accessibility (≥0.9), best practices (≥0.8), SEO (≥0.9 — hard fail). Runs 3 times per URL and averages. Blocks AdSense and Analytics scripts to get clean scores.</li>
  <li><strong>Canonical URL consistency</strong> — Every <code class="language-plaintext highlighter-rouge">&lt;link rel="canonical"&gt;</code> tag must use <code class="language-plaintext highlighter-rouge">mcgarrah.org</code></li>
  <li><strong>Sitemap validation</strong> — Valid XML, correct domain, no <code class="language-plaintext highlighter-rouge">www</code> prefix</li>
  <li><strong>Sitemap index validation</strong> — References both blog and resume sitemaps</li>
  <li><strong>Robots.txt</strong> — Exists, references correct sitemap index</li>
  <li><strong>Meta tags</strong> — Description and Open Graph tags on homepage</li>
  <li><strong>RSS feed</strong> — Valid XML</li>
  <li><strong>Link checking</strong> — <a href="https://github.com/lycheeverse/lychee-action">Lychee</a> for broken links across all HTML</li>
  <li><strong>Structured data</strong> — JSON-LD presence</li>
  <li><strong>Image optimization</strong> — Missing alt text, oversized images (&gt;500KB)</li>
  <li><strong>Content quality</strong> — Duplicate titles, duplicate meta descriptions, generic link text (“click here”, “read more”)</li>
  <li><strong>Mobile optimization</strong> — Viewport meta tag coverage</li>
  <li><strong>Accessibility indicators</strong> — Invalid anchors, small tap targets</li>
</ol>

<h3 id="lighthouse-configuration">Lighthouse Configuration</h3>

<p>The <code class="language-plaintext highlighter-rouge">.lighthouserc.json</code> blocks ad and analytics scripts to get clean performance scores:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"ci"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"collect"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"numberOfRuns"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
      </span><span class="nl">"settings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"blockedUrlPatterns"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="s2">"**/pagead/js/adsbygoogle.js*"</span><span class="p">,</span><span class="w">
          </span><span class="s2">"**/googlesyndication.com/**"</span><span class="p">,</span><span class="w">
          </span><span class="s2">"**/googletagmanager.com/**"</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"assert"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"assertions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"categories:performance"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"warn"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nl">"minScore"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.8</span><span class="p">}],</span><span class="w">
        </span><span class="nl">"categories:accessibility"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"warn"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nl">"minScore"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span><span class="p">}],</span><span class="w">
        </span><span class="nl">"categories:seo"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"error"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nl">"minScore"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span><span class="p">}]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>SEO is the only hard fail (<code class="language-plaintext highlighter-rouge">error</code>). Performance and accessibility are warnings — I want to know about regressions but don’t want to block deploys over a 0.79 performance score.</p>

<h3 id="the-canonical-url-bug">The Canonical URL Bug</h3>

<p>The SEO health check went through five rapid-fire commits on April 8, 2026 — all fixing the same class of problem. The canonical URL check was matching <code class="language-plaintext highlighter-rouge">mcgarrah.org</code> in blog post <em>content</em> (syntax-highlighted code examples), not just in <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tags. The fix narrowed the grep to match only actual <code class="language-plaintext highlighter-rouge">&lt;link rel="canonical"&gt;</code> tags:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>find _site <span class="nt">-name</span> <span class="s2">"*.html"</span> <span class="nt">-exec</span> <span class="nb">grep</span> <span class="nt">-h</span> <span class="s1">'&lt;link[^&gt;]*rel="canonical"'</span> <span class="o">{}</span> <span class="se">\;</span>
</code></pre></div></div>

<p>Lesson: when your blog posts contain code examples about your own blog’s configuration, your CI checks will match the examples. Always be specific in your grep patterns.</p>

<h2 id="dependabot">Dependabot</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="m">2</span>
<span class="na">updates</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">package-ecosystem</span><span class="pi">:</span> <span class="s2">"</span><span class="s">github-actions"</span>
    <span class="na">directory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">."</span>
    <span class="na">schedule</span><span class="pi">:</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s2">"</span><span class="s">daily"</span>
  <span class="pi">-</span> <span class="na">package-ecosystem</span><span class="pi">:</span> <span class="s2">"</span><span class="s">bundler"</span>
    <span class="na">directory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./"</span>
    <span class="na">schedule</span><span class="pi">:</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s2">"</span><span class="s">daily"</span>
  <span class="pi">-</span> <span class="na">package-ecosystem</span><span class="pi">:</span> <span class="s2">"</span><span class="s">npm"</span>
    <span class="na">directory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./"</span>
    <span class="na">schedule</span><span class="pi">:</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s2">"</span><span class="s">weekly"</span>
</code></pre></div></div>

<p>Three ecosystems, three schedules:</p>

<ul>
  <li><strong>GitHub Actions</strong> (daily) — Catches action version bumps quickly. These are the most security-sensitive since they run with repo permissions.</li>
  <li><strong>Bundler</strong> (daily) — Jekyll and Ruby gem updates. The <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> pins exact versions.</li>
  <li><strong>npm</strong> (weekly) — Tracks CDN library versions in <code class="language-plaintext highlighter-rouge">package.json</code> for security scanning, even though the actual libraries load from CDN at runtime. See <a href="/implementing-gdpr-compliance-jekyll-adsense/">the security dependency management</a> post for why.</li>
</ul>

<p>Dependabot creates PRs automatically. Each PR triggers the build and CodeQL workflows, so I get a test build before merging.</p>

<h2 id="how-the-pieces-interact">How the Pieces Interact</h2>

<p>The workflows aren’t isolated — they form a feedback loop:</p>

<ol>
  <li><strong>Dependabot</strong> creates a PR to bump <code class="language-plaintext highlighter-rouge">actions/checkout</code> from v5 to v6</li>
  <li>The PR triggers <strong>CodeQL</strong> (scans the updated workflow YAML) and <strong>Build</strong> (tests the build with the new action version)</li>
  <li>I merge the PR → push to main</li>
  <li>Push triggers all three workflows: <strong>Build</strong> deploys, <strong>CodeQL</strong> scans, <strong>SEO Health Check</strong> validates</li>
  <li>If the SEO check finds a regression (broken link, missing meta tag), I fix it and push again</li>
</ol>

<p>The <strong>daily cron</strong> on the build workflow handles future-dated posts without any manual intervention. The <strong>weekly crons</strong> on CodeQL and SEO catch drift — a dependency that introduced a vulnerability, or an external link that went dead.</p>

<h2 id="cost">Cost</h2>

<p>All of this runs on GitHub’s free tier for public repositories. The monthly usage is minimal:</p>

<ul>
  <li>Build &amp; Deploy: ~30 runs/month (daily cron + pushes), ~2 min each</li>
  <li>CodeQL: ~8 runs/month (weekly + pushes), ~1 min each</li>
  <li>SEO Health Check: ~12 runs/month (weekly + content pushes), ~4 min each</li>
  <li>Total: ~100 minutes/month, well within the 2,000 free minutes</li>
</ul>

<h2 id="what-id-add-next">What I’d Add Next</h2>

<ul>
  <li><strong>HTML-Proofer</strong> — More thorough internal link validation than my custom script</li>
  <li><strong>Pa11y</strong> — Automated accessibility testing beyond Lighthouse</li>
  <li><strong>Build time tracking</strong> — Alert if build time exceeds a threshold (currently ~90 seconds)</li>
  <li><strong>Deployment notifications</strong> — Slack or email on successful deploy of future-dated posts</li>
</ul>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/jekyll-seo-health-checks/">Advanced Jekyll SEO Health Checks</a> — Deep dive into the SEO workflow</li>
  <li><a href="/setting-up-jekyll-blog-github-pages/">Building This Blog: Jekyll on GitHub Pages</a> — Setup guide with CI/CD overview</li>
  <li><a href="/github-actions-pip-audit-pr/">Using GitHub Actions with pip-audit</a> — Similar CI pattern for Python projects</li>
  <li><a href="/jekyll-tag-category-generator-plugin/">Building a Custom Tag and Category Generator Plugin</a> — One of the custom plugins that requires this pipeline</li>
  <li><a href="/jekyll-sitemap-bloat-tags-categories-pagination/">Your Jekyll Sitemap Is 60% Garbage</a> — The sitemap problem that led to the validation step</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made</a> — Full feature inventory</li>
  <li><a href="/ruby-gem-release-automation/">Ruby Gem Release Automation</a> — CI/CD for the Pandoc exports gem</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="github-actions" /><category term="ci-cd" /><category term="codeql" /><category term="seo" /><category term="lighthouse" /><category term="dependabot" /><category term="automation" /><category term="github-pages" /><category term="security" /><summary type="html"><![CDATA[A complete walkthrough of the GitHub Actions CI/CD pipeline for a Jekyll blog on GitHub Pages: custom build and deploy with scheduled future posts, CodeQL security scanning, SEO health checks with Lighthouse CI, Dependabot dependency management, and the lessons learned building it over two years.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-github-actions-cicd-pipeline.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-github-actions-cicd-pipeline.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Adding Comments to a Static Site: Why I Chose Giscus for Jekyll</title><link href="https://mcgarrah.org/jekyll-giscus-comments-implementation/" rel="alternate" type="text/html" title="Adding Comments to a Static Site: Why I Chose Giscus for Jekyll" /><published>2026-05-06T00:00:00+00:00</published><updated>2026-05-06T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-giscus-comments-implementation</id><content type="html" xml:base="https://mcgarrah.org/jekyll-giscus-comments-implementation/"><![CDATA[<p>Jekyll is a static site generator. There’s no server, no database, no backend. When someone visits a page, they get pre-built HTML files served from a CDN. That’s the whole point — it’s fast, cheap, and secure.</p>

<p>But comments need state. Someone writes a comment, it has to be stored somewhere, and the next visitor needs to see it. Adding state to a stateless platform is a fundamental design tension — you need a data store, but you chose a platform specifically because it doesn’t have one.</p>

<!-- excerpt-end -->

<h2 id="the-problem">The Problem</h2>

<p>I wanted comments on this blog for a simple reason: readers ask good questions. When someone finds a gap in a Proxmox walkthrough or catches an error in a Ceph command, that feedback is valuable — not just to me, but to the next person reading the same post. Email works for one-to-one, but comments are one-to-many.</p>

<p>The requirements:</p>

<ol>
  <li><strong>No self-hosted infrastructure</strong> — I’m not running a database for blog comments</li>
  <li><strong>Free or very cheap</strong> — This is a hobby blog</li>
  <li><strong>GitHub-friendly</strong> — My readers are technical; most have GitHub accounts</li>
  <li><strong>GDPR-compatible</strong> — No third-party tracking cookies</li>
  <li><strong>Persistent</strong> — Comments survive site rebuilds and theme changes</li>
  <li><strong>Searchable</strong> — Ideally indexed and findable</li>
  <li><strong>Low maintenance</strong> — No moderation queue to babysit</li>
</ol>

<h2 id="the-alternatives-i-evaluated">The Alternatives I Evaluated</h2>

<h3 id="disqus--the-default-choice-rejected">Disqus — The Default Choice (Rejected)</h3>

<p><a href="https://disqus.com/">Disqus</a> is the most common comment system for static sites. It’s easy to embed and has a large user base.</p>

<p>Why I rejected it:</p>

<ul>
  <li><strong>Ads on the free tier</strong> — Disqus injects ads into your comment section unless you pay</li>
  <li><strong>Tracking and privacy</strong> — Disqus loads significant third-party JavaScript and tracks users across sites. This is a GDPR nightmare for a blog that already went through <a href="/implementing-gdpr-compliance-jekyll-adsense/">extensive GDPR compliance work</a></li>
  <li><strong>Data ownership</strong> — Comments live on Disqus’s servers. If they shut down or change terms, your comments are gone</li>
  <li><strong>Heavy JavaScript</strong> — The embed script is large and slows page load</li>
</ul>

<p>The original Jekyll theme I forked (Contrast) actually had Disqus support built in. The dead code is still in my <code class="language-plaintext highlighter-rouge">post.html</code> layout — a <code class="language-plaintext highlighter-rouge">disqus_thread</code> div that never renders because <code class="language-plaintext highlighter-rouge">site.comments.disqus_shortname</code> is never set.</p>

<h3 id="isso--self-hosted-alternative-rejected">Isso — Self-Hosted Alternative (Rejected)</h3>

<p><a href="https://isso-comments.de/">Isso</a> is a self-hosted, lightweight commenting server. It stores comments in a SQLite database and has a clean, minimal interface.</p>

<p>Why I rejected it:</p>

<ul>
  <li><strong>Requires a server</strong> — You need to run the Isso daemon somewhere. That’s infrastructure I don’t want to maintain for blog comments</li>
  <li><strong>SQLite on a server</strong> — Backups, uptime, security patches — all for a comment system</li>
</ul>

<p>The theme also had Isso support built in. Same dead code situation — <code class="language-plaintext highlighter-rouge">isso_domain</code> is never configured.</p>

<h3 id="github-issues-api--custom-lambda-evaluated-deeply">GitHub Issues API — Custom Lambda (Evaluated Deeply)</h3>

<p>This approach uses GitHub Issues as the comment store. Each blog post maps to a GitHub Issue. Comments on the Issue appear as comments on the post. <a href="https://www.aleksandrhovhannisyan.com/blog/jekyll-comment-system-github-issues/">Aleksandr Hovhannisyan’s implementation</a> is the best-known version.</p>

<p>I went deep on this one — deep enough to have <a href="https://chatgpt.com/c/67804dbd-2d38-8010-8074-f43b50bee567">ChatGPT convert the original Netlify serverless function to a Python Lambda</a>. The full prototype:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">get_comments_for_post</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">
    Lambda function to fetch comments for a GitHub issue dynamically.
    </span><span class="sh">"""</span>

    <span class="k">try</span><span class="p">:</span>
        <span class="c1"># Extract query parameters
</span>        <span class="n">query_params</span> <span class="o">=</span> <span class="n">event</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">queryStringParameters</span><span class="sh">"</span><span class="p">,</span> <span class="p">{})</span>
        <span class="n">issue_number</span> <span class="o">=</span> <span class="n">query_params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">)</span>
        <span class="n">github_url</span> <span class="o">=</span> <span class="n">query_params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">url</span><span class="sh">"</span><span class="p">)</span>

        <span class="k">if</span> <span class="ow">not</span> <span class="n">issue_number</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">issue_number</span><span class="p">.</span><span class="nf">isdigit</span><span class="p">():</span>
            <span class="k">return</span> <span class="p">{</span>
                <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">400</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">You must specify a valid issue ID.</span><span class="sh">"</span><span class="p">}),</span>
            <span class="p">}</span>

        <span class="c1"># Determine owner and repo
</span>        <span class="k">if</span> <span class="n">github_url</span><span class="p">:</span>
            <span class="n">owner</span><span class="p">,</span> <span class="n">repo</span> <span class="o">=</span> <span class="nf">extract_owner_and_repo</span><span class="p">(</span><span class="n">github_url</span><span class="p">)</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">owner</span> <span class="o">=</span> <span class="n">query_params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">owner</span><span class="sh">"</span><span class="p">)</span>
            <span class="n">repo</span> <span class="o">=</span> <span class="n">query_params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">repo</span><span class="sh">"</span><span class="p">)</span>

        <span class="k">if</span> <span class="ow">not</span> <span class="n">owner</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">repo</span><span class="p">:</span>
            <span class="k">return</span> <span class="p">{</span>
                <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">400</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">You must specify </span><span class="sh">'</span><span class="s">owner</span><span class="sh">'</span><span class="s"> and </span><span class="sh">'</span><span class="s">repo</span><span class="sh">'</span><span class="s"> or provide a valid GitHub URL.</span><span class="sh">"</span><span class="p">}),</span>
            <span class="p">}</span>

        <span class="n">issue_number</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">issue_number</span><span class="p">)</span>

        <span class="c1"># Check API rate limit
</span>        <span class="n">rate_limit</span> <span class="o">=</span> <span class="n">octokit</span><span class="p">.</span><span class="nf">request</span><span class="p">(</span><span class="sh">"</span><span class="s">GET /rate_limit</span><span class="sh">"</span><span class="p">)[</span><span class="sh">"</span><span class="s">rate</span><span class="sh">"</span><span class="p">]</span>
        <span class="n">remaining_requests</span> <span class="o">=</span> <span class="n">rate_limit</span><span class="p">[</span><span class="sh">"</span><span class="s">remaining</span><span class="sh">"</span><span class="p">]</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">GitHub API requests remaining: </span><span class="si">{</span><span class="n">remaining_requests</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">remaining_requests</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
            <span class="k">return</span> <span class="p">{</span>
                <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">503</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">API rate limit exceeded.</span><span class="sh">"</span><span class="p">}),</span>
            <span class="p">}</span>

        <span class="c1"># Fetch comments for the given issue
</span>        <span class="n">comments_response</span> <span class="o">=</span> <span class="n">octokit</span><span class="p">.</span><span class="nf">paginate</span><span class="p">(</span>
            <span class="sh">"</span><span class="s">GET /repos/{owner}/{repo}/issues/{issue_number}/comments</span><span class="sh">"</span><span class="p">,</span>
            <span class="p">{</span><span class="sh">"</span><span class="s">owner</span><span class="sh">"</span><span class="p">:</span> <span class="n">owner</span><span class="p">,</span> <span class="sh">"</span><span class="s">repo</span><span class="sh">"</span><span class="p">:</span> <span class="n">repo</span><span class="p">,</span> <span class="sh">"</span><span class="s">issue_number</span><span class="sh">"</span><span class="p">:</span> <span class="n">issue_number</span><span class="p">},</span>
        <span class="p">)</span>

        <span class="c1"># Process comments
</span>        <span class="n">response</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">comment</span> <span class="ow">in</span> <span class="n">comments_response</span><span class="p">:</span>
            <span class="n">response</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span>
                <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="sh">"</span><span class="s">avatarUrl</span><span class="sh">"</span><span class="p">:</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">][</span><span class="sh">"</span><span class="s">avatar_url</span><span class="sh">"</span><span class="p">],</span>
                    <span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">:</span> <span class="nf">escape</span><span class="p">(</span><span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">][</span><span class="sh">"</span><span class="s">login</span><span class="sh">"</span><span class="p">]),</span>
                    <span class="sh">"</span><span class="s">isAuthor</span><span class="sh">"</span><span class="p">:</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">author_association</span><span class="sh">"</span><span class="p">]</span> <span class="o">==</span> <span class="sh">"</span><span class="s">OWNER</span><span class="sh">"</span><span class="p">,</span>
                <span class="p">},</span>
                <span class="sh">"</span><span class="s">dateTime</span><span class="sh">"</span><span class="p">:</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">created_at</span><span class="sh">"</span><span class="p">],</span>
                <span class="sh">"</span><span class="s">dateRelative</span><span class="sh">"</span><span class="p">:</span> <span class="nf">str</span><span class="p">((</span><span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">fromisoformat</span><span class="p">(</span>
                    <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">created_at</span><span class="sh">"</span><span class="p">].</span><span class="nf">replace</span><span class="p">(</span><span class="sh">"</span><span class="s">Z</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">))).</span><span class="n">days</span><span class="p">)</span> <span class="o">+</span> <span class="sh">"</span><span class="s"> days ago</span><span class="sh">"</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">isEdited</span><span class="sh">"</span><span class="p">:</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">created_at</span><span class="sh">"</span><span class="p">]</span> <span class="o">!=</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">updated_at</span><span class="sh">"</span><span class="p">],</span>
                <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="nf">escape</span><span class="p">(</span><span class="nf">markdown</span><span class="p">(</span><span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">])),</span>
            <span class="p">})</span>

        <span class="k">return</span> <span class="p">{</span>
            <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">200</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">data</span><span class="sh">"</span><span class="p">:</span> <span class="n">response</span><span class="p">}),</span>
        <span class="p">}</span>

    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Error: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="p">{</span>
            <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">500</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Unable to fetch comments for this post.</span><span class="sh">"</span><span class="p">}),</span>
        <span class="p">}</span>
</code></pre></div></div>

<p>This prototype handled input validation, GitHub API rate limiting, pagination, relative date formatting, edit detection, and Markdown rendering. It worked — but it was a lot of moving parts for blog comments.</p>

<p>Why I ultimately rejected it:</p>

<ul>
  <li><strong>Requires a serverless function</strong> — Whether it’s Netlify Functions, AWS Lambda, or Cloudflare Workers, you need a backend to proxy the GitHub API (to avoid exposing tokens client-side)</li>
  <li><strong>GitHub Issues aren’t designed for comments</strong> — Issues have a flat structure. No threading, no reactions on individual comments (only on the issue itself)</li>
  <li><strong>Manual issue creation</strong> — You have to create a GitHub Issue for each post and link them. That’s a maintenance burden</li>
  <li><strong>API rate limits</strong> — The GitHub API has rate limits that could be hit on popular posts</li>
</ul>

<h3 id="utterances--github-issues-client-side-close-second">Utterances — GitHub Issues, Client-Side (Close Second)</h3>

<p><a href="https://github.com/utterance/utterances">Utterances</a> solves the serverless function problem by using a GitHub App to authenticate directly from the client. It still uses GitHub Issues as the backend but doesn’t need a proxy.</p>

<p>Why I almost chose it:</p>

<ul>
  <li>No server needed — just a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag</li>
  <li>Clean, minimal UI</li>
  <li>GitHub authentication (my audience has GitHub accounts)</li>
  <li>Open source</li>
</ul>

<p>Why I chose Giscus instead:</p>

<ul>
  <li>Utterances uses <strong>Issues</strong>, Giscus uses <strong>Discussions</strong> — Discussions have threading, categories, and reactions</li>
  <li>Utterances was less actively maintained at the time I evaluated it</li>
  <li>Giscus is essentially “Utterances but better” — same concept, newer implementation, more features</li>
</ul>

<h3 id="staticman--git-based-comments-rejected">Staticman — Git-Based Comments (Rejected)</h3>

<p><a href="https://staticman.net/">Staticman</a> takes a different approach: comments are submitted via a form, processed by a bot, and committed to your repository as data files (YAML/JSON). Jekyll then renders them at build time.</p>

<p>Why I rejected it:</p>

<ul>
  <li><strong>Build required for every comment</strong> — Each comment triggers a site rebuild. That’s slow and burns CI minutes</li>
  <li><strong>Moderation via pull requests</strong> — Clever, but adds friction</li>
  <li><strong>Self-hosted bot or shared instance</strong> — The shared instance has availability issues; self-hosting is more infrastructure</li>
</ul>

<h3 id="gdpr-compliant-approaches">GDPR-Compliant Approaches</h3>

<p>The <a href="https://jekyllcodex.org/blog/gdpr-compliant-comment/">Jekyll Codex GDPR-compliant comments</a> guide was useful for understanding the privacy landscape. Any solution that loads third-party JavaScript or sends user data to external servers needs consent management.</p>

<h2 id="why-giscus-won">Why Giscus Won</h2>

<p><a href="https://giscus.app/">Giscus</a> uses <strong>GitHub Discussions</strong> as the comment backend. It’s a single <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag that embeds a widget powered by the GitHub Discussions API via a GitHub App.</p>

<p>The key insight: <strong>everything stays in the GitHub ecosystem</strong>. The blog source is on GitHub. The build runs on GitHub Actions. The site deploys to GitHub Pages. Comments live in GitHub Discussions. There’s no external service, no separate database, no additional account.</p>

<p>What sealed the decision:</p>

<ul>
  <li><strong>Threaded replies</strong> — Discussions support nested replies, Issues don’t</li>
  <li><strong>Reactions</strong> — Readers can react to individual comments, not just the top-level post</li>
  <li><strong>Categories</strong> — Comments go into the “Announcements” category, keeping them organized</li>
  <li><strong>Automatic mapping</strong> — <code class="language-plaintext highlighter-rouge">pathname</code> mapping means Giscus creates a Discussion for each post URL automatically. No manual issue creation</li>
  <li><strong>Lazy loading</strong> — The widget loads only when scrolled into view (<code class="language-plaintext highlighter-rouge">loading: lazy</code>)</li>
  <li><strong>Theme matching</strong> — <code class="language-plaintext highlighter-rouge">preferred_color_scheme</code> follows the reader’s dark/light mode preference</li>
  <li><strong>Searchable</strong> — GitHub Discussions are fully searchable, both on GitHub and via search engines</li>
  <li><strong>No server</strong> — Just a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag and a <code class="language-plaintext highlighter-rouge">_config.yml</code> entry</li>
  <li><strong>GDPR-friendly</strong> — Giscus loads from <code class="language-plaintext highlighter-rouge">giscus.app</code> (a GitHub App), not a third-party ad network. No tracking cookies. The <a href="https://github.com/giscus/giscus/blob/main/SELF-HOSTING.md">self-hosting option</a> exists if you want full control</li>
</ul>

<h2 id="implementation">Implementation</h2>

<h3 id="step-1-enable-github-discussions">Step 1: Enable GitHub Discussions</h3>

<p>In the repository settings (<code class="language-plaintext highlighter-rouge">mcgarrah/mcgarrah.github.io</code>), enable the Discussions feature and create an “Announcements” category.</p>

<h3 id="step-2-install-the-giscus-github-app">Step 2: Install the Giscus GitHub App</h3>

<p>Go to <a href="https://giscus.app/">giscus.app</a>, select your repository, and configure the options. It generates the <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag and gives you the <code class="language-plaintext highlighter-rouge">repo_id</code> and <code class="language-plaintext highlighter-rouge">category_id</code> values.</p>

<h3 id="step-3-add-configuration-to-_configyml">Step 3: Add Configuration to <code class="language-plaintext highlighter-rouge">_config.yml</code></h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">giscus</span><span class="pi">:</span>
  <span class="na">repo</span><span class="pi">:</span> <span class="s">mcgarrah/mcgarrah.github.io</span>
  <span class="na">repo_id</span><span class="pi">:</span> <span class="s">R_kgDOKBKIdw</span>
  <span class="na">category</span><span class="pi">:</span> <span class="s">Announcements</span>
  <span class="na">category_id</span><span class="pi">:</span> <span class="s">DIC_kwDOKBKId84Cq3DK</span>
  <span class="na">mapping</span><span class="pi">:</span> <span class="s">pathname</span>
  <span class="na">strict</span><span class="pi">:</span> <span class="m">0</span>
  <span class="na">reactions_enabled</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">emit_metadata</span><span class="pi">:</span> <span class="m">0</span>
  <span class="na">input_position</span><span class="pi">:</span> <span class="s">bottom</span>
  <span class="na">theme</span><span class="pi">:</span> <span class="s">preferred_color_scheme</span>
  <span class="na">lang</span><span class="pi">:</span> <span class="s">en</span>
  <span class="na">loading</span><span class="pi">:</span> <span class="s">lazy</span>
</code></pre></div></div>

<p>Key configuration choices:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">mapping: pathname</code></strong> — Maps posts to Discussions by URL path. This means <code class="language-plaintext highlighter-rouge">/proxmox-ceph-nearfull/</code> gets its own Discussion automatically</li>
  <li><strong><code class="language-plaintext highlighter-rouge">strict: 0</code></strong> — Fuzzy matching on the pathname. Tolerates minor URL changes</li>
  <li><strong><code class="language-plaintext highlighter-rouge">input_position: bottom</code></strong> — Comment box below existing comments (natural reading order)</li>
  <li><strong><code class="language-plaintext highlighter-rouge">loading: lazy</code></strong> — Don’t load the iframe until the reader scrolls to the comments section. Improves initial page load performance</li>
  <li><strong><code class="language-plaintext highlighter-rouge">theme: preferred_color_scheme</code></strong> — Matches the reader’s OS dark/light mode setting</li>
</ul>

<h3 id="step-4-add-the-widget-to-the-post-layout">Step 4: Add the Widget to the Post Layout</h3>

<p>In <code class="language-plaintext highlighter-rouge">_layouts/post.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{%- if site.giscus -%}
<span class="nt">&lt;section</span> <span class="na">class=</span><span class="s">"page__comments"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://giscus.app/client.js"</span>
          <span class="na">data-repo=</span><span class="s">"{{ site.giscus.repo }}"</span>
          <span class="na">data-repo-id=</span><span class="s">"{{ site.giscus.repo_id }}"</span>
          <span class="na">data-category=</span><span class="s">"{{ site.giscus.category }}"</span>
          <span class="na">data-category-id=</span><span class="s">"{{ site.giscus.category_id }}"</span>
          <span class="na">data-mapping=</span><span class="s">"{{ site.giscus.mapping }}"</span>
          <span class="na">data-strict=</span><span class="s">"{{ site.giscus.strict }}"</span>
          <span class="na">data-reactions-enabled=</span><span class="s">"{{ site.giscus.reactions_enabled }}"</span>
          <span class="na">data-emit-metadata=</span><span class="s">"{{ site.giscus.emit_metadata }}"</span>
          <span class="na">data-input-position=</span><span class="s">"{{ site.giscus.input_position }}"</span>
          <span class="na">data-theme=</span><span class="s">"{{ site.giscus.theme }}"</span>
          <span class="na">data-lang=</span><span class="s">"{{ site.giscus.lang }}"</span>
          <span class="na">data-loading=</span><span class="s">"{{ site.giscus.loading }}"</span>
          <span class="na">crossorigin=</span><span class="s">"anonymous"</span>
          <span class="na">async</span><span class="nt">&gt;</span>
  <span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;/section&gt;</span>
{%- endif -%}
</code></pre></div></div>

<p>Every <code class="language-plaintext highlighter-rouge">_config.yml</code> value is templated via Liquid — no hardcoded values in the layout. The <code class="language-plaintext highlighter-rouge">{%- if site.giscus -%}</code> guard means the widget only renders if Giscus is configured, so the theme works without it.</p>

<h3 id="legacy-dead-code">Legacy Dead Code</h3>

<p>The post layout still contains the original theme’s Isso and Disqus support:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% if page.comments != false and site.comments.isso or site.comments.disqus %}
  {% if site.comments.isso_domain %}<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"isso-thread"</span><span class="nt">&gt;&lt;/div&gt;</span>{% endif %}
  {% if site.comments.disqus_shortname %}<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"disqus_thread"</span><span class="nt">&gt;&lt;/div&gt;</span>{% endif %}
{% endif %}
</code></pre></div></div>

<p>This never renders because neither <code class="language-plaintext highlighter-rouge">site.comments.isso_domain</code> nor <code class="language-plaintext highlighter-rouge">site.comments.disqus_shortname</code> is set in <code class="language-plaintext highlighter-rouge">_config.yml</code>. It’s harmless dead code from the Contrast theme fork. I’ve left it in case someone forks this blog and wants to use those systems instead.</p>

<h2 id="the-github-ecosystem-advantage">The GitHub Ecosystem Advantage</h2>

<p>What I find elegant about this setup is how the pieces reinforce each other:</p>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Service</th>
      <th>Data Location</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Source code</td>
      <td>GitHub repository</td>
      <td><code class="language-plaintext highlighter-rouge">mcgarrah/mcgarrah.github.io</code></td>
    </tr>
    <tr>
      <td>Build &amp; deploy</td>
      <td>GitHub Actions</td>
      <td><code class="language-plaintext highlighter-rouge">.github/workflows/jekyll.yml</code></td>
    </tr>
    <tr>
      <td>Hosting</td>
      <td>GitHub Pages</td>
      <td><code class="language-plaintext highlighter-rouge">mcgarrah.org</code> via CNAME</td>
    </tr>
    <tr>
      <td>Comments</td>
      <td>GitHub Discussions</td>
      <td>Same repository</td>
    </tr>
    <tr>
      <td>Security scanning</td>
      <td>GitHub CodeQL</td>
      <td><code class="language-plaintext highlighter-rouge">.github/workflows/codeql.yml</code></td>
    </tr>
    <tr>
      <td>Dependency updates</td>
      <td>GitHub Dependabot</td>
      <td><code class="language-plaintext highlighter-rouge">.github/dependabot.yml</code></td>
    </tr>
  </tbody>
</table>

<p>Everything is in one place. One login, one set of permissions, one backup strategy (the git repository itself). If GitHub goes down, the whole blog is down anyway — there’s no additional point of failure from the comment system.</p>

<p>Comments are also version-controlled in a sense — GitHub Discussions have full edit history, and they’re tied to the repository. If I ever migrate the blog, the Discussions come with the repo.</p>

<p>The broader pattern here is worth noting: keeping your entire operational surface area in a single ecosystem — source, build, hosting, comments, security scanning, dependency management — reduces the number of vendor relationships, authentication boundaries, and failure domains you have to manage. It’s the same principle behind choosing a single cloud provider for tightly coupled services.</p>

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<ul>
  <li><strong>Clean up the dead Isso/Disqus code</strong> — It’s been there since the fork. Time to remove it</li>
  <li><strong>Add a <code class="language-plaintext highlighter-rouge">comments: false</code> front matter option</strong> — The Isso/Disqus code checks <code class="language-plaintext highlighter-rouge">page.comments != false</code>, but the Giscus block doesn’t. Some posts (like the privacy policy) shouldn’t have comments</li>
  <li><strong>Consider self-hosting Giscus</strong> — The <a href="https://github.com/giscus/giscus/blob/main/SELF-HOSTING.md">self-hosting guide</a> would eliminate the dependency on <code class="language-plaintext highlighter-rouge">giscus.app</code>. Low priority since the service has been reliable</li>
</ul>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/setting-up-jekyll-blog-github-pages/">Building This Blog: Jekyll on GitHub Pages</a> — Setup guide with brief Giscus section</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made</a> — Feature inventory including comments</li>
  <li><a href="/implementing-gdpr-compliance-jekyll-adsense/">Implementing GDPR Compliance for Jekyll with AdSense</a> — The GDPR work that informed comment system requirements</li>
  <li><a href="/jekyll-github-actions-cicd-pipeline/">The CI/CD Pipeline Behind This Jekyll Blog</a> — How GitHub Actions ties into the ecosystem</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="giscus" /><category term="comments" /><category term="github-discussions" /><category term="github-pages" /><category term="gdpr" /><category term="engagement" /><summary type="html"><![CDATA[How to add a comment system to a Jekyll blog on GitHub Pages using Giscus and GitHub Discussions. Covers the evaluation of Disqus, Isso, Utterances, GitHub Issues API, Staticman, and Giscus, with implementation details, GDPR considerations, and the advantages of keeping everything in the GitHub ecosystem.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-giscus-comments-implementation.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-giscus-comments-implementation.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building a Custom Tag and Category Generator Plugin for Jekyll</title><link href="https://mcgarrah.org/jekyll-tag-category-generator-plugin/" rel="alternate" type="text/html" title="Building a Custom Tag and Category Generator Plugin for Jekyll" /><published>2026-05-04T00:00:00+00:00</published><updated>2026-05-04T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-tag-category-generator-plugin</id><content type="html" xml:base="https://mcgarrah.org/jekyll-tag-category-generator-plugin/"><![CDATA[<p>Content taxonomy at scale creates an architectural tension: you want granular tagging for discoverability, but every auto-generated tag page dilutes your sitemap and SEO signal. At 139 posts with 237 unique tags and 53 categories, this blog hit that tension head-on — and the solution evolved through three versions, each driven by a real operational signal.</p>

<p>GitHub Pages with Jekyll gives you tags and categories in front matter, but no pages for them. You can tag a post <code class="language-plaintext highlighter-rouge">proxmox</code> all day long — there’s no <code class="language-plaintext highlighter-rouge">/tags/proxmox/</code> page unless you build one. Manually creating a page for each tag doesn’t scale. This plugin solves it: a single Ruby file that generates a page for every tag and every category at build time.</p>

<!-- excerpt-end -->

<h2 id="the-problem">The Problem</h2>

<p>Jekyll’s built-in <code class="language-plaintext highlighter-rouge">site.tags</code> and <code class="language-plaintext highlighter-rouge">site.categories</code> hashes collect posts by taxonomy, but they don’t generate browsable pages. Most Jekyll themes include a <code class="language-plaintext highlighter-rouge">tags.html</code> that lists all tags on one page, but clicking a tag doesn’t go anywhere useful.</p>

<p>The common solutions are:</p>

<ol>
  <li><strong>Manual pages</strong> — Create a markdown file per tag. Doesn’t scale.</li>
  <li><strong>Third-party plugins</strong> — <a href="https://github.com/pattex/jekyll-tagging">jekyll-tagging</a>, <a href="https://github.com/jekyll/jekyll-archives">jekyll-archives</a>. Not whitelisted on GitHub Pages (if using the default build).</li>
  <li><strong>Custom generator plugin</strong> — Write your own. Works with GitHub Actions builds.</li>
</ol>

<p>Useful references I consulted during the decision:</p>

<ul>
  <li><a href="https://longqian.me/2017/02/09/github-jekyll-tag/">Long Qian’s Jekyll tag page guide</a> — The approach I adapted</li>
  <li><a href="https://www.untangled.dev/2020/06/02/tag-management-jekyll/">Untangled.dev tag management</a> — Alternative approach</li>
  <li><a href="https://jekyllrb.com/docs/posts/#tags">Jekyll docs on tags</a> — Official reference</li>
</ul>

<p>I went with option 3 because I was already using GitHub Actions for the build (required for other custom plugins), and I wanted full control over URL structure and SEO behavior.</p>

<h2 id="evolution-of-the-plugin">Evolution of the Plugin</h2>

<p>The plugin went through three distinct versions, each driven by a real problem. The git history tells the story.</p>

<h3 id="version-1-basic-generator-july-2025">Version 1: Basic Generator (July 2025)</h3>

<p>The initial version was straightforward — two Generator classes and two Page classes. It iterated <code class="language-plaintext highlighter-rouge">site.tags</code> and <code class="language-plaintext highlighter-rouge">site.categories</code>, created a page for each, and was done:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">Jekyll</span>
  <span class="k">class</span> <span class="nc">TagPageGenerator</span> <span class="o">&lt;</span> <span class="no">Generator</span>
    <span class="n">safe</span> <span class="kp">true</span>

    <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
      <span class="n">site</span><span class="p">.</span><span class="nf">tags</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="o">|</span>
        <span class="n">site</span><span class="p">.</span><span class="nf">pages</span> <span class="o">&lt;&lt;</span> <span class="no">TagPage</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">site</span><span class="p">.</span><span class="nf">source</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="c1"># ... CategoryPageGenerator identical pattern ...</span>

  <span class="k">class</span> <span class="nc">TagPage</span> <span class="o">&lt;</span> <span class="no">Page</span>
    <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="vi">@site</span> <span class="o">=</span> <span class="n">site</span>
      <span class="vi">@base</span> <span class="o">=</span> <span class="n">base</span>
      <span class="vi">@dir</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'tags'</span><span class="p">,</span> <span class="n">tag</span><span class="p">.</span><span class="nf">downcase</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="s1">' '</span><span class="p">,</span> <span class="s1">'-'</span><span class="p">))</span>
      <span class="vi">@name</span> <span class="o">=</span> <span class="s1">'index.html'</span>

      <span class="nb">self</span><span class="p">.</span><span class="nf">process</span><span class="p">(</span><span class="vi">@name</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">read_yaml</span><span class="p">(</span><span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s1">'_layouts'</span><span class="p">),</span> <span class="s1">'tag_page.html'</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'tag'</span><span class="p">]</span> <span class="o">=</span> <span class="n">tag</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">]</span> <span class="o">=</span> <span class="n">posts</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Posts tagged with </span><span class="se">\"</span><span class="si">#{</span><span class="n">tag</span><span class="si">}</span><span class="se">\"</span><span class="s2">"</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This worked fine for months. Then AdSense review forced a closer look at the sitemap.</p>

<h3 id="version-2-sitemap-exclusion-and-noindex-april-6-2026--morning">Version 2: Sitemap Exclusion and Noindex (April 6, 2026 — morning)</h3>

<p>During AdSense review preparation, I audited the sitemap and found it had <strong>434 URLs, 262 of which (60%) were auto-generated tag, category, and pagination pages</strong> with little unique content. This dilutes content quality signals.</p>

<p>The fix added two lines to each Page class and a <code class="language-plaintext highlighter-rouge">Jekyll::Hooks</code> callback for pagination:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Added to TagPage and CategoryPage:</span>
<span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
<span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'noindex'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">posts</span><span class="p">.</span><span class="nf">size</span> <span class="o">&lt;</span> <span class="mi">3</span>

<span class="c1"># Added for pagination pages:</span>
<span class="no">Jekyll</span><span class="o">::</span><span class="no">Hooks</span><span class="p">.</span><span class="nf">register</span> <span class="ss">:pages</span><span class="p">,</span> <span class="ss">:post_init</span> <span class="k">do</span> <span class="o">|</span><span class="n">page</span><span class="o">|</span>
  <span class="k">if</span> <span class="n">page</span><span class="p">.</span><span class="nf">url</span> <span class="o">=~</span> <span class="sr">%r{^/page</span><span class="se">\d</span><span class="sr">+/}</span>
    <span class="n">page</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Result: sitemap dropped from 434 to 172 URLs. 195 thin tag/category pages got <code class="language-plaintext highlighter-rouge">noindex</code>.</p>

<h3 id="version-3-hook-to-generator-refactor-april-6-2026--afternoon">Version 3: Hook-to-Generator Refactor (April 6, 2026 — afternoon)</h3>

<p>The <code class="language-plaintext highlighter-rouge">Jekyll::Hooks :pages :post_init</code> approach broke pagination within hours. The hook fired during page initialization — <em>before</em> <code class="language-plaintext highlighter-rouge">jekyll-paginate</code> had created its pagination pages. This caused the homepage to display as <strong>page 32 of 32</strong> (oldest posts first) and prevented pagination directories from being created.</p>

<p>The fix replaced the hook with a lowest-priority Generator that runs <em>after</em> <code class="language-plaintext highlighter-rouge">jekyll-paginate</code> has finished:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PaginationSitemapExcluder</span> <span class="o">&lt;</span> <span class="no">Generator</span>
  <span class="n">safe</span> <span class="kp">true</span>
  <span class="n">priority</span> <span class="ss">:lowest</span>

  <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
    <span class="n">site</span><span class="p">.</span><span class="nf">pages</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">page</span><span class="o">|</span>
      <span class="k">if</span> <span class="n">page</span><span class="p">.</span><span class="nf">dir</span> <span class="o">=~</span> <span class="sr">%r{^/page</span><span class="se">\d</span><span class="sr">+}</span>
        <span class="n">page</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The key insight: <code class="language-plaintext highlighter-rouge">priority :lowest</code> ensures this generator runs after all other generators (including <code class="language-plaintext highlighter-rouge">jekyll-paginate</code>), so the pagination pages exist by the time we iterate them. The hook approach was too early in the lifecycle.</p>

<p>Result: homepage showed newest posts again (page 1 of 32), all 31 pagination directories restored, pagination pages still excluded from sitemap.</p>

<h2 id="the-final-plugin">The Final Plugin</h2>

<p>The current version lives in <code class="language-plaintext highlighter-rouge">_plugins/tag_category_generator.rb</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">Jekyll</span>
  <span class="k">class</span> <span class="nc">TagPageGenerator</span> <span class="o">&lt;</span> <span class="no">Generator</span>
    <span class="n">safe</span> <span class="kp">true</span>

    <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
      <span class="n">site</span><span class="p">.</span><span class="nf">tags</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="o">|</span>
        <span class="n">site</span><span class="p">.</span><span class="nf">pages</span> <span class="o">&lt;&lt;</span> <span class="no">TagPage</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">site</span><span class="p">.</span><span class="nf">source</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">CategoryPageGenerator</span> <span class="o">&lt;</span> <span class="no">Generator</span>
    <span class="n">safe</span> <span class="kp">true</span>

    <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
      <span class="n">site</span><span class="p">.</span><span class="nf">categories</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">category</span><span class="p">,</span> <span class="n">posts</span><span class="o">|</span>
        <span class="n">site</span><span class="p">.</span><span class="nf">pages</span> <span class="o">&lt;&lt;</span> <span class="no">CategoryPage</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">site</span><span class="p">.</span><span class="nf">source</span><span class="p">,</span> <span class="n">category</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">TagPage</span> <span class="o">&lt;</span> <span class="no">Page</span>
    <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="vi">@site</span> <span class="o">=</span> <span class="n">site</span>
      <span class="vi">@base</span> <span class="o">=</span> <span class="n">base</span>
      <span class="vi">@dir</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'tags'</span><span class="p">,</span> <span class="n">tag</span><span class="p">.</span><span class="nf">downcase</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="s1">' '</span><span class="p">,</span> <span class="s1">'-'</span><span class="p">))</span>
      <span class="vi">@name</span> <span class="o">=</span> <span class="s1">'index.html'</span>

      <span class="nb">self</span><span class="p">.</span><span class="nf">process</span><span class="p">(</span><span class="vi">@name</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">read_yaml</span><span class="p">(</span><span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s1">'_layouts'</span><span class="p">),</span> <span class="s1">'tag_page.html'</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'tag'</span><span class="p">]</span> <span class="o">=</span> <span class="n">tag</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">]</span> <span class="o">=</span> <span class="n">posts</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Posts tagged with </span><span class="se">\"</span><span class="si">#{</span><span class="n">tag</span><span class="si">}</span><span class="se">\"</span><span class="s2">"</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'noindex'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">posts</span><span class="p">.</span><span class="nf">size</span> <span class="o">&lt;</span> <span class="mi">3</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">CategoryPage</span> <span class="o">&lt;</span> <span class="no">Page</span>
    <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="n">category</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="vi">@site</span> <span class="o">=</span> <span class="n">site</span>
      <span class="vi">@base</span> <span class="o">=</span> <span class="n">base</span>
      <span class="vi">@dir</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'categories'</span><span class="p">,</span> <span class="n">category</span><span class="p">.</span><span class="nf">downcase</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="s1">' '</span><span class="p">,</span> <span class="s1">'-'</span><span class="p">).</span><span class="nf">gsub</span><span class="p">(</span><span class="s1">'_'</span><span class="p">,</span> <span class="s1">'-'</span><span class="p">))</span>
      <span class="vi">@name</span> <span class="o">=</span> <span class="s1">'index.html'</span>

      <span class="nb">self</span><span class="p">.</span><span class="nf">process</span><span class="p">(</span><span class="vi">@name</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">read_yaml</span><span class="p">(</span><span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s1">'_layouts'</span><span class="p">),</span> <span class="s1">'category_page.html'</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'category'</span><span class="p">]</span> <span class="o">=</span> <span class="n">category</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">]</span> <span class="o">=</span> <span class="n">posts</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Posts in category </span><span class="se">\"</span><span class="si">#{</span><span class="n">category</span><span class="si">}</span><span class="se">\"</span><span class="s2">"</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'noindex'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">posts</span><span class="p">.</span><span class="nf">size</span> <span class="o">&lt;</span> <span class="mi">3</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">PaginationSitemapExcluder</span> <span class="o">&lt;</span> <span class="no">Generator</span>
    <span class="n">safe</span> <span class="kp">true</span>
    <span class="n">priority</span> <span class="ss">:lowest</span>

    <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
      <span class="n">site</span><span class="p">.</span><span class="nf">pages</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">page</span><span class="o">|</span>
        <span class="k">if</span> <span class="n">page</span><span class="p">.</span><span class="nf">dir</span> <span class="o">=~</span> <span class="sr">%r{^/page</span><span class="se">\d</span><span class="sr">+}</span>
          <span class="n">page</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
        <span class="k">end</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="design-decisions">Design Decisions</h3>

<p><strong><code class="language-plaintext highlighter-rouge">safe true</code></strong> — Marks the generator as safe for GitHub Pages compatibility. Even though we’re building with GitHub Actions, this is good practice.</p>

<p><strong>URL structure</strong> — Tags go to <code class="language-plaintext highlighter-rouge">/tags/tag-name/</code> and categories to <code class="language-plaintext highlighter-rouge">/categories/category-name/</code>. The <code class="language-plaintext highlighter-rouge">downcase.gsub(' ', '-')</code> normalizes spaces to hyphens. Categories also convert underscores to hyphens for consistency.</p>

<p><strong><code class="language-plaintext highlighter-rouge">sitemap: false</code></strong> — This was added after discovering that the generated pages were bloating the sitemap. With 237 tags, that’s 237 extra URLs in <code class="language-plaintext highlighter-rouge">sitemap.xml</code> — most pointing to pages with only one or two posts. See <a href="/jekyll-sitemap-bloat-tags-categories-pagination/">Your Jekyll Sitemap Is 60% Garbage</a> for the full story.</p>

<p><strong><code class="language-plaintext highlighter-rouge">noindex</code> for thin content</strong> — Tags with fewer than 3 posts get <code class="language-plaintext highlighter-rouge">noindex</code> to avoid search engines indexing low-value pages. This is a soft SEO signal — the page still exists for users, but search engines are asked to skip it.</p>

<p><strong><code class="language-plaintext highlighter-rouge">PaginationSitemapExcluder</code></strong> — A bonus generator that excludes <code class="language-plaintext highlighter-rouge">/page2/</code>, <code class="language-plaintext highlighter-rouge">/page3/</code>, etc. from the sitemap. These pagination pages add no SEO value and were another source of sitemap bloat. This was originally a <code class="language-plaintext highlighter-rouge">Jekyll::Hooks :pages :post_init</code> callback, but that broke <code class="language-plaintext highlighter-rouge">jekyll-paginate</code> because the hook fired before pagination pages existed. The <code class="language-plaintext highlighter-rouge">priority :lowest</code> Generator approach runs after all other generators have finished.</p>

<h2 id="the-layouts">The Layouts</h2>

<p>Both layouts are nearly identical. Here’s <code class="language-plaintext highlighter-rouge">_layouts/tag_page.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>---
layout: default
---

<span class="nt">&lt;article</span> <span class="na">class=</span><span class="s">"page"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"page-title"</span><span class="nt">&gt;</span>{{ page.title }}<span class="nt">&lt;/h1&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"page-content"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"posts-list"</span><span class="nt">&gt;</span>
      {% for post in page.posts %}
        <span class="nt">&lt;article</span> <span class="na">class=</span><span class="s">"post-item"</span><span class="nt">&gt;</span>
          <span class="nt">&lt;h2&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{ post.url | relative_url }}"</span><span class="nt">&gt;</span>{{ post.title }}<span class="nt">&lt;/a&gt;&lt;/h2&gt;</span>
          <span class="nt">&lt;time</span> <span class="na">datetime=</span><span class="s">"{{ post.date | date_to_xmlschema }}"</span><span class="nt">&gt;</span>{{ post.date | date: site.date_format }}<span class="nt">&lt;/time&gt;</span>
          {% if post.excerpt %}
            <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"post-excerpt"</span><span class="nt">&gt;</span>{{ post.excerpt }}<span class="nt">&lt;/div&gt;</span>
          {% endif %}
        <span class="nt">&lt;/article&gt;</span>
      {% endfor %}
    <span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/article&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">category_page.html</code> layout is identical. You could DRY this up with an include, but for two files it’s not worth the indirection.</p>

<h2 id="the-index-pages">The Index Pages</h2>

<p>Users need a way to browse all tags and categories. These are simple Liquid pages:</p>

<p><code class="language-plaintext highlighter-rouge">tags.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>---
layout: list_page
title: All Tags
---
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"taxonomies-list"</span><span class="nt">&gt;</span>
  {% for tag_hash in site.tags %}
    {% assign tag_name = tag_hash[0] %}
    {% assign num_posts = tag_hash[1].size %}
    <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ site.baseurl }}/tags/{{ tag_name | slugify }}/"</span> <span class="na">class=</span><span class="s">"taxonomy-item"</span><span class="nt">&gt;</span>
      {{ tag_name }} ({{ num_posts }})
    <span class="nt">&lt;/a&gt;</span>
  {% endfor %}
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">categories.html</code> follows the same pattern with <code class="language-plaintext highlighter-rouge">site.categories</code>.</p>

<h2 id="github-actions-requirement">GitHub Actions Requirement</h2>

<p>This plugin won’t work with the default GitHub Pages Jekyll build — only <a href="https://pages.github.com/versions/">whitelisted plugins</a> run there. You need a GitHub Actions workflow that runs <code class="language-plaintext highlighter-rouge">jekyll build</code> yourself and deploys the output.</p>

<p>If you’re already using GitHub Actions for your Jekyll build (which you’ll need for any custom plugin), this just works — drop the <code class="language-plaintext highlighter-rouge">.rb</code> file in <code class="language-plaintext highlighter-rouge">_plugins/</code> and it’s picked up automatically.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<p><strong>Tag proliferation is real.</strong> At 237 tags across 139 posts, many tags have only 1-2 posts. This creates thin content pages that dilute SEO. The <code class="language-plaintext highlighter-rouge">noindex</code> threshold helps, but the real fix is disciplined tagging — reuse existing tags rather than inventing new ones for every post.</p>

<p><strong>Sitemap exclusion matters.</strong> The original version of this plugin didn’t set <code class="language-plaintext highlighter-rouge">sitemap: false</code>. The result was a sitemap with 434 URLs where 262 were auto-generated junk. Google Search Console flagged many as “Discovered – currently not indexed.” Adding <code class="language-plaintext highlighter-rouge">sitemap: false</code> cut the sitemap to meaningful content only.</p>

<p><strong>Jekyll hook ordering is treacherous.</strong> The <code class="language-plaintext highlighter-rouge">:post_init</code> hook seemed like the right place to tag pagination pages for sitemap exclusion — it fires when a page is initialized. But <code class="language-plaintext highlighter-rouge">jekyll-paginate</code> creates pages <em>during</em> generation, not initialization. The hook fired too early, interfered with pagination’s template detection, and the homepage showed the oldest posts instead of the newest. The fix was moving to a <code class="language-plaintext highlighter-rouge">priority :lowest</code> Generator. Lesson: when modifying pages created by other plugins, use a Generator with lowest priority, not a hook.</p>

<p><strong>Categories vs tags</strong> — In practice, categories on this blog are broad buckets (<code class="language-plaintext highlighter-rouge">technical</code>, <code class="language-plaintext highlighter-rouge">homelab</code>, <code class="language-plaintext highlighter-rouge">web-development</code>) while tags are specific topics (<code class="language-plaintext highlighter-rouge">proxmox</code>, <code class="language-plaintext highlighter-rouge">ceph</code>, <code class="language-plaintext highlighter-rouge">jekyll</code>). The plugin treats them identically but they serve different navigation purposes.</p>

<h2 id="current-stats">Current Stats</h2>

<p>As of this writing:</p>

<ul>
  <li><strong>139</strong> published posts</li>
  <li><strong>237</strong> unique tags → 237 generated tag pages</li>
  <li><strong>53</strong> unique categories → 53 generated category pages</li>
  <li>Top tags: homelab (32), proxmox (23), jekyll (23), ceph (19), storage (18)</li>
  <li>Top categories: technical (63), homelab (22), web-development (21), hardware (20)</li>
</ul>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/jekyll-sitemap-bloat-tags-categories-pagination/">Your Jekyll Sitemap Is 60% Garbage</a> — The sitemap bloat problem this plugin caused and how it was fixed</li>
  <li><a href="/setting-up-jekyll-blog-github-pages/">Building This Blog: Jekyll on GitHub Pages from Zero to 130+ Posts</a> — Broader setup guide that mentions this plugin</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made: Every Feature Powering This Jekyll Blog</a> — Feature inventory including tag/category pages</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="ruby" /><category term="plugins" /><category term="tags" /><category term="categories" /><category term="github-pages" /><category term="seo" /><category term="github-actions" /><summary type="html"><![CDATA[How to build a custom Jekyll Ruby plugin that automatically generates tag and category pages, with SEO controls for sitemap exclusion and noindex on thin content. Includes the full plugin, layouts, and index pages.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-tag-category-generator-plugin.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-tag-category-generator-plugin.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Kiro IDE on Windows: WSL2 Support via Open Remote Extension</title><link href="https://mcgarrah.org/kiro-ide-wsl2-support-open-remote-extension/" rel="alternate" type="text/html" title="Kiro IDE on Windows: WSL2 Support via Open Remote Extension" /><published>2026-05-03T00:00:00+00:00</published><updated>2026-05-03T00:00:00+00:00</updated><id>https://mcgarrah.org/kiro-ide-wsl2-support-open-remote-extension</id><content type="html" xml:base="https://mcgarrah.org/kiro-ide-wsl2-support-open-remote-extension/"><![CDATA[<p>Kiro is a VS Code fork, but it doesn’t include Microsoft’s proprietary Remote - WSL extension. That extension isn’t published to Open VSX (the extension marketplace Kiro uses), and Microsoft’s marketplace terms restrict it to official VS Code builds. If you’re running Kiro on Windows with WSL2 as your primary development environment, this is a hard stop — unless you know where to look.</p>

<blockquote>
  <p><strong>Migration context:</strong> On May 1, 2026, AWS published the <a href="https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/">Amazon Q Developer end-of-support announcement</a>. If you’re migrating from Amazon Q Developer to Kiro on Windows, WSL2 support is likely a hard requirement. This article covers exactly that gap.</p>
</blockquote>

<p>The <a href="https://open-vsx.org/extension/jeanp413/open-remote-wsl">Open Remote - WSL</a> extension by jeanp413 fills this gap. It’s a community-built, Open VSX-compatible implementation that enables WSL2 support in any VS Code fork, including Kiro. The <a href="https://github.com/kirodotdev/Kiro/issues/17">Kiro GitHub issue #17</a> tracks the ongoing community experience — 38+ comments of workarounds, breakage reports, and fixes that inform everything in this article.</p>

<p>The extension works. It also breaks predictably on Kiro updates, has an easy-to-miss configuration requirement, and routes terminal commands to the wrong shell if you don’t set a default profile. None of these are dealbreakers, but they’ll cost you time if you don’t know about them going in.</p>

<!-- excerpt-end -->

<h2 id="the-problem">The Problem</h2>

<p>VS Code’s WSL integration is built on Microsoft’s proprietary Remote Development extensions. These extensions are:</p>

<ul>
  <li>Licensed exclusively for use with Microsoft’s VS Code builds</li>
  <li>Published only to Microsoft’s Visual Studio Marketplace, not Open VSX</li>
  <li>Not available in Kiro’s extension marketplace</li>
</ul>

<p>Without WSL support, Kiro on Windows can only access the Windows filesystem. Your Linux development environments, toolchains, and MCP servers running inside WSL2 are unreachable from the IDE. As multiple users in the tracking issue put it bluntly: Kiro is “completely useless” for Windows developers who do all their work inside WSL2.</p>

<p>Kiro itself acknowledges the gap — if you install the Linux version inside WSL2, it displays a message directing you to install the Windows version instead and use the remote extension approach.</p>

<h2 id="installation-and-the-argvjson-requirement">Installation and the argv.json Requirement</h2>

<p>Install the extension from Kiro’s extension panel or the command line:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">kiro</span><span class="w"> </span><span class="nt">--install-extension</span><span class="w"> </span><span class="nx">jeanp413.open-remote-wsl</span><span class="w">
</span></code></pre></div></div>

<p><strong>This is the step most people miss.</strong> The extension requires proposed API access to function. Without it, the extension installs silently but does nothing. Enable it in your <code class="language-plaintext highlighter-rouge">~/.kiro/argv.json</code> file (or open it via the command palette: <code class="language-plaintext highlighter-rouge">Preferences: Configure Runtime Arguments</code>):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"enable-proposed-api"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"jeanp413.open-remote-wsl"</span><span class="w">
    </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Restart Kiro after making this change. If you have other entries in <code class="language-plaintext highlighter-rouge">argv.json</code>, add the <code class="language-plaintext highlighter-rouge">enable-proposed-api</code> array alongside them — don’t replace the file contents. If WSL commands don’t appear in the command palette after installing the extension, this missing configuration is almost certainly the reason.</p>

<p>After installation, the command palette gains WSL-specific commands:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">WSL: New Window</code> — Open a new Kiro window connected to your default WSL distribution</li>
  <li><code class="language-plaintext highlighter-rouge">WSL: New Window using Distro...</code> — Choose a specific distribution</li>
  <li><code class="language-plaintext highlighter-rouge">WSL: Open Folder in WSL...</code> — Open a Linux folder directly</li>
  <li><code class="language-plaintext highlighter-rouge">WSL: Reopen Folder in WSL</code> — Switch the current folder to WSL context</li>
</ul>

<h2 id="the-terminal-default-profile-gotcha">The Terminal Default Profile Gotcha</h2>

<p>With the extension installed and a WSL2 folder open, Kiro’s agentic chat will attempt to run terminal commands. By default, it routes them to PowerShell — not the WSL terminal. This means commands that should execute in your Linux environment run in Windows instead, producing confusing failures.</p>

<p>The fix is to set the WSL distribution as your default terminal profile. Add this to your Kiro settings (<code class="language-plaintext highlighter-rouge">%APPDATA%\Kiro\User\settings.json</code>):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"wsl.defaultDistro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Ubuntu"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"terminal.integrated.defaultProfile.windows"</span><span class="p">:</span><span class="w"> </span><span class="s2">"WSL (Ubuntu)"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"terminal.integrated.profiles.windows"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"WSL (Ubuntu)"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Windows</span><span class="se">\\</span><span class="s2">System32</span><span class="se">\\</span><span class="s2">wsl.exe"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"-d"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Ubuntu"</span><span class="p">]</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">Ubuntu</code> with your distribution name. This ensures both manual terminal sessions and agent-initiated commands execute inside WSL2.</p>

<h2 id="opening-wsl2-folders">Opening WSL2 Folders</h2>

<p>From PowerShell or the Windows command line:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Open a specific Linux folder</span><span class="w">
</span><span class="n">kiro</span><span class="w"> </span><span class="nt">--folder-uri</span><span class="w"> </span><span class="s2">"vscode-remote://wsl+Ubuntu/home/username/projects"</span><span class="w">

</span><span class="c"># Open with the --remote flag</span><span class="w">
</span><span class="n">kiro</span><span class="w"> </span><span class="nt">--remote</span><span class="w"> </span><span class="nx">wsl</span><span class="o">+</span><span class="nx">Ubuntu</span><span class="w">
</span></code></pre></div></div>

<p>From inside a WSL2 terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># If the kiro CLI is on PATH (installed by the Windows Kiro installer)</span>
kiro <span class="nb">.</span>
</code></pre></div></div>

<p>Note: launching <code class="language-plaintext highlighter-rouge">kiro .</code> from inside WSL2 doesn’t always auto-detect the WSL context correctly. If Kiro opens in Windows mode instead of WSL mode, use the explicit <code class="language-plaintext highlighter-rouge">--folder-uri</code> or <code class="language-plaintext highlighter-rouge">--remote</code> flags from PowerShell, or use the command palette (<code class="language-plaintext highlighter-rouge">WSL: Open Folder in WSL...</code>) from within Kiro.</p>

<p>A community-contributed launcher script addresses this by detecting whether the path is a WSL path and automatically constructing the correct <code class="language-plaintext highlighter-rouge">--folder-uri</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># Save as /usr/local/bin/kiro-wsl or replace the Kiro bin/kiro script</span>
<span class="nv">KIRO_ROOT</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">readlink</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$0</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>

<span class="nv">ARGS</span><span class="o">=()</span>
<span class="k">for </span>arg <span class="k">in</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">!=</span> -<span class="k">*</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"."</span> <span class="o">]]</span> <span class="o">||</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">".."</span> <span class="o">]]</span><span class="p">;</span> <span class="o">}</span><span class="p">;</span> <span class="k">then
        </span><span class="nv">FOLDER</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">realpath</span> <span class="nt">-m</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
        ARGS+<span class="o">=(</span><span class="s2">"--folder-uri"</span> <span class="s2">"vscode-remote://wsl+</span><span class="k">${</span><span class="nv">WSL_DISTRO_NAME</span><span class="k">}${</span><span class="nv">FOLDER</span><span class="k">}</span><span class="s2">"</span><span class="o">)</span>
    <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">!=</span> -<span class="k">*</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
        </span><span class="nv">FILE</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">realpath</span> <span class="nt">-m</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
        ARGS+<span class="o">=(</span><span class="s2">"--file-uri"</span> <span class="s2">"vscode-remote://wsl+</span><span class="k">${</span><span class="nv">WSL_DISTRO_NAME</span><span class="k">}${</span><span class="nv">FILE</span><span class="k">}</span><span class="s2">"</span><span class="o">)</span>
    <span class="k">else
        </span>ARGS+<span class="o">=(</span><span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span><span class="o">)</span>
    <span class="k">fi
done</span>

<span class="s2">"</span><span class="nv">$KIRO_ROOT</span><span class="s2">/Kiro.exe"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">ARGS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> &lt;/dev/null &amp;&gt;/dev/null &amp;
<span class="nb">disown</span>
</code></pre></div></div>

<h2 id="the-kiro-update-breakage-cycle">The Kiro Update Breakage Cycle</h2>

<p>This is the most significant operational issue. When Kiro updates, the kiro-server binary installed inside WSL2 (at <code class="language-plaintext highlighter-rouge">~/.kiro-server/</code>) becomes incompatible with the new Kiro version. The extension tries to install a new server, and the installation script can fail — sometimes due to stale cached binaries, sometimes due to quoting bugs in the generated bash script.</p>

<p>The symptom is always the same:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Error] Error resolving authority
Error: Couldn't install vscode server on remote server,
       install script returned non-zero exit status
</code></pre></div></div>

<h3 id="recovery-procedure">Recovery Procedure</h3>

<p>When WSL2 connectivity breaks after a Kiro update:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Inside WSL2</span>
<span class="nb">rm</span> <span class="nt">-rf</span> ~/.kiro-server
</code></pre></div></div>

<p>Then restart Kiro and reconnect to WSL2. The extension will perform a fresh server installation. This has been the reliable fix across multiple Kiro versions (confirmed working through v0.1.25 and beyond in the tracking issue).</p>

<h3 id="the-server-installation-script-quoting-bug">The Server Installation Script Quoting Bug</h3>

<p>In some Kiro versions, the server installation script that the extension generates contains improperly escaped single quotes (<code class="language-plaintext highlighter-rouge">'\''</code>) that fail in bash. If the <code class="language-plaintext highlighter-rouge">rm -rf ~/.kiro-server</code> approach doesn’t resolve the issue:</p>

<ol>
  <li>Open Kiro’s Output panel and select the WSL extension output channel</li>
  <li>Copy the full bash script content from the output</li>
  <li>Save it to a file inside WSL2 (e.g., <code class="language-plaintext highlighter-rouge">~/kiro-server-install.sh</code>)</li>
  <li>Fix the three quoting errors (look for <code class="language-plaintext highlighter-rouge">'\''</code> patterns that break bash parsing)</li>
  <li>Run the fixed script manually: <code class="language-plaintext highlighter-rouge">bash ~/kiro-server-install.sh</code></li>
</ol>

<p>This is a known issue in the community extension’s server bootstrapping code. The manual fix is tedious but reliable.</p>

<h2 id="wsl2-path-escaping-issues">WSL2 Path Escaping Issues</h2>

<p>Kiro’s agent sometimes generates Windows-style UNC paths when it should be using Linux paths inside WSL2. Commands like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cd "\\wsl.localhost\Ubuntu\home\username\project"
</code></pre></div></div>

<p>…fail because the agent is constructing a Windows UNC path instead of the native Linux path <code class="language-plaintext highlighter-rouge">/home/username/project</code>. This is a known limitation of how the remote extension bridges the two filesystems. The agent doesn’t always correctly detect that it’s operating in a Linux context.</p>

<p>There’s no configuration fix for this — it’s a behavioral issue in how Kiro’s agent interacts with the remote extension’s filesystem abstraction. When it happens, the workaround is to manually correct the path in the terminal or re-prompt the agent with explicit Linux path context.</p>

<h2 id="chat-window-disabled-unsafe-environment">Chat Window Disabled: “Unsafe Environment”</h2>

<p>Some users report that after connecting to WSL2, the Kiro chat sidebar shows “Drag a view here to display” or displays an “unsafe environment” warning that disables the chat window entirely. This appears to be related to workspace trust settings.</p>

<p>If you encounter this:</p>

<ol>
  <li>Open the command palette and run <code class="language-plaintext highlighter-rouge">Workspaces: Manage Workspace Trust</code></li>
  <li>Trust the WSL2 workspace folder</li>
  <li>If the chat window still doesn’t appear, close the WSL2 window and reopen it via <code class="language-plaintext highlighter-rouge">WSL: New Window</code></li>
</ol>

<p>The alternative approach — running the Linux version of Kiro natively inside WSL2 via WSLg — avoids this issue entirely but introduces its own problems (GUI scaling issues on multi-monitor setups, occasional terminal hangs, and general WSLg instability).</p>

<h2 id="mcp-servers-in-wsl2">MCP Servers in WSL2</h2>

<p>This is where WSL2 support becomes particularly relevant for Kiro’s agentic workflows. MCP servers that depend on Linux toolchains — Python <code class="language-plaintext highlighter-rouge">uvx</code> packages, Node.js tools, Docker containers — run natively inside WSL2 rather than through Windows compatibility layers.</p>

<p>When Kiro connects to WSL2 via the Open Remote extension, the kiro-agent’s MCP server processes spawn inside the Linux environment. This means:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">uvx</code>-based MCP servers use the Linux Python installation</li>
  <li>Docker-based MCP servers connect to the WSL2 Docker daemon</li>
  <li>File paths in MCP server configs use Linux paths (<code class="language-plaintext highlighter-rouge">/home/...</code>), not Windows paths</li>
  <li>Environment variables resolve from the Linux shell, not PowerShell</li>
</ul>

<h3 id="wsl2-side-configuration">WSL2-Side Configuration</h3>

<p>The <code class="language-plaintext highlighter-rouge">~/.kiro</code> directory inside WSL2 is independent from <code class="language-plaintext highlighter-rouge">%USERPROFILE%\.kiro</code> on the Windows side. When connected to WSL2, Kiro reads:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/home/username/.kiro/
├── settings/mcp.json    # MCP servers configured for Linux execution
├── powers/              # Powers available in Linux context
├── hooks/               # Hooks that run in Linux shell
├── steering/            # Steering rules
└── secrets/             # Linux-side credentials
</code></pre></div></div>

<p>This natural separation means your Windows-side Kiro configuration (with Windows-native MCP servers) and your WSL2-side configuration (with Linux-native MCP servers) are already isolated by the filesystem boundary. You don’t need the <a href="/kiro-ide-parallel-personas-director-developer/">persona isolation techniques</a> to separate Windows and Linux configs — the remote extension handles that by virtue of running in a different filesystem.</p>

<h2 id="the-alternative-running-kiro-natively-in-wsl2">The Alternative: Running Kiro Natively in WSL2</h2>

<p>Several users in the tracking issue have tried running the Linux <code class="language-plaintext highlighter-rouge">.deb</code> version of Kiro directly inside WSL2 using WSLg (Windows Subsystem for Linux GUI). This bypasses the remote extension entirely — Kiro runs as a native Linux application with direct filesystem access.</p>

<p>It works, with caveats:</p>

<ul>
  <li><strong>Multi-monitor scaling</strong>: WSLg doesn’t respect Windows display scaling. On multi-monitor setups (especially mixed DPI), the mouse cursor may be oversized and click targets may be offset.</li>
  <li><strong>Terminal stability</strong>: The integrated terminal can hang after extended use in some configurations.</li>
  <li><strong>GTK dependencies</strong>: Requires up-to-date GTK packages in the WSL2 distribution. Missing or outdated libraries cause rendering issues.</li>
  <li><strong>Performance</strong>: Noticeably slower than the remote extension approach for UI rendering.</li>
</ul>

<p>Kiro itself discourages this approach — the Linux installer displays a message suggesting you use the Windows version with the remote extension instead. But for developers who find the remote extension’s breakage cycle unacceptable, it’s a viable if rough alternative.</p>

<h2 id="relationship-to-persona-isolation">Relationship to Persona Isolation</h2>

<p>If you’re running <a href="/kiro-ide-parallel-personas-director-developer/">parallel Kiro personas</a> on Windows, WSL2 adds a third dimension to the isolation story. The Windows-side personas (director and developer) each have their own <code class="language-plaintext highlighter-rouge">%USERPROFILE%\.kiro-*</code> directories. When either persona connects to WSL2, it sees the single <code class="language-plaintext highlighter-rouge">/home/username/.kiro/</code> inside Linux.</p>

<p>For most workflows this is fine — the WSL2 config is developer-focused by nature. The more common pattern is: director persona runs on Windows (Atlassian, GitLab, observability MCPs), developer persona connects to WSL2 (code toolchains, Docker, Linux-native MCP servers).</p>

<h2 id="setup-checklist">Setup Checklist</h2>

<ol>
  <li>Install WSL2 with your preferred distribution (<code class="language-plaintext highlighter-rouge">wsl --install -d Ubuntu</code>)</li>
  <li>Install the Open Remote - WSL extension in Kiro (<code class="language-plaintext highlighter-rouge">jeanp413.open-remote-wsl</code>)</li>
  <li><strong>Enable proposed API</strong> in <code class="language-plaintext highlighter-rouge">~/.kiro/argv.json</code> — this is the step most people miss</li>
  <li>Restart Kiro</li>
  <li>Set WSL as the default terminal profile in Kiro settings</li>
  <li>Open a WSL2 folder (command palette → <code class="language-plaintext highlighter-rouge">WSL: New Window</code>)</li>
  <li>On first connection, allow the kiro-server installation inside WSL2</li>
  <li>Configure <code class="language-plaintext highlighter-rouge">~/.kiro/settings/mcp.json</code> inside WSL2 for Linux-native MCP servers</li>
  <li>Verify MCP servers start correctly from the Kiro terminal (should show Linux paths)</li>
</ol>

<h3 id="after-every-kiro-update">After Every Kiro Update</h3>

<ol>
  <li>If WSL2 connectivity breaks: <code class="language-plaintext highlighter-rouge">rm -rf ~/.kiro-server</code> inside WSL2, then restart Kiro</li>
  <li>If that doesn’t work: extract the server install script from Kiro’s Output panel, fix quoting, run manually</li>
  <li>Verify <code class="language-plaintext highlighter-rouge">argv.json</code> still contains the <code class="language-plaintext highlighter-rouge">enable-proposed-api</code> entry (updates occasionally reset it)</li>
</ol>

<h2 id="the-state-of-things">The State of Things</h2>

<p>WSL2 support in Kiro is functional but fragile. The community extension works, the configuration is straightforward once you know about <code class="language-plaintext highlighter-rouge">argv.json</code>, and the recovery procedure after updates is reliable. But it’s a community-maintained bridge over a gap that arguably shouldn’t exist in a product targeting Windows developers.</p>

<p>The <a href="https://github.com/kirodotdev/Kiro/issues/17">tracking issue</a> remains open with the <code class="language-plaintext highlighter-rouge">pending-maintainer-response</code> label. Multiple users have requested either native WSL support or at minimum official documentation for the community extension setup. Until one of those happens, this article and that issue thread are the primary references.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://github.com/kirodotdev/Kiro/issues/17">Kiro GitHub Issue #17: WSL Support</a> — The canonical community thread</li>
  <li><a href="https://open-vsx.org/extension/jeanp413/open-remote-wsl">Open Remote - WSL on Open VSX</a></li>
  <li><a href="https://github.com/jeanp413/open-remote-wsl">jeanp413/open-remote-wsl on GitHub</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows/wsl/">WSL2 Documentation</a></li>
  <li><a href="/kiro-ide-parallel-personas-director-developer/">Kiro IDE: Running Parallel Personas</a> — Companion article on persona isolation</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="technical" /><category term="devtools" /><category term="kiro" /><category term="kiro" /><category term="vscode" /><category term="wsl2" /><category term="windows" /><category term="linux" /><category term="mcp" /><category term="remote-development" /><category term="open-vsx" /><summary type="html"><![CDATA[Complete guide to enabling WSL2 support in Kiro IDE on Windows using the Open Remote - WSL community extension. Covers the required argv.json configuration, kiro-server breakage recovery after updates, terminal default profile fix, MCP server execution within Linux, and known limitations.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/kiro-ide-wsl2-support-open-remote-extension.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/kiro-ide-wsl2-support-open-remote-extension.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Kiro IDE: Running Parallel Personas for Director and Developer Workflows</title><link href="https://mcgarrah.org/kiro-ide-parallel-personas-director-developer/" rel="alternate" type="text/html" title="Kiro IDE: Running Parallel Personas for Director and Developer Workflows" /><published>2026-05-02T00:00:00+00:00</published><updated>2026-05-02T00:00:00+00:00</updated><id>https://mcgarrah.org/kiro-ide-parallel-personas-director-developer</id><content type="html" xml:base="https://mcgarrah.org/kiro-ide-parallel-personas-director-developer/"><![CDATA[<p>Kiro is a compelling IDE — particularly for agentic workflows where powers, hooks, and MCP servers turn it into something closer to a command center than a text editor. The problem surfaces when you need that command center configured two fundamentally different ways at the same time.</p>

<blockquote>
  <p><strong>Migration context:</strong> On May 1, 2026, AWS published the <a href="https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/">Amazon Q Developer end-of-support announcement</a>. If you’re evaluating Kiro as the migration path from Amazon Q Developer, the timeline is now official. The configuration depth covered here is worth understanding before you commit to the switch.</p>
</blockquote>

<p>My use case: a Senior Director persona loaded with Atlassian (Jira, Confluence), GitLab, NewRelic, Wiz, and AWS pricing MCPs for ticket management, architecture reviews, and operational oversight — running alongside a developer persona stripped down to Terraform, AWS docs, and code-focused tooling. These configurations don’t overlap well. The director’s MCP servers add latency, consume resources, and clutter the tool list when I’m writing code. The developer’s minimal setup lacks the integrations I need when triaging incidents or managing a sprint.</p>

<p>VS Code solved this years ago with profiles. Kiro inherited the <code class="language-plaintext highlighter-rouge">--profile</code> flag, but it doesn’t do what you’d expect.</p>

<!-- excerpt-end -->

<h2 id="the-problem-what---profile-actually-isolates">The Problem: What <code class="language-plaintext highlighter-rouge">--profile</code> Actually Isolates</h2>

<p>Kiro has two separate configuration stores:</p>

<table>
  <thead>
    <tr>
      <th>Store</th>
      <th>Location (macOS)</th>
      <th>Location (Windows)</th>
      <th><code class="language-plaintext highlighter-rouge">--profile</code> isolates?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>VS Code state</td>
      <td><code class="language-plaintext highlighter-rouge">~/Library/Application Support/Kiro/</code></td>
      <td><code class="language-plaintext highlighter-rouge">%APPDATA%\Kiro\</code></td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Kiro agent config</td>
      <td><code class="language-plaintext highlighter-rouge">~/.kiro/</code></td>
      <td><code class="language-plaintext highlighter-rouge">%USERPROFILE%\.kiro\</code></td>
      <td>No</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">--profile</code> flag creates named profiles under the VS Code state directory — separate editor settings, extensions, keybindings, and UI state. That’s the inherited VS Code profile system working as designed.</p>

<p>But everything that makes Kiro <em>Kiro</em> lives in <code class="language-plaintext highlighter-rouge">~/.kiro/</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.kiro/
├── agents/          # Agent persona definitions
├── hooks/           # Automation hooks (AWS guard rails, VPN checks)
├── powers/          # MCP power integrations (Atlassian, GitLab, etc.)
├── settings/
│   └── mcp.json     # MCP server configurations
├── steering/        # Steering rules
├── skills/          # Custom skills
└── secrets/         # Encrypted credentials
</code></pre></div></div>

<p>All profiles share this single <code class="language-plaintext highlighter-rouge">~/.kiro/</code> directory. There’s no flag, environment variable, or configuration option to redirect it.</p>

<h2 id="how-kiro-resolves-the-kiro-path">How Kiro Resolves the <code class="language-plaintext highlighter-rouge">~/.kiro</code> Path</h2>

<p>I traced the path resolution through Kiro’s source to understand the full chain. There are two independent code paths that both land on <code class="language-plaintext highlighter-rouge">~/.kiro</code>:</p>

<h3 id="main-process-electron-layer">Main Process (Electron Layer)</h3>

<p>The main Electron process resolves user data through a priority chain:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. VSCODE_PORTABLE env var  → join(PORTABLE, "user-data")
2. VSCODE_APPDATA env var   → join(VSCODE_APPDATA, nameShort)
3. --user-data-dir flag     → direct path
4. Platform default         → macOS: ~/Library/Application Support/Kiro
                              Windows: %APPDATA%\Kiro
</code></pre></div></div>

<p>For <code class="language-plaintext highlighter-rouge">~/.kiro</code> specifically (argv.json, extensions, policy), it uses:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// product.json: dataFolderName = ".kiro"</span>
<span class="kd">get</span> <span class="nf">argvResource</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">portable</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">VSCODE_PORTABLE</span><span class="p">;</span>
    <span class="k">return</span> <span class="nx">portable</span>
        <span class="p">?</span> <span class="nx">URI</span><span class="p">.</span><span class="nf">file</span><span class="p">(</span><span class="nf">join</span><span class="p">(</span><span class="nx">portable</span><span class="p">,</span> <span class="dl">"</span><span class="s2">argv.json</span><span class="dl">"</span><span class="p">))</span>
        <span class="p">:</span> <span class="nf">joinPath</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">userHome</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="nx">product</span><span class="p">.</span><span class="nx">dataFolderName</span><span class="p">,</span> <span class="dl">"</span><span class="s2">argv.json</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">this.userHome</code> comes from <code class="language-plaintext highlighter-rouge">os.homedir()</code>, which on both macOS and Windows reads the <code class="language-plaintext highlighter-rouge">HOME</code> / <code class="language-plaintext highlighter-rouge">USERPROFILE</code> environment variable.</p>

<h3 id="kiro-agent-extension-powers-hooks-agents">Kiro Agent Extension (Powers, Hooks, Agents)</h3>

<p>The kiro-agent extension — the code that actually loads powers, hooks, agents, and steering — has its own <code class="language-plaintext highlighter-rouge">getHomeDir</code> function:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">var</span> <span class="nx">getHomeDir</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">HOME</span><span class="p">,</span> <span class="nx">USERPROFILE</span><span class="p">,</span> <span class="nx">HOMEPATH</span><span class="p">,</span> <span class="nx">HOMEDRIVE</span> <span class="o">=</span> <span class="s2">`C:</span><span class="p">${</span><span class="nx">path</span><span class="p">.</span><span class="nx">sep</span><span class="p">}</span><span class="s2">`</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">HOME</span><span class="p">)</span> <span class="k">return</span> <span class="nx">HOME</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">USERPROFILE</span><span class="p">)</span> <span class="k">return</span> <span class="nx">USERPROFILE</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">HOMEPATH</span><span class="p">)</span> <span class="k">return</span> <span class="s2">`</span><span class="p">${</span><span class="nx">HOMEDRIVE</span><span class="p">}${</span><span class="nx">HOMEPATH</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
    <span class="c1">// ... fallback to os.homedir()</span>
<span class="p">};</span>
</code></pre></div></div>

<p>Then it hardcodes the <code class="language-plaintext highlighter-rouge">.kiro</code> string:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">powersPath</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">homeDir</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.kiro</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">powers</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">installed</span><span class="dl">"</span><span class="p">,</span> <span class="nx">name</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">mcpSettingsPath</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">homeDir</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.kiro</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">settings</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">mcp.json</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">steeringDir</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">homeDir</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.kiro</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">steering</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p>The string <code class="language-plaintext highlighter-rouge">".kiro"</code> is not read from <code class="language-plaintext highlighter-rouge">product.json</code> — it’s a literal in the extension bundle. This is the key constraint that shapes every solution.</p>

<h2 id="what-doesnt-work">What Doesn’t Work</h2>

<p>Before covering what does work, here’s what I evaluated and rejected:</p>

<p><strong><code class="language-plaintext highlighter-rouge">--profile</code> flag</strong>: Only isolates VS Code state, not <code class="language-plaintext highlighter-rouge">~/.kiro/</code>. Useless for this problem.</p>

<p><strong><code class="language-plaintext highlighter-rouge">VSCODE_PORTABLE</code> env var</strong>: Redirects the VS Code data path chain (user-data, extensions, argv), but the kiro-agent extension ignores it entirely and still reads <code class="language-plaintext highlighter-rouge">~/.kiro/</code> from <code class="language-plaintext highlighter-rouge">HOME</code>/<code class="language-plaintext highlighter-rouge">USERPROFILE</code>. Partial solution at best.</p>

<p><strong><code class="language-plaintext highlighter-rouge">--user-data-dir</code> flag</strong>: Same limitation — redirects VS Code state but not the kiro-agent’s <code class="language-plaintext highlighter-rouge">~/.kiro/</code> resolution.</p>

<p><strong>Symlink swapping</strong>: Works for sequential use but not parallel. Two simultaneous Kiro instances would race on the symlink target.</p>

<h2 id="approach-1-homeuserprofile-override">Approach 1: HOME/USERPROFILE Override</h2>

<p>The kiro-agent checks <code class="language-plaintext highlighter-rouge">HOME</code> (macOS/Linux) or <code class="language-plaintext highlighter-rouge">USERPROFILE</code> (Windows) before falling back to <code class="language-plaintext highlighter-rouge">os.homedir()</code>. Override it per instance, and each Kiro resolves a different <code class="language-plaintext highlighter-rouge">~/.kiro</code>.</p>

<h3 id="macos">macOS</h3>

<p>Create a fake home directory per persona that symlinks everything back to the real home except <code class="language-plaintext highlighter-rouge">.kiro</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># One-time setup</span>
<span class="nv">REAL_HOME</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">"</span>
<span class="nv">PERSONA_HOME</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/kiro-homes/engineer"</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$PERSONA_HOME</span><span class="s2">"</span>

<span class="c"># Symlink .kiro to persona-specific config</span>
<span class="nb">cp</span> <span class="nt">-R</span> ~/.kiro ~/.kiro-engineer
<span class="nb">ln</span> <span class="nt">-sfn</span> ~/.kiro-engineer <span class="s2">"</span><span class="nv">$PERSONA_HOME</span><span class="s2">/.kiro"</span>

<span class="c"># Symlink everything else Kiro or shells might need</span>
<span class="k">for </span>item <span class="k">in</span> .zshrc .zshenv .ssh .gnupg .gitconfig .config Library<span class="p">;</span> <span class="k">do
    </span><span class="nb">ln</span> <span class="nt">-sfn</span> <span class="s2">"</span><span class="nv">$REAL_HOME</span><span class="s2">/</span><span class="nv">$item</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$PERSONA_HOME</span><span class="s2">/</span><span class="nv">$item</span><span class="s2">"</span> 2&gt;/dev/null
<span class="k">done</span>

<span class="c"># Launch</span>
<span class="nb">env </span><span class="nv">HOME</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PERSONA_HOME</span><span class="s2">"</span> <span class="se">\</span>
    kiro <span class="nt">--user-data-dir</span> <span class="s2">"</span><span class="nv">$REAL_HOME</span><span class="s2">/Library/Application Support/Kiro-Engineer"</span>
</code></pre></div></div>

<h3 id="windows-powershell">Windows (PowerShell)</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$realProfile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="w">
</span><span class="nv">$personaHome</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\kiro-homes\engineer"</span><span class="w">
</span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="nx">Directory</span><span class="w"> </span><span class="nt">-Force</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$personaHome</span><span class="w">

</span><span class="c"># Copy .kiro config</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\.kiro"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\.kiro-engineer"</span><span class="w">

</span><span class="c"># Symlink .kiro in fake home (requires Developer Mode or admin)</span><span class="w">
</span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="nx">SymbolicLink</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="nv">$personaHome</span><span class="s2">\.kiro"</span><span class="w"> </span><span class="nt">-Target</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\.kiro-engineer"</span><span class="w">

</span><span class="c"># Symlink essentials back to real profile</span><span class="w">
</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$item</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="p">@(</span><span class="s1">'.ssh'</span><span class="p">,</span><span class="w"> </span><span class="s1">'.gitconfig'</span><span class="p">,</span><span class="w"> </span><span class="s1">'AppData'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Documents'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Downloads'</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="n">Test-Path</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\</span><span class="nv">$item</span><span class="s2">"</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="nx">SymbolicLink</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="nv">$personaHome</span><span class="s2">\</span><span class="nv">$item</span><span class="s2">"</span><span class="w"> </span><span class="nt">-Target</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\</span><span class="nv">$item</span><span class="s2">"</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">SilentlyContinue</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># Launch</span><span class="w">
</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$personaHome</span><span class="w">
</span><span class="o">&amp;</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro\Kiro.exe"</span><span class="w"> </span><span class="nt">--user-data-dir</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\AppData\Roaming\Kiro-Engineer"</span><span class="w">
</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$realProfile</span><span class="w">
</span></code></pre></div></div>

<p><strong>Trade-offs</strong>: Works on both platforms. The fake home directory is the main annoyance — any tool that resolves paths relative to <code class="language-plaintext highlighter-rouge">HOME</code>/<code class="language-plaintext highlighter-rouge">USERPROFILE</code> sees the fake home. The symlinks cover common cases, but you’ll occasionally discover something missing and need to add another symlink. On Windows, creating symlinks requires either Developer Mode enabled or an elevated prompt.</p>

<h2 id="approach-2-duplicate-app-with-patched-productjson">Approach 2: Duplicate App with Patched product.json</h2>

<p>Copy the Kiro installation, modify <code class="language-plaintext highlighter-rouge">product.json</code> to change the <code class="language-plaintext highlighter-rouge">dataFolderName</code>, and patch the kiro-agent extension to match. Each copy is a fully independent Kiro instance.</p>

<h3 id="macos-1">macOS</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Copy the app bundle</span>
<span class="nb">cp</span> <span class="nt">-R</span> /Applications/Kiro.app /Applications/Kiro-Engineer.app

<span class="c"># Patch product.json</span>
python3 <span class="nt">-c</span> <span class="s2">"
import json
p = '/Applications/Kiro-Engineer.app/Contents/Resources/app/product.json'
with open(p) as f: d = json.load(f)
d['dataFolderName'] = '.kiro-engineer'
d['darwinBundleIdentifier'] = 'dev.kiro.desktop.engineer'
with open(p, 'w') as f: json.dump(d, f, indent=2)
"</span>

<span class="c"># Patch the kiro-agent extension (hardcoded ".kiro" string)</span>
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">''</span> <span class="s1">'s/".kiro"/".kiro-engineer"/g'</span> <span class="se">\</span>
    /Applications/Kiro-Engineer.app/Contents/Resources/app/extensions/kiro.kiro-agent/dist/extension.js

<span class="c"># Re-sign (macOS requires valid signature)</span>
codesign <span class="nt">--remove-signature</span> /Applications/Kiro-Engineer.app
codesign <span class="nt">--force</span> <span class="nt">--deep</span> <span class="nt">--sign</span> - /Applications/Kiro-Engineer.app

<span class="c"># Create the engineer's config directory</span>
<span class="nb">cp</span> <span class="nt">-R</span> ~/.kiro ~/.kiro-engineer
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">darwinBundleIdentifier</code> change is critical — macOS uses it to distinguish app instances. With different bundle IDs, both apps appear separately in the Dock and can run truly in parallel.</p>

<h3 id="windows">Windows</h3>

<p>No code signing needed. The parallel-instance constraint on Windows is a named mutex:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Copy the installation</span><span class="w">
</span><span class="nv">$source</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro"</span><span class="w">
</span><span class="nv">$dest</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro-Engineer"</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$source</span><span class="w"> </span><span class="nt">-Destination</span><span class="w"> </span><span class="nv">$dest</span><span class="w">

</span><span class="c"># Patch product.json</span><span class="w">
</span><span class="nv">$productJson</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\resources\app\product.json"</span><span class="w">
</span><span class="nv">$product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$productJson</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">dataFolderName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">".kiro-engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">win32MutexName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"kiro-engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">win32AppUserModelId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Kiro-Engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w"> </span><span class="nt">-Depth</span><span class="w"> </span><span class="nx">10</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$productJson</span><span class="w">

</span><span class="c"># Patch kiro-agent extension</span><span class="w">
</span><span class="nv">$extJs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\resources\app\extensions\kiro.kiro-agent\dist\extension.js"</span><span class="w">
</span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$extJs</span><span class="w"> </span><span class="nt">-Raw</span><span class="p">)</span><span class="w"> </span><span class="o">-replace</span><span class="w"> </span><span class="s1">'".kiro"'</span><span class="p">,</span><span class="w"> </span><span class="s1">'".kiro-engineer"'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$extJs</span><span class="w">

</span><span class="c"># Create the engineer's config directory</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="s2">\.kiro"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="s2">\.kiro-engineer"</span><span class="w">

</span><span class="c"># Optional: create Start Menu shortcut</span><span class="w">
</span><span class="nv">$shell</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">New-Object</span><span class="w"> </span><span class="nt">-ComObject</span><span class="w"> </span><span class="nx">WScript.Shell</span><span class="w">
</span><span class="nv">$shortcut</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$shell</span><span class="o">.</span><span class="nf">CreateShortcut</span><span class="p">(</span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">APPDATA</span><span class="s2">\Microsoft\Windows\Start Menu\Programs\Kiro Engineer.lnk"</span><span class="p">)</span><span class="w">
</span><span class="nv">$shortcut</span><span class="o">.</span><span class="nf">TargetPath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\Kiro.exe"</span><span class="w">
</span><span class="nv">$shortcut</span><span class="o">.</span><span class="nf">Save</span><span class="p">()</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">win32MutexName</code> change is the Windows equivalent of <code class="language-plaintext highlighter-rouge">darwinBundleIdentifier</code> — without it, the second instance would detect the mutex and hand off to the first instance instead of launching independently.</p>

<p><strong>Trade-offs</strong>: Cleanest runtime behavior — no fake home directories, no symlink gymnastics, both instances use your real home directory for git, SSH, and everything else. The cost is maintenance: you re-apply the patch after every Kiro update. A 10-line script handles it.</p>

<h2 id="approach-3-portable-mode-partial-solution">Approach 3: Portable Mode (Partial Solution)</h2>

<p>Kiro inherits VS Code’s portable mode. If a specific directory exists, Kiro auto-sets <code class="language-plaintext highlighter-rouge">VSCODE_PORTABLE</code> and redirects all VS Code state into it:</p>

<table>
  <thead>
    <tr>
      <th>Platform</th>
      <th>Portable directory</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>macOS</td>
      <td><code class="language-plaintext highlighter-rouge">/Applications/Kiro.app/Contents/Resources/kiro-portable-data/</code></td>
    </tr>
    <tr>
      <td>Windows</td>
      <td><code class="language-plaintext highlighter-rouge">%LOCALAPPDATA%\Programs\Kiro\data\</code></td>
    </tr>
  </tbody>
</table>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># macOS: enable portable mode</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"/Applications/Kiro.app/Contents/Resources/kiro-portable-data/tmp"</span>

<span class="c"># Windows (PowerShell): enable portable mode</span>
New-Item <span class="nt">-ItemType</span> Directory <span class="nt">-Force</span> <span class="nt">-Path</span> <span class="s2">"</span><span class="nv">$env</span><span class="s2">:LOCALAPPDATA</span><span class="se">\P</span><span class="s2">rograms</span><span class="se">\K</span><span class="s2">iro</span><span class="se">\d</span><span class="s2">ata</span><span class="se">\t</span><span class="s2">mp"</span>
</code></pre></div></div>

<p>This redirects user-data, extensions, argv.json, and policy — but <strong>not</strong> the kiro-agent’s <code class="language-plaintext highlighter-rouge">~/.kiro/</code> resolution. Powers, hooks, agents, and steering still come from the home directory. Portable mode is useful for carrying a self-contained Kiro on a USB drive, but it doesn’t solve the persona isolation problem on its own.</p>

<h2 id="recommended-setup">Recommended Setup</h2>

<p>For parallel personas, I recommend Approach 2 (duplicate app + patch) because:</p>

<ul>
  <li>Both instances run simultaneously with full isolation</li>
  <li>No fake home directories or symlink maintenance</li>
  <li>Each instance appears as a separate app in the Dock / Taskbar</li>
  <li>Git, SSH, and shell tools work normally from both instances</li>
  <li>The only maintenance is re-running the patch script after Kiro updates</li>
</ul>

<h3 id="post-setup-stripping-the-developer-persona">Post-Setup: Stripping the Developer Persona</h3>

<p>After creating <code class="language-plaintext highlighter-rouge">~/.kiro-engineer</code>, remove the director-specific integrations:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Remove director powers</span>
<span class="nb">rm</span> <span class="nt">-rf</span> ~/.kiro-engineer/powers/atlassian
<span class="nb">rm</span> <span class="nt">-rf</span> ~/.kiro-engineer/powers/gitlab
<span class="nb">rm</span> <span class="nt">-rf</span> ~/.kiro-engineer/powers/github

<span class="c"># Remove director agents</span>
<span class="nb">rm</span> ~/.kiro-engineer/agents/app-expert-<span class="k">*</span>.md
<span class="nb">rm</span> ~/.kiro-engineer/agents/iac-expert-<span class="k">*</span>.md

<span class="c"># Remove director hooks</span>
<span class="nb">rm</span> ~/.kiro-engineer/hooks/aws-<span class="k">*</span>.kiro.hook
<span class="nb">rm</span> ~/.kiro-engineer/hooks/vpn-check-gitlab.kiro.hook
</code></pre></div></div>

<p>Then edit <code class="language-plaintext highlighter-rouge">~/.kiro-engineer/settings/mcp.json</code> to keep only coding-relevant MCP servers (Terraform, AWS docs) and remove Atlassian, GitLab, NewRelic, and Wiz.</p>

<h3 id="update-automation">Update Automation</h3>

<h4 id="macos-update-kiro-engineersh">macOS (<code class="language-plaintext highlighter-rouge">update-kiro-engineer.sh</code>)</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">APP</span><span class="o">=</span><span class="s2">"/Applications/Kiro-Engineer.app"</span>
<span class="nv">SRC</span><span class="o">=</span><span class="s2">"/Applications/Kiro.app"</span>
<span class="nv">PRODUCT</span><span class="o">=</span><span class="s2">"</span><span class="nv">$APP</span><span class="s2">/Contents/Resources/app/product.json"</span>
<span class="nv">EXTENSION</span><span class="o">=</span><span class="s2">"</span><span class="nv">$APP</span><span class="s2">/Contents/Resources/app/extensions/kiro.kiro-agent/dist/extension.js"</span>

<span class="nb">echo</span> <span class="s2">"Updating Kiro-Engineer from Kiro..."</span>
<span class="nb">rm</span> <span class="nt">-rf</span> <span class="s2">"</span><span class="nv">$APP</span><span class="s2">"</span>
<span class="nb">cp</span> <span class="nt">-R</span> <span class="s2">"</span><span class="nv">$SRC</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$APP</span><span class="s2">"</span>

python3 <span class="nt">-c</span> <span class="s2">"
import json
with open('</span><span class="nv">$PRODUCT</span><span class="s2">') as f: d = json.load(f)
d['dataFolderName'] = '.kiro-engineer'
d['darwinBundleIdentifier'] = 'dev.kiro.desktop.engineer'
with open('</span><span class="nv">$PRODUCT</span><span class="s2">', 'w') as f: json.dump(d, f, indent=2)
"</span>

<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">''</span> <span class="s1">'s/\".kiro\"/\".kiro-engineer\"/g'</span> <span class="s2">"</span><span class="nv">$EXTENSION</span><span class="s2">"</span>

codesign <span class="nt">--remove-signature</span> <span class="s2">"</span><span class="nv">$APP</span><span class="s2">"</span>
codesign <span class="nt">--force</span> <span class="nt">--deep</span> <span class="nt">--sign</span> - <span class="s2">"</span><span class="nv">$APP</span><span class="s2">"</span>

<span class="nb">echo</span> <span class="s2">"Done. ~/.kiro-engineer/ config is preserved."</span>
</code></pre></div></div>

<h4 id="windows-update-kiroengineerps1">Windows (<code class="language-plaintext highlighter-rouge">Update-KiroEngineer.ps1</code>)</h4>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$source</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro"</span><span class="w">
</span><span class="nv">$dest</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro-Engineer"</span><span class="w">

</span><span class="n">Write-Host</span><span class="w"> </span><span class="s2">"Updating Kiro-Engineer from Kiro..."</span><span class="w">
</span><span class="n">Remove-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="nt">-Force</span><span class="w"> </span><span class="nv">$dest</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">SilentlyContinue</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$source</span><span class="w"> </span><span class="nt">-Destination</span><span class="w"> </span><span class="nv">$dest</span><span class="w">

</span><span class="nv">$productJson</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\resources\app\product.json"</span><span class="w">
</span><span class="nv">$product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$productJson</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">dataFolderName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">".kiro-engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">win32MutexName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"kiro-engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">win32AppUserModelId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Kiro-Engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w"> </span><span class="nt">-Depth</span><span class="w"> </span><span class="nx">10</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$productJson</span><span class="w">

</span><span class="nv">$extJs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\resources\app\extensions\kiro.kiro-agent\dist\extension.js"</span><span class="w">
</span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$extJs</span><span class="w"> </span><span class="nt">-Raw</span><span class="p">)</span><span class="w"> </span><span class="o">-replace</span><span class="w"> </span><span class="s1">'".kiro"'</span><span class="p">,</span><span class="w"> </span><span class="s1">'".kiro-engineer"'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$extJs</span><span class="w">

</span><span class="n">Write-Host</span><span class="w"> </span><span class="s2">"Done. </span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="s2">\.kiro-engineer\ config is preserved."</span><span class="w">
</span></code></pre></div></div>

<h2 id="platform-specific-notes">Platform-Specific Notes</h2>

<h3 id="macos-2">macOS</h3>

<ul>
  <li>Ad-hoc code signing (<code class="language-plaintext highlighter-rouge">codesign --force --deep --sign -</code>) is required after modifying any file inside the <code class="language-plaintext highlighter-rouge">.app</code> bundle. macOS Gatekeeper will block unsigned modified apps.</li>
  <li>The <code class="language-plaintext highlighter-rouge">darwinBundleIdentifier</code> must differ between copies for macOS to treat them as separate applications. Without this, the second instance may not launch or may share window state with the first.</li>
  <li>Kiro auto-updates only affect the original <code class="language-plaintext highlighter-rouge">/Applications/Kiro.app</code>. The engineer copy must be manually updated via the script above.</li>
</ul>

<h3 id="windows-11-pro">Windows 11 Pro</h3>

<ul>
  <li>No code signing is required for locally installed applications.</li>
  <li>The <code class="language-plaintext highlighter-rouge">win32MutexName</code> must differ between copies. Windows uses a named kernel mutex for single-instance enforcement — identical mutex names cause the second launch to hand off to the existing instance.</li>
  <li>If Kiro was installed via the system installer (to <code class="language-plaintext highlighter-rouge">C:\Program Files\Kiro\</code>), copying and patching requires administrator privileges. The user installer (<code class="language-plaintext highlighter-rouge">%LOCALAPPDATA%\Programs\Kiro\</code>) does not have this limitation.</li>
  <li>Creating symbolic links on Windows requires either Developer Mode enabled in Settings or an elevated PowerShell prompt. This only matters for Approach 1 (HOME override). Approach 2 doesn’t need symlinks.</li>
  <li>Kiro auto-updates only affect the original installation. The engineer copy must be manually updated.</li>
</ul>

<h2 id="what-kiro-could-do-natively">What Kiro Could Do Natively</h2>

<p>The underlying issue is that the kiro-agent extension hardcodes <code class="language-plaintext highlighter-rouge">".kiro"</code> as a string literal rather than reading <code class="language-plaintext highlighter-rouge">dataFolderName</code> from <code class="language-plaintext highlighter-rouge">product.json</code> or exposing a configuration option. If the agent resolved its config directory through the same <code class="language-plaintext highlighter-rouge">dataFolderName</code> mechanism the main process uses, the <code class="language-plaintext highlighter-rouge">--profile</code> flag or <code class="language-plaintext highlighter-rouge">--user-data-dir</code> could potentially isolate everything.</p>

<p>Even simpler: a <code class="language-plaintext highlighter-rouge">KIRO_HOME</code> environment variable that overrides the <code class="language-plaintext highlighter-rouge">~/.kiro</code> path would eliminate the need for any of these workarounds. VS Code’s <code class="language-plaintext highlighter-rouge">VSCODE_PORTABLE</code> demonstrates the pattern — Kiro just needs to extend it to the agent layer.</p>

<p>Until then, the duplicate-and-patch approach works reliably on both platforms. The friction is real, but the workaround is a one-time setup plus a 10-second script after updates.</p>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="technical" /><category term="devtools" /><category term="kiro" /><category term="kiro" /><category term="vscode" /><category term="ide" /><category term="personas" /><category term="macos" /><category term="windows" /><category term="mcp" /><category term="powers" /><category term="configuration" /><category term="productivity" /><summary type="html"><![CDATA[Deep technical analysis of Kiro IDE's configuration path resolution and practical approaches to running parallel Kiro instances with isolated powers, hooks, agents, and MCP server configurations on macOS and Windows 11 Pro.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/kiro-ide-parallel-personas-director-developer.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/kiro-ide-parallel-personas-director-developer.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Jekyll’s Invisible Bug: When Code Fences Don’t Protect Your Liquid Examples</title><link href="https://mcgarrah.org/jekyll-liquid-code-fence-rendering-trap/" rel="alternate" type="text/html" title="Jekyll’s Invisible Bug: When Code Fences Don’t Protect Your Liquid Examples" /><published>2026-05-01T00:00:00+00:00</published><updated>2026-05-01T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-liquid-code-fence-rendering-trap</id><content type="html" xml:base="https://mcgarrah.org/jekyll-liquid-code-fence-rendering-trap/"><![CDATA[<p>Jekyll’s rendering pipeline has a design decision that bites anyone who writes about template systems: Liquid processes every tag in your Markdown files <em>before</em> the Markdown processor sees them. Code fences, backtick spans, indented blocks — none of them protect Liquid syntax from execution. If you maintain a Jekyll-based documentation platform and your content includes template examples, this is a content integrity problem that silently corrupts output or crashes builds.</p>

<p>I discovered this when a blog post about reading time calculation crashed my build. The error pointed to a draft file, but the code it complained about was inside a Markdown code fence — supposedly safe, display-only text. It wasn’t.</p>

<p>Jekyll’s Liquid template engine processes <strong>every</strong> <code class="language-plaintext highlighter-rouge">{{ }}</code> and <code class="language-plaintext highlighter-rouge">{% %}</code> tag in your Markdown files before the Markdown processor ever sees them. Code fences, backtick spans, indented code blocks — none of them protect Liquid syntax from execution. If you write posts about Jekyll, Liquid, GitHub Actions, or anything else that uses double-curly-brace syntax, your examples are being silently eaten or actively breaking your build.</p>

<!-- excerpt-end -->

<h2 id="the-problem">The Problem</h2>

<p>Consider a blog post explaining how Jekyll’s reading time calculation works. You’d naturally include the implementation:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">```</span><span class="nl">liquid
</span><span class="cp">{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">words_per_minute</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">200</span><span class="w"> </span><span class="cp">-%}</span>
<span class="cp">{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">number_of_words</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">include</span><span class="p">.</span><span class="nv">post</span><span class="p">.</span><span class="nv">content</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">number_of_words</span><span class="w"> </span><span class="cp">-%}</span>
<span class="p">```</span>
</code></pre></div></div>

<p>This looks safe. It’s inside a fenced code block. Every other language treats code fences as literal text.</p>

<p>Jekyll doesn’t. The Liquid engine runs first, sees the <code class="language-plaintext highlighter-rouge">{% %}</code> tags, and tries to execute them. In this case, <code class="language-plaintext highlighter-rouge">include.post.content</code> is nil (there’s no include context), so <code class="language-plaintext highlighter-rouge">number_of_words</code> gets nil input and the build crashes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Liquid Exception: undefined method `split' for nil:NilClass
</code></pre></div></div>

<h2 id="why-it-sneaks-up-on-you">Why It Sneaks Up on You</h2>

<p>The insidious part is that most unprotected Liquid doesn’t crash — it <strong>silently renders as empty text</strong>. A code example like:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">```</span><span class="nl">html
</span><span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:url"</span> <span class="na">content=</span><span class="s">"{{ site.url }}{{ page.url }}"</span><span class="nt">&gt;</span>
<span class="p">```</span>
</code></pre></div></div>

<p>Won’t crash because <code class="language-plaintext highlighter-rouge">site.url</code> and <code class="language-plaintext highlighter-rouge">page.url</code> are valid variables. Jekyll evaluates them, substitutes the values, and your “code example” now shows your actual URL instead of the template syntax. The Markdown renders, the page looks fine at a glance, and you don’t notice that your readers see <code class="language-plaintext highlighter-rouge">https://mcgarrah.org/some-post/</code> where they should see <code class="language-plaintext highlighter-rouge">{{ site.url }}{{ page.url }}</code>.</p>

<p>You only discover the problem when:</p>

<ol>
  <li><strong>A variable is nil</strong> — build crashes with <code class="language-plaintext highlighter-rouge">undefined method</code> errors</li>
  <li><strong>A tag is unbalanced</strong> — <code class="language-plaintext highlighter-rouge">Liquid syntax error: 'if' tag was never closed</code></li>
  <li><strong>A reader reports</strong> that your code examples are blank or show wrong values</li>
  <li><strong>You view source</strong> and notice the Liquid output instead of Liquid syntax</li>
</ol>

<h3 id="what-triggers-build-failures-vs-silent-corruption">What Triggers Build Failures vs Silent Corruption</h3>

<table>
  <thead>
    <tr>
      <th>Pattern</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{{ site.url }}</code></td>
      <td>Silent — renders as your actual URL</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{{ page.title }}</code></td>
      <td>Silent — renders as the post’s title</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{{ include.post.content }}</code></td>
      <td><strong>Crash</strong> — nil when not inside an include</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{% if paginator.previous_page %}</code> with matching <code class="language-plaintext highlighter-rouge">{% endif %}</code></td>
      <td>Silent — evaluates the condition, renders nothing</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{% if condition %}</code> without <code class="language-plaintext highlighter-rouge">{% endif %}</code></td>
      <td><strong>Crash</strong> — unclosed tag error</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">${{ secrets.GITHUB_TOKEN }}</code></td>
      <td>Silent — renders as empty string</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{% seo %}</code></td>
      <td><strong>Crash or unexpected output</strong> — executes the SEO plugin</td>
    </tr>
  </tbody>
</table>

<h2 id="the-fix-raw--endraw-tags">The Fix: raw / endraw Tags</h2>

<p>The <code>{&#37; raw %}</code> and <code>{&#37; endraw %}</code> tags tell Liquid to pass content through without processing. Wrap your code examples:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">```</span><span class="nl">liquid
</span><span class="cp">{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">words_per_minute</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">200</span><span class="w"> </span><span class="cp">-%}</span>
<span class="cp">{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">number_of_words</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">include</span><span class="p">.</span><span class="nv">post</span><span class="p">.</span><span class="nv">content</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">number_of_words</span><span class="w"> </span><span class="cp">-%}</span>
<span class="p">```</span>
</code></pre></div></div>

<p>Place <code>{&#37; raw %}</code> immediately after the opening code fence and <code>{&#37; endraw %}</code> just before the closing fence.</p>

<h3 id="inline-code-too">Inline Code Too</h3>

<p>Backtick inline code is equally unprotected. This in your Markdown:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The <span class="sb">`{% seo %}`</span> tag generates meta tags.
</code></pre></div></div>

<p>Needs to become:</p>

<pre><code class="language-markdown">The &#123;&#37; raw &#37;&#125;`&#123;&#37; seo &#37;&#125;`&#123;&#37; endraw &#37;&#125; tag generates meta tags.</code></pre>

<h3 id="the-nested-rawendraw-problem">The Nested raw/endraw Problem</h3>

<p>You can’t show literal <code>&#123;&#37; raw &#37;&#125;</code> or <code>&#123;&#37; endraw &#37;&#125;</code> text inside a <code>&#123;&#37; raw &#37;&#125;</code> block — Liquid sees the inner <code>&#123;&#37; endraw &#37;&#125;</code> and terminates the block early. For posts that need to display the raw/endraw tags themselves (like this one), use HTML character entities:</p>

<pre><code>&lt;code&gt;&#123;&#38;#37; raw &#37;&#125;&lt;/code&gt; and &lt;code&gt;&#123;&#38;#37; endraw &#37;&#125;&lt;/code&gt;</code></pre>

<p>The <code class="language-plaintext highlighter-rouge">&amp;#37;</code> entity renders as <code class="language-plaintext highlighter-rouge">%</code>, producing <code>&#123;&#37; raw &#37;&#125;</code> visually while avoiding Liquid parsing.</p>

<h3 id="github-actions-expressions">GitHub Actions Expressions</h3>

<p>GitHub Actions uses <code class="language-plaintext highlighter-rouge">${{ }}</code> syntax which Liquid also intercepts. Any workflow YAML in a code block needs the same treatment:</p>

<pre><code class="language-markdown">```yaml
&#123;&#37; raw &#37;&#125;
- name: Build
  run: bundle exec jekyll build --baseurl "$&#123;&#123; steps.pages.outputs.base_path &#125;&#125;"
&#123;&#37; endraw &#37;&#125;
```</code></pre>

<h2 id="finding-every-affected-file">Finding Every Affected File</h2>

<p>After discovering this problem in one draft, I found it in a dozen more files. Here’s a script that scans your entire site for unprotected Liquid tags outside of <code class="language-plaintext highlighter-rouge">raw</code> blocks:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#!/usr/bin/env python3
</span><span class="sh">"""</span><span class="s">Scan Jekyll posts/drafts for unprotected Liquid tags.</span><span class="sh">"""</span>
<span class="kn">import</span> <span class="n">re</span><span class="p">,</span> <span class="n">glob</span>

<span class="n">LEGIT_TAGS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="sh">'</span><span class="s">{% highlight</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">{% endhighlight</span><span class="sh">'</span><span class="p">,</span>
    <span class="sh">'</span><span class="s">{% include</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">{% post_url</span><span class="sh">'</span><span class="p">,</span>
    <span class="sh">'</span><span class="s">{% comment</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">{% endcomment</span><span class="sh">'</span><span class="p">,</span>
<span class="p">]</span>

<span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="nf">sorted</span><span class="p">(</span><span class="n">glob</span><span class="p">.</span><span class="nf">glob</span><span class="p">(</span><span class="sh">'</span><span class="s">_posts/*.md</span><span class="sh">'</span><span class="p">)</span> <span class="o">+</span> <span class="n">glob</span><span class="p">.</span><span class="nf">glob</span><span class="p">(</span><span class="sh">'</span><span class="s">_drafts/*.md</span><span class="sh">'</span><span class="p">)):</span>
    <span class="n">content</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="n">f</span><span class="p">).</span><span class="nf">read</span><span class="p">()</span>
    <span class="c1"># Strip raw blocks
</span>    <span class="n">cleaned</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span>
        <span class="sa">r</span><span class="sh">'</span><span class="s">\{%-?\s*raw\s*-?%\}.*?\{%-?\s*endraw\s*-?%\}</span><span class="sh">'</span><span class="p">,</span>
        <span class="sh">''</span><span class="p">,</span> <span class="n">content</span><span class="p">,</span> <span class="n">flags</span><span class="o">=</span><span class="n">re</span><span class="p">.</span><span class="n">DOTALL</span>
    <span class="p">)</span>
    <span class="c1"># Strip front matter
</span>    <span class="n">cleaned</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sa">r</span><span class="sh">'</span><span class="s">^---.*?---</span><span class="sh">'</span><span class="p">,</span> <span class="sh">''</span><span class="p">,</span> <span class="n">cleaned</span><span class="p">,</span> <span class="n">count</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">flags</span><span class="o">=</span><span class="n">re</span><span class="p">.</span><span class="n">DOTALL</span><span class="p">)</span>

    <span class="n">found</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">line</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">cleaned</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="sh">'</span><span class="se">\n</span><span class="sh">'</span><span class="p">),</span> <span class="mi">1</span><span class="p">):</span>
        <span class="k">if</span> <span class="sh">'</span><span class="s">{%</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">line</span> <span class="ow">or</span> <span class="sh">'</span><span class="s">{{</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">line</span><span class="p">:</span>
            <span class="n">s</span> <span class="o">=</span> <span class="n">line</span><span class="p">.</span><span class="nf">strip</span><span class="p">()</span>
            <span class="k">if</span> <span class="nf">any</span><span class="p">(</span><span class="n">x</span> <span class="ow">in</span> <span class="n">s</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">LEGIT_TAGS</span><span class="p">):</span>
                <span class="k">continue</span>
            <span class="n">found</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sa">f</span><span class="sh">'</span><span class="s">  L</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">s</span><span class="p">[</span><span class="si">:</span><span class="mi">120</span><span class="p">]</span><span class="si">}</span><span class="sh">'</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">found</span><span class="p">:</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">'</span><span class="se">\n</span><span class="s">=== </span><span class="si">{</span><span class="n">f</span><span class="si">}</span><span class="s"> ===</span><span class="sh">'</span><span class="p">)</span>
        <span class="nf">print</span><span class="p">(</span><span class="sh">'</span><span class="se">\n</span><span class="sh">'</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">found</span><span class="p">))</span>
</code></pre></div></div>

<p>Run it from your Jekyll project root:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 find-unprotected-liquid.py
</code></pre></div></div>

<p>The script strips <code class="language-plaintext highlighter-rouge">raw</code>/<code class="language-plaintext highlighter-rouge">endraw</code> blocks and front matter first, then reports any remaining Liquid syntax. It skips legitimate Jekyll tags like <code class="language-plaintext highlighter-rouge">{% highlight %}</code> and <code class="language-plaintext highlighter-rouge">{% include %}</code> that are meant to execute.</p>

<h2 id="why-this-happens">Why This Happens</h2>

<p>Jekyll’s rendering pipeline processes files in this order:</p>

<ol>
  <li><strong>Liquid template engine</strong> — evaluates all <code class="language-plaintext highlighter-rouge">{{ }}</code> and <code class="language-plaintext highlighter-rouge">{% %}</code> tags</li>
  <li><strong>Markdown processor</strong> (Kramdown) — converts Markdown to HTML</li>
  <li><strong>Layout rendering</strong> — wraps content in layout templates</li>
</ol>

<p>Here’s the pipeline visualized — notice that Liquid runs before Markdown has any chance to identify code fences:</p>

<pre><code class="language-mermaid">flowchart TD
    A["📄 Markdown Source File"] --&gt; B["1️⃣ Liquid Template Engine"]
    B --&gt; |"Evaluates ALL &amp;#123;&amp;#123; &amp;#125;&amp;#125; and &amp;#123;% %&amp;#125; tags"| C{"Tag inside&lt;br/&gt;raw/endraw?"}
    C --&gt; |Yes| D["Pass through as literal text"]
    C --&gt; |No| E{"Valid variable&lt;br/&gt;or tag?"}
    E --&gt; |"Yes — e.g. site.url"| F["🔇 Silent substitution&lt;br/&gt;renders actual value"]
    E --&gt; |"No — e.g. nil variable"| G["💥 Build crash&lt;br/&gt;undefined method error"]
    E --&gt; |"Unbalanced tag"| H["💥 Build crash&lt;br/&gt;Liquid syntax error"]
    D --&gt; I["2️⃣ Kramdown Markdown Processor"]
    F --&gt; I
    I --&gt; |"NOW identifies code fences&lt;br/&gt;but Liquid already ran"| J["HTML Output"]
    J --&gt; K["3️⃣ Layout Rendering"]
    K --&gt; L["📄 Final Page"]

    style B fill:#e74c3c,color:#fff
    style I fill:#3498db,color:#fff
    style K fill:#2ecc71,color:#fff
    style F fill:#f39c12,color:#fff
    style G fill:#c0392b,color:#fff
    style H fill:#c0392b,color:#fff
    style D fill:#27ae60,color:#fff
</code></pre>

<p>The key insight: <strong>Liquid has no concept of Markdown code fences.</strong> By the time Kramdown identifies your triple-backtick block, Liquid has already consumed or evaluated everything inside it.</p>

<p>This is documented in <a href="https://jekyllrb.com/docs/liquid/">Jekyll’s Liquid processing docs</a>, but it’s easy to miss because every other context where you write code (GitHub READMEs, Stack Overflow, documentation sites) treats code fences as sacred.</p>

<h2 id="posts-that-commonly-need-this">Posts That Commonly Need This</h2>

<p>If you write about any of these topics, check your code examples:</p>

<ul>
  <li><strong>Jekyll configuration</strong> — <code class="language-plaintext highlighter-rouge">{{ site.* }}</code>, <code class="language-plaintext highlighter-rouge">{{ page.* }}</code> variables</li>
  <li><strong>Liquid templates</strong> — <code class="language-plaintext highlighter-rouge">{% if %}</code>, <code class="language-plaintext highlighter-rouge">{% for %}</code>, <code class="language-plaintext highlighter-rouge">{% assign %}</code> tags</li>
  <li><strong>GitHub Actions workflows</strong> — <code class="language-plaintext highlighter-rouge">${{ secrets.* }}</code>, <code class="language-plaintext highlighter-rouge">${{ env.* }}</code>, <code class="language-plaintext highlighter-rouge">${{ steps.* }}</code></li>
  <li><strong>Jinja2 templates</strong> — same <code class="language-plaintext highlighter-rouge">{{ }}</code> syntax as Liquid</li>
  <li><strong>Ansible playbooks</strong> — <code class="language-plaintext highlighter-rouge">{{ variable }}</code> syntax</li>
  <li><strong>Mustache/Handlebars</strong> — <code class="language-plaintext highlighter-rouge">{{ }}</code> and <code class="language-plaintext highlighter-rouge">{{{ }}}</code> syntax</li>
  <li><strong>Vue.js templates</strong> — <code class="language-plaintext highlighter-rouge">{{ }}</code> interpolation syntax</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ol>
  <li><strong>Code fences are not a security boundary</strong> for Liquid. Never assume content inside backticks is safe from template processing.</li>
  <li><strong>Silent failures are worse than crashes.</strong> The posts that render “successfully” with wrong content are harder to catch than the ones that blow up the build.</li>
  <li><strong>Scan proactively.</strong> After fixing one post, scan everything. The same pattern tends to appear in clusters — if you wrote one post about Jekyll internals, you probably wrote several.</li>
  <li><strong>Keep the detection script.</strong> Run it as part of your pre-commit or CI workflow to catch new instances before they ship.</li>
</ol>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made: Every Feature Powering This Jekyll Blog</a> — Complete feature reference including Liquid escaping</li>
  <li><a href="/jekyll-mermaid-diagram-rendering-challenges/">Mermaid Diagram Rendering Challenges</a> — Another case where Jekyll’s rendering pipeline causes surprises</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://jekyllrb.com/docs/liquid/">Jekyll Liquid Processing</a> — Official docs on how Liquid interacts with content</li>
  <li><a href="https://shopify.github.io/liquid/tags/template/#raw">Liquid raw Tag</a> — Shopify’s Liquid documentation</li>
  <li><a href="https://jekyllrb.com/docs/plugins/hooks/">Jekyll Rendering Order</a> — The pipeline that explains why Liquid runs before Markdown</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="liquid" /><category term="markdown" /><category term="debugging" /><category term="code-blocks" /><category term="github-pages" /><category term="raw-tag" /><summary type="html"><![CDATA[A deep dive into Jekyll's Liquid rendering behavior inside Markdown code fences. Explains why code blocks don't protect Liquid tags, how to detect unprotected tags across an entire site, the difference between silent failures and build crashes, and the correct use of raw/endraw tags with edge cases for nested examples.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-liquid-code-fence-rendering-trap.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-liquid-code-fence-rendering-trap.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Stop the macOS Dock from Jumping Between Monitors</title><link href="https://mcgarrah.org/macos-dock-jumping-between-monitors/" rel="alternate" type="text/html" title="Stop the macOS Dock from Jumping Between Monitors" /><published>2026-04-30T00:00:00+00:00</published><updated>2026-04-30T00:00:00+00:00</updated><id>https://mcgarrah.org/macos-dock-jumping-between-monitors</id><content type="html" xml:base="https://mcgarrah.org/macos-dock-jumping-between-monitors/"><![CDATA[<p>If you use multiple monitors on macOS, you have almost certainly experienced this: you move your mouse to the bottom of the wrong screen and the Dock teleports away from where you put it. You drag it back, and five minutes later it jumps again. It is one of those small annoyances that compounds into genuine frustration over a workday.</p>

<p>I run a multi-monitor setup on my macOS workstation and this drove me crazy until I tracked down the actual causes and fixes. None of this is complicated, but Apple does not make it obvious.</p>

<!-- excerpt-end -->

<h2 id="why-the-dock-jumps">Why the Dock Jumps</h2>

<p>The Dock follows a simple rule: it appears on whichever display the cursor pushes against the bottom edge. If your cursor drifts to the bottom of a secondary monitor — even briefly — macOS moves the Dock there. This is by design, not a bug. Apple considers it a feature for multi-monitor workflows.</p>

<p>Three things make it worse:</p>

<ul>
  <li><strong>Accidental activation</strong> — The Dock jumps when the cursor hits the bottom of an inactive display. If you are moving the mouse quickly between screens, you will trigger this constantly.</li>
  <li><strong>External display wakeup</strong> — When macOS wakes from sleep, it can default the Dock to the wrong display. This is especially common with external monitors that take a moment to handshake.</li>
  <li><strong>Display arrangement</strong> — If your displays are arranged vertically in System Settings → Displays → Arrangement, the Dock behaves unpredictably because the bottom edge of one display overlaps with the top of another.</li>
</ul>

<h2 id="the-quick-fix-click-and-hold">The Quick Fix: Click and Hold</h2>

<p>The fastest way to move the Dock back is also the least documented:</p>

<ol>
  <li>Move your cursor to the very bottom edge of the screen where you want the Dock.</li>
  <li>Push the cursor down against the edge and <strong>hold it there for 2–3 seconds</strong>.</li>
  <li>The Dock slides back to that display.</li>
</ol>

<p>This is not a permanent fix — the Dock will jump again the next time you trigger it — but it is the fastest recovery when it happens.</p>

<h2 id="the-permanent-fix-mission-control-settings">The Permanent Fix: Mission Control Settings</h2>

<p>The setting that causes most of the jumping is buried in Mission Control:</p>

<ol>
  <li>Open <strong>System Settings</strong> → <strong>Desktop &amp; Dock</strong>.</li>
  <li>Scroll down to the <strong>Mission Control</strong> section.</li>
  <li>Toggle off <strong>“Automatically rearrange Spaces based on most recent use”</strong>.</li>
</ol>

<p>This setting causes macOS to reorder your Spaces (and by extension, the Dock’s display assignment) based on which screen you used most recently. Turning it off keeps your Spaces — and the Dock — where you put them.</p>

<p>While you are in that settings panel, also check:</p>

<ul>
  <li><strong>“Displays have separate Spaces”</strong> should be <strong>enabled</strong>. This gives each monitor its own menu bar and improves multi-monitor behavior overall. Without it, macOS treats all displays as one continuous Space, which makes the Dock even more prone to wandering.</li>
</ul>

<p><a href="/assets/images/macos-dock-04.png" target="_blank"><img src="/assets/images/macos-dock-04.png" alt="Desktop &amp; Dock settings showing Spaces configuration — disable auto-rearrange Spaces and enable Displays have separate Spaces" width="45%" height="45%" style="display:block; margin-left:auto; margin-right:auto" /></a></p>

<p>Note that toggling “Displays have separate Spaces” requires a logout and login to take effect.</p>

<h2 id="display-arrangement-matters">Display Arrangement Matters</h2>

<p>If your monitors are physically side by side but macOS thinks one is above the other, the Dock will behave strangely. Check your arrangement:</p>

<ol>
  <li>Open <strong>System Settings</strong> → <strong>Displays</strong>.</li>
  <li>Click <strong>Arrange…</strong> (or drag the display icons directly in newer macOS versions).</li>
  <li>Make sure the display positions match your physical layout.</li>
  <li>The white bar at the top of one display icon indicates which monitor has the menu bar — drag it to your primary display if it is on the wrong one.</li>
</ol>

<p>The key detail: the Dock lives on the display with the white menu bar by default, but it can still jump to other displays. The main monitor (white bar) and the Dock’s home display are independent — you can have the menu bar on your laptop screen and pin the Dock to an external monitor, or vice versa. Understanding this distinction is what makes the arrangement settings click.</p>

<p>Vertical arrangements are particularly problematic because the bottom edge of the top display and the top edge of the bottom display share a boundary. The Dock can get confused about which display owns the bottom edge.</p>

<p>The Displays settings panel is the starting point — note the <strong>Arrange…</strong> button in the lower right corner:</p>

<p><a href="/assets/images/macos-dock-01.png" target="_blank"><img src="/assets/images/macos-dock-01.png" alt="macOS System Settings Displays panel with the Arrange button in the lower right corner" width="45%" height="45%" style="display:block; margin-left:auto; margin-right:auto" /></a></p>

<p>Clicking <strong>Arrange…</strong> opens the arrangement view where you drag displays to match your physical layout. The key detail is the instruction at the top: “To relocate the menu bar, drag it to a different display.” The menu bar — and by default the Dock — is hard-wired to whichever display has that white bar:</p>

<p><a href="/assets/images/macos-dock-02.png" target="_blank"><img src="/assets/images/macos-dock-02.png" alt="Display arrangement showing the menu bar position on the primary display" width="50%" height="50%" style="display:block; margin-left:auto; margin-right:auto" /></a></p>

<p><a href="/assets/images/macos-dock-03.png" target="_blank"><img src="/assets/images/macos-dock-03.png" alt="Display arrangement with an alternative layout" width="50%" height="50%" style="display:block; margin-left:auto; margin-right:auto" /></a></p>

<p>Vertical arrangements are particularly problematic because the bottom edge of the top display and the top edge of the bottom display share a boundary. The Dock can get confused about which display owns the bottom edge.</p>

<h2 id="summary">Summary</h2>

<table>
  <thead>
    <tr>
      <th>Fix</th>
      <th>Permanence</th>
      <th>Effort</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Click and hold bottom edge for 2–3 seconds</td>
      <td>Temporary</td>
      <td>Instant</td>
    </tr>
    <tr>
      <td>Disable “Automatically rearrange Spaces”</td>
      <td>Permanent</td>
      <td>30 seconds</td>
    </tr>
    <tr>
      <td>Enable “Displays have separate Spaces”</td>
      <td>Permanent</td>
      <td>30 seconds</td>
    </tr>
    <tr>
      <td>Fix display arrangement</td>
      <td>Permanent</td>
      <td>1 minute</td>
    </tr>
  </tbody>
</table>

<p>The Mission Control toggle is the one that fixes it for most people. If you only do one thing, do that.</p>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="macos" /><category term="hardware" /><category term="macos" /><category term="multi-monitor" /><category term="dock" /><category term="display" /><category term="tips" /><category term="productivity" /><summary type="html"><![CDATA[How to stop the macOS Dock from jumping between monitors in a multi-display setup. Covers the click-and-hold trick, Mission Control settings, and display arrangement fixes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/macos-dock-jumping-between-monitors.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/macos-dock-jumping-between-monitors.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Small Things: Polish Features That Make a Jekyll Blog Feel Professional</title><link href="https://mcgarrah.org/jekyll-small-things-polish-features/" rel="alternate" type="text/html" title="The Small Things: Polish Features That Make a Jekyll Blog Feel Professional" /><published>2026-04-29T00:00:00+00:00</published><updated>2026-04-29T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-small-things-polish-features</id><content type="html" xml:base="https://mcgarrah.org/jekyll-small-things-polish-features/"><![CDATA[<p>Nobody visits a blog because it has a print stylesheet. Nobody subscribes because the 404 page has a haiku. But these small touches signal that the site is maintained, that someone thought about the details, and that the content is worth the reader’s time.</p>

<p>Here are six features that each took less than a day to implement but collectively transformed this blog from a default Jekyll template into something that feels intentional.</p>

<!-- excerpt-end -->

<h2 id="darklight-theme">Dark/Light Theme</h2>

<p>The blog automatically matches the reader’s operating system preference — dark mode on dark systems, light mode on light systems. No toggle button, no JavaScript, no cookie to remember the choice.</p>

<h3 id="implementation">Implementation</h3>

<p>The entire dark mode is a single CSS media query in <code class="language-plaintext highlighter-rouge">_sass/basic.sass</code>:</p>

<div class="language-sass highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="p">(</span><span class="n">prefers-color-scheme</span><span class="o">:</span> <span class="n">dark</span><span class="p">)</span>
  <span class="nt">body</span>
    <span class="nl">background</span><span class="p">:</span> <span class="nv">$dark</span>
    <span class="nl">color</span><span class="p">:</span> <span class="nv">$light</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">$dark</code> and <code class="language-plaintext highlighter-rouge">$light</code> variables come from the Contrast theme’s SASS variable system. The theme was designed with dark mode support from the beginning (the original author, Niklas Buschmann, added it in December 2019), but it needed maintenance as I added features.</p>

<h3 id="the-mermaid-dark-mode-fix">The Mermaid Dark Mode Fix</h3>

<p>When I added Mermaid diagram support in September 2025, the diagrams rendered with a white background in dark mode — a blinding white rectangle in an otherwise dark page. The fix was detecting the color scheme in JavaScript and passing it to Mermaid’s initialization:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">isDarkMode</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">matchMedia</span> <span class="o">&amp;&amp;</span>
  <span class="nb">window</span><span class="p">.</span><span class="nf">matchMedia</span><span class="p">(</span><span class="dl">'</span><span class="s1">(prefers-color-scheme: dark)</span><span class="dl">'</span><span class="p">).</span><span class="nx">matches</span><span class="p">;</span>
<span class="nx">mermaid</span><span class="p">.</span><span class="nf">initialize</span><span class="p">({</span>
  <span class="na">startOnLoad</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">theme</span><span class="p">:</span> <span class="nx">isDarkMode</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">dark</span><span class="dl">'</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">default</span><span class="dl">'</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The Google Custom Search widget also needed dark mode styling (<code class="language-plaintext highlighter-rouge">_sass/google-search.sass</code>), and the Giscus comment widget gets it for free via the <code class="language-plaintext highlighter-rouge">preferred_color_scheme</code> theme setting.</p>

<h3 id="why-no-toggle">Why No Toggle?</h3>

<p>Some blogs add a manual dark/light toggle button. I chose not to because:</p>

<ul>
  <li><strong>OS preference is the right default</strong> — If someone set their system to dark mode, they want dark mode everywhere</li>
  <li><strong>No JavaScript dependency</strong> — The CSS media query works without JavaScript, in RSS readers, and in print</li>
  <li><strong>No state to manage</strong> — No cookie, no localStorage, no flash of wrong theme on page load</li>
</ul>

<p>The Contrast theme originally had a <code class="language-plaintext highlighter-rouge">dark_theme: true/false</code> config option for a site-wide override. I removed that in favor of the automatic approach.</p>

<h2 id="print-stylesheet">Print Stylesheet</h2>

<p>Technical blog posts get printed. People print Proxmox walkthroughs to have next to the server, or save Ceph commands as PDF references. The default Jekyll output looks terrible when printed — navigation bars, comment widgets, and copy buttons all show up on paper.</p>

<h3 id="implementation-1">Implementation</h3>

<p>The print stylesheet (<code class="language-plaintext highlighter-rouge">_sass/print.sass</code>, added September 11, 2025) is 134 lines that handle three things:</p>

<p><strong>1. Hide non-content elements:</strong></p>

<div class="language-sass highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="n">print</span>
  <span class="nt">nav</span><span class="o">,</span> <span class="nt">aside</span><span class="o">,</span> <span class="nt">footer</span><span class="o">,</span> <span class="nc">.giscus</span><span class="o">,</span> <span class="nc">.page__comments</span><span class="o">,</span>
  <span class="nc">.btn-copy</span><span class="o">,</span> <span class="nc">.gcse-search</span><span class="o">,</span> <span class="nt">header</span> <span class="nt">nav</span><span class="o">,</span>
  <span class="nc">.taxonomies-list</span><span class="o">,</span> <span class="nc">.more</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span> <span class="o">!</span><span class="n">important</span>
</code></pre></div></div>

<p><strong>2. Reset to print-friendly typography:</strong></p>

<div class="language-sass highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">body</span>
    <span class="nl">background</span><span class="p">:</span> <span class="n">white</span> <span class="o">!</span><span class="n">important</span>
    <span class="nl">color</span><span class="p">:</span> <span class="n">black</span> <span class="o">!</span><span class="n">important</span>
    <span class="nl">font-family</span><span class="p">:</span> <span class="s2">"Times New Roman"</span><span class="o">,</span> <span class="nb">serif</span> <span class="o">!</span><span class="n">important</span>
    <span class="nl">font-size</span><span class="p">:</span> <span class="m">12pt</span> <span class="o">!</span><span class="n">important</span>
    <span class="nl">line-height</span><span class="p">:</span> <span class="m">1</span><span class="mi">.4</span> <span class="o">!</span><span class="n">important</span>
</code></pre></div></div>

<p><strong>3. Make links useful on paper:</strong></p>

<p>Printed pages can’t be clicked, so links need to show their URLs. The stylesheet appends the URL after each link text so the reader can type it in.</p>

<h3 id="the-sass-circular-dependency">The SASS Circular Dependency</h3>

<p>Adding the print stylesheet triggered a SASS circular dependency nightmare — the same day I added it, I had to restructure the entire SASS architecture to eliminate circular imports. That story is told in <a href="/sass-circular-dependency-nightmare/">SASS Circular Dependency Nightmare</a>.</p>

<h2 id="custom-error-pages-with-haiku">Custom Error Pages with Haiku</h2>

<p>GitHub Pages serves a generic error page by default. Custom error pages are a small touch that shows the site is maintained and gives lost visitors a way back.</p>

<h3 id="404-page-not-found">404: Page Not Found</h3>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>---
permalink: /404.html
title: "404: Page not found"
layout: default
sitemap: false
---

<span class="nt">&lt;article&gt;</span>
  <span class="nt">&lt;header&gt;&lt;h1&gt;&lt;a</span> <span class="na">href=</span><span class="s">"https://www.rfc-editor.org/rfc/rfc9110.html#name-404-not-found"</span>
    <span class="na">target=</span><span class="s">"_blank"</span><span class="nt">&gt;</span>404 Not Found<span class="nt">&lt;/a&gt;&lt;/h1&gt;&lt;/header&gt;</span>
  <span class="nt">&lt;p&gt;</span>
    <span class="nt">&lt;i&gt;</span>You step in the stream,<span class="nt">&lt;/i&gt;&lt;br&gt;</span>
    <span class="nt">&lt;i&gt;</span>but the water has moved on.<span class="nt">&lt;/i&gt;&lt;br&gt;</span>
    <span class="nt">&lt;i&gt;</span>This page is not here.<span class="nt">&lt;/i&gt;&lt;br&gt;</span>
  <span class="nt">&lt;/p&gt;</span>
<span class="nt">&lt;/article&gt;</span>
</code></pre></div></div>

<h3 id="500-internal-server-error">500: Internal Server Error</h3>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;p&gt;</span>
  <span class="nt">&lt;i&gt;</span>Server, dark within,<span class="nt">&lt;/i&gt;&lt;br&gt;</span>
  <span class="nt">&lt;i&gt;</span>Unexpected fault appears,<span class="nt">&lt;/i&gt;&lt;br&gt;</span>
  <span class="nt">&lt;i&gt;</span>Try again, please wait.<span class="nt">&lt;/i&gt;&lt;br&gt;</span>
<span class="nt">&lt;/p&gt;</span>
</code></pre></div></div>

<h3 id="design-decisions">Design Decisions</h3>

<ul>
  <li><strong>Haiku format</strong> — A 5-7-5 syllable poem is memorable and human. It signals that a person built this site, not a template generator</li>
  <li><strong>RFC links</strong> — The error code in the heading links to the actual HTTP specification. A small nod to the technically curious reader</li>
  <li><strong><code class="language-plaintext highlighter-rouge">sitemap: false</code></strong> — Error pages should never appear in the sitemap</li>
  <li><strong>Uses the default layout</strong> — The error page has the same navigation as the rest of the site, so the reader can find their way back</li>
</ul>

<p>The 404 page went through four commits between 2020 and 2024, mostly simplifying it from the original theme’s version down to the haiku format.</p>

<h2 id="author-bio">Author Bio</h2>

<p>Every post ends with an author bio section — a short paragraph with credentials and links to professional profiles. This was added on April 2, 2026 as part of <a href="/improving-eeat-jekyll-adsense/">E-E-A-T improvements for AdSense approval</a>.</p>

<h3 id="implementation-2">Implementation</h3>

<p>The bio lives in <code class="language-plaintext highlighter-rouge">_includes/author-bio.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"author-bio"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;strong&gt;</span>About the Author:<span class="nt">&lt;/strong&gt;</span>
  <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"/about/"</span><span class="nt">&gt;</span>Michael McGarrah<span class="nt">&lt;/a&gt;</span> is a Cloud Architect with 25+ years
  in enterprise infrastructure, machine learning, and system administration.
  He holds an M.S. in Computer Science (AI/ML) from Georgia Tech and a B.S.
  in Computer Science from NC State University, and is currently pursuing an
  Executive MBA at UNC Wilmington.
  <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"author-links"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"https://www.linkedin.com/in/michaelmcgarrah/"</span> <span class="na">rel=</span><span class="s">"me"</span><span class="nt">&gt;</span>LinkedIn<span class="nt">&lt;/a&gt;</span> ·
    <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"https://github.com/mcgarrah"</span> <span class="na">rel=</span><span class="s">"me"</span><span class="nt">&gt;</span>GitHub<span class="nt">&lt;/a&gt;</span> ·
    <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"https://orcid.org/0000-0001-8935-1293"</span> <span class="na">rel=</span><span class="s">"me"</span><span class="nt">&gt;</span>ORCID<span class="nt">&lt;/a&gt;</span> ·
    <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"https://scholar.google.com/citations?user=Lt7T2SwAAAAJ"</span> <span class="na">rel=</span><span class="s">"me"</span><span class="nt">&gt;</span>Google Scholar<span class="nt">&lt;/a&gt;</span> ·
    <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"/resume/"</span><span class="nt">&gt;</span>Resume<span class="nt">&lt;/a&gt;</span>
  <span class="nt">&lt;/span&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h3 id="why-it-matters">Why It Matters</h3>

<ul>
  <li><strong>E-E-A-T signals</strong> — Google’s Experience, Expertise, Authoritativeness, and Trustworthiness framework rewards content with clear author attribution. The bio provides credentials, the <code class="language-plaintext highlighter-rouge">rel="me"</code> links establish identity across platforms</li>
  <li><strong>Reader trust</strong> — A reader deciding whether to follow a Ceph walkthrough wants to know the author has relevant experience</li>
  <li><strong>Professional visibility</strong> — Every post becomes a touchpoint to LinkedIn, GitHub, ORCID, and the resume</li>
</ul>

<p>The bio includes dark mode support via SASS theme variables and is automatically included in every post via the <code class="language-plaintext highlighter-rouge">post.html</code> layout.</p>

<h2 id="archive-page">Archive Page</h2>

<p>The archive page at <code class="language-plaintext highlighter-rouge">/archive/</code> provides a chronological listing of every post. It’s the simplest navigation feature on the site — just titles and dates, sorted newest first.</p>

<h3 id="history">History</h3>

<p>The archive page has the longest history of any feature on this blog:</p>

<ul>
  <li><strong>January 2020</strong> — Original archive include added to the Contrast theme by Niklas Buschmann</li>
  <li><strong>August 2023</strong> — I created <code class="language-plaintext highlighter-rouge">archive.html</code> as a standalone page</li>
  <li><strong>December 2025</strong> — Moved from a temporary <code class="language-plaintext highlighter-rouge">_jmm</code> debug directory to <code class="language-plaintext highlighter-rouge">_layouts/archive.html</code> (a cleanup of my own mess)</li>
</ul>

<p>It works alongside the tag pages (<code class="language-plaintext highlighter-rouge">/tags/</code>), category pages (<code class="language-plaintext highlighter-rouge">/categories/</code>), and the paginated homepage to give readers multiple ways to find content. The archive is the “just show me everything” option.</p>

<h2 id="favicon">Favicon</h2>

<p>The blog has a basic <code class="language-plaintext highlighter-rouge">favicon.ico</code> file at the site root. It’s the minimum viable favicon — a single <code class="language-plaintext highlighter-rouge">.ico</code> file that browsers pick up automatically without any <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tags in the HTML.</p>

<p>This is one of the items still on the TODO list for improvement — a proper favicon set would include multiple sizes (16x16, 32x32, 180x180 for Apple Touch), PNG format, and a <code class="language-plaintext highlighter-rouge">site.webmanifest</code> file. But the basic <code class="language-plaintext highlighter-rouge">.ico</code> works and prevents the 404 that browsers generate when they request <code class="language-plaintext highlighter-rouge">/favicon.ico</code> and find nothing.</p>

<p><strong>Update:</strong> This has since been addressed — you will see an upcoming favicon post shortly.</p>

<h2 id="the-compound-effect">The Compound Effect</h2>

<p>None of these features would justify a blog post on their own. But together they create a compound effect:</p>

<ul>
  <li>A reader arrives from a Substack link → the site matches their dark mode preference</li>
  <li>They read a Ceph walkthrough → the author bio establishes credibility</li>
  <li>They print the article for reference → the print stylesheet gives them clean output</li>
  <li>They mistype a URL → the haiku 404 page makes them smile instead of bounce</li>
  <li>They want to browse more → the archive page shows everything chronologically</li>
</ul>

<p>Each feature took less than a day. The total investment was maybe a week across two years. The return is a site that feels maintained and intentional — which matters more than any individual feature.</p>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/improving-eeat-jekyll-adsense/">Improving E-E-A-T for Jekyll and AdSense</a> — The author bio and credibility signals</li>
  <li><a href="/sass-circular-dependency-nightmare/">SASS Circular Dependency Nightmare</a> — Triggered by adding the print stylesheet</li>
  <li><a href="/jekyll-website-optimization-part-1/">Jekyll Website Optimization Part 1</a> — Dark mode and theme improvements</li>
  <li><a href="/jekyll-website-optimization-part-2/">Jekyll Website Optimization Part 2</a> — Error pages and UX polish</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made</a> — Full feature inventory</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="dark-mode" /><category term="print-stylesheet" /><category term="404-page" /><category term="favicon" /><category term="archive" /><category term="author-bio" /><category term="ux" /><category term="github-pages" /><category term="e-e-a-t" /><summary type="html"><![CDATA[Six small polish features for a Jekyll blog: automatic dark/light theme via prefers-color-scheme, print stylesheet for clean article printing, custom 404/500 error pages with haiku, author bio with E-E-A-T signals, archive page, and favicon. Each feature explained with code, git history, and design rationale.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-small-things-polish-features.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-small-things-polish-features.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">VS Code Markdown Preview: Closing the macOS vs Windows Context Menu Gap</title><link href="https://mcgarrah.org/vscode-wsl2-markdown-preview/" rel="alternate" type="text/html" title="VS Code Markdown Preview: Closing the macOS vs Windows Context Menu Gap" /><published>2026-04-28T00:00:00+00:00</published><updated>2026-04-28T00:00:00+00:00</updated><id>https://mcgarrah.org/vscode-wsl2-markdown-preview</id><content type="html" xml:base="https://mcgarrah.org/vscode-wsl2-markdown-preview/"><![CDATA[<!-- excerpt-end -->

<p>If you use VS Code across macOS, Windows, and WSL2, you have probably noticed that the Markdown preview experience is not consistent across platforms.</p>

<p>All three platforms have the “Open Preview to the Side” button in the editor title bar — the split-pane icon in the top-right corner. That button is always there:</p>

<p><img src="/assets/images/markdown-preview-macos-titlebar.png" alt="macOS VS Code title bar showing the Open Preview to the Side button" /></p>

<p>Here is a closer look at that button area:</p>

<p><img src="/assets/images/markdown-preview-macos-sidebyside.png" alt="Close-up of the VS Code title bar preview button" /></p>

<p>The difference is in the right-click context menu. On macOS, right-clicking a Markdown file in the Explorer gives you “Open Preview” and “Open Preview to the Side” right at the top of the menu — one click and you are in the preview:</p>

<p><img src="/assets/images/markdown-preview-macos-context-menu.png" alt="macOS VS Code context menu showing Open Preview at the top" /></p>

<p>On Windows and WSL2/Linux, those options are missing from the context menu. Instead you only get “Open With…” which opens a secondary list of editors to choose from:</p>

<p><img src="/assets/images/markdown-preview-wsl2-context-menu.png" alt="Windows/WSL2 VS Code context menu showing only Open With instead of Open Preview" /></p>

<p>Clicking “Open With…” reveals a submenu where you have to pick the editor — an extra click and a hunt every time you want to preview a Markdown file:</p>

<p><img src="/assets/images/markdown-preview-wsl2-open-with-submenu.png" alt="Windows/WSL2 Open With submenu showing the list of editors to choose from" /></p>

<p>The keyboard shortcut <code class="language-plaintext highlighter-rouge">Ctrl+Shift+V</code> still works on all platforms, but if you are a mouse-driven user or just want the same quick context menu experience everywhere, this gap is a daily annoyance.</p>

<p>This article covers how to close that gap — both with built-in settings and with Markdown Preview Enhanced as a longer-term fix.</p>

<h2 id="why-is-the-context-menu-different">Why Is the Context Menu Different?</h2>

<p>VS Code treats WSL as a “Remote” environment. Extensions, settings, and default file associations do not always sync between the local host (macOS or Windows) and the remote WSL instance. The built-in Markdown Language Features extension can end up disabled or overridden in the remote context, which removes the preview options from the context menu.</p>

<p>The same issue appears on native Windows or Linux installs when another extension claims the <code class="language-plaintext highlighter-rouge">.md</code> file type or when the built-in Markdown extension is not the default handler.</p>

<h2 id="fix-1-re-enable-the-built-in-markdown-extension">Fix 1: Re-Enable the Built-In Markdown Extension</h2>

<p>If the context menu is missing the preview options, the built-in Markdown Language Features extension is likely disabled in your current environment.</p>

<ol>
  <li>Open the Extensions view (<code class="language-plaintext highlighter-rouge">Ctrl+Shift+X</code>).</li>
  <li>Search for <code class="language-plaintext highlighter-rouge">@builtin markdown</code>.</li>
  <li>Verify that “Markdown Language Features” is enabled. If it shows as disabled for your WSL connection, click <strong>Enable (Workspace)</strong> or <strong>Enable</strong>.</li>
</ol>

<h2 id="fix-2-set-the-default-editor-for-markdown">Fix 2: Set the Default Editor for Markdown</h2>

<p>Even with the extension enabled, VS Code may still show “Open With…” instead of “Open Preview” in the context menu. Setting the default editor for <code class="language-plaintext highlighter-rouge">.md</code> files tells VS Code to stop asking:</p>

<ol>
  <li>Right-click any <code class="language-plaintext highlighter-rouge">.md</code> file in the Explorer.</li>
  <li>Select <strong>Open With…</strong></li>
  <li>At the bottom of the list, select <strong>Configure default editor for ‘*.md’</strong>.</li>
  <li>Choose <strong>Markdown Preview</strong>.</li>
</ol>

<h2 id="markdown-shortcut-cheat-sheet">Markdown Shortcut Cheat Sheet</h2>

<p>These three shortcuts work on all platforms regardless of icon visibility:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Shortcut</th>
      <th style="text-align: left">Action</th>
      <th style="text-align: left">Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">Ctrl+Shift+V</code></td>
      <td style="text-align: left">Open Preview</td>
      <td style="text-align: left">Replaces the current tab with a full preview</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">Ctrl+K</code> then <code class="language-plaintext highlighter-rouge">V</code></td>
      <td style="text-align: left">Open Preview to the Side</td>
      <td style="text-align: left">Split pane — edit and preview simultaneously</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">Ctrl+Shift+P</code> → “Markdown”</td>
      <td style="text-align: left">Command Palette</td>
      <td style="text-align: left">Lists all Markdown commands including export</td>
    </tr>
  </tbody>
</table>

<p>On macOS, substitute <code class="language-plaintext highlighter-rouge">Cmd</code> for <code class="language-plaintext highlighter-rouge">Ctrl</code>.</p>

<h2 id="the-better-fix-markdown-preview-enhanced">The Better Fix: Markdown Preview Enhanced</h2>

<p>The built-in preview is functional but minimal. <a href="https://marketplace.visualstudio.com/items?itemName=shd101wyy.markdown-preview-enhanced">Markdown Preview Enhanced</a> (MPE) is the power-user alternative — and it fixes the context menu gap as a side effect, because it registers its own dedicated commands and context menu entries that work consistently across all platforms.</p>

<p>After installing MPE, you get a dedicated preview icon in the editor title bar that works reliably across all platforms:</p>

<p><img src="/assets/images/markdown-preview-wsl2-mpe-icon.png" alt="Markdown Preview Enhanced icon in the VS Code editor title bar on Windows/WSL2" /></p>

<h3 id="why-mpe-is-an-upgrade">Why MPE Is an Upgrade</h3>

<ul>
  <li><strong>Math and diagrams</strong>: Native KaTeX/MathJax support for formulas, plus Mermaid, PlantUML, and Flowchart.js for diagrams — no extra extensions needed.</li>
  <li><strong>Image path handling</strong>: Resolves local image paths in WSL more gracefully than the built-in preview, which sometimes struggles with <code class="language-plaintext highlighter-rouge">\\wsl$</code> file resolution.</li>
  <li><strong>Export options</strong>: Direct export to PDF, PNG, HTML, ePub, and Marp/Pandoc presentations.</li>
  <li><strong>Code chunk execution</strong>: Can run code blocks in your Markdown (if the compiler is installed in your environment) and render the output inline in the preview.</li>
</ul>

<p>Here is MPE rendering a side-by-side preview with rich content that the built-in preview cannot handle:</p>

<p><img src="/assets/images/markdown-preview-wsl2-mpe-sidebyside-preview.png" alt="Markdown Preview Enhanced side-by-side preview on Windows/WSL2" /></p>

<h3 id="auto-open-preview">Auto-Open Preview</h3>

<p>To make MPE open the preview automatically whenever you open a Markdown file:</p>

<ol>
  <li>Open Settings (<code class="language-plaintext highlighter-rouge">Ctrl+,</code>).</li>
  <li>Search for <code class="language-plaintext highlighter-rouge">markdown-preview-enhanced.previewConfig.automaticallyShowPreviewOfMarkdownBeingEdited</code>.</li>
  <li>Toggle it on.</li>
</ol>

<h3 id="wsl2-performance-note">WSL2 Performance Note</h3>

<p>MPE uses a heavier rendering engine than the built-in preview. On very large files (10k+ lines) inside a WSL container, you may notice scroll lag. If that happens, check the Puppeteer settings in the MPE extension options to tune rendering performance.</p>

<h2 id="summary">Summary</h2>

<p>The Markdown preview context menu gap between macOS and Windows/WSL2 is a VS Code quirk caused by remote environment extension sync and default editor associations. You can fix it by re-enabling the built-in Markdown Language Features extension and setting the default editor for <code class="language-plaintext highlighter-rouge">.md</code> files — or skip the workaround entirely by installing Markdown Preview Enhanced, which provides consistent context menu entries and a feature-rich preview across macOS, Windows, and WSL2.</p>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="development-tools" /><category term="wsl" /><category term="vscode" /><category term="wsl2" /><category term="markdown" /><category term="linux" /><category term="macos" /><category term="windows" /><category term="troubleshooting" /><summary type="html"><![CDATA[The macOS VS Code context menu includes 'Open Preview' for Markdown files, but Windows and WSL2 only show 'Open With...' — requiring an extra step. How to fix it, plus why Markdown Preview Enhanced is the better long-term answer.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/vscode-wsl2-markdown-preview.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/vscode-wsl2-markdown-preview.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>