mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-29 15:15:19 -07:00
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
This commit is contained in:
@@ -13,31 +13,13 @@ module Liquid
|
|||||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||||
# compiled = template.compile_to_ruby
|
# compiled = template.compile_to_ruby
|
||||||
#
|
#
|
||||||
# # Render the template
|
# # Render with a Liquid::Context (preferred)
|
||||||
# result = compiled.render({ "name" => "World" })
|
# context = Liquid::Context.new({ "name" => "World" })
|
||||||
|
# result = compiled.render(context)
|
||||||
# # => "Hello, World!"
|
# # => "Hello, World!"
|
||||||
#
|
#
|
||||||
# # Access the source code
|
# # Or render with a simple hash
|
||||||
# puts compiled.source
|
# result = compiled.render({ "name" => "World" })
|
||||||
#
|
|
||||||
# # 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
|
|
||||||
#
|
#
|
||||||
class CompiledTemplate
|
class CompiledTemplate
|
||||||
attr_reader :source, :external_tags
|
attr_reader :source, :external_tags
|
||||||
@@ -70,120 +52,214 @@ module Liquid
|
|||||||
Liquid::Box.secure?
|
Liquid::Box.secure?
|
||||||
end
|
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+,
|
# @param context_or_assigns [Liquid::Context, Hash] A Liquid context or hash of assigns
|
||||||
# execution happens in a secure sandbox. On earlier versions, a warning
|
# @param registers [Hash] Registers (only used when passing a Hash)
|
||||||
# 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 filter_handler [Object] Optional filter handler module
|
# @param filter_handler [Object] Optional filter handler module
|
||||||
# @param strict_variables [Boolean] Raise on undefined variables
|
# @yield [call_type, *args] Called for external tags/filters only
|
||||||
# @param strict_filters [Boolean] Raise on undefined filters
|
|
||||||
# @yield [call_type, *args] Called for external tags/filters
|
|
||||||
# @return [String] The rendered output
|
# @return [String] The rendered output
|
||||||
#
|
#
|
||||||
# @example Basic usage
|
def render(context_or_assigns = {}, registers: {}, filter_handler: nil, &block)
|
||||||
# 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)
|
|
||||||
compiled_proc = to_proc
|
compiled_proc = to_proc
|
||||||
handler = filter_handler || @filter_handler
|
handler = filter_handler || @filter_handler
|
||||||
|
|
||||||
# Create a context for Drop support
|
# Accept either a Liquid::Context or a Hash of assigns
|
||||||
context = CompiledContext.new(
|
if context_or_assigns.is_a?(Liquid::Context)
|
||||||
assigns,
|
liquid_context = context_or_assigns
|
||||||
registers: registers,
|
assigns = extract_assigns(liquid_context)
|
||||||
strict_variables: strict_variables,
|
file_system = liquid_context.registers[:file_system]
|
||||||
strict_filters: strict_filters
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the external call handler
|
# Build external handler that handles include/render internally
|
||||||
external_handler = block || default_external_handler(handler)
|
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)
|
compiled_proc.call(assigns, context, external_handler)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Alias for backwards compatibility
|
|
||||||
alias call render
|
alias call render
|
||||||
|
|
||||||
# Returns the generated Ruby source code
|
|
||||||
def code
|
def code
|
||||||
@source
|
@source
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns the Ruby code as a string
|
|
||||||
def to_s
|
def to_s
|
||||||
@source
|
@source
|
||||||
end
|
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
|
def to_proc
|
||||||
@proc ||= compile_to_proc
|
@proc ||= compile_to_proc
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Default handler for external calls when no block is provided
|
def extract_assigns(liquid_context)
|
||||||
def default_external_handler(filter_handler)
|
# 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
|
external_tags = @external_tags
|
||||||
|
|
||||||
->(call_type, *args) do
|
->(call_type, *args) do
|
||||||
case call_type
|
case call_type
|
||||||
|
when :include
|
||||||
|
handle_include(liquid_context, file_system, *args)
|
||||||
|
|
||||||
|
when :render
|
||||||
|
handle_render(liquid_context, file_system, *args)
|
||||||
|
|
||||||
when :tag
|
when :tag
|
||||||
tag_var, tag_assigns = args
|
if block
|
||||||
tag = external_tags[tag_var]
|
block.call(call_type, *args)
|
||||||
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)
|
|
||||||
else
|
else
|
||||||
input # Return unchanged if filter not found
|
handle_tag(liquid_context, external_tags, *args)
|
||||||
end
|
end
|
||||||
|
|
||||||
when :include, :render
|
when :filter
|
||||||
# Dynamic include/render - not supported without a custom handler
|
if block
|
||||||
template_name, _var, _attrs, _alias_name, *_rest = args
|
block.call(call_type, *args)
|
||||||
raise Liquid::FileSystemError, "Could not find asset #{template_name}"
|
else
|
||||||
|
handle_filter(liquid_context, filter_handler, *args)
|
||||||
|
end
|
||||||
|
|
||||||
else
|
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
|
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
|
def compile_to_proc
|
||||||
if Liquid::Box.secure?
|
if Liquid::Box.secure?
|
||||||
compile_in_sandbox
|
compile_in_sandbox
|
||||||
@@ -192,7 +268,6 @@ module Liquid
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compile in a secure Ruby::Box sandbox (Ruby 4.0+)
|
|
||||||
def compile_in_sandbox
|
def compile_in_sandbox
|
||||||
@box ||= begin
|
@box ||= begin
|
||||||
box = Liquid::Box.new
|
box = Liquid::Box.new
|
||||||
@@ -201,7 +276,6 @@ module Liquid
|
|||||||
box
|
box
|
||||||
end
|
end
|
||||||
|
|
||||||
# Wrap the lambda source in a class for the sandbox
|
|
||||||
template_class_name = "CompiledTemplate_#{object_id}"
|
template_class_name = "CompiledTemplate_#{object_id}"
|
||||||
class_code = <<~RUBY
|
class_code = <<~RUBY
|
||||||
class #{template_class_name}
|
class #{template_class_name}
|
||||||
@@ -216,19 +290,14 @@ module Liquid
|
|||||||
@box.eval(class_code)
|
@box.eval(class_code)
|
||||||
template_class = @box[template_class_name]
|
template_class = @box[template_class_name]
|
||||||
|
|
||||||
# Return a proc that delegates to the sandboxed class
|
|
||||||
->(assigns, context, external_handler) do
|
->(assigns, context, external_handler) do
|
||||||
template_class.render(assigns, context, external_handler)
|
template_class.render(assigns, context, external_handler)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compile without sandbox (Ruby < 4.0) - shows warning
|
|
||||||
def compile_insecure
|
def compile_insecure
|
||||||
unless Liquid::Box.secure?
|
warn_once_insecure unless Liquid::Box.secure?
|
||||||
warn_once_insecure
|
|
||||||
end
|
|
||||||
|
|
||||||
# Ensure LR runtime is loaded for polyfill mode
|
|
||||||
require_relative 'runtime' unless defined?(::LR)
|
require_relative 'runtime' unless defined?(::LR)
|
||||||
|
|
||||||
# rubocop:disable Security/Eval
|
# rubocop:disable Security/Eval
|
||||||
@@ -238,16 +307,43 @@ module Liquid
|
|||||||
|
|
||||||
def warn_once_insecure
|
def warn_once_insecure
|
||||||
return if @warned_insecure
|
return if @warned_insecure
|
||||||
@warned_insecure = true
|
|
||||||
|
|
||||||
$stderr.puts <<~WARNING
|
@warned_insecure = true
|
||||||
[Liquid::CompiledTemplate] WARNING: Executing compiled template WITHOUT sandbox.
|
warn "[SECURITY WARNING] Liquid compiled template running outside of Ruby::Box sandbox. " \
|
||||||
Ruby::Box requires Ruby 4.0+. Template execution is NOT SECURE on this Ruby version.
|
"On Ruby 4.0+, this runs in a secure sandbox. On earlier versions, be cautious " \
|
||||||
WARNING
|
"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
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Make CompiledTemplate available at the top level for convenience
|
|
||||||
CompiledTemplate = Compile::CompiledTemplate
|
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user