‘Iacta alea est’
In this post I’d like to build on the inheritance-based extension of Hypertext DSL for variables and helpers and see how we might accomplish nested templating.
Nested templating is a common use-case that I looked at with Mote, and in the most basic case, consists of surrounding a template with a ‘parent’ layout and putting partials into the template as ‘children’. Ideally children can be infinitely nested, that is, partials are able to call partials.
To keep things simple we can keep the context the same, and avoid switching the template context per fragment unnecessarily. Perhaps in future we can pass in variables that are local to the partial and keep it more scoped.
As a reminder, this is the technique we used for processing layouts in Sew with Mote:
def render_page
Mote.parse(page.body, self, page.keys)[page]
end
def render
@page[:content] = render_page
Mote.parse(File.read("_layout.mote", self, page.keys)[page]
end
That is, a double pass, first rendering the template (page) content and then rendering the layout with the rendered template passed in assigned to the page’s content
variable.
I can see how we might accomplish it in two different ways. The first is by building up a string of Ruby, including layout, template and partial(s) and then evaluating it once with the Hypertext DSL. The second is to evaluate each individual fragment and piece it all together using Hypertext::DSL#append
.
Let’s start with the second approach alongside our inheritance based Hypertext DSL extension:
require "hypertext"
require "hypertext/dsl"
class Context < Hypertext::DSL
def initialize(params, &block)
params.each do |key, val|
instance_variable_set(sprintf("@%s", key), val)
end
super(&block)
end
end
class Template < Context
def id_generator
"id-#{rand(100)}"
end
end
The nested approach would essentially require two instances of the Template
class with identical parameters, save in the layout’s case, which would carry an additional content
parameter with a value of the rendered template.
A rough sketch of the code looks like this:
params = { page_title: "Hello, World!" }
template = Template.new page_title: "Hello, World!" do
h1 id: id_generator do
text: "Welcome"
end
end
layout = Template.new params.merge(content: template.to_s) do
html lang: "en-GB" do
head do
title do
text @page_title
end
end
body do
append @content
end
end
end
puts layout.to_s
This gets us all of the way there:
# =>
# <html lang="en-GB">
# <head>
# <title>
# Hello, World!
# </title>
# </head>
# <body>
# <h1 id="id-23">
# Welcome
# </h1>
#
# </body>
# </html>
We’ll leave that on the shelf for now and take a moment for partials. In principle both layouts and partials should work in the same way; the layout would correspond to the template and the template would correspond to the partial. However, with the layout above, we operated outside of the context of an individual template and for partials we’d much rather just be able to call a helper method from the template and render it in place.
We already have the append
method which we can use for including partials. A purist might alias it as partial
but we can stick with append
for now. append
takes a string, so the output of whatever we pass to it must be a string.
If we take the heading and extract it into a partial under a helper named heading
then let’s see what we can cobble together.
Now when putting the following code together, it occurred to me that we are already in the Hypertext::DSL
context, so instead of appending a string we could just use our DSL’s hypertext definition and call our helper method in the template directly. No need to mess about with intermediate string states. This is what that looks like:
class Template < Context
…
def heading
h1 id: id_generator do
text: "Welcome"
end
end
end
And then calling the partial in the template itself with an additional paragraph for good measure:
template = Template.new page_title: "Hello, World!" do
heading
p do
text "A Hypertext template."
end
end
Running the template (this time without the layout) now gives us clear evidence that our partial heading
method works and can be called directly.
puts template.to_s
# =>
# <h1 id="id-91">
# Welcome
# </h1>
# <p>
# A Hypertext template.
# </p>
That’s really nice. Maybe we can define our layout like this too. Let’s override the to_s
method, assuming that every template should be rendered with the layout.
def to_s
content = super
@ht = Hypertext.new
instance_eval do
html lang: "en-GB" do
head do
title do
text @page_title
end
end
body do
append content
end
end
end
@ht.to_s
end
end
We’re playing some tricks here, and I have to say up front that I’m not sure I like how I got this working, but let’s consider it before judging.
We’re grabbing the rendered content of the template by calling super
which calls the to_s
defined in Hypertext::DSL
and storing it in a local variable content
.
Then we’re resetting the @ht
instance variable, clearing out all of the template’s AST, so that the variable is an empty array. Then we evaluate the layout’s definition using instance_eval
which will build up an AST for the layout, and as part of that layout definition we are appending the template’s content from the local variable content
and then calling to_s
directly on the Hypertext
instance (to avoid an infinite loop) which renders both template and layout together.
puts template.to_s
# =>
# <html lang="en-GB">
# <head>
# <title>
# Hello, World!
# </title>
# </head>
# <body>
# <h1 id="id-76">
# Welcome
# </h1>
# <p>
# A Hypertext template.
# </p>
#
# </body>
# </html>
That’s a successful experiment. I’m going to reserve judgement until I investigate how I might load layouts, templates and partials from files. Adding this element may necessitate a change in approach which means any strong opinions at this point might be rendered moot.
We’ve explored two different ways of rendering a layout: one with machinery ‘outside’ the HTML definition instantiating two different classes and nesting one inside the other; and the other with machinery ‘inside’ the HTML definition using only one instance of the class (but cheating a little by resetting the accumulating instance variable). Maybe there are other ways I’ve missed?
—Saturday 3rd April 2021.