class HTX

A Ruby compiler for HTX templates.

Constants

ATTR_MAP
BEGIN_STATEMENT_END
BEGIN_WHITESPACE
CHILDLESS
CLOSE_STATEMENT
CONTROL_STATEMENT
DYNAMIC_KEY_ATTR
EMPTY_HASH
END_STATEMENT_END
END_WHITESPACE
FLAG_BITS
HTML_ENTITY
INDENT_DEFAULT
INDENT_GUESS
INTERPOLATION
LEADING_WHITESPACE
NEWLINE_NON_BLANK
NON_BLANK_NON_FIRST_LINE
NON_CONTROL_STATEMENT
RAW_VALUE
TAG_MAP

The Nokogiri HTML parser downcases all tag and attribute names, but SVG tags and attributes are case sensitive and often mix cased. These maps are used to restore the correct case of such tags and attributes.

TEMPLATE_STRING
TEXT_NODE
TRAILING_WHITESPACE
VERSION

Public Class Methods

compile(name, template, options = EMPTY_HASH) click to toggle source

Convenience method to create a new instance and immediately call compile on it.

# File lib/htx.rb, line 42
def self.compile(name, template, options = EMPTY_HASH)
  new(name, template).compile(options)
end
new(name, template) click to toggle source

Creates a new HTX instance. Conventionally the path of the template file is used for the name, but it can be anything.

# File lib/htx.rb, line 50
def initialize(name, template)
  @name = name
  @template = template
end

Public Instance Methods

compile(options = EMPTY_HASH) click to toggle source

Compiles the HTX template. Options:

  • :indent - Indent output by this number of spaces if Numeric, or by this string if a String (if the

    latter, may only contain space and tab characters).
  • :assign_to - Assign the template function to this JavaScript object instead of the `window` object.

# File lib/htx.rb, line 62
  def compile(options = EMPTY_HASH)
    doc = Nokogiri::HTML::DocumentFragment.parse(@template)
    root_nodes = doc.children.select { |n| n.element? || (n.text? && n.text.strip != '') }

    if (text_node = root_nodes.find(&:text?))
      raise(MalformedTemplateError.new('text nodes are not allowed at root level', @name, text_node))
    elsif root_nodes.size == 0
      raise(MalformedTemplateError.new('a root node is required', @name))
    elsif root_nodes.size > 1
      raise(MalformedTemplateError.new("root node already defined on line #{root_nodes[0].line}", @name,
          root_nodes[1]))
    end

    @compiled = ''.dup
    @static_key = 0

    @indent =
      if options[:indent].kind_of?(Numeric)
        ' ' * options[:indent]
      elsif options[:indent].kind_of?(String) && options[:indent] !~ /^[ \t]+$/
        raise("Invalid :indent value #{options[:indent].inspect}: only spaces and tabs are allowed")
      else
        options[:indent] || @template[INDENT_GUESS] || INDENT_DEFAULT
      end

    process(doc)
    @compiled.rstrip!

    <<~EOS
      #{options[:assign_to] || 'window'}['#{@name}'] = function(htx) {
      #{@indent}#{@compiled}
      }
    EOS
  end

Private Instance Methods

append(text) click to toggle source

Appends a string to the compiled template function string with appropriate punctuation and/or whitespace inserted.

# File lib/htx.rb, line 164
def append(text)
  if @compiled == ''
    # Do nothing.
  elsif @compiled !~ END_STATEMENT_END && text !~ BEGIN_STATEMENT_END
    @compiled << '; '
  elsif @compiled !~ END_WHITESPACE && text !~ BEGIN_WHITESPACE
    @compiled << ' '
  elsif @compiled[-1] == "\n"
    @compiled << @indent
  end

  @compiled << text
end
indent(text) click to toggle source

Indents each line of a string (except the first).

# File lib/htx.rb, line 181
def indent(text)
  return '' unless text

  text.gsub!(NEWLINE_NON_BLANK, "\n#{@indent}")
  text
end
process(base) click to toggle source

Processes a DOM node's descendents.

# File lib/htx.rb, line 102
def process(base)
  base.children.each do |node|
    next unless node.element? || node.text?

    dynamic_key = process_value(node.attr(DYNAMIC_KEY_ATTR), :attr)

    if node.text? || node.name == ':'
      if (non_text_node = node.children.find { |n| !n.text? })
        raise(MalformedTemplateError.new('dummy tags may not contain child tags', @name, non_text_node))
      end

      text = (node.text? ? node : node.children).text

      if (value = process_value(text))
        append(
          "#{indent(text[LEADING_WHITESPACE])}"\
          "htx.node(#{[
            value,
            dynamic_key,
            ((@static_key += 1) << FLAG_BITS) | TEXT_NODE,
          ].compact.join(', ')})"\
          "#{indent(text[TRAILING_WHITESPACE])}"
        )
      else
        append(indent(text))
      end
    else
      attrs = node.attributes.inject([]) do |attrs, (_, attr)|
        next attrs if attr.name == DYNAMIC_KEY_ATTR

        attrs << "'#{ATTR_MAP[attr.name] || attr.name}'"
        attrs << process_value(attr.value, :attr)
      end

      childless = node.children.empty? || (node.children.size == 1 && node.children[0].text.strip == '')

      append("htx.node(#{[
        "'#{TAG_MAP[node.name] || node.name}'",
        attrs,
        dynamic_key,
        ((@static_key += 1) << FLAG_BITS) | (childless ? CHILDLESS : 0),
      ].compact.flatten.join(', ')})")

      unless childless
        process(node)

        count = ''
        @compiled.sub!(CLOSE_STATEMENT) do
          count = $1 == '' ? 2 : $1.to_i + 1
          $2
        end

        append("htx.close(#{count})")
      end
    end
  end
end
process_value(text, is_attr = false) click to toggle source

Processes, formats, and encodes an attribute or text node value. Returns nil if the value is determined to be a control statement.

# File lib/htx.rb, line 192
def process_value(text, is_attr = false)
  return nil if text.nil? || (!is_attr && text.strip == '')

  if (value = text[RAW_VALUE, 1])
    # Entire text is enclosed in ${...}.
    value.strip!
    quote = false
  elsif (value = text[TEMPLATE_STRING, 1])
    # Entire text is enclosed in backticks (template string).
    quote = true
  elsif is_attr || text.gsub(NON_CONTROL_STATEMENT, '') !~ CONTROL_STATEMENT
    # Text is an attribute value or doesn't match control statement pattern.
    value = text.dup
    quote = true
  else
    return nil
  end

  # Strip one leading and trailing newline (and attached spaces) and perform outdent. Outdent amount
  # calculation ignores everything before the first newline in its search for the least-indented line.
  outdent = value.scan(NON_BLANK_NON_FIRST_LINE).min
  value.gsub!(/#{LEADING_WHITESPACE}|#{TRAILING_WHITESPACE}|^#{outdent}/, '')
  value.insert(0, '`').insert(-1, '`') if quote

  # Ensure any Unicode characters get converted to Unicode escape sequences. Also note that since Nokogiri
  # converts HTML entities to Unicode characters, this causes them to be properly passed to
  # `document.createTextNode` calls as Unicode escape sequences rather than (incorrectly) as HTML
  # entities.
  value.encode('ascii', fallback: ->(c) { "\\u#{c.ord.to_s(16).rjust(4, '0')}" })
end