Mike Slinn
Mike Slinn

I've Been Writing Jekyll Plugins

Published 2020-10-03. Last modified 2020-12-31.
Time to read: about 7 minutes.

This article is categorized under Jekyll, Ruby.

This is a Jekyll-powered web site. Jekyll is a free open-source preprocessor that generates static web sites. You can extend Jekyll by using the Liquid language to write includes. Includes are just macros for Jekyll.

I prefer to write and use Jekyll plugins instead of Jekyll includes. Non-trivial Jekyll includes require logic to be expressed in the Liquid language. Liquid is an interesting language, but it is quite verbose, syntax can be awkward, some expressions are impossible to formulate, and there are no debugging tools.

In contrast, plugins are written in Ruby. Plugin usage syntax is more flexible and require less typing for users.

The argument against writing plugins is that the Ruby language is subtle and powerful, and could be overwhelming for novice programmers. However, just as the Apache Spark framework allows novice Scala programmers to write in Just Enough Scala for Spark, and the Ruby on Rails framework allows novice Ruby programmers to write Just Enough Ruby for Rails, writing plugins for the Jekyll framework generally does not require total mastery of Ruby.

Here are some of my plugins. The source code for a plugin can be copied to the clipboard whenever you click on this icon at the top right corner of the code: Copy to clipboard.

A zip file containing all the plugins is available.

Update 2020-12-28

... but wait, there is more! I wrote a LogFactory Ruby library class after publishing this article. Most of these plugins have been retrofitted with LogFactory – calls to LoggerFactory.new.create_logger create a custom logger.

You can use LogFactory in your Jekyll plugins for debugging. I wrote it up separately but log_factory.rb is included in the above zip file.

archive_display

Lists the names and contents of each file in a tar file. For each text file, the following HTML is emitted:

<div class='codeLabel'>{tar_entry.full_name}</div>
<pre data-lt-active='false'><code>{tar_entry.file_contents}</pre>

Binary files are displayed like this:

usr/bin/ruby2.7 (application/x-sharedlib; charset=binary)
Binary file

Syntax

{% archive_display filename.tar %}

Sample output is:

Source Code

Yard docs are here.

# frozen_string_literal: true

# @author Copyright 2020 {https://www.mslinn.com Michael Slinn}
# @license SPDX-License-Identifier: Apache-2.0
#
# Displays information about the contents of tar files
#
# Install dependencies:
#   - Ubuntu: `sudo apt install libmagic-dev`
#   - Mac: `brew install libmagic`
module ArchiveDisplayTag
  require_relative './logger_factory.rb'

  @log = LoggerFactory.new.create_logger('my_tag', Jekyll.configuration({}), :warn, $stderr)

  # accessor allows classes in this module to use the logger
  def self.log
    @log
  end

  class ArchiveDisplay < Liquid::Tag
    # Constructor.
    # @param tag_name [String] is the name of the tag, which we already know.
    # @param archive_name [Hash, String, Liquid::Tag::Parser] the arguments from the web page.
    # @param tokens [Liquid::ParseContext] tokenized command line
    # @return [void]
    def initialize(tag_name, archive_name, tokens)
      super
      archive_name.strip!
      @archive_name = archive_name
    end

    # Method prescribed by the Jekyll plugin lifecycle.
    # @return [String]
    def render(context)
      source = context.registers[:site].config['source']
      tar_name = "#{source}/#{@archive_name}"
      ArchiveDisplayTag.log.info "archive_display: tar_name=#{tar_name}"
      traverse_tar(tar_name)
    end

    private

    # Walks through a `tar` file.
    #
    # Modified from this {https://gist.github.com/sinisterchipmunk/1335041/5be4e6039d899c9b8cca41869dc6861c8eb71f13 gist by sinisterchipmunk }.
    #
    # @param tar_name [String] Name of tar file to examine.
    # @return [String] containing HTML describing the contents of the `tar`.
    def traverse_tar(tar_name)
      require 'rubygems/package'
      require 'ruby-filemagic'
      # sudo apt install libmagic-dev
      # brew install libmagic

      file_magic = FileMagic.new(FileMagic::MAGIC_MIME)
      File.open(tar_name, "rb") do |file|
        Gem::Package::TarReader.new(file) do |tar|
          return tar.each.map { |entry|
            next if entry.file?

            content = entry.read
            fm_type = file_magic.buffer(content)
            {
              name: entry.full_name,
              content: content.strip,
              is_text: (fm_type.start_with? "text"),
              fm_type: fm_type
            }
          }.compact.sort_by { |entry| entry[:name] }.map { |entry|
            heading = "<div class='codeLabel'>#{entry[:name]} <span style='font-size: smaller'>(#{entry[:fm_type]})</span></div>"
            if entry[:is_text]
              "#{heading}\n<pre data-lt-active='false'>#{entry[:content]}</pre>"
            else
              "#{heading}\n<p><i>Binary file</i></pre>"
            end
          }
        end
      end
    end
  end
end

Liquid::Template.register_tag('archive_display', ArchiveDisplayTag::ArchiveDisplay)

Installation

  1. Install libmagic.
    Ubuntu & WSL
    Shell
    $ sudo apt install libmagic-dev
    Mac
    Shell
    $ brew install libmagic
  2. Add this line to Gemfile in your Jekyll site's top-level directory:
    gem 'ruby-filemagic'
  3. Install the ruby-filemagic gem. From your Jekyll site's top-level directory, type:
    Shell
    $ bundle install
  4. Copy archive_display.rb and logger_factory.rb into the _plugins/ directory of your Jekyll site.
  5. Restart Jekyll.

basename, dirname and basename_without_extension

These filters all return portions of a string. They are all defined in the same plugin.

  • basename — Filters a string containing a path, returning the filename and extension.
  • dirname — Filters a string containing a path, returning the portion before the filename and extension.
  • basename_without_extension — Filters a string containing a path, returning the filename without the extension.

Syntax

{{ "blah/blah/filename.ext" | basename }}
{{ "blah/blah/filename.ext" | dirname }}
{{ "blah/blah/filename.ext" | basename_without_extension }}

Source Code

Yard docs are here.

# frozen_string_literal: true

# @author Copyright 2020 Michael Slinn
# @license SPDX-License-Identifier: Apache-2.0
#
# Jekyll filters for working with paths.
module Basename
  # Filters a string containing a path.
  # @return [String] the filename extracted from the path, including the filetype.
  # @example Extracts "filename.ext" from the path
  #   {{ "blah/blah/filename.ext" | basename }}
  def basename(filepath)
    File.basename(filepath)
  end

  # Filters a string containing a path.
  # @return [String] the portion of th path before the filename and extension.
  # @example Extracts "blah/blah" from the path.
  #   {{ "blah/blah/filename.ext" | dirname }}
  def dirname(filepath)
    File.dirname(filepath)
  end

  # Filters a string containing a path.
  # @return the filename without the extension.
  # @example Extracts "filename" from the path.
  #   {{ "blah/blah/filename.ext" | basename_without_extension }}
  def basename_without_extension(filepath)
    File.basename(filepath).split('.')[0...-1].join('.')
  end
end

Liquid::Template.register_filter(Basename)

flexible_include

Jekyll's built-in include tag does not support including files outside of the _includes folder. Originally called include_absolute, this plugin name is now called flexible_include because it no longer just includes absolute file names. This plugin now supports 4 types of includes:

  1. Absolute filenames (first character is /).
  2. Filenames relative to the top-level directory of the Jekyll web site (unnecessary to preface with ./).
  3. Filenames relative to the user home directory (first character is ~).
  4. Executable filenames on the PATH (first character is !).

In addition, filenames that require environment expansion because they contain a $ character are expanded according to the environment variables defined when jekyll build executes.

Syntax

{% flexible_include 'path' %}

The optional parameters can have any name. The included file will have parameters substituted.

Usage Examples

  1. Include files without parameters; all four types of includes are shown.
    {% flexible_include '../../folder/outside/jekyll/site/foo.html' %}
    {% flexible_include 'folder/within/jekyll/site/bar.js' %}
    {% flexible_include '/etc/passwd' %}
    {% flexible_include '~/.ssh/config' %}
    Here is another flexible_include invocation using environment variables:
    {% flexible_include '$HOME/.bash_aliases' %}
  2. Include a file and pass parameters to it.
    {% flexible_include '~/folder/under/home/directory/foo.html'
      param1='yes' param2='green' %}

Source Code

This code lives in a GitHub repository. Yard docs are here.

from, to and until

These filters all return portions of a multiline string. They are all defined in the same plugin. A regular expression is used to specify the match; the simplest regular expression is a string.

  • from — returns the portion beginning with the line that satisfies a regular expression to the end of the multiline string.
  • to — returns the portion from the first line to the line that satisfies a regular expression, including the matched line.
  • until — returns the portion from the first line to the line that satisfies a regular expression, excluding the matched line.

Rubular is a handy online tool to try out regular expressions.

Syntax

The regular expression may be enclosed in single quotes, double quotes, or nothing.

from

All of these examples perform identically.
{{ sourceOfLines | from: 'regex' }}
{{ sourceOfLines | from: "regex" }}
{{ sourceOfLines | from: regex }}

to

All of these examples perform identically.
{{ sourceOfLines | to: 'regex' }}
{{ sourceOfLines | to: "regex" }}
{{ sourceOfLines | to: regex }}

until

All of these examples perform identically.
{{ sourceOfLines | until: 'regex' }}
{{ sourceOfLines | until: "regex" }}
{{ sourceOfLines | until: regex }}

Important: the name of the filter must be followed by a colon (:). If you fail to do that an error will be generated and the Jekyll site building process will halt. The error message looks something like this: Liquid Warning: Liquid syntax error (line 285): Expected end_of_string but found string in "{{ lines | from '2' | until: '4' | xml_escape }}" in /some_directory/some_files.html Liquid Exception: Liquid error (line 285): wrong number of arguments (given 1, expected 2) in /some_directory/some_file.html Error: Liquid error (line 285): wrong number of arguments (given 1, expected 2)

Usage Examples

Some of the following examples use a multiline string containing 5 lines, called lines, which was created this way:

{% capture lines %}line 1
line 2
line 3
line 4
line 5
{% endcapture %}

Other examples use a multiline string containing the contents of .gitignore, which looks like this:

.gitignore
*.code-workspace
.bsp/
project/
target/
*.gz
*.sublime*
*.swp
*.out
*.Identifier
*.log
.idea*
*.iml
*.tmp
*~
~*
.DS_Store
.idea
.jekyll-cache/
.jekyll-metadata
.makeAwsBucketAndDistribution.log
.sass-cache/
.yardoc/
__pycache__/
__MACOSX
_build/
_package/
_site/
bin/*.class
doc/
jekyll/doc/
node_modules/
Notepad++/
out/
package/
instances.json
rescue_ubuntu2010
rescue_ubuntu2010.b64
landingPageShortName.md
test.html
RUNNING_PID
mslinn_jekyll_plugins.zip
cloud9.tar
cloud9.zip
mslinn_aws.tar

From the third line of string

These examples return the lines of the file from the beginning of the until a line with the string "3" is found, including the matched line. The only difference between the examples is the delimiter around the regular expression.

{{ lines | from: '3' }}
{{ lines | from: "3" }}
{{ lines | from: 3 }}

These all generate:

line 3
line 4
line 5

From Line In a File Containing 'PID'

{% capture gitignore %}{% flexible_include '.gitignore' %}{% endcapture %}
{{ gitignore | from: 'PID' | xml_escape }}

This generates:

RUNNING_PID
mslinn_jekyll_plugins.zip
cloud9.tar
cloud9.zip
mslinn_aws.tar

To the third line of string

These examples return the lines of the file from the first line until a line with the string "3" is found, including the matched line. The only difference between the examples is the delimiter around the regular expression.

{{ lines | to: '3' }}
{{ lines | to: "3" }}
{{ lines | to: 3 }}

These all generate:

line 1
line 2
line 3

To Line In a File Containing 'idea'

{{ gitignore | to: 'idea' }}

This generates:

*.code-workspace
.bsp/
project/
target/
*.gz
*.sublime*
*.swp
*.out
*.Identifier
*.log
.idea*

Until the third line of string

These examples return the lines of the file until a line with the string "3" is found, excluding the matched line. The only difference between the examples is the delimiter around the regular expression.

{{ lines | until: '3' }}
{{ lines | until: "3" }}
{{ lines | until: 3 }}

These all generate:

line 1
line 2

Until Line In a File Containing 'idea'

{{ gitignore | until: 'idea' }}

This generates:

*.code-workspace
.bsp/
project/
target/
*.gz
*.sublime*
*.swp
*.out
*.Identifier
*.log

From the string "2" until the string "4"

These examples return the lines of the file until a line with the string "3" is found, excluding the matched line. The only difference between the examples is the delimiter around the regular expression.

{{ lines | from: '2' | until: '4' }}
{{ lines | from: "2" | until: "4" }}
{{ lines | from: 2 | until: 4 }}

These all generate:

line 2
line 3

From Line In a File Containing 'idea' Until no match

The .gitignore file does not contain the string xx. If we attempt to match against that string the remainder of the file is returned for the to and until filter, and the empty string is returned for the from filter.

{{ gitignore | from: 'PID' | until: 'xx' }}

This generates:

RUNNING_PID
mslinn_jekyll_plugins.zip
cloud9.tar
cloud9.zip
mslinn_aws.tar

More Complex Regular Expressions

The from, to and until filters can all accept more complex regular expressions. This regular expression matches lines that have either the string sun or cloud at the beginning of the line.

{{ gitignore | from: '^(cloud|sun)' }}

This generates:

cloud9.tar
cloud9.zip
mslinn_aws.tar

Source Code

Yard docs are here.

# frozen_string_literal: true

# @author Copyright 2020 Michael Slinn
# @license SPDX-License-Identifier: Apache-2.0
# Jekyll filters for working with multiline strings.
module FromToUntil
  # Filters a multiline string, returning the portion beginning with the line that satisfies a regex.
  # The regex could be enclosed in single quotes, double quotes, or nothing.
  # @param input_strings [String] The multi-line string to scan
  # @param regex [String] The regular expression to match against each line of `input_strings` until found
  # @return [String] The remaining multi-line string
  # @example Returns remaining lines starting with the line containing the word `module`.
  #   {{ flexible_include '/blog/2020/10/03/jekyll-plugins.html' | from 'module' }}
  def from(input_strings, regex)
    return '' unless check_parameters(input_strings, regex)

    regex = remove_quotations(regex.to_s.strip)
    matched = false
    result = ''
    input_strings.each_line do |line|
      matched = true if !matched && line =~ /#{regex}/
      result += line if matched
    end
    result
  end

  # Filters a multiline string, returning the portion from the beginning until and including the line that satisfies a regex.
  # The regex could be enclosed in single quotes, double quotes, or nothing.
  # @example Returns lines up to and including the line containing the word `module`.
  #   {{ flexible_include '/blog/2020/10/03/jekyll-plugins.html' | to 'module' }}
  def to(input_strings, regex)
    return '' unless check_parameters(input_strings, regex)

    regex = remove_quotations(regex.to_s.strip)
    result = ''
    input_strings.each_line do |line|
      result += line
      return result if line =~ /#{regex}/
    end
    result
  end

  # Filters a multiline string, returning the portion from the beginning until but not including the line that satisfies a regex.
  # The regex could be enclosed in single quotes, double quotes, or nothing.
  # @example Returns lines up to but not including the line containing the word `module`.
  #   {{ flexible_include '/blog/2020/10/03/jekyll-plugins.html' | until 'module' }}
  def until(input_strings, regex)
    return '' unless check_parameters(input_strings, regex)

    regex = remove_quotations(regex.to_s.strip)
    result = ''
    input_strings.each_line do |line|
      return result if line =~ /#{regex}/
      result += line
    end
    result
  end

  private

  def check_parameters(input_strings, regex)
    if input_strings.nil? || input_strings.empty? then
      puts "Warning: Plugin 'from' received no input."
      return false
    end

    regex = regex.to_s
    if regex.nil? || regex.empty? then
      puts "Warning: Plugin 'from' received no regex."
      return false
    end
    true
  end

  def remove_quotations(str)
    str = str.slice(1..-2) if (str.start_with?('"') && str.end_with?('"')) ||
                              (str.start_with?("'") && str.end_with?("'"))
    str
  end
end

Liquid::Template.register_filter(FromToUntil)

Installation

  1. Copy from_to_until.rb and logger_factory.rb into the _plugins/ directory of your Jekyll site.
  2. Restart Jekyll.

href

Generates an a href tag with target="_blank" and rel=nofollow.

Syntax

{% href url text to display %}

The url should not be enclosed in quotes.

Usage Examples

Default

{% href https://www.mslinn.com The Awesome %}

This generates:

<a href='https://www.mslinn.com' target='_blank' rel='nofollow'>The Awesome</a>

Which renders as: The Awesome

follow

{% href follow https://www.mslinn.com The Awesome %}

This generates:

<a href='https://www.mslinn.com' target='_blank'>The Awesome</a>

notarget

{% href notarget https://www.mslinn.com The Awesome %}

This generates:

<a href='https://www.mslinn.com' rel='nofollow'>The Awesome</a>

follow notarget

{% href follow notarget https://www.mslinn.com The Awesome %}

This generates:

<a href='https://www.mslinn.com'>The Awesome</a>

Source Code

Yard docs are here.

# @author Copyright 2020 Michael Slinn
# @license SPDX-License-Identifier: Apache-2.0
#
# Generates an href.
# Note that the url should not be enclosed in quotes.
#
# If the link starts with 'http' or `match` is specified:
#   The link will open in a new tab or window
#   The link will include `rel=nofollow` for SEO purposes.
#
# To suppress the `nofollow` attribute, preface the link with the word `follow`.
# To suppress the `target` attribute, preface the link with the word `notarget`.
#
# The `match` option looks through the pages collection for a URL with containing the provided substring.
# Match implies follow and notarget.
#
# If a section called plugin-vars exists then its name/value pairs are available for substitution.
#   plugin-vars:
#     django-github: 'https://github.com/django/django/blob/3.1.7'
#     django-oscar-github: 'https://github.com/django-oscar/django-oscar/blob/3.0.2'
#
#
# @example General form
#   {% href [follow] [notarget] [match] url text to display %}
#
# @example Generates `nofollow` and `target` attributes.
#   {% href https://mslinn.com The Awesome %}
#
# @example Does not generate `nofollow` or `target` attributes.
#   {% href follow notarget https://mslinn.com The Awesome %}
#
# @example Does not generate `nofollow` attribute.
#   {% href follow https://mslinn.com The Awesome %}
#
# @example Does not generate `target` attribute.
#   {% href notarget https://mslinn.com The Awesome %}
#
# @example Matches page with URL containing abc.
#   {% href match abc The Awesome %}
# @example Matches page with URL containing abc.
#   {% href match abc.html#tag The Awesome %}
#
# @example Substitute name/value pair for the django-github variable:
# {% href {{django-github}}/django/core/management/__init__.py#L398-L401
#   <code>django.core.management.execute_from_command_line</code> %}

module HrefTag
  class ExternalHref < Liquid::Tag
    # Constructor.
    # @param tag_name [String] is the name of the tag, which we already know.
    # @param command_line [Hash, String, Liquid::Tag::Parser] the arguments from the web page.
    # @param tokens [Liquid::ParseContext] tokenized command line
    # @return [void]
    def initialize(tag_name, command_line, tokens)
      super

      @follow = " rel='nofollow'"
      @match = false
      @target = " target='_blank'"

      tokens = command_line.strip.split(" ")

      followIndex = tokens.index("follow")
      if followIndex then
        tokens.delete_at(followIndex)
        @follow = ""
      end

      targetIndex = tokens.index("notarget")
      if targetIndex then
        tokens.delete_at(targetIndex)
        @target = ""
      end

      matchIndex = tokens.index("match")
      if matchIndex then
        tokens.delete_at(matchIndex)
        @follow = ""
        @match = true
        @target = ""
      end

      @link = tokens.shift

      unless @link.start_with? "http"
        @follow = ""
        @target = ""
      end

      @text = tokens.join(" ").strip
      @text = if @text.empty? then @link else @text end
    end

    def match(context)
      site = context.registers[:site]
      config = site.config['href']
      die_if_nomatch = !config.nil? && config['nomatch'] && config['nomatch']=='fatal'

      path, fragment = @link.split('#')

      # puts "@link=#{@link}"
      # puts "site.posts[0].url = #{site.posts.docs[0].url}"
      # puts "site.posts[0].path = #{site.posts.docs[0].path}"
      posts = site.posts.docs.select { |x| x.url.include?(path) }
      case posts.length
      when 0
        if die_if_nomatch then
          abort "href error: No url matches '#{@link}'"
        else
          @link = "#"
          @text = "<i>#{@link} is not available</i>"
        end
      when 1
        @link = "#{@link}\##{fragment}" if fragment
      else
        abort "Error: More than one url matched: #{ matches.join(", ")}"
      end
    end

    def replaceVars(context, link)
      variables = context.registers[:site].config['plugin-vars']
      variables.each do |name, value|
        #puts "#{name}=#{value}"
        link = link.gsub("{{#{name}}}", value)
      end
      link
    end

    # Method prescribed by the Jekyll plugin lifecycle.
    # @return [String]
    def render(context)
      if (@match) then match(context) end
      link = replaceVars(context, @link)
      # puts "@link=#{@link}; link=#{link}"
      "<a href='#{link}'#{@target}#{@follow}>#{@text}</a>"
    end
  end
end

Liquid::Template.register_tag('href', HrefTag::ExternalHref)

Installation

  1. Copy href.rb and logger_factory.rb into the _plugins/ directory of your Jekyll site.
  2. Restart Jekyll.

This plugin generates a link to the given URI, which must be a file on the server. The file name can be absolute or relative to the top-level directory of the web site.

Syntax

{% link uri %}

Usage Example

{% link cloud9.tar %}

Generates:

<a href="/cloud9.tar"><code>cloud9.tar</code></a> (4.5 KB)

Which renders as: cloud9.tar (4.5 KB)

Source Code

Yard docs are here.

# @author Copyright 2020 Michael Slinn
# @license SPDX-License-Identifier: Apache-2.0

module LinkTag
  # Generates an href to a file for the user to download from the site.
  # Also shows the file size in a human-readable format.
  class Linker < Liquid::Tag
    # Constructor.
    # @param tag_name [String] is the name of the tag, which we already know.
    #   Contains the name of the file, relative to the website top level directory
    # @param text [Hash, String, Liquid::Tag::Parser] the arguments from the web page.
    # @param tokens [Liquid::ParseContext] tokenized command line
    # @return [void]
    def initialize(tag_name, text, tokens)
      super(tag_name, text, tokens)
      @filename = text.delete('"').delete("'").strip
    end

    # Method prescribed by the Jekyll plugin lifecycle.
    # @return [String]
    def render(context)
      source = context.registers[:site].config['source']
      file_fq = File.join(source, @filename)
      abort("Error: '#{file_fq}' not found. See the link tag in") unless File.exist?(file_fq)

      "<a href='/#{@filename}'><code>#{@filename}</code></a> (#{as_size(File.size(file_fq))})"
    end

    def as_size(s)
      units = %w[B KB MB GB TB]

      size, unit = units.reduce(s.to_f) do |(fsize, _), utype|
        fsize > 512 ? [fsize / 1024, utype] : (break [fsize, utype])
      end

      "#{size > 9 || size.modulo(1) < 0.1 ? '%d' : '%.1f'} %s" % [size, unit]
    end
  end
end

Liquid::Template.register_tag('link', LinkTag::Linker)

Installation

  1. Copy link.rb and logger_factory.rb into the _plugins/ directory of your Jekyll site.
  2. Restart Jekyll.

make_archive

Creates tar and zip archives according to the make_archive entry in _config.yml. In production mode, the archives are built each time Jekyll generates the web site. In development mode, the archives are only built if they do not already exist, or if delete: true is set for that archive in _config.yml. Archives are placed in the top-level of the Jekyll project, and are copied to _site by Jekyll's normal build process. Entries are created in .gitignore for each of the generated archives.

File Specifications

This plugin supports 4 types of file specifications:

  1. Absolute filenames (start with /).
  2. Filenames relative to the top-level directory of the Jekyll web site (Do not preface with . or /).
  3. Filenames relative to the user home directory (preface with ~).
  4. Executable filenames on the PATH (preface with !).

_config.yml Syntax

Any number of archives can be specified. Each archive has 3 properties: archive_name, delete (defaults to true) and files. Take care that the dashes have exactly 2 spaces before them, and that the 2 lines following each dash have exactly 4 spaces in front.

make_archive:
  -
    archive_name: cloud9.zip
    delete: true  # This is the default, and need not be specified.
    files: [ index.html, error.html, ~/.ssh/config, /etc/passwd, '!update' ]
  -
    archive_name: cloud9.tar
    delete: false  # Do not overwrite the archive if it already exists
    files: [ index.html, error.html, ~/.ssh/config, /etc/passwd, '!update' ]

Source Code

Yard docs are here.

# frozen_string_literal: true

# @author Copyright 2020 Michael Slinn
# @license SPDX-License-Identifier: Apache-2.0

require 'fileutils'
require 'ptools'
require 'rubygems'
require 'rubygems/package'
require 'tmpdir'
require 'zlib'

# Makes tar or zip file based on _config.yml entry
class MakeArchive < Jekyll::Generator
  require_relative 'logger_factory'
  priority :high

  def initialize(config)
    super(config)
    @log = LoggerFactory.new.create_logger('make_archive', config, :warn, $stderr)
  end

  # Method prescribed by the Jekyll plugin lifecycle.
  # @param site [Jekyll.Site] Automatically provided by Jekyll plugin mechanism
  # @return [void]
  def generate(site)
    @live_reload = site.config['livereload']

    archive_config = site.config['make_archive']
    return if archive_config.nil?

    archive_config.each do |config|
      @archive_name = config['archive_name'] # Relative to _site
      abort 'Error: archive_name was not specified in _config.yml.' if @archive_name.nil?

      if @archive_name.end_with? '.zip'
        @archive_type = :zip
      elsif @archive_name.end_with? '.tar'
        @archive_type = :tar
      else
        abort "Error: archive must be zip or tar; #{@archive_name} is of an unknown archive type."
      end

      @archive_files = config['files'].compact
      abort 'Error: archive files were not specified in _config.yml.' if @archive_files.nil?

      delete_archive = config['delete']
      @force_delete = delete_archive.nil? ? !@live_reload : delete_archive

      @log.info "@archive_name=#{@archive_name}; @live_reload=#{@live_reload}; @force_delete=#{@force_delete}; @archive_files=#{@archive_files}"

      doit site.source
      site.keep_files << @archive_name
    end
  end

  private

  def doit(source)
    archive_name_full = "#{source}/#{@archive_name}"
    archive_exists = File.exist?(archive_name_full)
    return if archive_exists && @live_reload

    @log.info "#{archive_name_full} exists? #{archive_exists}"
    if archive_exists && @force_delete
      @log.info "Deleting old #{archive_name_full}"
      File.delete(archive_name_full)
    end

    if !archive_exists || @force_delete
      @log.info "Making #{archive_name_full}"
      case @archive_type
      when :tar
        make_tar(archive_name_full, source)
      when :zip
        make_zip(archive_name_full, source)
      end
    end

    @log.info "Looking for #{@archive_name} in .gitignore..."
    return if File.foreach('.gitignore').grep(/^#{@archive_name}\n?/).any?

    @log.info "#{@archive_name} not found in .gitignore, adding entry."
    File.open('.gitignore', 'a') do |f|
      f.puts File.basename(@archive_name)
    end
  end

  def make_tar(tar_name, source)
    Dir.mktmpdir do |dirname|
      @archive_files.each do |filename|
        fn, filename_full = qualify_file_name(filename, source)
        @log.info "Copying #{filename_full} to temporary directory #{dirname}; filename=#{filename}; fn=#{fn}"
        FileUtils.copy(filename_full, dirname)
      end
      write_tar(tar_name, dirname)
    end
  end

  def write_tar(tar_name, dirname)
    # Modified from https://gist.github.com/sinisterchipmunk/1335041/5be4e6039d899c9b8cca41869dc6861c8eb71f13
    File.open(tar_name, 'wb') do |tarfile|
      Gem::Package::TarWriter.new(tarfile) do |tar|
        Dir[File.join(dirname, '**/*')].each do |filename|
          write_tar_entry(tar, dirname, filename)
        end
      end
    end
  end

  def write_tar_entry(tar, dirname, filename)
    mode = File.stat(filename).mode
    relative_file = filename.sub(%r{^#{Regexp.escape dirname}/?}, '')
    if File.directory?(filename)
      tar.mkdir relative_file, mode
    else
      tar.add_file relative_file, mode do |tf|
        File.open(filename, 'rb') { |f| tf.write f.read }
      end
    end
  end

  def make_zip(zip_name, source)
    require 'zip'
    Zip.default_compression = Zlib::DEFAULT_COMPRESSION
    Zip::File.open(zip_name, Zip::File::CREATE) do |zipfile|
      @archive_files.each do |filename|
        filename_in_archive, filename_original = qualify_file_name(filename, source)
        @log.info "make_zip: adding #{filename_original} to #{zip_name} as #{filename_in_archive}"
        zipfile.add(filename_in_archive, filename_original)
      end
    end
  end

  # @return tuple of filename (without path) and fully qualified filename
  def qualify_file_name(path, source)
    case path[0]
    when '/' # Is the file absolute?
      @log.info "Absolute filename: #{path}"
      [File.basename(path), path]
    when '!' # Should the file be found on the PATH?
      clean_path = path[1..-1]
      filename_full = File.which(clean_path)
      abort "Error: #{clean_path} is not on the PATH." if filename_full.nil?

      @log.info "File on PATH: #{clean_path} -> #{filename_full}"
      [File.basename(clean_path), filename_full]
    when '~' # Is the file relative to user's home directory?
      clean_path = path[2..-1]
      filename_full = File.join(ENV['HOME'], clean_path)
      @log.info "File in home directory: #{clean_path} -> #{filename_full}"
      [File.basename(clean_path), filename_full]
    else # The file is relative to the Jekyll website top-level directory
      @log.info "Relative filename: #{path}"
      [File.basename(path), File.join(source, path)] # join yields the fully qualified path
    end
  end
end

Installation

  1. Copy make_archive.rb and logger_factory.rb into the _plugins/ directory of your Jekyll site.
  2. Restart Jekyll.

pre and noselect

This plugin provides 2 tags that frequently work together:

  1. A pre block tag that can optionally display a copy button.
  2. A noselect tag that can renders HTML content passed to it unselectable.

Syntax

{% pre [copyButton] [shell] [headline words] %}
Contents of pre tag
{% endpre %}
{% pre [copyButton] %}
{% noselect [text string]%}Contents of pre tag
{% endpre %}

Usage Example 1

This example does not generate a copy button and does not demonstrate noselect.

{% pre %}
Contents of pre tag
{% endpre %}

Generates:

<pre data-lt-active='false' class='maxOneScreenHigh copyContainer' id='id02f328bf82eb'>Contents of pre tag</pre>

Which renders as:

Contents of pre tag

Usage Example 2

This example generates a copy button and does not demonstrate noselect.

{% pre copyButton %}Contents of pre tag
{% endpre %}

Generates:

<pre data-lt-active='false' class='maxOneScreenHigh copyContainer' id='id11205ab91be3'><button class='copyBtn' data-clipboard-target='#id11205ab91be3' title='Copy to clipboard'><img src='/assets/images/clippy.svg' alt='Copy to clipboard' style='width: 13px'></button>Contents of pre tag</pre>

Which renders as:

Contents of pre tag

Usage Example 3

This example generates a copy button and does demonstrates the default usage of noselect, which renders an unselectable dollar sign followed by a space.

{% pre copyButton %}
{% noselect %}Contents of pre tag
{% endpre %}

Generates:

<pre data-lt-active='false' class='maxOneScreenHigh copyContainer' id='id0ff841250916'><button class='copyBtn' data-clipboard-target='#id0ff841250916' title='Copy to clipboard'><img src='/assets/images/clippy.svg' alt='Copy to clipboard' style='width: 13px'></button><span class='unselectable'>$ </span>Contents of pre tag</pre>

Which renders as:

$ Contents of pre tag

Usage Example 4

This example generates a copy button and does demonstrates the noselect being used twice: the first time to render an unselectable custom prompt, and the second time to render unselectable output.

{% pre copyButton %}{% noselect >>> %}Contents of pre tag
{% noselect How now brown cow%}
{% endpre %}

Generates:

<pre data-lt-active='false' class='maxOneScreenHigh copyContainer' id='idd3ac2d82564f'><button class='copyBtn' data-clipboard-target='#idd3ac2d82564f' title='Copy to clipboard'><img src='/assets/images/clippy.svg' alt='Copy to clipboard' style='width: 13px'></button><span class='unselectable'>>>> </span>contents of pre tag
<span class='unselectable'>How now brown cow</span></pre>

Which renders as:

>>> contents of pre tag
How now brown cow

CSS

Here are the CSS declarations that I defined pertaining to the pre and noselect tags:

.maxOneScreenHigh {
  max-height: 500px;
}

.unselectable {
  color: #7922f9;
  -moz-user-select: none;
  -khtml-user-select: none;
  user-select: none;
}

Comprehensive Example

The code I wrote to generate the above CSS was a good example of how the plugins work together:

{% capture css %}{% flexible_include '_sass/mystyle.scss' %}{% endcapture %}
{% pre copyButton %}{{ css | from: '.copyBtn' | to: '^$' | strip }}

{{ css | from: '.copyContainer' | to: '^$' | strip }}

{{ css | from: '.maxOneScreenHigh' | to: '^$' | strip }}

{{ css | from: '.unselectable' | to: '^$' | strip }}
{% endpre %}

Source Code

Yard docs are here.

# frozen_string_literal: true

# @author Copyright 2020 {https://www.mslinn.com Michael Slinn}
# @license SPDX-License-Identifier: Apache-2.0

require 'securerandom'

module PreTag
  # """
  #   \\{% pre %}
  #   Content here
  #   \\{% endpre %}
  #
  #   \\{% pre copyButton %}
  #   Content here
  #   \\{% endpre %}"""
  #
  #   \\{% pre shell %}
  #   Content here
  #   \\{% endpre %}
  #
  #   \\{% pre copyButton shell %}
  #   Content here
  #   \\{% endpre %}
  #
  #   \\{% pre copyButton label %}
  #   Content here
  #   \\{% endpre %}"""
  class PreTagBlock < Liquid::Block
    @@prefix = "<button class='copyBtn' data-clipboard-target="
    @@suffix = " title='Copy to clipboard'><img src='/assets/images/clippy.svg' " \
      "alt='Copy to clipboard' style='width: 13px'></button>"

    def self.make_copy_button(pre_id)
      "#{@@prefix}'##{pre_id}'#{@@suffix}"
    end

    def self.make_pre(make_copy_button, label, content)
      label = if label.to_s.empty? then
        ''
      elsif label.to_s.downcase.strip == 'shell'
        "<div class='codeLabel unselectable' data-lt-active='false'>Shell</div>"
      else
         "<div class='codeLabel unselectable' data-lt-active='false'>#{label}</div>"
      end
      pre_id = "id#{SecureRandom.hex(6)}"
      copy_button = make_copy_button ? PreTagBlock.make_copy_button(pre_id) : ''
      "#{label}<pre data-lt-active='false' class='maxOneScreenHigh copyContainer' id='#{pre_id}'>#{copy_button}#{content.strip}</pre>"
    end

    # Constructor.
    # @param tag_name [String] is the name of the tag, which we already know.
    # @param text [Hash, String, Liquid::Tag::Parser] the arguments from the web page.
    # @param tokens [Liquid::ParseContext] tokenized command line
    # @return [void]
    def initialize(tag_name, text, tokens)
      super(tag_name, text, tokens)
      text = '' if text.nil?
      text.strip!
      @make_copy_button = text.include? 'copyButton'
      remaining_text = text.sub('copyButton', '').strip
      #puts "@make_copy_button = '#{@make_copy_button}'; text = '#{text}'; remaining_text = '#{remaining_text}'"
      @label = remaining_text
    end

    # Method prescribed by the Jekyll plugin lifecycle.
    # @return [String]
    def render(context)
      content = super
      #puts "@make_copy_button = '#{@make_copy_button}'; @label = '#{@label}'"
      PreTagBlock.make_pre(@make_copy_button, @label, content)
    end
  end

  # """\\{% noselect %} or \\{% noselect this all gets copied.
  # Also, space before the closing percent is signficant %}"""
  class UnselectableTag < Liquid::Tag
    def initialize(tag_name, text, tokens)
      super(tag_name, text, tokens)
      @content = text
      # puts "UnselectableTag: content1= '#{@content}'"
      @content = '$ ' if @content.nil? || @content.empty?
      # puts "UnselectableTag: content2= '#{@content}'"
    end

    def render(_)
      "<span class='unselectable'>#{@content}</span>"
    end
  end
end

Liquid::Template.register_tag('pre', PreTag::PreTagBlock)
Liquid::Template.register_tag('noselect', PreTag::UnselectableTag)

Installation

  1. Copy pre.rb and logger_factory.rb into the _plugins/ directory of your Jekyll site.
  2. Restart Jekyll.

random_hex_string

This Liquid filter generates a random hexadecimal string of any length. Each byte displays as two characters. You can specify the number of bytes in the hex string; if you do not, 6 random bytes (12 characters) will be generated.

Usage Example

This example generates a random hex string 6 bytes long and stores the result in a Liquid variable called id. Both of the following do the same thing:

{% assign id = random_hex_string %}
{% assign id = random_hex_string 6 %}

The generated 6 bytes (12 characters) might be: 7e6f4ca11b6c.

Source Code

Yard docs are here.

# frozen_string_literal: true

# @author Copyright 2020 {https://www.mslinn.com Michael Slinn}
# @license SPDX-License-Identifier: Apache-2.0
module RandomHex
  # Outputs a string of random hexadecimal characters of any length.
  # Defaults to a six-character string.
  # @example Generate 6 random characters.
  #   {{ random_hex_string }}
  # @example Generate 20 random characters.
  #   {{ random_hex_string 10 }}
  class RandomNumberTag < Liquid::Tag
    # Called by Jekyll only once to register the module.
    # @param tag_name [String] Describe this parameter's purpose
    # @param text [String] Describe this parameter's purpose
    # @param context [String] Describe this parameter's purpose
    # @return [String, nil] Describe the return value
    def initialize(tag_name, text, context)
      super#(tag_name, text, context)
      text.to_s.strip!
      if text.empty?
        @n = 6
      else
        tokens = text.split(' ')
        abort "random_hex_string error - more than one token was provided: '#{text}'" if tokens.length > 1
        not_integer = !Integer(text, exception: false)
        abort "random_hex_string error: '#{text}' is not a valid integer" if not_integer
        @n = text.to_i
      end
    end

    def render(_)
      require 'securerandom'
      SecureRandom.hex(@n)
    end
  end
end

Liquid::Template.register_tag('random_hex_string', RandomHex::RandomNumberTag)

Installation

  1. Copy random_hex.rb and logger_factory.rb into the _plugins/ directory of your Jekyll site.
  2. Restart Jekyll.

site_inspector

Dumps lots of information from site when enabled by the site_inspector setting in _config.yml.

_config.yml Syntax

site_inspector: true   # Run in development mode
site_inspector: force  # Run in development and production modes
site_inspector: false  # The default is to not run

Sample Output

site is of type Jekyll::Site
site.time = 2020-10-05 05:18:27 -0400
site.config['env']['JEKYLL_ENV'] = development
site.collections.posts
site.collections.expertArticles
site.config.source = '/mnt/_/www/www.mslinn.com'
site.config.destination = '/mnt/_/www/www.mslinn.com/_site'
site.config.collections_dir = ''
site.config.plugins_dir = '_plugins'
site.config.layouts_dir = '_layouts'
site.config.data_dir = '_data'
site.config.includes_dir = '_includes'
site.config.collections = '{"posts"=>{"output"=>true, "permalink"=>"/blog/:year/:month/:day/:title:output_ext"}, "expertArticles"=>{"output"=>true, "relative_directory"=>"_expertArticles", "sort_by"=>"order"}}'
site.config.safe = 'false'
site.config.include = '[".htaccess"]'
site.config.exclude = '["_bin", ".ai", ".git", ".github", ".gitignore", "Gemfile", "Gemfile.lock", "script", ".jekyll-cache/assets"]'
site.config.keep_files = '[".git", ".svn", "cloud9.tar"]'
site.config.encoding = 'utf-8'
site.config.markdown_ext = 'markdown,mkdown,mkdn,mkd,md'
site.config.strict_front_matter = 'false'
site.config.show_drafts = 'true'
site.config.limit_posts = '0'
site.config.future = 'true'
site.config.unpublished = 'false'
site.config.whitelist = '[]'
site.config.plugins = '["classifier-reborn", "html-proofer", "jekyll", "jekyll-admin", "jekyll-assets", "jekyll-docs", "jekyll-environment-variables", "jekyll-feed", "jekyll-gist", "jekyll-sitemap", "kramdown"]'
site.config.markdown = 'kramdown'
site.config.lsi = 'false'
site.config.excerpt_separator = '

'
site.config.incremental = 'true'
site.config.detach = 'false'
site.config.port = '4000'
site.config.host = '127.0.0.1'
site.config.baseurl = ''
site.config.show_dir_listing = 'false'
site.config.permalink = '/blog/:year/:month/:day/:title:output_ext'
site.config.paginate_path = '/page:num'
site.config.timezone = ''
site.config.quiet = 'false'
site.config.verbose = 'false'
site.config.defaults = '[]'
site.config.liquid = '{"error_mode"=>"warn", "strict_filters"=>false, "strict_variables"=>false}'
site.config.rdiscount = '{"extensions"=>[]}'
site.config.redcarpet = '{"extensions"=>[]}'
site.config.kramdown = '{"auto_ids"=>true, "toc_levels"=>"1..6", "entity_output"=>"as_char", "smart_quotes"=>"lsquo,rsquo,ldquo,rdquo", "input"=>"GFM", "hard_wrap"=>false, "footnote_nr"=>1, "show_warnings"=>false}'
site.config.author = 'Mike Slinn'
site.config.compress_html = '{"blanklines"=>false, "clippings"=>"all", "comments"=>[""], "endings"=>"all", "ignore"=>{"envs"=>["development"]}, "profile"=>false, "startings"=>["html", "head", "body"]}'
site.config.email = 'mslinn@mslinn.com'
site.config.feed = '{"categories"=>["AI", "Blockchain", "Scala", "Software-Expert"]}'
site.config.ignore_theme_config = 'true'
site.config.site_inspector = 'false'
site.config.make_archive = '[{"archive_name"=>"cloud9.tar", "delete"=>true, "files"=>["!killPortFwdLocal", "!killPortFwdOnJumper", "!tunnelToJumper"]}]'
site.config.sass = '{"style"=>"compressed"}'
site.config.title = 'Mike Slinn'
site.config.twitter = '{"username"=>"mslinn", "card"=>"summary"}'
site.config.url = 'http://localhost:4000'
site.config.livereload = 'true'
site.config.livereload_port = '35729'
site.config.serving = 'true'
site.config.watch = 'true'
site.config.assets = '{}'
site.config.tag_data = '[]'
site.keep_files: [".git", ".svn", "cloud9.tar"]

Source Code

Yard docs are here.

# frozen_string_literal: true

# @author Copyright 2020 {https://www.mslinn.com Michael Slinn}
# @license SPDX-License-Identifier: Apache-2.0
#
# Dumps lots of information from `site` if in `development` mode and `site_inspector: true` in `_config.yml`.
class SiteInspector < Jekyll::Generator
  require_relative 'logger_factory'

  def initialize(config)
    super(config)
    @log = LoggerFactory.new.create_logger('site_inspector', config, :warn, $stderr)
  end


  # Displays information about the Jekyll site
  #
  # @param site [Jekyll.Site] Automatically provided by Jekyll plugin mechanism
  # @return [void]
  def generate(site)
    mode = site.config['env']['JEKYLL_ENV']

    config = site.config['site_inspector']
    return if config.nil?

    inspector_enabled = config != false
    return unless inspector_enabled

    force = config == 'force'
    return unless force || mode == 'development'

    @log.info "site is of type #{site.class}"
    @log.info "site.time = #{site.time}"
    @log.info "site.config['env']['JEKYLL_ENV'] = #{mode}"
    site.collections.each do |key, _|
      puts "site.collections.#{key}"
    end

    # key env contains all environment variables, quite verbose so output is suppressed
    site.config.sort.each { |key, value| @log.info "site.config.#{key} = '#{value}'" unless key == 'env' }

    site.data.sort.each { |key, value| @log.info "site.data.#{key} = '#{value}'" }
    # site.documents.each {|key, value| @log.info "site.documents.#{key}" } # Generates too much output!
    @log.info "site.keep_files: #{site.keep_files.sort}"
    # site.pages.each {|key, value| @log.info "site.pages.#{key}'" } # Generates too much output!
    # site.posts.each {|key, value| @log.info "site.posts.#{key}" }  # Generates too much output!
    site.tags.sort.each { |key, value| @log.info "site.tags.#{key} = '#{value}'" }
  end
end

Installation

  1. Copy site_inspector.rb and logger_factory.rb into the _plugins/ directory of your Jekyll site.
  2. Restart Jekyll.