ICICLES OF THOUGHT

Ramblings on Web Development and Software Architecture

Posted  4 years ago

Tags

Reducing BEM boilerplate through HAML extensions
This post has not been updated in quite some time and the content here may be out of date or not reflect my current my recommedation in the matter.

BEM is a nice pattern with the primary caveat being having to repetitively specify long class name prefixes in html.

In Sass this problem is significantly alleviated because the parent operator & allows for extending of parent class name:

.Button {
  &__label {
    &--primary {
      font-weight: bold
    }
  }
  &__icon {
    opacity: 0.5;
  }
}

compiles to:

.Button__label--primary {
  font-weight: bold;
}
.Button__icon {
  opacity: 0.5;
}

But usage in HTML is quite verbose:

<label class="Button__label Button__label--primary">
</label>

Fortunately, if we are using HAML, then we can extend the HAML parser to reduce some of this verbosity. HAML has a method Haml::Parser.parse_class_and_id to expand expressions like .hello.world#some-id. We can monkey-patch this method to make it bem aware.

Haml::Parser.class_eval do
  class << self

    CLASS_KEY = 'class'.freeze
    ID_KEY = 'id'.freeze

    def parse_class_and_id(list)
      attributes = {}
      return attributes if list.empty?

      list.scan(/([#.])([-:_a-zA-Z0-9]+)/) do |type, property|
        case type
        when '.'
          if attributes[CLASS_KEY]
            attributes[CLASS_KEY] += " "
          else
            attributes[CLASS_KEY] = ""
          end
          if match = property.match(/^bem:(.*)$/)
            property = expand_bem_class match[1]
          end
          attributes[CLASS_KEY] += property
        when '#'
          attributes[ID_KEY] = property
        end
      end
      attributes
    end

    def expand_bem_class(property)
      convert_to_classes(parse_bem_shorthand(property)).join(" ")
    end

    def parse_bem_shorthand(property)
      breakdown = [{}]
      previous_char = nil
      current_part = :block
      property.each_char do |char|
        breakdown.last[current_part] ||= ''
        if char == '_'
          if previous_char == '_'
            previous_char = nil
            unless current_part == :block
              breakdown.push(block: breakdown.last[:block])
            end
            current_part = :element
          else
            previous_char = char
          end
        elsif char == '-'
          if previous_char == '-'
            previous_char = nil
            unless current_part == :block || current_part == :element
              breakdown.push(
                block: breakdown.last[:block],
                element: breakdown.last[:element]
              )
            end
            current_part = :modifier
          else
            previous_char = char
          end
        else
          if previous_char == '_' || previous_char == '-'
            breakdown.last[current_part] += previous_char
          end
          breakdown.last[current_part] += char
        end
      end
      breakdown
    end

    def convert_to_classes(bem_breakdown)
      classes = []
      bem_breakdown.each do |item|
        current_class = ''
        if item[:block].blank?
          raise "BEM Block missing"
        else
          current_class += item[:block]
        end
        unless item[:element].blank?
          current_class += "__#{item[:element]}"
        end
        classes.push(current_class)
        unless item[:modifier].blank?
          current_class += "--#{item[:modifier]}"
          classes.push(current_class)
        end
      end
      classes.uniq
    end

  end

end

Now classes prefixed with bem: will receive special treatment:

.bem:Header__row--secondary--dark
  %a Hello

will compile to:

<div class="Header__row Header__row--secondary Header__row--dark">
</div>

Note that we were able to chain multiple modifiers in single expression. We can also use multiple elements in single expression:

.bem:Header__row--secondary--dark__bar--clear
    %a Hello

will compile to:

<div class="Header__row Header__row--secondary Header__row--dark Header__bar Header__bar--clear">
</div>

Of course this works with arbitrary selectors:

%header.bem:Header__row--secondary--dark
    %a Hello

will compile to:

<header class="Header__row Header__row--secondary Header__row--dark">
</header>

What about cases when we want to use ruby helpers ? We can define two helper methods that expose this functionality:

def expand_bem_class(name)
  Haml::Parser.expand_bem_class(name)
end

def expand_bem_classes(name)
  name.split(".").map{|name| expand_bem_class name }.join(" ")
end

Of course, the usual caveats of tinkering with internals of vendor libraries apply. If tommorrow HAML changes the compiler API, then the monkey patch would have to updated to accomodate for that.