diff --git a/History.md b/History.md index 94feaabfd..3acb0fca3 100644 --- a/History.md +++ b/History.md @@ -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] diff --git a/lib/liquid.rb b/lib/liquid.rb index 4d0a71a64..d37369aee 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -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' diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index e4ada7d16..e2a9e5326 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -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 @@ -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 @@ -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) + + true + end end end diff --git a/lib/liquid/document.rb b/lib/liquid/document.rb index 7742ae5fa..bd5c59d62 100644 --- a/lib/liquid/document.rb +++ b/lib/liquid/document.rb @@ -37,6 +37,10 @@ def unknown_tag(tag, _markup, _tokenizer) end end + def blank? + @body.blank? + end + def render_to_output_buffer(context, output) @body.render_to_output_buffer(context, output) end diff --git a/lib/liquid/hybrid_tag.rb b/lib/liquid/hybrid_tag.rb new file mode 100644 index 000000000..412e76862 --- /dev/null +++ b/lib/liquid/hybrid_tag.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Liquid + class HybridTag < Block + def reparent_as_block(children, parse_context) + @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?" + 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 diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index b99d490c8..d2b5edd3c 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -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}" diff --git a/test/unit/hybrid_tag_unit_test.rb b/test/unit/hybrid_tag_unit_test.rb new file mode 100644 index 000000000..6ad2b3f89 --- /dev/null +++ b/test/unit/hybrid_tag_unit_test.rb @@ -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