‘Iacta alea est’
I was so overjoyed with my discovery yesterday that I forgot to polish up the final artefact.
There are a couple of improvements I think that we can make to the API and logic, namely how we handle text, and a more extensive switching logic for HTML void elements (elements that do not need to be closed).
At the moment, because I was trying to find a unified API for both HTML elements and text nodes I treated a text node just like an HTML element:
d.el :text do
"Content"
end
I don’t like this for two reasons. Well, three actually. It isn’t faithful to the underlying structure we are trying to abstract, a text node has no attributes, and is not technically an element. As a result of carrying this unnecessary weight it’s overly verbose in the template. And lastly, it clouds our rendering logic a little by adding an extra branch to the decision tree.
What if we introduced a new text
method to handle text nodes. It wouldn’t need to take a block, so we can just pass a string. In the ‘template’:
d.el :h1 do
text "Hello, World!"
end
And in the HTMLDocument
class:
def text(string)
@doc << string
end
This then transforms our el
method by removing the :text
case from the case statement:
def el(type, attrs={})
case type
when :text
@doc << yield
when :input
@doc << opening_tag(type, attrs)
else
@doc << opening_tag(type, attrs)
yield
@doc << closing_tag(type)
end
end
To this:
def el(type, attrs={})
case type
when :input
@doc << opening_tag(type, attrs)
else
@doc << opening_tag(type, attrs)
yield
@doc << closing_tag(type)
end
end
As you can see, as a remnant from our initial use-case, we are only catering for the input
void element. There are a number of different void elements and we would do well to cater for them all. Let’s introduce them here in a constant:
VOID_ELEMENTS = %i[
area base br col embed hr img input
link meta param source track wbr
]
def el(type, attrs={})
case type
when *VOID_ELEMENTS
@doc << opening_tag(type, attrs)
else
@doc << opening_tag(type, attrs)
yield
@doc << closing_tag(type)
end
end
That should now handle those other cases of void elements.
Now, while staring at a case statement with just two cases is bad enough, when one case duplicates one of the statements in the other then you know it’s time for a change. Let’s clean that up:
def el(type, attrs={})
@doc << opening_tag(type, attrs)
unless VOID_ELEMENTS.include? type
yield
@doc << closing_tag(type)
end
end
Ah. Much better. Although I’ve broken the golden rule of using an unless
across multiple lines. We should have an Enumerable#exclude?
method as unpalatable as it sounds. I was caught between a rock and a hard place. The alternative is to use,
if !VOID_ELEMENTS.include? type
and my keen eye for aesthetic sensibility thinks it worse to negate at the beginning of a constant given the resulting obscurity.
To show you the levels to which my dissatisfaction will drive me I did come up with:
if Set[*VOID_ELEMENTS].disjoint? Set[type]
but it really is an acquired taste.
Anyway, that digression aside, we have cleaned up our code significantly. Here’s the polished class:
class HTMLDocument
VOID_ELEMENTS = %i[
area base br col embed hr img input link meta param source track wbr
]
def initialize
@doc = ""
yield self if block_given?
end
def text(string)
@doc << string
end
def el(type, attrs={})
@doc << opening_tag(type, attrs)
unless VOID_ELEMENTS.include? type
yield
@doc << closing_tag(type)
end
end
def to_s
@doc
end
def opening_tag(name, attrs)
if attrs.empty?
"<#{name}>"
else
"<#{name} #{attributes(attrs)}>"
end
end
def closing_tag(name)
"</#{name}>"
end
def attributes(attrs)
attrs.map do |key, value|
%Q(#{key}="#{value}")
end.join(" ")
end
end
doc = HTMLDocument.new do |d|
d.el :html, lang: "en-GB" do
d.el :head do
d.el :title do
d.text "Hello, "
d.text "World!"
end
end
d.el :body do
d.el :input, { type: "text" }
end
end
end.to_s
And I’m sure there are lots more tricks in the bag. What if we want to format our HTML? Indent it? Can we embed one document inside another document? I don’t know. But let’s find out.
—Wednesday 10th March 2021.