546 lines
13 KiB
Ruby
546 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Implements a {% render %} tag with the same syntax as Liquid 5, plus
|
|
# featuring a small schema where you can define allowed parameters,
|
|
# their types and default values.
|
|
#
|
|
# ---
|
|
# param1:
|
|
# type: string
|
|
# default: hi
|
|
# ---
|
|
#
|
|
# {{ param1 }}
|
|
require 'digest'
|
|
|
|
module Dumpable
|
|
def self.included(base)
|
|
base.class_eval do
|
|
# Allow the document/page to be dumped in a way that we can use it
|
|
# as a cache key. We don't need to load it back again yet.
|
|
#
|
|
# @param _
|
|
# @return [String]
|
|
def _dump(_)
|
|
"#{self.class};#{data['uuid']};#{relative_path};#{cache_key}"
|
|
end
|
|
|
|
# Use a cache key
|
|
#
|
|
# @return [String]
|
|
def cache_key
|
|
@cache_key ||= Digest::SHA2.file(relative_path).hexdigest
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
Jekyll::Document.include Dumpable
|
|
Jekyll::Page.include Dumpable
|
|
|
|
module RenderWithSchema
|
|
DEPRECATED_TYPES = {}.freeze
|
|
VALID_TYPES = %(string integer float hash bool array)
|
|
SINGLE_QUOTE = "'"
|
|
DOUBLE_QUOTE = '"'
|
|
SPLAT = '**'
|
|
DONT_SPLAT = %(cache locale if unless)
|
|
|
|
def self.included(base)
|
|
base.class_eval do
|
|
def self.reset
|
|
@@site = nil
|
|
@@liquid_files = {}
|
|
@@template_file_cache = {}
|
|
@@template_cache = {}
|
|
@@schema_cache = {}
|
|
@@validate_params = {}
|
|
@@required_keys = {}
|
|
@@type_from = {}
|
|
@@default_values = {}
|
|
@@digest = {}
|
|
@@dependencies_cache = {}
|
|
@@dependencies_digest = {}
|
|
end
|
|
end
|
|
end
|
|
|
|
def initialize(tag_name, markup, tokens)
|
|
super
|
|
parse_markup(markup)
|
|
end
|
|
|
|
private
|
|
|
|
def production?
|
|
Jekyll.env == 'production'
|
|
end
|
|
|
|
# @param markup [String]
|
|
# @return [nil]
|
|
def parse_markup(markup)
|
|
args = markup.split(',').map(&:strip).reject(&:empty?)
|
|
@file = args.shift
|
|
|
|
@params ||= {}
|
|
@params[@file] ||= args.to_h do |arg|
|
|
a, b = arg.split(':', 2).map(&:strip)
|
|
b = a if b.nil? || b.empty?
|
|
|
|
[a, b]
|
|
end
|
|
|
|
@conditional_if = params.delete('if')
|
|
@conditional_unless = params.delete('unless')
|
|
|
|
nil
|
|
end
|
|
|
|
# @return [Hash]
|
|
def params
|
|
@params[@include_file || @file] || @params[@file]
|
|
end
|
|
|
|
# If the render is conditional, we check that the condition is met
|
|
#
|
|
# @param context [Liquid::Context]
|
|
def condition_met?(context)
|
|
if @conditional_if
|
|
result = context[@conditional_if]
|
|
!result.nil? && result != false
|
|
elsif @conditional_unless
|
|
result = context[@conditional_unless]
|
|
result.nil? || result == false
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
def set_site!(context)
|
|
# xxx: we remember this the first time because we need the site to
|
|
# find includes, and doesn't come with the context on nested render.
|
|
@@site ||= context.registers[:site]
|
|
|
|
report_error('site missing from context!', fatal: true) unless @@site
|
|
end
|
|
|
|
# We only use this to be able to report where the error is coming from
|
|
def set_page!(context)
|
|
@page ||= context.registers[:page]
|
|
end
|
|
|
|
def splat_params!
|
|
params.select do |key, _|
|
|
key.start_with? SPLAT
|
|
end.each_key do |splat|
|
|
@splatted = true
|
|
params.delete(splat)
|
|
|
|
@schema.each_key do |key|
|
|
next if DONT_SPLAT.include? key
|
|
|
|
params[key] ||= "#{splat[(SPLAT.length)..]}.#{key}"
|
|
end
|
|
end
|
|
end
|
|
|
|
def set_context!(context)
|
|
@context = params.transform_values do |value|
|
|
case value
|
|
when String
|
|
if quoted?(value)
|
|
unquote(value)
|
|
else
|
|
liquid_value = context[value]
|
|
|
|
case liquid_value
|
|
when Liquid::Drop then liquid_value.to_h
|
|
else liquid_value
|
|
end
|
|
end
|
|
when Liquid::Drop
|
|
value.to_h
|
|
else
|
|
value
|
|
end
|
|
end
|
|
end
|
|
|
|
def find_include_file!(context)
|
|
# XXX: we must not cache @file if it's a variable
|
|
if quoted?(@file)
|
|
@include_file ||= unquote(@file)
|
|
else
|
|
# Fix params retroactively
|
|
@include_file = Liquid::Template.parse("{{ #{@file} }}").render(context)
|
|
@params[@include_file] = @params[@file].dup
|
|
end
|
|
|
|
report_error('file param is empty!', fatal: true) if @include_file.nil? || @include_file.empty?
|
|
end
|
|
|
|
# @param file [String]
|
|
# @return [String, nil]
|
|
def find_template_file(file)
|
|
@@template_file_cache[file] ||=
|
|
@@site.includes_load_paths.map do |dir|
|
|
Jekyll::PathManager.join(dir, file)
|
|
end.find do |path|
|
|
File.exist?(path) && (@@site.includes_load_paths.any? do |dir|
|
|
File.realpath(path).start_with?(dir)
|
|
end)
|
|
end
|
|
end
|
|
|
|
def find_template_file!
|
|
@template_file = find_template_file @include_file
|
|
|
|
report_error("doesn't exist or is outside the include dirs", fatal: true) unless @template_file
|
|
report_error("doesn't exist!", fatal: true) unless File.file?(@template_file)
|
|
end
|
|
|
|
def find_liquid_file!
|
|
@liquid_file = @@liquid_files[@template_file] ||= @@site.liquid_renderer.file(@template_file)
|
|
end
|
|
|
|
# rubocop:disable Naming/MemoizedInstanceVariableName
|
|
def parse_template_and_find_schema!
|
|
unless @@template_cache[@template_file]
|
|
content = File.read(@template_file, **@@site.file_read_opts)
|
|
|
|
if content =~ Jekyll::Document::YAML_FRONT_MATTER_REGEXP
|
|
# Add lines to the top of the template to offset the schema
|
|
offset = "\n" * (Regexp.last_match(1).lines.count + 1)
|
|
@@template_cache[@template_file] = @liquid_file.parse(offset + Regexp.last_match.post_match)
|
|
@@schema_cache[@template_file] = SafeYAML.load(Regexp.last_match(1))
|
|
|
|
@schema = @@schema_cache[@template_file]
|
|
@@dependencies_cache[@template_file] = [@schema.delete('dependencies')].flatten.compact.map { find_template_file _1 }
|
|
|
|
validate_schema!
|
|
end
|
|
end
|
|
|
|
@schema ||= @@schema_cache[@template_file]
|
|
@dependencies ||= @@dependencies_cache[@template_file]
|
|
end
|
|
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
|
|
# Calculates a cache key from the contents of the template and the
|
|
# params used.
|
|
#
|
|
# @return [String]
|
|
def cache_key
|
|
Digest::SHA2.new.tap do |dig|
|
|
dig.update digest
|
|
dig.update(dependencies_digest) unless @dependencies.empty?
|
|
dig.update Marshal.dump(@context.sort)
|
|
end.hexdigest
|
|
end
|
|
|
|
# The template file digest
|
|
#
|
|
# @return [String]
|
|
def digest
|
|
@@digest[@template_file] ||= Digest::SHA2.file(@template_file).hexdigest
|
|
end
|
|
|
|
def dependencies_digest
|
|
@@dependencies_digest[@template_file] ||= @dependencies.map do |dependency|
|
|
Digest::SHA2.file(dependency).hexdigest
|
|
end.join
|
|
end
|
|
|
|
# Renders or reports errors
|
|
#
|
|
# @return [String]
|
|
def render!
|
|
@@template_cache[@template_file].render!(@context, strict_variables: strict_variables?)
|
|
rescue Exception => e
|
|
report_error(e.message, fatal: true)
|
|
end
|
|
|
|
# Should we fail on missing variables?
|
|
#
|
|
# @return [Bool]
|
|
def strict_variables?
|
|
!!@@site.config.dig('liquid', 'strict_render') || !!@@site.config.dig('liquid', 'strict_variables')
|
|
end
|
|
|
|
# Validates the schema itself
|
|
#
|
|
# @todo Use dry-schema
|
|
def validate_schema!
|
|
report_error("doesn't have a valid schema", fatal: true) unless @schema.is_a? Hash
|
|
|
|
@schema['cache'] = { 'type' => 'bool' }
|
|
@schema['locale'] = { 'type' => 'string' }
|
|
|
|
@schema.each_pair do |key, definition|
|
|
report_error("#{key} should be a Hash", fatal: true) unless definition.is_a? Hash
|
|
unless VALID_TYPES.include?(definition['type'])
|
|
if DEPRECATED_TYPES.keys.include?(definition['type'])
|
|
report_error("Deprecated #{key}. #{DEPRECATED_TYPES[definition['type']]}", fatal: false)
|
|
else
|
|
report_error("#{definition['type'] || 'nil'} is not a valid type for #{key}", fatal: true)
|
|
end
|
|
end
|
|
|
|
next unless definition.key?('default')
|
|
|
|
default = definition['default']
|
|
|
|
next if valid_type?(definition['type'], default)
|
|
|
|
report_error(
|
|
"\"#{key}\" default value type \"#{default.class.to_s.downcase}\" and must be \"#{definition['type']}\"", fatal: true
|
|
)
|
|
end
|
|
end
|
|
|
|
def valid_type?(type, value)
|
|
type_from(type).include?(value.class)
|
|
end
|
|
|
|
# Sets the default values for missing keys on params
|
|
def default_values!
|
|
default_values = @@default_values[@template_file] ||=
|
|
@schema.select do |_, definition|
|
|
definition.key?('default')
|
|
end.transform_values do |definition|
|
|
definition['default']
|
|
end.tap do |default|
|
|
default['cache'] = @@site.config.key?('cache') ? @@site.config['cache'] : true
|
|
default['locale'] = @@site.config['locale']
|
|
end
|
|
|
|
default_values.each_key do |key|
|
|
@context[key] = default_values[key] unless @context.key?(key) && !@context[key].nil?
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
# @param type_string [String]
|
|
# @return [Array<Object>]
|
|
def type_from(type_string)
|
|
@@type_from[type_string] ||=
|
|
case type_string
|
|
when 'bool' then [TrueClass, FalseClass]
|
|
else [Object.const_get(type_string.capitalize)]
|
|
end
|
|
end
|
|
|
|
# Throw non-fatal errors
|
|
def validate_params!
|
|
validate_types = @@validate_params[@template_file] ||=
|
|
@schema.map do |key, definition|
|
|
[key, definition['type']]
|
|
end.compact.to_h
|
|
|
|
validate_types.each_pair do |key, type|
|
|
next if valid_type?(type, @context[key])
|
|
|
|
report_error("param \"#{key}\" is of invalid type \"#{@context[key].class.to_s.downcase}\" instead of \"#{type}\"")
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
# Validate we didn't provide more keys than needed
|
|
def unallowed_params!
|
|
(@context.keys - @schema.keys).each do |param|
|
|
if @schema.empty?
|
|
report_error "\"#{param}\" param for {% render %} isn't allowed"
|
|
else
|
|
report_error "\"#{param}\" param for {% render %} isn't allowed, keys allowed are #{@schema.keys.join(', ')}"
|
|
end
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
# Reports an error
|
|
#
|
|
# @param error [String]
|
|
# @param :fatal [Bool]
|
|
def report_error(error, fatal: false)
|
|
error = "#{error} at #{@include_file}"
|
|
error += ", page #{@page['path']}" if @page
|
|
|
|
Jekyll.logger.error "#{self.class.name}:", error
|
|
|
|
abort if fatal || strict_variables?
|
|
end
|
|
|
|
# @return [Array<String>]
|
|
def required_keys
|
|
@@required_keys[@template_file] ||= @schema.select do |_, definition|
|
|
definition['required']
|
|
end.keys
|
|
end
|
|
|
|
def quoted?(string)
|
|
(string.start_with?(DOUBLE_QUOTE) && string.end_with?(DOUBLE_QUOTE)) || (string.start_with?(SINGLE_QUOTE) && string.end_with?(SINGLE_QUOTE))
|
|
end
|
|
|
|
def unquote(string)
|
|
string[1..-2]
|
|
end
|
|
end
|
|
|
|
class RenderWithCache < Liquid::Block
|
|
include RenderWithSchema
|
|
|
|
def self.cache
|
|
@cache ||= Jekyll::Cache.new('RenderWithCache')
|
|
end
|
|
|
|
def initialize(tag_name, markup, tokens)
|
|
markup = "cache, #{markup}"
|
|
super
|
|
end
|
|
|
|
def cache_key
|
|
Digest::SHA2.hexdigest(params['key'] + Marshal.dump(@context['key']))
|
|
end
|
|
|
|
def render(context)
|
|
set_site!(context)
|
|
set_page!(context)
|
|
|
|
return '' unless condition_met?(context)
|
|
|
|
set_context!(context)
|
|
|
|
@@template ||= Liquid::Template.parse('{{ yield }}')
|
|
|
|
rendered =
|
|
RenderWithCache.cache.getset(cache_key) do
|
|
@context['yield'] = super
|
|
|
|
@@template.render!(@context, strict_variables: strict_variables?)
|
|
end
|
|
|
|
if production?
|
|
rendered
|
|
else
|
|
<<~EOM
|
|
<!-- #{@include_file} -->
|
|
#{rendered}
|
|
<!-- #{@include_file} -->
|
|
EOM
|
|
end
|
|
rescue Exception => e
|
|
report_error(e.message, fatal: true)
|
|
end
|
|
end
|
|
|
|
# Renders a block, which can contain other blocks or renders.
|
|
class RenderWithSchemaBlock < Liquid::Block
|
|
include RenderWithSchema
|
|
|
|
def self.cache
|
|
@cache ||= Jekyll::Cache.new('RenderWithSchemaBlock')
|
|
end
|
|
|
|
def render(context)
|
|
set_site!(context)
|
|
set_page!(context)
|
|
|
|
return '' unless condition_met?(context)
|
|
|
|
find_include_file!(context)
|
|
|
|
find_template_file!
|
|
find_liquid_file!
|
|
parse_template_and_find_schema!
|
|
splat_params!
|
|
set_context!(context)
|
|
|
|
default_values!
|
|
unallowed_params!
|
|
validate_params!
|
|
|
|
# TODO: find a way in liquid to checksum the block content
|
|
@context['yield'] = super
|
|
|
|
rendered =
|
|
if @context['cache']
|
|
RenderWithSchemaBlock.cache.getset(cache_key) do
|
|
render!
|
|
end
|
|
else
|
|
render!
|
|
end
|
|
|
|
if production?
|
|
rendered
|
|
else
|
|
<<~EOM
|
|
<!-- #{@include_file} -->
|
|
#{rendered}
|
|
<!-- #{@include_file} -->
|
|
EOM
|
|
end
|
|
end
|
|
end
|
|
|
|
# Renders a component
|
|
class RenderWithSchemaTag < Liquid::Tag
|
|
include RenderWithSchema
|
|
|
|
def self.cache
|
|
@cache ||= Jekyll::Cache.new('RenderWithSchemaTag')
|
|
end
|
|
|
|
def render(context)
|
|
set_site!(context)
|
|
set_page!(context)
|
|
|
|
return '' unless condition_met?(context)
|
|
|
|
find_include_file!(context)
|
|
|
|
find_template_file!
|
|
find_liquid_file!
|
|
parse_template_and_find_schema!
|
|
|
|
splat_params!
|
|
set_context!(context)
|
|
|
|
default_values!
|
|
unallowed_params!
|
|
validate_params!
|
|
|
|
rendered =
|
|
if @context['cache']
|
|
RenderWithSchemaTag.cache.getset(cache_key) do
|
|
render!
|
|
end
|
|
else
|
|
render!
|
|
end
|
|
|
|
if production?
|
|
rendered
|
|
else
|
|
<<~EOM
|
|
<!-- #{@include_file} -->
|
|
#{rendered}
|
|
<!-- #{@include_file} -->
|
|
EOM
|
|
end
|
|
end
|
|
end
|
|
|
|
Jekyll::Hooks.register :site, :after_reset, priority: :high do |_|
|
|
RenderWithSchemaTag.reset
|
|
RenderWithSchemaBlock.reset
|
|
RenderWithCache.reset
|
|
end
|
|
|
|
Liquid::Template.register_tag('render', RenderWithSchemaTag)
|
|
Liquid::Template.register_tag('block', RenderWithSchemaBlock)
|
|
Liquid::Template.register_tag('cache', RenderWithCache)
|