Writing a Jekyll plugin
In this blog post I share my experience of building a Jekyll plugin. I have a map page in my travel blog which shows all the places I’ve been to. This page goes through all blog posts and collects locations from YAML data. After that it builds a Google Map with a marker for each location.
I thought it might be a good idea to extract this functionality into a separate plugin and open source it, so that other people can also use it on their pages.
Introduction
Jekyll plugins allow you to change behaviour without modifying Jekyll source. There are several ways to use plugins:
- you can just dump plugin code to
_plugins
directory - or you can pack your code as a Ruby gem and reference it in
_config.yml
Jekyll has good documentation for plugins system. Plugins as a gem are more convenient to users and generally preferred.
Best Practices
Before diving into coding my new plugin I decided to research existing plugins implementations to find some common patterns and best practices. I’ve inspected plugins such as jekyll-assets, jekyll-feed, jekyll-sitemap, jekyll-mentions and others and found some good ideas.
Directory Structure
Most of the plugins have following directory structure:
/lib/
/lib/${plugin_name}.rb # Plugin module defined here
/lib/${plugin_name}/*.rb # Actual plugin implementation
/lib/${plugin_name}/version.rb # Keep version number in constant here
/script/ # Bootstrap, CI scripts and other bash scripts
/spec/ # Tests for plugin code
HISTORY.md # Describe release changes here
README.md # User documentation for the plugin
${plugin_name}.gemspec # Ruby Gem specification
Versioning
Plugin version is kept in a separate file and re-used everywhere when you need to display a version. It makes releasing new versions easier.
Gemspec
Gemspec describes your gem and its dependencies, I’ve came up with following for my plugin, based on several other plugins:
# coding: utf-8
lib = File.expand_path("../lib", __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require "jekyll-maps/version"
Gem::Specification.new do |spec|
spec.name = "jekyll-maps"
spec.summary = "Jekyll Google Maps integration"
spec.description = "Google Maps support in Jekyll blog to easily embed maps with posts' locations"
spec.version = Jekyll::Maps::VERSION
spec.authors = ["Anatoliy Yastreb"]
spec.email = ["[email protected]"]
spec.homepage = "https://github.com/ayastreb/jekyll-maps"
spec.licenses = ["MIT"]
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r!^(test|spec|features)/!) }
spec.require_paths = ["lib"]
spec.add_dependency "jekyll", "~> 3.0"
spec.add_development_dependency "rake", "~> 11.0"
spec.add_development_dependency "rspec", "~> 3.5"
spec.add_development_dependency "rubocop", "~> 0.41"
end
Testing
To test integration of your plugin in Jekyll you can use a test helper.
# spec_helper.rb
$LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
require "jekyll"
require "jekyll-maps"
Jekyll.logger.log_level = :error
RSpec.configure do |config|
config.run_all_when_everything_filtered = true
config.filter_run :focus
config.order = "random"
SOURCE_DIR = File.expand_path("../fixtures", __FILE__)
DEST_DIR = File.expand_path("../dest", __FILE__)
def source_dir(*files)
File.join(SOURCE_DIR, *files)
end
def dest_dir(*files)
File.join(DEST_DIR, *files)
end
CONFIG_DEFAULTS = {
"source" => source_dir,
"destination" => dest_dir,
"gems" => ["jekyll-maps"]
}.freeze
def make_page(options = {})
page = Jekyll::Page.new(site, CONFIG_DEFAULTS["source"], "", "page.md")
page.data = options
page
end
def make_site(options = {})
site_config = Jekyll.configuration(CONFIG_DEFAULTS.merge(options))
Jekyll::Site.new(site_config)
end
def make_context(registers = {}, environments = {})
Liquid::Context.new(environments, {},
{ :site => site, :page => page }.merge(registers))
end
end
And use it in the tests:
# google_map_tag_spec.rb
require "spec_helper"
describe Jekyll::Maps::GoogleMapTag do
let(:site) { make_site }
before { site.process }
context "full page rendering" do
let(:content) { File.read(dest_dir("page.html")) }
it "builds javascript" do
expect(content).to match(%r!#{Jekyll::Maps::GoogleMapTag::JS_LIB_NAME}!)
end
it "includes external js only once" do
expect(content.scan(%r!maps\.googleapis\.com!).length).to eq(1)
end
it "renders API key" do
expect(content).to match(%r!maps/api/js\?key=GOOGLE_MAPS_API_KEY!)
end
end
end
Plugin Categories
There are several different ways your plugin can alter Jekyll’s behaviour:
- Generators: create additional content or fill in template variables
- Converters: convert custom markup language
- Commands: implement custom subcommands for
jekyll
executable - Tags: create custom Liquid templates
- Hooks: subscribe to different events and modify content
In jekyll-maps I used tag to create {% google_map %}
tag and hooks to inject JavaScript code into the page.
Jekyll documentation provides good examples for each plugin category and you can also check my implementation on GitHub.
Releasing
To release a new version of a plugin we need to do following:
- increase version number in
lib/${plugin_name}/version.rb
- add release notes to
HISTORY.md
- create a release and a tag in GitHub (optional)
- build new gem
gem build ${plugin_name}.gemspec
- push new version to rubygems.org
gem push ${plugin_name}-${version}.gem
When your plugin is ready to be used — don’t forget to add it to the list of plugins in Jekyll docs, so that people can find it!