Files
new-website-jekyll-theme/_plugins/render_with_schema.rb
2026-02-06 17:05:53 -03:00

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)