diff --git a/README.md b/README.md index 76cfa3c0e..2caa23b65 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,47 @@ For standard use you can just pass it the content of a file and call render with @template.render('name' => 'tobi') # => "hi tobi" ``` +### Concept of Worlds + +In Liquid, a "World" is a scoped environment that encapsulates custom tags, filters, and other configurations. This allows you to define and isolate different sets of functionality for different contexts, avoiding global overrides that can lead to conflicts and unexpected behavior. + +By using Worlds, you can: + +1. **Encapsulate Logic**: Keep the logic for different parts of your application separate. +2. **Avoid Conflicts**: Prevent custom tags and filters from clashing with each other. +3. **Improve Maintainability**: Make it easier to manage and understand the scope of customizations. +4. **Enhance Security**: Limit the availability of certain tags and filters to specific contexts. + +We encourage the use of Worlds over globally overriding things because it promotes better software design principles such as modularity, encapsulation, and separation of concerns. + +Here's an example of how you can define and use Worlds in Liquid: + +```ruby +user_world = Liquid::World.build do |world| + world.register_tag("renderobj", RenderObjTag) +end + +Liquid::Template.parse(<<~LIQUID, world: user_world) + {% renderobj src: "path/to/model.obj" %} +LIQUID +``` + +In this example, `RenderObjTag` is a custom tag that is only available within the `user_world`. + +Similarly, you can define another world for a different context, such as email templates: + +```ruby +email_world = Liquid::World.build do |world| + world.register_tag("unsubscribe_footer", UnsubscribeFooter) +end + +Liquid::Template.parse(<<~LIQUID, world: email_world) + {% unsubscribe_footer %} +LIQUID +``` + +By using Worlds, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. + ### Error Modes Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted. @@ -62,9 +103,10 @@ Liquid also comes with a stricter parser that can be used when editing templates when templates are invalid. You can enable this new parser like this: ```ruby -Liquid::Template.error_mode = :strict # Raises a SyntaxError when invalid syntax is used -Liquid::Template.error_mode = :warn # Adds strict errors to template.errors but continues as normal -Liquid::Template.error_mode = :lax # The default mode, accepts almost anything. +Liquid::World.default.error_mode = :strict +Liquid::World.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used +Liquid::World.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal +Liquid::World.default.error_mode = :lax # The default mode, accepts almost anything. ``` If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`: diff --git a/lib/liquid.rb b/lib/liquid.rb index eba84e140..229f7a261 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -50,7 +50,18 @@ module Liquid end require "liquid/version" +require "liquid/deprecations" +require "liquid/const" +require "liquid/template/tag_registry" +require 'liquid/standardfilters' +require 'liquid/file_system' +require 'liquid/parser_switching' +require 'liquid/tag' +require 'liquid/block' require 'liquid/parse_tree_visitor' +require 'liquid/interrupts' +require 'liquid/tags' +require "liquid/world" require 'liquid/lexer' require 'liquid/parser' require 'liquid/i18n' @@ -64,20 +75,14 @@ module Liquid require 'liquid/strainer_factory' require 'liquid/expression' require 'liquid/context' -require 'liquid/parser_switching' require 'liquid/tag' -require 'liquid/tag/disabler' -require 'liquid/tag/disableable' -require 'liquid/block' require 'liquid/block_body' require 'liquid/document' require 'liquid/variable' require 'liquid/variable_lookup' require 'liquid/range_lookup' -require 'liquid/file_system' require 'liquid/resource_limits' require 'liquid/template' -require 'liquid/standardfilters' require 'liquid/condition' require 'liquid/utils' require 'liquid/tokenizer' @@ -86,7 +91,3 @@ module Liquid require 'liquid/usage' require 'liquid/registers' require 'liquid/template_factory' - -# Load all the tags of the standard library -# -Dir["#{__dir__}/liquid/tags/*.rb"].each { |f| require f } diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index 61096de80..c30fae868 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -52,7 +52,7 @@ def freeze next parse_liquid_tag(markup, parse_context) end - unless (tag = registered_tags[tag_name]) + unless (tag = parse_context.world.tag_for_name(tag_name)) # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed return yield tag_name, markup @@ -147,7 +147,7 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag) next end - unless (tag = registered_tags[tag_name]) + unless (tag = parse_context.world.tag_for_name(tag_name)) # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed return yield tag_name, markup @@ -262,9 +262,5 @@ def raise_missing_tag_terminator(token, parse_context) def raise_missing_variable_terminator(token, parse_context) BlockBody.raise_missing_variable_terminator(token, parse_context) end - - def registered_tags - Template.tags - end end end diff --git a/lib/liquid/const.rb b/lib/liquid/const.rb new file mode 100644 index 000000000..607f51a5b --- /dev/null +++ b/lib/liquid/const.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Liquid + module Const + EMPTY_HASH = {}.freeze + EMPTY_ARRAY = [].freeze + end +end diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index e712b4cc8..b5ca9c08f 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -15,14 +15,15 @@ module Liquid # context['bob'] #=> nil class Context class Context attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers, :static_environments - attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters + attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters, :world # rubocop:disable Metrics/ParameterLists - def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block) + def self.build(world: World.default, environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block) new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, &block) end - def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}) + def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, world = World.default) + @world = world @environments = [environments] @environments.flatten! @@ -32,7 +33,7 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @errors = [] @partial = false @strict_variables = false - @resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits) + @resource_limits = resource_limits || ResourceLimits.new(world.default_resource_limits) @base_scope_depth = 0 @interrupts = [] @filters = [] @@ -40,10 +41,10 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @disabled_tags = {} @registers.static[:cached_partials] ||= {} - @registers.static[:file_system] ||= Liquid::Template.file_system + @registers.static[:file_system] ||= world.file_system @registers.static[:template_factory] ||= Liquid::TemplateFactory.new - self.exception_renderer = Template.default_exception_renderer + self.exception_renderer = world.exception_renderer if rethrow_errors self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA end @@ -60,7 +61,7 @@ def warnings end def strainer - @strainer ||= StrainerFactory.create(self, @filters) + @strainer ||= @world.create_strainer(self, @filters) end # Adds filters to this context. diff --git a/lib/liquid/deprecations.rb b/lib/liquid/deprecations.rb new file mode 100644 index 000000000..e1ec628f4 --- /dev/null +++ b/lib/liquid/deprecations.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "set" + +module Liquid + class Deprecations + class << self + attr_accessor :warned + + Deprecations.warned = Set.new + + def warn(name, alternative) + return if warned.include?(name) + + warned << name + + caller_location = caller_locations(2, 1).first + Warning.warn("[DEPRECATION] #{name} is deprecated. Use #{alternative} instead. Called from #{caller_location}\n") + end + end + end +end diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 87570ad52..6b5b56a5a 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -3,9 +3,10 @@ module Liquid class ParseContext attr_accessor :locale, :line_number, :trim_whitespace, :depth - attr_reader :partial, :warnings, :error_mode + attr_reader :partial, :warnings, :error_mode, :world - def initialize(options = {}) + def initialize(options = Const::EMPTY_HASH) + @world = options.fetch(:world, World.default) @template_options = options ? options.dup : {} @locale = @template_options[:locale] ||= I18n.new @@ -35,7 +36,7 @@ def partial=(value) @partial = value @options = value ? partial_options : @template_options - @error_mode = @options[:error_mode] || Template.error_mode + @error_mode = @options[:error_mode] || @world.error_mode end def partial_options diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 3b0ecf15d..6f1afaca6 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -1001,6 +1001,4 @@ def each end end end - - Template.register_filter(StandardFilters) end diff --git a/lib/liquid/strainer_factory.rb b/lib/liquid/strainer_factory.rb index bb51ef062..f24e6c995 100644 --- a/lib/liquid/strainer_factory.rb +++ b/lib/liquid/strainer_factory.rb @@ -5,37 +5,19 @@ module Liquid module StrainerFactory extend self - def add_global_filter(filter) - strainer_class_cache.clear - GlobalCache.add_filter(filter) + def add_global_filter(filter, world = World.default) + Deprecations.warn("StrainerFactory.add_global_filter", "World#register_filter") + world.register_filter(filter) end - def create(context, filters = []) - strainer_from_cache(filters).new(context) + def create(context, filters = Const::EMPTY_ARRAY, world = World.default) + Deprecations.warn("StrainerFactory.create", "StrainerFactory.create_strainer") + world.create_strainer(context, filters) end - def global_filter_names - GlobalCache.filter_method_names - end - - GlobalCache = Class.new(StrainerTemplate) - - private - - def strainer_from_cache(filters) - if filters.empty? - GlobalCache - else - strainer_class_cache[filters] ||= begin - klass = Class.new(GlobalCache) - filters.each { |f| klass.add_filter(f) } - klass - end - end - end - - def strainer_class_cache - @strainer_class_cache ||= {} + def global_filter_names(world = World.default) + Deprecations.warn("StrainerFactory.global_filter_names", "World#filter_method_names") + World.strainer_template.filter_method_names end end end diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 8303a7233..9ca97d0f3 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'liquid/tag/disabler' +require 'liquid/tag/disableable' + module Liquid class Tag attr_reader :nodelist, :tag_name, :line_number, :parse_context diff --git a/lib/liquid/tags.rb b/lib/liquid/tags.rb new file mode 100644 index 000000000..916a63bd5 --- /dev/null +++ b/lib/liquid/tags.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "tags/table_row" +require_relative "tags/echo" +require_relative "tags/if" +require_relative "tags/break" +require_relative "tags/inline_comment" +require_relative "tags/for" +require_relative "tags/assign" +require_relative "tags/ifchanged" +require_relative "tags/case" +require_relative "tags/include" +require_relative "tags/continue" +require_relative "tags/capture" +require_relative "tags/decrement" +require_relative "tags/unless" +require_relative "tags/increment" +require_relative "tags/comment" +require_relative "tags/raw" +require_relative "tags/render" +require_relative "tags/cycle" + +module Liquid + module Tags + STANDARD_TAGS = { + 'cycle' => Cycle, + 'render' => Render, + 'raw' => Raw, + 'comment' => Comment, + 'increment' => Increment, + 'unless' => Unless, + 'decrement' => Decrement, + 'capture' => Capture, + 'continue' => Continue, + 'include' => Include, + 'case' => Case, + 'ifchanged' => Ifchanged, + 'assign' => Assign, + 'for' => For, + '#' => InlineComment, + 'break' => Break, + 'if' => If, + 'echo' => Echo, + 'tablerow' => TableRow, + }.freeze + end +end diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb index 9eff9796e..702831781 100644 --- a/lib/liquid/tags/assign.rb +++ b/lib/liquid/tags/assign.rb @@ -72,6 +72,4 @@ def children end end end - - Template.register_tag('assign', Assign) end diff --git a/lib/liquid/tags/break.rb b/lib/liquid/tags/break.rb index c2293161d..cec640074 100644 --- a/lib/liquid/tags/break.rb +++ b/lib/liquid/tags/break.rb @@ -26,6 +26,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('break', Break) end diff --git a/lib/liquid/tags/capture.rb b/lib/liquid/tags/capture.rb index 1b9f28193..59aa62e7f 100644 --- a/lib/liquid/tags/capture.rb +++ b/lib/liquid/tags/capture.rb @@ -39,6 +39,4 @@ def blank? true end end - - Template.register_tag('capture', Capture) end diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 4fe4cd98c..6b67601fe 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -123,6 +123,4 @@ def children end end end - - Template.register_tag('case', Case) end diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 798355d54..659108e37 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -85,6 +85,4 @@ def parse_raw_tag_body(tokenizer) raise_tag_never_closed("raw") end end - - Template.register_tag('comment', Comment) end diff --git a/lib/liquid/tags/continue.rb b/lib/liquid/tags/continue.rb index 875508384..48e235186 100644 --- a/lib/liquid/tags/continue.rb +++ b/lib/liquid/tags/continue.rb @@ -17,6 +17,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('continue', Continue) end diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 2514bf3ba..06d788de3 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -78,6 +78,4 @@ def children end end end - - Template.register_tag('cycle', Cycle) end diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb index 8a99eb94f..677166621 100644 --- a/lib/liquid/tags/decrement.rb +++ b/lib/liquid/tags/decrement.rb @@ -35,6 +35,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('decrement', Decrement) end diff --git a/lib/liquid/tags/echo.rb b/lib/liquid/tags/echo.rb index 8abb6c2b2..2c45c9cb6 100644 --- a/lib/liquid/tags/echo.rb +++ b/lib/liquid/tags/echo.rb @@ -36,6 +36,4 @@ def children end end end - - Template.register_tag('echo', Echo) end diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index 9205fa42f..0dda3081d 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -201,6 +201,4 @@ def children end end end - - Template.register_tag('for', For) end diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 92ed8aa3b..bcf250a11 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -135,6 +135,4 @@ def children end end end - - Template.register_tag('if', If) end diff --git a/lib/liquid/tags/ifchanged.rb b/lib/liquid/tags/ifchanged.rb index dd3be532a..50397794d 100644 --- a/lib/liquid/tags/ifchanged.rb +++ b/lib/liquid/tags/ifchanged.rb @@ -14,6 +14,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('ifchanged', Ifchanged) end diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 7b9685eeb..b4f1be137 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -110,6 +110,4 @@ def children end end end - - Template.register_tag('include', Include) end diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index 5f7e4f6fa..c650e5ac5 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -35,6 +35,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('increment', Increment) end diff --git a/lib/liquid/tags/inline_comment.rb b/lib/liquid/tags/inline_comment.rb index 493cfddc7..3aa7c50f9 100644 --- a/lib/liquid/tags/inline_comment.rb +++ b/lib/liquid/tags/inline_comment.rb @@ -25,6 +25,4 @@ def blank? true end end - - Template.register_tag('#', InlineComment) end diff --git a/lib/liquid/tags/raw.rb b/lib/liquid/tags/raw.rb index 02ee2b3cb..2721c4a2d 100644 --- a/lib/liquid/tags/raw.rb +++ b/lib/liquid/tags/raw.rb @@ -56,6 +56,4 @@ def ensure_valid_markup(tag_name, markup, parse_context) end end end - - Template.register_tag('raw', Raw) end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index b9ae58ea0..3615b1b35 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -108,6 +108,4 @@ def children end end end - - Template.register_tag('render', Render) end diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index be9a64c44..f7e8cd72d 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -91,6 +91,4 @@ def to_integer(value) raise Liquid::ArgumentError, "invalid integer" end end - - Template.register_tag('tablerow', TableRow) end diff --git a/lib/liquid/tags/unless.rb b/lib/liquid/tags/unless.rb index 2fc79db56..a6faa6f93 100644 --- a/lib/liquid/tags/unless.rb +++ b/lib/liquid/tags/unless.rb @@ -44,6 +44,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('unless', Unless) end diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index f6369d1a4..7d5a45499 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -18,89 +18,78 @@ class Template attr_accessor :root, :name attr_reader :resource_limits, :warnings - class TagRegistry - include Enumerable + attr_reader :profiler - def initialize - @tags = {} - @cache = {} + class << self + # Sets how strict the parser should be. + # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. + # :warn is the default and will give deprecation warnings when invalid syntax is used. + # :strict will enforce correct syntax. + def error_mode=(mode) + Deprecations.warn("Template.error_mode=", "World#error_mode=") + World.default.error_mode = mode end - def [](tag_name) - return nil unless @tags.key?(tag_name) - return @cache[tag_name] if Liquid.cache_classes - - lookup_class(@tags[tag_name]).tap { |o| @cache[tag_name] = o } + def error_mode + World.default.error_mode end - def []=(tag_name, klass) - @tags[tag_name] = klass.name - @cache[tag_name] = klass + def default_exception_renderer=(renderer) + Deprecations.warn("Template.default_exception_renderer=", "World#exception_renderer=") + World.default.exception_renderer = renderer end - def delete(tag_name) - @tags.delete(tag_name) - @cache.delete(tag_name) + def default_exception_renderer + World.default.exception_renderer end - def each(&block) - @tags.each(&block) + def file_system=(file_system) + Deprecations.warn("Template.file_system=", "World#file_system=") + World.default.file_system = file_system end - private - - def lookup_class(name) - Object.const_get(name) + def file_system + World.default.file_system end - end - - attr_reader :profiler - class << self - # Sets how strict the parser should be. - # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. - # :warn is the default and will give deprecation warnings when invalid syntax is used. - # :strict will enforce correct syntax. - attr_accessor :error_mode - Template.error_mode = :lax - - attr_accessor :default_exception_renderer - Template.default_exception_renderer = lambda do |exception| - exception + def tags + World.default.tags end - attr_accessor :file_system - Template.file_system = BlankFileSystem.new - - attr_accessor :tags - Template.tags = TagRegistry.new - private :tags= - def register_tag(name, klass) - tags[name.to_s] = klass + Deprecations.warn("Template.register_tag", "World#register_tag") + World.default.register_tag(name, klass) end # Pass a module with filter methods which should be available # to all liquid views. Good for registering the standard library def register_filter(mod) - StrainerFactory.add_global_filter(mod) + Deprecations.warn("Template.register_filter", "World#register_filter") + World.default.register_filter(mod) end - attr_accessor :default_resource_limits - Template.default_resource_limits = {} - private :default_resource_limits= + private def default_resource_limits=(limits) + Deprecations.warn("Template.default_resource_limits=", "World#default_resource_limits=") + World.default.default_resource_limits = limits + end + + def default_resource_limits + World.default.default_resource_limits + end # creates a new Template object from liquid source code # To enable profiling, pass in profile: true as an option. # See Liquid::Profiler for more information def parse(source, options = {}) - new.parse(source, options) + world = options[:world] || World.default + new(world: world).parse(source, options) end end - def initialize + def initialize(world: World.default) + @world = world @rethrow_errors = false - @resource_limits = ResourceLimits.new(Template.default_resource_limits) + @resource_limits = ResourceLimits.new(world.default_resource_limits) end # Parse source code. @@ -162,11 +151,11 @@ def render(*args) c when Liquid::Drop drop = args.shift - drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits) + drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @world) when Hash - Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits) + Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @world) when nil - Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits) + Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @world) else raise ArgumentError, "Expected Hash or Liquid::Context as parameter" end @@ -226,8 +215,14 @@ def configure_options(options) @options = options @profiling = profiling @line_numbers = options[:line_numbers] || @profiling - parse_context = options.is_a?(ParseContext) ? options : ParseContext.new(options) - @warnings = parse_context.warnings + parse_context = if options.is_a?(ParseContext) + options + else + opts = options.key?(:world) ? options : options.merge(world: @world) + ParseContext.new(opts) + end + + @warnings = parse_context.warnings parse_context end diff --git a/lib/liquid/template/tag_registry.rb b/lib/liquid/template/tag_registry.rb new file mode 100644 index 000000000..5f2ba3a68 --- /dev/null +++ b/lib/liquid/template/tag_registry.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Liquid + class Template + class TagRegistry + include Enumerable + + def initialize(tags = nil) + @tags = {} + @cache = {} + tags.each { |tag_name, klass| self[tag_name] = klass } + Deprecations.warn("Template::TagRegistry", "Use a World instance with zeitwerk") + end + + def [](tag_name) + return nil unless @tags.key?(tag_name) + return @cache[tag_name] if Liquid.cache_classes + + lookup_class(@tags[tag_name]).tap { |o| @cache[tag_name] = o } + end + + def delete(tag_name) + Deprecations.warn("Template::TagRegistry#delete", "Use a World instance with immutable tags") + @tags.delete(tag_name) + @cache.delete(tag_name) + end + + def []=(tag_name, klass) + @tags[tag_name] = klass.name + @cache[tag_name] = klass + end + + def each(&block) + @tags.each(&block) + end + + private + + def lookup_class(name) + Object.const_get(name) + end + end + end +end diff --git a/lib/liquid/world.rb b/lib/liquid/world.rb new file mode 100644 index 000000000..8f3699c7f --- /dev/null +++ b/lib/liquid/world.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Liquid + # The World is the container for all configuration options of Liquid, such as + # the registered tags, filters, and the default error mode. + class World + # The default error mode for all templates. This can be overridden on a + # per-template basis. + attr_accessor :error_mode + + # The tags that are available to use in the template. + attr_accessor :tags + + # The strainer template which is used to store filters that are available to + # use in templates. + attr_accessor :strainer_template + + # The exception renderer that is used to render exceptions that are raised + # when rendering a template + attr_accessor :exception_renderer + + # The default file system that is used to load templates from. + attr_accessor :file_system + + # The default resource limits that are used to limit the resources that a + # template can consume. + attr_accessor :default_resource_limits + + class << self + # Creates a new world instance. + # + # @param tags [Hash] The tags that are available to use in + # the template. + # @param file_system The default file system that is used + # to load templates from. + # @param error_mode [Symbol] The default error mode for all templates + # (either :strict, :warn, or :lax). + # @param exception_renderer [Proc] The exception renderer that is used to + # render exceptions. + # @yieldparam world [World] The world instance that is being built. + # @return [World] The new world instance. + def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil) + ret = new + ret.tags = Template::TagRegistry.new(tags) if tags + ret.file_system = file_system if file_system + ret.error_mode = error_mode if error_mode + ret.exception_renderer = exception_renderer if exception_renderer + yield ret if block_given? + ret.freeze + end + + # Returns the default world instance. + # + # @return [World] The default world instance. + def default + @default ||= new + end + + # Sets the default world instance for the duration of the block + # + # @param world [World] The world instance to use as the default for the + # duration of the block. + # @yield + # @return [Object] The return value of the block. + def dangerously_override(world) + original_default = @default + @default = world + yield + ensure + @default = original_default + end + end + + # Initializes a new world instance. + # @api private + def initialize + @tags = Template::TagRegistry.new(Tags::STANDARD_TAGS) + @error_mode = :lax + @strainer_template = Class.new(StrainerTemplate).tap do |klass| + klass.add_filter(StandardFilters) + end + @exception_renderer = ->(exception) { exception } + @file_system = BlankFileSystem.new + @default_resource_limits = Const::EMPTY_HASH + @strainer_template_class_cache = {} + end + + # Registers a new tag with the world. + # + # @param name [String] The name of the tag. + # @param klass [Liquid::Tag] The class that implements the tag. + # @return [void] + def register_tag(name, klass) + @tags[name] = klass + end + + # Registers a new filter with the world. + # + # @param filter [Module] The module that contains the filter methods. + # @return [void] + def register_filter(filter) + @strainer_template_class_cache.clear + @strainer_template.add_filter(filter) + end + + # Registers multiple filters with this world. + # + # @param filters [Array] The modules that contain the filter methods. + # @return [self] + def register_filters(filters) + @strainer_template_class_cache.clear + filters.each { |f| @strainer_template.add_filter(f) } + self + end + + # Creates a new strainer instance with the given filters, caching the result + # for faster lookup. + # + # @param context [Liquid::Context] The context that the strainer will be + # used in. + # @param filters [Array] The filters that the strainer will have + # access to. + # @return [Liquid::Strainer] The new strainer instance. + def create_strainer(context, filters = Const::EMPTY_ARRAY) + return @strainer_template.new(context) if filters.empty? + + strainer_template = @strainer_template_class_cache[filters] ||= begin + klass = Class.new(@strainer_template) + filters.each { |f| klass.add_filter(f) } + klass + end + + strainer_template.new(context) + end + + # Returns the names of all the filter methods that are available to use in + # the strainer template. + # + # @return [Array] The names of all the filter methods. + def filter_method_names + @strainer_template.filter_method_names + end + + # Returns the tag class for the given tag name. + # + # @param name [String] The name of the tag. + # @return [Liquid::Tag] The tag class. + def tag_for_name(name) + @tags[name] + end + + def freeze + @tags.freeze + # TODO: freeze the tags, currently this is not possible because of liquid-c + # @strainer_template.freeze + super + end + end +end diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb index 69ce8297e..9d4e4eee9 100644 --- a/test/integration/error_handling_test.rb +++ b/test/integration/error_handling_test.rb @@ -219,6 +219,21 @@ def test_setting_default_exception_renderer Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer end + def test_setting_exception_renderer_on_world + exceptions = [] + exception_renderer = ->(e) do + exceptions << e + '' + end + + world = Liquid::World.build(exception_renderer: exception_renderer) + template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', world: world) + output = template.render('errors' => ErrorDrop.new) + + assert_equal('This is a runtime error: ', output) + assert_equal([Liquid::ArgumentError], template.errors.map(&:class)) + end + def test_exception_renderer_exposing_non_liquid_error template = Liquid::Template.parse('This is a runtime error: {{ errors.runtime_error }}', line_numbers: true) exceptions = [] @@ -242,16 +257,10 @@ def read_template_file(_template_path) end def test_included_template_name_with_line_numbers - old_file_system = Liquid::Template.file_system - - begin - Liquid::Template.file_system = TestFileSystem.new + world = Liquid::World.build(file_system: TestFileSystem.new) + template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true, world: world) + page = template.render('errors' => ErrorDrop.new) - template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true) - page = template.render('errors' => ErrorDrop.new) - ensure - Liquid::Template.file_system = old_file_system - end assert_equal("Argument error:\nLiquid error (product line 1): argument error", page) assert_equal("product", template.errors.first.template_name) end diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index b86d40eee..45551419b 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -49,14 +49,6 @@ def render_to_output_buffer(_context, output) class IncludeTagTest < Minitest::Test include Liquid - def setup - @default_file_system = Liquid::Template.file_system - end - - def teardown - Liquid::Template.file_system = @default_file_system - end - def test_include_tag_looks_for_file_system_in_registers_first assert_equal( 'from OtherFileSystem', @@ -214,9 +206,10 @@ def test_dynamically_choosen_template def test_include_tag_caches_second_read_of_same_partial file_system = CountingFileSystem.new + world = Liquid::World.build(file_system: file_system) assert_equal( 'from CountingFileSystemfrom CountingFileSystem', - Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }), + Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}", world: world).render!({}, registers: { file_system: file_system }), ) assert_equal(1, file_system.count) end diff --git a/test/test_helper.rb b/test/test_helper.rb index 34d85aeb5..7a5368053 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -42,10 +42,11 @@ def assert_template_result( message: nil, partials: nil, error_mode: nil, render_errors: false, template_factory: nil ) - template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym) file_system = StubFileSystem.new(partials || {}) + world = Liquid::World.build(file_system: file_system) + template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym, world: world) registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory) - context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers) + context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers, world: world) output = template.render(context) assert_equal(expected, output, message) end @@ -78,22 +79,12 @@ def assert_usage_increment(name, times: 1) assert_equal(times, calls, "Number of calls to Usage.increment with #{name.inspect}") end - def with_global_filter(*globals) - original_global_cache = Liquid::StrainerFactory::GlobalCache - Liquid::StrainerFactory.send(:remove_const, :GlobalCache) - Liquid::StrainerFactory.const_set(:GlobalCache, Class.new(Liquid::StrainerTemplate)) - - globals.each do |global| - Liquid::Template.register_filter(global) - end - Liquid::StrainerFactory.send(:strainer_class_cache).clear - begin - yield - ensure - Liquid::StrainerFactory.send(:remove_const, :GlobalCache) - Liquid::StrainerFactory.const_set(:GlobalCache, original_global_cache) - Liquid::StrainerFactory.send(:strainer_class_cache).clear + def with_global_filter(*globals, &blk) + world = Liquid::World.build do |w| + w.register_filters(globals) end + + World.dangerously_override(world, &blk) end def with_error_mode(mode) @@ -104,18 +95,11 @@ def with_error_mode(mode) Liquid::Template.error_mode = old_mode end - def with_custom_tag(tag_name, tag_class) - old_tag = Liquid::Template.tags[tag_name] - begin - Liquid::Template.register_tag(tag_name, tag_class) - yield - ensure - if old_tag - Liquid::Template.tags[tag_name] = old_tag - else - Liquid::Template.tags.delete(tag_name) - end - end + def with_custom_tag(tag_name, tag_class, &block) + world = Liquid::World.default.dup + world.register_tag(tag_name, tag_class) + + World.dangerously_override(world, &block) end end end diff --git a/test/unit/strainer_template_unit_test.rb b/test/unit/strainer_template_unit_test.rb index fd6a5a3ca..18b9f499b 100644 --- a/test/unit/strainer_template_unit_test.rb +++ b/test/unit/strainer_template_unit_test.rb @@ -58,8 +58,9 @@ def public_filter def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method with_global_filter do - strainer = Context.new.strainer - strainer.class.add_filter(PublicMethodOverrideFilter) + context = Context.new + context.add_filters([PublicMethodOverrideFilter]) + strainer = context.strainer assert(strainer.class.send(:filter_methods).include?('public_filter')) end end diff --git a/test/unit/template_unit_test.rb b/test/unit/template_unit_test.rb index b75669470..f44cd1538 100644 --- a/test/unit/template_unit_test.rb +++ b/test/unit/template_unit_test.rb @@ -36,7 +36,6 @@ def test_with_cache_classes_tags_returns_the_same_class assert(Template.tags['custom'].equal?(original_klass)) ensure Object.send(:remove_const, :CustomTag) - Template.tags.delete('custom') Liquid.cache_classes = original_cache_setting end @@ -46,36 +45,26 @@ def test_without_cache_classes_tags_reloads_the_class original_klass = Class.new Object.send(:const_set, :CustomTag, original_klass) - Template.register_tag('custom', CustomTag) - - Object.send(:remove_const, :CustomTag) + with_custom_tag('custom', CustomTag) do + Object.send(:remove_const, :CustomTag) - new_klass = Class.new - Object.send(:const_set, :CustomTag, new_klass) + new_klass = Class.new + Object.send(:const_set, :CustomTag, new_klass) - assert(Template.tags['custom'].equal?(new_klass)) + assert(Template.tags['custom'].equal?(new_klass)) + end ensure Object.send(:remove_const, :CustomTag) - Template.tags.delete('custom') Liquid.cache_classes = original_cache_setting end class FakeTag; end - def test_tags_delete - Template.register_tag('fake', FakeTag) - assert_equal(FakeTag, Template.tags['fake']) - - Template.tags.delete('fake') - assert_nil(Template.tags['fake']) - end - def test_tags_can_be_looped_over - Template.register_tag('fake', FakeTag) - result = Template.tags.map { |name, klass| [name, klass] } - assert(result.include?(["fake", "TemplateUnitTest::FakeTag"])) - ensure - Template.tags.delete('fake') + with_custom_tag('fake', FakeTag) do + result = Template.tags.map { |name, klass| [name, klass] } + assert(result.include?(["fake", "TemplateUnitTest::FakeTag"])) + end end class TemplateSubclass < Liquid::Template