activemail 1.2.3 → 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 +55 -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 +17 -4
  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
@@ -1,3 +1,5 @@
1
+ @use "settings" as *;
2
+
1
3
  // Gutters are unconditional padding (inlined by the CSS inliner): the gem emits
2
4
  // none, and they must not depend on media queries (stripped by Gmail mobile).
3
5
 
@@ -1,5 +1,8 @@
1
- // All !default so a host app can pre-declare any value before importing the framework.
2
- @import "activemail/activemail_tokens";
1
+ // Sass only lets `with (...)` configure !default variables of forwarded modules:
2
+ // every configurable variable must live here or in the forwarded token bridge.
3
+ @forward "activemail_tokens";
4
+ @use "sass:color";
5
+ @use "activemail_tokens" as *;
3
6
 
4
7
  // Typography
5
8
  $am-font-family: $am-font-body !default;
@@ -11,19 +14,19 @@ $am-background: $am-color-background !default;
11
14
  $am-muted: $am-color-muted !default;
12
15
  $am-border: $am-color-border !default;
13
16
 
14
- // Buttons
17
+ // Buttons — a null border means no border at all.
15
18
  $am-button-primary-bg: $am-color-primary !default;
19
+ $am-button-primary-color: $am-color-primary-text !default;
20
+ $am-button-primary-border: $am-color-primary-border !default;
16
21
  $am-button-secondary-bg: $am-color-secondary !default;
17
- $am-button-color: $am-color-button-text !default;
18
- // Secondary falls back to filled-button defaults; override *-secondary-* for an outline variant.
19
- $am-button-secondary-color: $am-color-button-text !default;
20
- $am-button-secondary-border: transparent !default;
22
+ $am-button-secondary-color: $am-color-secondary-text !default;
23
+ $am-button-secondary-border: $am-color-secondary-border !default;
21
24
  $am-button-radius: $am-radius-button !default;
22
25
 
23
26
  // Info box
24
- $am-info-box-bg: $am-background !default;
25
- $am-info-box-border: $am-border !default;
26
- $am-info-box-text: $am-text !default;
27
+ $am-info-box-background: $am-color-info-box-background !default;
28
+ $am-info-box-border: $am-color-info-box-border !default;
29
+ $am-info-box-text: $am-color-info-box-text !default;
27
30
  $am-box-radius: $am-radius-box !default;
28
31
 
29
32
  // Grid — the gem emits no gutters; we provide them via column padding. Width
@@ -31,3 +34,12 @@ $am-box-radius: $am-radius-box !default;
31
34
  // aligned with the transpiled markup (ghost tables, column max-width).
32
35
  $am-container-width: $am-grid-container-width !default;
33
36
  $am-gutter: $am-spacing-sm !default;
37
+ $am-breakpoint: $am-container-width !default;
38
+
39
+ // Dark mode
40
+ $am-dark-bg: color.mix($am-color-text, $am-color-background, 96%) !default;
41
+ $am-dark-surface: color.mix($am-color-text, $am-color-background, 92%) !default;
42
+ $am-dark-panel: color.mix($am-color-text, $am-color-background, 82%) !default;
43
+ $am-dark-text: color.mix($am-color-background, $am-color-text, 88%) !default;
44
+ $am-dark-muted: $am-color-muted !default;
45
+ $am-dark-link: $am-button-primary-bg !default;
@@ -1,3 +1,5 @@
1
+ @use "settings" as *;
2
+
1
3
  // Alignment and visibility utilities.
2
4
 
3
5
  .text-center {
@@ -32,7 +34,7 @@
32
34
  overflow: hidden;
33
35
  }
34
36
 
35
- @media only screen and (min-width: 596px) {
37
+ @media only screen and (min-width: $am-breakpoint) {
36
38
  .show-for-large {
37
39
  display: block !important;
38
40
  font-size: inherit;
@@ -1,11 +1,12 @@
1
1
  // ActiveMail email framework entry point. Critical styles are inlined by the
2
2
  // configured CSS inliner (Gmail mobile strips <style>); only media-query and
3
3
  // dark-mode enhancements stay in the <head>.
4
- @import "activemail/settings";
5
- @import "activemail/grid";
6
- @import "activemail/components";
7
- @import "activemail/utilities";
8
- @import "activemail/dark";
4
+ @forward "settings";
5
+ @use "settings" as *;
6
+ @use "grid";
7
+ @use "components";
8
+ @use "utilities";
9
+ @use "dark";
9
10
 
10
11
  body,
11
12
  table,
@@ -1,29 +1,28 @@
1
1
  # typed: false
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'activemail/asset_pipeline'
5
+
4
6
  module ActiveMail
5
- # Embeds the compiled framework CSS as a <style> block so the Premailer adapter
6
- # (string-only) inlines it — it can't fetch the stylesheet_link_tag's asset URL.
7
+ # Embeds the compiled CSS as a <style> block: the inliners take an HTML string
8
+ # and cannot fetch the digest URL a stylesheet_link_tag would emit.
7
9
  module StylesHelper
8
- FRAMEWORK_STYLESHEET = 'activemail/activemail.css'
9
-
10
- # '' (not raise) when the asset can't be read — degrades to the link fallback,
11
- # but warns, since the email then ships unstyled.
12
- def activemail_inline_styles
13
- css = activemail_compiled_css
14
- if css.blank?
15
- ActiveMail.log_warning('[activemail] framework stylesheet could not be read from the asset pipeline; ' \
16
- 'email ships without inlined framework CSS')
17
- return ''.html_safe
18
- end
10
+ FRAMEWORK_STYLESHEET = 'activemail/activemail'
19
11
 
12
+ def activemail_inline_styles(*logical_paths)
13
+ paths = logical_paths.empty? ? [FRAMEWORK_STYLESHEET] : logical_paths
14
+ css = paths.map { |path| activemail_compiled_css(path.to_s) }.join("\n")
20
15
  content_tag(:style, css.html_safe, type: 'text/css')
21
16
  end
22
17
 
23
18
  private
24
19
 
25
- def activemail_compiled_css
26
- ActiveMail::CompiledStylesheet.read(FRAMEWORK_STYLESHEET)
20
+ def activemail_compiled_css(logical_path)
21
+ asset = logical_path.end_with?('.css') ? logical_path : "#{logical_path}.css"
22
+ css = ActiveMail::AssetPipeline.current.read(asset)
23
+ return css if css.present?
24
+
25
+ raise ActiveMail::StylesheetNotFound, "stylesheet #{asset.inspect} is missing or empty in the asset pipeline"
27
26
  end
28
27
  end
29
28
  end
@@ -15,12 +15,7 @@
15
15
  </xml>
16
16
  </noscript>
17
17
  <![endif]-->
18
- <%# Embedded so the Premailer adapter (HTML-string only) can inline the framework CSS. %>
19
18
  <%= activemail_inline_styles %>
20
- <%# Fallback for when activemail_inline_styles cannot read the compiled asset %>
21
- <%# (CDN-only host, compile=false without precompile); redundant otherwise, as the %>
22
- <%# embed's rules are idempotent once inlined. %>
23
- <%= stylesheet_link_tag "activemail/activemail", media: "all" %>
24
19
  </head>
25
20
 
26
21
  <body style="margin:0;padding:0;word-spacing:normal;">
@@ -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,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