Skip to content

Add HybridTag base class and BlockBody reparenting#2059

Open
charlespwd wants to merge 1 commit intomainfrom
cp-theme-composition
Open

Add HybridTag base class and BlockBody reparenting#2059
charlespwd wants to merge 1 commit intomainfrom
cp-theme-composition

Conversation

@charlespwd
Copy link
Contributor

@charlespwd charlespwd commented Mar 18, 2026

Summary

Adds foundational support for "hybrid tags" — tags that can function in both self-closing form ({% section 'name' %}) and block form ({% section 'name' %}...{% endsection %}).

Changes

  • Liquid::HybridTag — New base class extending Liquid::Block that supports two rendering modes:

    • Self-closing: tag has no body, renders via render_self_closing_to_output_buffer
    • Block form: tag captures sibling nodes via end-tag-triggered reparenting, renders via render_block_form_to_output_buffer
    • Subclasses register which end tag triggers reparenting (e.g., endsection)
  • BlockBody#parse reparenting — When the parser encounters an end tag for a registered hybrid tag (e.g., {% endsection %}), it looks back through @nodelist, finds the matching hybrid tag, splices intervening nodes into its body, and flips it to block form. No tokenizer changes needed.

  • Parse-time nesting prevention — Hybrid tags of the same type cannot be nested (raises SyntaxError). Sequential hybrid tags and different types are allowed.

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

Design

Uses end-tag-triggered reparenting rather than tokenizer lookahead. When BlockBody#parse encounters {% endsection %}:

  1. Walks @nodelist backwards to find the matching section tag
  2. Splices all nodes between the hybrid tag and end tag
  3. Calls reparent_as_block(spliced_nodes) on the hybrid tag
  4. Recomputes @blank for the parent body

This approach requires zero tokenizer modifications.

Why nesting is prohibited for hybrid tags

Hybrid tags support an optional end tag — the same tag can appear in self-closing form ({% render 'a' %}) or block form ({% render 'a' %}...{% endrender %}). This creates a fundamental parser ambiguity when nesting is allowed, because the parser cannot determine which opening tag an end tag belongs to.

Consider this template:

{% render 'a' %}
{% render 'b' %}
{% endrender %}
{% render 'c' %}
{% endrender %}

Without indentation (which Liquid does not use for parsing), this is ambiguous. It could mean any of:

  1. a contains b and ca contains b (not self closing) and c (self closing)
  2. All three are sequentiala is self-closing, b is a block, c is a block
  3. a contains b, then c is sequentiala contains b (selfclosing),c is block

More concretely:

{% render 'header' %}
{% render 'main' %}{% endrender %}
{% render 'footer' %}{% endrender %}

vs.

{% render 'header' %}
  {% render 'nav' %}
{% endrender %}
{% render 'footer' %}{% endrender %}

These are visually different but structurally identical to the parser — whitespace/indentation is not significant in Liquid.

The root cause: when you can omit end$tag, the parser cannot tell whether an opening tag is self-closing or is the start of a block that hasn't been closed yet. With mandatory end tags (like {% block %}...{% endblock %}), there is no ambiguity — every opening tag must have a matching close.

This is why Liquid::Block subclasses (like the block tag) can nest — their end tags are mandatory. HybridTag's optional end tag makes nesting fundamentally ambiguous, so we prevent it at parse time.

Test plan

  • Unit tests for HybridTag self-closing form (no end tag → stays self-closing)
  • Unit tests for HybridTag block form (end tag → reparents siblings)
  • @blank recomputation after reparenting
  • Nesting prevention (same-type hybrid tags raise SyntaxError)
  • Sequential hybrid tags (allowed)
  • Mixed self-closing + block in same template
  • Full existing Liquid test suite passes — zero regressions

🤖 Generated with Claude Code

@charlespwd charlespwd force-pushed the cp-theme-composition branch from e7d47ed to f39e154 Compare March 19, 2026 13:36
@charlespwd charlespwd force-pushed the cp-theme-composition branch from de02dd6 to dae47e3 Compare March 19, 2026 14:07
@charlespwd charlespwd marked this pull request as ready for review March 19, 2026 14:23
@charlespwd charlespwd requested a review from ianks March 19, 2026 14:24
@charlespwd charlespwd force-pushed the cp-theme-composition branch from dae47e3 to 28b0cc2 Compare March 19, 2026 14:24
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) <[email protected]>
@charlespwd charlespwd force-pushed the cp-theme-composition branch from 28b0cc2 to cca902b Compare March 19, 2026 14:26
@charlespwd charlespwd requested a review from mmorissette March 19, 2026 14:28
end

def blank?
raise NotImplementedError, "#{self.class} must implement blank?"
Copy link
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?


module Liquid
class HybridTag < Block
def reparent_as_block(children, parse_context)
Copy link
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

end
end

def blank?
Copy link
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?

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

hybrid_tag.reparent_as_block(children, parse_context)
Copy link
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants