activemail 1.2.3 → 2.1.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -2
  3. data/README.md +141 -56
  4. data/UPGRADING.md +185 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +44 -24
  7. data/app/assets/stylesheets/activemail/_grid.scss +40 -14
  8. data/app/assets/stylesheets/activemail/_settings.scss +23 -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/_document_head.html.erb +18 -0
  13. data/app/views/layouts/activemail/mailer.html.inky-erb +1 -22
  14. data/lib/activemail/asset_pipeline/base.rb +47 -0
  15. data/lib/activemail/asset_pipeline/null.rb +19 -0
  16. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  17. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  18. data/lib/activemail/asset_pipeline.rb +35 -0
  19. data/lib/activemail/components/base.rb +35 -39
  20. data/lib/activemail/components/block_grid.rb +11 -4
  21. data/lib/activemail/components/button.rb +5 -28
  22. data/lib/activemail/components/button_renderer.rb +69 -0
  23. data/lib/activemail/components/callout.rb +4 -5
  24. data/lib/activemail/components/center.rb +10 -10
  25. data/lib/activemail/components/columns.rb +58 -15
  26. data/lib/activemail/components/container.rb +13 -6
  27. data/lib/activemail/components/cta.rb +9 -33
  28. data/lib/activemail/components/h_line.rb +3 -5
  29. data/lib/activemail/components/info_box.rb +6 -12
  30. data/lib/activemail/components/menu.rb +3 -4
  31. data/lib/activemail/components/menu_item.rb +10 -3
  32. data/lib/activemail/components/row.rb +3 -4
  33. data/lib/activemail/components/spacer.rb +16 -12
  34. data/lib/activemail/components/table_component.rb +23 -0
  35. data/lib/activemail/components/wrapper.rb +10 -4
  36. data/lib/activemail/configuration.rb +43 -57
  37. data/lib/activemail/errors.rb +15 -0
  38. data/lib/activemail/inliner/base.rb +2 -10
  39. data/lib/activemail/inliner/null.rb +0 -5
  40. data/lib/activemail/inliner/premailer/document.rb +100 -0
  41. data/lib/activemail/inliner/premailer.rb +3 -2
  42. data/lib/activemail/inliner/roadie.rb +1 -1
  43. data/lib/activemail/inliner.rb +33 -0
  44. data/lib/activemail/parse_error_reporter.rb +5 -4
  45. data/lib/activemail/pipeline.rb +26 -0
  46. data/lib/activemail/quality/builtin_message_rules.rb +58 -0
  47. data/lib/activemail/quality/builtin_rules.rb +165 -0
  48. data/lib/activemail/quality/configuration.rb +103 -8
  49. data/lib/activemail/quality/guard.rb +61 -83
  50. data/lib/activemail/quality/minitest.rb +56 -27
  51. data/lib/activemail/quality/output_dir.rb +52 -0
  52. data/lib/activemail/quality/output_filter.rb +24 -0
  53. data/lib/activemail/quality/preview_renderer.rb +46 -33
  54. data/lib/activemail/quality/render_all.rb +95 -39
  55. data/lib/activemail/quality/rspec.rb +14 -13
  56. data/lib/activemail/quality/rule.rb +47 -0
  57. data/lib/activemail/quality/rule_exemptions.rb +49 -0
  58. data/lib/activemail/rails/engine.rb +13 -13
  59. data/lib/activemail/rails/rendering.rb +51 -0
  60. data/lib/activemail/rails/template_handler.rb +3 -1
  61. data/lib/activemail/tokens/button_style.rb +1 -2
  62. data/lib/activemail/tokens/defaults.rb +78 -0
  63. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  64. data/lib/activemail/tokens.rb +19 -58
  65. data/lib/activemail/version.rb +1 -1
  66. data/lib/activemail.rb +89 -61
  67. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  68. data/lib/generators/activemail/templates/initializer.rb +0 -3
  69. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -19
  70. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -11
  71. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -10
  72. data/lib/tasks/activemail.rake +4 -5
  73. metadata +22 -4
  74. data/lib/activemail/components/inky.rb +0 -18
  75. data/lib/activemail/inliner/interceptor.rb +0 -51
  76. data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
@@ -0,0 +1,47 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ActiveMail
7
+ module AssetPipeline
8
+ class Base
9
+ extend T::Sig
10
+ extend T::Helpers
11
+
12
+ abstract!
13
+
14
+ sig { params(app: T.untyped).void }
15
+ def initialize(app)
16
+ @app = T.let(app, T.untyped)
17
+ @cache = T.let({}, T::Hash[String, String])
18
+ end
19
+
20
+ sig { params(logical_path: String).returns(T.nilable(String)) }
21
+ def read(logical_path)
22
+ return fetch(logical_path) unless cache?
23
+
24
+ cached = @cache[logical_path]
25
+ return cached if cached
26
+
27
+ fetch(logical_path).tap { |css| @cache[logical_path] = css if css }
28
+ end
29
+
30
+ sig { params(_paths: T::Array[String]).void }
31
+ def register_precompile(_paths); end
32
+
33
+ private
34
+
35
+ sig { abstract.params(logical_path: String).returns(T.nilable(String)) }
36
+ def fetch(logical_path); end
37
+
38
+ sig { returns(T.untyped) }
39
+ attr_reader :app
40
+
41
+ sig { returns(T::Boolean) }
42
+ def cache?
43
+ !app.config.enable_reloading
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module AssetPipeline
8
+ class Null < Base
9
+ extend T::Sig
10
+
11
+ private
12
+
13
+ sig { override.params(_logical_path: String).returns(T.nilable(String)) }
14
+ def fetch(_logical_path)
15
+ nil
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module AssetPipeline
8
+ class Propshaft < Base
9
+ extend T::Sig
10
+
11
+ private
12
+
13
+ sig { override.params(logical_path: String).returns(T.nilable(String)) }
14
+ def fetch(logical_path)
15
+ app.assets.load_path.find(logical_path)&.content&.force_encoding(Encoding::UTF_8)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,39 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module AssetPipeline
8
+ class Sprockets < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(paths: T::Array[String]).void }
12
+ def register_precompile(paths)
13
+ app.config.assets.precompile += paths
14
+ end
15
+
16
+ private
17
+
18
+ # app.assets is the live environment (compile = true); with precompiled
19
+ # assets it is nil and only the manifest can resolve the logical path.
20
+ sig { override.params(logical_path: String).returns(T.nilable(String)) }
21
+ def fetch(logical_path)
22
+ environment = app.assets
23
+ return environment.find_asset(logical_path)&.to_s if environment
24
+
25
+ manifest_source(logical_path)
26
+ end
27
+
28
+ sig { params(logical_path: String).returns(T.nilable(String)) }
29
+ def manifest_source(logical_path)
30
+ manifest = app.assets_manifest
31
+ compiled = manifest&.assets&.[](logical_path)
32
+ return unless compiled
33
+
34
+ path = File.join(manifest.directory, compiled)
35
+ File.read(path) if File.file?(path)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ require_relative 'asset_pipeline/base'
7
+ require_relative 'asset_pipeline/sprockets'
8
+ require_relative 'asset_pipeline/propshaft'
9
+ require_relative 'asset_pipeline/null'
10
+
11
+ module ActiveMail
12
+ module AssetPipeline
13
+ class << self
14
+ extend T::Sig
15
+
16
+ # Never respond_to?: Sprockets' config.assets is an OrderedOptions that
17
+ # answers it for any key.
18
+ sig { params(app: T.untyped).returns(ActiveMail::AssetPipeline::Base) }
19
+ def for(app)
20
+ if defined?(::Sprockets::Railtie)
21
+ Sprockets.new(app)
22
+ elsif defined?(::Propshaft)
23
+ Propshaft.new(app)
24
+ else
25
+ Null.new(app)
26
+ end
27
+ end
28
+
29
+ sig { returns(ActiveMail::AssetPipeline::Base) }
30
+ def current
31
+ @current ||= T.let(self.for(::Rails.application), T.nilable(ActiveMail::AssetPipeline::Base))
32
+ end
33
+ end
34
+ end
35
+ end
@@ -21,22 +21,20 @@ module ActiveMail
21
21
  end
22
22
  end
23
23
 
24
- # Public extension point: subclass, implement #transform(node, inner), then register.
24
+ # `inner` is an opaque slot: interpolate it exactly once; the engine moves the
25
+ # already transformed children there.
25
26
  class Base
26
27
  extend T::Sig
27
28
  extend T::Helpers
28
29
 
29
30
  abstract!
30
31
 
31
- IGNORED_ON_PASSTHROUGH = T.let(
32
- %w[class id href size large no-expander small target rel up size-sm size-lg style].freeze,
33
- T::Array[String]
34
- )
32
+ HANDLED_ATTRIBUTES = T.let(%w[class style].freeze, T::Array[String])
33
+ LINK_ATTRIBUTES = T.let(%w[href target rel].freeze, T::Array[String])
35
34
 
36
35
  # Layout tables: presentation role (a11y) and zeroed legacy spacing.
37
36
  TABLE_RESET = 'role="presentation" border="0" cellpadding="0" cellspacing="0"'
38
-
39
- BUTTON_PADDING = 'padding:12px 24px;'
37
+ TABLE_RESET_ATTRIBUTES = T.let(%w[role border cellpadding cellspacing].freeze, T::Array[String])
40
38
 
41
39
  sig { params(core: ::ActiveMail::Core).void }
42
40
  def initialize(core)
@@ -57,19 +55,33 @@ module ActiveMail
57
55
  CGI.escapeHTML(value.to_s)
58
56
  end
59
57
 
60
- # Author attributes are untrusted: "abc".to_i would silently become 0.
61
58
  sig { params(value: T.untyped).returns(T.nilable(Integer)) }
62
59
  def positive_int(value)
63
- # The RBI types Integer(exception: false) as non-nilable; it does return nil.
64
- int = T.let(Integer(value.to_s, exception: false), T.nilable(Integer))
65
- int if int&.positive?
60
+ ActiveMail.parse_positive_integer(value)
66
61
  end
67
62
 
68
- sig { params(node: Nokogiri::XML::Node).returns(String) }
69
- def pass_through_attributes(node)
70
- node.attributes.reject { |name, _| IGNORED_ON_PASSTHROUGH.include?(name.downcase) }.map do |name, value|
71
- %(#{name}="#{escape_attr(value)}" )
72
- end.join
63
+ sig { overridable.returns(T::Array[String]) }
64
+ def consumed_attributes
65
+ []
66
+ end
67
+
68
+ sig do
69
+ params(node: Nokogiri::XML::Node, classes: T.nilable(String), layout_style: String, skip: T::Array[String]).returns(String)
70
+ end
71
+ def root_attributes(node, classes:, layout_style: '', skip: [])
72
+ "#{class_attribute(node, classes)}#{passthrough_attributes(node, skip)}#{style_attribute(node, layout_style)}"
73
+ end
74
+
75
+ sig { params(node: Nokogiri::XML::Node, classes: T.nilable(String)).returns(String) }
76
+ def class_attribute(node, classes)
77
+ merged = combine_classes(node, classes)
78
+ merged.empty? ? '' : %( class="#{escape_attr(merged)}")
79
+ end
80
+
81
+ sig { params(node: Nokogiri::XML::Node, skip: T::Array[String]).returns(String) }
82
+ def passthrough_attributes(node, skip = [])
83
+ reserved = HANDLED_ATTRIBUTES + consumed_attributes + skip
84
+ node.attributes.except(*reserved).map { |name, attr| %( #{name}="#{escape_attr(attr.value)}") }.join
73
85
  end
74
86
 
75
87
  # Author style merged after layout (a duplicated style attribute would be
@@ -84,7 +96,7 @@ module ActiveMail
84
96
 
85
97
  sig { params(node: Nokogiri::XML::Node, klass: String).returns(T::Boolean) }
86
98
  def class?(node, klass)
87
- !((node.attr('class') || '') =~ /(^|\s)#{Regexp.escape(klass)}($|\s)/).nil?
99
+ node['class'].to_s.split.include?(klass)
88
100
  end
89
101
 
90
102
  sig { params(node: Nokogiri::XML::Node, extra_classes: T.nilable(String)).returns(String) }
@@ -94,14 +106,6 @@ module ActiveMail
94
106
  (existing + to_add).uniq.join(' ')
95
107
  end
96
108
 
97
- sig { params(node: Nokogiri::XML::Node, extra_classes: T.nilable(String)).returns(String) }
98
- def combine_attributes(node, extra_classes = nil)
99
- classes = combine_classes(node, extra_classes)
100
- # Escape like the other attribute paths: a literal " in the class would
101
- # otherwise reopen the attribute.
102
- [pass_through_attributes(node), %(class="#{escape_attr(classes)}")].join
103
- end
104
-
105
109
  sig { params(node: Nokogiri::XML::Node).returns(String) }
106
110
  def link_attributes(node)
107
111
  target = node.attributes['target']&.value
@@ -112,24 +116,16 @@ module ActiveMail
112
116
  ].join
113
117
  end
114
118
 
119
+ sig { params(node: Nokogiri::XML::Node, inner: String, before: String, after: String).returns(String) }
120
+ def anchor(node, inner, before: '', after: '')
121
+ %(<a#{before} href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)}#{after}>#{inner}</a>)
122
+ end
123
+
115
124
  sig { params(node: Nokogiri::XML::Node, target: T.nilable(String)).returns(T.nilable(String)) }
116
125
  def resolve_rel(node, target)
117
126
  rel = node.attributes['rel']&.value
118
127
  rel = nil if rel&.strip&.empty?
119
- rel || (ActiveMail.configuration.blank_link_rel if target == '_blank')
120
- end
121
-
122
- # Outlook-safe nested-table structure kept in one place for <button> and <cta>.
123
- sig do
124
- params(outer_classes: String, inner: String, cell_style: String, outer_extra: String).returns(String)
125
- end
126
- def bulletproof_button_table(outer_classes:, inner:, cell_style: '', outer_extra: '')
127
- cell = cell_style.empty? ? '<td>' : %(<td style="#{cell_style}">)
128
- [
129
- %(<table class="#{outer_classes}" #{TABLE_RESET}><tbody><tr><td>),
130
- %(<table #{TABLE_RESET}><tbody><tr>#{cell}#{inner}</td></tr></tbody></table>),
131
- %(</td>#{outer_extra}</tr></tbody></table>)
132
- ].join
128
+ rel || (core.config.blank_link_rel if target == '_blank')
133
129
  end
134
130
 
135
131
  sig { returns(Integer) }
@@ -1,18 +1,25 @@
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 BlockGrid < Base
8
+ class BlockGrid < 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
  up = positive_int(node.attr('up'))
14
- classes = combine_classes(node, ['block-grid', up && "up-#{up}"].compact.join(' '))
15
- %(<table class="#{classes}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>#{inner}</tr></tbody></table>)
14
+ classes = ['block-grid', up && "up-#{up}"].compact.join(' ')
15
+ %(<table#{root_attributes(node, classes: classes, layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>)
16
+ end
17
+
18
+ private
19
+
20
+ sig { override.returns(T::Array[String]) }
21
+ def consumed_attributes
22
+ [*super, 'up']
16
23
  end
17
24
  end
18
25
  end
@@ -1,39 +1,16 @@
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
- class Button < Base
8
+ class Button < ButtonRenderer
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
- expand = class?(node, 'expand')
14
- inner = anchor(node, inner, expand) if node.attr('href')
15
- inner = "<center>#{inner}</center>" if expand
16
-
17
- expander = expand ? '<td class="expander"></td>' : ''
18
- # CSS-driven by design: colors come from .button rules, not inline tokens —
19
- # a distinct robustness model from <cta>, which inlines its palette.
20
- bulletproof_button_table(
21
- outer_classes: combine_classes(node, 'button'),
22
- inner: inner,
23
- outer_extra: expander
24
- )
25
- end
26
-
27
- private
28
-
29
- sig { params(node: Nokogiri::XML::Node, inner: String, expand: T::Boolean).returns(String) }
30
- def anchor(node, inner, expand)
31
- links = link_attributes(node)
32
- extra = expand ? ' align="center" class="float-center"' : ''
33
- # Padding on the <a> makes the whole button a clickable target.
34
- link_style = "display:inline-block;text-decoration:none;#{BUTTON_PADDING}"
35
- attrs = %(#{pass_through_attributes(node)}href="#{escape_attr(node.attr('href'))}"#{links}#{extra})
36
- %(<a #{attrs}#{style_attribute(node, link_style)}>#{inner}</a>)
11
+ sig { override.returns(String) }
12
+ def root_class
13
+ 'button'
37
14
  end
38
15
  end
39
16
  end
@@ -0,0 +1,69 @@
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
+ MSO_CELL_PADDING = 'mso-padding-alt:12px 24px;'
18
+ LINE_HEIGHT = '1.25'
19
+
20
+ sig { abstract.returns(String) }
21
+ def root_class; end
22
+
23
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
24
+ def transform(node, inner)
25
+ style = core.config.tokens.button_style(class?(node, 'secondary') ? :secondary : :primary)
26
+ expand = class?(node, 'expand')
27
+ width = expand ? ' style="width:100%;"' : ''
28
+ [
29
+ %(<table#{class_attribute(node, root_class)} #{TABLE_RESET}#{width}><tbody><tr><td>),
30
+ %(<table #{TABLE_RESET}#{width}><tbody><tr><td style="#{cell_style(style, expand)}">),
31
+ control(node, inner, style, expand),
32
+ '</td></tr></tbody></table></td></tr></tbody></table>'
33
+ ].join
34
+ end
35
+
36
+ private
37
+
38
+ sig { override.returns(T::Array[String]) }
39
+ def consumed_attributes
40
+ LINK_ATTRIBUTES
41
+ end
42
+
43
+ # Padding on the control makes the whole button a clickable target; Outlook Word
44
+ # ignores it there, so the cell repeats it as `mso-padding-alt`.
45
+ sig { params(node: Nokogiri::XML::Node, inner: String, style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
46
+ def control(node, inner, style, expand)
47
+ css = control_style(style, expand)
48
+ extra = passthrough_attributes(node)
49
+ return %(<span#{extra}#{style_attribute(node, css)}>#{inner}</span>) unless node.attr('href')
50
+
51
+ anchor(node, inner, before: extra, after: style_attribute(node, css))
52
+ end
53
+
54
+ # Fill, corners and outline live on the cell only: repeating them on the control would double them.
55
+ sig { params(style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
56
+ def cell_style(style, expand)
57
+ border = style.border ? "border:1px solid #{style.border};" : ''
58
+ "background:#{style.background};border-radius:#{style.radius};#{border}#{MSO_CELL_PADDING}#{'text-align:center;' if expand}"
59
+ end
60
+
61
+ sig { params(style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
62
+ def control_style(style, expand)
63
+ "display:#{expand ? 'block' : 'inline-block'};text-decoration:none;#{CONTROL_PADDING}" \
64
+ "font-family:#{escape_attr(core.config.tokens.font!(:body))};line-height:#{LINE_HEIGHT};" \
65
+ "color:#{style.color};font-weight:bold;"
66
+ end
67
+ end
68
+ end
69
+ 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,16 @@ 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|
15
- child['align'] = 'center'
16
- child['class'] = combine_classes(child, 'float-center')
17
- 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
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
13
+ %(<center#{root_attributes(node, classes: 'float-center')} align="center">#{inner}</center>)
14
+ end
15
+
16
+ private
17
+
18
+ sig { override.returns(T::Array[String]) }
19
+ def consumed_attributes
20
+ ['align']
21
21
  end
22
22
  end
23
23
  end
@@ -11,46 +11,89 @@ module ActiveMail
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
13
  small, large = column_sizes(node)
14
- width_px = ghost_width(large)
14
+ width_px = ghost_width(node, large)
15
15
 
16
16
  [
17
- %(<!--[if mso | IE]><td width="#{width_px}" valign="top"><![endif]-->),
17
+ %(<!--[if mso]><td width="#{width_px}" valign="top"><table #{TABLE_RESET} width="#{width_px}"><tr><![endif]-->),
18
18
  column_markup(node, inner, column_classes(node, small, large), width_px, expander(node, large)),
19
- '<!--[if mso | IE]></td><![endif]-->'
19
+ '<!--[if mso]></tr></table></td><![endif]-->'
20
20
  ].join
21
21
  end
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
48
67
 
49
- # Clamp: an oversized `large` would push the MSO ghost cell past the
50
- # container and force a wrap in Outlook Word.
51
- sig { params(large: Integer).returns(Integer) }
52
- def ghost_width(large)
53
- [((large.to_f / column_count) * container_width).round, container_width].min
68
+ # Columns are transformed leaves first, so the enclosing <columns> are still raw nodes whose
69
+ # sizes and positions are readable. A nested row shares the content box of its parent column.
70
+ sig { params(node: Nokogiri::XML::Node, large: Integer).returns(Integer) }
71
+ def ghost_width(node, large)
72
+ available = node.ancestors('columns').reverse.reduce(Rational(container_width)) do |width, ancestor|
73
+ share_of(width, column_sizes(ancestor).last) - horizontal_padding(ancestor)
74
+ end
75
+ share_of(available, large).round
76
+ end
77
+
78
+ # Clamped: an oversized `large` would push the MSO ghost cell past its parent and force a wrap in Outlook Word.
79
+ sig { params(width: Rational, share: Integer).returns(Rational) }
80
+ def share_of(width, share)
81
+ width * Rational([share, column_count].min, column_count)
82
+ end
83
+
84
+ # Mirrors _grid.scss, whose padding is inlined on each column.
85
+ sig { params(node: Nokogiri::XML::Node).returns(Rational) }
86
+ def horizontal_padding(node)
87
+ return Rational(0) if node.ancestors('row').any? { |row| class?(row, 'collapse') }
88
+
89
+ siblings = column_siblings(node)
90
+ outer_side = node.ancestors('columns').empty? ? spacing(:container_gutter) : Rational(0)
91
+ [siblings.first, siblings.last].sum(Rational(0)) { |edge| edge == node ? outer_side : spacing(:sm) }
92
+ end
93
+
94
+ sig { params(name: Symbol).returns(Rational) }
95
+ def spacing(name)
96
+ Rational(core.config.tokens.spacing!(name).delete_suffix('px'))
54
97
  end
55
98
 
56
99
  sig { params(node: Nokogiri::XML::Node, inner: String, classes: String, width_px: Integer, expander: String).returns(String) }
@@ -61,7 +104,7 @@ module ActiveMail
61
104
  # Neutralize the client default th rendering (bold, centered).
62
105
  content_style = 'font-weight:normal;text-align:left;'
63
106
  [
64
- %(<#{::ActiveMail::Core::INTERIM_TH_TAG} class="#{classes}"#{style_attribute(node, style)} #{pass_through_attributes(node)}>),
107
+ %(<#{::ActiveMail::Core::INTERIM_TH_TAG}#{root_attributes(node, classes: classes, layout_style: style)}>),
65
108
  %(<table #{TABLE_RESET} style="width:100%;"><tbody><tr>),
66
109
  %(<th style="#{content_style}">#{inner}</th>#{expander}),
67
110
  %(</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
- %(<!--[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;")}>),
17
+ %(<!--[if mso]><table #{TABLE_RESET} align="center" width="#{width}"><tr><td><![endif]-->),
18
+ %(<table#{attributes} #{TABLE_RESET} align="center">),
19
19
  %(<tbody><tr><td>#{inner}</td></tr></tbody></table>),
20
- '<!--[if mso | IE]></td></tr></table><![endif]-->'
20
+ '<!--[if mso]></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