Mike Slinn
Mike Slinn

Jekyll Plugin Template Collection

Published 2022-03-28. Last modified 2022-04-21.
Time to read: 7 minutes.

This site is categorized under Jekyll, Ruby.

Early Access

The material on this web page and the GitHub project it links to is still in the development / heavy edit stage.

If you are interested in watching this project evolve in real time, please revist this page daily, if you are so inclined. I am working on it fairly intensely. The dependencies and the sample plugin are solid, but the run_this_first installation script is not (yet).

This blog post builds upon the previous post, and discusses several templates that you can use to start writing your next Jekyll plugin in Ruby. Templates are provided for custom Jekyll filters, generators, tags and block tags. These templates:

  • Are structured and implemented as Ruby gems, which are the easiest way to work with Jekyll plugins. It is suprisingly easy to publish Ruby gems. You can publish to RubyGems.org or to a private repository.
  • Provide an interactive run_this_first installation script, which sets up your new Jekyll plugin and a git repo for it, so you can immediately start to work with it.
  • For plugins that can accept parameters, the templates provide convenient parameter parsing that is really easy to work with.
  • Set up their own custom loggers as described in this post. Separate loggers are predefined for the tag plugins, as well as 5 loggers for the 5 major categories of Jekyll hooks: clean, documents, pages, posts, and site.
  • Provides Jekyll site, page and mode variables to scopes that need those variables when writing Jekyll plugins. This can dramatically reduce the amount of time developing Jekyll plugins. I have wasted oh, so many hours trying figure out how to provide a variable to a scope when working on Jekyll plugins. These templates are structured to help you avoid wasting time on undocumented or underdocumented details.

GitHub Project and RubyGem

More information is available about this plugin from its GitHub project at github.com/mslinn/jekyll_plugin_template.

category_index_generator.rb

Generators are only invoked once during the website build process, when all the pages have been scanned and the site structure is available for processing. It is common for generators to include code that loops through various collections of pages.

Functionally, a Jekyll generator is the same as a :site :pre_render hook. The choice of whether to write a generator class, which subclasses Jekyll::Generator, or writing a :site :pre_render hook is arbitrary. Flip a coin to decide.

Generators can create files containing web pages in any directory, and they can modify front matter and content of existing files. Generators usually log information to the console whenever a problem occurs, or progress needs to be shown. Here is the official documentation:

You can create a generator when you need Jekyll to create additional content based on your own rules.

A generator is a subclass of Jekyll::Generator that defines a generate method, which receives an instance of Jekyll::Site. The return value of generate is ignored.

Generators run after Jekyll has made an inventory of the existing content, and before the site is generated. Pages with front matter are stored as instances of Jekyll::Page and are available via site.pages. Static files become instances of Jekyll::StaticFile and are available via site.static_files. See the Variables documentation page and Jekyll::Site for details.
# frozen_string_literal: true

# Inspired by the badly broken example on https://jekyllrb.com/docs/plugins/generators/, and completely redone so it works.
module CategoryIndexGenerator
  # Creates an index page for each catagory, plus a main index, all within a directory called _site/categories.
  class CategoryGenerator < Jekyll::Generator
    safe true

    # Only generates content in development mode
    # rubocop:disable Style/StringConcatenation, Metrics/AbcSize
    def generate(site)
      # This plugin is disabled unless _config.yml contains an entry for category_generator_enable and the value is not false
      return if site.config['category_generator_enable']

      return if site.config['env']['JEKYLL_ENV'] == "production"

      index = Jekyll::PageWithoutAFile.new(site, site.source, 'categories', "index.html")
      index.data['layout'] = "default"
      index.data['title'] = "Post Categories"
      index.content = "<p>"

      site.categories.each do |category, posts|
        new_page = Jekyll::PageWithoutAFile.new(site, site.source, 'categories', "#{category}.html")
        new_page.data['layout'] = "default"
        new_page.data['title'] = "Category #{category} Posts"
        new_page.content = "<p>" + posts.map do |post|
          "<a href='#{post.url}'>#{post.data['title']}</a><br>"
        end.join("\n") + "</p>\n"
        site.pages << new_page
        index.content += "<a href='#{category}.html'>#{category}</a><br>\n"
      end
      index.content += "</p>"
      site.pages << index
    end
    # rubocop:enable Style/StringConcatenation, Metrics/AbcSize
  end

  PluginMetaLogger.instance.logger.info { "Loaded CategoryGenerator v#{JekyllPluginTemplateVersion::VERSION} plugin." }
end

jekyll_filter_template.rb

Filters are the easiest type of Jekyll plugin to write. All that is required is a module with methods in it, and to register the module with Liquid::Template.register_filter. All of the methods in the module become filters. No subclassing is required.

# frozen_string_literal: true

require "jekyll_plugin_logger"

# @author Copyright 2020 {https://www.mslinn.com Michael Slinn}
# Template for Jekyll filters.
module JekyllFilterTemplate
  class << self
    attr_accessor :logger
  end
  self.logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)

  # This Jekyll filter returns the URL to search Google for the contents of the input string.
  # @param input_string [String].
  # @return [String] empty string if input_string has no contents except whitespace.
  # @example Use.
  #   {{ "joy" | my_filter_template }} => <a href='https://www.google.com/search?q=joy' target='_blank' rel='nofollow'>joy</a>
  def my_filter_template(input_string)
    # @context[Liquid::Context] is available here to look up variables defined in front matter, templates, page, etc.

    JekyllFilterTemplate.logger.debug do
      "Defined filters are: " + self.class # rubocop:disable Style/StringConcatenation
                                    .class_variable_get('@@global_strainer')
                                    .filter_methods.instance_variable_get('@hash')
                                    .map { |k, _v| k }
                                    .sort
    end

    input_string.strip!
    JekyllFilterTemplate.logger.debug "input_string=#{input_string}"
    if input_string.empty?
      ""
    else
      "<a href='https://www.google.com/search?q=#{input_string}' target='_blank' rel='nofollow'>#{input_string}</a>"
    end
  end

  PluginMetaLogger.instance.logger.info { "Loaded JekyllFilterTemplate v#{JekyllPluginTemplateVersion::VERSION} plugin." }
end

Liquid::Template.register_filter(JekyllFilterTemplate)

Output

Given this markup in an HTML file:

Search for {{ "joy" | my_filter_template }}

This is what is rendered to the web page after being passed through the filter:

Search for joy

Using module_function Properly

Liquid only registers instance methods as filters, not singleton methods.

If you want to to define a method that can be called as a Jekyll filter, and be used by other plugins as well, then you should read this section.

Liquid only registers instance methods as filters, not singleton methods. module_function statements convert instance methods into singleton methods.

If you mention a filter method in a module_function statement, an insidious bug will be introduced into your plugin, which can be difficult to understand at first. Adding a module_function statement into a module that defines Jekyll filters does the following:

  • The filters are never called
  • The unfiltered value is returned
  • No warnings or errors are issued.

You might want to define a module_function so it can be invoked from another module. The following example was taken from my jekyll_draft plugin:

module Jekyll
  # Define this method outside of the filter module so they can be invoked externally
  module Draft
    def draft?(doc)
      # blah blah
    end
  end

  module DraftFilter
    def is_draft(doc)
      Draft::draft?(doc)
    end

    Liquid::Template.register_filter(DraftFilter)
  end
end

Tag Plugins

😁 Here is a :smiley: emoji, created by
{% tag_template name='smiley' align='left' size='2em' %}

This is the code for the tag plugin that creates the emojis:

# frozen_string_literal: true

require "jekyll_plugin_logger"
require "key_value_parser"
require "shellwords"

module JekyllPluginTagTemplate
  PLUGIN_NAME = "tag_template"
end

# This Jekyll tag plugin creates an emoji of the desired size and alignment.
#
# @example Float Smiley emoji right, sized 3em
#     {% tag_template name='smile' align='right' size='5em' %}
#   The above results in the following HTML:
#     <span style="float: right; font-size: 5em;">&#x1F601;</span>
#
# @example Defaults
#     {% tag_template name='smile' %}
#   The above results in the following HTML:
#     <span style="font-size: 3em;">&#x1F601;</span>
#
# The Jekyll log level defaults to :info, which means all the Jekyll.logger statements below will not generate output.
# You can control the log level when you start Jekyll.
# To set the log level to :debug, write an entery into _config.yml, like this:
# plugin_loggers:
#   MyTag: debug
module JekyllTagPlugin
  # This class implements the Jekyll tag functionality
  class MyTag < Liquid::Tag
    # Supported emojis (GitHub symbol, hex code) - see https://gist.github.com/rxaviers/7360908 and
    # https://www.quackit.com/character_sets/emoji/emoji_v3.0/unicode_emoji_v3.0_characters_all.cfm
    @@emojis = {
      'angry'      => '&#x1F620;',
      'boom'       => '&#x1F4A5;', # used when requested emoji is not recognized
      'kiss'       => '&#x1F619;',
      'scream'     => '&#x1F631;',
      'smiley'     => '&#x1F601;', # default emoji
      'smirk'      => '&#x1F60F;',
      'two_hearts' => '&#x1F495;',
    }

    # @param tag_name [String] is the name of the tag, which we already know.
    # @param argument_string [String] the arguments from the web page.
    # @param tokens [Liquid::ParseContext] tokenized command line
    # @return [void]
    def initialize(tag_name, argument_string, tokens)
      super
      @logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)

      argv = Shellwords.split(argument_string) # Scans name/value arguments
      params = KeyValueParser.new.parse(argv) # Extracts key/value pairs, default value for non-existant keys is nil

      @emoji_name  = params[:name]  || "smiley"
      @emoji_align = params[:align] || "inline" # Could be inline, right or left
      @emoji_size  = params[:size]  || "3em"
      @emoji_hex_code = @@emojis[@emoji_name] || @@emojis['boom']
    end

    # Method prescribed by the Jekyll plugin lifecycle.
    # Several variables are created to illustrate how they are made.
    # @param liquid_context [Liquid::Context]
    # @return [String]
    def render(liquid_context) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
      @site = liquid_context.registers[:site]
      @config = @site.config
      @mode = @config["env"]["JEKYLL_ENV"] || "development"

      # variables defined in pages are stored as hash values in liquid_context
      _assigned_page_variable = liquid_context['assigned_page_variable']

      # The names of front matter variables are hash keys for @page
      @page = liquid_context.registers[:page] # @page is a Jekyll::Drops::DocumentDrop

      @envs = liquid_context.environments.first
      @layout_hash = @envs['layout']
      # @layout_hash = @page['layout']

      @logger.debug do
        <<~HEREDOC
          liquid_context.scopes=#{liquid_context.scopes}
          mode="#{@mode}"
          page attributes:
            #{@page.sort
                   .reject { |k, _| REJECTED_ATTRIBUTES.include? k }
                   .map { |k, v| "#{k}=#{v}" }
                   .join("\n  ")}
        HEREDOC
      end

      assemble_emoji
    end

    def assemble_emoji # rubocop:disable Metrics/MethodLength
      case @emoji_align
      when "inline"
        align = ""
      when "right"
        align = "float: right; margin-left: 5px;"
      when "left"
        align = "float: left; margin-right: 5px;"
      else
        @logger.error { "Invalid emoji alignment #{@emoji_align}" }
        align = ""
      end
      # Compute the return value of this Jekyll tag
      "<span style='font-size: #{@emoji_size}; #{align}'>#{@emoji_hex_code}</span>"
    end
  end
end

PluginMetaLogger.instance.info { "Loaded #{JekyllPluginTagTemplate::PLUGIN_NAME} v#{JekyllPluginTemplateVersion::VERSION} plugin." }
Liquid::Template.register_tag(JekyllPluginTagTemplate::PLUGIN_NAME, JekyllTagPlugin::MyTag)

Supported emojis for this example tag plugin are: angry (😠), boom (💥), kiss (😙), scream (😱), smiley (😁), smirk (😏) and two_hearts (💕). Go ahead and implement more!

Block Tag Plugins

Following is the source file containing a Jekyll block tag plugin, and an explanation of the key features of the template.

# frozen_string_literal: true

require "jekyll_plugin_logger"
require "key_value_parser"
require "shellwords"

module JekyllPluginBlockTagTemplate
  PLUGIN_NAME = "block_tag_template"
end

# This is the module-level description.
#
# @example Heading for this example
#   Describe what this example does
#   {% block_tag_template "parameter" %}
#     Hello, world!
#   {% endblock_tag_template %}
#
# The Jekyll log level defaults to :info, which means all the Jekyll.logger statements below will not generate output.
# You can control the log level when you start Jekyll.
# To set the log level to :debug, write an entery into _config.yml, like this:
# plugin_loggers:
#   MyBlock: debug

module JekyllBlockTagPlugin
  # This class implements the Jekyll block tag functionality
  class MyBlock < Liquid::Block
    # See https://github.com/Shopify/liquid/wiki/Liquid-for-Programmers#create-your-own-tags
    # @param tag_name [String] the name of the tag, which we already know.
    # @param argument_string [String] the arguments from the tag, as a single string.
    # @param _parse_context [Liquid::ParseContext] hash that stores Liquid options.
    #        By default it has two keys: :locale and :line_numbers, the first is a Liquid::I18n object, and the second,
    #        a boolean parameter that determines if error messages should display the line number the error occurred.
    #        This argument is used mostly to display localized error messages on Liquid built-in Tags and Filters.
    #        See https://github.com/Shopify/liquid/wiki/Liquid-for-Programmers#create-your-own-tags
    # @return [void]
    def initialize(tag_name, argument_string, parse_context) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
      super
      @logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)

      @argument_string = argument_string

      argv = Shellwords.split(argument_string) # Scans name/value arguments
      params = KeyValueParser.new.parse(argv) # Extracts key/value pairs, default value for non-existant keys is nil
      @param1 = params[:param1] # Obtain the value of parameter param1
      @param2 = params[:param2]
      @param3 = params[:param3]
      @param4 = params[:param4]
      @param5 = params[:param5]
      @param_x = params[:not_present] # The value of parameters that are present is nil, but displays as the empty string

      @logger.debug do
        <<~HEREDOC
          tag_name = '#{tag_name}'
          argument_string = '#{argument_string}'
          @param1 = '#{@param1}'
          @param2 = '#{@param2}'
          @param3 = '#{@param3}'
          @param4 = '#{@param4}'
          @param5 = '#{@param5}'
          @param_x = '#{@param_x}'
          params =
            #{params.map { |k, v| "#{k} = #{v}" }.join("\n  ")}
        HEREDOC
      end
    end

    REJECTED_ATTRIBUTES = %w[content excerpt next previous].freeze

    # Method prescribed by the Jekyll plugin lifecycle.
    # @param liquid_context [Liquid::Context]
    # @return [String]
    def render(liquid_context) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
      content = super # This underdocumented assignment returns the text within the block.

      @site = liquid_context.registers[:site]
      @config = @site.config
      @mode = @config.dig("env", "JEKYLL_ENV") || "development"

      # variables defined in pages are stored as hash values in liquid_context
      _assigned_page_variable = liquid_context['assigned_page_variable']

      # The names of front matter variables are hash keys for @page
      @page = liquid_context.registers[:page] # @page is a Jekyll::Drops::DocumentDrop
      # layout = @page['layout']

      @envs = liquid_context.environments.first
      @layout_hash = @envs['layout']

      @logger.debug do
        <<~HEREDOC
          liquid_context.scopes=#{liquid_context.scopes}
          mode="#{@mode}"
          page attributes:
            #{@page.sort
                   .reject { |k, _| REJECTED_ATTRIBUTES.include? k }
                   .map { |k, v| "#{k}=#{v}" }
                   .join("\n  ")}
        HEREDOC
      end

      # Compute the return value of this Jekyll tag
      <<~HEREDOC
        <p style="color: green; background-color: yellow; padding: 1em; border: solid thin grey;">
          #{content} #{@param1}
        </p>
      HEREDOC
    end
  end
end

PluginMetaLogger.instance.info { "Loaded #{JekyllPluginBlockTagTemplate::PLUGIN_NAME} v#{JekyllPluginTemplateVersion::VERSION} plugin." }
Liquid::Template.register_tag(JekyllPluginBlockTagTemplate::PLUGIN_NAME, JekyllBlockTagPlugin::MyBlock)

Usage

Given this markup in an HTML file:

{% block_tag_template param1="Today is a wonderful day!" %}
Hello, world!
{% endblock_tag_template %}

The rendered HTML from the block tag looks like this:

Hello, world! Today is a wonderful day!

Console output looks like this, when the plugin's log level is set to debug:

DEBUG MyBlock: tag_name = 'block_tag_template'
argument_string = 'param1="Today is a wonderful day!" '
@param1 = 'Today is a wonderful day!'
@param_x = ''
params =
  param1 = Today is a wonderful day!
  param2 =
  param3 =

DEBUG MyBlock: mode=""
page.path="_posts/2022-03-28-jekyll-plugin-template-collection.html"
page.url="/blog/2022/03/28/jekyll-plugin-template-collection.html"

Output

Following is the output when starting the demo Jekyll server.

Shell
$ demo/_bin/debug -r
INFO PluginMetaLogger: Loaded block_tag_template v0.1.2 plugin.
Configuration file: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_config.yml
INFO PluginMetaLogger: Loaded jekyll_plugin_logger v2.1.0 plugin.
            Source: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo
        Destination: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_site
  Incremental build: enabled
      Generating...
INFO PluginMetaLogger: Loaded jekyll_plugin_logger v2.1.0 plugin.
INFO PostHooks: Jekyll::Hooks.register(:posts, :post_init) invoked.
INFO PostHooks: Jekyll::Hooks.register(:posts, :post_init)
  page:
    path = /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts/2022/2022-01-01-test.html
    extname = .html
    collection = 'posts' collection within '_posts'
      Directory: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts
      Does the directory exist and is it not a symlink if in safe mode? true
      Collection_dir: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts
      Metadata: {"output"=>true, "permalink"=>"/blog/:year/:month/:day/:title:output_ext"}
      Static files: []
      Filtered entries: ["2022/2022-01-01-test.html"]
      type = posts
    content not dumped because it would likely be too long
    site not dumped also

INFO DocumentHooks: Jekyll::Hooks.register(:documents, :post_init) invoked.
INFO PageHooks: Jekyll::Hooks.register(:documents, :post_init)
  page:
    path = /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts/2022/2022-01-01-test.html
    extname = .html
    collection = 'posts' collection within '_posts'
      Directory: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts
      Does the directory exist and is it not a symlink if in safe mode? true
      Collection_dir: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts
      Metadata: {"output"=>true, "permalink"=>"/blog/:year/:month/:day/:title:output_ext"}
      Static files: []
      Filtered entries: ["2022/2022-01-01-test.html"]
      type = posts
    content not dumped because it would likely be too long
    site not dumped also

INFO PostHooks: Jekyll::Hooks.register(:posts, :post_init) invoked.
INFO PostHooks: Jekyll::Hooks.register(:posts, :post_init)
  page:
    path = /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_drafts/2022/2022-05-01-test2.html
    extname = .html
    collection = 'posts' collection within '_posts'
      Directory: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts
      Does the directory exist and is it not a symlink if in safe mode? true
      Collection_dir: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts
      Metadata: {"output"=>true, "permalink"=>"/blog/:year/:month/:day/:title:output_ext"}
      Static files: []
      Filtered entries: ["2022/2022-01-01-test.html"]
      type = posts
    content not dumped because it would likely be too long
    site not dumped also

INFO DocumentHooks: Jekyll::Hooks.register(:documents, :post_init) invoked.
INFO PageHooks: Jekyll::Hooks.register(:documents, :post_init)
  page:
    path = /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_drafts/2022/2022-05-01-test2.html
    extname = .html
    collection = 'posts' collection within '_posts'
      Directory: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts
      Does the directory exist and is it not a symlink if in safe mode? true
      Collection_dir: /mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo/_posts
      Metadata: {"output"=>true, "permalink"=>"/blog/:year/:month/:day/:title:output_ext"}
      Static files: []
      Filtered entries: ["2022/2022-01-01-test.html"]
      type = posts
    content not dumped because it would likely be too long
    site not dumped also

INFO PageHooks: Jekyll::Hooks.register(:pages, :post_init) invoked.
INFO PageHooks: Jekyll::Hooks.register(:pages, :post_init)
  page at /blog/:
    basename = blogsByDate
    ext = .html
    name = blogsByDate.html
    output =
    pager =
    Is it HTML? true; is it an index? false
    Permalink:
    URL: /blog/blogsByDate.html
    content not dumped because it would likely be too long
    site not dumped also
    Excerpt: ""
  data:
    description = Blog posts sorted by date
    layout = default
    reading_time = false
    subtitle = Blog Posts, Listed Newest to Oldest
    title-override = Blog Posts, Listed Newest to Oldest

INFO PageHooks: Jekyll::Hooks.register(:pages, :post_init) invoked.
INFO PageHooks: Jekyll::Hooks.register(:pages, :post_init)
  page at /blog/:
    basename = index
    ext = .html
    name = index.html
    output =
    pager =
    Is it HTML? true; is it an index? true
    Permalink:
    URL: /blog/
    content not dumped because it would likely be too long
    site not dumped also
    Excerpt: ""
  data:
    canonical_url = https://bogus.jekylldemo.com/blog/index.html
    description = Blog posts by category
    layout = default
    reading_time = false
    subtitle = Blog Posts by Category
    title-override = Blog Posts by Category

INFO PageHooks: Jekyll::Hooks.register(:pages, :post_init) invoked.
INFO PageHooks: Jekyll::Hooks.register(:pages, :post_init)
  page at /:
    basename = index
    ext = .html
    name = index.html
    output =
    pager =
    Is it HTML? true; is it an index? true
    Permalink:
    URL: /
    content not dumped because it would likely be too long
    site not dumped also
    Excerpt: ""
  data:
    description = Jekyll Plugin Template Demonstration
    layout = default
    subtitle = Demonstration
    title-override = Jekyll Plugin Template Demonstration

INFO CleanHook: Jekyll::Hooks.register(:clean, :on_obsolete) invoked for [].
done in 0.291 seconds.
Auto-regeneration may not work on some Windows versions.
Please see: https://github.com/Microsoft/BashOnWindows/issues/216
If it does not work, please upgrade Bash on Windows or run Jekyll with --no-watch.
Auto-regeneration: enabled for '/mnt/f/work/jekyll/my_plugins/jekyll_plugin_template/demo'
LiveReload address: http://0.0.0.0:35721
Server address: http://0.0.0.0:4444
Server running... press ctrl-c to stop. 

demo/index.html renders as:

Example Hooks

Modifying Pages Across the Entire Jekyll Site

You can modify the generated HTML for the entire Jekyll website. This is easy to do.

The very last hook that gets called before writing posts to disk is :post_render. We can modify the output property of the document at the :documents :post_render hook to make edits to rendered web pages in collections, regardless of whether they were originally written in Markdown or HTML:

module JekyllPluginHookExamples
  Jekyll::Hooks.register(:documents, :post_render) do |doc|
    doc.output.gsub!('Jekyll', 'Awesome')
  end
end

To also modify web pages that are not in a collection (for example, /index.html), add the following into the above module JekyllPluginHooks:

Jekyll::Hooks.register(:pages, :post_render) do |page|
  page.output.gsub!('Jekyll', 'Awesome')
end

Notice that both of the hook invocations has duplicate code. If we want all web pages to be modified, we can rewrite the above and extract the common code to a new method called modify_output:

module JekyllPluginHookExamples
  def modify_output
    Proc.new do |webpage|
      webpage.output.gsub!('Jekyll', 'Awesome')
    end
  end

  module_function :modify_output

  Jekyll::Hooks.register(:documents, :post_render, &modify_output)
  Jekyll::Hooks.register(:pages, :post_render, &modify_output)
end

The demo/index.html web page now looks like the following:

If you want to translate web pages into other languages or dialects, for example Pig Latin or Pirate Talk, or even spelling and grammar autocorrection, just rewrite modify_output to suit.

Talk Like a Pirate Translator

I could not help myself, and wrote a quick Pirate Talk translator for Jekyll sites.

require "active_support"
require "active_support/inflector"
require "nokogiri"
require "talk_like_a_pirate"

def pirate_translator
  proc do |webpage|
    html = Nokogiri.HTML(webpage.output)
    html.css("p").each do |node|
      node.content = TalkLikeAPirate.translate(node.content)
    end
    webpage.output = html
  end
end

module_function :pirate_translator

Jekyll::Hooks.register(:documents, :post_render, &pirate_translator)
Jekyll::Hooks.register(:pages, :post_render, &pirate_translator)

Here is the output of one of the demo web pages:

Original HTML
<h2>Don't Worry, Be Happy</h2>
<p>
If you do not worry, someone else will.
That is their problem.
Enjoy life, it comes at you fast.
</p>
<p class="copyright" id="copyright" xmlns:dct="http://purl.org/dc/terms/" xmlns:vcard="http://www.w3.org/2001/vcard-rdf/3.0#">
T' tha extent possible under law, Michael Slinn has waived all copyright n' related or neighborin' rights t'
Jekyll Plugin Template Collection.
This duty is published from Great North.
</p>
<p class="copyright" id="copyright" xmlns:dct="http://purl.org/dc/terms/" xmlns:vcard="http://www.w3.org/2001/vcard-rdf/3.0#">
<a rel="license" style="float: left; margin-right: 1em; padding-top: 9px; padding-bottom: 2em;"
href="http://creativecommons.org/publicdomain/zero/1.0/">
<img src="http://i.creativecommons.org/p/zero/1.0/88x31.png" style="border-style: none;" alt="CC0" />
</a>
To the extent possible under law,
<a rel="dct:publisher"
href="https://www.mslinn.com/blog/2022/03/28/jekyll-plugin-template-collection.html">
<span property="dct:title">Michael Slinn</span></a>
has waived all copyright and related or neighboring rights to
<span property="dct:title">Jekyll Plugin Template Collection</span>.
This work is published from <span property="vcard:Country" datatype="dct:ISO3166" content="CA"
about="https://www.mslinn.com/blog/2022/03/28/jekyll-plugin-template-collection.html"> Canada</span>.

Notice the copyright has had all the inner HTML removed by my simple translator. With more work (and more code) some of the inner HTML could be retained.

HTML translated to Pirate Talk
<h2>Don't Worry, Be Happy</h2>
<p>
If ye d' not worry, someone else will.
That is their problem.
Enjoy life, it comes at ye fast.
</p>
<p class="copyright" id="copyright" xmlns:dct="http://purl.org/dc/terms/" xmlns:vcard="http://www.w3.org/2001/vcard-rdf/3.0#">
T' tha extent possible under law, Michael Slinn has waived all copyright n' related or neighborin' rights t'
Jekyll Plugin Template Collection.
This duty is published from Great North.
</p>

The translated HTML renders in a web browser like this:

Selecting Pages to Translate

The above pirate_translator plugin modifies every page in the web site. If you want to only translate certain pages, you could take advantage of the fact that page data, including front matter variables, is available to all of the hooks for :documents, :pages, and :posts.

Let's modify the hook so it checks for the existance of a front matter variable called pirate_talk. If present, and has a value, and that value is not false, that page will be translated into Pirate Talk, otherwise it will not be modified. Here is the modified version:

lib/jekyll_hook_examples.rb
def pirate_translator
  proc do |webpage|
    return unless webpage.data['pirate_talk']

    html = Nokogiri.HTML(webpage.output)
    html.css("p").each do |node|
      node.content = TalkLikeAPirate.translate(node.content)
    end
    webpage.output = html
  end
end

demo/_posts/2022/2022-01-01-test.html looks like this:

---
categories: [Jekyll, Ruby]
description: Test post.
date: 2022-03-28
last_modified_at: 2022-04-01
layout: default
title: Test Post
pirate_talk: true
---
<h2>Don't Worry, Be Happy</h2>
<p>
  If you do not worry, someone else will.
  That is their problem.
  Enjoy life, it comes at you fast.
</p>

Jekyll Sub-Commands

Jekyll Commands extend the jekyll executable with subcommands. The official Jekyll documentation is quite brief.

Undocumented But Important

Some important details that the official Jekyll documentation does not mention:

  • Unlike the default Jekyll subcommands, which include jekyll build, jekyll clean, jekyll new, and jekyll serve, Jekyll subcommands defined by a plugin are only available within Jekyll projects that declare the plugin as a dependency.
  • Jekyll subcommands implemented by a plugin are not gem executables. Do not bother reading up on how to make a command-line program in Ruby, because Jekyll subcommands are just Ruby code that Jekyll calls for you.
  • When packaged into a gem, the name of the gem is significant. If the gem's name does not start with jekyll-, any Jekyll sub-command within will not be found. This means your .gemspec file must have a line that looks like this:
    Gem::Specification.new do |spec|
    spec.name = "jekyll-hello"
    end
    This is consistent with the Ruby Gem Naming Conventions, because Jekyll sub-commands of course extend the Jekyll gem.

    Use Dashes for Extensions

    If you’re adding functionality to another gem, use a dash. This usually corresponds to a / in the require statement (and therefore your gem’s directory structure) and a :: in the name of your main class or module.
    Now we know why the implementation of your gem must be defined within a directory called lib/jekyll.
  • Jekyll provides support for subcommands via the Mercenary gem. Contrary to what the Jekyll plugin documentation says, plugin authors do not care about that implementation detail. Do not waste your time trying to figure out how Jekyll uses Mercenary to implement subcommands. All you need to know is that the entry point for your subcommand is a method called init_with_program.
  • Hooks can be registered in a Jekyll command, thereby initiating a causal chain that injects variables, such as the site. Jekyll::Hooks.register(:site, :post_read) do |site| end Jekyll::Hooks.register(:site, :post_read) should be the first hook that could be called after all files are read, and their front matter is parsed.

The Code

Below is the implementation of my jekyll hello sub-command. Follow this pattern closely.

# frozen_string_literal: true

require_relative 'hello/version'

# See https://www.mslinn.com/blog/2022/03/28/jekyll-plugin-template-collection.html#cmds
class Hello < Jekyll::Command
  class << self
    # @param prog [Mercenary::Program]
    def init_with_program(prog)
      prog.command(:hello) do |c|
        c.action do |args, options|
          Jekyll::Hooks.register(:site, :post_read) do |_site|
            Jekyll.logger.info "Hello! args=#{args}; options=#{options}"
            # Your custom code goes here, site is available
            # Register another hook if you need other variables
          end
        end
      end
    end
  end
end

Some comments about the above code:

  • class << self opens up self’s singleton class, so that methods can be redefined for the current self object (which inside a class or module body is the class or module itself). As is usually the case, this techique is used here to define class/module (“static”) methods.
  • Jekyll will call your entry point (init_with_program) and pass in a value for prog, which has type Mercenary::Program.
  • The name of the subcommand (hello) is specified as a symbol and passed to prog.command. If this name does not match the gem suffix, the sub-command will fail.

Building and Running

Here is an example of building the gem and running the subcommand within:

Shell
$ bundle exec rake install && (cd demo; jekyll hello k tx bye)
jekyll-hello 0.1.0 built to pkg/jekyll-hello-0.1.0.gem.
jekyll-hello (0.1.0) installed.
Hello! args=["k", "tx", "bye"]; options={} 

Debugging

There are undoubtedly many ways to debug a Jekyll sub-command. Following are two ways that I use.

Here is the Visual Studio Code launch.json that I set up for debugging this Jekyll plugin:

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "args": ["hello"],
      "cwd": "${workspaceFolder}/demo",
      "type": "Ruby",
      "name": "Debug hello sub-command",
      "program": "~/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/jekyll-4.2.2/exe/jekyll",
      "request": "launch",
    },
    {
      "cwd": "${workspaceRoot}",
      "name": "Attach rdebug-ide",
      "request": "attach",
      "remoteHost": "localhost",
      "remotePort": "1234",
      "remoteWorkspaceRoot": "/",
      "restart": true,
      "showDebuggerOutput": true,
      "stopOnEntry": true,
      "type": "Ruby",
    },
  ]
}

Nail down the desired Jekyll version to the exe/ subdirectory:

Shell
$ bundle binstubs jekyll --path exe

Debug Source

Use this technique when developing code. You do not need to install the gem you are working on when using this technique. A tiny Jekyll site is provided in the demo/ directory.

That site's Gemfile references the gem source code in the parent directory, like this:

Gemfile
group :jekyll_plugins do
  gem 'jekyll-hello', path: '../'
end

The bin/attach script launches Jekyll under control of a debugger.

  1. Tell the script the directory containing the Jekyll site.
    $ attach demo
    ... lots of output ...
    Fast Debugger (ruby-debug-ide 0.7.3, debase 0.2.4.1, file filtering is supported) listens on 0.0.0.0:1234
  2. Set breakpoints in lib/jekyll/hello.rb.
  3. Now switch to the Visual Studio Code debugging view and launch the configuration called Attach rdebug-ide.
😁

Easy!

Debug Installed Gems

Use this technique for a quick inspection of gems that have been installed.

  • Create a new Visual Studio Code project.
  • Load the source from the gem into a Visual Studio Code editor pane. (in my case the code was at ~/.rbenv/versions/3.1.0/lib/ruby/gems/3.1.0/gems/jekyll-hello/)
  • Switch to the Visual Studio Code debugging view.
  • Set your breakpoints in those files.
  • Examine the run configuration for Debug hello sub-command has the same value for the program property as the source path for the gem, above.
  • Launch the configuration called Debug hello sub-command
😁

Easy!