diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index 9d669c7cd816a..52c11ad2664ce 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,34 @@ +* New syntax for tag helpers. Avoid positional parameters and suport HTML5 by default. + Example usage of tag helpers before: + + ```ruby + tag(:br) + + content_tag(:div, content_tag(:p, "Hello world!"), class: "strong") + ``` + + ```html + <%= content_tag :div, class: "strong" do -%> + Hello world! + <% end -%> + ``` + + Example usage of tag helpers after: + + ```ruby + tag.br + + tag.div tag.p("Hello world!"), class: "strong" + ``` + + ```html + <%= tag.div class: "strong" do %> + Hello world! + <% end %> + ``` + + *Marek Kirejczyk* + * `select_tag`'s `include_blank` option for generation for blank option tag, now adds an empty space label, when the value as well as content for option tag are empty, so that we confirm with html specification. Ref: https://www.w3.org/TR/html5/forms.html#the-option-element. diff --git a/actionview/lib/action_view/helpers/tag_helper.rb b/actionview/lib/action_view/helpers/tag_helper.rb index 9414ac876b6b9..abb308a506320 100644 --- a/actionview/lib/action_view/helpers/tag_helper.rb +++ b/actionview/lib/action_view/helpers/tag_helper.rb @@ -5,7 +5,7 @@ module ActionView # = Action View Tag Helpers module Helpers #:nodoc: # Provides methods to generate HTML tags programmatically when you can't use - # a Builder. By default, they output XHTML compliant tags. + # a Builder. module TagHelper extend ActiveSupport::Concern include CaptureHelper @@ -26,44 +26,67 @@ module TagHelper PRE_CONTENT_STRINGS[:textarea] = "\n" PRE_CONTENT_STRINGS["textarea"] = "\n" - # TagBuilder work in progress - # TODO: - # * Documentation - # * More tests - # * support for NEED_CLOSING element - # * Method missing -> raise if unknown html tag - # * fill NEED_CLOSING - # * include support for escape argument - # * blocks - # * Extract to sepearete file (?) - - class TagBuilder - include ActionView::Helpers::TagHelper - - VOID_ELEMENTS = %w(base br col embed hr img input keygen link meta param source track wbr).to_set + class TagBuilder #:nodoc: + include TagHelper + + VOID_ELEMENTS = %w(base br col embed hr img input keygen link + meta param source track wbr).to_set + VOID_ELEMENTS.merge(VOID_ELEMENTS.map(&:to_sym)) + ELEMENTS = %w(a abbr acronym address applet area article aside audio b + base basefont bdi bdo bgsound big blink blockquote body br + button canvas caption center cite code col colgroup command + content data datalist dd del details dfn dialog dir div dl + dt element em embed fieldset figcaption figure font footer + form frame frameset h1 h2 h3 h4 h5 h6 head header hgroup hr + html i iframe image img input ins isindex kbd keygen label + legend li link listing main map mark marquee menu menuitem + meta meter multicol nav nextid nobr noembed noframes + noscript object ol optgroup option output p param picture + plaintext pre progress q rp rt rtc ruby s samp script + section select shadow small source spacer span strike strong + style sub summary sup table tbody td template textarea tfoot + th thead time title tr track tt u ul var video wbr xmp + ).to_set + + ELEMENTS.merge(ELEMENTS.map(&:to_sym)) + + def initialize(view_context) + @view_context = view_context + end + private - def render_tag(name, content_or_options = nil, options = nil, &block) + def tag_string(name, content_or_options = nil, options_or_escape = nil, escape = true, &block) + raise_if_void_tag_with_content(name, content_or_options, &block) if block_given? - content_tag(name, yield(self), content_or_options) + content_tag_string(name, @view_context.capture(self, &block), content_or_options, options_or_escape) elsif content_or_options.is_a? String - content_tag(name, content_or_options, options) + content_tag_string(name, content_or_options, options_or_escape, escape) elsif VOID_ELEMENTS.include?(name) - tag(name, content_or_options, false, escape = true) + options_or_escape = true if options_or_escape.nil? + "<#{name}#{tag_options(content_or_options, options_or_escape)}>".html_safe else - content_tag(name, "", content_or_options) + options_or_escape = true if options_or_escape.nil? + content_tag_string(name, "", content_or_options, options_or_escape) end end - def method_missing(called, *args, &block) - render_tag(called, args[0], args[1], &block) + def raise_if_void_tag_with_content(name, content_or_options, &block) + has_content = block_given? || content_or_options.is_a?(String) + void_with_content = has_content && VOID_ELEMENTS.include?(name) + raise ArgumentError, "Void tag with content" if void_with_content end + def method_missing(called, *args, &block) + return tag_string(called, *args, &block) if ELEMENTS.include?(called) + super + end end - + # Returns an HTML tag. Supports two syntax variants: traditonal and modern. + # === Traditional syntax # Returns an empty HTML tag of type +name+ which by default is XHTML # compliant. Set +open+ to true to create an open tag compatible # with HTML 4.0 and below. Add HTML attributes by passing an attributes @@ -109,8 +132,63 @@ def method_missing(called, *args, &block) # # tag("div", data: {name: 'Stephen', city_state: %w(Chicago IL)}) # # =>
+ # + # === Modern syntax + # Modern syntax uses one of following format: + # tag.(options, escape) + # tag.(content, options, escape) + # Returns an HTML tag. Conetent has to be a string. If content is passed tag is surrounding the content. Otherwise tag will be empty. You can also use a block to pass the content inside ERB templates. Result is by default is HTML5 compliant. Set escape parameter to false to disable attribute value escaping. The tag will be generated with related closing tag unless tag is a void[https://www.w3.org/TR/html5/syntax.html#void-elements] element. Method will rise NoMethodError if element is not a part of the standard and ArgumentError if you try to pass content to a void element. + # + # ==== Options + # Like with traditional syntax the options hash can be used with attributes with no value like (disabled and readonly), which you can give a value of true in the options hash. You can use symbols or strings for the attribute names. + # + # ==== Examples + # tag.span + # # => + # + # tag.span(class: "bookmark") + # # => + # + # tag.input type: 'text', disabled: true + # # => + # + # tag.input type: 'text', class: ["strong", "highlight"] + # # => + # + # tag.img src: "open & shut.png" + # # => + # + # tag.img({src: "open & shut.png"}, nil, false) + # # => + # + # tag.div(data: {name: 'Stephen', city_state: %w(Chicago IL)}) + # # =>
+ # + # tag.p "Hello world!" + # # =>

Hello world!

+ # + # tag.div tag.p("Hello world!"), class: "strong" + # # =>

Hello world!

+ # + # tag.div "Hello world!", class: ["strong", "highlight"] + # # =>
Hello world!
+ # + # tag.select options, multiple: true + # # => + # + # <%= tag.div class: "strong" do %> + # Hello world! + # <% end %> + # # =>
Hello world!
+ # + # <%= tag.div class: "strong" do |t| %> + # <% t.p("Hello world!") %> + # <% end %> + # # =>

Hello world!

+ + def tag(name = nil, options = nil, open = false, escape = true) - return TagBuilder.new if name == nil + return TagBuilder.new(self) if name == nil "<#{name}#{tag_options(options, escape) if options}#{open ? ">" : " />"}".html_safe end diff --git a/actionview/test/fixtures/test/_builder_tag_nested_in_content_tag.erb b/actionview/test/fixtures/test/_builder_tag_nested_in_content_tag.erb new file mode 100644 index 0000000000000..ddad7ec3ace98 --- /dev/null +++ b/actionview/test/fixtures/test/_builder_tag_nested_in_content_tag.erb @@ -0,0 +1,3 @@ +<%= tag.p do %> + <%= tag.b 'Hello' %> +<% end %> diff --git a/actionview/test/template/tag_helper_test.rb b/actionview/test/template/tag_helper_test.rb index 269ec6c24264b..cc1fd5b06fbf6 100644 --- a/actionview/test/template/tag_helper_test.rb +++ b/actionview/test/template/tag_helper_test.rb @@ -5,23 +5,29 @@ class TagHelperTest < ActionView::TestCase tests ActionView::Helpers::TagHelper - def test_builder_tag - assert_equal "
", tag.br + def test_tag + assert_equal "
", tag("br") + assert_equal "
", tag(:br, :clear => "left") + assert_equal "
", tag("br", nil, true) + end + + def test_tag_builder + assert_equal "", tag.span assert_equal "", tag.span(class: "bookmark") end - def test_builder_content_tag - assert_equal "
Content
", tag.div("Content", id: "post_1") + def test_tag_builder_void_tag + assert_equal "
", tag.br + assert_equal "
", tag.br(class: 'some_class') end - def test_builder_nested - assert_equal "
hello
", tag.div(id: 'header') { |tag| tag.span 'hello' } + def test_tag_builder_void_tag_with_content + assert_raises(ArgumentError) { tag.br "Content Not Allowed for void tags" } + assert_raises(ArgumentError) { tag.br { |t| t.span } } end - def test_tag - assert_equal "
", tag("br") - assert_equal "
", tag(:br, :clear => "left") - assert_equal "
", tag("br", nil, true) + def test_tag_builder_unknown_tag + assert_raises(NoMethodError) { tag.unknown_tag } end def test_tag_options @@ -30,23 +36,46 @@ def test_tag_options assert_match(/class="elsewhere"/, str) end + def test_tag_builder_options + str = tag.p "class" => "show", :class => "elsewhere" + assert_match(/class="show"/, str) + assert_match(/class="elsewhere"/, str) + end + def test_tag_options_rejects_nil_option assert_equal "

", tag("p", :ignored => nil) end + def test_tag_builder_options_rejects_nil_option + assert_equal "

", tag.p(:ignored => nil) + end + def test_tag_options_accepts_false_option assert_equal "

", tag("p", :value => false) end + def test_tag_builder_options_accepts_false_option + assert_equal "

", tag.p(value: false) + end + def test_tag_options_accepts_blank_option assert_equal "

", tag("p", :included => '') end + def test_tag_builder_options_accepts_blank_option + assert_equal "

", tag.p(included: '') + end + def test_tag_options_converts_boolean_option assert_dom_equal '

', tag("p", :disabled => true, :itemscope => true, :multiple => true, :readonly => true, :allowfullscreen => true, :seamless => true, :typemustmatch => true, :sortable => true, :default => true, :inert => true, :truespeed => true) end + def test_tag_builder_options_converts_boolean_option + assert_dom_equal '

', + tag.p(:disabled => true, :itemscope => true, :multiple => true, :readonly => true, :allowfullscreen => true, :seamless => true, :typemustmatch => true, :sortable => true, :default => true, :inert => true, :truespeed => true) + end + def test_content_tag assert_equal "Create", content_tag("a", "Create", "href" => "create") assert content_tag("a", "Create", "href" => "create").html_safe? @@ -58,43 +87,96 @@ def test_content_tag content_tag(:p, '', nil, false) end + def test_tag_builder_with_content + assert_equal "

Content
", tag.div("Content", id: "post_1") + assert tag.div("Content", id: "post_1").html_safe? + assert_equal tag.div("Content", id: "post_1"), + tag.div("Content", "id" => "post_1") + assert_equal "

<script>evil_js</script>

", + tag.p("") + assert_equal "

", + tag.p('', nil, false) + end + + def test_tag_builder_nested + assert_equal "
content
", + tag.div { "content" } + assert_equal "
hello
", + tag.div(id: 'header') { |tag| tag.span 'hello' } + assert_equal "
hello
", + tag.div(id: 'header') { |tag| tag.div(class: 'world') { tag.span 'hello' } } + end + def test_content_tag_with_block_in_erb buffer = render_erb("<%= content_tag(:div) do %>Hello world!<% end %>") assert_dom_equal "
Hello world!
", buffer end + def test_tag_builder_with_block_in_erb + buffer = render_erb("<%= tag.div do %>Hello world!<% end %>") + assert_dom_equal "
Hello world!
", buffer + end + def test_content_tag_with_block_in_erb_containing_non_displayed_erb buffer = render_erb("<%= content_tag(:p) do %><% 1 %><% end %>") assert_dom_equal "

", buffer end + def test_tag_builder_with_block_in_erb_containing_non_displayed_erb + buffer = render_erb("<%= tag.p do %><% 1 %><% end %>") + assert_dom_equal "

", buffer + end + def test_content_tag_with_block_and_options_in_erb buffer = render_erb("<%= content_tag(:div, :class => 'green') do %>Hello world!<% end %>") assert_dom_equal %(
Hello world!
), buffer end + def test_tag_builder_with_block_and_options_in_erb + buffer = render_erb("<%= tag.div(:class => 'green') do %>Hello world!<% end %>") + assert_dom_equal %(
Hello world!
), buffer + end + def test_content_tag_with_block_and_options_out_of_erb assert_dom_equal %(
Hello world!
), content_tag(:div, :class => "green") { "Hello world!" } end + def test_tag_builder_with_block_and_options_out_of_erb + assert_dom_equal %(
Hello world!
), tag.div(:class => "green") { "Hello world!" } + end + def test_content_tag_with_block_and_options_outside_out_of_erb assert_equal content_tag("a", "Create", :href => "create"), content_tag("a", "href" => "create") { "Create" } end + def test_tag_builder_with_block_and_options_outside_out_of_erb + assert_equal tag.a("Create", :href => "create"), + tag.a("href" => "create") { "Create" } + end + def test_content_tag_with_block_and_non_string_outside_out_of_erb assert_equal content_tag("p"), content_tag("p") { 3.times { "do_something" } } end + def test_tag_builder_with_block_and_non_string_outside_out_of_erb + assert_equal tag.p, + tag.p { 3.times { "do_something" } } + end + def test_content_tag_nested_in_content_tag_out_of_erb assert_equal content_tag("p", content_tag("b", "Hello")), content_tag("p") { content_tag("b", "Hello") }, output_buffer + assert_equal tag.p(tag.b("Hello")), + tag.p {tag.b("Hello") }, + output_buffer end def test_content_tag_nested_in_content_tag_in_erb assert_equal "

\n Hello\n

", view.render("test/content_tag_nested_in_content_tag") + assert_equal "

\n Hello\n

", view.render("test/builder_tag_nested_in_content_tag") end def test_content_tag_with_escaped_array_class @@ -108,6 +190,17 @@ def test_content_tag_with_escaped_array_class assert_equal "

limelight

", str end + def test_tag_builder_with_escaped_array_class + str = tag.p "limelight", :class => ["song", "play>"] + assert_equal "

limelight

", str + + str = tag.p "limelight", :class => ["song", "play"] + assert_equal "

limelight

", str + + str = tag.p "limelight", :class => ["song", ["play"]] + assert_equal "

limelight

", str + end + def test_content_tag_with_unescaped_array_class str = content_tag('p', "limelight", {:class => ["song", "play>"]}, false) assert_equal "

\">limelight

", str @@ -116,21 +209,44 @@ def test_content_tag_with_unescaped_array_class assert_equal "

\">limelight

", str end + def test_tag_builder_with_unescaped_array_class + str = tag.p "limelight", {:class => ["song", "play>"]}, false + assert_equal "

\">limelight

", str + + str = tag.p "limelight", {:class => ["song", ["play>"]]}, false + assert_equal "

\">limelight

", str + end + def test_content_tag_with_empty_array_class str = content_tag('p', 'limelight', {:class => []}) assert_equal '

limelight

', str end + def test_tag_builder_with_empty_array_class + str = tag.p 'limelight', {:class => []} + assert_equal '

limelight

', str + end + def test_content_tag_with_unescaped_empty_array_class str = content_tag('p', 'limelight', {:class => []}, false) assert_equal '

limelight

', str end + def test_tag_builder_with_unescaped_empty_array_class + str = tag.p 'limelight', {:class => []}, false + assert_equal '

limelight

', str + end + def test_content_tag_with_data_attributes assert_dom_equal '

limelight

', content_tag('p', "limelight", data: { number: 1, string: 'hello', string_with_quotes: 'double"quote"party"' }) end + def test_tag_builder_with_data_attributes + assert_dom_equal '

limelight

', + tag.p("limelight", data: { number: 1, string: 'hello', string_with_quotes: 'double"quote"party"' }) + end + def test_cdata_section assert_equal "]]>", cdata_section("") end @@ -152,6 +268,7 @@ def test_escape_once def test_tag_honors_html_safe_for_param_values ['1&2', '1 < 2', '“test“'].each do |escaped| assert_equal %(), tag('a', :href => escaped.html_safe) + assert_equal %(), tag.a(:href => escaped.html_safe) end end @@ -163,9 +280,18 @@ def test_tag_honors_html_safe_with_escaped_array_class assert_equal '

', str end + def test_tag_builder_honors_html_safe_with_escaped_array_class + str = tag.p(:class => ['song>', raw('play>')]) + assert_equal '

', str + + str = tag.p(:class => [raw('song>'), 'play>']) + assert_equal '

', str + end + def test_skip_invalid_escaped_attributes ['&1;', 'dfa3;', '& #123;'].each do |escaped| assert_equal %(), tag('a', :href => escaped) + assert_equal %(), tag.a(:href => escaped) end end @@ -173,10 +299,20 @@ def test_disable_escaping assert_equal '', tag('a', { :href => '&' }, false, false) end + def test_tag_builder_disable_escaping + assert_equal '', tag.a({ :href => '&' }, false) + assert_equal 'cnt', tag.a({ :href => '&' }, false) { "cnt"} + assert_equal '
', tag.br({ "data-hidden": '&' }, false) + assert_equal 'content', tag.a("content", { :href => '&' }, false) + assert_equal 'content', tag.a({ :href => '&' }, false) { "content"} + end + def test_data_attributes ['data', :data].each { |data| assert_dom_equal '', tag('a', { data => { a_float: 3.14, a_big_decimal: BigDecimal.new("-123.456"), a_number: 1, string: 'hello', symbol: :foo, array: [1, 2, 3], hash: { key: 'value'}, string_with_quotes: 'double"quote"party"' } }) + assert_dom_equal '', + tag.a({ data => { a_float: 3.14, a_big_decimal: BigDecimal.new("-123.456"), a_number: 1, string: 'hello', symbol: :foo, array: [1, 2, 3], hash: { key: 'value'}, string_with_quotes: 'double"quote"party"' } }) } end @@ -184,6 +320,8 @@ def test_aria_attributes ['aria', :aria].each { |aria| assert_dom_equal '', tag('a', { aria => { a_float: 3.14, a_big_decimal: BigDecimal.new("-123.456"), a_number: 1, string: 'hello', symbol: :foo, array: [1, 2, 3], hash: { key: 'value'}, string_with_quotes: 'double"quote"party"' } }) + assert_dom_equal '', + tag.a({ aria => { a_float: 3.14, a_big_decimal: BigDecimal.new("-123.456"), a_number: 1, string: 'hello', symbol: :foo, array: [1, 2, 3], hash: { key: 'value'}, string_with_quotes: 'double"quote"party"' } }) } end @@ -192,4 +330,11 @@ def test_link_to_data_nil_equal div_type2 = content_tag(:div, 'test', { data: {tooltip: nil} }) assert_dom_equal div_type1, div_type2 end + + def test_tag_builder_link_to_data_nil_equal + div_type1 = tag.div 'test', { 'data-tooltip' => nil } + div_type2 = tag.div 'test', { data: {tooltip: nil} } + assert_dom_equal div_type1, div_type2 + end + end