Skip to content

Commit

Permalink
Add a new {% render %} tag
Browse files Browse the repository at this point in the history
Example:

```
// the_count.liquid
{{ number }}! Ah ah ah.

// my_template.liquid
{% for number in range (1..3) %}
  {% render "the_count", number: number %}
{% endfor %}

Output:
1! Ah ah ah.
2! Ah ah ah.
3! Ah ah ah.
```

The `render` tag is a more strict version of the `include` tag. It is
designed to isolate itself from the parent rendering context both by
creating a new scope (which does not inherit the parent scope) and by
only inheriting "static" registers.

Static registers are those that do not hold mutable state which could
affect rendering. This again helps `render`ed templates remain entirely
separate from their calling context.

Unlike `include`, `render` does not permit specifying the target
template using a variable, only a string literal. For example, this
means that `{% render my_dynamic_template %}` is invalid syntax. This
will make it possible to statically analyze the dependencies between
templates without making Turing angry.

Note that the `static_environment` of a rendered template is inherited, unlike
the scope and regular environment. This environment is immutable from within the
template.

An alternate syntax, which mimics the `{% include ... for %}` tag is
currently in design discussion.
  • Loading branch information
samdoiron committed Aug 29, 2019
1 parent d338ccb commit 9672ed5
Show file tree
Hide file tree
Showing 12 changed files with 287 additions and 515 deletions.
1 change: 1 addition & 0 deletions lib/liquid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ module Liquid
require 'liquid/utils'
require 'liquid/tokenizer'
require 'liquid/parse_context'
require 'liquid/partial_cache'

# Load all the tags of the standard library
#
Expand Down
90 changes: 59 additions & 31 deletions lib/liquid/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,25 @@ module Liquid
#
# context['bob'] #=> nil class Context
class Context
attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers
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

def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_registers: {})
new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_registers)
# rubocop:disable Metrics/ParameterLists
def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_registers: {}, static_environments: {})
new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_registers, static_environments)
end

def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_registers = {})
@environments = [environments].flatten
@scopes = [(outer_scope || {})]
@registers = registers
@static_registers = static_registers.tap(&:freeze)
@errors = []
@partial = false
@strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_registers = {}, static_environments = {})
@environments = [environments].flatten
@static_environments = [static_environments].flatten.map(&:freeze).freeze
@scopes = [(outer_scope || {})]
@registers = registers
@static_registers = static_registers.freeze
@errors = []
@partial = false
@strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
@base_scope_depth = 0
squash_instance_assigns_with_environments

@this_stack_used = false
Expand All @@ -41,6 +44,7 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro
@filters = []
@global_filter = nil
end
# rubocop:enable Metrics/ParameterLists

def warnings
@warnings ||= []
Expand Down Expand Up @@ -94,7 +98,7 @@ def invoke(method, *args)
# Push new local scope on the stack. use <tt>Context#stack</tt> instead
def push(new_scope = {})
@scopes.unshift(new_scope)
raise StackLevelError, "Nesting too deep".freeze if @scopes.length > Block::MAX_DEPTH
check_overflow
end

# Merge a hash of variables in the current local scope
Expand Down Expand Up @@ -134,13 +138,19 @@ def stack(new_scope = nil)
# Creates a new context inheriting resource limits, filters, environment etc.,
# but with an isolated scope.
def new_isolated_subcontext
check_overflow

Context.build(
environments: environments,
resource_limits: resource_limits,
static_environments: static_environments,
static_registers: static_registers
).tap do |subcontext|
subcontext.base_scope_depth = base_scope_depth + 1
subcontext.exception_renderer = exception_renderer
subcontext.add_filters(@filters)
subcontext.filters = @filters
subcontext.strainer = nil
subcontext.errors = errors
subcontext.warnings = warnings
end
end

Expand Down Expand Up @@ -182,25 +192,13 @@ def find_variable(key, raise_on_not_found: true)
# This was changed from find() to find_index() because this is a very hot
# path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) }
scope = @scopes[index] if index

variable = nil

if scope.nil?
@environments.each do |e|
variable = lookup_and_evaluate(e, key, raise_on_not_found: raise_on_not_found)
# When lookup returned a value OR there is no value but the lookup also did not raise
# then it is the value we are looking for.
if !variable.nil? || @strict_variables && raise_on_not_found
scope = e
break
end
end
variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
else
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
end

scope ||= @environments.last || @scopes.last
variable ||= lookup_and_evaluate(scope, key, raise_on_not_found: raise_on_not_found)

variable = variable.to_liquid
variable.context = self if variable.respond_to?(:context=)

Expand All @@ -221,8 +219,38 @@ def lookup_and_evaluate(obj, key, raise_on_not_found: true)
end
end

protected

attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters

private

attr_reader :base_scope_depth

def try_variable_find_in_environments(key, raise_on_not_found:)
@environments.each do |environment|
found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found)
if !found_variable.nil? || @strict_variables && raise_on_not_found
return found_variable
end
end
@static_environments.each do |environment|
found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found)
if !found_variable.nil? || @strict_variables && raise_on_not_found
return found_variable
end
end
nil
end

def check_overflow
raise StackLevelError, "Nesting too deep".freeze if overflow?
end

def overflow?
base_scope_depth + @scopes.length > Block::MAX_DEPTH
end

def internal_error
# raise and catch to set backtrace and cause on exception
raise Liquid::InternalError, 'internal'
Expand Down
1 change: 1 addition & 0 deletions lib/liquid/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@
tag_never_closed: "'%{block_name}' tag was never closed"
meta_syntax_error: "Liquid syntax error: #{e.message}"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
render: "Syntax error in tag 'render' - Template name must be a quoted string"
argument:
include: "Argument error in tag 'include' - Illegal template name"
18 changes: 18 additions & 0 deletions lib/liquid/partial_cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module Liquid
class PartialCache
def self.load(template_name, context:, parse_context:)
cached_partials = (context.registers[:cached_partials] ||= {})
cached = cached_partials[template_name]
return cached if cached

file_system = (context.registers[:file_system] ||= Liquid::Template.file_system)
source = file_system.read_template_file(template_name)
parse_context.partial = true

partial = Liquid::Template.parse(source, parse_context)
cached_partials[template_name] = partial
ensure
parse_context.partial = false
end
end
end
33 changes: 6 additions & 27 deletions lib/liquid/tags/include.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ def render_to_output_buffer(context, output)
template_name = context.evaluate(@template_name_expr)
raise ArgumentError.new(options[:locale].t("errors.argument.include")) unless template_name

partial = load_cached_partial(template_name, context)
partial = PartialCache.load(
template_name,
context: context,
parse_context: parse_context
)

context_variable_name = template_name.split('/'.freeze).last

variable = if @variable_name_expr
Expand Down Expand Up @@ -83,35 +88,9 @@ def render_to_output_buffer(context, output)
output
end

private

alias_method :parse_context, :options
private :parse_context

def load_cached_partial(template_name, context)
cached_partials = context.registers[:cached_partials] || {}

if cached = cached_partials[template_name]
return cached
end
source = read_template_from_file_system(context)
begin
parse_context.partial = true
partial = Liquid::Template.parse(source, parse_context)
ensure
parse_context.partial = false
end
cached_partials[template_name] = partial
context.registers[:cached_partials] = cached_partials
partial
end

def read_template_from_file_system(context)
file_system = context.registers[:file_system] || Liquid::Template.file_system

file_system.read_template_file(context.evaluate(@template_name_expr))
end

class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
Expand Down
1 change: 1 addition & 0 deletions lib/liquid/tags/increment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def initialize(tag_name, markup, options)
def render_to_output_buffer(context, output)
value = context.environments.first[@variable] ||= 0
context.environments.first[@variable] = value + 1

output << value.to_s
output
end
Expand Down
63 changes: 14 additions & 49 deletions lib/liquid/tags/render.rb
Original file line number Diff line number Diff line change
@@ -1,81 +1,46 @@
module Liquid
#
# TODO: docs
#
class Render < Tag
Syntax = /(#{QuotedFragment}+)/o
Syntax = /(#{QuotedString})#{QuotedFragment}*/o

attr_reader :template_name_expr, :attributes

def initialize(tag_name, markup, options)
super

if markup =~ Syntax
template_name = $1
raise SyntaxError.new(options[:locale].t("errors.syntax.render".freeze)) unless markup =~ Syntax

@template_name_expr = Expression.parse(template_name)
template_name = $1

@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = Expression.parse(value)
end
@template_name_expr = Expression.parse(template_name)

else
raise SyntaxError.new(options[:locale].t("errors.syntax.include".freeze))
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = Expression.parse(value)
end
end

def parse(_tokens)
end

def render_to_output_buffer(context, output)
# Though we evaluate this here we will only ever parse it as a string literal.
template_name = context.evaluate(@template_name_expr)
raise ArgumentError.new(options[:locale].t("errors.argument.include")) unless template_name

partial = load_cached_partial(template_name, context)
partial = PartialCache.load(
template_name,
context: context,
parse_context: parse_context
)

inner_context = Context.new
inner_context = context.new_isolated_subcontext
inner_context.template_name = template_name
inner_context.partial = true
@attributes.each do |key, value|
inner_context[key] = context.evaluate(value)
end
partial.render_to_output_buffer(inner_context, output)

# TODO: Put into a new #isolated_stack method in Context?
inner_context.errors.each { |e| context.errors << e }

output
end

private

alias_method :parse_context, :options
private :parse_context

def load_cached_partial(template_name, context)
cached_partials = context.registers[:cached_partials] || {}

if cached = cached_partials[template_name]
return cached
end
source = read_template_from_file_system(context)
begin
parse_context.partial = true
partial = Liquid::Template.parse(source, parse_context)
ensure
parse_context.partial = false
end
cached_partials[template_name] = partial
context.registers[:cached_partials] = cached_partials
partial
end

def read_template_from_file_system(context)
file_system = context.registers[:file_system] || Liquid::Template.file_system
file_system.read_template_file(context.evaluate(@template_name_expr))
end

class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
Expand Down
Loading

0 comments on commit 9672ed5

Please sign in to comment.