Skip to content

Commit cca902b

Browse files
charlespwdclaude
andcommitted
Add HybridTag base class and BlockBody reparenting
Introduces hybrid tags — tags that work in both self-closing and block form. When BlockBody#parse encounters an end tag for a registered hybrid tag, it walks backward through sibling nodes and reparents them into the tag's body. No tokenizer changes needed. - HybridTag subclasses must implement blank? (raises NotImplementedError) - Block form is derived from @Body presence, no separate tracking flag - Nested hybrid tags in block form are detected during the backward walk - Parent BlockBody blank state is not recomputed since hybrid tags that render content already return blank? == false Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 59d8d0d commit cca902b

7 files changed

Lines changed: 270 additions & 8 deletions

File tree

History.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Liquid Change Log
22

3+
## 5.12.0
4+
* Introduce HybridTag base class for tags that can be self-closing or block-form, with end-tag-triggered reparenting in BlockBody [CP Clermont]
5+
36
## 5.11.0
47
* 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]
58
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]

lib/liquid.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ module Liquid
5757
require 'liquid/parser_switching'
5858
require 'liquid/tag'
5959
require 'liquid/block'
60+
require 'liquid/hybrid_tag'
6061
require 'liquid/parse_tree_visitor'
6162
require 'liquid/interrupts'
6263
require 'liquid/tags'

lib/liquid/block_body.rb

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,17 @@ def freeze
5252
next parse_liquid_tag(markup, parse_context)
5353
end
5454

55-
unless (tag = parse_context.environment.tag_for_name(tag_name))
56-
# end parsing if we reach an unknown tag and let the caller decide
57-
# determine how to proceed
58-
return yield tag_name, markup
55+
tag = parse_context.environment.tag_for_name(tag_name)
56+
57+
if tag.nil? && try_reparent_hybrid_tag(tag_name, parse_context)
58+
parse_context.line_number = tokenizer.line_number
59+
next
5960
end
61+
62+
# end parsing if we reach an unknown tag and let the caller decide
63+
# determine how to proceed
64+
return yield tag_name, markup unless tag
65+
6066
new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
6167
@blank &&= new_tag.blank?
6268
@nodelist << new_tag
@@ -147,11 +153,17 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag)
147153
next
148154
end
149155

150-
unless (tag = parse_context.environment.tag_for_name(tag_name))
151-
# end parsing if we reach an unknown tag and let the caller decide
152-
# determine how to proceed
153-
return yield tag_name, markup
156+
tag = parse_context.environment.tag_for_name(tag_name)
157+
158+
if tag.nil? && try_reparent_hybrid_tag(tag_name, parse_context)
159+
parse_context.line_number = tokenizer.line_number
160+
next
154161
end
162+
163+
# end parsing if we reach an unknown tag and let the caller decide
164+
# determine how to proceed
165+
return yield tag_name, markup unless tag
166+
155167
new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
156168
@blank &&= new_tag.blank?
157169
@nodelist << new_tag
@@ -269,5 +281,46 @@ def raise_missing_tag_terminator(token, parse_context)
269281
def raise_missing_variable_terminator(token, parse_context)
270282
BlockBody.raise_missing_variable_terminator(token, parse_context)
271283
end
284+
285+
private def try_reparent_hybrid_tag(end_tag_name, parse_context)
286+
return false unless end_tag_name.start_with?("end")
287+
288+
hybrid_tag_name = end_tag_name.delete_prefix("end")
289+
tag_class = parse_context.environment.tag_for_name(hybrid_tag_name)
290+
return false unless tag_class && tag_class < HybridTag
291+
292+
hybrid_index = nil
293+
i = @nodelist.length - 1
294+
while i >= 0
295+
node = @nodelist[i]
296+
if node.is_a?(HybridTag) && node.tag_name == hybrid_tag_name
297+
if node.block_form?
298+
raise SyntaxError, parse_context.locale.t(
299+
"errors.syntax.hybrid_tag_nested",
300+
tag: hybrid_tag_name,
301+
)
302+
end
303+
304+
hybrid_index = i
305+
break
306+
end
307+
i -= 1
308+
end
309+
310+
unless hybrid_index
311+
raise SyntaxError, parse_context.locale.t(
312+
"errors.syntax.hybrid_tag_no_match",
313+
end_tag: end_tag_name,
314+
tag: hybrid_tag_name,
315+
)
316+
end
317+
318+
children = @nodelist.slice!((hybrid_index + 1)..)
319+
hybrid_tag = @nodelist[hybrid_index]
320+
321+
hybrid_tag.reparent_as_block(children, parse_context)
322+
323+
true
324+
end
272325
end
273326
end

lib/liquid/document.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def unknown_tag(tag, _markup, _tokenizer)
3737
end
3838
end
3939

40+
def blank?
41+
@body.blank?
42+
end
43+
4044
def render_to_output_buffer(context, output)
4145
@body.render_to_output_buffer(context, output)
4246
end

lib/liquid/hybrid_tag.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
module Liquid
4+
class HybridTag < Block
5+
def reparent_as_block(children, parse_context)
6+
@body = new_body
7+
@body.nodelist.concat(children)
8+
@body.freeze
9+
end
10+
11+
def parse(_tokens)
12+
end
13+
14+
def block_form?
15+
!!@body
16+
end
17+
18+
def nodelist
19+
@body ? @body.nodelist : Const::EMPTY_ARRAY
20+
end
21+
22+
def blank?
23+
raise NotImplementedError, "#{self.class} must implement blank?"
24+
end
25+
26+
def render_to_output_buffer(context, output)
27+
if block_form?
28+
render_block_form_to_output_buffer(context, output)
29+
else
30+
render_self_closing_to_output_buffer(context, output)
31+
end
32+
end
33+
34+
private
35+
36+
def render_block_form_to_output_buffer(_context, _output)
37+
raise NotImplementedError, "#{self.class} must implement render_block_form_to_output_buffer"
38+
end
39+
40+
def render_self_closing_to_output_buffer(_context, _output)
41+
raise NotImplementedError, "#{self.class} must implement render_self_closing_to_output_buffer"
42+
end
43+
end
44+
end

lib/liquid/locales/en.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
tag_never_closed: "'%{block_name}' tag was never closed"
2525
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
2626
unexpected_else: "%{block_name} tag does not expect 'else' tag"
27+
hybrid_tag_nested: "'%{tag}' tag cannot be nested inside another '%{tag}' tag"
28+
hybrid_tag_no_match: "Unexpected end tag '%{end_tag}' — no matching '%{tag}' tag found"
2729
unexpected_outer_tag: "Unexpected outer '%{tag}' tag"
2830
unknown_tag: "Unknown tag '%{tag}'"
2931
variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}"

test/unit/hybrid_tag_unit_test.rb

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
class HybridTagUnitTest < Minitest::Test
6+
include Liquid
7+
8+
class TestHybridTag < Liquid::HybridTag
9+
def blank?
10+
true
11+
end
12+
13+
private
14+
15+
def render_self_closing_to_output_buffer(_context, output)
16+
output << "self-closing"
17+
end
18+
19+
def render_block_form_to_output_buffer(context, output)
20+
output << "block["
21+
@body.render_to_output_buffer(context, output)
22+
output << "]"
23+
end
24+
end
25+
26+
def setup
27+
@environment = Liquid::Environment.build do |env|
28+
env.register_tag("hybrid", TestHybridTag)
29+
end
30+
end
31+
32+
def test_hybrid_tag_is_subclass_of_block
33+
assert(TestHybridTag < Liquid::Block)
34+
end
35+
36+
def test_self_closing_renders_correctly
37+
template = Liquid::Template.parse("{% hybrid %}", environment: @environment)
38+
assert_equal("self-closing", template.render)
39+
end
40+
41+
def test_self_closing_block_form_predicate_is_false
42+
tag = parse_hybrid_tag("{% hybrid %}")
43+
refute(tag.block_form?)
44+
end
45+
46+
def test_self_closing_does_not_consume_subsequent_tokens
47+
template = Liquid::Template.parse("{% hybrid %}after", environment: @environment)
48+
assert_equal("self-closingafter", template.render)
49+
end
50+
51+
def test_block_form_renders_correctly
52+
template = Liquid::Template.parse("{% hybrid %}body{% endhybrid %}", environment: @environment)
53+
assert_equal("block[body]", template.render)
54+
end
55+
56+
def test_block_form_predicate_is_true
57+
tag = parse_hybrid_tag("{% hybrid %}body{% endhybrid %}")
58+
assert(tag.block_form?)
59+
end
60+
61+
def test_block_form_body_accessible_via_nodelist
62+
tag = parse_hybrid_tag("{% hybrid %}hello world{% endhybrid %}")
63+
assert(tag.block_form?)
64+
refute_empty(tag.nodelist)
65+
assert_equal("hello world", tag.nodelist.map(&:to_s).join)
66+
end
67+
68+
def test_empty_block_form
69+
template = Liquid::Template.parse("{% hybrid %}{% endhybrid %}", environment: @environment)
70+
assert_equal("block[]", template.render)
71+
end
72+
73+
def test_block_form_with_liquid_content
74+
template = Liquid::Template.parse(
75+
"{% hybrid %}before{{ var }}after{% endhybrid %}",
76+
environment: @environment,
77+
)
78+
assert_equal("block[beforeVafter]", template.render({ "var" => "V" }))
79+
end
80+
81+
def test_sequential_block_forms
82+
template = Liquid::Template.parse(
83+
"{% hybrid %}a{% endhybrid %}{% hybrid %}b{% endhybrid %}",
84+
environment: @environment,
85+
)
86+
assert_equal("block[a]block[b]", template.render)
87+
end
88+
89+
def test_self_closing_followed_by_block_form
90+
template = Liquid::Template.parse(
91+
"{% hybrid %}{% hybrid %}body{% endhybrid %}",
92+
environment: @environment,
93+
)
94+
assert_equal("self-closingblock[body]", template.render)
95+
end
96+
97+
def test_block_form_followed_by_self_closing
98+
template = Liquid::Template.parse(
99+
"{% hybrid %}body{% endhybrid %}{% hybrid %}",
100+
environment: @environment,
101+
)
102+
assert_equal("block[body]self-closing", template.render)
103+
end
104+
105+
def test_mixed_forms
106+
template = Liquid::Template.parse(
107+
"{% hybrid %}{% hybrid %}body{% endhybrid %}{% hybrid %}",
108+
environment: @environment,
109+
)
110+
assert_equal("self-closingblock[body]self-closing", template.render)
111+
end
112+
113+
def test_self_closing_inside_block_tag
114+
template = Liquid::Template.parse(
115+
"{% if true %}{% hybrid %}{% endif %}",
116+
environment: @environment,
117+
)
118+
assert_equal("self-closing", template.render)
119+
end
120+
121+
def test_block_form_inside_block_tag
122+
template = Liquid::Template.parse(
123+
"{% if true %}{% hybrid %}body{% endhybrid %}{% endif %}",
124+
environment: @environment,
125+
)
126+
assert_equal("block[body]", template.render)
127+
end
128+
129+
def test_nested_same_type_raises_syntax_error
130+
error = assert_raises(Liquid::SyntaxError) do
131+
Liquid::Template.parse(
132+
"{% hybrid %}{% hybrid %}inner{% endhybrid %}{% endhybrid %}",
133+
environment: @environment,
134+
)
135+
end
136+
assert_match(/cannot be nested/, error.message)
137+
end
138+
139+
def test_orphan_end_tag_raises_syntax_error
140+
error = assert_raises(Liquid::SyntaxError) do
141+
Liquid::Template.parse(
142+
"{% endhybrid %}",
143+
environment: @environment,
144+
)
145+
end
146+
assert_match(/no matching/, error.message)
147+
end
148+
149+
private
150+
151+
def parse_hybrid_tag(source)
152+
template = Liquid::Template.parse(source, environment: @environment)
153+
template.root.nodelist.find { |node| node.is_a?(TestHybridTag) }
154+
end
155+
end

0 commit comments

Comments
 (0)