Creating a Jekyll plugin. Part 2
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:
You should now have two directories placed beside of each other.
Navigate to the plugin’s dir:
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:
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:
require "jekyll_metrics/version"
module JekyllMetrics
class Error < StandardError; end
# Your code goes here...
end
Let’s copy our plugin’s code there:
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:
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.
# 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.
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
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:
- If no custom path for the template exists, use the default one
- If an absolute path is given, use it without modification
- 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.
<!-- 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.
# 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:
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:
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:
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.