Developing Custom Jekyll Plugins (Ruby)
Jekyll is written in Ruby and provides a full extension API through plugins. Plugins are Ruby classes that are embedded in the site generation pipeline. They can add custom Liquid tags, filters, page generators, format converters, and hooks. GitHub Pages doesn't run plugins (only a whitelist) — you need your own CI/CD.
Plugin types and when to use each
| Type | Superclass | Use case |
|---|---|---|
| Generator | Jekyll::Generator |
Create pages programmatically, aggregate data |
| Converter | Jekyll::Converter |
New content formats (AsciiDoc, reStructuredText) |
| Command | Jekyll::Command |
New CLI commands (jekyll mycommand) |
| Tag | Liquid::Tag |
Custom tags {% mytag %} |
| Block | Liquid::Block |
Tags with content {% block %}...{% endblock %} |
| Filter | register in Liquid::Template.register_filter |
Custom filters {{ value | myfilter }} |
Plugin structure
Plugins are placed in _plugins/:
_plugins/
├── image_optimizer.rb
├── reading_time.rb
├── related_posts.rb
└── generators/
└── tag_pages.rb
Example 1: Custom filter
Filter for formatting a number in US format:
# _plugins/filters/number_format.rb
module NumberFormatFilter
def en_number(number, decimals = 0)
return number unless number.is_a?(Numeric)
formatted = number.to_f.round(decimals)
parts = formatted.to_s.split('.')
integer_part = parts[0].gsub(/(\d)(?=(\d{3})+$)/, '\1,')
if decimals > 0 && parts[1]
"#{integer_part}.#{parts[1].ljust(decimals, '0')}"
else
integer_part
end
end
def en_currency(number, currency = '$')
"#{currency}#{en_number(number)}"
end
def reading_time(content)
words = content.split.length
minutes = (words / 200.0).ceil
"#{minutes} min"
end
end
Liquid::Template.register_filter(NumberFormatFilter)
Usage in template:
{{ 1234567 | en_number }} → 1,234,567
{{ 9990.5 | en_currency }} → $9,990
{{ page.content | reading_time }} → 5 min
Example 2: Custom tag with parameters
Tag for embedding video with lazy loading:
# _plugins/tags/video_embed.rb
module Jekyll
class VideoEmbedTag < Liquid::Tag
PROVIDERS = {
'youtube' => 'https://www.youtube.com/embed/%s',
'vimeo' => 'https://player.vimeo.com/video/%s',
}.freeze
def initialize(tag_name, markup, tokens)
super
@params = {}
markup.scan(/(\w+)="([^"]*)"/) do |key, value|
@params[key] = value
end
end
def render(context)
provider = @params['provider'] || 'youtube'
video_id = @params['id']
title = @params['title'] || 'Video'
aspect = @params['aspect'] || '16-9'
return "<!-- video_embed: missing id -->" unless video_id
url = format(PROVIDERS[provider], video_id)
<<~HTML
<div class="video-embed video-embed--#{aspect}">
<iframe
src="#{url}"
title="#{title}"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
></iframe>
</div>
HTML
end
end
end
Liquid::Template.register_tag('video_embed', Jekyll::VideoEmbedTag)
Usage:
{% video_embed provider="youtube" id="dQw4w9WgXcQ" title="Project demo" aspect="16-9" %}
Example 3: Generator for tag pages
Jekyll natively generates _site/tags/ only through third-party plugins. Implementation:
# _plugins/generators/tag_pages.rb
module Jekyll
class TagPageGenerator < Generator
safe true
priority :low
def generate(site)
# Collect all tags from all posts
all_tags = site.posts.docs.flat_map { |post|
post.data['tags'] || []
}.uniq.sort
all_tags.each do |tag|
site.pages << TagPage.new(site, site.source, tag)
end
# Create tag index page
site.pages << TagIndexPage.new(site, site.source, all_tags)
end
end
class TagPage < Page
def initialize(site, base, tag)
@site = site
@base = base
@dir = File.join('tags', Jekyll::Utils.slugify(tag))
@name = 'index.html'
process(@name)
read_yaml(File.join(base, '_layouts'), 'tag.html')
self.data['tag'] = tag
self.data['title'] = "Posts tagged: #{tag}"
self.data['description'] = "All posts about #{tag}"
# Get all posts with this tag
self.data['tag_posts'] = site.posts.docs.select { |post|
(post.data['tags'] || []).include?(tag)
}.sort_by { |post| post.date }.reverse
end
end
class TagIndexPage < Page
def initialize(site, base, tags)
@site = site
@base = base
@dir = 'tags'
@name = 'index.html'
process(@name)
read_yaml(File.join(base, '_layouts'), 'tags-index.html')
self.data['title'] = 'All tags'
self.data['tags_with_counts'] = tags.map { |tag|
count = site.posts.docs.count { |post|
(post.data['tags'] || []).include?(tag)
}
{ 'name' => tag, 'slug' => Jekyll::Utils.slugify(tag), 'count' => count }
}.sort_by { |t| -t['count'] }
end
end
end
Example 4: Hooks for post-processing
# _plugins/hooks/minify_html.rb
Jekyll::Hooks.register [:pages, :documents], :post_render do |doc|
next unless doc.output_ext == '.html'
next if doc.output.nil? || doc.output.empty?
# Basic HTML minification (remove extra spaces between tags)
doc.output = doc.output
.gsub(/>\s+</, '><')
.gsub(/\s{2,}/, ' ')
.strip
end
# Hook after file write
Jekyll::Hooks.register :site, :post_write do |site|
puts " Site built: #{site.pages.length} pages, #{site.posts.docs.length} posts"
puts " Output directory: #{site.dest}"
end
Testing plugins
# spec/plugins/number_format_spec.rb
require 'jekyll'
require_relative '../../_plugins/filters/number_format'
RSpec.describe NumberFormatFilter do
include NumberFormatFilter
describe '#en_number' do
it 'formats thousands with comma' do
expect(en_number(1234567)).to eq('1,234,567')
end
it 'formats decimals' do
expect(en_number(1234.5, 2)).to eq('1,234.50')
end
end
describe '#reading_time' do
it 'calculates reading time' do
content = Array.new(400, 'word').join(' ')
expect(reading_time(content)).to eq('2 min')
end
end
end
Distribution as gem
# myplugin.gemspec
Gem::Specification.new do |spec|
spec.name = "jekyll-myplugin"
spec.version = "1.0.0"
spec.authors = ["Your Name"]
spec.summary = "Plugin description"
spec.files = Dir["lib/**/*", "LICENSE"]
spec.require_paths = ["lib"]
spec.add_dependency "jekyll", ">= 4.0"
end
Timeline
Simple filter or tag — half day to 1 day. Generator for tag/author pages — 2–3 days. Complex plugin with image processing, external APIs, tests — 1–2 weeks.







