A function for generating HTML attribute values

When using the ActionView content_tag helper, you can pass either an array or a scalar value as the value of an HTML attribute. For example, these two method calls produce the exact same output:

content_tag(:div, 'Hello world!', class: ['strong', 'highlight'])
content_tag(:div, 'Hello world!', class: 'strong highlight')
# => <div class="strong highlight">Hello world!</div>

This is a nifty and helpful small feature. However, it has a few limitations.

First, it leaves a trailing space in sitations like this

content_tag(:div, 'Hello world!', class: ['strong', ('active' if i_am_an_active_item?)])
# => <div class="strong active">Hello world!</div>
# => <div class="strong ">Hello world!</div>

Second, it can only work with one-dimensional arrays and scalar values. Now, it makes sense for this function to have a restricted type signature, but when working with this helper in other contexts, this limitation can be irksome.

I recently found myself in such a context and was thereby irked. This irk got me to thinking: How might I write a function that was as flexible as possible in its type signature, and yet still predictable and sane in its output of HTML attribute values?

I began by writing some expectations:

expect(my_method('a')).to eq 'a'
expect(my_method('a', 'b')).to eq 'a b'
expect(my_method('a', nil)).to eq 'a'
 
expect(my_method(['a'])).to eq 'a'
expect(my_method(['a', 'b'])).to eq 'a b'
expect(my_method(['a', nil])).to eq 'a'
 
expect(my_method('a', ['b'])).to eq 'a b'
expect(my_method(['a'], nil)).to eq 'a'

I want my method to handle n number of params and to handle arrays; I also want it to handle nils intelligently.

In order to get these expectations passing, I wrote a method that looked like this:

def my_method(*args)
args.flatten.compact.join(' ')
end

With those expectations met, I began considering other edge cases I wanted to cover. First, I don’t want duplicate values:

expect(my_method('a', 'a')).to eq 'a'
expect(my_method('a', ['a'])).to eq 'a'
expect(my_method('a', 'b', 'a')).to eq 'a b'
expect(my_method('a', ['b', 'a'])).to eq 'a b'
expect(my_method('a', [nil, 'a'])).to eq 'a'

This required a minor update:

def my_method(*args)
args.flatten.compact.uniq.join(' ')
end

Next, I wanted to handle extraneous whitespace:

expect(my_method(' a ')).to eq 'a'
expect(my_method('a ')).to eq 'a'
expect(my_method(' a')).to eq 'a'
 
expect(my_method(' a ', 'b')).to eq 'a b'
expect(my_method('a ', ['b'])).to eq 'a b'
expect(my_method([' a'], 'b')).to eq 'a b'
 
expect(my_method(' a ', nil)).to eq 'a'
expect(my_method('a ', [nil])).to eq 'a'
 
expect(my_method(' a', 'a')).to eq 'a'
expect(my_method(' a ', ['a'])).to eq 'a'
expect(my_method(['a '], 'a')).to eq 'a'

Another minor update to get these specs passing:

def my_method(*args)
# NOTE: `strip` must come before `uniq`
# or else duplicates will sneak in
args.flatten.compact.map(&:strip).uniq.join(' ')
end

Finally, I wanted to handle non-string scalar values:

expect(my_method(2**64)).to eq '18446744073709551616'
expect(my_method(true)).to eq 'true'
expect(my_method(false)).to eq 'false'
expect(my_method(1.day.from_now.to_date)).to eq '2017-11-16'
expect(my_method(1.day.from_now.to_datetime)).to eq '2017-11-16T16:38:32-05:00'
expect(my_method(1.day.from_now.to_time)).to eq '2017-11-16 16:38:45 -0500'
expect(my_method(1.11)).to eq '1.11'
expect(my_method(1)).to eq '1'
expect(my_method(nil)).to eq ''
expect(my_method(:s)).to eq 's'

Once again, this was a very minor update:

def my_method(*args)
args.flatten.compact.map(&:to_s).map(&:strip).uniq.join(' ')
end

I tend to prefer pipelines of Enumerable methods like this to be formatted with each “pipe” on a separate line. I also wanted to give it a more meaningful name. Since the key (and final) action is join, I wanted a name that communicated this essence in addition to the data-munging that goes on. After some consideration, I went with:

def meld(*args)
args.flatten
.compact
.map(&:to_s)
.map(&:strip)
.uniq
.join(' ')
end