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,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