Writing a DSL in Ruby

Hey all. I am trying to solve a problem creating a DSL to product HTML output. This:

output = FancyMarkup.new.document do
  body do
    div id: "container" do
      ul class: "pretty" do
        li "Item 1", class: :active
        li "Item 2"
      end
    end
  end
end

generates this:

<html>
  <body>
    <div id="container">
      <ul class="pretty">
        <li class="active">Item 1</li>
        <li>Item 2</li>
      </ul>
    </div>
  </body>
</html>

Any hints how to approach this? It is an interesting problem I think…

@beaugaines you might want to look into instance_eval. Alternatively, you could accomplish something similar by just yielding to a block. Here’s some rough and dirty code:

class DOMElement
  INDENT_STEP = "  ".freeze
  ELEMENTS = [:body, :div, :ul, :li]

  ELEMENTS.each do |element|
    define_method(element) do |attributes={}, &block|
      append_child(element, attributes, &block)
    end
  end

  attr_reader :children, :tag_name, :attributes

  def initialize(tag_name, attributes={}, indent="")
    @attributes = attributes
    @children = []
    @tag_name = tag_name
    @indent = indent
  end

  def text=(text)
    children << next_indent + text + "\n"
  end

  def to_s
    "#{indent}<#{tag_name} #{html_attributes}>\n" +
      children.map(&:to_s).join +
    "#{indent}</#{tag_name}>\n"
  end

  private

  attr_reader :indent

  def append_child(name, attributes)
    child = DOMElement.new(name, attributes, next_indent)
    yield child if block_given?
    children << child
  end

  def next_indent
    indent + INDENT_STEP
  end

  def html_attributes
    attributes.map { |key, value| %(#{key}="#{value}") }.join(" ")
  end
end

How this works:

Calling a method such as div on a DOMElement creates a new DOMElement object and yields it to a block. The user can then call methods on this div element if desired (including creating children). Once the user is done, the div is appended to the original elements children.

Since the implementation of all the methods that create child elements is the same apart for the name of the element created, I used define_method to programmatically create a method for each element type in ELEMENTS.

Text is appended to children just like a DOMElement would. In a fancier implementation, I might wrap the strings with a TextElement object.

to_s does three things:

  • Creates an opening tag based on name and adds the attributes
  • Creates a closing tag based on name
  • Populates the tag by calling to_s on each of it’s children

This recursive approach allows us to render arbitrarily large DOM trees.

Indentation is handled by incrementing a prefix by INDENT_STEP every time we go down a level

In Action

Recreating your example:

doc = DOMElement.new(:html)

doc.body do |body|
  body.div id: "container" do |div|
    div.ul class: "pretty" do |ul|
      ul.li class: "active" do |li|
        li.text = "Item 1"
      end

      ul.li do |li|
        li.text = "Item 2"
      end
    end
  end
end

doc.to_s generates the following:

<html >
  <body >
    <div id="container">
      <ul class="pretty">
        <li class="active">
          Item 1
        </li>
        <li >
          Item 2
        </li>
      </ul>
    </div>
  </body>
</html>

Hope this helps!

Thanks @joelq, very interesting!