Add bin and edit workflow
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 9s

This commit is contained in:
2026-09-16 13:11:16 -06:00
parent c8ac4fcae5
commit 4cee170d66
17576 changed files with 895740 additions and 2 deletions
@@ -0,0 +1,99 @@
plugins:
# - rubocop-jekyll
- rubocop-md
- rubocop-performance
- rubocop-rake
- rubocop-rspec
AllCops:
Exclude:
- binstub/**/*
- demo/_site/*
- vendor/**/*
- Gemfile*
NewCops: enable
Gemspec/DeprecatedAttributeAssignment:
Enabled: false
Gemspec/RequireMFA:
Enabled: false
Gemspec/RequiredRubyVersion:
Enabled: false
Layout/HashAlignment:
EnforcedColonStyle: table
EnforcedHashRocketStyle: table
Layout/IndentationConsistency:
Exclude:
- "*.md"
Layout/InitialIndentation:
Exclude:
- "*.md"
Layout/LineLength:
Max: 150
Metrics/AbcSize:
Max: 55
Metrics/BlockLength:
Exclude:
- jekyll_plugin_support.gemspec
Max: 30
Metrics/ClassLength:
Exclude:
- "**/all_collections_tag.rb"
Metrics/CyclomaticComplexity:
Max: 25
Metrics/MethodLength:
Max: 50
Metrics/ModuleLength:
Exclude:
- "**/jekyll_plugin_support_class.rb"
Metrics/PerceivedComplexity:
Max: 25
Naming/FileName:
Exclude:
- Rakefile
- "*.md"
RSpec/ExampleLength:
Max: 30
RSpec/SpecFilePathFormat:
Enabled: false
IgnoreMethods: true
RSpec/SpecFilePathSuffix:
Enabled: false
RSpec/IndexedLet:
Enabled: false
RSpec/MultipleExpectations:
Max: 15
Style/ClassVars:
Enabled: false
Style/Documentation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/StringConcatenation:
Enabled: false
Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: comma
@@ -0,0 +1,4 @@
# Notes on Attribution
The following additional public methods could be supported for the attribution string:
`full_name`, `conflicts`, `dependencies`, `platform`, `specification_version`, `summary`, `cert_chain`, `description`, `email`, `extensions`, `licenses`, `metadata`, `post_install_message`, `rg_full_gem_path`, `location`, `git_version`, `rg_extension_dir`, `nondevelopment_dependencies`, `default_executable`, `original_platform`, `gems_dir`, `groups`, `requirements`, `bin_file`, `executables`, `bindir`, `loaded_from`, `hash`, `files`, `spec_file`, `abbreviate`, `bin_dir`, `default_value`, `doc_dir`, `mark_version`, `executable`, `file_name`, `rubygems_version`, `lib_files`, `license`, `name_tuple`, `original_name`, `require_path`, `ri_dir`, `spec_dir`, `spec_name`, `source`, `validate_permissions`, `extension_dir`, `full_gem_path`, `remote`, `base_dir`, `gem_dir`, `normalize`, `match_platform`, `plugins`, `source_paths`, `datadir`, `require_paths`, and `this`.
@@ -0,0 +1,216 @@
# Change Log
## 3.0.0 / 2025-02-09
* Numbered as v3.0.0 because `jekyll_draft` was at v2.1.0 and the two projects should have similar version numbers.
* `Jekyll_all_collections` was folded into this project.
* Now requires [Jekyll 4.4.1](https://jekyllrb.com/news/2025/01/29/jekyll-4-4-1-released/) or later,
and Ruby 3.2.0 or later
## 1.1.0 / 2025-02-07
* Now replaces include and layout variables with their values
* Renamed `@helper.markup` to `@helper.argument_string`
* Improved `nil` handling throughout
* Added the **Debug Demo production** launch configuration to debug the demo website in production mode
* Added the `bin/release_port` script that kills hung processes being debugged
* Added the `cleanDemo` task for the **Debug Demo production** and **Debug Demo development** tasks
* Many minor improvements to the demo.
* `JekyllSupport::JekyllPluginHelper::register` now accepts a `quiet` option, to suppress the signon message.
Use it like this:
```ruby
JekyllPluginHelper.register(self, 'tag_name', quiet: true)
```
## 1.0.3 / 2024-08-19
* Still fussing with error handling
## 1.0.2 / 2024-08-19
* Computes line_number and path properly
* Refactored demo CSS
* Improved custom plugin error handling and sample code.
The following seems to be optimal for custom plugins; it suppresses the ridiculously long stack trace that used to be generated:
```ruby
begin
# ...
rescue DemoInlineTagError => e # jekyll_plugin_support handles StandardError
@logger.error { e.logger_message }
exit! 1 if @die_on_demo_tag_error
e.html_message
end
```
## 1.0.1 / 2024-07-27
* Moved `warn_short_trace`, `maybe_reraise_error`, `remove_ansi_color`,
`format_error_message` and `exit_without_stack_trace` into `JekyllSupportError`.
* Added missing `shared_include.css` and `clippy.svg` to `demo/`.
* Fixed missing module name when invoking `format_error_message`.
## 1.0.0 / 2024-07-23
* Added the `redef_without_warning` method so tag and block plugins can be subclassed.
* Put most of the functionality into the `JekyllSupport` module.
* Made tag and block code more consistent
## 0.8.6 / 2024-06-11
* Fixup version, containing what was supposed to be in v0.8.5
## 0.8.5 / 2024-03-25
* Empty block tags now return a value. See https://talk.jekyllrb.com/t/empty-liquid-block-not-rendering
## 0.8.4 / 2024-02-27
* Problem in error handler fixed.
* A warning is logged if an environment variable is undefined and `die_if_undefined` is not set.
## 0.8.3 / 2024-01-05
* Variables defined in front matter of layouts and pages are now handled.
## 0.8.2 / 2024-01-03
* `JekyllSupport::JekyllBlock` and `JekyllSupport::JekyllTag` subclasses now have automatically created error classes,
named after the subclass.
* Error class methods have been provided for standardized and convenient error handling:
* `shorten_backtrace`
* `logger_message`
* `html_message`
* Tags now self-report when registered.
## 0.8.1 / 2023-12-10
* Added the `JekyllPluginHelper.remove_html_tags` method.
## 0.8.0 / 2023-11-14
* Restructured for better support of Liquid variables.
* Removed redundant class name in error message.
* Improved handling of StandardError.
* Demo site now shows how to define and handle custom errors.
* Added `@tag_config` variable, which can be inspected to determine whether to die on various types of exceptions.
* Added `format_error_message`, `maybe_reraise_error`, and `raise_error` to `JekyllBlock` and `JekyllTag`
* Adds a method called `shorten_backtrace` to `StandardError` and all custom error
classes defined by `JekyllSupport.define_error`.
See the `demo/` directory for examples of how to use this optional feature.
* The `plugin-vars` section in `_config.yml` was renamed to `liquid_vars`.
## 0.7.2 / 2023-08-14
* Hopefully takes care of issue [#4](../../issues/4).
## 0.7.1 / 2023-08-11
* Suppressed stack trace from issue [#4](../../issues/4).
* Now using Shopify `ruby-lsp` instead of `rebornix.Ruby` by Peng Lv
* Debug configurations changed to `rdbg` instead of `ruby`, so the `debase` development dependency was changed to
[`debug`](https://marketplace.visualstudio.com/items?itemName=KoichiSasada.vscode-rdbg),
which provides the `rdgb` command.
The `ruby-debug-ide` development dependency is also no longer required.
## 0.7.0 / 2023-05-22
* Added `error_short_trace` and `warn_short_trace` methods for displaying non-fatal errors on the console.
## 0.6.2 / 2023-05-15
* Removed annoying blank line output when attribute was invoked.
## 0.6.1 / 2023-04-12
* Added `CallChain.excerpt_caller` to detect if an excerpt is being generated.
## 0.6.0 / 2023-04-05
* Added attribution support
* Renamed `jekyll_plugin_support_helper.rb` to `jekyll_plugin_helper.rb`.
* Now returns `undefined` instead of `false'` when `@keys_values.empty?`
and parameter is not specified.
## 0.5.3 / 2023-03-19
* Added properties `argv_original`, `keys_values_original`, `remaining_markup_original`,
and `params_original`, which are the original values of `argv`, `keys_values`,
`remaining_markup`, and `params`, before environment variable expansion.
## 0.5.2 / 2023-03-17
* Added `@helper.remaining_markup` public method, which returns remaining markup passed to your tag,
after keyword and name/value parsing is complete.
* Finally wrote proper `rspec` tests.
* Finally documented argument parsing.
* Fixed bug introduced in v0.5.1 which did not remove elements from `@params`.
## 0.5.1 / 2023-02-17
* `no_arg_parsing` optimization added.
## 0.5.0 / 2023-02-15
* Plugins now register themselves
* Plugins now report their name and version
* `@layout`, `@paginator`, and `@theme` have values if supported by the version of Jekyll, and they are active.
(See [Jekyll docs](https://jekyllrb.com/docs/variables/).)
## 0.4.1 / 2023-02-14
* Fixed several problems
* Added demo site
## 0.4.0 / 2023-02-12
* `render_impl` for tags and blocks now predefines more instance variables:
* `@liquid_context` – passed to `render`
* `@config` Jekyll configuration
* `@envs` Environment variables
* `@mode` ('development', 'test' or 'production')
## 0.3.1 / 2023-02-07
* `JekyllBlock.render_impl` and `JekyllTag.render_impl` now define `@page` and `@site`.
## 0.3.0 / 2023-01-14
* Added support for tags, which should subclass JekyllSupport::JekyllTag
## 0.2.0 / 2023-01-12
* Refactored
## 0.1.0 / 2023-01-10
* Initial version; only supports Jekyll block tags
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2020 Mike Slinn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
task default: :spec
@@ -0,0 +1,38 @@
require_relative 'lib/jekyll_plugin_support/version'
Gem::Specification.new do |spec|
github = 'https://github.com/mslinn/jekyll_plugin_support'
spec.bindir = 'exe'
spec.authors = ['Mike Slinn']
spec.email = ['mslinn@mslinn.com']
spec.files = Dir['.rubocop.yml', 'LICENSE.*', 'Rakefile', '{lib,spec}/**/*', '*.gemspec', '*.md']
spec.homepage = 'https://www.mslinn.com/jekyll_plugins/jekyll_plugin_support.html'
spec.license = 'MIT'
spec.metadata = {
'allowed_push_host' => 'https://rubygems.org',
'bug_tracker_uri' => "#{github}/issues",
'changelog_uri' => "#{github}/CHANGELOG.md",
'homepage_uri' => spec.homepage,
'source_code_uri' => github,
}
spec.name = 'jekyll_plugin_support'
spec.platform = Gem::Platform::RUBY
spec.post_install_message = <<~END_MESSAGE
Thanks for installing #{spec.name}!
END_MESSAGE
spec.require_paths = ['lib']
spec.required_ruby_version = '>= 2.6.0'
spec.summary = 'Provides a framework for writing and testing Jekyll plugins'
spec.test_files = spec.files.grep %r{^(test|spec|features)/}
spec.version = JekyllPluginSupportVersion::VERSION
spec.add_dependency 'facets'
spec.add_dependency 'jekyll', '>= 4.4.1'
spec.add_dependency 'jekyll_plugin_logger'
spec.add_dependency 'key-value-parser'
spec.add_dependency 'pry'
spec.add_dependency 'sorted_set'
end
@@ -0,0 +1,118 @@
module JekyllSupport
# Base class for Jekyll block tags
class JekyllBlock < Liquid::Block
attr_reader :argument_string, :helper, :line_number, :logger, :page, :site, :text
# 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 usually know.
# @param argument_string [String] the arguments passed to the tag, as a single string.
# @param parse_context [Liquid::ParseContext] contains the following attributes:
# @depth might have the value 0
# @error_mode might have the value `:strict`
# @line_number duplicates @ptions[:line_number]
# @locale duplicates @ptions[:locale]
# @options is a hash with the following two keys that holds Liquid options:
# :locale is a Liquid::I18n object, used to display localized error messages on Liquid built-in tags and filters.
# :line_number is the line number containing the plugin invocation.
# See https://github.com/Shopify/liquid/wiki/Liquid-for-Programmers#create-your-own-tags
# @partial Boolean, unclear what this indicates
# @template_options Replicates @options
# @trim_whitespace might have the value `false`
# @warnings array
# @return [void]
def initialize(tag_name, markup, parse_context)
super
@tag_name = tag_name
raise JekyllPluginSupportError, "markup is a #{markup.class} with value '#{markup}'." unless markup.instance_of? String
# Lookup variable names with values in markup in render because site and config are not available here
@argument_string = markup # Replace variable names with values in markup in render because site and config are not available here
@logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)
@logger.debug { "#{self.class}: respond_to?(:no_arg_parsing) #{respond_to?(:no_arg_parsing) ? 'yes' : 'no'}." }
@helper = JekyllPluginHelper.new tag_name, markup, @logger, respond_to?(:no_arg_parsing)
@error_name = "#{tag_name.camelcase(:upper)}Error"
::JekyllSupport::CustomError.factory @error_name
end
# Liquid::Block subclasses do not render if there is no content within the tag
# This override fixes that
def blank?
false
end
# Method prescribed by the Jekyll plugin lifecycle.
# Defines @config, @envs, @mode, @page and @site
# @return [String]
def render(liquid_context)
@helper.liquid_context = ::JekyllSupport.inject_config_vars liquid_context # modifies liquid_context
text = super # Liquid variable values in content are looked up and substituted
@envs = liquid_context.environments.first
@page = liquid_context.registers[:page]
@scopes = liquid_context.scopes
@site = liquid_context.registers[:site]
@config = @site.config
@tag_config = @config[@tag_name]
@jps = @config['jekyll_plugin_support']
@pry_on_standard_error = @jps['pry_on_standard_error'] || false if @jps
set_error_context
# @envs.keys are :content, :highlighter_prefix, :highlighter_suffix, :jekyll, :layout, :page, :paginator, :site, :theme
@layout = @envs[:layout]
@paginator = @envs[:paginator]
@theme = @envs[:theme]
env = @config['env']
@mode = env&.key?('JEKYLL_ENV') ? env['JEKYLL_ENV'] : 'development'
@argument_string = JekyllSupport.lookup_liquid_variables @logger, @helper.liquid_context, @argument_string.to_s.strip
@helper.reinitialize @argument_string.to_s.strip
@attribution = @helper.parameter_specified?('attribution') || false unless @no_arg_parsing
@logger.debug { "@keys_values='#{@keys_values}'" }
# @argument_string = JekyllSupport.lookup_liquid_variables @logger, liquid_context, @argument_string # Is this redundant?
# @argument_string.strip! # Is this redundant?
# @helper.reinitialize @argument_string # Is this redundant?
render_impl(text)
rescue StandardError => e
e.shorten_backtrace
file_name = e.backtrace[0]&.split(':')&.first
in_file_name = "in '#{file_name}' " if file_name
of_page = "of '#{@page['path']}'" if @page
@logger.error { "#{e.class} on line #{@line_number} #{of_page} while processing #{tag_name} #{in_file_name}- #{e.message}" }
binding.pry if @pry_on_standard_error # rubocop:disable Lint/Debugger
raise e if @die_on_standard_error
<<~END_MSG
<div class='standard_error'>
#{e.class} on line #{@line_number} of #{e.backtrace[0].split(':').first} by #{@tag_name}: #{e.message}
</div>
END_MSG
end
# Jekyll plugins should override this method, not render,
# so they can be tested more easily.
# The following variables are predefined:
# @argument_string, @config, @envs, @helper, @layout, @logger, @mode, @page, @paginator, @site, @tag_name and @theme
# @return [String] The result to be rendered to the invoking page
def render_impl(text)
text
end
def set_error_context
return unless Object.const_defined? @error_name
error_class = Object.const_get @error_name
error_class.class_variable_set(:@@argument_string, @argument_string)
error_class.class_variable_set(:@@line_number, @line_number)
error_class.class_variable_set(:@@path, @page['path'])
error_class.class_variable_set(:@@tag_name, @tag_name)
end
end
end
@@ -0,0 +1,31 @@
module JekyllSupport
class JekyllBlockNoArgParsing < JekyllBlock
attr_reader :argument_string, :helper, :line_number, :logger, :page, :site
def initialize(tag_name, argument_string, parse_context)
class << self
include NoArgParsing
end
super
@logger.debug { "#{self.class}: respond_to?(:o_arg_parsing) #{respond_to?(:no_arg_parsing) ? 'yes' : 'no'}." }
rescue StandardError => e
e.shorten_backtrace
@logger.error { e.full_message }
JekyllSupport.error_short_trace(@logger, e)
end
# Liquid::Block subclasses do not render if there is no content within the tag
# This override fixes that
def blank?
false
end
# Jekyll plugins must override this method, not render, so their plugin can be tested more easily
# The following variables are predefined:
# @argument_string, @config, @envs, @helper, @layout, @logger, @mode, @page, @paginator, @site, @tag_name and @theme
def render_impl
abort "#{self.class}.render_impl for tag #{@tag_name} must be overridden, but it was not."
end
end
end
@@ -0,0 +1,52 @@
require 'facets/string/camelcase'
require 'facets/string/snakecase'
module JekyllSupport
# Use like this:
# CustomError.new(:MyError, 'blah', 'asdf')
class CustomError < StandardError
def self.factory(error_class_name)
return if Object.const_defined? "::#{error_class_name}"
Object.const_set error_class_name, Class.new(CustomError)
end
def error_name
self.class.name.split('::').last
end
def calling_file
file_fq, _line_number, _extra = backtrace[0]&.split(':')
file_fq
end
# @return HTML <div> tag with class set to the snake_case version of the error class name.
def html_message
shorten_backtrace
path, line_number, _caller = backtrace[1]&.split(':')
<<~END_MSG
<div class='#{error_name.snakecase}'>
#{self.class} raised in #{calling_file} while processing line #{line_number} (after front matter) of #{path}
#{message}
</div>
END_MSG
end
def logger_message
shorten_backtrace
kaller = caller(1..1).first
path, line_number, _caller = backtrace[1]&.split(':')
<<~END_MSG
#{error_name} raised in #{kaller} while processing line #{line_number} (after front matter) of #{path}
#{message}
END_MSG
end
def shorten_backtrace(backtrace_element_count = 3)
b = backtrace[0..backtrace_element_count - 1].map do |x|
x.gsub(Dir.pwd + '/', './')
end
set_backtrace b
end
end
end
@@ -0,0 +1,48 @@
module JekyllSupportError
attr_reader :logger, :page
# If a Jekyll plugin needs to crash exit, and stop Jekyll, call this method.
# It does not generate a stack trace.
# This method does not return because the process is abruptly terminated.
#
# @param error StandardError or a subclass of StandardError is required
#
# Do not raise the error before calling this method, just create it via 'new', like this:
# exit_without_stack_trace StandardError.new('This is my error message')
#
# If you want to call this method from a handler method, the default index for the backtrace array must be specified.
# The default backtrace index is 1, which means the calling method.
# To specify the calling method's caller, pass in 2, like this:
# exit_without_stack_trace StandardError.new('This is my error message'), 2
def exit_without_stack_trace(error, caller_index = 1)
raise error
rescue StandardError => e
file, line_number, caller = e.backtrace[caller_index].split(':')
caller = caller.tr('`', "'")
warn "#{error.message} #{caller} on line #{line_number} (after front matter) of #{file}".red
# Process.kill('HUP', Process.pid) # generates huge stack trace
exec "echo ''"
end
def format_error_message(message)
page = " of #{@page['path']}" if @page
remove_ansi_color "on line #{line_number} (after front matter)#{page}.\n#{message}"
end
def remove_ansi_color(string)
string.gsub(/\e\[([;\d]+)?m/, '')
end
def maybe_reraise_error(error, throw_error: true)
fmsg = format_error_message "#{error.class}: #{error.message.strip}"
@logger.error { fmsg }
return "<span class='jekyll_plugin_support_error'>#{fmsg}</span>" unless throw_error
error.set_backtrace error.backtrace[0..9]
raise error
end
def warn_short_trace(error)
JekyllSupport.warn_short_trace(@logger, error)
end
end
@@ -0,0 +1,69 @@
module JekyllSupport
# Base class for Jekyll generators.
# PluginMetaLogger.instance.config is populated with the contents of `_config.yml` before Jekyll::Generator instances run.
class JekyllGenerator < Jekyll::Generator
attr_reader :helper, :line_number, :logger, :site
# Method prescribed by the Jekyll plugin lifecycle.
# Defines @config, @envs, @mode and @site
# @return [void]
def generate(site)
@logger ||= PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)
@error_name = "#{self.class.name}Error"
@site = site
@config = @site.config
@envs = site.config['env']
@theme = @site.theme
@mode = ENV['JEKYLL_ENV'] || 'development'
generate_impl
rescue StandardError => e
e.shorten_backtrace
@logger.error { "#{e.class} on line #{@line_number} of #{e.backtrace[0].split(':').first} by #{self.class.name} - #{e.message}" }
binding.pry if @pry_on_standard_error # rubocop:disable Lint/Debugger
raise e if @die_on_standard_error
<<~END_MSG
<div class='standard_error'>
#{e.class} on line #{@line_number} of #{e.backtrace[0].split(':').first} by #{self.class.name}: #{e.message}
</div>
END_MSG
end
# Jekyll plugins should override this method, not `generate`, so they can be tested more easily.
# The following variables are predefined:
# @config, @envs, @helper, @logger, @mode, @paginator, @site and @theme
# @return [void]
def generate_impl; end
def self.register(klass)
abort("Error: The #{klass.name} plugin does not define VERSION") \
unless klass.const_defined?(:VERSION)
version = klass.const_get(:VERSION)
error_name_stub = klass.name.include?('::') ? klass.name.split('::')[1] : klass.name
error_ruby_class_name = "#{error_name_stub.camelcase(:upper)}Error"
error_css_class_name = error_ruby_class_name.split('::').last.snakecase
msg = <<~END_MSG
Loaded generator plugin #{klass.name} v#{version}. It has:
Error class: #{@error_name}
CSS class for error messages: #{error_css_class_name}
END_MSG
PluginMetaLogger.instance.info { msg }
end
def set_error_context(klass) # rubocop:disable Naming/AccessorMethodName
return unless Object.const_defined? @error_name
error_class = Object.const_get @error_name
error_class.class_variable_set(:@@argument_string, @argument_string)
error_class.class_variable_set(:@@line_number, @line_number)
error_class.class_variable_set(:@@path, @page['path'])
error_class.class_variable_set(:@@tag_name, klass.name)
end
end
end
@@ -0,0 +1,112 @@
require 'facets/string/interpolate'
require 'key_value_parser'
require 'shellwords'
module JekyllSupport
class JekyllPluginHelper
attr_accessor :liquid_context
attr_reader :argument_string, :argv, :argv_original, :attribution, :excerpt_caller, :keys_values_original,
:keys_values, :jpsh_subclass_caller, :logger, :markup, :no_arg_parsing, :params, :params_original,
:tag_name
# 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, markup, logger, no_arg_parsing)
@tag_name = tag_name
@logger = logger
@markup = markup
@argument_string = markup
@no_arg_parsing = no_arg_parsing
rescue StandardError => e
e.shorten_backtrace
@logger.error { e.message }
end
# @return undefined if parameter was specified, removes it from the available tokens and returns value
def parameter_specified?(name, delete_param: true)
return false if @keys_values.to_s.empty?
key = name
key = name.to_sym if @keys_values&.first&.first.instance_of?(Symbol)
value = @keys_values[key]
delete_parameter(name) if delete_param
value
end
def reinitialize(markup)
# @keys_values was a Hash[Symbol, String|Boolean] but now it is Hash[String, String|Boolean]
@argument_string = markup
if @no_arg_parsing
define_singleton_method(:argv) { warn_fetch :argv }
define_singleton_method(:keys_values) { warn_fetch :keys_values }
define_singleton_method(:params) { warn_fetch :params }
define_singleton_method(:parameter_specified?) { |_name| warn_parse(:parameter_specified?) }
define_singleton_method(:delete_parameter) { |_name| warn_parse(:delete_parameter) }
@attribution = false
else
parse markup
@attribution = parameter_specified?('attribution') || false
@logger.debug { "@keys_values='#{@keys_values}'" }
end
end
# Call this method to return the remaining markup after `parameter_specified?` has been invoked.
def remaining_markup
@argv&.join(' ')
end
def remaining_markup_original
@argv_original&.join(' ')
end
def warn_fetch(variable)
abort "Error: Argument parsing was suppressed, but an attempt to obtain the value of #{variable} was made"
end
def warn_parse(meth)
abort "Error: Argument parsing was suppressed, but an attempt to invoke #{meth} was made"
end
def delete_parameter(key)
return if @keys_values.empty? || @params.nil?
@params.delete(key)
@argv.delete_if { |x| x == key or x.start_with?("#{key}=") }
@keys_values.delete(key)
@params_original.delete(key)
@argv_original.delete_if { |x| x == key or x.start_with?("#{key}=") }
@keys_values_original.delete(key)
end
private
def page
@liquid_context.registers[:page]
end
def parse(markup)
@argv_original = Shellwords.split(markup)
@keys_values_original = KeyValueParser
.new({}, { array_values: false, normalize_keys: false, separator: /=/ })
.parse(@argv_original)
@params_original = @keys_values_original unless respond_to?(:no_arg_parsing) && no_arg_parsing
@argv = Shellwords.split(self.class.expand_env(markup, logger))
@keys_values = KeyValueParser
.new({}, { array_values: false, normalize_keys: false, separator: /=/ })
.parse(@argv)
return if respond_to?(:no_arg_parsing) && no_arg_parsing
@params = @keys_values # TODO: @keys_values should be deleted
end
end
end
@@ -0,0 +1,74 @@
module JekyllSupport
# Attribution aspect of JekyllPluginHelper
class JekyllPluginHelper
# @param file must be a fully qualified file name that points to a file within a gem.
# @return Gem::Specification of gem that file points into, or nil if not called from a gem
# See https://stackoverflow.com/a/75890279/553865
def self.current_spec(file)
abort 'JekyllPluginHelper::current_spec: file is nil' if file.nil?
return nil unless File.exist?(file)
searcher = if Gem::Specification.respond_to?(:find)
Gem::Specification
elsif Gem.respond_to?(:searcher)
Gem.searcher.init_gemspecs
end
searcher&.find do |spec|
file.start_with? spec.full_gem_path
end
end
def attribute
return unless @current_gem
<<~END_OUTPUT
<div id="jps_attribute_#{rand(999_999)}" class="jps_attribute">
<div>
<a href="#{@homepage}" target="_blank" rel="nofollow">
#{attribution_string}
</a>
</div>
</div>
END_OUTPUT
end
def default_attribution
authors = @authors&.join(', ')
result = "Generated by the \#{@name} v\#{@version} Jekyll plugin"
result << ", written by #{authors}" if authors
result << " \#{@published_date}" if @published_date
result << '.'
result
end
# Sets @current_gem if file points at a uniquely named file within a gem.
# @param file must be a fully qualified file name in a gem, for example: __FILE__
def gem_file(file)
@current_gem = JekyllPluginHelper.current_spec file
@logger.debug "No gem found for '#{file} was found." unless @current_gem
annotate_globals if @attribution && @current_gem
end
private
def annotate_globals
return unless @current_gem
@name = @current_gem.name
@authors = @current_gem.authors
@homepage = @current_gem.homepage
@published_date = @current_gem.date.to_date.to_s
@version = @current_gem.version
end
def attribution_string
string = if @attribution == true
default_attribution
else
@attribution
end
String.interpolate { string }
end
end
end
@@ -0,0 +1,88 @@
require 'facets/string/camelcase'
require 'facets/string/snakecase'
require 'yaml'
module JekyllSupport
# Class methods for JekyllPluginHelper
class JekyllPluginHelper
# Expand an environment variable reference
def self.expand_env(str, logger = nil, die_if_undefined: false)
str&.gsub(/\$([a-zA-Z_][a-zA-Z0-9_]*)|\${\g<1>}/) do
envar = Regexp.last_match(1)
unless ENV.key? envar
msg = "jekyll_plugin_support error: environment variable #{envar} is undefined"
raise JekyllPluginSupportError, msg.red, [] if die_if_undefined
if logger
logger.warn msg
else
puts msg.red
end
end
ENV.fetch(envar, nil)
end
end
def self.generate_message(klass, tag_name, version)
error_name_stub = klass.name.include?('::') ? klass.name.split('::')[1] : klass.name
error_ruby_class_name = "#{error_name_stub.camelcase(:upper)}Error"
config_die_key = "die_on_#{error_ruby_class_name.snakecase}"
error_css_class_name = error_ruby_class_name.split('::').last.snakecase
config = YAML.load_file('_config.yml')
tag_config = config[tag_name]
tag_config_msg = if tag_config.nil?
<<~END_MSG
_config.yml does not contain configuration information for this plugin.
You could add a section containing default values by specifying a section for the tag name,
and an entry whose name starts with `die_on_`, followed by a snake_case version of the error name.
#{tag_name}:
#{config_die_key}: false
END_MSG
else
<<~END_MSG
_config.yml contains the following configuration for this plugin:
#{tag_config}
END_MSG
end
<<~END_MSG
Loaded plugin #{tag_name} v#{version}. It has:
Error class: #{error_ruby_class_name}
CSS class for error messages: #{error_css_class_name}
#{tag_config_msg}
END_MSG
end
# @param klass [Class] class instance to register
# @param tag_name [String] name of plugin defined by klass to register as
# @param quiet [Boolean] suppress registration message if truthy
def self.register(klass, tag_name, quiet: false)
abort("Error: The #{tag_name} plugin does not define VERSION") \
unless klass.const_defined?(:VERSION)
version = klass.const_get(:VERSION)
abort("Error: The #{tag_name} plugin is not an instance of JekyllSupport::JekyllBlock or JekyllSupport::JekyllTag") \
unless klass.instance_of?(Class) &&
(klass.ancestors.include?(::JekyllSupport::JekyllBlock) ||
klass.ancestors.include?(::JekyllSupport::JekyllTag))
Liquid::Template.register_tag(tag_name, klass)
return if quiet
msg = generate_message(klass, tag_name, version)
PluginMetaLogger.instance.info { msg }
end
def self.remove_html_tags(string)
string.gsub(/<[^>]*>/, '').strip
end
# strip leading and trailing quotes if present
def self.remove_quotes(string)
string.strip.gsub(/\A'|\A"|'\Z|"\Z/, '').strip if string
end
end
end
@@ -0,0 +1,69 @@
module AllCollectionsHooks
class APage
attr_reader :content, :data, :date, :description, :destination, :draft, :excerpt, :ext, :extname, :href,
:label, :last_modified, :layout, :origin, :path, :relative_path, :tags, :title, :type, :url
def initialize(obj, origin)
@origin = origin
data_field_init obj
obj_field_init obj
@draft = Jekyll::Draft.draft? obj
@href = @url if @href.nil?
# @href = "/#{@href}" if @origin == 'individual_page'
@href = "#{@href}index.html" if @href.end_with? '/'
@name = File.basename(@href)
@title = if @data&.key?('title')
@data['title']
elsif obj.respond_to?(:title)
obj.title
else
"<code>#{@href}</code>"
end
rescue StandardError => e
JekyllSupport.error_short_trace(@logger, e)
# JekyllSupport.warn_short_trace(@logger, e)
end
def to_s
@label || @date.to_s
end
private
def data_field_init(obj)
return unless obj.respond_to? :data
@data = obj.data
@categories = @data['categories'] if @data.key? 'categories'
@date = (@data['date'].to_date if @data&.key?('date')) || Date.today
@description = @data['description'] if @data.key? 'description'
@excerpt = @data['excerpt'] if @data.key? 'excerpt'
@ext ||= @data['ext'] if @data.key? 'ext'
@last_modified = @data['last_modified'] || @data['last_modified_at'] || @date
@last_modified_field = case @data
when @data.key?('last_modified')
'last_modified'
when @data.key?('last_modified_at')
'last_modified_at'
end
@layout = @data['layout'] if @data.key? 'layout'
@tags = @data['tags'] if @data.key? 'tags'
end
def obj_field_init(obj)
@content = obj.content if obj.respond_to? :content
# TODO: What _config.yml setting should be passed to destination()?
@destination = obj.destination('') if obj.respond_to? :destination
@ext = obj.extname
@extname = @ext # For compatibility with previous versions of all_collections
@label = obj.collection.label if obj.respond_to?(:collection) && obj.collection.respond_to?(:label)
@path = obj.path if obj.respond_to? :path
@relative_path = obj.relative_path if obj.respond_to? :relative_path
@type = obj.type if obj.respond_to? :type
@url = obj.url
@url = "#{@url}index.html" if @url.end_with? '/'
end
end
end
@@ -0,0 +1,61 @@
module JekyllAllCollections
module AllCollectionsHooks
class << self
attr_accessor :logger
end
@logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)
# No, all_collections is not defined for this hook
# Jekyll::Hooks.register(:site, :after_init, priority: :normal) do |site|
# defined_msg = ::AllCollectionsHooks.all_collections_defined?(site)
# @logger.debug { "Jekyll::Hooks.register(:site, :after_init: #{defined_msg}" }
# end
# Creates a `Array[APage]` property called site.all_collections if it does not already exist.
# The array is available from :site, :pre_render onwards
# Each `APage` entry is one document or page.
Jekyll::Hooks.register(:site, :post_read, priority: :normal) do |site|
@site = site
unless site.class.method_defined? :all_collections
defined_msg = ::AllCollectionsHooks.all_collections_defined? site
@logger.debug { "Jekyll::Hooks.register(:site, :post_read, :normal: #{defined_msg}" }
::AllCollectionsHooks.compute(site) if !@site.class.method_defined?(:all_documents) || @site.all_documents.nil?
end
rescue StandardError => e
JekyllSupport.error_short_trace(@logger, e)
# JekyllSupport.warn_short_trace(@logger, e)
end
# Yes, all_collections is defined for this hook
# Jekyll::Hooks.register(:site, :post_read, priority: :low) do |site|
# defined_msg = ::AllCollectionsHooks.all_collections_defined?(site)
# @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :low: #{defined_msg}" }
# rescue StandardError => e
# JekyllSupport.error_short_trace(@logger, e)
# # JekyllSupport.warn_short_trace(@logger, e)
# end
# Yes, all_collections is defined for this hook
# Jekyll::Hooks.register(:site, :post_read, priority: :normal) do |site|
# defined_msg = ::AllCollectionsHooks.all_collections_defined?(site)
# @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :normal: #{defined_msg}" }
# rescue StandardError => e
# JekyllSupport.error_short_trace(@logger, e)
# # JekyllSupport.warn_short_trace(@logger, e)
# end
# Yes, all_collections is defined for this hook
# Jekyll::Hooks.register(:site, :pre_render, priority: :normal) do |site, _payload|
# defined_msg = ::AllCollectionsHooks.all_collections_defined?(site)
# @logger.debug { "Jekyll::Hooks.register(:site, :pre_render: #{defined_msg}" }
# rescue StandardError => e
# JekyllSupport.error_short_trace(@logger, e)
# # JekyllSupport.warn_short_trace(@logger, e)
# end
end
end
PluginMetaLogger.instance.logger.info do
"Loaded AllCollectionsHooks v#{JekyllPluginSupportVersion::VERSION} :site, :pre_render, :normal hook plugin."
end
Liquid::Template.register_filter(JekyllAllCollections::AllCollectionsHooks)
@@ -0,0 +1,48 @@
# Insert the url of a Jekyll::Page into each LruFile instance,
# along with the Page reference
# todo: replace references to url and :url with reverse_url and :reverse_url
LruFile = Struct.new(:url, :page) do
include SendChain
def <=>(other)
url <=> other.url
end
def href
url.reverse
end
end
# Matches suffixes of an array of urls
# Converts suffixes to prefixes
class SortedLruFiles
attr_reader :msbs
def initialize
@msbs = MSlinnBinarySearch.new %i[url start_with?]
end
# @param apages [Array[APage]]
def add_pages(apages)
apages.each { |apage| insert apage.href, apage }
@msbs.enable_search
end
def enable_search
@msbs.enable_search
end
def find(suffix)
@msbs.find suffix
end
def insert(url, file)
lru_file = LruFile.new(url.reverse, file)
lru_file.new_chain [:url, %i[start_with? placeholder]]
@msbs.insert(lru_file)
end
def select(suffix)
@msbs.select_pages suffix
end
end
@@ -0,0 +1,50 @@
module AllCollectionsHooks
class << self
attr_accessor :all_collections, :all_documents, :everything, :sorted_lru_files
end
# @sort_by = ->(apages, criteria) { [apages.sort(criteria)] } # todo delete this
# @return [String] indicating if :all_collections is defined or not
def self.all_collections_defined?(site)
"site.all_collections #{site.class.method_defined?(:all_collections) ? 'IS' : 'IS NOT'} defined"
end
# Create Array of AllCollectionsHooks::APage from objects
# @param objects [Array] An array of Jekyll::Document, Jekyll::Page or file names
# @param origin [String] Indicates type of objects being passed
def self.apages_from_objects(objects, origin)
pages = []
objects.each do |object|
page = APage.new(object, origin)
pages << page unless page.data['exclude_from_all'] || page.path == 'redirect.html'
end
pages
end
# Called by early, high-priority hook.
# Computes site.all_collections, site.all_documents, site.everything, and site.sorted_lru_files
def self.compute(site)
site.class.module_eval { attr_accessor :all_collections, :all_documents, :everything, :sorted_lru_files }
documents = site.collections
.values
.map { |x| x.class.method_defined?(:docs) ? x.docs : x }
.flatten
.compact
@all_collections = AllCollectionsHooks.apages_from_objects(documents, 'collection')
@all_documents = @all_collections +
AllCollectionsHooks.apages_from_objects(site.pages, 'individual_page')
@everything = @all_documents +
AllCollectionsHooks.apages_from_objects(site.static_files, 'static_file')
@sorted_lru_files = SortedLruFiles.new.add_pages @everything
site.all_collections = @all_collections
site.all_documents = @all_documents
site.everything = @everything
site.sorted_lru_files = @sorted_lru_files
rescue StandardError => e
JekyllSupport.error_short_trace(::JekyllAllCollections::AllCollectionsHooks.logger, e)
# JekyllSupport.warn_short_trace(::JekyllAllCollections::AllCollectionsHooks.logger, e)
end
end
@@ -0,0 +1,157 @@
# require 'jekyll_draft'
require 'securerandom'
# @author Copyright 2020 Michael Slinn
# @license SPDX-License-Identifier: Apache-2.0
module JekyllAllCollections
PLUGIN_NAME = 'all_collections'.freeze unless defined?(PLUGIN_NAME)
CRITERIA = %w[date destination draft label last_modified last_modified_at path relative_path title type url].freeze unless defined?(CRITERIA)
DRAFT_HTML = '<i class="jekyll_draft">Draft</i>'.freeze unless defined?(DRAFT_HTML)
class AllCollectionsTag < ::JekyllSupport::JekyllTag
include ::JekyllPluginSupportVersion
# Method prescribed by JekyllTag.
# @return [String]
def render_impl
parse_arguments # Defines instance variables like @sort_by
sort_lambda = init_sort_by @sort_by, @sort_by_param
@heading = @helper.parameter_specified?('heading') || default_head(@sort_by)
generate_output sort_lambda
rescue StandardError => e
JekyllSupport.error_short_trace @logger, e
# JekyllSupport.warn_short_trace @logger, e
end
# Descending sort keys reverse the order of comparison
# Example return values:
# "->(a, b) { [a.last_modified] <=> [b.last_modified] }"
# "->(a, b) { [b.last_modified] <=> [a.last_modified] }" (descending)
# "->(a, b) { [a.last_modified, a.date] <=> [b.last_modified, b.date] }" (descending last_modified, ascending date)
# "->(a, b) { [a.last_modified, b.date] <=> [b.last_modified, a.date] }" (ascending last_modified, descending date)
def self.create_lambda_string(criteria)
criteria_lhs_array = []
criteria_rhs_array = []
verify_sort_by_type(criteria).each do |c|
descending_sort = c.start_with? '-'
c.delete_prefix! '-'
abort("Error: '#{c}' is not a valid sort field. Valid field names are: #{CRITERIA.join ', '}") \
unless CRITERIA.include?(c)
criteria_lhs_array << (descending_sort ? "b.#{c}" : "a.#{c}")
criteria_rhs_array << (descending_sort ? "a.#{c}" : "b.#{c}")
end
"->(a, b) { [#{criteria_lhs_array.join(', ')}] <=> [#{criteria_rhs_array.join(', ')}] }"
end
def self.verify_sort_by_type(sort_by)
case sort_by
when Array
sort_by
when Enumerable
sort_by.to_a
when Date
[sort_by.to_i]
when String
[sort_by]
else
abort "Error: @sort_by was specified as '#{sort_by}'; it must either be a string or an array of strings"
end
end
private
def default_head(sort_by)
criteria = (sort_by.map do |x|
reverse = x.start_with? '-'
criterion = x.delete_prefix('-').capitalize
criterion += reverse ? ' (Newest to Oldest)' : ' (Oldest to Newest)'
criterion
end).join(', ')
"All Posts in All Categories Sorted By #{criteria}"
end
def evaluate(string)
self.eval string, binding, __FILE__, __LINE__
rescue StandardError => e
warn_short_trace e.red
end
def last_modified_value(apage)
@logger.debug do
" apage.last_modified='#{apage.last_modified}'; " \
"apage.last_modified_at='#{apage.last_modified_at}'; " \
"@date_column='#{@date_column}'"
end
last_modified = if @date_column == 'last_modified' && apage.respond_to?(:last_modified)
apage.last_modified
elsif apage.respond_to? :last_modified_at
apage.last_modified_at
else
apage.date
end
last_modified ||= apage.date || Date.today
last_modified
end
def generate_output(sort_lambda)
id = @id.to_s.strip.empty? ? '' : " id='#{@id}'"
heading = @heading.strip.to_s.empty? ? '' : "<h2#{id}>#{@heading}</h2>"
data = case @data_selector
when 'all_collections'
@site.all_collections
when 'all_documents'
@site.all_documents
when 'everything'
@site.everything
else
raise AllCollectionsError, "Invalid value for @data_selector (#{data_selector})"
end
collection = data.sort(&sort_lambda)
posts = collection.map do |x|
last_modified = last_modified_value x
date = last_modified.strftime '%Y-%m-%d'
draft = x.draft ? DRAFT_HTML : ''
href = "<a href='#{x.href}'>#{x.title}</a>"
@logger.debug { " date='#{date}' #{x.title}\n" }
" <span>#{date}</span><span>#{href}#{draft}</span>"
end
<<~END_TEXT
#{heading}
<div class="posts">
#{posts.join "\n"}
</div>
END_TEXT
rescue ArgumentError => e
warn_short_trace e
end
# See https://stackoverflow.com/a/75377832/553865
def init_sort_by(sort_by, sort_by_param)
sort_lambda_string = AllCollectionsTag.create_lambda_string sort_by
@logger.debug do
"#{@page['path']} sort_by_param=#{sort_by_param} " \
"sort_lambda_string = #{sort_lambda_string}\n"
end
evaluate sort_lambda_string
end
def parse_arguments
@data_selector = @helper.parameter_specified?('data_selector') || 'all_collections'
abort "Invalid data_selector #{@data_selector}" unless %w[all_collections all_documents everything].include? @data_selector
@date_column = @helper.parameter_specified?('date_column') || 'date'
unless %w[date last_modified].include?(@date_column)
raise AllCollectionsError "The date_column attribute must either have value 'date' or 'last_modified', " \
"but '#{@date_column}' was specified"
end
@id = @helper.parameter_specified?('id') || SecureRandom.hex(10)
@sort_by_param = @helper.parameter_specified? 'sort_by'
@sort_by = (@sort_by_param&.delete(' ')&.split(',') if @sort_by_param != false) || ['-date']
end
::JekyllSupport::JekyllPluginHelper.register(self, PLUGIN_NAME)
end
end
@@ -0,0 +1,53 @@
def require_directory(dir)
Dir[File.join(dir, '*.rb')]&.sort&.each do |file|
require file unless file == __FILE__
end
end
require 'colorator'
require 'jekyll'
require 'jekyll_plugin_logger'
require 'pry'
require 'sorted_set'
# require_directory __dir__
require_directory "#{__dir__}/util"
require_directory "#{__dir__}/error"
require_directory "#{__dir__}/block"
require_directory "#{__dir__}/generator"
require_directory "#{__dir__}/helper"
require_directory "#{__dir__}/jekyll_plugin_support"
require_directory "#{__dir__}/tag"
require_directory "#{__dir__}/jekyll_all_collections"
require_directory "#{__dir__}/hooks"
module JekyllSupport
def self.redef_without_warning(const, value)
send(:remove_const, const) if const_defined?(const)
const_set const, value
end
end
module NoArgParsing
attr_accessor :no_arg_parsing
@no_arg_parsing = true
end
module JekyllSupport
class JekyllTag
include JekyllSupportError
end
class JekyllTagNoArgParsing
include JekyllSupportError
end
class JekyllBlock
include JekyllSupportError
end
class JekyllBlockNoArgParsing
include JekyllSupportError
end
end
@@ -0,0 +1,159 @@
# Monkey patch StandardError so a new method called shorten_backtrace is added.
class StandardError
def shorten_backtrace(backtrace_element_count = 3)
# self.backtrace = backtrace[0..backtrace_element_count].map do |x|
# raise JekyllPluginSupportError, "backtrace contains a #{x.class} with value '#{x}'." unless x.instance_of? String
# x.gsub(Dir.pwd + '/', './')
# end
end
end
module JekyllSupport
DISPLAYED_CALLS = 8 unless defined?(DISPLAYED_CALLS)
def self.error_short_trace(logger, error)
error.set_backtrace error.backtrace[0..DISPLAYED_CALLS]
logger.error { error }
error
end
# @return a new StandardError subclass containing the shorten_backtrace method
def define_error
Class.new ::JekyllSupport::CustomError
end
module_function :define_error
JekyllPluginSupportError = define_error unless defined?(JekyllPluginSupportError)
def self.dump_vars(_logger, liquid_context)
page = liquid_context.registers[:page]
vars = liquid_context.scopes.map do |scope|
scope.map { |name, value| " #{name} = #{value}" }.join("\n")
end.join("\n")
puts <<~END_MSG
#{page['name']} variables after injecting any defined in _config.yml:
#{vars}
END_MSG
end
# Inject variable definitions from _config.yml into liquid_context
# Modifies liquid_context.scopes in the caller
# (call by object reference, see https://stackoverflow.com/a/1872159/553865)
# @return modified liquid_context
# See README.md#configuration-variable-definitions
# See demo/variables.html
def self.inject_config_vars(liquid_context)
site = liquid_context.registers[:site]
plugin_variables = site.config['liquid_vars']
scope = liquid_context.scopes.last
env = site.config['env']
@mode = env&.key?('JEKYLL_ENV') ? env['JEKYLL_ENV'] : 'development'
# Set default values
plugin_variables&.each do |name, value|
scope[name] = value if value.instance_of? String
end
# Override with environment-specific values
plugin_variables&.[](@mode)&.each do |name, value|
scope[name] = value if value.instance_of? String
end
liquid_context
end
def self.dump_stack(stack, cycles, interval)
stack_depth = stack.length
puts "Stack depth is #{stack_depth}"
num_entries = cycles * interval
return unless stack_depth > interval * 5
stack.last(num_entries).each_with_index do |x, i|
msg = " #{i}: #{x}"
(i % interval).zero? ? puts(msg.yellow) : puts(msg)
end
end
# Modifies a clone of markup_original so variable references are replaced by their values
# @param markup_original to be cloned
# @return modified markup_original
def self.lookup_liquid_variables(logger, liquid_context, markup_original)
markup = markup_original.clone
page = liquid_context.registers[:page]
envs = liquid_context.environments.first
layout = envs[:layout]
markup = process_layout_variables logger, layout, markup
markup = process_page_variables logger, page, markup
liquid_context.scopes&.each do |scope|
markup = process_included_variables logger, scope, markup
markup = process_liquid_variables logger, scope, markup
end
markup
rescue StandardError => e
logger.error { e.full_message }
end
def self.process_included_variables(logger, scope, markup)
scope['include']&.each do |name, value|
if value.nil?
value = ''
logger.warn { "include.#{name} is undefined." }
end
markup.gsub!("{{include.#{name}}}", value)
end
markup
rescue StandardError => e
logger.error { e.full_message }
end
def self.process_layout_variables(logger, layout, markup)
layout&.each do |name, value|
if value.nil?
value = ''
logger.warn { "layout.#{value} is undefined." }
end
markup.gsub!("{{layout.#{name}}}", value.to_s)
end
markup
rescue StandardError => e
logger.error { e.full_message }
end
# Process assigned, captured and injected variables
def self.process_liquid_variables(logger, scope, markup)
scope&.each do |name, value|
next if name.nil?
value = '' if value.nil?
markup.gsub!("{{#{name}}}", value&.to_s)
end
markup
rescue StandardError => e
logger.error { e.full_message }
end
def self.process_page_variables(logger, page, markup)
page&.each_key do |key|
next if %w[content excerpt next previous output].include? key # Skip problem attributes
markup.gsub!("{{page.#{key}}}", page[key].to_s)
end
markup
rescue StandardError => e
logger.error { e.full_message }
end
def self.warn_short_trace(logger, error)
remaining = error.backtrace.length - DISPLAYED_CALLS
logger.warn do
error.message + "\n" +
error.backtrace.take(DISPLAYED_CALLS).join("\n") +
"\n...Remaining #{remaining} call sites elided.\n"
end
end
end
@@ -0,0 +1,34 @@
Registers = Struct.new(:page, :site) unless defined?(Registers)
# Mock for Collections
class Collections
def values
[]
end
end
# Mock for Site
class SiteMock
attr_reader :config
def collections
Collections.new
end
end
# Mock for Liquid::ParseContent
class TestParseContext < Liquid::ParseContext
attr_reader :line_number, :registers
# rubocop:disable Layout/CommentIndentation
# def initialize
# super
# @line_number = 123
# @registers = Registers.new(
# { 'path' => 'https://feeds.soundcloud.com/users/soundcloud:users:7143896/sounds.rss' },
# SiteMock.new
# )
# end
# rubocop:enable Layout/CommentIndentation
end
@@ -0,0 +1,3 @@
module JekyllPluginSupportVersion
VERSION = '3.0.0'.freeze unless defined?(VERSION)
end
@@ -0,0 +1,106 @@
module JekyllSupport
# Base class for Jekyll tags
class JekyllTag < Liquid::Tag
attr_reader :argument_string, :helper, :line_number, :logger, :page, :site
# 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 usually know.
# @param argument_string [String] the arguments passed to the tag, as a single string.
# @param parse_context [Liquid::ParseContext] contains the following attributes:
# @depth might have the value 0
# @error_mode might have the value `:strict`
# @line_number duplicates @ptions[:line_number]
# @locale duplicates @ptions[:locale]
# @options is a hash with the following two keys that holds Liquid options:
# :locale is a Liquid::I18n object, used to display localized error messages on Liquid built-in tags and filters.
# :line_number is the line number containing the plugin invocation.
# See https://github.com/Shopify/liquid/wiki/Liquid-for-Programmers#create-your-own-tags
# @partial Boolean, unclear what this indicates
# @template_options Replicates @options
# @trim_whitespace might have the value `false`
# @warnings array
# @return [void]
def initialize(tag_name, markup, parse_context)
super
@tag_name = tag_name
raise JekyllPluginSupportError, "markup is a #{markup.class} with value '#{markup}'." unless markup.instance_of? String
# Vars in plugin parameters cannot be replaced yet
@argument_string = markup.to_s # Lookup variable names with values in markup in render because site and config are not available here
@logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)
@logger.debug { "#{self.class}: respond_to?(:no_arg_parsing) #{respond_to?(:no_arg_parsing) ? 'yes' : 'no'}." }
@helper = JekyllPluginHelper.new(tag_name, @argument_string, @logger, respond_to?(:no_arg_parsing))
@error_name = "#{tag_name.camelcase(:upper)}Error"
::JekyllSupport::CustomError.factory @error_name
end
# Method prescribed by the Jekyll plugin lifecycle.
def render(liquid_context)
return if @helper.excerpt_caller
@helper.liquid_context = JekyllSupport.inject_config_vars liquid_context # modifies liquid_context
@envs = liquid_context.environments.first
@page = liquid_context.registers[:page]
@scopes = liquid_context.scopes
@site = liquid_context.registers[:site]
@config = @site.config
@tag_config = @config[@tag_name]
@jps = @config['jekyll_plugin_support']
@pry_on_standard_error = @jps['pry_on_standard_error'] || false if @jps
set_error_context
# @envs.keys are :content, :highlighter_prefix, :highlighter_suffix, :jekyll, :layout, :page, :paginator, :site, :theme
@layout = @envs[:layout]
@paginator = @envs[:paginator]
@theme = @envs[:theme]
env = @config['env']
@mode = env&.key?('JEKYLL_ENV') ? env['JEKYLL_ENV'] : 'development'
@argument_string = JekyllSupport.lookup_liquid_variables @logger, @helper.liquid_context, @argument_string.to_s.strip
@helper.reinitialize @argument_string.to_s.strip
# @argument_string = JekyllSupport.lookup_liquid_variables @logger, liquid_context, @argument_string # Is this redundant?
# @argument_string.strip! # Is this redundant?
# @helper.reinitialize @argument_string # Is this redundant?
render_impl
rescue StandardError => e
e.shorten_backtrace
file_name = e.backtrace[0]&.split(':')&.first
in_file_name = "in '#{file_name}' " if file_name
of_page = "of '#{@page['path']}'" if @page
@logger.error { "#{e.class} on line #{@line_number} #{of_page} while processing #{tag_name} #{in_file_name}- #{e.message}" }
binding.pry if @pry_on_standard_error # rubocop:disable Lint/Debugger
raise e if @die_on_standard_error
<<~END_MSG
<div class='standard_error'>
#{e.class} on line #{@line_number} #{of_page} while processing #{tag_name} #{in_file_name} - #{JekyllPluginHelper.remove_html_tags e.message}
</div>
END_MSG
end
# Jekyll plugins must override this method, not render, so their plugin can be tested more easily
# The following variables are predefined:
# @argument_string, @config, @envs, @helper, @layout, @logger, @mode, @page, @paginator, @site, @tag_name and @theme
def render_impl
abort "#{self.class}.render_impl for tag #{@tag_name} must be overridden, but it was not."
end
def set_error_context
return unless Object.const_defined? @error_name
error_class = Object.const_get @error_name
error_class.class_variable_set(:@@argument_string, @argument_string)
error_class.class_variable_set(:@@line_number, @line_number)
error_class.class_variable_set(:@@path, @page['path'])
error_class.class_variable_set(:@@tag_name, @tag_name)
end
end
end
@@ -0,0 +1,21 @@
module JekyllSupport
class JekyllTagNoArgParsing < JekyllTag
attr_reader :argument_string, :helper, :line_number, :logger, :page, :site
def initialize(tag_name, argument_string, parse_context)
class << self
include NoArgParsing
end
super
@logger.debug { "#{self.class}: respond_to?(:no_arg_parsing) #{respond_to?(:no_arg_parsing) ? 'yes' : 'no'}." }
end
# Jekyll plugins must override this method, not render, so their plugin can be tested more easily
# The following variables are predefined:
# @argument_string, @config, @envs, @helper, @layout, @logger, @mode, @page, @paginator, @site, @tag_name and @theme
def render_impl
abort "#{self.class}.render_impl for tag #{@tag_name} must be overridden, but it was not."
end
end
end
@@ -0,0 +1,152 @@
unless defined?(MSlinnBinarySearchError)
class MSlinnBinarySearchError < StandardError
end
end
# Ruby's binary search is unsuitable because the value to be searched for changes the required ordering for String compares
class MSlinnBinarySearch
attr_reader :accessor_chain, :array # For testing only
def initialize(accessor_chain)
@array = SortedSet.new # [LruFile] Ordered highest to lowest
@accessor_chain = accessor_chain
end
# Convert the SortedSet to an Array
def enable_search
@array = @array.to_a
end
# A match is found when the Array[LruFile] has an href which starts with the given stem
# @param stem [String]
# @return first item from @array.url that matches, or nil if no match
def find(stem)
raise MSlinnBinarySearchError, 'Invalid find because stem to search for is nil.' if stem.nil?
index = find_index(stem)
return nil if index.nil?
@array[index]
end
# @param stem [String]
# @return index of first matching stem, or nil if @array is empty, or 0 if no stem specified
def find_index(stem)
return nil if @array.empty?
return 0 if stem.nil? || stem.empty?
mets = stem.reverse
return nil if @array[0].url[0...mets.size] > mets # TODO: use chain eval for item
return nil if @array[0].url[0] != mets[0]
_find_index(mets, 0, @array.length - 1)
end
# @param stem [String]
# @return [index] of matching values, or [] if @array is empty, or entire array if no stem specified
def find_indices(stem)
return [] if @array.empty?
return @array if stem.nil? || stem.empty?
first_index = _find_index(stem, 0, @array.length - 1)
last_index = first_index
last_index += 1 while @array[last_index].url.start_with? stem
[first_index..last_index]
end
# @param item [LruFile]
# @return [int] index of matching LruFile in @array, or nil if not found
def index_of(lru_file)
raise MSlinnBinarySearchError, 'Invalid index_of lru_file (nil).' if lru_file.nil?
find_index lru_file.url
end
# @return [LruFile] item at given index in @array
def item_at(index)
if index > @array.length - 1
raise MSlinnBinarySearchError,
"Invalid item_at index (#{index}) is greater than maximum stem (#{@array.length - 1})."
end
raise MSlinnBinarySearchError, "Invalid item_at index (#{index}) is less than zero." if index.negative?
@array[index]
end
# @param lru_file [LruFile]
def insert(lru_file)
raise MSlinnBinarySearchError, 'Invalid insert because new item is nil.' if lru_file.nil?
raise MSlinnBinarySearchError, "Invalid insert because new item has no chain (#{lru_file})" if lru_file.chain.nil?
@array.add lru_file
end
# TODO: Cache this method
# @param suffix [String] to use stem search on
# @return nil if @array is empty
# @return the first item in @array if suffix is nil or an empty string
def prefix_search(suffix)
return nil if @array.empty?
return @array[0] if suffix.empty? || suffix.nil?
low = search_index { |x| x.evaluate_with suffix }
return [] if low.nil?
high = low
high += 1 while high < @array.length &&
@array[high].evaluate_with(suffix)
@array[low..high]
end
# @param stem [String]
# @return [APage] matching APages, or [] if @array is empty, or entire array if no stem specified
def select_pages(stem)
first_index = find_index stem
return [] if first_index.nil?
last_index = first_index
while last_index < @array.length - 1
# LruFile.url is reversed, bug LruFile.page is not
break unless @array[last_index + 1].url.start_with?(stem.reverse)
last_index += 1
end
Range.new(first_index, last_index).map { |i| @array[i].page }
end
private
# A match is found when the Array[LruFile] has an href which starts with the given stem
# @param stem [String]
# @return [int] first index in @array that matches, or nil if no match
def _find_index(mets, min_index, max_index)
raise MSlinnBinarySearchError, "_find_index min_index(#{min_index})<0" if min_index.negative?
raise MSlinnBinarySearchError, "_find_index min_index(#{min_index})>max_index(#{max_index})" if min_index > max_index
raise MSlinnBinarySearchError, "_find_index max_index(#{max_index})>=@array.length(#{@array.length})" if max_index >= @array.length
return min_index if (min_index == max_index) && @array[min_index].url.start_with?(mets)
while min_index < max_index
mid_index = (min_index + max_index) / 2
mid_value = @array[mid_index].url[0...(mets.size)] # TODO: use chain eval for item
if mid_value == mets # back up until the first match is found
index = mid_index
loop do
return 0 if index.zero?
return index unless @array[index - 1].url.start_with?(mets)
index -= 1
end
elsif mid_value > mets
max_index = mid_index - 1
return _find_index(mets, min_index, max_index)
else
min_index = mid_index + 1
return _find_index(mets, min_index, max_index)
end
end
nil
end
end
@@ -0,0 +1,56 @@
# Supports one chain at a time
module SendChain
# See https://stackoverflow.com/a/79333706/553865
# This method can be called directly if no methods in the chain require arguments
# Does not use any external state
def send_chain(chain)
Array(chain).inject(self) { |o, a| o.send(*a) }
end
# Saves @chain structure containing :placeholders for arguments to be supplied later
# Call when a different chain with :placeholders is desired
def new_chain(chain)
abort "new_chain error: chain must be an array ('#{chain}' was an #{chain.class.name})" \
unless chain.instance_of?(Array)
@chain = chain
end
# Call after new_chain, to evaluate @chain with values
def substitute_and_send_chain_with(values)
send_chain substitute_chain_with values
end
alias evaluate_with substitute_and_send_chain_with
# Call this method after calling new_chain to perform error checking and replace :placeholders with values.
# @chain is not modified.
# @return [Array] Modified chain
def substitute_chain_with(values)
values = [values] unless values.instance_of?(Array)
placeholder_count = @chain.flatten.count { |x| x == :placeholder }
if values.length != placeholder_count
abort "with_values error: number of values (#{values.length}) does not match the number of placeholders (#{placeholder_count})"
end
eval_chain @chain, values
end
private
# Replaces :placeholders with values
# Does not use any external state
# @return modified chain
def eval_chain(chain, values)
chain.map do |c|
case c
when :placeholder
values.shift
when Array
eval_chain c, values
else
c
end
end
end
end
@@ -0,0 +1,112 @@
require 'spec_helper'
require_relative '../../lib/jekyll_all_collections'
class APageStub
attr_reader :date, :last_modified, :label
def initialize(date, last_modified, label = '')
@date = Date.parse date
@last_modified = Date.parse last_modified
@label = label
end
def to_s
@label
end
end
def show(lambda_string, result, expected)
p "For lambda_string: #{lambda_string}"
p " result: #{result.map(&:label).join(', ')} <==> expected: #{expected.map(&:label).join(', ')}"
end
# See https://stackoverflow.com/a/75388137/553865
RSpec.describe(AllCollectionsTag::AllCollectionsTag) do
let(:o1) { APageStub.new('2020-01-01', '2020-01-01', 'a_A') }
let(:o2) { APageStub.new('2021-01-01', '2020-01-01', 'b_A') }
let(:o3) { APageStub.new('2021-01-01', '2023-01-01', 'b_B') }
let(:o4) { APageStub.new('2022-01-01', '2023-01-01', 'c_B') }
let(:objs) { [o1, o2, o3, o4] }
it 'defines sort_by lambda with last_modified' do
sort_lambda = ->(a, b) { [a.last_modified] <=> [b.last_modified] }
result = objs.sort(&sort_lambda)
expect(result).to eq([o1, o2, o3, o4])
end
it 'makes sort_by lambdas from stringified date' do
sort_lambda = eval '->(a, b) { a.last_modified <=> b.last_modified }',
NullBinding.new.min_binding, __FILE__, __LINE__ - 1
result = objs.sort(&sort_lambda)
expect(result).to eq([o1, o2, o3, o4])
end
it 'makes sort_by lambdas from stringified array of last_modified' do
sort_lambda = eval '->(a, b) { [a.last_modified] <=> [b.last_modified] }',
NullBinding.new.min_binding, __FILE__, __LINE__ - 1
result = objs.sort(&sort_lambda)
expect(result).to eq([o1, o2, o3, o4])
end
it 'makes sort_by lambdas with descending keys from stringified array of last_modified' do
sort_lambda = eval '->(a, b) { [b.last_modified] <=> [a.last_modified] }',
NullBinding.new.min_binding, __FILE__, __LINE__ - 1
result = objs.sort(&sort_lambda)
expected = [o3, o4, o1, o2]
expect(result).to eq(expected)
end
it 'create_lambda with 1 date key, descending' do
lambda_string = described_class.create_lambda_string('-last_modified')
sort_lambda = described_class.evaluate(lambda_string)
result = objs.sort(&sort_lambda)
expected = [o3, o4, o1, o2]
# show(lambda_string, result, expected)
expect(result).to eq(expected)
end
it 'create_lambda with 1 date key, ascending' do
lambda_string = described_class.create_lambda_string('date')
sort_lambda = described_class.evaluate(lambda_string)
result = objs.sort(&sort_lambda)
expected = [o1, o2, o3, o4]
# show(lambda_string, result, expected)
expect(result).to eq(expected)
end
it 'create_lambda with 2 date keys, both ascending' do
lambda_string = described_class.create_lambda_string(%w[date last_modified])
sort_lambda = described_class.evaluate(lambda_string)
result = objs.sort(&sort_lambda)
expected = [o1, o2, o3, o4]
# show(lambda_string, result, expected)
expect(result).to eq(expected)
end
it 'create_lambda with 2 date keys, both descending' do
lambda_string = described_class.create_lambda_string(['-date', '-last_modified'])
sort_lambda = described_class.evaluate(lambda_string)
result = objs.sort(&sort_lambda)
expected = [o4, o3, o2, o1]
# show(lambda_string, result, expected)
expect(result).to eq(expected)
end
it 'create_lambda with 2 date keys, first descending and second ascending' do
lambda_string = described_class.create_lambda_string(['-date', 'last_modified'])
sort_lambda = described_class.evaluate(lambda_string)
result = objs.sort(&sort_lambda)
expected = [o4, o2, o3, o1]
# show(lambda_string, result, expected)
expect(result).to eq(expected)
end
it 'create_lambda with 2 date keys, first ascending and second descending' do
lambda_string = described_class.create_lambda_string(['date', '-last_modified'])
sort_lambda = described_class.evaluate(lambda_string)
result = objs.sort(&sort_lambda)
expected = [o1, o3, o2, o4]
# show(lambda_string, result, expected)
expect(result).to eq(expected)
end
end
@@ -0,0 +1,50 @@
require 'spec_helper'
# Ruby's binary search is unsuitable because ordering requirements are not stable.
# the value to be searched for changes the required ordering
RSpec.describe(Array) do
before { skip('Never gonna give you up/Never gonna let you down') }
sorted_ints = [0, 4, 7, 10, 12]
sorted_strings = %w[aaa aab aac bbb bbc bbd ccc ccd cce].sort.reverse
it 'returns index of first int match' do
actual = sorted_ints.bsearch_index { |x| x >= 4 }
expect(actual).to eq(1)
actual = sorted_ints.bsearch_index { |x| x >= 6 }
expect(actual).to eq(2)
actual = sorted_ints.bsearch_index { |x| x >= -1 }
expect(actual).to eq(0)
actual = sorted_ints.bsearch_index { |x| x >= 100 }
expect(actual).to be_nil
end
# See https://stackoverflow.com/q/79333097/553865
it 'returns index of first string match' do
puts(sorted_strings.map { |x| x.start_with? 'a' })
index = sorted_strings.bsearch_index { |x| x.start_with? 'a' }
expect(sorted_strings[index]).to eq('aac')
index = sorted_strings.bsearch_index { |x| x.start_with? 'aa' }
expect(sorted_strings[index]).to eq('aac')
index = sorted_strings.bsearch_index { |x| x.start_with? 'aaa' }
expect(sorted_strings[index]).to eq('aaa')
index = sorted_strings.bsearch_index { |x| x.start_with? 'b' }
expect(sorted_strings[index]).to eq('bbd')
index = sorted_strings.bsearch_index { |x| x.start_with? 'bb' }
expect(sorted_strings[index]).to eq('bbd')
index = sorted_strings.bsearch_index { |x| x.start_with? 'bbc' }
expect(sorted_strings[index]).to eq('bbc')
index = sorted_strings.bsearch_index { |x| x.start_with? 'cc' }
expect(sorted_strings[index]).to eq('cce')
end
end
@@ -0,0 +1,39 @@
require_relative '../lib/error/jekyll_custom_error'
require_relative '../lib/jekyll_plugin_support/jekyll_plugin_support_class'
class Dummy
def just_for_testing; end
end
class CustomErrorSpec
tag_name = 'test_tag'
argument_string = 'This is the argument string'
AnError = ::JekyllSupport.define_error
AnError.class_variable_set(:@@tag_name, tag_name)
AnError.class_variable_set(:@@argument_string, argument_string)
puts "AnError is a #{AnError.class}; StandardError is a #{StandardError.class}"
begin
raise AnError, 'Oops'
rescue AnError => e
puts "Caught AnError: #{e.message}"
rescue ::JekyllSupport::CustomError => e
puts "Caught CustomError: #{e.message}"
end
RSpec.describe JekyllSupport::JekyllPluginHelper do
it 'generates messages' do
msg = described_class.generate_message(Dummy, tag_name, '0.1.0')
puts msg
expect(msg).to include('Error class: DummyError')
expect(msg).to include('CSS class for error messages: dummy_error')
expect(msg).to include('die_on_dummy_error: false')
end
end
RSpec.describe ::JekyllSupport::CustomError do
it 'can create custom errors' do
expect { raise AnError, 'Oops' }.to raise_error(AnError)
end
end
end
@@ -0,0 +1,84 @@
require 'spec_helper'
require_relative '../lib/jekyll_plugin_support'
# Verifies how data comparisons work
class Obj
# `last_modified` is primary sort key
# `date` (when specified) is secondary sort key
attr_reader :date, :last_modified
def initialize(param1, param2)
@last_modified = Date.parse(param1)
@date = Date.parse(param2)
end
end
RSpec.describe(Obj) do
let(:o1) { described_class.new('2000-01-01', '2001-01-01') }
let(:o2) { described_class.new('2010-01-01', '2001-01-01') }
let(:o3) { described_class.new('2010-01-01', '2011-01-01') }
let(:o4) { described_class.new('2020-01-01', '2011-01-01') }
let(:objs) { [o1, o2, o3, o4] }
# See https://ruby-doc.org/3.2.0/Comparable.html
it 'compares one key with ascending dates' do
expect([o1.last_modified] <=> [o2.last_modified]).to eq(-1)
expect([o2.last_modified] <=> [o3.last_modified]).to eq(0)
expect([o3.last_modified] <=> [o4.last_modified]).to eq(-1)
end
it 'compares two keys with ascending dates' do
expect([o1.last_modified, o1.date] <=> [o2.last_modified, o2.date]).to eq(-1)
expect([o2.last_modified, o2.date] <=> [o3.last_modified, o3.date]).to eq(-1)
expect([o3.last_modified, o3.date] <=> [o4.last_modified, o4.date]).to eq(-1)
end
it 'compares one key with descending dates' do
expect([o1.last_modified] <=> [o2.last_modified]).to eq(-1)
expect([o2.last_modified] <=> [o3.last_modified]).to eq(0)
end
it 'compares two keys with descending dates' do
expect([o2.last_modified, o2.date] <=> [o1.last_modified, o1.date]).to eq(1)
expect([o3.last_modified, o3.date] <=> [o2.last_modified, o2.date]).to eq(1)
expect([o4.last_modified, o4.date] <=> [o3.last_modified, o3.date]).to eq(1)
end
# See https://ruby-doc.org/3.2.0/Enumerable.html#method-i-sort
it 'sort with one key ascending' do
sort_lambda = ->(a, b) { [a.last_modified] <=> [b.last_modified] }
result = objs.sort(&sort_lambda)
expect(result).to eq([o1, o2, o3, o4])
end
it 'sort with one key descending' do
sort_lambda = ->(a, b) { [b.last_modified] <=> [a.last_modified] }
result = objs.sort(&sort_lambda)
expect(result).to eq([o4, o2, o3, o1])
end
it 'sort with two keys ascending' do
sort_lambda = ->(a, b) { [a.last_modified, a.date] <=> [b.last_modified, b.date] }
result = objs.sort(&sort_lambda)
expect(result).to eq([o1, o2, o3, o4])
end
it 'sort with both keys descending' do
sort_lambda = ->(a, b) { [b.last_modified, b.date] <=> [a.last_modified, a.date] }
result = objs.sort(&sort_lambda)
expect(result).to eq([o4, o3, o2, o1])
end
it 'sort with last_modified descending and date ascending' do
sort_lambda = ->(a, b) { [b.last_modified, a.date] <=> [a.last_modified, b.date] }
result = objs.sort(&sort_lambda)
expect(result).to eq([o4, o2, o3, o1])
end
it 'sort with last_modified ascending and date descending' do
sort_lambda = ->(a, b) { [a.last_modified, b.date] <=> [b.last_modified, a.date] }
result = objs.sort(&sort_lambda)
expect(result).to eq([o1, o3, o2, o4])
end
end
@@ -0,0 +1,64 @@
require 'jekyll_plugin_logger'
# require 'rspec/match_ignoring_whitespace'
require_relative '../lib/jekyll_plugin_support'
require_relative '../lib/jekyll_plugin_support/jekyll_plugin_support_spec_support'
class JekyllPluginHelperOptionsTest
RSpec.describe ::JekyllSupport::JekyllPluginHelper do
logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)
it 'parses quoted string options' do
helper = described_class.new('my_tag', "colors='blue or green' blah ick", logger, false)
helper.reinitialize helper.markup
expect(helper.keys_values.keys).to eq(%w[colors blah ick])
colors = helper.parameter_specified? 'colors'
expect(colors).to eq('blue or green')
expect(helper.keys_values.keys).to eq(%w[blah ick])
end
it 'parses unquoted string options' do
helper = described_class.new('my_tag', 'color=blue blah ick', logger, false)
helper.reinitialize helper.markup
expect(helper.keys_values.keys).to eq(%w[color blah ick])
color = helper.parameter_specified? 'color'
expect(color).to eq('blue')
expect(helper.keys_values.keys).to eq(%w[blah ick])
end
it 'parses quoted booleans' do
helper = described_class.new('my_tag', "bool1='true' bool2='false' blah ick", logger, false)
helper.reinitialize helper.markup
expect(helper.keys_values.keys).to eq(%w[bool1 bool2 blah ick])
bool1 = helper.parameter_specified? 'bool1'
expect(bool1).to be true
bool2 = helper.parameter_specified? 'bool2'
expect(bool2).to be false
expect(helper.keys_values.keys).to eq(%w[blah ick])
expect(helper.remaining_markup).to eq('blah ick')
end
it 'parses unquoted booleans' do
helper = described_class.new('my_tag', 'bool1=true bool2=false blah ick', logger, false)
helper.reinitialize helper.markup
expect(helper.keys_values.keys).to eq(%w[bool1 bool2 blah ick])
bool1 = helper.parameter_specified? 'bool1'
expect(bool1).to be true
bool2 = helper.parameter_specified? 'bool2'
expect(bool2).to be false
expect(helper.keys_values.keys).to eq(%w[blah ick])
expect(helper.remaining_markup).to eq('blah ick')
end
end
end
@@ -0,0 +1,34 @@
require 'jekyll_plugin_logger'
# require 'rspec/match_ignoring_whitespace'
require_relative '../lib/jekyll_plugin_support'
require_relative '../lib/jekyll_plugin_support/jekyll_plugin_support_spec_support'
class LiquidVariableParsing
# @return copy of str with references to defined variables replaced by the values of the variables
def self.variable_replace(str, scopes)
result = str.clone
match_data_list = str.to_enum(:scan, /{{[a-z_][a-zA-Z_0-9]*}}/).map { Regexp.last_match }.reverse
match_data_list&.each do |md|
from = md.begin(0)
to = md.end(0) - 1
ref = str[from..to]
name = ref[2..-3]
scopes&.each do |scope|
value = scope.key?(name) ? scope[name] : ref
# puts "str=#{str}; from=#{from}; to=#{to}; name=#{name} value=#{value}"
result[from..to] = value
end
end
result
end
RSpec.describe ::JekyllSupport::JekyllPluginHelper do
it 'substitutes variable references for values without recursion' do
scopes = [{ 'a' => '{{', 'b' => 'asdf', 'c' => '}}' }]
str = '{{a}}{{b}}{{c}} This should be unchanged: {{d}}'
new_str = LiquidVariableParsing.variable_replace(str, scopes)
expect(str).to start_with('{{a}}')
expect(new_str).to eq('{{asdf}} This should be unchanged: {{d}}')
end
end
end
@@ -0,0 +1,47 @@
require 'spec_helper'
require_relative '../lib/hooks/all_files'
def build_msbs(sorted_strings)
sorted_strings.each do |string|
$msbs.insert LruFile.new(string.reverse, "Page #{string}") # { |x| x.url.start_with? string }
end
$msbs.enable_search
end
RSpec.describe(MSlinnBinarySearch) do
$msbs = described_class.new [:url, %i[start_with? placeholder]]
build_msbs %w[aaa.html baa.html caa.html bbb.html cbb.html dbb.html ccc.html ccd.html cce.html]
it 'handles an empty search string by returning the index of the first item (0)' do
index = $msbs.find_index '' # { |x| x.url.start_with? '' }
expect(index).to be_zero
end
it 'returns the index of the first partial match' do
index = $msbs.find_index 'a.html' # { |x| x.url.start_with? 'a.html' }
expect(index).to eq(0)
index = $msbs.find_index 'baa.html' # { |x| x.url.start_with? 'baa.html' }
expect(index).to eq(1)
index = $msbs.find_index 'c.html' # { |x| x.url.start_with? 'c.html' }
expect(index).to eq(6)
index = $msbs.find_index 'cce.html' # { |x| x.url.start_with? 'cce.html' }
expect(index).to eq(8)
end
it 'returns the item of the first match' do
item = $msbs.find 'a.html' # { |x| x.url.start_with? 'a.html' }
expect(item.url.reverse).to eq('aaa.html')
item = $msbs.find 'baa.html' # { |x| x.url.start_with? 'baa.html' }
expect(item.url.reverse).to eq('baa.html')
item = $msbs.find 'c.html' # { |x| x.url.start_with? 'c.html' }
expect(item.url.reverse).to eq('ccc.html')
item = $msbs.find 'cce.html' # { |x| x.url.start_with? 'cce.html' }
expect(item.url.reverse).to eq('cce.html')
end
end
@@ -0,0 +1,72 @@
require 'spec_helper'
require_relative '../lib/util/send_chain'
unless defined? LruFile
LruFile = Struct.new(:url, :page) do
include SendChain
end
end
RSpec.describe(LruFile) do
lru_file = described_class.new 'abc', 'def'
it 'can perform a simple call if no arguments are required' do
# Equivalent to: lru_file.url.reverse
actual = lru_file.send_chain %i[url reverse]
expect(actual).to eq('cba')
end
it 'can accept a scalar argument in stages' do
lru_file.new_chain [:url, %i[end_with? placeholder]]
# Equivalent to: lru_file.url.end_with?('bc')
substituted_chain = lru_file.substitute_chain_with 'bc'
actual = lru_file.send_chain substituted_chain
expect(actual).to be true
end
it 'can accept a vector argument in stages' do
lru_file.new_chain [:url, %i[end_with? placeholder]]
# Next 2 lines are equivalent to: lru_file.url.end_with?('bc')
substituted_chain = lru_file.substitute_chain_with ['bc']
actual = lru_file.send_chain substituted_chain
expect(actual).to be true
end
it 'can accept a scalar argument in one stage' do
lru_file.new_chain [:url, %i[end_with? placeholder]]
# Equivalent to: lru_file.url.end_with?('bc')
actual = lru_file.substitute_and_send_chain_with 'bc'
expect(actual).to be true
end
it 'can accept an array argument in one stage' do
lru_file.new_chain [:url, %i[end_with? placeholder]]
# Equivalent to: lru_file.url.end_with?('bc')
actual = lru_file.substitute_and_send_chain_with ['bc']
expect(actual).to be true
end
it 'can use the evaluate_with alias' do
lru_file.new_chain [:url, %i[end_with? placeholder]]
# Equivalent to: lru_file.url.end_with?('bc')
actual = lru_file.evaluate_with ['bc']
expect(actual).to be true
end
it 'can reuse the chain with different values' do
lru_file.new_chain [:url, %i[end_with? placeholder]]
# Equivalent to: lru_file.url.end_with?('bc')
actual = lru_file.substitute_and_send_chain_with 'bc'
expect(actual).to be true
# Equivalent to: lru_file.url.end_with?('abc')
substituted_chain = lru_file.substitute_chain_with ['abc']
actual = lru_file.send_chain substituted_chain
expect(actual).to be true
# Equivalent to: lru_file.url.end_with?('de')
actual = lru_file.substitute_and_send_chain_with 'de'
expect(actual).to be false
end
end
@@ -0,0 +1,28 @@
require 'spec_helper'
# Exploring how to set date attributes in an object
class Obj
# `last_modified` is primary sort key
# `date` (when specified) is secondary sort key
attr_reader :date, :last_modified
def initialize(param1, param2)
@last_modified = Date.parse(param1)
@date = Date.parse(param2)
end
end
RSpec.describe(Obj) do
let(:obj) { described_class.new('2000-01-01', '2001-01-01') }
it 'can send date' do
date = obj.send :date
expect(obj.date).to eq(date)
end
it 'can send last_modified' do
last_modified = obj.send :last_modified
expect(obj.last_modified).to eq(last_modified)
end
end
@@ -0,0 +1,82 @@
require 'spec_helper'
require_relative '../lib/hooks/all_files'
RSpec.describe(SortedLruFiles) do
expected1 = 'first-page.html'
expected2 = 'second-page.html'
expected3 = 'third-page.html'
it 'can read back an inserted item' do
sorted_files = described_class.new
sorted_files.insert(expected1, "https://mslinn.com/#{expected1}") # insert reverses expected1
sorted_files.enable_search
actual = sorted_files.select expected1
expect(actual.length).to eq(1)
expect(actual.first&.reverse&.start_with?(expected1.reverse)).to be true
actual = sorted_files.select expected1[5..]
expect(actual.length).to eq(1)
expect(actual.first&.reverse&.start_with?(expected1[5..].reverse)).to be true
actual = sorted_files.select 'should_not_match'
expect(actual).to be_empty
end
it 'works with 2 items and one match' do
sorted_files = described_class.new
sorted_files.insert(expected1, "https://mslinn.com/#{expected1}") # insert reverses expected1
sorted_files.insert(expected2, "https://mslinn.com/#{expected2}")
sorted_files.enable_search
expect(sorted_files.msbs.array[0].url).to be <= sorted_files.msbs.array[1].url
actual = sorted_files.select expected1
expect(actual.length).to eq(1)
expect(actual.first&.end_with?(expected1)).to be true
actual = sorted_files.select expected2
expect(actual.length).to eq(1)
expect(actual.first&.end_with?(expected2)).to be true
end
it 'handles multiple matches' do
sorted_files = described_class.new
sorted_files.insert(expected1, "https://mslinn.com/#{expected1}") # insert reverses expected1
sorted_files.insert(expected2, "https://mslinn.com/#{expected2}")
sorted_files.enable_search
expected = '.html'
actual = sorted_files.select expected
expect(actual.length).to eq(2)
expect(actual.first&.end_with?(expected)).to be true
end
it 'works with 3 items' do
sorted_files = described_class.new
sorted_files.insert(expected1, "https://mslinn.com/#{expected1}") # insert reverses expected1
sorted_files.insert(expected2, "https://mslinn.com/#{expected2}")
sorted_files.insert(expected3, "https://mslinn.com/#{expected3}")
sorted_files.enable_search
expect(sorted_files.msbs.array[0].url).to be <= sorted_files.msbs.array[1].url
expect(sorted_files.msbs.array[1].url).to be <= sorted_files.msbs.array[2].url
actual = sorted_files.select expected1
expect(actual.length).to eq(1)
expect(actual.first&.end_with?(expected1)).to be true
actual = sorted_files.select expected2
expect(actual.length).to eq(1)
expect(actual.first&.end_with?(expected2)).to be true
actual = sorted_files.select expected3
expect(actual.length).to eq(1)
expect(actual.first&.end_with?(expected3)).to be true
expected = '.html'
actual = sorted_files.select expected
expect(actual.length).to eq(3)
expect(actual.first&.end_with?(expected)).to be true
end
end
@@ -0,0 +1,12 @@
require 'jekyll'
require_relative '../lib/jekyll_plugin_support'
Jekyll.logger.log_level = :info
RSpec.configure do |config|
config.filter_run_when_matching focus: true
# config.order = 'random'
# See https://relishapp.com/rspec/rspec-core/docs/command-line/only-failures
config.example_status_persistence_file_path = 'spec/status_persistence.txt'
end
@@ -0,0 +1,3 @@
example_id | status | run_time |
----------------------------------------------- | ------ | --------------- |
./spec/jekyll_block_plugin_support_spec.rb[1:1] | passed | 0.00062 seconds |
@@ -0,0 +1,38 @@
require 'spec_helper'
# Exploring how to invoke methods from classes and modules
# See https://mslinn.com/jekyll/10700-designing-for-testability.html
module TestModule
def a_method
'a_method says Hi!'
end
end
class TestClass
extend TestModule # Defines class methods
def initialize(param1, param2)
super()
@param1 = param1
@param2 = param2
end
end
RSpec.describe(TestModule) do
extend described_class
it 'Invokes a_method from module' do
result = self.class.a_method
expect(result).to eq('a_method says Hi!')
end
end
RSpec.describe(TestClass) do
let(:o1) { described_class.new('value1', 'value2') }
it 'Invokes a_method from class' do
result = o1.class.a_method
expect(result).to eq('a_method says Hi!')
end
end