Inherited Templates With Rails
A nice feature of some template languages (for instance, Django's) is the ability to create templates that can be "inherited" by other templates. In effect, the goal is to create a template that has some missing content, and let "inheritors" fill in that content downstream.
It's not terribly difficult to add this feature to Rails; let's first design the API. First, the parent template (_parent.html.erb
):
Before in parent
<%= first_from_child %>
<%= second_from_child %>
After in parent
We just use normal partial locals for the parent API, because they're a well-understood construct and pretty easy to work with. Next, the "subclass" (_child.html.erb
):
Before in child
<% override_template("parent") do |template| -%>
<% template.set_content(:first_from_child) do -%>first_from_child<% end -%>
<% template.set_content(:second_from_child) do -%>second_from_child<% end -%>
<% end -%>
After in child
Here, we use Ruby's block syntax to create a context for supplying the content for the template. We expect the following output:
Before in child
Before in parent
first_from_child
second_from_child
After in parent
After in child
There are two parts to this implementation. First, let's create the implementation for the override_template
method that will be exposed onto ActionView::Base
:
module ActionView
module InheritedTemplates
class Collector
end
def override_template(name, &block)
locals = Collector.collect(self, &block)
concat(render :partial => name, :locals => locals)
end
end
class Base
include InheritedTemplates
end
end
To start, we create the method, deferring the heavy lifting to the Collector.collect
method. The API we've designed asks #collect
to hand back a Hash of locals, which we can pass unmodified into the "super" template.
The collector is a pretty straight-forward implementation, but it deserves some explanation:
class Collector
def self.collect(view)
collector = new(view)
yield collector
collector.content
end
attr_reader :content
def initialize(view)
@view = view
@content = {}
end
def set_content(name, &block)
@content[name] = @view.capture(&block)
end
end
def override_template(name, &block)
locals = Collector.collect(self, &block)
concat(render :partial => name, :locals => locals)
end
The first thing we do is define the collect
method. It's a basic DSL implementation, creating an instance of itself and evaluating the block in its context. In order to fully understand what's going on here, you need to see what the block looks like, once it's compiled from ERB into pure-Ruby:
override_template("parent") do |template|
template.set_content(:first_from_child) do; @output_buffer << "first_from_child"; end
template.set_content(:second_from_child) do; @output_buffer << "second_from_child"; end
end
We start by creating a new collector, yielding the collector to the block passed to override_template
. Inside the block, template.set_content
is called twice; each call sets a key and value in the @content
Hash. When done, the Hash will look like {:first_from_child => "first_from_child", :second_from_child => "second_from_child"}
. The collector then returns the content Hash.
When that's done, override_template
simply passes that locals Hash through to render :partial
, and concats the output to the buffer.
Afterthought
Those following my blog might remember that I showed how we can improve block helpers by smarter compilation. If that fix was in place, here's what _child.html.erb
would look like:
Before in child
<%= override_template("parent") do |template| -%>
<% template.set_content(:first_from_child) do -%>first_from_child<% end -%>
<% template.set_content(:second_from_child) do -%>second_from_child<% end -%>
<% end -%>
After in child
More importantly, here's what override_template
would look like:
def override_template(name, &block)
locals = Collector.collect(self, &block)
render :partial => name, :locals => locals
end
We're actually saved a bit of pain here in the unfixed case, because override_template can only be called from inside templates, so we don't need to use block_called_from_erb? to disambiguate. Had this been a normal block helper, override_template would have looked like:
def override_template(name, &block)
locals = Collector.collect(self, &block)
response = render(:partial => name, :locals => locals)
if block_called_from_erb?(&block)
concat(response)
else
response
end
end
Of course, realizing all of this is non-trivial, and the reason we're going to be moving toward the simple block helper syntax above.