Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions History.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Liquid Change Log

## 5.12.0
* Introduce HybridTag base class for tags that can be self-closing or block-form, with end-tag-triggered reparenting in BlockBody [CP Clermont]

## 5.11.0
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
Expand Down
1 change: 1 addition & 0 deletions lib/liquid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ module Liquid
require 'liquid/parser_switching'
require 'liquid/tag'
require 'liquid/block'
require 'liquid/hybrid_tag'
require 'liquid/parse_tree_visitor'
require 'liquid/interrupts'
require 'liquid/tags'
Expand Down
69 changes: 61 additions & 8 deletions lib/liquid/block_body.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,17 @@ def freeze
next parse_liquid_tag(markup, parse_context)
end

unless (tag = parse_context.environment.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
tag = parse_context.environment.tag_for_name(tag_name)

if tag.nil? && try_reparent_hybrid_tag(tag_name, parse_context)
parse_context.line_number = tokenizer.line_number
next
end

# end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed
return yield tag_name, markup unless tag

new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
@blank &&= new_tag.blank?
@nodelist << new_tag
Expand Down Expand Up @@ -147,11 +153,17 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag)
next
end

unless (tag = parse_context.environment.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
tag = parse_context.environment.tag_for_name(tag_name)

if tag.nil? && try_reparent_hybrid_tag(tag_name, parse_context)
parse_context.line_number = tokenizer.line_number
next
end

# end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed
return yield tag_name, markup unless tag

new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
@blank &&= new_tag.blank?
@nodelist << new_tag
Expand Down Expand Up @@ -269,5 +281,46 @@ 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

private def try_reparent_hybrid_tag(end_tag_name, parse_context)
return false unless end_tag_name.start_with?("end")

hybrid_tag_name = end_tag_name.delete_prefix("end")
tag_class = parse_context.environment.tag_for_name(hybrid_tag_name)
return false unless tag_class && tag_class < HybridTag

hybrid_index = nil
i = @nodelist.length - 1
while i >= 0
node = @nodelist[i]
if node.is_a?(HybridTag) && node.tag_name == hybrid_tag_name
if node.block_form?
raise SyntaxError, parse_context.locale.t(
"errors.syntax.hybrid_tag_nested",
tag: hybrid_tag_name,
)
end

hybrid_index = i
break
end
i -= 1
end

unless hybrid_index
raise SyntaxError, parse_context.locale.t(
"errors.syntax.hybrid_tag_no_match",
end_tag: end_tag_name,
tag: hybrid_tag_name,
)
end

children = @nodelist.slice!((hybrid_index + 1)..)
hybrid_tag = @nodelist[hybrid_index]

hybrid_tag.reparent_as_block(children, parse_context)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to update @blank here after reparenting so that whitespace control works correctly for the parent body?

{%- if something -%}
  {% hybrid %}content{% endhybrid %}
{% endif %}

Without updating it, whitespace control might not work properly.


true
end
end
end
4 changes: 4 additions & 0 deletions lib/liquid/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def unknown_tag(tag, _markup, _tokenizer)
end
end

def blank?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

@body.blank?
end

def render_to_output_buffer(context, output)
@body.render_to_output_buffer(context, output)
end
Expand Down
44 changes: 44 additions & 0 deletions lib/liquid/hybrid_tag.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module Liquid
class HybridTag < Block
def reparent_as_block(children, parse_context)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_context is never used here

@body = new_body
@body.nodelist.concat(children)
@body.freeze
end

def parse(_tokens)
end

def block_form?
!!@body
end

def nodelist
@body ? @body.nodelist : Const::EMPTY_ARRAY
end

def blank?
raise NotImplementedError, "#{self.class} must implement blank?"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the description you mention

@blank recomputation — After reparenting, the parent BlockBody recomputes its @blank flag based on remaining nodes.

Should this be done here instead of letting subclasses manage blank?

end

def render_to_output_buffer(context, output)
if block_form?
render_block_form_to_output_buffer(context, output)
else
render_self_closing_to_output_buffer(context, output)
end
end

private

def render_block_form_to_output_buffer(_context, _output)
raise NotImplementedError, "#{self.class} must implement render_block_form_to_output_buffer"
end

def render_self_closing_to_output_buffer(_context, _output)
raise NotImplementedError, "#{self.class} must implement render_self_closing_to_output_buffer"
end
end
end
2 changes: 2 additions & 0 deletions lib/liquid/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
tag_never_closed: "'%{block_name}' tag was never closed"
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
unexpected_else: "%{block_name} tag does not expect 'else' tag"
hybrid_tag_nested: "'%{tag}' tag cannot be nested inside another '%{tag}' tag"
hybrid_tag_no_match: "Unexpected end tag '%{end_tag}' — no matching '%{tag}' tag found"
unexpected_outer_tag: "Unexpected outer '%{tag}' tag"
unknown_tag: "Unknown tag '%{tag}'"
variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}"
Expand Down
155 changes: 155 additions & 0 deletions test/unit/hybrid_tag_unit_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# frozen_string_literal: true

require 'test_helper'

class HybridTagUnitTest < Minitest::Test
include Liquid

class TestHybridTag < Liquid::HybridTag
def blank?
true
end

private

def render_self_closing_to_output_buffer(_context, output)
output << "self-closing"
end

def render_block_form_to_output_buffer(context, output)
output << "block["
@body.render_to_output_buffer(context, output)
output << "]"
end
end

def setup
@environment = Liquid::Environment.build do |env|
env.register_tag("hybrid", TestHybridTag)
end
end

def test_hybrid_tag_is_subclass_of_block
assert(TestHybridTag < Liquid::Block)
end

def test_self_closing_renders_correctly
template = Liquid::Template.parse("{% hybrid %}", environment: @environment)
assert_equal("self-closing", template.render)
end

def test_self_closing_block_form_predicate_is_false
tag = parse_hybrid_tag("{% hybrid %}")
refute(tag.block_form?)
end

def test_self_closing_does_not_consume_subsequent_tokens
template = Liquid::Template.parse("{% hybrid %}after", environment: @environment)
assert_equal("self-closingafter", template.render)
end

def test_block_form_renders_correctly
template = Liquid::Template.parse("{% hybrid %}body{% endhybrid %}", environment: @environment)
assert_equal("block[body]", template.render)
end

def test_block_form_predicate_is_true
tag = parse_hybrid_tag("{% hybrid %}body{% endhybrid %}")
assert(tag.block_form?)
end

def test_block_form_body_accessible_via_nodelist
tag = parse_hybrid_tag("{% hybrid %}hello world{% endhybrid %}")
assert(tag.block_form?)
refute_empty(tag.nodelist)
assert_equal("hello world", tag.nodelist.map(&:to_s).join)
end

def test_empty_block_form
template = Liquid::Template.parse("{% hybrid %}{% endhybrid %}", environment: @environment)
assert_equal("block[]", template.render)
end

def test_block_form_with_liquid_content
template = Liquid::Template.parse(
"{% hybrid %}before{{ var }}after{% endhybrid %}",
environment: @environment,
)
assert_equal("block[beforeVafter]", template.render({ "var" => "V" }))
end

def test_sequential_block_forms
template = Liquid::Template.parse(
"{% hybrid %}a{% endhybrid %}{% hybrid %}b{% endhybrid %}",
environment: @environment,
)
assert_equal("block[a]block[b]", template.render)
end

def test_self_closing_followed_by_block_form
template = Liquid::Template.parse(
"{% hybrid %}{% hybrid %}body{% endhybrid %}",
environment: @environment,
)
assert_equal("self-closingblock[body]", template.render)
end

def test_block_form_followed_by_self_closing
template = Liquid::Template.parse(
"{% hybrid %}body{% endhybrid %}{% hybrid %}",
environment: @environment,
)
assert_equal("block[body]self-closing", template.render)
end

def test_mixed_forms
template = Liquid::Template.parse(
"{% hybrid %}{% hybrid %}body{% endhybrid %}{% hybrid %}",
environment: @environment,
)
assert_equal("self-closingblock[body]self-closing", template.render)
end

def test_self_closing_inside_block_tag
template = Liquid::Template.parse(
"{% if true %}{% hybrid %}{% endif %}",
environment: @environment,
)
assert_equal("self-closing", template.render)
end

def test_block_form_inside_block_tag
template = Liquid::Template.parse(
"{% if true %}{% hybrid %}body{% endhybrid %}{% endif %}",
environment: @environment,
)
assert_equal("block[body]", template.render)
end

def test_nested_same_type_raises_syntax_error
error = assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse(
"{% hybrid %}{% hybrid %}inner{% endhybrid %}{% endhybrid %}",
environment: @environment,
)
end
assert_match(/cannot be nested/, error.message)
end

def test_orphan_end_tag_raises_syntax_error
error = assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse(
"{% endhybrid %}",
environment: @environment,
)
end
assert_match(/no matching/, error.message)
end

private

def parse_hybrid_tag(source)
template = Liquid::Template.parse(source, environment: @environment)
template.root.nodelist.find { |node| node.is_a?(TestHybridTag) }
end
end
Loading