:bulb: This is the second part of the article. The previous part can be found here.

Gemify the plugin

Creating a gem

Now it’s time for us to start gemifying the plugin to make it easily shareable and to separate the development of it from our main project.

Navigate outside of the test_project dir and create a new gem beside of the project’s directory:

$ cd ..
$ bundle gem jekyll_metrics

You should now have two directories placed beside of each other.

$ ls
$ jekyll_metrics  test_project

Navigate to the plugin’s dir:

$ cd jekyll_metrics
$ ls
$ bin  CODE_OF_CONDUCT.md  Gemfile  jekyll_metrics.gemspec  lib  LICENSE.txt  Rakefile  README.md  spec

In short, the bin folder is one for executables (it may be removed as we won’t use it at all), spec - for tests aka specification, while the lib directory is for our main library code.

The main specification of our plugin is defined in the jekyll_metrics.gemspec file. Let’s make it look like following:

jekyll_metrics.gemspec
require_relative 'lib/jekyll_metrics/version'

Gem::Specification.new do |spec|
  spec.name          = 'jekyll_metrics'
  spec.version       = JekyllMetrics::VERSION
  spec.authors       = ['Ivan Zinovyev']
  spec.email         = ['ivan@zinovyev.net']

  spec.summary       = 'Metrics plugin for Jekyll'
  spec.description   = 'Metrics plugin for Jekyll. Supports Yandex Metrics and Google Analytics out of the box.'
  spec.homepage      = 'https://github.com/zinovyev/jekyll_metrics'
  spec.license       = 'MIT'
  spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')

  spec.metadata['homepage_uri'] = spec.homepage
  spec.metadata['source_code_uri'] = spec.homepage

  spec.files            = Dir['lib/**/*']
  spec.extra_rdoc_files = Dir['README.md', 'LICENSE.txt']
  spec.require_paths    = ['lib']

  spec.add_dependency 'jekyll', '>= 3.7', '< 5.0'
  spec.add_development_dependency 'bundler'
  spec.add_development_dependency 'rake'
  spec.add_development_dependency 'rspec'
end

The purpose of this file is to describe how to load gem files, dependencies and also to add some metadata for the project. Just replace the name, authors, email and the homepage fields with your own values.

Now let’s move the main logic of our plugin to the gem. In the lib folder there should be a file called jekyll_metrics.rb with the following content:

lib/jekyll_metrics.rb
require "jekyll_metrics/version"

module JekyllMetrics
  class Error < StandardError; end
  # Your code goes here...
end

Let’s copy our plugin’s code there:

lib/jekyll_metrics.rb
require "jekyll_metrics/version"

module JekyllMetrics
  class Error < StandardError; end
  # Your code goes here...
end

# Main metrics plugin class
class JekyllMetrics::Hook
  CONFIG_NAME = 'jekyll_metrics'.freeze
  DEFAULT_CONFIG = {
    'template' => '_includes/metrics.html',
    'google_analytics_id' => 'XX-XXXXXXXXX-X',
    'yandex_metrica_id' => 'XXXXXXXX'
  }.freeze

  attr_accessor :page

  def initialize(page)
    @page = page
    yield(@page, self) if block_given? && injectable?
  end

  def injectable?
    ['Jekyll::Document', 'Jekyll::Page'].include?(page.class.name) || page.write? &&
      (page.output_ext == '.html' || page.permalink&.end_with?('/'))
  end

  def inject_scripts
    page.output.gsub!(%r{<\/head>}, load_scripts)
  end

  def prepare_scripts_for(closing_tag)
    [load_scripts, "</#{closing_tag}>"].compact.join("\n")
  end

  def load_scripts
    render_template(File.read(metrics_template))
  end

  def render_template(file)
    Liquid::Template.parse(file).render(plugin_config)
  end

  def metrics_template
    root_path.join(plugin_config['template'])
  end

  def root_path
    Pathname.new(site.source)
  end

  def plugin_config
    @plugin_config ||= DEFAULT_CONFIG.merge(config[CONFIG_NAME].to_h)
  end

  def config
    site.config
  end

  def site
    page.site
  end
end

# Register the hook
Jekyll::Hooks.register [:documents, :pages], :post_render do |page|
  JekyllMetrics::Hook.new(page) { |_page, metrics| metrics.inject_scripts }
end

Loading gem into the project

Now go back to the test_project directory and modify the Gemfile to make the plugin be loaded from the separate gem. Just add this line to the bottom of it:

Gemfile
  source "https://rubygems.org"
  # Hello! This is where you manage which Jekyll version is used to run.
  # When you want to use a different version, change it below, save the
  # file and run `bundle install`. Run Jekyll with `bundle exec`, like so:
  ..
+ gem 'jekyll_metrics', path: '../jekyll_metrics'

You will also enable the plugin in the _config.yml file in the project’s directory. Add it to the plugins section (probably there will also be the jekyll-feed plugin already initiated if you’re using the standard edition of jekyll.

_config.yml
 # Build settings
 theme: minima
 plugins:
   - jekyll-feed
+   - jekyll_metrics

Remove the _plugins directory cause we don’t need it anymore. And install the plugin with bundle:

$ rm -rf _plugins
$ bundle install
The dependency tzinfo (~> 1.2) will be unused by any of the platforms Bundler is installing for. Bundler is installing for ruby but the dependency is only for x86-mingw32, x64-mingw32, x86-mswin32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x64-mingw32 x86-mswin32 java`.
...
Bundle complete! 8 Gemfile dependencies, 35 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

Reload the page. The metrics code should be still there.

Refactoring

Let’s do some refactoring. The gem structure will should look in that way afterwards:

├── CODE_OF_CONDUCT.md
├── Gemfile
├── Gemfile.lock
├── jekyll_metrics.gemspec
├── lib
│   ├── jekyll_metrics
│   │   ├── config.rb
│   │   ├── hook.rb
│   │   ├── includes
│   │   │   └── metrics.html.liquid
│   │   └── version.rb
│   └── jekyll_metrics.rb
├── LICENSE.txt
├── Rakefile
├── README.md
└── spec
    ├── jekyll_metrics_spec.rb
    ├── lib
    │   ├── config_spec.rb
    │   └── hook_spec.rb
    └── spec_helper.rb

First of all we’ll split the main plugin class into two files: lib/jekyll_metrics/config.rb and lib/jekyll_metrics/hook.rb so the code will be better maintanable and easy to read.

lib/jekyll_metrics/hook.rb
module JekyllMetrics
  # Compile metrics template and inject it into the page code
  class Hook
    attr_accessor :page

    def initialize(page)
      @page = page
      yield(@page, self) if block_given? && injectable?
    end

    def injectable?
      ['Jekyll::Document', 'Jekyll::Page'].include?(page.class.name) || page.write? &&
        (page.output_ext == '.html' || page.permalink&.end_with?('/'))
    end

    def inject_scripts
      page.output.gsub!(%r{<\/head>}, load_scripts)
    end

    private

    def prepare_scripts_for(closing_tag)
      [load_scripts, "</#{closing_tag}>"].compact.join("\n")
    end

    def load_scripts
      verify_path!(config.template_path)

      render_template(File.read(config.template_path))
    end

    def verify_path!(path)
      return if path && File.exist?(path)

      raise ConfigurationError, "Template not found in path \"#{path}\""
    end

    def render_template(file)
      Liquid::Template.parse(file).render(config.plugin_vars)
    end

    def config
      @config ||= Config.instance(page.site)
    end
  end
end
lib/jekyll_metrics/config.rb
module JekyllMetrics
  # Hold the configuration needed for {JekyllMetrics::Hook} to work
  class Config
    CONFIG_NAME = 'jekyll_metrics'.freeze
    DEFAULT_TEMPLATE_PATH = 'lib/jekyll_metrics/includes/metrics.html.liquid'.freeze
    DEFAULT_CONFIG = {
      'template' => DEFAULT_TEMPLATE_PATH,
      'yandex_metrica_id' => 'XXXXXXXX',
      'google_analytics_id' => 'XX-XXXXXXXXX-X'
    }.freeze

    class << self
      def instance(site)
        @instance ||= Config.new(site)
      end
    end

    attr_accessor :site

    def initialize(site)
      @site = site
    end

    def template_path
      @template_path ||= build_template_path
    end

    def plugin_vars
      @plugin_vars ||= DEFAULT_CONFIG.merge(plugin_config)
    end

    private

    def build_template_path
      custom_path = plugin_config['template']

      return default_template_path if custom_path.nil?

      if custom_path.match?(%r{^\/})
        Pathname.new(custom_path)
      else
        site_root_path.join(custom_path)
      end
    end

    def default_template_path
      plugin_root_path.join(DEFAULT_TEMPLATE_PATH)
    end

    def plugin_root_path
      Pathname.new(File.expand_path('../..', __dir__))
    end

    def site_root_path
      raise ConfigurationError, 'Couldn\'t access site.source' unless site.source

      Pathname.new(site.source)
    end

    def plugin_config
      @plugin_config ||= site_config[CONFIG_NAME].to_h.transform_keys(&:to_s)
    end

    def site_config
      site.config
    end
  end
end

Here I’ve added some checks in case if an empty/improper config is given. And also pay attention on the JekyllMetrics::Config#build_template_path method which will build the path accordinatly to the type of path is given:

  1. If no custom path for the template exists, use the default one
  2. If an absolute path is given, use it without modification
  3. If the path is set up as a relative join it with the project’s dir path

The other logic remains to be more or less the same.

Migrating the template

Let’s now copy the metrics.html file to the lib/jekyll_metrics/includes/metrics.html.liquid file.

Don’t forget to do it cause we don’t want to be dependend from the project’s directory when we’re building a plugin like a separate gem.

lib/jekyll_metrics/includes/metrics.html.liquid
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id="></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', '');
</script>
<!-- End Global site tag (gtag.js) - Google Analytics -->

<!-- Yandex.Metrika counter -->
<script type="text/javascript" >
   (function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
   m[i].l=1*new Date();k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
   (window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");

   ym(, "init", {
        clickmap:true,
        trackLinks:true,
        accurateTrackBounce:true
   });
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->

Now it’s time to modify the entry-point file lib/jekyll_metrics.rb in which there is not much left.

lib/jekyll_metrics.rb
# frozen_string_literal: true

require 'jekyll'
require File.expand_path('jekyll_metrics/version', __dir__)
require File.expand_path('jekyll_metrics/config', __dir__)
require File.expand_path('jekyll_metrics/hook', __dir__)

module JekyllMetrics
  class ConfigurationError < StandardError; end
end

# Register the hook
Jekyll::Hooks.register [:documents, :pages], :post_render do |page|
  JekyllMetrics::Hook.new(page) { |_page, metrics| metrics.inject_scripts }
end

Basic unit testing

Now to make sure that everything is working as expected let’s add some basic specs to the gem.

Set up the helper class so everything will be loaded find:

spec/spec_helper.rb
require 'bundler/setup'
require 'jekyll_metrics'
require File.expand_path('../lib/jekyll_metrics', __dir__)

RSpec.configure do |config|
  # Enable flags like --only-failures and --next-failure
  # config.example_status_persistence_file_path = ".rspec_status"

  # Disable RSpec exposing methods globally on `Module` and `main`
  config.disable_monkey_patching!

  config.expect_with :rspec do |c|
    c.syntax = :expect
  end
end

Add a test for Config class:

spec/lib/config_spec.rb
RSpec.describe JekyllMetrics::Config do
  subject(:config) { described_class.new(site) }
  let(:site) { OpenStruct.new(config: site_config, source: '/foo/boo/bar') }
  let(:site_config) { Jekyll::Configuration.from(jekyll_metrics: plugin_config) }
  let(:plugin_config) do
    {
      'template' => path,
      'yandex_metrica_id' => '22222222',
      'google_analytics_id' => '11-111111111-1'
    }
  end
  let(:path) { 'path/to/template.html' }
  let(:absolute_path) { '/path/to/template.html' }
  let(:default_path) do
    Pathname.new(__dir__).join('../..', JekyllMetrics::Config::DEFAULT_TEMPLATE_PATH)
  end

  describe '#plugin_vars' do
    specify { expect(config.plugin_vars).to include(plugin_config) }

    context 'with default config' do
      let(:plugin_config) { OpenStruct.new({}) }

      specify do
        expect(config.plugin_vars).to include(JekyllMetrics::Config::DEFAULT_CONFIG)
      end
    end
  end

  describe '#template_path' do
    specify { expect(config.template_path).to eq(Pathname.new('/foo/boo/bar/path/to/template.html')) }

    context 'with absolute path' do
      let(:path) { absolute_path }

      specify { expect(config.template_path).to eq(Pathname.new(absolute_path)) }
    end

    context 'with default config' do
      let(:plugin_config) { OpenStruct.new({}) }

      specify do
        expect(config.template_path).to eq(default_path)
      end
    end
  end
end

And for the Hook class too:

spec/lib/hook_spec.rb
RSpec.describe JekyllMetrics::Hook do
  subject(:hook) { described_class.new(page) }
  let(:page) { OpenStruct.new({ site: nil }) }
  let(:config) do
    OpenStruct.new(
      template_path: template_path,
      plugin_vars: {
        'template_path' => template_path,
        'yandex_metrica_id' => '22222222',
        'google_analytics_id' => '11-111111111-1'
      }
    )
  end
  let(:template_path) { File.expand_path('../../lib/jekyll_metrics/includes/metrics.html.liquid', __dir__) }

  describe '#load_scripts' do
    before do
      allow(hook).to receive(:config).and_return(config)
    end

    subject(:loaded_scripts) { hook.__send__(:load_scripts) }

    it { is_expected.to include('yandex.ru') }
    it { is_expected.to include('googletagmanager.com') }
    it { is_expected.to include('22222222') }
    it { is_expected.to include('11-111111111-1') }

    context 'when template_path is nil' do
      let(:template_path) {}
      specify do
        expect do
          hook.__send__(:load_scripts)
        end.to raise_error(JekyllMetrics::ConfigurationError, 'Template not found in path ""')
      end
    end
  end
end

The code here should be self explanatory.

Run the specs to make sure everything is working as expected:

$ bundle exec rspec

JekyllMetrics::Config
  #plugin_vars
    is expected to include {"template" => "path/to/template.html", "yandex_metrica_id" => "22222222", "google_analytics_id" => "11-111111111-1"}
    with default config
      is expected to include {"template" => "lib/jekyll_metrics/includes/metrics.html.liquid", "yandex_metrica_id" => "XXXXXXXX", "google_analytics_id" => "XX-XXXXXXXXX-X"}
  #template_path
    is expected to eq #<Pathname:/foo/boo/bar/path/to/template.html>
    with absolute path
      is expected to eq #<Pathname:/path/to/template.html>
    with default config
      is expected to eq #<Pathname:/home/vanya/Projects/zinovyev/tutorials/jekyll_metrics_tutorial/jekyll_metrics/lib/jekyll_metrics/includes/metrics.html.liquid>

JekyllMetrics::Hook
  #load_scripts
    is expected to include "yandex.ru"
    is expected to include "googletagmanager.com"
    is expected to include "22222222"
    is expected to include "11-111111111-1"
    when template_path is nil
      is expected to raise JekyllMetrics::ConfigurationError with "Template not found in path \"\""

Finished in 0.0094 seconds (files took 0.26624 seconds to load)
10 examples, 0 failures

The specs should be green and fine.

That’s almost it.

Publishing a gem

The process of publishing a gem is relatively simple (just two commands to execute) and is well described in the official documentation (I’ll post a link for every step below):

To build a gem use the following command (read Make your own gem):

gem build jekyll_metrics.gemspec

To publish it (read Publishing):

gem push jekyll_metrics-1.0.0.gem

The source code of the plugin

The full source code of the plugin may be found on github. It may slightly differ from the one described in the article. Please follow commits to find out what changed.