<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-05-08T13:41:29+08:00</updated><id>/feed.xml</id><title type="html">Ryan Chang</title><subtitle>Fueled by coffee, driven by craft</subtitle><author><name>Ryan Chang</name><email>ryancyq@gmail.com</email></author><entry><title type="html">Automating Changelog Generation with GitHub Actions and Signed Commit</title><link href="/posts/2024/10/16/changelog-generation-with-github-actions" rel="alternate" type="text/html" title="Automating Changelog Generation with GitHub Actions and Signed Commit" /><published>2024-10-16T00:00:00+08:00</published><updated>2024-10-16T00:00:00+08:00</updated><id>/posts/2024/10/16/changelog-generation-with-github-actions</id><content type="html" xml:base="/posts/2024/10/16/changelog-generation-with-github-actions"><![CDATA[<p>Managing changelogs manually can be tedious and error-prone, especially for a project with many contributors. In this post, I will share my experience with setting up automated changelog generation using GitHub Actions, commit it to github with signed commits for enhanced security.</p>

<h3 id="why-automated-changelog-generation">Why Automated Changelog Generation?</h3>
<ul>
  <li>Ensures consistency in changelog formatting</li>
  <li>Reduces manual effort and human error</li>
  <li>Maintains up-to-date documentation automatically</li>
</ul>

<h3 id="prerequisites">Prerequisites</h3>

<p>These are the tools being used:</p>
<ul>
  <li><a href="https://git-cliff.org/">Git-Cliff</a> : A highly customizable changelog generator built in Rust.</li>
  <li><a href="https://github.com/features/actions">GitHub Actions</a> : A CI/CD platform integrated with GitHub.</li>
  <li><a href="https://github.com/marketplace/actions/github-signed-commit">github-signed-commit</a> : A GitHub Actions that commit your files via <a href="https://docs.github.com/en/graphql">GitHub Graphql API</a> to guarantee verified commit on GitHub.</li>
</ul>

<h3 id="initialize-cliff-configuration">Initialize Cliff configuration</h3>

<p>First, let’s install <code class="language-plaintext highlighter-rouge">git-cliff</code> CLI to do so. I will use the <code class="language-plaintext highlighter-rouge">npm</code> package for this example. However, you can visit <a href="https://git-cliff.org/docs/installation/">Git-Cliff Installation</a> for other package mangers.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> git-cliff@latest
</code></pre></div></div>

<p>Personally, I like the format <code class="language-plaintext highlighter-rouge">cocogitto</code> for its styling base on <a href="https://www.conventionalcommits.org/">conventional commits</a>.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git cliff <span class="nt">--config</span> cocogitto
</code></pre></div></div>

<p>This will generate a <code class="language-plaintext highlighter-rouge">cliff.toml</code> file in your repository root that contains info on how <a href="https://git-cliff.org/">Git-Cliff</a> processes your git commits:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># git-cliff ~ configuration file</span>
<span class="c"># https://git-cliff.org/docs/configuration</span>

<span class="nn">[changelog]</span>
<span class="c"># template for the changelog header</span>
<span class="py">header</span> <span class="p">=</span> <span class="s">"""
# Changelog</span><span class="se">\n</span><span class="s">
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.</span><span class="se">\n</span><span class="s">
"""</span>
<span class="c"># template for the changelog body</span>
<span class="c"># https://keats.github.io/tera/docs/#introduction</span>
<span class="py">body</span> <span class="p">=</span> <span class="s">"""
&lt;!-- generated by git-cliff --&gt;
"""</span>
<span class="c"># template for the changelog footer</span>
<span class="py">footer</span> <span class="p">=</span> <span class="s">"""
&lt;!-- generated by git-cliff --&gt;
"""</span>
<span class="c"># remove the leading and trailing whitespace from the templates</span>
<span class="py">trim</span> <span class="p">=</span> <span class="kc">true</span>
<span class="c"># postprocessors</span>
<span class="py">postprocessors</span> <span class="p">=</span> <span class="p">[</span>
    <span class="err">{</span> <span class="py">pattern</span> <span class="p">=</span> <span class="s">'&lt;REPO&gt;'</span><span class="p">,</span> <span class="py">replace</span> <span class="p">=</span> <span class="s">"https://github.com/my-organization/my-repo"</span> <span class="err">}</span><span class="p">,</span> <span class="c"># replace repository URL</span>
<span class="p">]</span>

<span class="nn">[git]</span>
<span class="c"># parse the commits based on https://www.conventionalcommits.org</span>
<span class="py">conventional_commits</span> <span class="p">=</span> <span class="kc">true</span>
<span class="c"># filter out the commits that are not conventional</span>
<span class="py">filter_unconventional</span> <span class="p">=</span> <span class="kc">true</span>
<span class="c"># process each line of a commit as an individual commit</span>
<span class="py">split_commits</span> <span class="p">=</span> <span class="kc">false</span>
<span class="c"># regex for preprocessing the commit messages</span>
<span class="py">commit_preprocessors</span> <span class="p">=</span> <span class="p">[</span>
    <span class="err">{</span> <span class="py">pattern</span> <span class="p">=</span> <span class="s">'\((\w+\s)?#([0-9]+)\)'</span><span class="p">,</span> <span class="py">replace</span> <span class="p">=</span> <span class="s">"([#${2}](&lt;REPO&gt;/issues/${2}))"</span><span class="err">}</span><span class="p">,</span> <span class="c"># replace issue numbers</span>
<span class="p">]</span>
<span class="c"># regex for parsing and grouping commits</span>
<span class="py">commit_parsers</span> <span class="p">=</span> <span class="p">[</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^feat"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Features"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^fix"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Bug Fixes"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^doc"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Documentation"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^perf"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Performance"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^refactor"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Refactoring"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^style"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Style"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^revert"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Revert"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^test"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Tests"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^chore</span><span class="se">\\</span><span class="s">(version</span><span class="se">\\</span><span class="s">):"</span><span class="p">,</span> <span class="py">skip</span> <span class="p">=</span> <span class="kc">true</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">message</span> <span class="p">=</span> <span class="s">"^chore"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Miscellaneous Chores"</span> <span class="err">}</span><span class="p">,</span>
    <span class="err">{</span> <span class="py">body</span> <span class="p">=</span> <span class="s">".*security"</span><span class="p">,</span> <span class="py">group</span> <span class="p">=</span> <span class="s">"Security"</span> <span class="err">}</span><span class="p">,</span>
<span class="p">]</span>
<span class="c"># filter out the commits that are not matched by commit parsers</span>
<span class="py">filter_commits</span> <span class="p">=</span> <span class="kc">false</span>
<span class="c"># sort the tags topologically</span>
<span class="py">topo_order</span> <span class="p">=</span> <span class="kc">false</span>
<span class="c"># sort the commits inside sections by oldest/newest order</span>
<span class="py">sort_commits</span> <span class="p">=</span> <span class="s">"oldest"</span>
</code></pre></div></div>

<h2 id="running-git-cliff">Running Git-Cliff</h2>

<p>Next, let’s do a dry run for the changelog generation. For example, generating changelog from version <code class="language-plaintext highlighter-rouge">0.9.2</code> to the latest commit.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git cliff v0.9.2..HEAD
</code></pre></div></div>

<p>The following output will be shown:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Changelog

All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.

---
## [Unreleased](https://github.com/my-organization/my-repo/tree/HEAD)

### Bug Fixes

- handle invalid MIME format during image processing - ([f78c0d2](https://github.com/my-organization/my-repo/commit/f78c0d2de653ea46e13ea4c216b9ae951b339d50)) - Ryan Chang

### Lib

- bump webrick &gt;= 1.8.2 ([#95](https://github.com/my-organization/my-repo/issues/95)) - ([9cc0e93](https://github.com/my-organization/my-repo/commit/9cc0e934c5cb64cee1ab3eedad2cd32e11c117de)) - Ryan Chang

---
## [1.0.0](https://github.com/my-organization/my-repo/compare/v0.9.2..v1.0.0) - 2024-08-18

### Refactoring

- use code generator only when activesupport &gt;= 7.2 - ([95f9b41](https://github.com/my-organization/my-repo/commit/95f9b41de3107b685a1cd771061b209b79d21071)) - Ryan Chang

&lt;!-- generated by git-cliff --&gt;
</code></pre></div></div>

<p>To persist the changelog content, we need to add <code class="language-plaintext highlighter-rouge">--ouput</code> when running <a href="https://git-cliff.org/">Git-Cliff</a> command.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git cliff v1.0.0..HEAD <span class="nt">--output</span> CHANGELOG.md
</code></pre></div></div>

<h2 id="setting-up-the-workflow">Setting up the Workflow</h2>

<p>Next, let’s create a GitHub Actions workflow file that handles both changelog generation and signed commits:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># changelog.yml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">Generate Changelog</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">workflow_run</span><span class="pi">:</span>
    <span class="na">workflows</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">CI</span><span class="pi">]</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">completed</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">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">0</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"># Runs daily at midnight UTC</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">changelog</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">write</span> <span class="c1"># Required to update CHANGELOG.md</span>

    <span class="c1"># Generate changelog for successful build only</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}</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@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">fetch-depth</span><span class="pi">:</span> <span class="m">0</span> <span class="c1"># Required to check out full git history</span>

      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">orhun/git-cliff-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">config</span><span class="pi">:</span> <span class="s">cliff.toml</span>
          <span class="na">args</span><span class="pi">:</span> <span class="s">--output CHANGELOG.md</span>
          
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">ryancyq/github-signed-commit@v1</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">GH_TOKEN</span><span class="pi">:</span> <span class="s">${{ github.token }}</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">files</span><span class="pi">:</span> <span class="s">CHANGELOG.md</span>
          <span class="na">commit-message</span><span class="pi">:</span> <span class="s2">"</span><span class="s">docs:</span><span class="nv"> </span><span class="s">update</span><span class="nv"> </span><span class="s">CHANGELOG.md"</span>
</code></pre></div></div>

<h3 id="how-it-works">How It Works</h3>

<ol>
  <li>The workflow triggers when the <code class="language-plaintext highlighter-rouge">CI</code> workflow has completed on the main branch or runs daily at midnight UTC</li>
  <li><a href="https://git-cliff.org/">Git-Cliff</a> analyzes your git history and generates a formatted changelog based on <a href="https://www.conventionalcommits.org/">conventional commits</a></li>
  <li><a href="https://github.com/marketplace/actions/github-signed-commit">github-signed-commit</a> GitHub Action will commit <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> to the repository using <a href="https://docs.github.com/en/graphql">GitHub Graphql API</a> to guarantee verified commit on GitHub</li>
</ol>

<h3 id="best-practices">Best Practices</h3>

<ol>
  <li>Use <a href="https://www.conventionalcommits.org/">conventional commits</a> in your workflow:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">feat:</code> for new features</li>
      <li><code class="language-plaintext highlighter-rouge">fix:</code> for bug fixes</li>
      <li><code class="language-plaintext highlighter-rouge">docs:</code> for documentation changes</li>
      <li><code class="language-plaintext highlighter-rouge">chore:</code> for maintenance tasks</li>
    </ul>
  </li>
  <li>Review generated changelogs regularly:
    <ul>
      <li>Ensure proper categorization</li>
      <li>Verify formatting</li>
      <li>Check for sensitive information</li>
    </ul>
  </li>
</ol>

<h3 id="conclusion">Conclusion</h3>

<p>Automating changelog generation with GitHub Actions streamlines your documentation process, combining <a href="https://git-cliff.org/">Git-Cliff</a> powerful parsing capabilities and signed commits ensures your changelog remains accurate, up-to-date, and trustworthy.</p>

<p>Remember to adjust the configuration to match your project’s needs and commit message patterns. This automation will save you time and ensure consistency across your project’s documentation.</p>]]></content><author><name>Ryan Chang</name><email>ryancyq@gmail.com</email></author><category term="git" /><category term="git-cliff" /><category term="changelog" /><category term="conventional-commits" /><category term="github-actions" /><category term="signed-commit" /><summary type="html"><![CDATA[Managing changelogs manually can be tedious and error-prone, especially for a project with many contributors. In this post, I will share my experience with setting up automated changelog generation using GitHub Actions, commit it to github with signed commits for enhanced security.]]></summary></entry><entry><title type="html">A Light/Dark Theme Switcher for Static Sites with Tailwind CSS</title><link href="/posts/2024/10/02/light-dark-theme-switch-static-site" rel="alternate" type="text/html" title="A Light/Dark Theme Switcher for Static Sites with Tailwind CSS" /><published>2024-10-02T00:00:00+08:00</published><updated>2024-10-02T00:00:00+08:00</updated><id>/posts/2024/10/02/light-dark-theme-switch-static-site</id><content type="html" xml:base="/posts/2024/10/02/light-dark-theme-switch-static-site"><![CDATA[<p><a href="https://tailwindcss.com">Tailwind CSS</a> provides light/dark styling out of the box through its intuitive dark mode modifiers. By default, 
<a href="https://tailwindcss.com/docs/dark-mode">Tailwind CSS Dark Mode</a> uses the <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme">prefers-color-scheme</a> CSS media feature from the browser, which serves as a good starting point for supporting light/dark themes on our static site.</p>

<p>However, to further improve our readers’ experience, users should be able to indicate their preference for light/dark mode across their favorite sites. As a user, I always welcome utilities to customize my reading experience for different site domains.</p>

<p>In this post, I’m going to share my experience of adding a light/dark theme switcher to a static site with minimal JavaScript involved.</p>

<h3 id="tailwind-css-manual-dark-mode-toggle">Tailwind CSS Manual Dark Mode Toggle</h3>

<p>First, we need to move away from the default dark mode configuration that uses the <code class="language-plaintext highlighter-rouge">media</code> strategy. 
As of this writing, <a href="https://github.com/tailwindlabs/tailwindcss/releases/tag/v3.4.1">Tailwind CSS 3.4.1</a> offers two other strategies: <code class="language-plaintext highlighter-rouge">class</code> and <code class="language-plaintext highlighter-rouge">selector</code>. Both will work in our case, but the official documentation recommends the <code class="language-plaintext highlighter-rouge">selector</code> strategy (<a href="https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually">Tailwind CSS manual dark mode</a>).</p>

<h3 id="simple-lightdark-theme-switcher">Simple Light/Dark Theme Switcher</h3>

<p>Let’s look at a simple light/dark theme switcher styled with <a href="https://tailwindcss.com">Tailwind CSS</a>. Similar to the <a href="/posts/2024/08/10/collapsible-navigation-menu-without-js">JavaScript-free Responsive Navigation Menu guide</a>, we’ll be using HTML <code class="language-plaintext highlighter-rouge">&lt;label&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> elements to keep track of the light/dark theme state.</p>

<p><strong>Demo</strong></p>
<div class="not-prose group/demo">
  <nav class="flex justify-between items-center bg-gray-50 group-[.dark]/demo:bg-gray-800 p-4 border border-2 rounded-lg border-gray-200 dark:border-gray-700">
    <a href="#" class="text-gray-800 group-[.dark]/demo:text-gray-200">Playground</a>
    <div class="flex gap-x-2">
      <div class="inline-flex items-center font-semibold text-cyan-600 group-[.dark]/demo:text-cyan-400">
        Click Me
        <svg class="rtl:rotate-180 size-4 ms-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">
          <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9" />
        </svg>
      </div>
      <input id="theme-trigger" type="checkbox" class="hidden" />
      <label for="theme-trigger" class="rounded p-2 hover:bg-gray-100 group-[.dark]/demo:hover:bg-gray-700">
        <span class="sr-only">Switch to light / dark theme</span>
        <!-- Moon Icon -->
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="size-6 group-[.dark]/demo:hidden">
          <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" class="fill-gray-500" />
        </svg>
        <!-- Sun Icon -->
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="size-6 hidden group-[.dark]/demo:block">
          <path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd" class="fill-gray-400" />
        </svg>
      </label>
    </div>
  </nav>
  <script>
    const trigger = document.getElementById('theme-trigger');
    const demo = trigger.closest('.not-prose')
    function triggerTheme(event) {
      const forceDark = event.target instanceof HTMLInputElement ? event.target.checked : !!event
      demo.classList.toggle('dark', forceDark)
    }
    const demoDark = document.documentElement.classList.contains('dark')
    trigger.checked = demoDark
    trigger.addEventListener('change', triggerTheme)

    triggerTheme(demoDark)
  </script>
</div>

<p><strong>HTML</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;nav</span> <span class="na">class=</span><span class="s">"flex justify-between items-center bg-gray-100 dark:bg-gray-800"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"text-gray-800 dark:text-gray-200"</span><span class="nt">&gt;</span>Playground<span class="nt">&lt;/a&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">id=</span><span class="s">"theme-trigger"</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">class=</span><span class="s">"hidden"</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"theme-trigger"</span> <span class="na">class=</span><span class="s">"rounded p-2 hover:bg-gray-200 dark:hover:bg-gray-700"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"sr-only"</span><span class="nt">&gt;</span>Switch to light / dark theme<span class="nt">&lt;/span&gt;</span>
    <span class="c">&lt;!-- Moon Icon --&gt;</span>
    <span class="nt">&lt;svg</span> <span class="na">xmlns=</span><span class="s">"http://www.w3.org/2000/svg"</span> <span class="na">viewBox=</span><span class="s">"0 0 20 20"</span> <span class="na">class=</span><span class="s">"size-6 dark:hidden"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;path</span> <span class="na">d=</span><span class="s">"M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"</span> <span class="na">class=</span><span class="s">"fill-gray-500"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;/svg&gt;</span>
    <span class="c">&lt;!-- Sun Icon --&gt;</span>
    <span class="nt">&lt;svg</span> <span class="na">xmlns=</span><span class="s">"http://www.w3.org/2000/svg"</span> <span class="na">viewBox=</span><span class="s">"0 0 20 20"</span> <span class="na">class=</span><span class="s">"size-6 hidden dark:block"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;path</span> <span class="na">d=</span><span class="s">"M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"</span>
        <span class="na">fill-rule=</span><span class="s">"evenodd"</span>
        <span class="na">clip-rule=</span><span class="s">"evenodd"</span>
        <span class="na">class=</span><span class="s">"fill-gray-400"</span>
      <span class="nt">/&gt;</span>
    <span class="nt">&lt;/svg&gt;</span>
  <span class="nt">&lt;/label&gt;</span>
<span class="nt">&lt;/nav&gt;</span>
</code></pre></div></div>

<h3 id="store-theme-switcher-state">Store Theme Switcher State</h3>

<p>Next, let’s use the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage">localStorage API</a> to keep track of the state when a user toggles between light and dark themes.</p>

<p><strong>Javascript</strong></p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">opted</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">color-theme</span><span class="dl">'</span> <span class="k">in</span> <span class="nx">localStorage</span>
<span class="kd">const</span> <span class="nx">optedDark</span> <span class="o">=</span> <span class="nx">localStorage</span><span class="p">.</span><span class="nf">getItem</span><span class="p">(</span><span class="dl">'</span><span class="s1">color-theme</span><span class="dl">'</span><span class="p">)</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">dark</span><span class="dl">'</span>
<span class="kd">const</span> <span class="nx">preferDark</span> <span class="o">=</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="kd">const</span> <span class="nx">shouldBeDark</span> <span class="o">=</span> <span class="nx">optedDark</span> <span class="o">||</span> <span class="p">(</span><span class="o">!</span><span class="nx">opted</span> <span class="o">&amp;&amp;</span> <span class="nx">preferDark</span><span class="p">)</span>

<span class="kd">function</span> <span class="nf">toggleTheme</span><span class="p">(</span><span class="nx">forceDark</span><span class="p">)</span> <span class="p">{</span>
  <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">toggle</span><span class="p">(</span><span class="dl">'</span><span class="s1">dark</span><span class="dl">'</span><span class="p">,</span> <span class="nx">forceDark</span><span class="p">)</span>
  <span class="nx">localStorage</span><span class="p">.</span><span class="nf">setItem</span><span class="p">(</span><span class="dl">'</span><span class="s1">color-theme</span><span class="dl">'</span><span class="p">,</span> <span class="nx">forceDark</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">light</span><span class="dl">'</span><span class="p">)</span>
<span class="p">}</span>

<span class="nf">toggleTheme</span><span class="p">(</span><span class="nx">shouldBeDark</span><span class="p">)</span>
</code></pre></div></div>

<p>To avoid theme switching happening after DOM rendering, we could place the script in the HTML head section instead.</p>

<p><strong>HTML</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;head&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"utf-8"</span><span class="nt">&gt;</span>
  <span class="c">&lt;!-- more metadata --&gt;</span>

  <span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"text/javascript"</span><span class="nt">&gt;</span>
    <span class="c1">// .. toggle theme script</span>
  <span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;/head&gt;</span>
</code></pre></div></div>

<h3 id="register-user-interaction-with-theme-switcher">Register User Interaction with Theme Switcher</h3>

<p>Once the default or user-selected theme has been initialized, we can register an event handler to the theme switcher and allow users to toggle the theme freely.</p>

<p><strong>Javascript</strong></p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">trigger</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">theme-trigger</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">trigger</span><span class="p">.</span><span class="nx">checked</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">contains</span><span class="p">(</span><span class="dl">'</span><span class="s1">dark</span><span class="dl">'</span><span class="p">)</span> <span class="c1">// assign initial state to the trigger element</span>
<span class="nx">trigger</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">change</span><span class="dl">'</span><span class="p">,</span> <span class="nx">toggleTheme</span><span class="p">)</span>
</code></pre></div></div>

<p>We’ll also need to modify the <code class="language-plaintext highlighter-rouge">toggleTheme</code> function to handle the <code class="language-plaintext highlighter-rouge">change</code> event.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nf">toggleTheme</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">forceDark</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">target</span> <span class="k">instanceof</span> <span class="nx">HTMLInputElement</span> <span class="p">?</span> <span class="nx">event</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">checked</span> <span class="p">:</span> <span class="o">!!</span><span class="nx">event</span>
  <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">toggle</span><span class="p">(</span><span class="dl">'</span><span class="s1">dark</span><span class="dl">'</span><span class="p">,</span> <span class="nx">forceDark</span><span class="p">)</span>
  <span class="nx">localStorage</span><span class="p">.</span><span class="nf">setItem</span><span class="p">(</span><span class="dl">'</span><span class="s1">color-theme</span><span class="dl">'</span><span class="p">,</span> <span class="nx">forceDark</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">light</span><span class="dl">'</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The event handler registration should only be executed once (e.g., when the HTML document is loaded/ready). An easy way to achieve this is to append a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag at the end of the document’s <code class="language-plaintext highlighter-rouge">&lt;body&gt;</code> tag.</p>

<p><strong>HTML</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"en"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"utf-8"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"text/javascript"</span><span class="nt">&gt;</span>
      <span class="c1">// toggle theme</span>
    <span class="nt">&lt;/script&gt;</span>
  <span class="nt">&lt;/head&gt;</span>
  <span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"text/javascript"</span><span class="nt">&gt;</span>
      <span class="c1">// register event handler</span>
    <span class="nt">&lt;/script&gt;</span>
  <span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>And that’s all you need to create a light/dark theme switcher for your static site.</p>]]></content><author><name>Ryan Chang</name><email>ryancyq@gmail.com</email></author><category term="html" /><category term="css" /><category term="theme" /><category term="dark-mode" /><category term="tailwind-css" /><summary type="html"><![CDATA[Tailwind CSS provides light/dark styling out of the box through its intuitive dark mode modifiers. By default, Tailwind CSS Dark Mode uses the prefers-color-scheme CSS media feature from the browser, which serves as a good starting point for supporting light/dark themes on our static site.]]></summary></entry><entry><title type="html">Multiple Test Runners for Ruby Code Coverage with CodeCov</title><link href="/posts/2024/09/15/ruby-code-coverage-with-multiple-test-runners" rel="alternate" type="text/html" title="Multiple Test Runners for Ruby Code Coverage with CodeCov" /><published>2024-09-15T00:00:00+08:00</published><updated>2024-09-15T00:00:00+08:00</updated><id>/posts/2024/09/15/ruby-code-coverage-with-multiple-test-runners</id><content type="html" xml:base="/posts/2024/09/15/ruby-code-coverage-with-multiple-test-runners"><![CDATA[<p>In Ruby development, maintaining code quality across versions is crucial. This post demonstrates how to set up a workflow to test your code across versions and generate detailed coverage reports, keeping your project robust as Ruby evolves.</p>

<p>If you don’t already have a working setup for Ruby code coverage, I recommend checking out my <a href="/posts/2024/09/08/ruby-code-coverage-for-backward-compatibility">Ruby Code Coverage Setup Guide</a>, where I explain how to configure coverage reports for different dependencies and runtimes via test runners.  You can also find the example at <a href="https://github.com/ryancyq/ruby-code-coverage">ryancyq/ruby-code-coverage</a>.</p>

<h3 id="prerequisites">Prerequisites</h3>

<p>These are the tools being used:</p>
<ul>
  <li><a href="https://codecov.io/">CodeCov</a> : A code coverage reporting and tracking tool.</li>
  <li><a href="https://github.com/features/actions">GitHub Actions</a> : A CI/CD platform integrated with GitHub.</li>
</ul>

<h3 id="create-a-github-actions-workflow">Create a GitHub Actions Workflow</h3>

<p>Let’s create a GitHub Actions workflow for code coverage analysis and run the same job for different Ruby versions. This will be done by leveraging <a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow">GitHub Actions matrix strategies</a>.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># coverage.yml</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">coverage</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Ruby</span><span class="nv"> </span><span class="s">${{</span><span class="nv"> </span><span class="s">matrix.ruby-version</span><span class="nv"> </span><span class="s">}}"</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <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">ruby-version</span><span class="pi">:</span> 
          <span class="pi">-</span> <span class="s2">"</span><span class="s">2.7"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">3.0"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">3.1"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">3.2"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">3.3"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">head"</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@v4</span>

      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s">${{ matrix.ruby-version }}</span>
          <span class="na">rubygems</span><span class="pi">:</span> <span class="s">3.4.22</span> <span class="c1"># last version to support Ruby 2.7</span>
</code></pre></div></div>

<p>Next, we need to install the gems required for generating the coverage report as described in <a href="/posts/2024/09/08/ruby-code-coverage-for-backward-compatibility">Ruby Code Coverage setup guide</a>.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># coverage.yml</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">coverage</span><span class="pi">:</span>
      <span class="c1"># .. more</span>

      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">gem install rake rspec simplecov simplecov-html simplecov-cobertura</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">rake coverage:run</span>

      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">sudo apt install tree</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">tree -a coverage</span>

      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">coverage-ruby-${{</span><span class="nv"> </span><span class="s">matrix.ruby-version</span><span class="nv"> </span><span class="s">}}"</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">coverage/ruby-*</span>
          <span class="na">include-hidden-files</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">retention-days</span><span class="pi">:</span> <span class="m">1</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">tree</code> command is included in the example for troubleshooting purposes. In the case of <code class="language-plaintext highlighter-rouge">Ruby 3.3</code>, we should see the following output after executing the <code class="language-plaintext highlighter-rouge">tree</code> command:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>coverage
└── ruby-3.3.5
    ├── .last_run.json
    ├── .resultset.json
    ├── .resultset.json.lock
    └── coverage.xml

1 directory, 4 files
</code></pre></div></div>

<p>The coverage result from each Ruby version will be uploaded to <a href="https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/storing-and-sharing-data-from-a-workflow">GitHub Actions Artifacts</a>.</p>
<figure>
  <img src="/assets/screenshots/2024-09-15/code-coverage-artifacts.png" alt="Code Coverage Artifacts" />
  <figcaption>Code Coverage results uploaded to CodeCov</figcaption>
</figure>

<h3 id="collate-coverage-reports-from-test-runners">Collate Coverage Reports from Test Runners</h3>

<p>Next, we need to add another <code class="language-plaintext highlighter-rouge">report</code> job to collate the coverage results into a single coverage result.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># coverage.yml</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="c1"># .. more</span>

  <span class="na">report</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Report</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">CodeCov"</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">coverage</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@v4</span>

      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/download-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">pattern</span><span class="pi">:</span> <span class="s">coverage-ruby-*</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">coverage-results</span>
          <span class="na">merge-multiple</span><span class="pi">:</span> <span class="kc">true</span>

      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3"</span>

      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">gem install rake rspec simplecov simplecov-html simplecov-cobertura</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">rake coverage:report</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">COV_DIR</span><span class="pi">:</span> <span class="s">coverage-results</span>

      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">sudo apt install tree</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">tree -a coverage-results</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">tree -a coverage</span>
</code></pre></div></div>

<p>The outputs from the <code class="language-plaintext highlighter-rouge">tree</code> commands above should look something like:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree <span class="nt">-a</span> coverage-results
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>coverage-results
├── ruby-2.7.8
│   ├── .last_run.json
│   ├── .resultset.json
│   ├── .resultset.json.lock
│   └── coverage.xml
├── ruby-3.0.7
│   ├── .last_run.json
│   ├── .resultset.json
│   ├── .resultset.json.lock
│   └── coverage.xml
├── ruby-3.1.6
│   ├── .last_run.json
│   ├── .resultset.json
│   ├── .resultset.json.lock
│   └── coverage.xml
├── ruby-3.2.5
│   ├── .last_run.json
│   ├── .resultset.json
│   ├── .resultset.json.lock
│   └── coverage.xml
├── ruby-3.3.5
│   ├── .last_run.json
│   ├── .resultset.json
│   ├── .resultset.json.lock
│   └── coverage.xml
└── ruby-3.4.0
    ├── .last_run.json
    ├── .resultset.json
    ├── .resultset.json.lock
    └── coverage.xml

6 directories, 24 files
</code></pre></div></div>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree <span class="nt">-a</span> coverage
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>coverage
├── .last_run.json
├── .resultset.json
├── .resultset.json.lock
└── coverage.xml

0 directories, 4 files
</code></pre></div></div>

<h3 id="report-to-codecov">Report to CodeCov</h3>

<p>In the next step, we will pass the <code class="language-plaintext highlighter-rouge">coverage</code> directory (which contains the collated coverage result) to <a href="https://github.com/codecov/codecov-action">CodeCov Action</a>.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># coverage.yml</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="c1"># .. more</span>

  <span class="na">report</span><span class="pi">:</span>
    <span class="na">steps</span><span class="pi">:</span>
        <span class="c1"># .. more</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload coverage to Codecov</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">codecov/codecov-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">directory</span><span class="pi">:</span> <span class="s">coverage</span>
          <span class="na">token</span><span class="pi">:</span> <span class="s">${{ secrets.CODECOV_TOKEN }}</span>
          <span class="na">fail_ci_if_error</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<h3 id="report-to-codecov-with-flags">Report to CodeCov with Flags</h3>

<p>Alternatively, if you want to leverage <a href="https://docs.codecov.com/docs/flags">CodeCov Flags</a> to have a better overview of the coverage result for each Ruby version, we could skip coverage result collation and upload individual coverage results to CodeCov with the corresponding flags.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># coverage.yml</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="c1"># .. more</span>

  <span class="na">report</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Report</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">CodeCov"</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">coverage</span><span class="pi">]</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <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">ruby-version</span><span class="pi">:</span> 
          <span class="pi">-</span> <span class="s2">"</span><span class="s">2.7"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">3.0"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">3.1"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">3.2"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">3.3"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">head"</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@v4</span>

      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/download-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">pattern</span><span class="pi">:</span> <span class="s2">"</span><span class="s">coverage-ruby-${{</span><span class="nv"> </span><span class="s">matrix.ruby-version</span><span class="nv"> </span><span class="s">}}*"</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">coverage</span>

      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">sudo apt install tree</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">tree -a coverage</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload coverage to Codecov</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">codecov/codecov-action@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">directory</span><span class="pi">:</span> <span class="s">coverage</span>
          <span class="na">token</span><span class="pi">:</span> <span class="s">${{ secrets.CODECOV_TOKEN }}</span>
          <span class="na">flags</span><span class="pi">:</span> <span class="s2">"</span><span class="s">ruby-${{</span><span class="nv"> </span><span class="s">matrix.ruby-version</span><span class="nv"> </span><span class="s">}}"</span>
          <span class="na">fail_ci_if_error</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<p>In the modified <code class="language-plaintext highlighter-rouge">report</code> job, we’ve removed the need for <code class="language-plaintext highlighter-rouge">ruby-setup</code> to collate coverage results, and artifacts are now downloaded one at a time.</p>

<figure>
  <img src="/assets/screenshots/2024-09-15/code-coverage-codecov-flags.png" alt="CodeCov with flags" />
  <figcaption>Code Coverage uploaded to CodeCov with flags</figcaption>
</figure>

<p>The coverage report for each Ruby version remains around 91% due to the <code class="language-plaintext highlighter-rouge">if-else</code> statement on <code class="language-plaintext highlighter-rouge">RUBY_VERSION</code>.</p>
<figure>
  <img src="/assets/screenshots/2024-09-15/codecov-reporting-flags.png" alt="CodeCov Report for flags" />
  <figcaption>CodeCov Report for flags</figcaption>
</figure>

<p>However, the overall code coverage stays at 100% as the result of merging the individual coverage reports.</p>
<figure>
  <img src="/assets/screenshots/2024-09-15/codecov-reporting.png" alt="Overall CodeCov Report" />
  <figcaption>Overall CodeCov Report</figcaption>
</figure>]]></content><author><name>Ryan Chang</name><email>ryancyq@gmail.com</email></author><category term="ruby" /><category term="github-actions" /><category term="codecov" /><category term="continuous-integration" /><category term="code-coverage" /><summary type="html"><![CDATA[In Ruby development, maintaining code quality across versions is crucial. This post demonstrates how to set up a workflow to test your code across versions and generate detailed coverage reports, keeping your project robust as Ruby evolves.]]></summary></entry><entry><title type="html">Ruby Code Coverage for Backward Compatibility with RSpec</title><link href="/posts/2024/09/08/ruby-code-coverage-for-backward-compatibility" rel="alternate" type="text/html" title="Ruby Code Coverage for Backward Compatibility with RSpec" /><published>2024-09-08T00:00:00+08:00</published><updated>2024-09-08T00:00:00+08:00</updated><id>/posts/2024/09/08/ruby-code-coverage-for-backward-compatibility</id><content type="html" xml:base="/posts/2024/09/08/ruby-code-coverage-for-backward-compatibility"><![CDATA[<p>Code coverage in Ruby is straightforward, thanks to the <a href="https://github.com/simplecov-ruby/simplecov">SimpleCov</a> library, which supports various testing frameworks. Typically, when running coverage for web applications, the focus is on examining line and branch coverage in a standardized environment, such as a server or Docker container.</p>

<p>Most of the time, testing is limited to the current production environment or major updates in OS or runtime images. However, things are slightly different when it comes to Ruby libraries or standalone applications.</p>

<p>In this post, I’ll share my experience maintaining a Ruby library across different Ruby versions, gem dependencies, and OS versions. You can also find the example at <a href="https://github.com/ryancyq/ruby-code-coverage">ryancyq/ruby-code-coverage</a>.</p>

<h3 id="prerequisites">Prerequisites</h3>

<p>These are the libraries being used in this project:</p>
<ul>
  <li><a href="https://github.com/simplecov-ruby/simplecov">SimpleCov</a> : A code coverage analysis tool for Ruby</li>
  <li><a href="https://github.com/rspec/rspec">RSpec</a> : A behavior-driven development framework for Ruby</li>
  <li><a href="https://github.com/ruby/rake">Rake</a> : A Ruby build utility similar to Make</li>
</ul>

<h3 id="code-using-different-ruby-apis">Code using different Ruby APIs</h3>

<p>For example, Ruby 3.1 introduced an improvement to the <code class="language-plaintext highlighter-rouge">File#dirname</code> method, allowing for parent directory retrieval with an optional level parameter. For more details, you can check the issue tracker at <a href="https://bugs.ruby-lang.org/issues/12194">Ruby Issue 12194</a>.</p>

<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># lib/config.rb</span>
<span class="k">class</span> <span class="nc">Config</span>
  <span class="no">ROOT</span> <span class="o">=</span> <span class="k">if</span> <span class="no">RUBY_VERSION</span> <span class="o">&gt;=</span> <span class="s2">"3.1"</span>
           <span class="no">File</span><span class="p">.</span><span class="nf">dirname</span><span class="p">(</span><span class="kp">__FILE__</span><span class="p">,</span> <span class="mi">2</span><span class="p">).</span><span class="nf">freeze</span>
         <span class="k">else</span>
           <span class="no">File</span><span class="p">.</span><span class="nf">expand_path</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="no">File</span><span class="p">.</span><span class="nf">dirname</span><span class="p">(</span><span class="kp">__FILE__</span><span class="p">),</span> <span class="s2">".."</span><span class="p">)).</span><span class="nf">freeze</span>
         <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="write-a-test-with-rspec">Write a Test with RSpec</h3>

<p>Now write a <a href="https://github.com/rspec/rspec">RSpec</a> test for <code class="language-plaintext highlighter-rouge">Config::ROOT</code></p>

<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># spec/config_spec.rb</span>
<span class="nb">require</span> <span class="s2">"pathname"</span>
<span class="nb">require_relative</span> <span class="s2">"../lib/config"</span>
<span class="no">RSpec</span><span class="p">.</span><span class="nf">describe</span> <span class="no">Config</span> <span class="k">do</span>
  <span class="n">describe</span> <span class="s2">"ROOT"</span> <span class="k">do</span>
    <span class="n">subject</span> <span class="p">{</span> <span class="no">Config</span><span class="o">::</span><span class="no">ROOT</span> <span class="p">}</span>

    <span class="n">let</span><span class="p">(</span><span class="ss">:root_dir</span><span class="p">)</span> <span class="p">{</span> <span class="no">Pathname</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">__dir__</span><span class="p">).</span><span class="nf">parent</span><span class="p">.</span><span class="nf">to_s</span> <span class="p">}</span>

    <span class="n">it</span> <span class="p">{</span> <span class="n">is_expected</span><span class="p">.</span><span class="nf">to</span> <span class="n">eq</span> <span class="n">root_dir</span> <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="rspec-execution-approaches">RSpec Execution Approaches</h3>

<p>There are two ways to execute <a href="https://github.com/rspec/rspec">RSpec</a> commands:</p>
<ol>
  <li>run <code class="language-plaintext highlighter-rouge">rspec</code> with CLI arguments. e.g. <code class="language-plaintext highlighter-rouge">rspec --require spec_helper --format documentation</code>.</li>
  <li>
    <p>run the RSpec rake task with preconfigured setup.</p>

    <blockquote>
      <p>RSpec rake task will spawn child process to execute RSpec CLI with arguments.</p>
    </blockquote>
  </li>
</ol>

<h3 id="create-rspec-rake-task">Create RSpec Rake Task</h3>

<p>Let’s add an RSpec rake task to simplify the command to run tests. This will become handy for code coverage later on.</p>
<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># tasks/rspec.rake</span>
<span class="nb">require</span> <span class="s2">"rspec/core/rake_task"</span>
<span class="no">RSpec</span><span class="o">::</span><span class="no">Core</span><span class="o">::</span><span class="no">RakeTask</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">:spec</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">rspec_opts</span> <span class="o">=</span> <span class="s2">"--format documentation"</span>
<span class="k">end</span>
</code></pre></div></div>
<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Rakefile</span>
<span class="no">Dir</span><span class="p">[</span><span class="no">File</span><span class="p">.</span><span class="nf">expand_path</span><span class="p">(</span><span class="s2">"tasks/*.rake"</span><span class="p">,</span> <span class="n">__dir__</span><span class="p">)].</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">task</span><span class="o">|</span> <span class="nb">load</span> <span class="n">task</span> <span class="p">}</span>
</code></pre></div></div>

<p>Now, run <code class="language-plaintext highlighter-rouge">rake --tasks</code> to see the rake tasks that have been configured.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rake spec             # Run RSpec code examples
</code></pre></div></div>

<p>When running the command <code class="language-plaintext highlighter-rouge">rake spec</code>, we will see the following output.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Config
  ROOT
    is expected to eq "/path/to/root"

Finished in 0.00058 seconds (files took 0.05219 seconds to load)
1 example, 0 failures
</code></pre></div></div>

<h3 id="configure-code-coverage-when-running-rspec">Configure Code Coverage when Running RSpec</h3>

<p>With <a href="https://github.com/rspec/rspec">RSpec</a> set up sucessfully, we can proceed with code coverage configuration. For this step, we will use the centeralized configuration file, <code class="language-plaintext highlighter-rouge">.simplecov</code> to initialize <code class="language-plaintext highlighter-rouge">SimpleCov</code> whenever <code class="language-plaintext highlighter-rouge">require 'simplecov'</code> statement is called.</p>

<p>Using the following configuration, the coverage report (<code class="language-plaintext highlighter-rouge">ruby-X.X.X</code> folder) will be generated in the folder specified by <code class="language-plaintext highlighter-rouge">ENV['COV_DIR']</code> or default to <code class="language-plaintext highlighter-rouge">coverage</code> folder in the current working directory.</p>

<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .simplecov</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span>
  <span class="n">enable_coverage</span> <span class="ss">:branch</span>
  <span class="n">command_name</span> <span class="s2">"ruby-</span><span class="si">#{</span><span class="no">RUBY_VERSION</span><span class="si">}</span><span class="s2">"</span>
  <span class="n">coverage_dir</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"COV_DIR"</span><span class="p">,</span> <span class="s2">"coverage"</span><span class="p">),</span> <span class="n">command_name</span><span class="p">)</span>

  <span class="k">if</span> <span class="no">ENV</span><span class="p">[</span><span class="s2">"CI"</span><span class="p">]</span>
    <span class="nb">require</span> <span class="s2">"simplecov-cobertura"</span>
    <span class="n">formatter</span> <span class="no">SimpleCov</span><span class="o">::</span><span class="no">Formatter</span><span class="o">::</span><span class="no">CoberturaFormatter</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Now, let’s create <code class="language-plaintext highlighter-rouge">coverage.rake</code> to run code coverage analysis through RSpec.</p>
<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># tasks/coverage.rake</span>
<span class="n">namespace</span> <span class="ss">:coverage</span> <span class="k">do</span>
  <span class="n">desc</span> <span class="s2">"Run coverage with spec"</span>
  <span class="n">task</span> <span class="ss">:run</span> <span class="k">do</span>
    <span class="no">Rake</span><span class="o">::</span><span class="no">Task</span><span class="p">[</span><span class="ss">:spec</span><span class="p">].</span><span class="nf">invoke</span><span class="p">(</span><span class="ss">coverage: </span><span class="kp">true</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>We also need to modify RSpec rake task to start <code class="language-plaintext highlighter-rouge">SimpleCov</code> during <a href="https://github.com/rspec/rspec">RSpec</a> execution.</p>
<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># tasks/rspec.rake</span>
<span class="nb">require</span> <span class="s2">"rspec/core/rake_task"</span>
<span class="no">RSpec</span><span class="o">::</span><span class="no">Core</span><span class="o">::</span><span class="no">RakeTask</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">:spec</span><span class="p">,</span> <span class="p">[</span><span class="ss">:coverage</span><span class="p">])</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="p">,</span> <span class="n">args</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">ruby_opts</span> <span class="o">=</span> <span class="s2">"-r./.simplecov_spawn"</span> <span class="k">if</span> <span class="n">args</span><span class="p">[</span><span class="ss">:coverage</span><span class="p">]</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">rspec_opts</span> <span class="o">=</span> <span class="s2">"--format documentation"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Following the <a href="https://github.com/simplecov-ruby/simplecov?#running-simplecov-against-spawned-subprocesses">SimpleCov Spawn Subprocesses Guide</a> to start <code class="language-plaintext highlighter-rouge">SimpleCov</code> for spawned subprocesses, we’ll skip the <code class="language-plaintext highlighter-rouge">SimpleCov.at_fork.call(Process.pid)</code> step, as the additional fork behavior isn’t needed in this case.</p>

<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .simplecov_spawn.rb</span>
<span class="nb">require</span> <span class="s2">"simplecov"</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span>
</code></pre></div></div>

<p>Now, run <code class="language-plaintext highlighter-rouge">rake --tasks</code> to view the updated rake tasks:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rake coverage:run     # Run coverage with spec
rake spec[coverage]   # Run RSpec code examples
</code></pre></div></div>

<p>When running <code class="language-plaintext highlighter-rouge">rake coverage:run</code> with <code class="language-plaintext highlighter-rouge">RUBY_VERSION</code> set to <code class="language-plaintext highlighter-rouge">3.3.4</code>, the output will be:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Coverage report generated for ruby-3.3.4 to /path/to/root/coverage/ruby-3.3.4.
Line Coverage: 90.91% (10 / 11)
Branch Coverage: 50.0% (1 / 2)
</code></pre></div></div>

<p>Similarly, with <code class="language-plaintext highlighter-rouge">RUBY_VERSION</code> set to <code class="language-plaintext highlighter-rouge">3.0.7</code>, you’ll see:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Coverage report generated for ruby-3.0.7 to /path/to/root/coverage/ruby-3.0.7.
Line Coverage: 90.91% (10 / 11)
Branch Coverage: 50.0% (1 / 2)
</code></pre></div></div>

<p>However, despite the identical output, the underlying results are completely different.</p>

<figure>
  <img src="/assets/screenshots/2024-09-08/coverage-ruby-3.0.7.png" alt="Coverage Ruby 3.0.7" />
  <figcaption>coverage/ruby-3.0.7/index.html</figcaption>
</figure>

<figure>
  <img src="/assets/screenshots/2024-09-08/coverage-ruby-3.3.4.png" alt="Coverage Ruby 3.3.4" />
  <figcaption>coverage/ruby-3.3.4/index.html</figcaption>
</figure>

<h3 id="collate-multiple-reports-into-a-unified-coverage-report">Collate Multiple Reports into a Unified Coverage Report</h3>

<p>Finally, we will add another rake task <code class="language-plaintext highlighter-rouge">coverage:report</code> to collate the coverage results into a single coverage report.</p>
<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># tasks/coverage.rake</span>
<span class="n">namespace</span> <span class="ss">:coverage</span> <span class="k">do</span>
  <span class="n">desc</span> <span class="s2">"Run coverage with spec"</span>
  <span class="n">task</span> <span class="ss">:run</span> <span class="k">do</span>
    <span class="no">Rake</span><span class="o">::</span><span class="no">Task</span><span class="p">[</span><span class="ss">:spec</span><span class="p">].</span><span class="nf">invoke</span><span class="p">(</span><span class="ss">coverage: </span><span class="kp">true</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="n">desc</span> <span class="s2">"Collate coverage results generated by different test runners"</span>
  <span class="n">task</span> <span class="ss">:report</span> <span class="k">do</span>
    <span class="nb">require</span> <span class="s2">"simplecov"</span>
    <span class="n">coverage_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="no">File</span><span class="p">.</span><span class="nf">dirname</span><span class="p">(</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_dir</span><span class="p">),</span> <span class="s2">"ruby-*"</span><span class="p">)</span>
    <span class="n">coverage_results</span> <span class="o">=</span> <span class="no">Dir</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">coverage_dir</span><span class="p">,</span> <span class="s2">".resultset.json"</span><span class="p">)]</span>

    <span class="k">raise</span> <span class="o">&lt;&lt;~</span><span class="no">MSG</span> <span class="k">if</span> <span class="n">coverage_results</span><span class="p">.</span><span class="nf">empty?</span><span class="sh">
      Coverage results not found, searched in:
      </span><span class="si">#{</span><span class="no">Dir</span><span class="p">[</span><span class="n">coverage_dir</span><span class="p">].</span><span class="nf">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">dir</span><span class="o">|</span> <span class="s2">"  - </span><span class="si">#{</span><span class="n">dir</span><span class="si">}</span><span class="s2">"</span> <span class="si">}</span><span class="sh">.join("</span><span class="se">\n</span><span class="sh">")}
</span><span class="no">    MSG</span>

    <span class="no">SimpleCov</span><span class="p">.</span><span class="nf">collate</span><span class="p">(</span><span class="n">coverage_results</span><span class="p">)</span> <span class="k">do</span>
      <span class="n">coverage_dir</span> <span class="s2">"coverage"</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="n">desc</span> <span class="s2">"Run coverage analysis and collate coverage results"</span>
<span class="n">task</span> <span class="ss">coverage: </span><span class="p">[</span><span class="s2">"coverage:run"</span><span class="p">,</span> <span class="s2">"coverage:report"</span><span class="p">]</span>
</code></pre></div></div>

<p>Now, run <code class="language-plaintext highlighter-rouge">rake --tasks</code> to view the updated rake tasks:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rake coverage         # Run coverage analysis and collate coverage results
rake coverage:report  # Collate coverage results generated by different test runners
rake coverage:run     # Run coverage with spec
rake spec[coverage]   # Run RSpec code examples
</code></pre></div></div>

<p>When running <code class="language-plaintext highlighter-rouge">rake coverage:report</code>, the output will be:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Coverage report generated for ruby-3.0.7, ruby-3.3.4 to /path/to/root/coverage.
Line Coverage: 100.0% (11 / 11)
Branch Coverage: 100.0% (2 / 2)
</code></pre></div></div>

<figure>
  <img src="/assets/screenshots/2024-09-08/coverage-collated.png" alt="Collated Coverage" />
  <figcaption>coverage/index.html</figcaption>
</figure>

<p>The final project structure should look like this:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├── coverage
│   ├── ruby-3.0.7
│   │   ├── assets
│   │   ├── .resultset.json
│   │   ├── index.html
│   │   └── coverage.xml # when CoberturaFormatter is enabled
│   ├── ruby-3.3.4
│   │   ├── assets
│   │   ├── .resultset.json
│   │   ├── index.html
│   │   └── coverage.xml # when CoberturaFormatter is enabled
│   ├── assets
│   ├── .resultset.json
│   ├── index.html
│   └── coverage.xml # when CoberturaFormatter is enabled
├── lib
│   └── config.rb
├── spec
│   └── config_spec.rb
├── tasks
│   ├── coverage.rake
│   └── rspec.rake
├── .simplecov
├── .simplecov_spawn.rb
└── Rakefile
</code></pre></div></div>]]></content><author><name>Ryan Chang</name><email>ryancyq@gmail.com</email></author><category term="ruby" /><category term="simple-cov" /><category term="rspec" /><category term="rake" /><category term="continuous-integration" /><category term="code-coverage" /><summary type="html"><![CDATA[Code coverage in Ruby is straightforward, thanks to the SimpleCov library, which supports various testing frameworks. Typically, when running coverage for web applications, the focus is on examining line and branch coverage in a standardized environment, such as a server or Docker container.]]></summary></entry><entry><title type="html">A JavaScript-free Responsive Navigation Menu with Tailwind CSS</title><link href="/posts/2024/08/10/collapsible-navigation-menu-without-js" rel="alternate" type="text/html" title="A JavaScript-free Responsive Navigation Menu with Tailwind CSS" /><published>2024-08-10T00:00:00+08:00</published><updated>2024-08-10T00:00:00+08:00</updated><id>/posts/2024/08/10/collapsible-navigation-menu-without-js</id><content type="html" xml:base="/posts/2024/08/10/collapsible-navigation-menu-without-js"><![CDATA[<p>If you are building a static website using <a href="https://tailwindcss.com">Tailwind CSS</a> and looking for a responsive, collapsible navigation menu that is JavaScript-free, I might have something for you.</p>

<p>To create responsive navigation menus, it often involves using a “hamburger” menu for small screen widths and a regular navbar for larger screens. The conventional way of achieving a collapsible navigation menu is by attaching a JavaScript event handler to the <code class="language-plaintext highlighter-rouge">hamburger</code> menu to toggle the menu’s visibility on the <code class="language-plaintext highlighter-rouge">click</code> event.</p>

<p>However, we can achieve the same behavior by leveraging native HTML.</p>

<h3 id="simple-navigation-menu">Simple Navigation Menu</h3>

<p>Let’s look at a simple navigation menu styled with <a href="https://tailwindcss.com">Tailwind CSS</a>.</p>

<p><strong>Demo</strong></p>
<div class="not-prose">
  <nav class="flex flex-wrap justify-between items-center bg-gray-50 dark:bg-gray-800 p-4 border border-2 rounded-lg border-gray-200 dark:border-gray-700">
    <a href="#">Playground</a>
    <ul class="inline-flex gap-x-2">
      <li><a href="#" class="hover:underline">Home</a></li>
      <li><a href="#" class="hover:underline">About</a></li>
      <li><a href="#" class="hover:underline">Contact</a></li>
    </ul>
  </nav>
</div>

<p><strong>HTML</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;nav</span> <span class="na">class=</span><span class="s">"flex flex-wrap justify-between items-center bg-gray-50 dark:bg-gray-800"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"#"</span><span class="nt">&gt;</span>Playground<span class="nt">&lt;/a&gt;</span>
  <span class="nt">&lt;ul</span> <span class="na">class=</span><span class="s">"inline-flex gap-x-2"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>Home<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>About<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>Contact<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
  <span class="nt">&lt;/ul&gt;</span>
<span class="nt">&lt;/nav&gt;</span>
</code></pre></div></div>

<h3 id="responsive-navigation-menu">Responsive Navigation Menu</h3>

<p>Next, let’s add the hamburger menu for screens smaller than <code class="language-plaintext highlighter-rouge">md</code> using an HTML <code class="language-plaintext highlighter-rouge">&lt;button&gt;</code>.</p>

<p><strong>Demo</strong></p>
<div class="not-prose">
  <nav class="flex flex-wrap justify-between items-center bg-gray-50 dark:bg-gray-800 p-4 border border-2 rounded-lg border-gray-200 dark:border-gray-700">
    <a href="#">Playground</a>
    <div class="inline-flex gap-x-2 items-center">
      <button class="rounded p-2 hover:bg-gray-100 dark:hover:bg-gray-700">
        <svg class="size-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
            <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
        </svg>
      </button>
      <ul class="flex-col gap-y-2 hidden">
        <li><a href="#" class="hover:underline">Home</a></li>
        <li><a href="#" class="hover:underline">About</a></li>
        <li><a href="#" class="hover:underline">Contact</a></li>
      </ul>
    </div>
  </nav>
</div>

<p><strong>HTML</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;nav</span> <span class="na">class=</span><span class="s">"flex flex-wrap justify-between items-center bg-gray-50 dark:bg-gray-800"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"#"</span><span class="nt">&gt;</span>Playground<span class="nt">&lt;/a&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"inline-flex gap-x-2 items-center"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;button</span> <span class="na">class=</span><span class="s">"md:hidden rounded p-2 hover:bg-gray-100 dark:hover:bg-gray-700"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;svg</span> <span class="na">class=</span><span class="s">"size-5"</span> <span class="na">aria-hidden=</span><span class="s">"true"</span> <span class="na">xmlns=</span><span class="s">"http://www.w3.org/2000/svg"</span> <span class="na">fill=</span><span class="s">"none"</span> <span class="na">viewBox=</span><span class="s">"0 0 17 14"</span><span class="nt">&gt;</span>
          <span class="nt">&lt;path</span> <span class="na">stroke=</span><span class="s">"currentColor"</span> <span class="na">stroke-linecap=</span><span class="s">"round"</span> <span class="na">stroke-linejoin=</span><span class="s">"round"</span> <span class="na">stroke-width=</span><span class="s">"2"</span> <span class="na">d=</span><span class="s">"M1 1h15M1 7h15M1 13h15"</span><span class="nt">/&gt;</span>
      <span class="nt">&lt;/svg&gt;</span>
    <span class="nt">&lt;/button&gt;</span>
    <span class="nt">&lt;ul</span> <span class="na">class=</span><span class="s">"flex-col gap-y-2 hidden md:inline-flex md:flex-row md:gap-x-2"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>Home<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>About<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
      <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>Contact<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
    <span class="nt">&lt;/ul&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/nav&gt;</span>
</code></pre></div></div>

<p>Usually, handling the button <code class="language-plaintext highlighter-rouge">click</code> event to toggle menu visibility requires JavaScript. Here, we are going to use HTML <code class="language-plaintext highlighter-rouge">&lt;label&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> elements to replace the <code class="language-plaintext highlighter-rouge">&lt;button&gt;</code>. According to <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label">HTML label element</a> definition, the <code class="language-plaintext highlighter-rouge">&lt;label&gt;</code> element is designed to pass user interaction events to the associated <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> element via a matching <code class="language-plaintext highlighter-rouge">id</code>. We can utilize this design principle by placing the <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> tag anywhere in the HTML body.</p>

<p><strong>Demo</strong></p>
<div class="not-prose">
  <nav class="flex flex-wrap justify-between items-center bg-gray-50 dark:bg-gray-800 p-4 border border-2 rounded-lg border-gray-200 dark:border-gray-700">
    <a href="#">Playground</a>
    <input id="navbar-trigger-1" type="checkbox" class="hidden" />
    <label for="navbar-trigger-1" class="rounded p-2 hover:bg-gray-100 dark:hover:bg-gray-700">
      <svg class="size-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
          <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
      </svg>
    </label>
    <ul class="flex-col gap-y-2 hidden">
      <li><a href="#" class="hover:underline">Home</a></li>
      <li><a href="#" class="hover:underline">About</a></li>
      <li><a href="#" class="hover:underline">Contact</a></li>
    </ul>
  </nav>
</div>

<p><strong>HTML</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;nav</span> <span class="na">class=</span><span class="s">"flex flex-wrap justify-between items-center bg-gray-50 dark:bg-gray-800"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"#"</span><span class="nt">&gt;</span>Playground<span class="nt">&lt;/a&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">id=</span><span class="s">"navbar-trigger"</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">class=</span><span class="s">"hidden"</span><span class="nt">/&gt;</span>
  <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"navbar-trigger"</span> <span class="na">class=</span><span class="s">"md:hidden rounded p-2 hover:bg-gray-100 dark:hover:bg-gray-700"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;svg</span> <span class="na">class=</span><span class="s">"size-5"</span> <span class="na">aria-hidden=</span><span class="s">"true"</span> <span class="na">xmlns=</span><span class="s">"http://www.w3.org/2000/svg"</span> <span class="na">fill=</span><span class="s">"none"</span> <span class="na">viewBox=</span><span class="s">"0 0 17 14"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;path</span> <span class="na">stroke=</span><span class="s">"currentColor"</span> <span class="na">stroke-linecap=</span><span class="s">"round"</span> <span class="na">stroke-linejoin=</span><span class="s">"round"</span> <span class="na">stroke-width=</span><span class="s">"2"</span> <span class="na">d=</span><span class="s">"M1 1h15M1 7h15M1 13h15"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;/svg&gt;</span>
  <span class="nt">&lt;/label&gt;</span>
  <span class="nt">&lt;ul</span> <span class="na">class=</span><span class="s">"flex-col gap-y-2 hidden md:inline-flex md:flex-row md:gap-x-2"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>Home<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>About<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>Contact<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
  <span class="nt">&lt;/ul&gt;</span>
<span class="nt">&lt;/nav&gt;</span>
</code></pre></div></div>

<h3 id="tailwind-css-peer-modifier">Tailwind CSS Peer Modifier</h3>

<p>Next, we are going to use a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors">CSS attribute selector</a> on siblings to toggle menu visibility. This is where <a href="https://tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-sibling-state">Tailwind CSS peer-{modifier}</a> comes in handy.</p>

<p>This is the reason behind placing the <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> outside of the <code class="language-plaintext highlighter-rouge">&lt;label&gt;</code> to achieve the following hierarchy:</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;input</span> <span class="na">id=</span><span class="s">"navbar-trigger"</span><span class="nt">/&gt;</span>
<span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"navbar-trigger"</span><span class="nt">&gt;&lt;/label&gt;</span>
<span class="nt">&lt;ul&gt;</span>
  <span class="nt">&lt;li&gt;</span>Home<span class="nt">&lt;/li&gt;</span>
  <span class="nt">&lt;li&gt;</span>About<span class="nt">&lt;/li&gt;</span>
  <span class="nt">&lt;li&gt;</span>Contact<span class="nt">&lt;/li&gt;</span>
<span class="nt">&lt;/ul&gt;</span>
</code></pre></div></div>

<p>Using <code class="language-plaintext highlighter-rouge">peer-{modifiers}</code>, we can now hide the <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> and add the <code class="language-plaintext highlighter-rouge">peer/hamburger</code> CSS class to allow the sibling <code class="language-plaintext highlighter-rouge">&lt;ul&gt;</code> CSS classes to bind with the <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> using <code class="language-plaintext highlighter-rouge">peer-checked/hamburger:inline-flex</code>.</p>

<p><strong>Demo</strong></p>
<div class="not-prose">
  <nav class="flex flex-wrap justify-between items-center bg-gray-50 dark:bg-gray-800 p-4 border border-2 rounded-lg border-gray-200 dark:border-gray-700">
    <a href="#">Playground</a>
    <input id="navbar-trigger" type="checkbox" checked="" class="hidden peer/hamburger" />
    <div class="inline-flex items-center gap-x-4">
      <div class="inline-flex items-center font-semibold text-cyan-600 dark:text-cyan-400">
        Click Me
        <svg class="rtl:rotate-180 size-4 ms-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">
          <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9" />
        </svg>
      </div>
      <label for="navbar-trigger" class="rounded p-2 hover:bg-gray-100 dark:hover:bg-gray-700">
        <svg class="size-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
          <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
        </svg>
      </label>
    </div>
    <ul class="flex-col gap-y-2 mt-2 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 w-full hidden peer-checked/hamburger:inline-flex">
      <li><a href="#" class="hover:underline">Home</a></li>
      <li><a href="#" class="hover:underline">About</a></li>
      <li><a href="#" class="hover:underline">Contact</a></li>
    </ul>
  </nav>
</div>

<p><strong>HTML</strong></p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- adding flex-wrap to ensure expanded menu will be wrapped --&gt;</span>
<span class="nt">&lt;nav</span> <span class="na">class=</span><span class="s">"flex flex-wrap justify-between items-center bg-gray-50 dark:bg-gray-800"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"#"</span><span class="nt">&gt;</span>Playground<span class="nt">&lt;/a&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">id=</span><span class="s">"navbar-trigger"</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">class=</span><span class="s">"hidden peer/hamburger"</span><span class="nt">/&gt;</span>
  <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"navbar-trigger"</span> <span class="na">class=</span><span class="s">"md:hidden rounded p-2 hover:bg-gray-100 dark:hover:bg-gray-700"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;svg</span> <span class="na">class=</span><span class="s">"size-5"</span> <span class="na">aria-hidden=</span><span class="s">"true"</span> <span class="na">xmlns=</span><span class="s">"http://www.w3.org/2000/svg"</span> <span class="na">fill=</span><span class="s">"none"</span> <span class="na">viewBox=</span><span class="s">"0 0 17 14"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;path</span> <span class="na">stroke=</span><span class="s">"currentColor"</span> <span class="na">stroke-linecap=</span><span class="s">"round"</span> <span class="na">stroke-linejoin=</span><span class="s">"round"</span> <span class="na">stroke-width=</span><span class="s">"2"</span> <span class="na">d=</span><span class="s">"M1 1h15M1 7h15M1 13h15"</span><span class="nt">/&gt;</span>
    <span class="nt">&lt;/svg&gt;</span>
  <span class="nt">&lt;/label&gt;</span>
  <span class="c">&lt;!-- There are 2 styles for the &lt;ul&gt; --&gt;</span>
  <span class="c">&lt;!-- Full width with border menu (&lt; md): --&gt;</span>
  <span class="c">&lt;!-- flex-col gap-y-2 mt-2 px-4 py-2 rounded-lg bg-gray-200 w-full hidden peer-checked/hamburger:inline-flex --&gt;</span>
  <span class="c">&lt;!-- Auto-sized and borderless menu (&gt;= md): --&gt;</span>
  <span class="c">&lt;!-- md:w-auto md:inline-flex md:flex-row md:gap-x-2 md:mt-0 md:p-0 md:rounded-none md:bg-transparent --&gt;</span>
  <span class="nt">&lt;ul</span> <span class="na">class=</span><span class="s">"flex-col gap-y-2 mt-2 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 w-full hidden peer-checked/hamburger:inline-flex md:w-auto md:inline-flex md:flex-row md:gap-x-2 md:mt-0 md:p-0 md:rounded-none md:bg-transparent"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>Home<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>About<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#"</span> <span class="na">class=</span><span class="s">"hover:underline"</span><span class="nt">&gt;</span>Contact<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
  <span class="nt">&lt;/ul&gt;</span>
<span class="nt">&lt;/nav&gt;</span>
</code></pre></div></div>

<p>And with that, you’ve created a fully functional, responsive navigation menu using <a href="https://tailwindcss.com">Tailwind CSS</a>, completely free of JavaScript, making it an ideal solution for lightweight, static websites.</p>]]></content><author><name>Ryan Chang</name><email>ryancyq@gmail.com</email></author><category term="html" /><category term="css" /><category term="responsive" /><category term="tailwind-css" /><summary type="html"><![CDATA[If you are building a static website using Tailwind CSS and looking for a responsive, collapsible navigation menu that is JavaScript-free, I might have something for you.]]></summary></entry><entry><title type="html">Deploy Jekyll to GitHub Pages with Tailwind CSS</title><link href="/posts/2024/07/30/deploy-jekyll-with-tailwindcss-via-github-actions" rel="alternate" type="text/html" title="Deploy Jekyll to GitHub Pages with Tailwind CSS" /><published>2024-07-30T00:00:00+08:00</published><updated>2024-07-30T00:00:00+08:00</updated><id>/posts/2024/07/30/deploy-jekyll-with-tailwindcss-via-github-actions</id><content type="html" xml:base="/posts/2024/07/30/deploy-jekyll-with-tailwindcss-via-github-actions"><![CDATA[<p>Setting up a <a href="https://jekyllrb.com/">Jekyll</a> 4.x site with <a href="https://tailwindcss.com/">Tailwind CSS</a> on <a href="https://docs.github.com/en/pages">GitHub Pages</a> is a bit different from the usual <a href="https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site">GitHub Pages deployment</a>. You’ll need a custom deployment workflow, but don’t worry, it’s not too complicated!</p>

<p>For personal blogs, most folks deploy from the <code class="language-plaintext highlighter-rouge">main</code> branch with the <code class="language-plaintext highlighter-rouge">root</code> folder, while project websites might deploy from a separate branch like <code class="language-plaintext highlighter-rouge">gh-pages</code> with a <code class="language-plaintext highlighter-rouge">doc</code> folder in the <code class="language-plaintext highlighter-rouge">root</code>. Check out <a href="https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site">GitHub Pages deployment</a> for more details.</p>

<figure>
  <img src="/assets/screenshots/2024-07-30/deploy-from-branch.png" alt="Deploy Github Pages from Branch" />
  <figcaption>deploy github-pages from branch</figcaption>
</figure>

<p>Once you’ve got the basic setup, you might want to add some cool 3rd party libraries to enhance your website. GitHub Pages is powered by <a href="https://jekyllrb.com/">Jekyll</a> and has a safelist of supported Ruby gems. See <a href="https://pages.github.com/versions/">GitHub Pages dependencies</a> for the list.</p>

<p>At the moment, <a href="https://docs.github.com/en/pages">GitHub Pages</a> supports only up to <a href="https://jekyllrb.com/">Jekyll</a> 3.x, but there are plenty of reasons to upgrade to <a href="https://jekyllrb.com/">Jekyll</a> 4.x. Plus, there are many awesome 3rd party libraries that aren’t on the safelist.</p>

<h2 id="deploy-custom-jekyll-build-pipeline">Deploy Custom Jekyll Build Pipeline</h2>

<p>To get around this, we can set up a <a href="https://github.com/features/actions">GitHub Actions</a> workflow to bypass the default deployment pipeline offered by <a href="https://docs.github.com/en/pages">GitHub Pages</a>. The official <a href="https://jekyllrb.com/docs/continuous-integration/github-actions/">Jekyll documentation on GitHub Actions</a> explains the benefits of using a <a href="https://github.com/features/actions">GitHub Actions</a> workflow.</p>

<p>This way, we can add dependencies like <a href="https://tailwindcss.com/">Tailwind CSS</a>, a super popular utility-first CSS framework that makes your website look amazing with minimal effort.</p>

<h3 id="prerequisites">Prerequisites</h3>

<p>Make sure you have a working <a href="https://jekyllrb.com/">Jekyll</a> + <a href="https://tailwindcss.com/">Tailwind CSS</a> setup on your machine. See my post on <a href="/posts/2024/07/24/building-a-static-site-with-jekyll-and-tailwind-css">Jekyll and Tailwind CSS setup guide</a>.</p>

<h3 id="step-1-ensure-gemfilelock-supports-the-os-on-github-actions">Step 1: Ensure <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> Supports the OS on GitHub Actions</h3>

<p>We usually use the <code class="language-plaintext highlighter-rouge">ubuntu</code> OS image in <a href="https://github.com/features/actions">GitHub Actions</a>. To make sure <code class="language-plaintext highlighter-rouge">bundler</code> can install the dependencies with <code class="language-plaintext highlighter-rouge">ubuntu</code>, your <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> should have <code class="language-plaintext highlighter-rouge">x86_64-linux</code> under <code class="language-plaintext highlighter-rouge">PLATFORMS</code>.</p>

<p>If not, you can add it with this command:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  bundle lock <span class="nt">--add-platform</span> x86_64-linux
</code></pre></div></div>

<h3 id="step-2-select-page-deployment-approach">Step 2: Select Page Deployment Approach</h3>

<p>Following the <a href="https://jekyllrb.com/docs/continuous-integration/github-actions/">Jekyll documentation on GitHub Actions</a>, your configuration will look something like this:</p>

<figure>
  <img src="/assets/screenshots/2024-07-30/deploy-with-gha.png" alt="Deploy Github Pages with Github Actions" />
  <figcaption>deploy github-pages with github actions</figcaption>
</figure>

<p>Click on <code class="language-plaintext highlighter-rouge">Configure</code> under <code class="language-plaintext highlighter-rouge">GitHub Pages Jekyll</code> workflow.</p>
<figure>
  <img src="/assets/screenshots/2024-07-30/deploy-with-gha-configure-template.png" alt="Configure Github Actions for Github Pages Deployment" />
  <figcaption>configure github actions for github-pages deployment</figcaption>
</figure>

<h3 id="step-3-create-a-github-actions-workflow">Step 3: Create a GitHub Actions Workflow</h3>

<p>When creating the workflow, you’ll need to tweak the default template a bit:</p>

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

<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">name</span><span class="pi">:</span> <span class="s">Checkout</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Ruby</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span> <span class="c1"># replaced with major version to get latest updates</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3.3"</span> <span class="c1"># or create a .ruby-version</span>
          <span class="na">bundler-cache</span><span class="pi">:</span> <span class="kc">true</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Pages</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">pages</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/configure-pages@v5</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">Upload artifact</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-pages-artifact@v3</span>
</code></pre></div></div>

<p>For reference, see the official <a href="https://github.com/marketplace/actions/build-jekyll-for-github-pages">actions/build-jekyll-for-github-pages</a>.</p>

<h3 id="step-4-add-nodejs-setup-for-tailwindcss">Step 4: Add Node.js Setup for TailwindCSS</h3>

<p>Include Node.js setup and install JavaScript dependencies with:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># jekyll.yml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Jekyll site to Pages</span>
<span class="c1"># ... more</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="c1"># ... more</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Ruby</span>
        <span class="c1"># ... more</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Node</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="m">20</span> <span class="c1"># any node version would do, preferably an LTS version</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install Tailwind CSS dependencies</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npm install</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Pages</span>
        <span class="c1"># ... more</span>
</code></pre></div></div>

<h3 id="step-5-push-and-deploy">Step 5: Push and Deploy</h3>

<p>After all these steps, you should see a successful build like this:</p>

<figure>
  <img src="/assets/screenshots/2024-07-30/deployment-successful.png" alt="Github Pages Deployment Successful" />
  <figcaption>github-pages deployment successful</figcaption>
</figure>

<p>And that’s it! You’ve now set up a <a href="https://jekyllrb.com/">Jekyll</a> site with <a href="https://tailwindcss.com/">Tailwind CSS</a> and deployed it using <a href="https://github.com/features/actions">GitHub Actions</a>. With this setup, you can take full advantage of <a href="https://jekyllrb.com/">Jekyll</a> 4.x and any other dependencies you want to include!</p>]]></content><author><name>Ryan Chang</name><email>ryancyq@gmail.com</email></author><category term="ruby" /><category term="jekyll" /><category term="github-pages" /><category term="github-actions" /><category term="tailwind-css" /><summary type="html"><![CDATA[Setting up a Jekyll 4.x site with Tailwind CSS on GitHub Pages is a bit different from the usual GitHub Pages deployment. You’ll need a custom deployment workflow, but don’t worry, it’s not too complicated!]]></summary></entry><entry><title type="html">Building a Static Site with Jekyll and Tailwind CSS v3</title><link href="/posts/2024/07/24/building-a-static-site-with-jekyll-and-tailwind-css" rel="alternate" type="text/html" title="Building a Static Site with Jekyll and Tailwind CSS v3" /><published>2024-07-24T00:00:00+08:00</published><updated>2024-07-24T00:00:00+08:00</updated><id>/posts/2024/07/24/building-a-static-site-with-jekyll-and-tailwind-css</id><content type="html" xml:base="/posts/2024/07/24/building-a-static-site-with-jekyll-and-tailwind-css"><![CDATA[<p><a href="https://jekyllrb.com/">Jekyll</a> is an awesome tool for building static sites, whether it’s for your personal blog or a hobby project. Adding <a href="https://v3.tailwindcss.com/">Tailwind CSS</a> 3.x into the mix makes creating your site even easier and way more visually appealing.</p>

<p>One huge advantage of using Jekyll is the free hosting you get with <a href="https://docs.github.com/en/pages">GitHub Pages</a> for public repositories. If you’re cool with this setup, GitHub Pages is a no-brainer.</p>

<p>Check out my post on <a href="/posts/2024/07/30/deploy-jekyll-with-tailwindcss-via-github-actions">Deploy Jekyll to GitHub Pages with Tailwind CSS</a> for more details on deploying a custom Jekyll build to GitHub Pages.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>Make sure you have the necessary dependencies installed on your machine.</p>

<h2 id="step-1-install-ruby-and-jekyll">Step 1: Install Ruby and Jekyll</h2>

<p>First things first, you need to install Ruby using <code class="language-plaintext highlighter-rouge">rbenv</code> (recommended) and then get the <a href="https://jekyllrb.com/">Jekyll</a> gem. Follow <a href="https://github.com/rbenv/rbenv#installation"><code class="language-plaintext highlighter-rouge">rbenv</code> installation guide</a> to have it installed on your machine.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install rbenv and Ruby</span>
rbenv <span class="nb">install </span>3.3.1
rbenv global 3.3.1

<span class="c"># Install Jekyll, Bundler</span>
gem <span class="nb">install </span>jekyll bundler
</code></pre></div></div>

<h2 id="step-2-create-a-new-jekyll-site">Step 2: Create a New Jekyll Site</h2>

<p>Now, let’s create a new Jekyll site from scratch.</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jekyll new my-personal-blog <span class="nt">--blank</span>
<span class="nb">cd </span>my-personal-blog
</code></pre></div></div>

<p>Optionally, you can create a <code class="language-plaintext highlighter-rouge">.ruby-version</code> file for the Ruby version you have installed.</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s2">"3.3.1"</span> <span class="o">&gt;</span> .ruby-version
</code></pre></div></div>

<h2 id="step-3-install-jekyll-postcss-dependency-for-ruby">Step 3: Install <code class="language-plaintext highlighter-rouge">jekyll-postcss</code> dependency for Ruby</h2>

<p>Add <code class="language-plaintext highlighter-rouge">jekyll-postcss</code> to your Gemfile.</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s2">"gem 'jekyll-postcss', '~&gt; 0.5.0', group: :jekyll_plugins"</span> <span class="o">&gt;&gt;</span> Gemfile
bundle <span class="nb">install</span>
</code></pre></div></div>

<p>Then configure <code class="language-plaintext highlighter-rouge">jekyll-postcss</code> by adding the following to your Jekyll <code class="language-plaintext highlighter-rouge">_config.yml</code>.</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _config.yml</span>
<span class="na">plugins</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">jekyll-postcss</span>

<span class="na">postcss</span><span class="pi">:</span>
  <span class="na">cache</span><span class="pi">:</span> <span class="kc">false</span> <span class="c1"># disable cache in favor of Tailwind CSS JIT engine</span>
</code></pre></div></div>

<p><a href="https://v3.tailwindcss.com/">Tailwind CSS</a> 3.x has the Just-In-Time (JIT) engine enabled by default. For more details, refer to the <a href="https://tailwindcss.com/docs/upgrade-guide#migrating-to-the-jit-engine">Tailwind CSS JIT Migration guide</a>.</p>

<h2 id="step-4-install-node-and-tailwind-css-v3">Step 4: Install Node and Tailwind CSS v3</h2>

<p>Next, install Node.js using <code class="language-plaintext highlighter-rouge">nvm</code> (recommended). Follow <a href="https://github.com/nvm-sh/nvm#installing-and-updating"><code class="language-plaintext highlighter-rouge">nvm</code> installation guide</a> to get it installed.</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Node.js LTS version</span>
nvm <span class="nb">install</span> <span class="nt">--lts</span>
</code></pre></div></div>

<p>Then install <a href="https://v3.tailwindcss.com/">Tailwind CSS</a>, <a href="https://postcss.org/">PostCSS</a>, <a href="https://cssnano.co/">cssnano</a>, and <a href="https://github.com/postcss/autoprefixer">autoprefixer</a> as dev dependencies.</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Initialize npm and install dependencies</span>
npm init <span class="nt">-y</span>
npm <span class="nb">install</span> <span class="nt">--save-dev</span> tailwindcss postcss cssnano autoprefixer
</code></pre></div></div>

<ul>
  <li><a href="https://v3.tailwindcss.com/">Tailwind CSS</a> : A utility-first CSS framework for rapid UI development.</li>
  <li><a href="https://postcss.org/">PostCSS</a> : A tool for transforming CSS with JavaScript plugins.</li>
  <li><a href="https://cssnano.co/">cssnano</a> : A PostCSS plugin to minify the final CSS output for better performance.</li>
  <li><a href="https://github.com/postcss/autoprefixer">autoprefixer</a> : A PostCSS plugin to add vendor prefixes to CSS rules for better browser support.</li>
</ul>

<h2 id="step-5-create-postcss-config">Step 5: Create PostCSS Config</h2>

<p>Now, create a <code class="language-plaintext highlighter-rouge">postcss.config.js</code> file with the following content:</p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// postcss.config.js</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">plugins</span><span class="p">:</span> <span class="p">[</span>
    <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">tailwindcss</span><span class="dl">'</span><span class="p">),</span>
    <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">autoprefixer</span><span class="dl">'</span><span class="p">),</span>
    <span class="p">...(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">JEKYLL_ENV</span> <span class="o">==</span> <span class="dl">'</span><span class="s1">production</span><span class="dl">'</span>
      <span class="p">?</span> <span class="p">[</span><span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">cssnano</span><span class="dl">'</span><span class="p">)({</span> <span class="na">preset</span><span class="p">:</span> <span class="dl">'</span><span class="s1">default</span><span class="dl">'</span> <span class="p">})]</span>
      <span class="p">:</span> <span class="p">[])</span>
  <span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="step-6-create-tailwind-v3-config">Step 6: Create Tailwind v3 Config</h2>

<p>Specify the content (Markdown and HTML files) for <a href="https://v3.tailwindcss.com/">Tailwind CSS</a> to detect the usage of CSS classes. Only the CSS classes that are used will be compiled into the output CSS file (<code class="language-plaintext highlighter-rouge">assets/css/main.css</code>).</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// tailwind.config.js</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">content</span><span class="p">:</span> <span class="p">[</span>
    <span class="dl">'</span><span class="s1">./_drafts/**/*.html</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">./_includes/**/*.html</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">./_layouts/**/*.html</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">./_posts/*.md</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">./*.md</span><span class="dl">'</span><span class="p">,</span>
    <span class="dl">'</span><span class="s1">./*.html</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">],</span>
  <span class="na">theme</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">extend</span><span class="p">:</span> <span class="p">{},</span>
  <span class="p">},</span>
  <span class="na">plugins</span><span class="p">:</span> <span class="p">[],</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="step-7-remove-jekyll-sass">Step 7: Remove Jekyll SASS</h2>

<p>Rename the default <code class="language-plaintext highlighter-rouge">scss</code> file provided by <a href="https://jekyllrb.com/">Jekyll</a> at <code class="language-plaintext highlighter-rouge">assets/css/main.scss</code> to <code class="language-plaintext highlighter-rouge">assets/css/main.css</code>. Also, change the content to the following:</p>
<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* assets/css/main.css */</span>
<span class="nt">---</span>
<span class="nt">---</span>

<span class="k">@tailwind</span> <span class="n">base</span><span class="p">;</span>
<span class="k">@tailwind</span> <span class="n">components</span><span class="p">;</span>
<span class="k">@tailwind</span> <span class="n">utilities</span><span class="p">;</span>
</code></pre></div></div>

<p>Remove the <code class="language-plaintext highlighter-rouge">_sass</code> folder in the root directory as well to entirely get rid of the SASS setup.</p>

<h2 id="step-8-start-the-jekyll-server">Step 8: Start the Jekyll Server</h2>

<p>Finally, start the <a href="https://jekyllrb.com/">Jekyll</a> server with live reload enabled.</p>

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

<p>By following these steps, you’ll have a <a href="https://jekyllrb.com/">Jekyll</a> site styled with <a href="https://v3.tailwindcss.com/">Tailwind CSS</a> at <a href="http://localhost:4000">localhost:4000</a>.</p>

<p>With <code class="language-plaintext highlighter-rouge">--livereload</code>, you can head over to the root directory and create any <code class="language-plaintext highlighter-rouge">md</code> or <code class="language-plaintext highlighter-rouge">html</code> files and start using <a href="https://v3.tailwindcss.com/">Tailwind CSS</a> classes like <code class="language-plaintext highlighter-rouge">text-blue-500</code> to see the changes reflected in real-time.</p>

<p>Enjoy building your static site!</p>]]></content><author><name>Ryan Chang</name><email>ryancyq@gmail.com</email></author><category term="ruby" /><category term="jekyll" /><category term="tailwind-css" /><summary type="html"><![CDATA[Jekyll is an awesome tool for building static sites, whether it’s for your personal blog or a hobby project. Adding Tailwind CSS 3.x into the mix makes creating your site even easier and way more visually appealing.]]></summary></entry></feed>