diff --git a/CHANGELOG.md b/CHANGELOG.md index c18af5e..04aad56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-07-01 + +### Added + +- `config.blank_link_rel` (default `"noopener"`): a `rel` is now emitted automatically on + `target="_blank"` anchors. Set `nil` to disable; an explicit `rel="…"` still wins. + ## [1.1.1] - 2026-06-16 ### Added diff --git a/lib/activemail/components/base.rb b/lib/activemail/components/base.rb index 77e5a0b..78d1caa 100644 --- a/lib/activemail/components/base.rb +++ b/lib/activemail/components/base.rb @@ -29,7 +29,7 @@ class Base abstract! IGNORED_ON_PASSTHROUGH = T.let( - %w[class id href size large no-expander small target up size-sm size-lg style].freeze, + %w[class id href size large no-expander small target rel up size-sm size-lg style].freeze, T::Array[String] ) @@ -103,8 +103,20 @@ def combine_attributes(node, extra_classes = nil) end sig { params(node: Nokogiri::XML::Node).returns(String) } - def target_attribute(node) - node.attributes['target'] ? %( target="#{escape_attr(node.attributes['target'])}") : '' + def link_attributes(node) + target = node.attributes['target']&.value + rel = resolve_rel(node, target) + [ + target ? %( target="#{escape_attr(target)}") : '', + rel ? %( rel="#{escape_attr(rel)}") : '' + ].join + end + + sig { params(node: Nokogiri::XML::Node, target: T.nilable(String)).returns(T.nilable(String)) } + def resolve_rel(node, target) + rel = node.attributes['rel']&.value + rel = nil if rel&.strip&.empty? + rel || (ActiveMail.configuration.blank_link_rel if target == '_blank') end # Outlook-safe nested-table structure kept in one place for ') + + assert_includes output, 'rel="noopener"' + end + + def test_non_blank_target_gets_no_rel + output = render('') + + refute_includes output, 'rel=' + end + + def test_no_target_gets_no_rel + output = render('') + + refute_includes output, 'rel=' + end + + def test_explicit_rel_wins_without_duplication + output = render('') + + assert_includes output, 'rel="noopener noreferrer"' + assert_equal 1, output.scan('rel="').size + end + + def test_empty_rel_falls_back_to_default + output = render('') + + assert_includes output, 'rel="noopener"' + assert_equal 1, output.scan('rel="').size + end + + def test_rel_is_escaped + output = render('') + anchor = Nokogiri::HTML.fragment(output).at_css('a') + + assert_equal 'a"b', anchor['rel'] + end + def test_classes_are_merged_with_button output = render('') diff --git a/test/components/cta_test.rb b/test/components/cta_test.rb index cc7c078..0f5e110 100644 --- a/test/components/cta_test.rb +++ b/test/components/cta_test.rb @@ -80,6 +80,26 @@ def test_cta_is_bulletproof_presentation_tables assert_equal tables, output.scan('role="presentation"').size end + def test_cta_blank_injects_default_rel + output = render('Go') + + assert_includes output, 'target="_blank"' + assert_includes output, 'rel="noopener"' + end + + def test_cta_non_blank_gets_no_rel + output = render('Go') + + refute_includes output, 'rel=' + end + + def test_cta_explicit_rel_wins_without_duplication + output = render('Go') + + assert_includes output, 'rel="noopener noreferrer"' + assert_equal 1, output.scan('rel="').size + end + def test_info_box_reads_border_token ActiveMail.tokens.color(:border, '#445566') output = render('note') diff --git a/test/components/menu_test.rb b/test/components/menu_test.rb index 698d6d2..1a16a93 100644 --- a/test/components/menu_test.rb +++ b/test/components/menu_test.rb @@ -22,6 +22,19 @@ def test_item_preserves_target assert_includes output, 'target="_blank"' end + def test_item_blank_injects_default_rel + output = render('I') + + assert_includes output, 'rel="noopener"' + end + + def test_item_explicit_rel_wins_without_duplication + output = render('I') + + assert_includes output, 'rel="noopener noreferrer"' + assert_equal 1, output.scan('rel="').size + end + def test_item_without_href_emits_no_broken_anchor output = render('Label') diff --git a/test/configuration_test.rb b/test/configuration_test.rb index fd54a88..8af12ac 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -9,9 +9,35 @@ def test_defaults assert_equal :erb, config.template_engine assert_equal 12, config.column_count assert_equal 600, config.container_width + assert_equal 'noopener', config.blank_link_rel assert_empty config.components end + def test_blank_link_rel_override_reflected_in_output + ActiveMail.configuration.blank_link_rel = 'noopener noreferrer' + + assert_includes render(''), 'rel="noopener noreferrer"' + end + + def test_blank_link_rel_nil_disables_injection + ActiveMail.configuration.blank_link_rel = nil + + refute_includes render(''), 'rel=' + end + + def test_blank_link_rel_rejects_invalid + config = ActiveMail::Configuration.new + + assert_raises(TypeError) { config.blank_link_rel = 123 } + end + + def test_blank_link_rel_empty_string_disables_injection + ActiveMail.configuration.blank_link_rel = ' ' + + assert_nil ActiveMail.configuration.blank_link_rel + refute_includes render(''), 'rel=' + end + def test_template_engine_setter_coerces_to_symbol config = ActiveMail::Configuration.new config.template_engine = 'slim' @@ -58,7 +84,9 @@ def test_dimension_setters_reject_floats_instead_of_truncating config = ActiveMail::Configuration.new assert_raises(TypeError) { config.column_count = 12.9 } + assert_raises(TypeError) { config.container_width = 4.2 } assert_equal 12, config.column_count + assert_equal 600, config.container_width end def test_constructor_rejects_non_positive_dimensions