52 minute read

As I’ve been using Jekyll and Minimal Mistakes for this blog and added my own customizations quite a lot, I figure maybe I should release them as a standalone Gem so that others can use it painlessly. It provides or integrates more features besides everything in Jekyll and Minimal Mistakes—like dark mode toggling, drafting in Org Mode, numbering headings automatically, managing Kramdown attributes in Org files, and encrypting posts with a password, etc.

Library, Plugin and Theme

Some basic concepts to know before packaging up the codes.

The Core Difference

Technically, every Jekyll plugin is packaged and distributed as a Ruby gem, which is why you used the bundle gem command to generate the scaffolding. However, its intended use case and architecture make it a plugin rather than a general-purpose library.

  • A general Ruby library, like org-ruby or nokogiri, is framework-agnostic. You feed it inputs, and it gives you outputs. It doesn’t know anything about Jekyll, static sites, or front matter.
  • A Jekyll plugin is framework-dependent. It specifically injects itself into a host environment. Because your code uses Jekyll::Hooks.register and inherits from Jekyll::Converter, it fundamentally requires the Jekyll ecosystem to run. If someone tried to require your gem in a standard Ruby script or a Ruby on Rails application, it wouldn’t do anything because it is specifically listening for Jekyll’s build events.

In short, you are using the Ruby library packaging system, RubyGems, to distribute a Jekyll plugin.

Theme Gem

While Minimal Mistakes is packaged and distributed as a Ruby gem (which is why you can easily install it via your Gemfile), it uses a specific pattern in the Jekyll ecosystem called a “Theme Gem.” It is fundamentally a Jekyll Theme, not a standard Ruby application or a custom Jekyll Plugin.

If you observe the Minimal Mistakes repository you will find there are not the lib and spec directories, nor any .rb files. It’s because it is fundamentally a Jekyll Theme, not a standard Ruby application or a custom Jekyll Plugin.

  1. No Custom Ruby Logic

    A standard Ruby gem uses the lib/ directory to store its executable Ruby code (.rb files) and the spec/ directory to store the testing framework for that code. Minimal Mistakes does not contain any custom Ruby backend scripts. It is built entirely out of Liquid templating, HTML, Sass, and JavaScript. Because there is no raw Ruby code to execute or test, those directories are simply omitted.

  2. The Jekyll Theme Convention

    When you package a Jekyll theme as a gem, RubyGems and Jekyll look for a completely different set of files. Instead of loading code from lib/, Jekyll looks for the standard frontend directories at the root of the repository:

    _layouts/
    _includes/
    _sass/
    assets/

    When you declare theme: minimal-mistakes-jekyll in your _config.yml, Jekyll finds the gem on your computer and virtually merges those frontend folders into your local project during the build process.

  3. Reliance on External Plugins

    If a theme developer wants to add backend functionality (like pagination or sitemaps), they have two choices: write custom .rb plugins (which would require a lib/ folder) or rely on existing ones. Minimal Mistakes takes the second route. It achieves its advanced functionality by requiring established, external plugins as dependencies in its .gemspec file (such as jekyll-paginate and jekyll-include-cache).

The Loading and Deploying of Gems

In you Gemfile there is the block grouping plugins:

group :jekyll_plugins do
  gem "jekyll-paginate"
  gem "jekyll-sitemap"
  ...
end

This is a specific feature designed to automatically load Jekyll plugins during the build process.

When Jekyll boots up, it asks Bundler to find all the gems listed specifically inside the :jekyll_plugins group and requires them automatically. This saves you from having to manually type out every single plugin name into the plugins: array inside your _config.yml file.

You should put ~gem “org-ruby”~ outside the block. Here is why: org-ruby is not actually a Jekyll plugin. It is a standalone Ruby text parser. Because it doesn’t contain Jekyll-specific hooks or generators, Jekyll doesn’t need to (and can’t) automatically load it as a plugin. Instead, your custom script (org_converter.rb) is the actual plugin that manually requires and uses org-ruby under the hood. Placing it outside the group simply tells Bundler “install this gem on the system so my custom scripts can use it,” which is the exact and correct behavior for a standard dependency.

You absolutely must list a plugin in _config.yml (under the plugins: array) in two specific scenarios:

  1. When you are deploying to standard GitHub Pages

    If you are pushing your site to GitHub Pages without using a custom GitHub Actions workflow, GitHub completely ignores your Gemfile and the :jekyll_plugins group. Instead, GitHub Pages strictly reads your _config.yml file to check if the plugins you want are on their “safe whitelist” (like jekyll-paginate or jekyll-sitemap). If they aren’t listed in _config.yml, GitHub Pages will not load them.

  2. When a gem is installed globally but not in the :jekyll_plugins group

    When you run jekyll build, Jekyll automatically asks Bundler to require any gem inside the group :jekyll_plugins do block.

    However, if you have a Jekyll plugin listed normally in your Gemfile (outside of that group), Jekyll won’t know it’s supposed to hook into the build process. Adding it to the plugins: array in _config.yml forces Jekyll to execute require “that-plugin” during the boot sequence.

Why org-ruby works without being listed?

The reason org-ruby works regardless of where you put it—and without being in _config.yml —is because org-ruby is not a Jekyll plugin. It is just a standard Ruby library (a parser). Jekyll does not know what org-ruby is and never attempts to load it.

Instead, the plugin is actually your custom org_converter.rb script (which sits in your _plugins/ directory, a folder Jekyll does auto-load). Inside that script, you explicitly wrote ~require ‘org-ruby’~. When Jekyll runs your script, your script manually fetches the org-ruby library from your system.

The Rule of Thumb for Jekyll:

  • If it modifies Jekyll’s build process (adds tags, commands, generators, or converters like jekyll-sitemap or your new minimal-mistakes-plus gem): It must be required by Jekyll either via the :jekyll_plugins Gemfile group OR the _config.yml array.
  • If it is just a helper library (like nokogiri, rouge, or org-ruby): It just needs to be in your standard Gemfile so it is installed on your machine, and your custom Ruby code will manually require it when needed.

Which Should I Build, a Plugin Gem or a Theme Gem?

You should absolutely release this as a Plugin Gem.

Here is exactly why, how it fits into the broader Jekyll ecosystem, and what already exists in the wild.

Why It Must Be a Plugin Gem

In Jekyll’s architecture, there is a strict separation of concerns between presentation and build logic:

  • Theme Gems (Presentation): These are strictly for the frontend. They provide layouts, includes, CSS, and static assets. They do not execute Ruby code to change how Jekyll parses files.
  • Plugin Gems (Build Pipeline): These hook directly into Jekyll’s build lifecycle (like the :pre_render and :post_render hooks you’ve been using). They manipulate the raw text, intercept the DOM via Nokogiri, and compile different formats.

Because almost all of your features (Org Mode drafting, automatic numbering, Kramdown attribute injection, and AES post encryption) require intercepting and rewriting data during the Ruby build phase, a Theme Gem simply cannot do the job.

Can These Features Apply to Other Themes?

Yes, and this is the biggest advantage of packaging it as a Plugin Gem. By decoupling your build logic from Minimal Mistakes, your plugin becomes completely theme-agnostic.

Whether someone is using Minimal Mistakes, Chirpy, or a completely custom layout, your Ruby logic will still compile their Org files, automatically number their headings, and securely encrypt their post content before the theme ever touches the HTML.

The only feature that walks the line is the Dark Mode Toggle and the Encrypted Post Password UI. Because these rely on specific DOM elements and CSS, they are usually bundled into themes. However, you can easily ship the raw JavaScript and baseline CSS within your plugin and let users map it to their theme’s specific classes.

Do Similar Plugins Already Exist?

Some of these features exist in isolation, but your specific implementations are heavily customized to solve edge cases that the standard tools miss:

  • Org Mode Drafting: There is an official jekyll-org plugin. However, it is a very basic wrapper around org-ruby. It does not support Jekyll/Liquid front matter properly without hacks, and it completely lacks your advanced features like Kramdown attribute parsing ({: .class }), native link abbreviations, and highly optimized furigana parsing.
  • Post Encryption: There are a few plugins out there like jekyll-encrypt or jekyll-password-protect. Some rely on basic Base64 obfuscation (which isn’t secure), while others rely on external serverless functions. Your approach of handling AES encryption natively in the Ruby build step and decrypting client-side is a very solid, self-contained method.
  • Heading Numbering: Most people rely on Kramdown’s auto-TOC generation or frontend JavaScript to number headings. Doing it at the DOM level via Nokogiri in the Ruby pipeline is a much cleaner approach that prevents client-side layout shifts.
  • Dark Mode: This is almost universally handled by individual themes nowadays (themes like jekyll-theme-chirpy have it natively). Standalone dark mode plugins are rare because styling is so deeply tied to the theme’s specific Sass variables.

The Procedures

1. Scaffold the Gem

To package this up, I’m essentially building a “Jekyll Power-User Utility Belt.” Based on the previous mental discussions I’ve decided that the best approach for this gem is to create a plugin gem firmly tied with Minimal Mistakes (non-theme-agnostic), hence the name “minimal-mistakes-plus”.

  1. Use Bundler to generate the gem skeleton where standard Ruby Gem conventions apply:

    bundle gem minimal_mistakes_plus

    Make sure you use minimal_mistakes_plus instead of minimal-mistakes-plus. With the former Bundler generates a flat file structure, and with the latter it generates a deeply nested file hierarchy. See this section for more details.

  2. Rename the gem to a hyphenated name, i.e., minimal-mistakes-plus:

    1. Rename these folders and files:

      mv minimal_mistakes_plus minimal-mistakes-plus
      cd minimal-mistakes-plus/lib/
      mv minimal_mistakes_plus.rb minimal-mistakes-plus.rb
    2. Update spec.name in the file minimal_mistakes_plus.gemspec:

      spec.name = "minimal-mistakes-plus"
      spec.summary = "Write a short summary, because RubyGems requires one."
      spec.description = "Write a longer description or delete this line."
      
      spec.homepage = "https://github.com/josephtesfaye/minimal-mistakes-plus/"
      spec.required_ruby_version = ">= 3.1.0"
      
      spec.metadata["allowed_push_host"] = "https://rubygems.org"
      
      spec.metadata["source_code_uri"] = "https://github.com/josephtesfaye/minimal-mistakes-plus/"
      spec.metadata["changelog_uri"] = "https://github.com/josephtesfaye/minimal-mistakes-plus/CHANGELOG.md"

      The value of spec.name determines the final name of the gem as released to the public and required in other projects. There must be a .rb file of the same name under lib/, e.g., lib/minimal-mistakes-plus.rb.

  3. Create the file lib/minimal_mistakes_plus/hello.rb with the content:

    puts "Hello World from Minimal Mistakes Plus!"

    Then add the require statement in lib/minimal-mistakes-plus.rb:

    require_relative "minimal_mistakes_plus/hello"
  4. Add the files to Git:

    cd ~/projects/minimal-mistakes-plus/
    git add .

    Because the gemspec uses git ls-files to determine which files belong to the gem, any file that has not been tracked by Git is completely invisible to Bundler. See this section for more details.

  5. Add the gem to the Gemfile of your blog:

    group :jekyll_plugins do
      gem "minimal-mistakes-plus", path: "~/projects/minimal-mistakes-plus"
    end
  6. Run the commands:

    bundle install
    bundle exec jekyll serve

    The gem is loaded properly if you see the message:

    Hello World from Minimal Mistakes Plus!

Your final file structure will look like this beautiful, professional setup:

minimal-mistakes-plus/
├── minimal_mistakes_plus.gemspec
├── lib/
│   ├── minimal-mistakes-plus.rb        # The Bridge File Jekyll looks for
│   ├── minimal_mistakes_plus           # Folder for your actual logic
│   │   ├── version.rb
│   │   └── hello.rb

1.1. What Happens Upon First Run

If this is the first time you have ever run the bundle gem command, it will enter an interactive session where you are asked to choose certain features for the project. For example, here’s the output of an initial run:

~/projects/temp $ bundle gem minimal-mistakes-plus
Creating gem 'minimal-mistakes-plus'...
Do you want to generate tests with your gem?
Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.test`.
Enter a test framework. rspec/minitest/test-unit/(none): rspec
Do you want to set up continuous integration for your gem? Supported services:
* CircleCI:       https://circleci.com/
* GitHub Actions: https://github.com/features/actions
* GitLab CI:      https://docs.gitlab.com/ee/ci/
* Travis CI:      https://travis-ci.org/

Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.ci`.
Enter a CI service. github/travis/gitlab/circle/(none): github
Do you want to license your code permissively under the MIT license?
This means that any other developer or company will be legally allowed to use your code for free as long as they admit you created it. You can read more about the MIT license at https://choosealicense.com/licenses/mit. y/(n): y
MIT License enabled in config
Do you want to include a code of conduct in gems you generate?
Codes of conduct can increase contributions to your project by contributors who prefer collaborative, safe spaces. You can read more about the code of conduct at contributor-covenant.org. Having a code of conduct means agreeing to the responsibility of enforcing it, so be sure that you are prepared to do that. Be sure that your email address is specified as a contact in the generated code of conduct so that people know who to contact in case of a violation. For suggestions about how to enforce codes of conduct, see https://bit.ly/coc-enforcement. y/(n): y
Code of conduct enabled in config
Do you want to include a changelog?
A changelog is a file which contains a curated, chronologically ordered list of notable changes for each version of a project. To make it easier for users and contributors to see precisely what notable changes have been made between each release (or version) of the project. Whether consumers or developers, the end users of software are human beings who care about what's in the software. When the software changes, people want to know why and how. see https://keepachangelog.com y/(n): y
Changelog enabled in config
Do you want to add a code linter and formatter to your gem? Supported Linters:
* RuboCop:       https://rubocop.org
* Standard:      https://github.com/testdouble/standard

Future `bundle gem` calls will use your choice. This setting can be changed anytime with `bundle config gem.linter`.
Enter a linter. rubocop/standard/(none): rubocop
RuboCop enabled in config
Initializing git repo in /Users/josephtesfaye/projects/temp/minimal-mistakes-plus
      create  minimal-mistakes-plus/Gemfile
      create  minimal-mistakes-plus/lib/minimal/mistakes/plus.rb
      create  minimal-mistakes-plus/lib/minimal/mistakes/plus/version.rb
      create  minimal-mistakes-plus/sig/minimal/mistakes/plus.rbs
      create  minimal-mistakes-plus/minimal-mistakes-plus.gemspec
      create  minimal-mistakes-plus/Rakefile
      create  minimal-mistakes-plus/README.md
      create  minimal-mistakes-plus/bin/console
      create  minimal-mistakes-plus/bin/setup
      create  minimal-mistakes-plus/.gitignore
      create  minimal-mistakes-plus/.rspec
      create  minimal-mistakes-plus/spec/spec_helper.rb
      create  minimal-mistakes-plus/spec/minimal/mistakes/plus_spec.rb
      create  minimal-mistakes-plus/.github/workflows/main.yml
      create  minimal-mistakes-plus/LICENSE.txt
      create  minimal-mistakes-plus/CODE_OF_CONDUCT.md
      create  minimal-mistakes-plus/CHANGELOG.md
      create  minimal-mistakes-plus/.rubocop.yml
Gem 'minimal-mistakes-plus' was successfully created. For more information on making a RubyGem visit https://bundler.io/guides/creating_gem.html

Bundler saves your responses as global preferences in the configuration file ~/.bundle/config:

---
BUNDLE_GEM__TEST: "rspec"
BUNDLE_GEM__CI: "github"
BUNDLE_GEM__MIT: "true"
BUNDLE_GEM__COC: "true"
BUNDLE_GEM__CHANGELOG: "true"
BUNDLE_GEM__LINTER: "rubocop"

This suppresses the interactive session for future bundle gem commands and passes those preferences as the default.

To update a global preference, say, for the test framework to use minitest, run:

bundle config [set] gem.test minitest

But it will not retroactively generate the test files for your already-created project. Bundler only generates the boilerplate files at the exact moment you run bundle gem.

1.2. File Structure & Ruby Naming Convention

If you run bundle gem against the name minimal-mistakes-plus:

bundle gem minimal-mistakes-plus

You will notice that the hyphens in the name are treated as directory hierarchy indicators for some files in the generated file structure:

...
minimal-mistakes-plus/lib/minimal/mistakes/plus.rb
minimal-mistakes-plus/lib/minimal/mistakes/plus/version.rb
minimal-mistakes-plus/sig/minimal/mistakes/plus.rbs
...
minimal-mistakes-plus/spec/minimal/mistakes/plus_spec.rb
...

Why this happens: The Ruby Naming Convention

In the Ruby and RubyGems ecosystem, there is a strict naming convention that translates gem names into module hierarchies and directory structures. It all comes down to the difference between hyphens (-) and underscores (_):

  • Hyphens indicate namespace nesting. Bundler interprets hyphens as a signal to create nested directories and modules.

    • Your input: minimal-mistakes-plus
    • Bundler generates: lib/minimal/mistakes/plus.rb
    • Ruby Module created: Minimal::Mistakes::Plus
  • Underscores indicate word boundaries in a single namespace.

    • Your input: minimal_mistakes_plus
    • Bundler generates: lib/minimal_mistakes_plus.rb
    • Ruby Module created: MinimalMistakesPlus

Because you used hyphens, Bundler scaffolded a deeply nested directory structure assuming you wanted three levels of module hierarchy.

Should you commit these files?

These files form the foundational boilerplate of your gem. They set up the versioning, the base module, and the test environment. You will commit them once you are satisfied with the structure.

1.2.1. The Jekyll Plugin Catch

There is an important detail to consider because you are building a Jekyll plugin. When you add a plugin to Jekyll’s _config.yml like this:

plugins:
  - minimal-mistakes-plus

Jekyll will literally attempt to execute ~require “minimal-mistakes-plus”~. By default, Ruby looks inside the lib/ folder for a file named exactly minimal-mistakes-plus.rb. Because Bundler only created the nested folder structure, Jekyll won’t find the file and will crash.

To fix this, you have two options:

  • Option 1: The “Flat” Approach

    Delete the current folder and regenerate the gem using underscores. This keeps the file structure flat, which is generally easier to manage for simple Jekyll plugins.

    rm -rf minimal-mistakes-plus
    bundle gem minimal_mistakes_plus

    Your gem will be named minimal_mistakes_plus, and you will list it exactly like that in your _config.yml.

  • Option 2: The “Hyphenated” Approach

    While there are some popular Ruby gems with underscores (like awesome_print or active_record), in the Jekyll ecosystem, hyphens are the standard convention (e.g., jekyll-paginate, jekyll-sitemap, minimal-mistakes-jekyll).

    If you want the gem to be published and referenced as minimal-mistakes-plus, you can keep your current nested folder. However, you must manually create a new file named lib/minimal-mistakes-plus.rb that simply requires the nested file so Jekyll can find it (the “Bridge File” pattern):

    # Require the auto-generated version and namespace file
    require "minimal/mistakes/plus"
    
    # Require all of your custom Jekyll plugin scripts
    require "minimal_mistakes_plus/org_converter"

    To keep things clean, you don’t actually have to bury your custom Jekyll scripts three folders deep in lib/minimal/mistakes/plus/. You can create a single folder inside lib/ named minimal_mistakes_plus/ (using underscores for the folder name is a standard Ruby practice for housing secondary files belonging to a hyphenated gem).

    Your final file structure will look like this beautiful, professional setup:

    minimal-mistakes-plus/
    ├── minimal-mistakes-plus.gemspec       # The official hyphenated name
    ├── lib/
    │   ├── minimal-mistakes-plus.rb        # The Bridge File Jekyll looks for
    │   ├── minimal/
    │   │   └── mistakes/
    │   │       └── plus.rb                 # Auto-generated base module
    │   └── minimal_mistakes_plus/          # Folder for your actual logic
    │       ├── org_converter.rb
    │       ├── link_abbr.rb
    │       └── encrypt_post.rb

    This strategy gives you the best of both worlds: a standard hyphenated public gem name, strict compliance with Ruby’s module naming conventions, and perfect compatibility with Jekyll’s plugin loader.

2. Restructure the Ruby Code

Jekyll plugins distributed as Gems need to be loaded into the Jekyll environment.

  1. Move the contents of your org_converter.rb and encrypt_post.rb into the lib/minimal_mistakes_plus/ directory.
  2. In the root lib/minimal-mistakes-plus.rb file, add standard require statements to load your specific plugin files:

    require "minimal_mistakes_plus/org_converter"
    require "minimal_mistakes_plus/encrypt_post"

3. Handle the UI Assets (The Tricky Part)

Currently, encrypt_post.rb looks for _includes/secure_ui.html inside the user’s Jekyll source directory. When users install your Gem, they won’t naturally have this file. You have two elegant options to handle this:

  • Option A (The Zero-Config Approach): Refactor the Ruby script to read secure_ui.html directly from the Gem’s internal directory structure using __dir__. The user doesn’t have to copy or manage any HTML files.
  • Option B (The Overridable Approach): Keep the logic that checks the user’s _includes directory first. If the file doesn’t exist, fall back to the Gem’s internal default UI. This gives users the option to customize the prompt while keeping it plug-and-play.

4. Define Dependencies in the Gemspec

You need to ensure that anyone installing your Gem automatically gets the necessary underlying libraries. Open your minimal-mistakes-plus.gemspec and add the required dependencies:

spec.required_ruby_version = ">= 3.1.0"

spec.add_dependency "jekyll", "~> 3.9" # or your target version
spec.add_dependency "minimal-mistakes-jekyll", "~> 4.26"
spec.add_dependency "nokogiri", "~> 1.16"
spec.add_dependency "org-ruby", "~> 0.9"
spec.add_dependency "rouge", "~> 3.30"

To set the Ruby version dependency, you use the required_ruby_version attribute.

Instead of locking the gem strictly to, say, 3.1.3 (which would prevent anyone with newer versions like 3.2.0 or 3.3.0 from installing it), the standard best practice in the Ruby community is to set a minimum required version using the >= operator. If your code doesn’t rely on a feature specifically introduced in patch .3, it is very common to round down to the minor version to maximize compatibility for other users, hence the chosen version 3.1.0.

The ~~>~ operator in RubyGems is known as the pessimistic version constraint. The =”~> 3.9”= constraint means the gem requires a Jekyll version that is greater than or equal to 3.9.0, but strictly less than 4.0.0. This safely allows your gem to automatically accept newer minor and patch releases (like 3.9.3 or 3.9.5) which contain backward-compatible bug fixes and security updates, while explicitly blocking major updates (like 4.0.0) that could introduce breaking API changes.

You can omit the version string entirely, which tells RubyGems to accept any available version of the gem. While this works in the short term, it is highly discouraged for a published plugin. For example, if Jekyll releases version 4.0 or 5.0 with completely different internal methods, users installing your gem will automatically pull the newest incompatible Jekyll version, instantly breaking your plugin. Defining a version constraint ensures long-term stability.

Adding the theme gem minimal-mistakes-jekyll as a dependency is a deliberate architectural decision: it permanently locks this plugin so that it can only be used by people who also have the Minimal Mistakes theme installed. My goal is to build an exclusive “add-on” specifically for that theme, and this is the correct approach.

Can you omit the jekyll dependency declaration when you have declared dependency on minimal-mistakes-jekyll, which should have also declared dependency on it?

—No, you should never omit your direct dependency on jekyll, even if minimal-mistakes-jekyll already requires it. Here is why you must declare both:

  1. The Rule of Direct Dependencies

    In package management, there is a golden rule: If your code directly calls a library, you must explicitly declare that library as a dependency. Because your Ruby scripts explicitly use constants like Jekyll::Hooks and inherit from Jekyll::Converter, your code has a hard, direct dependency on the jekyll gem. Relying on a secondary gem (the theme) to quietly provide your primary framework is known as relying on a “transitive dependency.” This is highly discouraged because it obscures what your code actually needs to run.

  2. Version Constraint Control

    If you rely on the theme to install Jekyll, you are surrendering control over which versions of Jekyll your plugin supports.

    If minimal-mistakes-jekyll updates to require Jekyll 4.3, but your Nokogiri/Org-Ruby logic breaks on Jekyll 4.3, your plugin will break for users.

    By explicitly declaring spec.add_dependency “jekyll”, “~> 3.9”, you tell Bundler exactly what version your specific code is guaranteed to work with, preventing conflicting version installations.

Why use >= instead of ~~>~ for Ruby versions?

While the pessimistic operator (~~>~) is highly recommended for gem dependencies (like Jekyll or Nokogiri), it is generally discouraged for the Ruby language itself. If you were to use ~~> 3.1~, your gem would completely reject Ruby 3.2 and 3.3. Because newer Ruby versions are highly backwards-compatible, using >= 3.1.0 ensures your gem continues to work seamlessly as users upgrade their base environments over the years.

5. Documentation

5.1. README

Because this plugin tightly couples with Minimal Mistakes, your README will be the most critical part of the repository. You will need to explicitly document:

  1. How to add the Gem to their Gemfile and _config.yml.
  2. How to use encrypted: true and encrypted: buttitle, including setting the BLOG_PASSWORD environment variable.
  3. How to use the Org-mode features: {: .notice--warning}, ordered: true, {: .ordered}, and CUSTOM_ID.

5.2. User Manual and Demos

The detailed user manual and demos are written in a Minimal Mistakes themed site hosted on Github Pages, and the contents reside in minimal-mistakes-plus/docs/. The site is also using the gem minimal-mistakes-plus. Here’s how to do it from scratch step by step.

To set up a documentation site in the docs/ folder that uses both the Minimal Mistakes theme and your unpublished minimal-mistakes-plus gem, you need to configure a local Jekyll environment and deploy it using GitHub Actions. Standard GitHub Pages builds block custom plugins for security, so a custom Action is required.

Here is the step-by-step process from scratch:

  1. Scaffold the directory

    1. Navigate to the root of your minimal-mistakes-plus repository and create the docs/ folder.

      cd ~/projects/minimal-mistakes-plus
      mkdir docs
      cd docs
    2. Create the Gemfile

      Inside the docs/ directory, create a Gemfile. This file must explicitly link your local gem using the ~path: “../”~ parameter so Jekyll knows where to find your plugin code during local development and the GitHub Actions build.

      source "https://rubygems.org"
      
      gem "jekyll", "~> 3.9"
      gem "minimal-mistakes-jekyll"
      
      # Load your local plugin directly from the parent directory
      gem "minimal-mistakes-plus", path: "../"

      In GitHub Actions, the home directory (~) or relative paths pointing outside the repository are not accessible during CI runs. Thus a path like ~/projects/minimal-mistakes-plus/ can be used here.

    3. Create the _config.yml

      Create your Jekyll configuration file in docs/_config.yml to set the theme and activate your plugin.

      title: Minimal Mistakes Plus Documentation
      url: "https://<your-github-username>.github.io"
      baseurl: "/minimal-mistakes-plus" # Crucial for project pages on GitHub
      
      theme: minimal-mistakes-jekyll
      
      plugins:
        - minimal-mistakes-plus
  2. Create the landing page

    1. In _config.yml add:

      include:
        - _pages
    2. In _pages/ create a new file named home.md:

      ---
      layout: splash
      permalink: /
      hidden: true
      header:
        overlay_color: "#5e616c"
        actions:
          - label: "<i class='fas fa-download'></i> Install now"
            url: "/docs/quick-start-guide/"
      excerpt: >
        A flexible two-column Jekyll theme. Perfect for building personal sites, blogs, and portfolios.<br />
        <small><a href="https://github.com/mmistakes/minimal-mistakes/releases/tag/4.26.0">Latest release v4.26.0</a></small>
      ---
      
      This is the landing page...
  3. Create the “User Guide” documentation

    1. In _config.yml define a collection called docs and set the default values for pages in the collection:

      collections:
        docs:
          output: true
          permalink: /:collection/:path/
      
      defaults:
        - scope:
            path: ""
            type: docs
          values:
            layout: single
            read_time: false
            author_profile: false
            share: false
            comments: false
            toc_sticky: true
            sidebar:
              nav: "docs"
    2. Create the main menu and sidebar menus in _data/navigation.yml:

      main:
        - title: "User Guide"
          url: /docs/quick-start-guide/
      
      docs:
        - title: Getting Started
          children:
            - title: "Quick-Start Guide"
              url: /docs/quick-start-guide/
    3. Create the page _docs/01-quick-start-guide.md:

      ---
      title: "Quick-Start Guide"
      permalink: /docs/quick-start-guide/
      excerpt: "How to quickly install and setup Minimal Mistakes Plus for use with GitHub Pages."
      last_modified_at: 2026-03-15
      redirect_from:
        - /theme-setup/
      toc: true
      ---
      
      Minimal Mistakes Plus ...
  4. Test Locally

    While still inside the docs/ directory, install the dependencies and start the local server.

    bundle install
    bundle exec jekyll serve -P 4001

    Visit http://127.0.0.1:4000/minimal-mistakes-plus/ to ensure the site renders with the Minimal Mistakes layout and that your plugin loads without errors.

  5. Configure GitHub Actions for Deployment

    Because standard GitHub Pages will silently disable minimal-mistakes-plus as an “unrecognized plugin,” you must use GitHub Actions to build the site.

    1. At the root of your repository (not in docs/), create the file .github/workflows/pages.yml and paste this workflow:

      name: Build and Deploy Docs
      
      on:
        push:
          branches:
            - main
            - master
      
      permissions:
        contents: read
        pages: write
        id-token: write
      
      jobs:
        build:
          runs-on: ubuntu-latest
          steps:
            - name: Checkout
              uses: actions/checkout@v4
      
            - name: Setup Ruby
              uses: ruby/setup-ruby@v1
              with:
                ruby-version: '3.1'
                bundler-cache: true
                working-directory: docs
      
            - name: Setup Pages
              uses: actions/configure-pages@v4
      
            - name: Build with Jekyll
              working-directory: docs
              run: bundle exec jekyll build --destination ../_site
              env:
                JEKYLL_ENV: production
      
            - name: Upload artifact
              uses: actions/upload-pages-artifact@v3
              with:
                path: _site
      
        deploy:
          environment:
            name: github-pages
            url: $
          runs-on: ubuntu-latest
          needs: build
          steps:
            - name: Deploy to GitHub Pages
              id: deployment
              uses: actions/deploy-pages@v4
    2. To install dependencies correctly on GitHub Actions you need to add the platform x86_64-linux to your lockfiles (Gemfile.lock). Run the following command under both root and docs/:

      bundle lock --add-platform x86_64-linux

      Then commit the changes:

      git add Gemfile.lock docs/Gemfile.lock
      git commit -m "Add x86_64-linux platform to Gemfile.lock for CI compatibility"
      git push

      Error when failing to do this step:

      Your bundle only supports platforms ["arm64-darwin-23"] but your local platform
      is x86_64-linux.
  6. Enable GitHub Actions Deployment

    1. Go to your repository on GitHub.
    2. Click Settings > Pages.
    3. Under Build and deployment, change the Source dropdown from “Deploy from a branch” to GitHub Actions.
    4. Commit all your files and push to GitHub. The action will trigger, build your site from the docs/ directory using your parent gem, and publish it.
5.2.1. Two Gemfiles Trap

Github Actions continuously reports the following error even though you have made the changes:

Your bundle only supports platforms ["arm64-darwin-23"] but your local platform
is x86_64-linux. Add the current platform to the lockfile with
`bundle lock --add-platform x86_64-linux` and try again.
Error: The process '/opt/hostedtoolcache/Ruby/3.1.3/x64/bin/bundle' failed with exit code 16

It seems to be loading the old Gemfile.lock file because there’s no x86_64-linux in the “Set up Ruby > Print lockfile” in the job log.

This almost always happens due to the *”Two Gemfiles Trap”* that occurs when you set up a nested documentation site, or a simple missed push.

Since GitHub Actions checks out your exact repository state on every run, it never “holds onto” old files in the working directory. If the “Print lockfile” step shows the old version, the remote repository genuinely does not have the updated file in the location the Action is looking.

Here are the three steps to fix this, in order of most likely to least likely:

  1. The docs/ Directory Trap (Most Likely)

    Because we created a completely separate Jekyll environment inside the docs/ folder, your repository now has two Gemfile.lock files:

    • ~/projects/minimal-mistakes-plus/Gemfile.lock (The plugin’s lockfile)
    • ~/projects/minimal-mistakes-plus/docs/Gemfile.lock (The documentation site’s lockfile)

    Because the GitHub Action is configured with working-directory: docs, it is completely ignoring the root lockfile.

    The Fix: Ensure you run the platform command inside the docs/ directory, commit that specific file, and push:

    cd ~/projects/minimal-mistakes-plus/docs
    bundle lock --add-platform x86_64-linux
    git add Gemfile.lock
    git commit -m "Add linux platform to docs lockfile"
    git push
  2. Verify via the GitHub Web UI

    Sometimes local Git staging can be tricky. To guarantee the file actually made it to GitHub’s servers:

    1. Open your repository in your web browser.
    2. Navigate to the docs/ folder.
    3. Click on Gemfile.lock and inspect the code.
    4. If you do not see x86_64-linux under PLATFORMS, the file was either not committed or pushed to a different branch than the one triggering the Action.
  3. Clear the GitHub Actions Cache

    If you absolutely verified that the docs/Gemfile.lock on the main branch has the correct platforms via the web UI, the ruby/setup-ruby dependency cache might be aggressively restoring an old bundler state based on a broken cache key.

    The Fix:

    1. Go to your repository on GitHub.
    2. Click the Actions tab.
    3. On the left sidebar, under the “Management” section, click Caches.
    4. Click the trash can icon next to any caches listed there to force a completely fresh install on the next run.
    5. Re-run all jobs for your latest workflow.

The issue isn’t that GitHub is caching an old file. The issue is that the job failing is a completely different workflow, and your documentation deployment workflow isn’t actually running at all.

If you look closely at your repository structure, there are two distinct problems causing this:

  1. The Failing Job is the Auto-Generated Ruby Test Suite

    When you first ran bundle gem, it automatically generated a workflow file at .github/workflows/main.yml. This job triggers on every push, runs at the root of your repository, and attempts to execute your test suite (bundle exec rake).

    Because your root Gemfile.lock currently only supports arm64-darwin-23, this test job immediately crashes on GitHub’s Linux runners. It is completely ignoring the docs/Gemfile.lock where you successfully added the platform.

  2. The pages.yml Workflow is Misplaced and Misspelled

    Your deployment workflow is currently located at docs/.github/worklows/pages.yml.

    GitHub Actions strictly requires all workflow files to be located in the top-level .github/workflows/ directory of the repository. It will not scan subdirectories like docs/. Additionally, the folder name worklows is missing the “f”.

  3. The Fix: To solve this efficiently, open your terminal at the root of your project (~/projects/minimal-mistakes-plus) and run these exact commands:

    1. Fix the root lockfile so the test suite passes

      bundle lock --add-platform x86_64-linux
    2. Move the pages workflow to the correct top-level directory

      mv docs/.github/worklows/pages.yml .github/workflows/pages.yml
    3. Clean up the empty folder and push

      rm -rf docs/.github
      git add .
      git commit -m "Fix workflow locations and root lockfile platforms"
      git push

    Once pushed, if you check the Actions tab, you will now see two jobs running simultaneously: the default Ruby test suite (which will now pass) and your new Build and Deploy Docs workflow.

6. Build and Test

There are two ways to use your plugin locally, depending on whether you are still actively editing the plugin code or just want to install the finished package.

6.1. Method 1: The path approach (Best for active development)

If you are still tweaking the Ruby code inside minimal-mistakes-plus and want to test it in your blog immediately, you do not need to build the gem at all. You can tell Bundler to load the code directly from your local folder.

  1. Open the Gemfile of the target blog project (not the plugin).
  2. Add the gem using the path option, pointing to the local directory where your plugin lives:

    # Point this to the actual relative or absolute path on your machine
    gem "minimal-mistakes-plus", path: "~/projects/minimal-mistakes-plus"

    If you add it inside the group :jekyll_plugins do block, Jekyll automatically detects it and loads it during the build process. Otherwise, Jekyll treats it like a standard Ruby library (like nokogiri or org-ruby). It doesn’t know it is supposed to hook into the build process unless you explicitly add it to the plugins: array in _config.yml:

    plugins:
      - minimal-mistakes-plus
  3. Run bundle install in your blog directory.

With this method, any changes you make to the Ruby files in the plugin directory are instantly picked up the next time you run bundle exec jekyll serve on your blog.

6.1.1. Codes in lib/ Not Loaded

You have added the gem using the path option, but the codes in lib/ aren’t loaded.

This issue is extremely common when developing new gems locally. It usually comes down to a combination of how Bundler tracks files and how Jekyll loads plugins.

Here are the three things you need to check to fix this:

  1. The Git Tracking Trap (Most Likely)

    If you look closely at your minimal-mistakes-plus.gemspec file, you will see this block of code:

    spec.files = Dir.chdir(__dir__) do
      `git ls-files -z`.split("\x0").reject do |f|

    Because the gemspec uses git ls-files to determine which files belong to the gem, any file that has not been tracked by Git is completely invisible to Bundler, even when using the path: approach.

    Since you just created lib/minimal-mistakes-plus.rb and lib/minimal_mistakes_plus/org_converter.rb, they are likely untracked.

    The Fix: Open your terminal, go into your plugin directory, and simply add the files to Git:

    cd ~/projects/minimal-mistakes-plus
    git add .

    (You don’t even need to commit them yet; they just need to be staged/tracked so git ls-files can see them).

  2. Is it in Jekyll’s _config.yml?

    Adding the gem to your Gemfile tells Ruby to install the code, but it does not tell Jekyll to execute it during the site build.

    For your org_converter.rb logic to actually run, you must explicitly declare the gem as a Jekyll plugin.

    The Fix: Open _config.yml in your josephs-blog directory and ensure the gem is listed under the plugins array:

    plugins:
      - jekyll-paginate
      - jekyll-sitemap
      - minimal-mistakes-plus   # Add this line!
  3. Verify the Bridge File

    Finally, ensure that your bridge file (lib/minimal-mistakes-plus.rb) is successfully requiring your custom converter. When Jekyll reads _config.yml, it looks for this exact file and evaluates it.

    The Fix: Ensure lib/minimal-mistakes-plus.rb looks exactly like this:

    # Require the default auto-generated module
    require_relative "minimal/mistakes/plus"
    
    # Require your custom logic
    require_relative "minimal_mistakes_plus/org_converter"

    Once you have staged the files in Git, added the name to _config.yml, and verified the bridge file, run bundle exec jekyll serve in your blog directory. Jekyll will now successfully load and apply your Org Mode converter.

6.1.2. Validation Error: Gem contains itself
~/projects/josephs-blog $ bundle exec jekyll serve
bundler: failed to load command: jekyll (/Users/josephtesfaye/.gem/ruby/3.1.3/bin/jekyll)
/Users/josephtesfaye/.rubies/ruby-3.1.3/lib/ruby/3.1.0/bundler/rubygems_integration.rb:72:in `rescue in validate': The gemspec at /Users/josephtesfaye/projects/minimal-mistakes-plus/minimal-mistakes-plus.gemspec is not valid. Please fix this gemspec. (Gem::InvalidSpecificationException)
The validation error was 'minimal-mistakes-plus-0.1.0 contains itself (minimal-mistakes-plus-0.1.0.gem), check your files list'
	from /Users/josephtesfaye/.rubies/ruby-3.1.3/lib/ruby/3.1.0/bundler/rubygems_integration.rb:67:in `validate'

The error message “minimal-mistakes-plus-0.1.0 contains itself” is caused by a very specific sequence of events that just occurred.

Earlier, when you ran gem build, it created the file minimal-mistakes-plus-0.1.0.gem in your project folder. Then, when you ran git add ., Git started tracking that .gem file alongside your Ruby scripts.

Because your .gemspec uses git ls-files to decide what goes into the package, it is currently trying to pack the built .gem file inside of the new gem! RubyGems detects this infinite loop and immediately throws a validation error.

Here is how to fix it and prevent it from happening again:

  1. Remove the .gem file from Git tracking

    Open your terminal, navigate to your plugin directory, and tell Git to stop tracking any .gem files:

    cd ~/projects/minimal-mistakes-plus
    git rm --cached *.gem

    (This removes it from Git’s index, but keeps the file on your hard drive).

  2. Add .gem files to .gitignore

    To make sure this never happens again when you run git add ., open the .gitignore file in your minimal-mistakes-plus directory and add *.gem to the bottom of it:

    # .gitignore
    # ... existing ignores ...
    *.gem
  3. Try Jekyll again

    Now that the .gem file is no longer in git ls-files, your gemspec is valid again. Go back to your blog directory and start your server:

    cd ~/projects/josephs-blog
    bundle exec jekyll serve

    It should now successfully boot up and load your local plugin!

6.1.3. Load Error: uri 1.1.1 already activated

For some reason when I run bundle exec jekyll serve the error occurs:

~/projects/josephs-blog $ bundle exec jekyll serve
bundler: failed to load command: jekyll (/Users/josephtesfaye/.gem/ruby/3.1.3/bin/jekyll)
/Users/josephtesfaye/.rubies/ruby-3.1.3/lib/ruby/3.1.0/bundler/runtime.rb:308:in `check_for_activated_spec!': You have already activated uri 1.1.1, but your Gemfile requires uri 0.13.0. Prepending `bundle exec` to your command may solve this. (Gem::LoadError)
	from /Users/josephtesfaye/.rubies/ruby-3.1.3/lib/ruby/3.1.0/bundler/runtime.rb:25:in `block in setup'
	from /Users/josephtesfaye/.rubies/ruby-3.1.3/lib/ruby/3.1.0/bundler/spec_set.rb:155:in `each'

This is a notorious Bundler conflict involving Ruby’s “default gems.”

When Ruby boots up, it automatically activates its built-in, newer version of the uri gem (1.1.1). However, your blog’s Gemfile.lock is strictly demanding an older version (0.13.0). Bundler crashes because it cannot load two different versions of the same gem simultaneously, even when using bundle exec.

Here are the most efficient ways to resolve this, starting with the fastest approach:

  1. Update the locked gem (Fastest)

    You can tell Bundler to update the Gemfile.lock so it accepts the newer, already-activated version of the uri gem. Run this in your blog directory:

    bundle update uri
    bundle exec jekyll serve
  2. Rebuild the lockfile (Most Comprehensive)

    Sometimes, updating uri just reveals a daisy-chain of other default gem conflicts (like logger or date). If the first command fails with a similar error for a different gem, the best approach is to let Bundler recalculate the entire dependency tree from scratch based on your current Ruby environment.

    rm Gemfile.lock
    bundle install
    bundle exec jekyll serve

6.2. Method 2: Build and Install locally (Best for final testing)

If you want to simulate exactly how it will behave once published, you can compile the source code into a .gem file and install it directly into your local machine’s Ruby environment.

  1. Open your terminal and navigate to your plugin directory.
  2. Build the gem using the gemspec file:

    gem build minimal-mistakes-plus.gemspec

    This will generate a compiled file in that folder, e.g., minimal-mistakes-plus-0.1.0.gem.

  3. Install that specific file locally:

    gem install ./minimal-mistakes-plus-0.1.0.gem
  4. Now, go to your target blog project. You can add it to the Gemfile exactly as if you had downloaded it from the internet:

    gem "minimal-mistakes-plus"
  5. Run bundle install to link it.

Note: If you use Method 2 and later change the plugin’s code, you must increment the version number in lib/minimal/mistakes/plus/version.rb, run gem build again, and run gem install again for the changes to take effect.

6.3. The Command bundle exec rake

The command bundle exec rake is essentially the master execution switch for running your project’s automated tests and code quality checks.

To understand exactly what it does, we can break it down into two parts:

  1. bundle exec

    This prefix tells your terminal, “Do not use the global Ruby gems installed on my computer. Instead, strictly execute the following command using the exact gem versions locked inside this project’s Gemfile.lock.” This prevents version conflicts and ensures your code runs exactly the same way on your local machine as it does on the GitHub Actions servers.

  2. rake

    Rake is Ruby’s built-in task runner (similar to make in C or npm run in Node.js). When you type rake without any arguments, it automatically looks for a file named Rakefile in your project’s root directory and runs whatever is set as the default task.

What it does in your specific project

If we look at the Rakefile generated for your minimal-mistakes-plus gem, you will see this line at the very bottom:

task default: %i[spec rubocop]

This means that when GitHub Actions (or you locally) runs bundle exec rake, it is sequentially executing two distinct tasks:

  1. spec (RSpec): It boots up the RSpec testing framework, looks inside your spec/ directory, and runs all the automated Ruby tests you have written to ensure your plugin logic (like org_converter.rb) works correctly.
  2. rubocop: If all the tests pass, it then boots up RuboCop (a Ruby static code analyzer). RuboCop scans every .rb file in your project and checks it against the style rules defined in your .rubocop.yml file to ensure your code is cleanly formatted.

If either the RSpec tests fail or RuboCop finds formatting violations, the rake command returns an error code, which causes the GitHub Actions main.yml workflow to display a red “Failed” badge.

6.4. How to disable rubocop for the whole project?

To completely disable RuboCop for the whole project and stop it from failing your GitHub Actions build, you need to remove it from your default test execution and remove its dependencies.

Here is the fastest and most comprehensive approach to rip it out of the project entirely:

  1. Remove it from the Rakefile

    The reason bundle exec rake runs RuboCop is because your Rakefile explicitly defines it as a default task alongside your RSpec tests.

    Open your Rakefile and delete the RuboCop configuration:

    @@ -5,8 +5,4 @@ require "rspec/core/rake_task"
    
     RSpec::Core::RakeTask.new(:spec)
    
    -require "rubocop/rake_task"
    -
    -RuboCop::RakeTask.new
    -
    -task default: %i[spec rubocop]
    +task default: :spec
  2. Remove the Gem Dependency

    Open your Gemfile and delete this line at the bottom:

    gem "rubocop", "~> 1.21"

    After saving the file, run this command in your terminal to update your lockfile and clean up the dependencies:

    bundle install
  3. Delete the Configuration File

    Finally, delete the .rubocop.yml file from the root of your project since it is no longer needed.

  4. Commit and Push

    Commit these changes and push them to GitHub.

Now, when GitHub Actions runs bundle exec rake, it will only execute your RSpec tests and will completely ignore style checking.

6.5. Use Your Unpublished Gem on GitHub Actions

To use your unpublished gem across different repositories on GitHub Actions, you must stop using the local path: declaration. The GitHub runner building your blog will not have access to your local hard drive or parent directories.

Instead, you can instruct Bundler to download the plugin directly from its Git repository during the build process.

Here is the exact workflow:

  1. Update your Blog’s Gemfile

    Open the Gemfile in your josephs-blog repository. Replace the local path reference with the github: keyword, pointing to your plugin’s repository.

    -gem "minimal-mistakes-plus", path: "~/projects/minimal-mistakes-plus"
    +gem "minimal-mistakes-plus", github: "josephtesfaye/minimal-mistakes-plus", branch: "main"

    Note: You can also lock it to a specific release tag if you prefer stability over bleeding-edge updates by using ~tag: “v0.1.0”~ instead of ~branch: “main”~.

  2. Update the Lockfile Locally

    Because you changed the source from a local file path to a remote Git repository, you must recalculate the lockfile before pushing.

    Run this in your josephs-blog terminal:

    bundle install

    You will see output indicating that Bundler is fetching the gem from GitHub.

  3. Commit and Push

    Commit the updated Gemfile and Gemfile.lock to your blog’s repository and push to GitHub.

    git add Gemfile Gemfile.lock
    git commit -m "Use minimal-mistakes-plus directly from GitHub"
    git push
6.5.1. How GitHub Actions Handles This

As long as your minimal-mistakes-plus repository is public, your existing GitHub Actions workflow for josephs-blog does not need to change. When the ruby/setup-ruby step runs bundle install, it will read the GitHub source from the Gemfile, seamlessly clone the plugin repository into its cache, and execute your custom Ruby logic.

If your plugin repository is currently set to Private, the Action will fail with an authentication error. You would either need to make the plugin repository public, or generate a GitHub Personal Access Token and pass it to the Action via the BUNDLE_GITHUB__COM environment variable.

6.5.2. What to Do When the Plugin is Updated

It seems bundle install in GitHub Actions’ build_and_deploy workflow of josephs-blog doesn’t pull the latest code from minimal-mistakes-plus automatically with the setup:

gem "minimal-mistakes-plus", github: "josephtesfaye/minimal-mistakes-plus", branch: "main"

It still uses the old commit as indicated by the line:

Using minimal-mistakes-plus 0.1.0 from https://github.com/josephtesfaye/minimal-mistakes-plus.git (at main@70a94f2)

which is the same as shown in Gemfile.lock of josephs-blog. Does this mean I have to run bundle install and commit the updated Gemfile.lock in order to use the latest code? Can you make it pull in the latest code automatically?

—Yes, you hit the nail on the head. This is your Gemfile.lock doing exactly what it was designed to do: guarantee completely deterministic, repeatable builds.

When you first added the github: path and ran bundle install locally, Bundler reached out to GitHub, found the latest commit on main (which happened to be 70a94f2), and hardcoded that exact SHA hash into your Gemfile.lock.

Because GitHub Actions strictly respects the lockfile, it will download that exact commit every single time, completely ignoring any new code you push to the plugin’s repository.

  • Option 1: The “Standard” Way (Manual Update)

    In the Ruby ecosystem, the standard best practice is indeed to manually update the lockfile and commit it. This ensures that if you introduce a breaking change to your plugin, it doesn’t spontaneously crash your blog’s automated daily build.

    To do this locally, you don’t just run bundle install. You must explicitly tell Bundler to update that specific gem’s locked commit:

    bundle update minimal-mistakes-plus
    git add Gemfile.lock
    git commit -m "Bump plugin to latest commit"
    git push
  • Option 2: The “Bleeding Edge” Way (Automated CI Update)

    If you want your blog to always build with the absolute latest commit from the main branch of your plugin without you having to touch the lockfile, you can force GitHub Actions to update it on the fly.

    You simply need to add a bundle update step to your jekyll.yml workflow right after the Ruby setup step.

    Here is what you need to change in josephs-blog/.github/workflows/jekyll.yml:

    --- .github/workflows/jekyll.yml
    +++ .github/workflows/jekyll.yml
    @@ -25,6 +25,10 @@
               ruby-version: '3.1'
               bundler-cache: true
               cache-version: 0
    +
    +      - name: Force pull latest plugin code
    +        run: bundle update minimal-mistakes-plus
    +
           - name: Setup Pages
             id: pages
             uses: actions/configure-pages@v4

    How this works: The ruby/setup-ruby step will quickly install all your standard gems using the cached Gemfile.lock. Immediately after, your new step runs. Bundler will reach out to GitHub, see that minimal-mistakes-plus has a new commit on main, download the fresh code, and temporarily overwrite the lockfile in the GitHub runner’s memory just in time for the Jekyll build step.


But why can the latest code be pulled in and the Gemfile.lock be updated automatically when running bundle install locally? What’s the difference of the command between running locally and running on GitHub Actions? How is it done?

—Your premise is actually experiencing a slight illusion! Running bundle install locally does not automatically pull the latest GitHub commit if a lockfile already exists.

Here is exactly what is happening under the hood, and the strict difference between your local machine and GitHub Actions.

  1. The Local Illusion

    If you push a new commit to your plugin repository right now, and then run bundle install locally without touching your Gemfile, Bundler will not pull the new code. It will stubbornly stay locked to the old commit.

    The reason it seemed to update automatically for you locally recently is due to two specific events:

    • The Source Switch: When you edited your Gemfile to change path: to github:, Bundler detected that the file was modified. Whenever the Gemfile changes, Bundler is forced to recalculate the dependencies. It reached out to GitHub, grabbed the latest commit on the main branch at that exact second, and wrote it to Gemfile.lock.
    • The Local Override: Earlier, you configured your local environment to route all requests for the gem to your local folder (see Local Git Overrides). Because of this, your local Bundler is completely ignoring the GitHub commit hash inside your Gemfile.lock and reading the live files straight from your hard drive. This makes it feel like it is instantly updating, but it isn’t even talking to GitHub!
  2. The GitHub Actions Reality (Deployment Mode)

    In your GitHub Actions workflow (.github/workflows/jekyll.yml), you are using the official ruby/setup-ruby action with the setting bundler-cache: true.

    When that setting is enabled, it completely changes how Bundler operates. It forces Bundler into Deployment Mode (which is the equivalent of running bundle install --deployment).

    In Deployment Mode, Bundler operates under strict, immutable rules:

    1. It is physically forbidden from modifying Gemfile.lock.
    2. It is forbidden from resolving new dependencies or checking for newer branch commits.
    3. It strictly reads the exact 40-character SHA commit hash saved in Gemfile.lock and downloads that exact snapshot from GitHub.

    This feature exists because production servers need absolute stability. If you or a contributor accidentally pushed a broken commit to the plugin’s main branch, Deployment Mode ensures your blog’s automated build doesn’t spontaneously download the broken code and crash.

The Rule of Thumb

  • Locally: bundle install only updates the lockfile if you change the text inside the Gemfile. Otherwise, it respects the lockfile (unless bypassed by your local path override).
  • GitHub Actions: bundle install runs in Deployment Mode, which strictly enforces the lockfile and refuses to update anything.
  • The Command: If you want to force Bundler to fetch the latest commit on a branch without changing the Gemfile, the required command in the Ruby ecosystem is bundle update minimal-mistakes-plus.

When I run bundle update minimal-mistakes-plus all the other gems are also updated. Is this right?

—No, that is not the intended behavior for a targeted update, but it is a very common side effect of how Bundler resolves dependency trees.

When you run bundle update minimal-mistakes-plus, Bundler does not just pull the latest commit from GitHub. It also evaluates all the dependencies declared in your plugin’s gemspec (such as jekyll, nokogiri, or rouge). If newer versions of those underlying gems are available and allowed by your version constraints, Bundler will upgrade them, which creates a ripple effect that updates other gems across your entire project.

The fastest, most efficient, and best approach to pull the latest GitHub commit without touching a single other gem is to use the source flag:

bundle update --source minimal-mistakes-plus

This strict command tells Bundler to only fetch the new commits from that specific Git source and deliberately ignore any potential version bumps for its dependencies.

You should also swap this into your GitHub Actions jekyll.yml workflow. Using bundle update --source minimal-mistakes-plus guarantees that your automated builds get your latest plugin code while keeping the rest of your blog’s build environment perfectly locked and stable.

6.5.3. Use Local Git Overrides for Efficient Development

When your Gemfile strictly says ~github: “josephtesfaye/minimal-mistakes-plus”~, Bundler will reach out to GitHub and download the gem to your global cache every time you run bundle install, even when you are working on your local machine.

But you do not need to manually switch back and forth between path: and github:. Bundler has a built-in feature specifically designed to solve this exact problem for active gem development.

It is called Local Git Overrides. You can tell your local Bundler environment to intercept any requests for the GitHub URL and route them to your local folder instead, without ever changing the Gemfile.

Here is how to set this up:

  1. Keep the GitHub link in your Gemfile

    Leave your Gemfile exactly like this so GitHub Actions always works:

    gem "minimal-mistakes-plus", github: "josephtesfaye/minimal-mistakes-plus", branch: "main"
  2. Configure the Local Override

    Open your terminal inside your josephs-blog directory and run this command (adjust the path to point to your actual local plugin directory):

    bundle config set --local local.minimal-mistakes-plus ~/projects/minimal-mistakes-plus

    This configuration is stored in ~/josephs-blog/.bundle/config. You should never commit the .bundle/config file to your Git repository. Here is exactly why, especially in your current setup:

    1. It contains machine-specific paths: Right now, that file holds the local override you just configured (~~/projects/minimal-mistakes-plus~). That specific folder structure only exists on your personal computer.
    2. It will immediately break GitHub Actions: If you commit and push that file, the GitHub Actions runner will read it and attempt to install the gem from the local path ~/projects/minimal-mistakes-plus instead of downloading it from GitHub. Because that directory does not exist on the isolated Linux build server, your deployment workflow will crash.
    3. It overrides standard behavior: The entire purpose of the .bundle/config file is to store temporary, developer-specific configurations that should not affect anyone else (or any automated system) cloning the repository.
  3. How it Works

    Now, when you run bundle install or bundle exec jekyll serve locally, Bundler sees the github: declaration, but your local config immediately overrides it. It will use the live files straight from ~/projects/minimal-mistakes-plus.

    Any changes you make to your local plugin’s Ruby code will instantly be picked up the next time you restart the Jekyll server, just as if you had used path:.

    Note: For the override to work, your local plugin repository must be on the same branch specified in the Gemfile, which is main in this case.

How to turn it off later

Once the gem is completely finished and you want your local machine to go back to pulling the official code from GitHub, simply run:

bundle config unset local.minimal-mistakes-plus

7. Publish

The setting spec.metadata["allowed_push_host"] in the .gemspec file is a security feature built into Bundler and RubyGems. Its purpose is to prevent you from accidentally publishing a private, internal gem to the public internet.

When you run the command rake release or gem push, RubyGems checks this URL. It will strictly refuse to upload your code to any server other than the one listed here.

Here is how you should set it based on your goals for minimal-mistakes-plus:

  • Option 1: You want to publish it publicly

    If your goal is to share this plugin with the world so anyone can add it to their Gemfile, you should set it to the official public RubyGems server.

    spec.metadata["allowed_push_host"] = "https://rubygems.org"
  • Option 2: You want to keep it private (GitHub Packages)

    If you only want to use this gem for your own projects and do not want it available to the public, you can host it privately on GitHub.

    You would change the URL to point to GitHub’s package registry (replace YOUR_GITHUB_USERNAME with your actual username):

    spec.metadata["allowed_push_host"] = "https://rubygems.pkg.github.com/YOUR_GITHUB_USERNAME"
  • Option 3: You don’t want to publish it anywhere at all

    If you only plan to use this gem locally on your own computer (e.g., by pointing to its local folder path in your blog’s Gemfile), you can safely delete that line.

  1. Push it to RubyGems: gem push minimal-mistakes-plus-0.1.0.gem

Feature Migration

Dark Mode

I want to maintain all of the the original dark-mode-related setups used in josephs-blog. For example, dark_mode_toggle: true should control whether dark mode toggle is enabled for the site, and the dark mode styles should be same as defined in _sass/custom/dark_mode_toggle.scss. This article recorded how this feature was implemented previously for this blog. Here are the steps to migrate to minimal-mistakes-plus with minimal changes, with the final result being very performant.

To make this highly performant, we will avoid parsing every single HTML page with Nokogiri (which slows down build times). Instead, we will use Jekyll’s native hooks to seamlessly add your plugin’s _sass folder to the global load path, and use a lightning-fast Regex string replacement to inject the button and script only when dark_mode_toggle: true is enabled.

  1. Transfer the Assets to the Plugin

    Navigate to your minimal-mistakes-plus directory and recreate the asset structure:

    1. Create the directories:

      cd ~/projects/minimal-mistakes-plus/
      mkdir -p _sass/minimal_mistakes_plus
      mkdir -p assets/js
      mkdir -p _includes/head
    2. Copy the files:

      cd ~/projects/josephs-blog/
      cp _sass/custom/dark_mode_toggle.scss ~/projects/minimal-mistakes-plus/_sass/minimal_mistakes_plus/
      cp assets/js/dark_mode_toggle.js ~/projects/minimal-mistakes-plus/assets/js/
      cp _includes/masthead.html ~/projects/minimal-mistakes-plus/_includes/
      cp _includes/head/custom.html ~/projects/minimal-mistakes-plus/_includes/head/
  2. Create the Ruby Integrator

    Create a new file in your plugin at lib/minimal_mistakes_plus/dark_mode.rb and add this code:

    module MinimalMistakesPlus
      # 1. Add the plugin's _sass directory to Jekyll's global Sass load path
      Jekyll::Hooks.register :site, :after_init do |site|
        sass_dir = File.expand_path("../../_sass", __dir__)
        site.config['sass'] ||= {}
        site.config['sass']['load_paths'] ||= []
        site.config['sass']['load_paths'] << sass_dir unless
          site.config['sass']['load_paths'].include?(sass_dir)
    
        # Inject the plugin's _includes directory to natively override the theme
        includes_dir = File.expand_path("../../_includes", __dir__)
        site.includes_load_paths.insert(1, includes_dir) unless
          site.includes_load_paths.include?(includes_dir)
      end
    
      # 2. Register the JS file to be automatically copied to _site/assets/js/
      Jekyll::Hooks.register :site, :post_read do |site|
        next unless site.config['dark_mode_toggle']
        gem_dir = File.expand_path("../../", __dir__)
        site.static_files << Jekyll::StaticFile.new(site, gem_dir, 'assets/js', 'dark_mode_toggle.js')
      end
    end
  3. Require the Integrator

    Update your bridge file (lib/minimal-mistakes-plus.rb) to load the new logic:

    require_relative "minimal_mistakes_plus/dark_mode"
  4. Clean up the Blog (josephs-blog)

    Now that the plugin intelligently manages the file routing and HTML injection natively, you can clean out the overridden files in your blog repository.

    1. Delete these files:

      rm ~/projects/josephs-blog/_sass/custom/dark_mode_toggle.scss
      rm ~/projects/josephs-blog/assets/js/dark_mode_toggle.js

      Jekyll will now safely fall back to the upstream Minimal Mistakes masthead, and your plugin will magically inject the button.

    2. For the _includes/masthead.html and _includes/head/custom.html files:

      You can still keep them in the project but then they will completely override the same files in both the theme and the plugin. In this case you need to add the dark-mode-related snippets to them to properly enable dark mode toggling. See ~/projects/minimal-mistakes-plus/docs/_docs/02-dark-mode.org.

      If they don’t have customizations exclusive to the blog you can delete them. Then the ones in the plugin will be used.

    3. Update or Create assets/css/main.scss: Change the SCSS @import path to point to your new plugin namespace:

      ---
      # Only the main Sass file needs front matter (the dashes are enough)
      ---
      
      @charset "utf-8";
      
      @import "minimal-mistakes/skins/default"; // skin
      @import "minimal-mistakes"; // main partials
      
      
      @import "minimal_mistakes_plus/dark_mode_toggle";

      You can delete this file if you’ve also migrated it to the plugin. See Custom Styles.

With this setup, your plugin handles 100% of the dark mode logic, you get to keep your exact original source code, and your blog’s source code shrinks down drastically.

Custom Styles

We can hand over the style sheet assets/css/main.scss to the plugin so that we don’t need to copy it around for new projects.

We can use an incredibly performant Jekyll trick: In-Memory SCSS Middleware. Instead of forcing users to copy a physical file, we can write a tiny Ruby script that intercepts the default main.scss file provided by the upstream Minimal Mistakes theme while Jekyll holds it in memory. We can then dynamically inject your variable overrides and SCSS imports right before the Sass compiler runs.

This completely eliminates the need for any physical configuration files in new projects. Here is the exact setup.

  1. Create the Plugin’s Main SCSS File

    Inside your minimal-mistakes-plus repository, create a new file at _sass/minimal_mistakes_plus/main.scss.

    Copy all of your actual CSS rules from your old file into this one. Do not include the front matter (dashes at the top), the charset, or the Liquid dark mode statements. Remove these lines:

    ----
    -# Only the main Sass file needs front matter (the dashes are enough)
    ----
    -
    -@charset "utf-8";
    -
    -$code-background-color: mix(#fff, #93E0E3, 50%);
    -
    -@import "minimal-mistakes/skins/default"; // skin
    -@import "minimal-mistakes"; // main partials
  2. Create a Variables File

    Inside your plugin, create a new file named _sass/minimal_mistakes_plus/_variables.scss. Place your $code-background-color override (and any future variables) inside it:

    $code-background-color: mix(#fff, #93E0E3, 50%);

    Whenever you want to override a default Minimal Mistakes variable (like $primary-color, $text-color, or $border-radius), you simply drop it into _variables.scss.

  3. Create the Style Injector Hook

    Inside your plugin, create a new file at lib/minimal_mistakes_plus/style_injector.rb. This script will safely mutate the base theme’s SCSS file in memory:

    module MinimalMistakesPlus
      Jekyll::Hooks.register :site, :post_read do |site|
        # Locate the default main.scss file loaded by the Minimal Mistakes theme
        main_scss = site.pages.find { |page| page.name == 'main.scss' && page.dir == '/assets/css/' }
    
        if main_scss
          # 1. Inject the variables partial right BEFORE the theme imports its skins
          unless main_scss.content.include?('minimal_mistakes_plus/variables')
            main_scss.content = main_scss.content.sub(
              /(@import "minimal-mistakes\/skins)/,
              "@import \"minimal_mistakes_plus/variables\";\n\1"
            )
          end
    
          # 2. Dynamically import dark mode (This replaces your old Liquid tags!)
          if site.config['dark_mode_toggle'] && !main_scss.content.include?('minimal_mistakes_plus/dark_mode_toggle')
            main_scss.content = main_scss.content.sub(
              /(@import "minimal-mistakes";?)/,
              "\1\n@import \"minimal_mistakes_plus/dark_mode_toggle\";"
            )
          end
    
          # 3. Inject our plugin's core layout modifications after the base theme imports
          unless main_scss.content.include?('minimal_mistakes_plus/main')
            main_scss.content = main_scss.content.sub(
              /(@import "minimal-mistakes";?)/,
              "\1\n@import \"minimal_mistakes_plus/main\";"
            )
          end
        end
      end
    end

    We inject the plugin styles (minimal_mistakes_plus/main) immediately after the base theme imports, leaving the entire rest of your local file (josephs-blog/assets/css/main.scss) untouched so your custom styles cascade last. In this way you can override the plugin styles exclusively for a project.

  4. Require the Injector

    Add the new file to your bridge file lib/minimal-mistakes-plus.rb:

    require_relative "minimal_mistakes_plus/style_injector"
  5. The Final Cleanup

    Because the plugin now handles 100% of the CSS rendering and configuration automatically, you can delete the assets/css/main.scss file from both your josephs-blog repository and your plugin’s docs/ directory.

Going forward, any Jekyll site that includes ~gem “minimal-mistakes-plus”~ will instantly inherit your grid layouts, rotating ordered lists, TOC mobile styling, and dark mode!

You can still create a local assets/css/main.scss in your project, which overwrites the base theme completely and the plugin styles partially. To ensure the theme and the plugin injector can function properly you must preserve the base theme imports in it:

---
# Only the main Sass file needs front matter (the dashes are enough)
---

@charset "utf-8";

@import "minimal-mistakes/skins/default"; // skin
@import "minimal-mistakes"; // main partials
  1. Move link_abbr.rb to the plugin:

    mv ~/projects/josephs-blog/_plugins/link_abbr.rb ~/projects/minimal-mistakes-plus/lib/minimal_mistakes_plus/
  2. Require the code:

    require_relative "minimal_mistakes_plus/link_abbr"
  3. Optimize code:

    Rewrite the whole link_abbr.rb so that it handling code of Org files and Markdown files are decoupled, rather than being tangled together.

    To be more specific, if a file is an .org file, only expand the link abbreviations in the standard Org links; if it’s a markdown (.md, .markdown, etc.) only expand the link abbreviations in the standard markdown links. In both cases, always expand the link abbreviations used in a gallery in the front matter.

    gallery:
      - url: foo:unsplash-gallery-image-1.jpg
        image_path: foo:unsplash-gallery-image-1.jpg
        title: Check this [[foo:unsplash-gallery-image-1.jpg][image]]
      - image_path: foo:unsplash-gallery-image-1.jpg
      - foo:unsplash-gallery-image-1.jpg
      - image_path: [[foo:unsplash-gallery-image-1.jpg]]
        title: An image referenced through an org link
      - [[foo:unsplash-gallery-image-1.jpg]]
      - [[foo:unsplash-gallery-image-1.jpg]]

    But again, don’t expand links like [[foo:unsplash-gallery-image-1.jpg]] in a markdown file even if they are in a gallery in the front matter because they are Org Links. Only expand Org links in Org files.

    In all cases, link abbreviations in literal blocks (including inline blocks) should never be expanded.

    The final code should be very performant.

  4. The code.

Related articles:

  1. Move the assets to the plugin:

    mv ~/projects/josephs-blog/_includes/gallery ~/projects/minimal-mistakes-plus/_includes/
    mv ~/projects/josephs-blog/_plugins/liquify.rb ~/projects/minimal-mistakes-plus/lib/minimal_mistakes_plus/
    mv ~/projects/josephs-blog/_plugins/symlink_external_assets.rb ~/projects/minimal-mistakes-plus/lib/minimal_mistakes_plus/
    mv ~/projects/josephs-blog/_plugins/encrypt_post.rb ~/projects/minimal-mistakes-plus/lib/minimal_mistakes_plus/
  2. Require the codes:

    require_relative "minimal_mistakes_plus/liquify"
    require_relative "minimal_mistakes_plus/symlink_external_assets"
    require_relative "minimal_mistakes_plus/encrypt_post"

Comments