Published 2020-10-03. Last modified 2020-12-31.
Time to read: about 7 minutes.
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: .
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:
Binary file
Syntax
{% archive_display filename.tar %}
Sample output is:
Source Code
# 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 @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
- Install
libmagic
.Ubuntu & WSL$ sudo apt install libmagic-dev
Mac$ brew install libmagic
- Add this line to
Gemfile
in your Jekyll site's top-level directory:gem 'ruby-filemagic'
- Install the
ruby-filemagic
gem. From your Jekyll site's top-level directory, type:$ bundle install
- Copy
archive_display.rb
andlogger_factory.rb
into the_plugins/
directory of your Jekyll site. - 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
# 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:
- Absolute filenames (first character is
/
). - Filenames relative to the top-level directory of the Jekyll web site (unnecessary to preface with
./
). - Filenames relative to the user home directory (first character is
~
). - Executable filenames on the
PATH
(first character is!
).
In addtion, 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' optionalParam1='yes' optionalParam2='green' %}
The optional parameters can have any name. The included file will have parameters substituted.
Usage Examples
- 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 anotherflexible_include
invocation using environment variables:{% flexible_include '$HOME/.bash_aliases' %}
- 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:
*.gz *.sublime* *.swp *.out *.Identifier *.log .idea* *.iml *.tmp *~ .DS_Store .idea .jekyll-cache/ .jekyll-metadata .sass-cache/ .yardoc/ jekyll/doc/ out/ __pycache__/ __MACOSX _build/ _package/ _site/ ~* bin/*.class node_modules/ Notepad++/ package/ cloud9.tar cloud9.zip instances.json rescue_ubuntu2010 rescue_ubuntu2010.b64 landingPageShortName.md stderr test.html RUNNING_PID mslinn_jekyll_plugins.zip mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip
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 mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip
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:
*.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:
*.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 mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip
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 instances.json rescue_ubuntu2010 rescue_ubuntu2010.b64 landingPageShortName.md stderr test.html RUNNING_PID mslinn_jekyll_plugins.zip mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip cloud9.tar mslinn_jekyll_plugins.zip
Source Code
# 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
- Copy
from_to_until.rb
andlogger_factory.rb
into the_plugins/
directory of your Jekyll site. - 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
# @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. # By default the link includes `target='_blank'`, # which causes the link to open in a new tab or window. # By default the link also includes `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`. # # @example General form # {% href [follow] [notarget] 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 %} 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'" @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 @link = tokens.shift @text = tokens.join(" ").strip @text = if @text.empty? then @link else @text end end # Method prescribed by the Jekyll plugin lifecycle. # @return [String] def render(_) "<a href='#{@link}'#{@target}#{@follow}>#{@text}</a>" end end end Liquid::Template.register_tag('href', HrefTag::ExternalHref)
Installation
- Copy
href.rb
andlogger_factory.rb
into the_plugins/
directory of your Jekyll site. - Restart Jekyll.
link
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
# @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
- Copy
link.rb
andlogger_factory.rb
into the_plugins/
directory of your Jekyll site. - 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:
- Absolute filenames (start with
/
). - Filenames relative to the top-level directory of the Jekyll web site (Do not preface with
.
or/
). - Filenames relative to the user home directory (preface with
~
). - 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
# 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 return unless File.foreach('.gitignore').grep(/^#{@archive_name}/).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
- Copy
make_archive.rb
andlogger_factory.rb
into the_plugins/
directory of your Jekyll site. - Restart Jekyll.
pre and noselect
This plugin provides 2 tags that frequently work together:
- A
pre
block tag that can optionally display a copy button. - A
noselect
tag that can renders HTML content passed to it unselectable.
Syntax
{% pre [copyButton] %} 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='id${SecureRandom.hex(6)}'> 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='id${SecureRandom.hex(6)}'><button class='copyBtn' data-clipboard-target='#id${SecureRandom.hex(6)}' 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 followd by a space.
{% pre copyButton %} {% noselect %}Contents of pre tag {% endpre %}
Generates:
<pre data-lt-active='false' class='maxOneScreenHigh copyContainer' id='id${SecureRandom.hex(6)}'><button class='copyBtn' data-clipboard-target='#id${SecureRandom.hex(6)}' 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='id${SecureRandom.hex(6)}'><button class='copyBtn' data-clipboard-target='#id${SecureRandom.hex(6)}' 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:
.copyBtn { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; -webkit-appearance: none; background-color: #eee; background-image: linear-gradient(#fcfcfc, #eee); border: 1px solid #d5d5d5; border-radius: 3px; color: #333; cursor: pointer; display: inline-block; float: right; font-size: 13px; font-weight: 700; line-height: 20px; padding: 2px 2px 0 4px;; position: -webkit-sticky; position: sticky; right: 4px; top: 0; user-select: none; z-index: 1; } .copyContainer { position: relative; } .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
# 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 %}""" 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, content) pre_id = 'id${SecureRandom.hex(6)}' copy_button = make_copy_button ? PreTagBlock.make_copy_button(pre_id) : '' "<pre data-lt-active='false' class='maxOneScreenHigh copyContainer' id='#{pre_id}'>#{copy_button}#{content}</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? @make_copy_button = text.strip! == 'copyButton' end # Method prescribed by the Jekyll plugin lifecycle. # @return [String] def render(context) content = super PreTagBlock.make_pre(@make_copy_button, 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
- Copy
pre.rb
andlogger_factory.rb
into the_plugins/
directory of your Jekyll site. - 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: c8a22c821b57
.
Source Code
# 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
- Copy
random_hex.rb
andlogger_factory.rb
into the_plugins/
directory of your Jekyll site. - 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
# frozen_string_literal: true # @author Copyright 2020 {https: 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
- Copy
site_inspector.rb
andlogger_factory.rb
into the_plugins/
directory of your Jekyll site. - Restart Jekyll.