From 9416d7a9ef76964357ea999aab62998cf111a537 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Wed, 31 Dec 2025 13:26:24 -0500 Subject: [PATCH] Refactor CompiledTemplate to handle include/render internally - render() now accepts Liquid::Context or Hash - include/render handled internally using file_system from registers - Only yields to block for truly external tags/filters - Cleaner separation of concerns --- lib/liquid/compile/compiled_template.rb | 318 +++++++++++++++--------- 1 file changed, 207 insertions(+), 111 deletions(-) diff --git a/lib/liquid/compile/compiled_template.rb b/lib/liquid/compile/compiled_template.rb index d646e1c7..138a5c94 100644 --- a/lib/liquid/compile/compiled_template.rb +++ b/lib/liquid/compile/compiled_template.rb @@ -13,31 +13,13 @@ module Liquid # template = Liquid::Template.parse("Hello, {{ name }}!") # compiled = template.compile_to_ruby # - # # Render the template - # result = compiled.render({ "name" => "World" }) + # # Render with a Liquid::Context (preferred) + # context = Liquid::Context.new({ "name" => "World" }) + # result = compiled.render(context) # # => "Hello, World!" # - # # Access the source code - # puts compiled.source - # - # # Check security status - # compiled.secure? # => true on Ruby 4.0+, false otherwise - # - # == External Calls (Tags and Filters) - # - # When the sandbox encounters an external tag or filter it can't handle, - # it yields back to the caller. You can provide a block to handle these: - # - # compiled.render(assigns) do |call_type, *args| - # case call_type - # when :tag - # tag_name, tag_obj, tag_context = args - # tag_obj.render(tag_context) - # when :filter - # filter_name, input, filter_args = args - # my_filter_handler.send(filter_name, input, *filter_args) - # end - # end + # # Or render with a simple hash + # result = compiled.render({ "name" => "World" }) # class CompiledTemplate attr_reader :source, :external_tags @@ -70,120 +52,214 @@ module Liquid Liquid::Box.secure? end - # Render the compiled template with the given assigns. + # Render the compiled template. # - # This is the primary way to execute a compiled template. On Ruby 4.0+, - # execution happens in a secure sandbox. On earlier versions, a warning - # is printed to STDERR. - # - # When the template needs to call an external tag or filter, it yields - # back to the caller with [:tag, ...] or [:filter, ...] args. If no block - # is given, a default handler is used. - # - # @param assigns [Hash] Variables to make available in the template - # @param registers [Hash] Registers for custom tags (accessible via context.registers) + # @param context_or_assigns [Liquid::Context, Hash] A Liquid context or hash of assigns + # @param registers [Hash] Registers (only used when passing a Hash) # @param filter_handler [Object] Optional filter handler module - # @param strict_variables [Boolean] Raise on undefined variables - # @param strict_filters [Boolean] Raise on undefined filters - # @yield [call_type, *args] Called for external tags/filters + # @yield [call_type, *args] Called for external tags/filters only # @return [String] The rendered output # - # @example Basic usage - # compiled.render({ "name" => "World" }) - # - # @example With block for external calls - # compiled.render(assigns) do |type, *args| - # case type - # when :tag then handle_tag(*args) - # when :filter then handle_filter(*args) - # end - # end - # - def render(assigns = {}, registers: {}, filter_handler: nil, strict_variables: false, strict_filters: false, &block) + def render(context_or_assigns = {}, registers: {}, filter_handler: nil, &block) compiled_proc = to_proc handler = filter_handler || @filter_handler - # Create a context for Drop support - context = CompiledContext.new( - assigns, - registers: registers, - strict_variables: strict_variables, - strict_filters: strict_filters - ) + # Accept either a Liquid::Context or a Hash of assigns + if context_or_assigns.is_a?(Liquid::Context) + liquid_context = context_or_assigns + assigns = extract_assigns(liquid_context) + file_system = liquid_context.registers[:file_system] - # Create the external call handler - external_handler = block || default_external_handler(handler) + # Build external handler that handles include/render internally + external_handler = build_external_handler(liquid_context, file_system, handler, &block) + + # Use a wrapper context that delegates to the Liquid::Context + context = ContextWrapper.new(liquid_context) + else + assigns = context_or_assigns + file_system = registers[:file_system] + + # Create a minimal context for Drop support + context = CompiledContext.new(assigns, registers: registers) + + # Build external handler + external_handler = build_external_handler(nil, file_system, handler, &block) + end - # Build arguments: assigns, context, external_handler compiled_proc.call(assigns, context, external_handler) end - # Alias for backwards compatibility alias call render - # Returns the generated Ruby source code def code @source end - # Returns the Ruby code as a string def to_s @source end - # Returns the compiled proc. - # - # On Ruby 4.0+, this compiles the code in a secure sandbox. - # On earlier versions, this uses standard eval with a security warning. - # - # The proc is cached after first compilation. def to_proc @proc ||= compile_to_proc end private - # Default handler for external calls when no block is provided - def default_external_handler(filter_handler) + def extract_assigns(liquid_context) + # Get the first environment (static_environments) + liquid_context.environments.first || {} + end + + # Build the external call handler + # Handles :include and :render internally using file_system + # Yields to block for :tag and :filter if block given + def build_external_handler(liquid_context, file_system, filter_handler, &block) external_tags = @external_tags ->(call_type, *args) do case call_type + when :include + handle_include(liquid_context, file_system, *args) + + when :render + handle_render(liquid_context, file_system, *args) + when :tag - tag_var, tag_assigns = args - tag = external_tags[tag_var] - return '' unless tag - - # Create a context and render the tag - ctx = Liquid::Context.new( - [tag_assigns], {}, {}, - false, nil, {}, - Liquid::Environment.default - ) - output = +'' - tag.render_to_output_buffer(ctx, output) - output - - when :filter - filter_name, input, filter_args = args - if filter_handler&.respond_to?(filter_name) - m = filter_handler.method(filter_name) - m.call(input, *filter_args) + if block + block.call(call_type, *args) else - input # Return unchanged if filter not found + handle_tag(liquid_context, external_tags, *args) end - when :include, :render - # Dynamic include/render - not supported without a custom handler - template_name, _var, _attrs, _alias_name, *_rest = args - raise Liquid::FileSystemError, "Could not find asset #{template_name}" + when :filter + if block + block.call(call_type, *args) + else + handle_filter(liquid_context, filter_handler, *args) + end else - raise ArgumentError, "Unknown external call type: #{call_type}" + if block + block.call(call_type, *args) + else + raise ArgumentError, "Unknown external call type: #{call_type}" + end end end end + def handle_include(liquid_context, file_system, template_name, variable, attrs, alias_name, assigns, context) + raise Liquid::FileSystemError, "Could not find asset #{template_name}" unless file_system + + snippet_source = file_system.read_template_file(template_name) + snippet = Liquid::Template.parse(snippet_source, line_numbers: true) + snippet.name = template_name + + # Include shares scope with parent + if liquid_context + # Set attributes in context + attrs&.each { |k, v| liquid_context[k] = v } + + context_var_name = alias_name || template_name.to_s.split('/').last + if variable + if variable.is_a?(Array) + return variable.map do |item| + liquid_context[context_var_name] = item + snippet.render(liquid_context) + end.join + else + liquid_context[context_var_name] = variable + end + end + + snippet.render(liquid_context) + else + # No liquid context - just use assigns + render_assigns = assigns.merge(attrs || {}) + snippet.render(render_assigns) + end + end + + def handle_render(liquid_context, file_system, template_name, variable, attrs, alias_name, is_for_loop, context) + raise Liquid::FileSystemError, "Could not find asset #{template_name}" unless file_system + + snippet_source = file_system.read_template_file(template_name) + snippet = Liquid::Template.parse(snippet_source, line_numbers: true) + snippet.name = template_name + + # Render creates isolated scope - only attrs are passed + render_assigns = attrs&.dup || {} + context_var_name = alias_name || template_name.to_s.split('/').last.sub(/\.liquid$/, '') + + if variable + if is_for_loop && variable.is_a?(Array) + return variable.map do |item| + render_assigns[context_var_name] = item + if liquid_context + isolated_ctx = Liquid::Context.build( + static_environments: render_assigns, + registers: liquid_context.registers, + rethrow_errors: false, + ) + isolated_ctx.exception_renderer = liquid_context.exception_renderer + snippet.render(isolated_ctx) + else + snippet.render(render_assigns) + end + end.join + else + render_assigns[context_var_name] = variable + end + end + + if liquid_context + isolated_ctx = Liquid::Context.build( + static_environments: render_assigns, + registers: liquid_context.registers, + rethrow_errors: false, + ) + isolated_ctx.exception_renderer = liquid_context.exception_renderer + snippet.render(isolated_ctx) + else + snippet.render(render_assigns) + end + end + + def handle_tag(liquid_context, external_tags, tag_var, tag_assigns) + tag = external_tags[tag_var] + return '' unless tag + + if liquid_context + output = +'' + tag.render_to_output_buffer(liquid_context, output) + output + else + # Create a minimal context + ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default) + output = +'' + tag.render_to_output_buffer(ctx, output) + output + end + end + + def handle_filter(liquid_context, filter_handler, filter_name, input, *filter_args) + # Try filter handler first + if filter_handler&.respond_to?(filter_name) + return filter_handler.public_send(filter_name, input, *filter_args) + end + + # Try liquid context's strainer + if liquid_context + strainer = liquid_context.strainer + if strainer.class.invokable?(filter_name) + return strainer.invoke(filter_name, input, *filter_args) + end + end + + # Return input unchanged if filter not found + input + end + def compile_to_proc if Liquid::Box.secure? compile_in_sandbox @@ -192,7 +268,6 @@ module Liquid end end - # Compile in a secure Ruby::Box sandbox (Ruby 4.0+) def compile_in_sandbox @box ||= begin box = Liquid::Box.new @@ -201,7 +276,6 @@ module Liquid box end - # Wrap the lambda source in a class for the sandbox template_class_name = "CompiledTemplate_#{object_id}" class_code = <<~RUBY class #{template_class_name} @@ -216,19 +290,14 @@ module Liquid @box.eval(class_code) template_class = @box[template_class_name] - # Return a proc that delegates to the sandboxed class ->(assigns, context, external_handler) do template_class.render(assigns, context, external_handler) end end - # Compile without sandbox (Ruby < 4.0) - shows warning def compile_insecure - unless Liquid::Box.secure? - warn_once_insecure - end + warn_once_insecure unless Liquid::Box.secure? - # Ensure LR runtime is loaded for polyfill mode require_relative 'runtime' unless defined?(::LR) # rubocop:disable Security/Eval @@ -238,16 +307,43 @@ module Liquid def warn_once_insecure return if @warned_insecure - @warned_insecure = true - $stderr.puts <<~WARNING - [Liquid::CompiledTemplate] WARNING: Executing compiled template WITHOUT sandbox. - Ruby::Box requires Ruby 4.0+. Template execution is NOT SECURE on this Ruby version. - WARNING + @warned_insecure = true + warn "[SECURITY WARNING] Liquid compiled template running outside of Ruby::Box sandbox. " \ + "On Ruby 4.0+, this runs in a secure sandbox. On earlier versions, be cautious " \ + "about running untrusted templates." + end + + # Wrapper around Liquid::Context for compiled template compatibility + class ContextWrapper + def initialize(liquid_context) + @liquid_context = liquid_context + end + + def [](key) + @liquid_context[key] + end + + def []=(key, value) + @liquid_context[key] = value + end + + def key?(key) + @liquid_context.key?(key) + end + + def registers + @liquid_context.registers + end + + def strainer + @liquid_context.strainer + end + + def handle_error(e, line_number = nil) + @liquid_context.handle_error(e, line_number) + end end end end - - # Make CompiledTemplate available at the top level for convenience - CompiledTemplate = Compile::CompiledTemplate end