Block capture in an ECR-like template engine results in out-of-order output

Context

In my armature shard, I include a templating engine which is mostly a copy/paste of ECR except it sanitizes the output for HTML by default:

<!-- This will be HTML-escaped -->
<%= content %>

<!-- This will be output raw -->
<%== content %>

I’m working on block-capture support like what the erubi gem uses (or used to use, apparently, until last month) for when you need to wrap the block’s content inside of something else. For example, a Form component could be used like this:

<%|== Form.new method: "POST", action: action do |f| %>
  <%== f.input "name" %>
<%| end %>

And the code could look something like this (note the to_s(io) method):

struct Form
  def initialize(@method : String, @action : String, &@block : self -> Nil)
  end

  def input(name)
    Input.new name
  end

  def to_s(io) : Nil
    io << "<form>"
    @block.call self
    io << "</form>"
  end

  record Input, name : String do
    def to_s(io) : Nil
      io << %{<input name="} << name << %{">}
    end
  end
end

Problem

Now, for this particular component I want to output raw HTML, but when testing with HTML sanitization enabled, the block output appears in the result ahead of the header/trailer content:

<%|= Form.new method: "POST", action: "/" do |f| %>
  <%== f.input "name" %>
<%| end %>

<!-- result — notice that the `input` tag comes before the `form` -->
  <input name="name">
&lt;form method=&quot;POST&quot; action=&quot;/&quot;&gt;&lt;/form&gt;

This doesn’t happen when I output the raw content, so I’ve narrowed it down to the fact that when I call .to_s on the object that sanitizes the object’s output for HTML, it’s passed a String::Builder but the block is piping its output directly to the original IO object.

So I’m trying to figure out how to make Armature::HTML::SanitizableValue pipe directly to the same IO while also HTML-escaping the object’s output, but I can’t figure out how to do that. Any ideas?