activemail 1.2.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -2
  3. data/README.md +105 -47
  4. data/UPGRADING.md +112 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +31 -20
  7. data/app/assets/stylesheets/activemail/_grid.scss +2 -0
  8. data/app/assets/stylesheets/activemail/_settings.scss +22 -10
  9. data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
  10. data/app/assets/stylesheets/activemail/activemail.scss +6 -5
  11. data/app/helpers/active_mail/styles_helper.rb +14 -15
  12. data/app/views/layouts/activemail/mailer.html.inky-erb +0 -5
  13. data/lib/activemail/asset_pipeline/base.rb +47 -0
  14. data/lib/activemail/asset_pipeline/null.rb +19 -0
  15. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  16. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  17. data/lib/activemail/asset_pipeline.rb +35 -0
  18. data/lib/activemail/components/base.rb +35 -39
  19. data/lib/activemail/components/block_grid.rb +11 -4
  20. data/lib/activemail/components/button.rb +5 -28
  21. data/lib/activemail/components/button_renderer.rb +65 -0
  22. data/lib/activemail/components/callout.rb +4 -5
  23. data/lib/activemail/components/center.rb +5 -7
  24. data/lib/activemail/components/columns.rb +26 -7
  25. data/lib/activemail/components/container.rb +11 -4
  26. data/lib/activemail/components/cta.rb +9 -33
  27. data/lib/activemail/components/h_line.rb +3 -5
  28. data/lib/activemail/components/info_box.rb +6 -12
  29. data/lib/activemail/components/menu.rb +3 -4
  30. data/lib/activemail/components/menu_item.rb +10 -3
  31. data/lib/activemail/components/row.rb +3 -4
  32. data/lib/activemail/components/spacer.rb +16 -12
  33. data/lib/activemail/components/table_component.rb +23 -0
  34. data/lib/activemail/components/wrapper.rb +10 -4
  35. data/lib/activemail/configuration.rb +43 -57
  36. data/lib/activemail/errors.rb +15 -0
  37. data/lib/activemail/inliner/base.rb +2 -10
  38. data/lib/activemail/inliner/null.rb +0 -5
  39. data/lib/activemail/inliner/roadie.rb +1 -1
  40. data/lib/activemail/inliner.rb +33 -0
  41. data/lib/activemail/parse_error_reporter.rb +5 -4
  42. data/lib/activemail/pipeline.rb +26 -0
  43. data/lib/activemail/quality/builtin_rules.rb +154 -0
  44. data/lib/activemail/quality/configuration.rb +83 -3
  45. data/lib/activemail/quality/guard.rb +45 -87
  46. data/lib/activemail/quality/minitest.rb +41 -25
  47. data/lib/activemail/quality/output_dir.rb +52 -0
  48. data/lib/activemail/quality/preview_renderer.rb +46 -33
  49. data/lib/activemail/quality/render_all.rb +96 -40
  50. data/lib/activemail/quality/rspec.rb +4 -8
  51. data/lib/activemail/quality/rule.rb +47 -0
  52. data/lib/activemail/rails/engine.rb +13 -13
  53. data/lib/activemail/rails/rendering.rb +51 -0
  54. data/lib/activemail/rails/template_handler.rb +3 -1
  55. data/lib/activemail/tokens/button_style.rb +1 -2
  56. data/lib/activemail/tokens/defaults.rb +77 -0
  57. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  58. data/lib/activemail/tokens.rb +19 -58
  59. data/lib/activemail/version.rb +1 -1
  60. data/lib/activemail.rb +89 -61
  61. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  62. data/lib/generators/activemail/templates/initializer.rb +0 -3
  63. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +0 -2
  64. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
  65. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
  66. data/lib/tasks/activemail.rake +4 -5
  67. metadata +38 -7
  68. data/lib/activemail/components/inky.rb +0 -18
  69. data/lib/activemail/inliner/interceptor.rb +0 -51
  70. data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
@@ -0,0 +1,65 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ # Bulletproof button shared by <button> and <cta>: styles are inlined from the
9
+ # tokens (read at transform time) so it survives clients that strip <style>.
10
+ class ButtonRenderer < Base
11
+ extend T::Sig
12
+ extend T::Helpers
13
+
14
+ abstract!
15
+
16
+ CONTROL_PADDING = 'padding:12px 24px;'
17
+
18
+ sig { abstract.returns(String) }
19
+ def root_class; end
20
+
21
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
22
+ def transform(node, inner)
23
+ style = core.config.tokens.button_style(class?(node, 'secondary') ? :secondary : :primary)
24
+ expand = class?(node, 'expand')
25
+ width = expand ? ' style="width:100%;"' : ''
26
+ [
27
+ %(<table#{class_attribute(node, root_class)} #{TABLE_RESET}#{width}><tbody><tr><td>),
28
+ %(<table #{TABLE_RESET}#{width}><tbody><tr><td style="#{cell_style(style, expand)}">),
29
+ control(node, inner, style, expand),
30
+ '</td></tr></tbody></table></td></tr></tbody></table>'
31
+ ].join
32
+ end
33
+
34
+ private
35
+
36
+ sig { override.returns(T::Array[String]) }
37
+ def consumed_attributes
38
+ LINK_ATTRIBUTES
39
+ end
40
+
41
+ # Padding on the control makes the whole button a clickable target.
42
+ sig { params(node: Nokogiri::XML::Node, inner: String, style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
43
+ def control(node, inner, style, expand)
44
+ css = control_style(style, expand)
45
+ extra = passthrough_attributes(node)
46
+ return %(<span#{extra}#{style_attribute(node, css)}>#{inner}</span>) unless node.attr('href')
47
+
48
+ anchor(node, inner, before: extra, after: style_attribute(node, css))
49
+ end
50
+
51
+ # The outline lives on the cell only: repeating it on the control would double the line.
52
+ sig { params(style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
53
+ def cell_style(style, expand)
54
+ border = style.border ? "border:1px solid #{style.border};" : ''
55
+ "background:#{style.background};border-radius:#{style.radius};#{border}#{'text-align:center;' if expand}"
56
+ end
57
+
58
+ sig { params(style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
59
+ def control_style(style, expand)
60
+ "display:#{expand ? 'block' : 'inline-block'};text-decoration:none;#{CONTROL_PADDING}" \
61
+ "background:#{style.background};color:#{style.color};font-weight:bold;border-radius:#{style.radius};"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -1,20 +1,19 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Callout < Base
8
+ class Callout < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
13
  classes = combine_classes(node, 'callout-inner')
14
- attributes = pass_through_attributes(node)
15
14
  [
16
- %(<table #{attributes}class="callout" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>),
17
- %(<th class="#{classes}">#{inner}</th><th class="expander"></th>),
15
+ %(<table class="callout"#{passthrough_attributes(node)}#{style_attribute(node, 'width:100%;')} #{TABLE_RESET}><tbody><tr>),
16
+ %(<th class="#{escape_attr(classes)}">#{inner}</th><th class="expander"></th>),
18
17
  '</tr></tbody></table>'
19
18
  ].join
20
19
  end
@@ -8,16 +8,14 @@ module ActiveMail
8
8
  class Center < Base
9
9
  extend T::Sig
10
10
 
11
- sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
12
- def transform(node, _inner)
13
- elements = node.elements
14
- elements.each do |child|
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
13
+ node.elements.each do |child|
15
14
  child['align'] = 'center'
16
15
  child['class'] = combine_classes(child, 'float-center')
17
16
  end
18
- items = elements.css('.menu-item').to_a.concat(elements.css('item').to_a)
19
- items.each { |item| item['class'] = combine_classes(item, 'float-center') }
20
- node.to_s
17
+ node.css('.menu-item').each { |item| item['class'] = combine_classes(item, 'float-center') }
18
+ %(<center#{root_attributes(node, classes: nil)}>#{inner}</center>)
21
19
  end
22
20
  end
23
21
  end
@@ -22,26 +22,45 @@ module ActiveMail
22
22
 
23
23
  private
24
24
 
25
+ sig { override.returns(T::Array[String]) }
26
+ def consumed_attributes
27
+ %w[small large]
28
+ end
29
+
25
30
  sig { params(node: Nokogiri::XML::Node).returns([Integer, Integer]) }
26
31
  def column_sizes(node)
27
32
  small = positive_int(node.attr('small'))
28
33
  large = positive_int(node.attr('large'))
29
- siblings = node.parent ? node.parent.elements.size : 1
30
- [small || column_count, large || small || [column_count / siblings, 1].max]
34
+ [small || column_count, large || small || [column_count / column_siblings(node).size, 1].max]
35
+ end
36
+
37
+ # Already transformed columns are interim <th class="columns"> elements; other siblings take no width.
38
+ sig { params(node: Nokogiri::XML::Node).returns(T::Array[Nokogiri::XML::Node]) }
39
+ def column_siblings(node)
40
+ return [node] unless node.parent
41
+
42
+ node.parent.elements.select { |sibling| sibling.name == node.name || column?(sibling) }
43
+ end
44
+
45
+ sig { params(node: Nokogiri::XML::Node).returns(T::Boolean) }
46
+ def column?(node)
47
+ return false unless node.name == ::ActiveMail::Core::INTERIM_TH_TAG
48
+
49
+ class?(node, 'columns')
31
50
  end
32
51
 
33
52
  sig { params(node: Nokogiri::XML::Node, small: Integer, large: Integer).returns(String) }
34
53
  def column_classes(node, small, large)
54
+ siblings = column_siblings(node)
35
55
  classes = combine_classes(node, "small-#{small} large-#{large} columns")
36
- classes += ' first' unless node.previous_element
37
- classes += ' last' unless node.next_element
56
+ classes += ' first' if siblings.first == node
57
+ classes += ' last' if siblings.last == node
38
58
  classes
39
59
  end
40
60
 
41
61
  sig { params(node: Nokogiri::XML::Node, large: Integer).returns(String) }
42
62
  def expander(node, large)
43
- subrows = node.elements.css('.row').to_a.concat(node.elements.css('row').to_a)
44
- return '' unless large == column_count && subrows.empty?
63
+ return '' unless large == column_count && node.at_css('.row').nil?
45
64
 
46
65
  %(<th class="expander"></th>)
47
66
  end
@@ -61,7 +80,7 @@ module ActiveMail
61
80
  # Neutralize the client default th rendering (bold, centered).
62
81
  content_style = 'font-weight:normal;text-align:left;'
63
82
  [
64
- %(<#{::ActiveMail::Core::INTERIM_TH_TAG} class="#{classes}"#{style_attribute(node, style)} #{pass_through_attributes(node)}>),
83
+ %(<#{::ActiveMail::Core::INTERIM_TH_TAG}#{root_attributes(node, classes: classes, layout_style: style)}>),
65
84
  %(<table #{TABLE_RESET} style="width:100%;"><tbody><tr>),
66
85
  %(<th style="#{content_style}">#{inner}</th>#{expander}),
67
86
  %(</tr></tbody></table></#{::ActiveMail::Core::INTERIM_TH_TAG}>)
@@ -1,25 +1,32 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Container < Base
8
+ class Container < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'container')
14
13
  width = container_width
14
+ attributes = root_attributes(node, classes: 'container', layout_style: "width:100%;max-width:#{width}px;margin:0 auto;")
15
15
  # Outlook Word ignores max-width; the MSO ghost table pins the width.
16
16
  [
17
17
  %(<!--[if mso | IE]><table #{TABLE_RESET} align="center" width="#{width}"><tr><td><![endif]-->),
18
- %(<table #{attributes} #{TABLE_RESET} align="center"#{style_attribute(node, "width:100%;max-width:#{width}px;margin:0 auto;")}>),
18
+ %(<table#{attributes} #{TABLE_RESET} align="center">),
19
19
  %(<tbody><tr><td>#{inner}</td></tr></tbody></table>),
20
20
  '<!--[if mso | IE]></td></tr></table><![endif]-->'
21
21
  ].join
22
22
  end
23
+
24
+ private
25
+
26
+ sig { override.returns(T::Array[String]) }
27
+ def consumed_attributes
28
+ [*super, 'align']
29
+ end
23
30
  end
24
31
  end
25
32
  end
@@ -1,48 +1,24 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'button_renderer'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- # Colors read from tokens at transform time (runtime config), not load-time constants.
9
- # Styles are inlined so the button survives clients that strip <style> (Gmail mobile…).
10
- class Cta < Base
8
+ class Cta < ButtonRenderer
11
9
  extend T::Sig
12
10
 
13
- sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
11
+ sig { override.returns(String) }
12
+ def root_class
13
+ 'cta'
14
+ end
15
+
16
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
14
17
  def transform(node, inner)
15
18
  # A CTA without a link is an authoring bug — surface it at render time.
16
19
  raise ArgumentError, '<cta> requires an href attribute' if node.attr('href').to_s.strip.empty?
17
20
 
18
- style = ActiveMail.tokens.button_style(class?(node, 'secondary') ? :secondary : :primary)
19
- anchor = %(<a href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)} ) +
20
- %(style="#{link_style(style)}">#{inner}</a>)
21
- bulletproof_button_table(
22
- outer_classes: combine_classes(node, 'cta'),
23
- inner: anchor,
24
- cell_style: cell_style(style)
25
- )
26
- end
27
-
28
- private
29
-
30
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
31
- def cell_style(style)
32
- "background:#{style.background};border-radius:#{style.radius};#{border_css(style)}"
33
- end
34
-
35
- # No border here: it lives on the cell only — a repeated <a> border doubles the line.
36
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
37
- def link_style(style)
38
- "display:inline-block;text-decoration:none;#{BUTTON_PADDING}" \
39
- "background:#{style.background};color:#{style.color};font-weight:bold;" \
40
- "border-radius:#{style.radius};"
41
- end
42
-
43
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
44
- def border_css(style)
45
- style.border ? "border:1px solid #{style.border};" : ''
21
+ super
46
22
  end
47
23
  end
48
24
  end
@@ -1,18 +1,16 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class HLine < Base
8
+ class HLine < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
12
12
  def transform(node, _inner)
13
- classes = combine_classes(node, 'h-line')
14
- attributes = pass_through_attributes(node)
15
- %(<table #{attributes}class="#{classes}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr><th>&nbsp;</th></tr></tbody></table>)
13
+ %(<table#{root_attributes(node, classes: 'h-line', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr><th>&nbsp;</th></tr></tbody></table>)
16
14
  end
17
15
  end
18
16
  end
@@ -1,19 +1,18 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
8
  # Colors read from tokens at transform time (runtime config), not load-time constants.
9
- class InfoBox < Base
9
+ class InfoBox < TableComponent
10
10
  extend T::Sig
11
11
 
12
12
  sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
13
13
  def transform(node, inner)
14
- classes = combine_classes(node, 'info-box')
15
14
  [
16
- %(<table class="#{classes}" #{TABLE_RESET} style="width:100%;"><tbody><tr>),
15
+ %(<table#{root_attributes(node, classes: 'info-box', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>),
17
16
  %(<td style="#{cell_style}">#{inner}</td>),
18
17
  '</tr></tbody></table>'
19
18
  ].join
@@ -21,16 +20,11 @@ module ActiveMail
21
20
 
22
21
  private
23
22
 
24
- # Box-scoped tokens (fall back to page-level ones) so a host can give the box
25
- # a distinct surface without colliding with :background/:border/:text.
26
23
  sig { returns(String) }
27
24
  def cell_style
28
- tokens = ActiveMail.tokens
29
- background = tokens.color(:info_box_background) || tokens.color!(:background)
30
- border = tokens.color(:info_box_border) || tokens.color!(:border)
31
- text = tokens.color(:info_box_text) || tokens.color!(:text)
32
- "background-color:#{background};border-left:5px solid #{border};border-radius:#{tokens.radius!(:box)};" \
33
- "color:#{text};padding:#{tokens.spacing!(:md)};"
25
+ tokens = core.config.tokens
26
+ "background-color:#{tokens.color!(:info_box_background)};border-left:5px solid #{tokens.color!(:info_box_border)};" \
27
+ "border-radius:#{tokens.radius!(:box)};color:#{tokens.color!(:info_box_text)};padding:#{tokens.spacing!(:md)};"
34
28
  end
35
29
  end
36
30
  end
@@ -1,18 +1,17 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Menu < Base
8
+ class Menu < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'menu')
14
13
  [
15
- %(<table #{attributes} #{TABLE_RESET}#{style_attribute(node)}><tbody><tr><td>),
14
+ %(<table#{root_attributes(node, classes: 'menu')} #{TABLE_RESET}><tbody><tr><td>),
16
15
  %(<table #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>),
17
16
  '</td></tr></tbody></table>'
18
17
  ].join
@@ -10,11 +10,18 @@ module ActiveMail
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'menu-item')
13
+ attributes = root_attributes(node, classes: 'menu-item')
14
14
  # No href → a non-link item, not a broken <a href="">; mirrors <button>.
15
- content = node.attr('href') ? %(<a href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)}>#{inner}</a>) : inner
15
+ content = node.attr('href') ? anchor(node, inner) : inner
16
16
  th = ::ActiveMail::Core::INTERIM_TH_TAG
17
- %(<#{th} #{attributes}#{style_attribute(node)}>#{content}</#{th}>)
17
+ %(<#{th}#{attributes}>#{content}</#{th}>)
18
+ end
19
+
20
+ private
21
+
22
+ sig { override.returns(T::Array[String]) }
23
+ def consumed_attributes
24
+ LINK_ATTRIBUTES
18
25
  end
19
26
  end
20
27
  end
@@ -1,17 +1,16 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Row < Base
8
+ class Row < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'row')
14
- %(<table #{attributes} #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>#{inner}</tr></tbody></table>)
13
+ %(<table#{root_attributes(node, classes: 'row', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>)
15
14
  end
16
15
  end
17
16
  end
@@ -1,41 +1,45 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Spacer < Base
8
+ class Spacer < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
12
12
  def transform(node, _inner)
13
- classes = combine_classes(node, 'spacer')
14
13
  size_sm = node.attr('size-sm')
15
14
  size_lg = node.attr('size-lg')
16
15
 
17
- return build_table(node, classes, nil, size_for(node.attr('size'))) unless size_sm || size_lg
16
+ return build_table(node, 'spacer', size_for(node.attr('size')), skip: []) unless size_sm || size_lg
18
17
 
19
- html = +''
20
- html << build_table(node, classes, 'hide-for-large', size_for(size_sm)) if size_sm
21
- html << build_table(node, classes, 'show-for-large', size_for(size_lg)) if size_lg
22
- html
18
+ tables = []
19
+ tables << ['spacer hide-for-large', size_for(size_sm)] if size_sm
20
+ tables << ['spacer show-for-large', size_for(size_lg)] if size_lg
21
+ tables.each_with_index.map { |(classes, size), index| build_table(node, classes, size, skip: index.zero? ? [] : %w[id]) }.join
23
22
  end
24
23
 
25
24
  private
26
25
 
26
+ sig { override.returns(T::Array[String]) }
27
+ def consumed_attributes
28
+ [*super, 'size', 'size-sm', 'size-lg']
29
+ end
30
+
27
31
  sig { params(value: T.untyped).returns(Integer) }
28
32
  def size_for(value)
29
33
  positive_int(value) || 16
30
34
  end
31
35
 
32
- sig { params(node: Nokogiri::XML::Node, classes: String, extra: T.nilable(String), size: Integer).returns(String) }
33
- def build_table(node, classes, extra, size)
34
- css_class = extra ? "#{classes} #{extra}" : classes
36
+ # A repeated id would be invalid HTML: only the first table carries it.
37
+ sig { params(node: Nokogiri::XML::Node, classes: String, size: Integer, skip: T::Array[String]).returns(String) }
38
+ def build_table(node, classes, size, skip:)
35
39
  # mso-line-height-rule:exactly keeps Outlook from inflating the spacer.
36
40
  style = "font-size:#{size}px;line-height:#{size}px;mso-line-height-rule:exactly;"
37
41
  [
38
- %(<table class="#{css_class}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>),
42
+ %(<table#{root_attributes(node, classes: classes, layout_style: 'width:100%;', skip: skip)} #{TABLE_RESET}><tbody><tr>),
39
43
  %(<td height="#{size}" style="#{style}">&nbsp;</td>),
40
44
  '</tr></tbody></table>'
41
45
  ].join
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ # Root element is a layout table: its reset attributes are fixed, not author-overridable.
9
+ class TableComponent < Base
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ abstract!
14
+
15
+ private
16
+
17
+ sig { override.overridable.returns(T::Array[String]) }
18
+ def consumed_attributes
19
+ TABLE_RESET_ATTRIBUTES
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,21 +1,27 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Wrapper < Base
8
+ class Wrapper < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'wrapper')
14
13
  [
15
- %(<table #{attributes} #{TABLE_RESET} align="center"#{style_attribute(node, 'width:100%;')}><tbody><tr>),
14
+ %(<table#{root_attributes(node, classes: 'wrapper', layout_style: 'width:100%;')} #{TABLE_RESET} align="center"><tbody><tr>),
16
15
  %(<td class="wrapper-inner">#{inner}</td></tr></tbody></table>)
17
16
  ].join
18
17
  end
18
+
19
+ private
20
+
21
+ sig { override.returns(T::Array[String]) }
22
+ def consumed_attributes
23
+ [*super, 'align']
24
+ end
19
25
  end
20
26
  end
21
27
  end