So, if I loop through and create a collection of li/a tags, I get as expected.. an array of these tags:
(1..5).to_a.map do
content_tag(:li) do
link_to("boo", "www.boohoo.com")
end
end
=> ["<li><a href=\"www.boohoo.com\">boo</a></li>", "<li><a href=\"www.boohoo.com\">boo</a></li>", "<li><a href=\"www.boohoo.com\">boo</a></li>", "<li><a href=\"www.boohoo.com\">boo</a></li>", "<li><a href=\"www.boohoo.com\">boo</a></li>"]
I call join on them and I get an expected string...
(1..5).to_a.map do
content_tag(:li) do
link_to("boo", "www.boohoo.com")
end
end.join
=> "<li><a href=\"www.boohoo.com\">boo</a></li><li><a href=\"www.boohoo.com\">boo</a></li><li><a href=\"www.boohoo.com\">boo</a></li><li><a href=\"www.boohoo.com\">boo</a></li><li><a href=\"www.boohoo.com\">boo</a></li>"
However, if I nest this one level deeper in an ol tag...
content_tag(:ol) do
(1..5).to_a.map do
content_tag(:li) { link_to("boo", "www.boohoo.com") }
end.join
end
=> "<ol><li><a href="www.boohoo.com">boo</a></li><li><a href="www.boohoo.com">boo</a></li><li><a href="www.boohoo.com">boo</a></li><li><a href="www.boohoo.com">boo</a></li><li><a href="www.boohoo.com">boo</a></li></ol>"
I get escaped inner-html madness!!!
When looking at the rails source code:
def content_tag(name, content_or_options_with_block = nil, options = nil, escape = true, &block)
if block_given?
options = content_or_options_with_block if content_or_options_with_block.is_a?(Hash)
content_tag_string(name, capture(&block), options, escape)
else
content_tag_string(name, content_or_options_with_block, options, escape)
end
end
private
def content_tag_string(name, content, options, escape = true)
tag_options = tag_options(options, escape) if options
"<#{name}#{tag_options}>#{escape ? ERB::Util.h(content) : content}</#{name}>".html_safe
end
It deceivingly looks like I can just do: content_tag(:li, nil, nil, false) and not have it escape the content.. However:
content_tag(:ol, nil, nil, false) do
(1..5).to_a.map do
content_tag(:li, nil, nil, false) do
link_to("boo", "www.boohoo.com")
end
end.join
end
=> "<ol><li><a href="www.boohoo.com">boo</a></li><li><a href="www.boohoo.com">boo</a></li><li><a href="www.boohoo.com">boo</a></li><li><a href="www.boohoo.com">boo</a></li><li><a href="www.boohoo.com">boo</a></li></ol>"
I still am suffering from unwanted html_escape syndrome...
So the only way I know to avoid this is to do:
content_tag(:ol) do
(1..5).to_a.map do
content_tag(:li) do
link_to("boo", "www.boohoo.com")
end
end.join.html_safe
end
=> "<ol><li><a href=\"www.boohoo.com\">boo</a></li><li><a href=\"www.boohoo.com\">boo</a></li><li><a href=\"www.boohoo.com\">boo</a></li><li><a href=\"www.boohoo.com\">boo</a></li><li><a href=\"www.boohoo.com\">boo</a></li></ol>"
But.. Why does this happen?
It happens because in Rails 3 the SafeBuffer class was introduced which wraps the String class and overrides the default escaping that would otherwise occur when concat is called.
In your case, the content_tag(:li) is outputting a proper SafeBuffer, but Array#join doesn't understand SafeBuffers and simply outputs a String. The content_tag(:ol) is then be called with a String as it's value instead of a SafeBuffer and escapes it. So it doesn't so much have to do with nesting as it does to do with join returning a String not a SafeBuffer.
Calling html_safe on a String, passing the String to raw, or passing the array to safe_join will all return a proper SafeBuffer and prevent the outer content_tag from escaping it.
Now in the case of passing false to the escape argument, this doesn't work when your passing a block to content tag because it is calling capture(&block)
ActionView::Helpers::CaptureHelper which is used to pull in the template, or your case the output value of join, which then causes it to call html_escape
on the string before it makes its way into the content_tag_string
method.
# action_view/helpers/tag_helper.rb
def content_tag(name, content_or_options_with_block = nil, options = nil, escape = true, &block)
if block_given?
options = content_or_options_with_block if content_or_options_with_block.is_a?(Hash)
# capture(&block) escapes the string from join before being passed
content_tag_string(name, capture(&block), options, escape)
else
content_tag_string(name, content_or_options_with_block, options, escape)
end
end
# action_view/helpers/capture_helper.rb
def capture(*args)
value = nil
buffer = with_output_buffer { value = yield(*args) }
if string = buffer.presence || value and string.is_a?(String)
ERB::Util.html_escape string
end
end
Since value here is the return value from join, and join returns a String, it calls html_escape before the content_tag code even gets to it with it's own escaping.
Some reference links for those interested
https://github.com/rails/rails/blob/v3.1.0/actionpack/lib/action_view/helpers/capture_helper.rb
https://github.com/rails/rails/blob/v3.1.0/actionpack/lib/action_view/helpers/tag_helper.rb
http://yehudakatz.com/2010/02/01/safebuffers-and-rails-3-0/
http://railsdispatch.com/posts/security
Edit
Another way to handle this is to do a map/reduce instead of map/join since if reduce is not passed an argument it will use the first element and run the given operation using that object, which in the case of map content_tag will be calling the operation on a SafeBuffer.
content_tag(:ol) do
(1..5).to_a.map do
content_tag(:li) do
link_to(...)
end
end.reduce(:<<)
# Will concat using the SafeBuffer instead of String with join
end
As a one-liner
content_tag(:ul) { collection.map {|item| content_tag(:li) { link_to(...) }}.reduce(:<<) }
Add a little meta-spice to clean things up
ul_tag { collection.map_reduce(:<<) {|item| li_link_to(...) } }
Who needs html_safe... this is Ruby!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With