This commit is contained in:
@@ -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
|
||||
+157
@@ -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
|
||||
+159
@@ -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
|
||||
+34
@@ -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
|
||||
Reference in New Issue
Block a user