‘Iacta alea est’

Hypertext DSL: Reading (Nested) Templates from File (2)

In the first post on nested templating I successfully rendered a layout and template by creating two instances of a Template class which inherited from Hypertext::DSL and combining the output together with Hypertext::DSL#append.

The other solution in the same post was to modify the initialize method with a hard-coded layout.

Neither option feels right to me at this stage, and although I mimicked the double-instance approach when loading from file I feel that we might better be able to build on the approach used to load partials.

In a future post I’d like to explore if we can have “turtles all the way down” and integrate the layout, template and partial using the same technique.

The implementation of a partial method in our Template class is a testament to our desired simplicity:

def partial(filename)
  instance_eval File.read(filename)
end

When partial is called from a fragment with the name of a file containing another Hypertext DSL fragment then it simply reads the file and evaluates it in the current context. An additional benefit here is that it also executes it in the correct ‘position’ in the document’s AST so that it appears in the correct output location. There is no need to piece together previously executed fragments after rendering using append. It feels right.

This is how it would be called:

partial "header.ht"
p do
  text "A Hypertext template."
end

To then combine the layout and the template in the same fashion that we do a template and a partial, without instantiating two Template objects, then we’ll need to pass in references to both the layout and the template file when creating the template.

There are a couple of design decisions to consider. For starters, we already have a params hash. Now strictly speaking the params hash is for populating a set of instance variables for interpolation in the template output. However, this could also be used as a transport mechanism for template and layout information.

The alternative would be to have dedicated arguments for either template or layout, or both.

Concretely, our Context#initialize function could have one of the following signatures:

# Using params for everything

params = { layout: "layout.ht", template: "template.ht" }
def initialize(params)

# Using args for everything

def initialize(params, template, layout)

# Using a combination, either

params = { layout: "layout.ht" }
def initialize(params, template)

# or

params = { template: "template.ht" }
def initialize(params, layout)

Let’s go with passing everything through in the params for now. One side of the trade-off is that these keys in the hash (template and layout) become ‘reserved’ and that they are less explicit and could therefore be considered ‘magical’. The other side is that it is a simple API with no need to remember various arguments and their order. (And strictly speaking, @template is a variable just like the others.) We can always pass judgement later.

If we begin with our Context class, and modify the initialize method:

  class Context < Hypertext::DSL
-   def initialize(params, fragment)
+   def initialize(params)
      params.each do |key, val|
        instance_variable_set(sprintf("@%s", key), val)
      end
  
      @ht = Hypertext.new
-     instance_eval fragment
+     instance_eval File.read(@layout)
    end
  end

The layout key from the params hash will be assigned to the @layout instance variable. We can then read the contents of the file with that name and evaluate it in the context of the current instance.

Now for this to work together with the template, then we’ll need to include a means of executing the template as a partial.

  # layout.ht

  html lang: "en-GB" do
    head do
      title do
        text @page_title
      end
    end
    body do
-     append @content
+     partial @template
    end
  end

Given we assume the presence of the partial method in this scenario let’s move it up from our Template class into our Context class:

  class Context < Hypertext::DSL
    def initialize(params)
      …
      @ht = Hypertext.new
      instance_eval File.read(@layout)
    end
  
+   def partial(filename)
+     instance_eval File.read(filename)
+   end
  end

The eagle-eyed among you will have spotted that there is now no difference between the evaluation we are doing in the initialize method and the partial method, so we can further simplify by getting rid of the duplication:

  class Context < Hypertext::DSL
    def initialize(params)
      …
      @ht = Hypertext.new
-     instance_eval File.read(@layout)
+     partial @layout
    end
  
    def partial(filename)
      instance_eval File.read(filename)
    end
  end

And there we have it. Turtles all the way down. The Russian dolls can dance. We have implemented all of our rendering execution in terms of one simple primitive.

If we then test this out on a layout, template and partial:

# layout.ht

html lang: "en-GB" do
  head do                                          
    title do                                       
      text @page_title                             
    end
  end
  body do                                          
    partial @template
  end
end

# template.ht

partial "header.ht"
p do
  text "A Hypertext template."
end

# header.ht (partial)

h1 id: id_generator do
  text "Welcome"
end

And create our Template object, rendering it to an HTML string:

require "hypertext"
require "hypertext/dsl"

class Context < Hypertext::DSL
  def initialize(params)
    params.each do |key, val|
      instance_variable_set(sprintf("@%s", key), val)
    end

    @ht = Hypertext.new
    partial @layout
  end

  def partial(filename)
    instance_eval File.read(filename)
  end
end

class Template < Context
  def id_generator
    "id-#{rand(100)}"
  end
end

params = {
  layout: "layout.ht",
  template: "template.ht",
  page_title: "Hello, World!"
}
template = Template.new(params)
puts template.to_s

This does exactly what we expect it to do:

# =>
# <html lang="en-GB">
#   <head>
#     <title>
#       Hello, World!
#     </title>
#   </head>
#   <body>
#     <h1 id="id-77">
#       Welcome
#     </h1>
#     <p>
#       A Hypertext template.
#     </p>
#   </body>
# </html>

I’m really happy with this. The Context class is the meat and potatoes of this implementation, building a small templating layer on top of the Hypertext DSL.

It’s 12 SLOC (14 if you include the requires).

It introduces one method, partial which enables the infinite nesting of Hypertext DSL fragments and allows a straightforward way of handling layouts, templates and partials.

The layout, template and partials are all executed in the same context, meaning all helpers and variables are available at each level of the hierarchy.

It is a foundation for one’s own Template or Renderer class which can then be expanded with helper methods and other, extra desired functionality.

The question about the appropriate method signature fades away in the light of the appropriateness of the rest of the solution to this problem. It is a side-show.

What do you think? Are you as delighted with this seemingly final conclusion as I am? Do you see other ways to do this? More elegant approaches? Have I missed anything?

Tuesday 6th April 2021.