activemail 1.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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +60 -0
  3. data/LICENSE.txt +23 -0
  4. data/README.md +375 -0
  5. data/app/assets/stylesheets/active_mail/_active_mail_tokens.scss.erb +3 -0
  6. data/app/assets/stylesheets/active_mail/_components.scss +58 -0
  7. data/app/assets/stylesheets/active_mail/_dark.scss +123 -0
  8. data/app/assets/stylesheets/active_mail/_grid.scss +49 -0
  9. data/app/assets/stylesheets/active_mail/_settings.scss +25 -0
  10. data/app/assets/stylesheets/active_mail/_utilities.scss +47 -0
  11. data/app/assets/stylesheets/active_mail/active_mail.scss +44 -0
  12. data/app/helpers/active_mail/styles_helper.rb +29 -0
  13. data/app/views/layouts/active_mail/_footer.html.inky-erb +10 -0
  14. data/app/views/layouts/active_mail/_head.html.inky-erb +6 -0
  15. data/app/views/layouts/active_mail/mailer.html.inky-erb +35 -0
  16. data/lib/active_mail/components/base.rb +119 -0
  17. data/lib/active_mail/components/block_grid.rb +19 -0
  18. data/lib/active_mail/components/button.rb +39 -0
  19. data/lib/active_mail/components/callout.rb +23 -0
  20. data/lib/active_mail/components/center.rb +24 -0
  21. data/lib/active_mail/components/columns.rb +72 -0
  22. data/lib/active_mail/components/container.rb +25 -0
  23. data/lib/active_mail/components/cta.rb +36 -0
  24. data/lib/active_mail/components/h_line.rb +19 -0
  25. data/lib/active_mail/components/info_box.rb +32 -0
  26. data/lib/active_mail/components/inky.rb +18 -0
  27. data/lib/active_mail/components/menu.rb +22 -0
  28. data/lib/active_mail/components/menu_item.rb +21 -0
  29. data/lib/active_mail/components/row.rb +18 -0
  30. data/lib/active_mail/components/spacer.rb +45 -0
  31. data/lib/active_mail/components/wrapper.rb +21 -0
  32. data/lib/active_mail/configuration.rb +229 -0
  33. data/lib/active_mail/inliner/base.rb +29 -0
  34. data/lib/active_mail/inliner/interceptor.rb +51 -0
  35. data/lib/active_mail/inliner/null.rb +23 -0
  36. data/lib/active_mail/inliner/premailer.rb +22 -0
  37. data/lib/active_mail/inliner/roadie.rb +30 -0
  38. data/lib/active_mail/libxml.rb +8 -0
  39. data/lib/active_mail/parse_error_reporter.rb +47 -0
  40. data/lib/active_mail/quality/configuration.rb +56 -0
  41. data/lib/active_mail/quality/guard.rb +131 -0
  42. data/lib/active_mail/quality/minitest.rb +64 -0
  43. data/lib/active_mail/quality/preview_renderer.rb +70 -0
  44. data/lib/active_mail/quality/render_all.rb +90 -0
  45. data/lib/active_mail/quality/rspec.rb +65 -0
  46. data/lib/active_mail/quality.rb +38 -0
  47. data/lib/active_mail/rails/compiled_stylesheet.rb +62 -0
  48. data/lib/active_mail/rails/engine.rb +36 -0
  49. data/lib/active_mail/rails/template_handler.rb +62 -0
  50. data/lib/active_mail/tokens.rb +139 -0
  51. data/lib/active_mail/version.rb +6 -0
  52. data/lib/active_mail.rb +161 -0
  53. data/lib/activemail.rb +5 -0
  54. data/lib/generators/active_mail/component_generator.rb +31 -0
  55. data/lib/generators/active_mail/install_generator.rb +59 -0
  56. data/lib/generators/active_mail/styles_generator.rb +30 -0
  57. data/lib/generators/active_mail/templates/component.rb.tt +13 -0
  58. data/lib/generators/active_mail/templates/initializer.rb +32 -0
  59. data/lib/generators/active_mail/templates/mailer_layout.html.inky-erb +30 -0
  60. data/lib/generators/active_mail/templates/mailer_layout.html.inky-haml +17 -0
  61. data/lib/generators/active_mail/templates/mailer_layout.html.inky-slim +16 -0
  62. data/lib/generators/active_mail/views_generator.rb +16 -0
  63. data/lib/tasks/active_mail.rake +36 -0
  64. metadata +151 -0
@@ -0,0 +1,49 @@
1
+ // Gutters are unconditional padding (inlined by the CSS inliner): the gem emits
2
+ // none, and they must not depend on media queries (stripped by Gmail mobile).
3
+
4
+ .container {
5
+ width: 100%;
6
+ max-width: $am-container-width;
7
+ margin: 0 auto;
8
+ }
9
+
10
+ .body {
11
+ width: 100%;
12
+ }
13
+
14
+ .row {
15
+ width: 100%;
16
+ }
17
+
18
+ // Gutters: half on each side, outer edges collapsed via first/last so content
19
+ // stays flush with the container.
20
+ // box-sizing keeps the gutter inside the inline max-width — without it a 300px
21
+ // 2-col cell + 8px padding becomes 316px and the row overflows ~616px, wrapping
22
+ // in clients that honor inline padding.
23
+ .columns {
24
+ box-sizing: border-box;
25
+ padding-left: $am-gutter;
26
+ padding-right: $am-gutter;
27
+ }
28
+
29
+ .columns.first {
30
+ padding-left: 0;
31
+ }
32
+
33
+ .columns.last {
34
+ padding-right: 0;
35
+ }
36
+
37
+ // A single full-width column carries no side gutter.
38
+ .columns.first.last {
39
+ padding-left: 0;
40
+ padding-right: 0;
41
+ }
42
+
43
+ // Collapse removes gutters on demand (e.g. edge-to-edge images). Columns are not
44
+ // direct children of .row — the real markup nests them as <th class="columns">
45
+ // under tbody > tr — so this must be a descendant selector, not a child one.
46
+ .row.collapse .columns {
47
+ padding-left: 0;
48
+ padding-right: 0;
49
+ }
@@ -0,0 +1,25 @@
1
+ // Semantic aliases mapped from the $am-* token vars. All !default so a host
2
+ // app can pre-declare any value before importing the framework.
3
+ @import "active_mail/active_mail_tokens";
4
+
5
+ // Typography
6
+ $am-font-family: $am-font-body !default;
7
+ $am-heading-font-family: $am-font-heading !default;
8
+
9
+ // Palette
10
+ $am-text: $am-color-text !default;
11
+ $am-background: $am-color-background !default;
12
+ $am-muted: $am-color-muted !default;
13
+ $am-border: $am-color-border !default;
14
+
15
+ // Buttons
16
+ $am-button-primary-bg: $am-color-primary !default;
17
+ $am-button-secondary-bg: $am-color-secondary !default;
18
+ $am-button-color: $am-color-button-text !default;
19
+ $am-button-radius: 4px !default;
20
+
21
+ // Grid — the gem emits no gutters; we provide them via column padding.
22
+ // Width follows config.container_width via the token bridge, so the SCSS grid
23
+ // stays aligned with the transpiled markup (ghost tables, column max-width).
24
+ $am-container-width: $am-grid-container-width !default;
25
+ $am-gutter: $am-spacing-sm !default;
@@ -0,0 +1,47 @@
1
+ // Alignment and visibility utilities.
2
+
3
+ .text-center {
4
+ text-align: center;
5
+ }
6
+
7
+ .text-right {
8
+ text-align: right;
9
+ }
10
+
11
+ .text-left {
12
+ text-align: left;
13
+ }
14
+
15
+ .text-muted {
16
+ color: $am-muted;
17
+ }
18
+
19
+ .float-center {
20
+ margin: 0 auto;
21
+ float: none;
22
+ text-align: center;
23
+ }
24
+
25
+ // Shown only on large screens. Hidden by default (mobile-first); the media
26
+ // query below reveals it as an enhancement.
27
+ .show-for-large {
28
+ display: none;
29
+ font-size: 0;
30
+ max-height: 0;
31
+ line-height: 0;
32
+ overflow: hidden;
33
+ }
34
+
35
+ @media only screen and (min-width: 596px) {
36
+ .show-for-large {
37
+ display: block !important;
38
+ font-size: inherit;
39
+ max-height: none;
40
+ line-height: inherit;
41
+ overflow: visible;
42
+ }
43
+
44
+ .hide-for-large {
45
+ display: none !important;
46
+ }
47
+ }
@@ -0,0 +1,44 @@
1
+ // ActiveMail email framework entry point. Critical styles are inlined by the
2
+ // configured CSS inliner (Gmail mobile strips <style>); only media-query and
3
+ // dark-mode enhancements stay in the <head>.
4
+ @import "active_mail/settings";
5
+ @import "active_mail/grid";
6
+ @import "active_mail/components";
7
+ @import "active_mail/utilities";
8
+ @import "active_mail/dark";
9
+
10
+ body,
11
+ table,
12
+ td,
13
+ p,
14
+ li,
15
+ h1,
16
+ h2,
17
+ h3,
18
+ h4,
19
+ h5,
20
+ h6,
21
+ a {
22
+ font-family: $am-font-family;
23
+ }
24
+
25
+ h1,
26
+ h2,
27
+ h3,
28
+ h4,
29
+ h5,
30
+ h6 {
31
+ font-family: $am-heading-font-family;
32
+ }
33
+
34
+ body {
35
+ margin: 0;
36
+ padding: 0;
37
+ background-color: $am-background;
38
+ color: $am-text;
39
+ }
40
+
41
+ img {
42
+ border: 0;
43
+ max-width: 100%;
44
+ }
@@ -0,0 +1,29 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ 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
+ module StylesHelper
8
+ FRAMEWORK_STYLESHEET = 'active_mail/active_mail.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 active_mail_inline_styles
13
+ css = active_mail_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
19
+
20
+ content_tag(:style, css.html_safe, type: 'text/css')
21
+ end
22
+
23
+ private
24
+
25
+ def active_mail_compiled_css
26
+ ActiveMail::CompiledStylesheet.read(FRAMEWORK_STYLESHEET)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,10 @@
1
+ <%# Neutral footer. Hosts override this partial to add address/social/unsubscribe. %>
2
+ <row>
3
+ <columns>
4
+ <spacer size="24"></spacer>
5
+ <h-line></h-line>
6
+ <p class="text-muted text-center">
7
+ <%= I18n.t("active_mail.footer.sent_with", default: "Sent with ActiveMail") %>
8
+ </p>
9
+ </columns>
10
+ </row>
@@ -0,0 +1,6 @@
1
+ <%# Header slot — hosts override this partial to add a logo/banner. %>
2
+ <row>
3
+ <columns>
4
+ <spacer size="16"></spacer>
5
+ </columns>
6
+ </row>
@@ -0,0 +1,35 @@
1
+ <!DOCTYPE html>
2
+ <html lang="<%= I18n.locale %>" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="x-apple-disable-message-reformatting">
7
+ <meta name="color-scheme" content="light dark">
8
+ <meta name="supported-color-schemes" content="light dark">
9
+ <!--[if mso]>
10
+ <noscript>
11
+ <xml>
12
+ <o:OfficeDocumentSettings>
13
+ <o:PixelsPerInch>96</o:PixelsPerInch>
14
+ </o:OfficeDocumentSettings>
15
+ </xml>
16
+ </noscript>
17
+ <![endif]-->
18
+ <%# Embedded so the Premailer adapter (HTML-string only) can inline the framework CSS. %>
19
+ <%= active_mail_inline_styles %>
20
+ <%# Fallback for when active_mail_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 "active_mail/active_mail", media: "all" %>
24
+ </head>
25
+
26
+ <body style="margin:0;padding:0;word-spacing:normal;">
27
+ <div role="article" aria-roledescription="email" lang="<%= I18n.locale %>" style="width:100%;">
28
+ <container>
29
+ <%= render "layouts/active_mail/head" %>
30
+ <%= yield %>
31
+ <%= render "layouts/active_mail/footer" %>
32
+ </container>
33
+ </div>
34
+ </body>
35
+ </html>
@@ -0,0 +1,119 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'cgi/escape'
5
+ require 'sorbet-runtime'
6
+
7
+ module ActiveMail
8
+ module Components
9
+ class << self
10
+ extend T::Sig
11
+
12
+ # A non-class (e.g. a tag-name string) would NoMethodError downstream; reject it here.
13
+ sig { params(tag: T.any(String, Symbol), klass: T.untyped).void }
14
+ def validate_component!(tag, klass)
15
+ return if klass.is_a?(Class) && klass < Components::Base
16
+
17
+ raise TypeError,
18
+ "component for tag '#{tag}' must be a class inheriting from ActiveMail::Components::Base, " \
19
+ "got #{klass.inspect}. Register components with " \
20
+ 'ActiveMail.configuration.register_component(tag, ComponentClass).'
21
+ end
22
+ end
23
+
24
+ # Public extension point: subclass, implement #transform(node, inner), then register.
25
+ class Base
26
+ extend T::Sig
27
+ extend T::Helpers
28
+
29
+ abstract!
30
+
31
+ IGNORED_ON_PASSTHROUGH = T.let(
32
+ %w[class id href size large no-expander small target up size-sm size-lg style].freeze,
33
+ T::Array[String]
34
+ )
35
+
36
+ # Layout tables: presentation role (a11y) and zeroed legacy spacing.
37
+ TABLE_RESET = 'role="presentation" border="0" cellpadding="0" cellspacing="0"'
38
+
39
+ sig { params(core: ::ActiveMail::Core).void }
40
+ def initialize(core)
41
+ @core = core
42
+ end
43
+
44
+ sig { abstract.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
45
+ def transform(node, inner); end
46
+
47
+ private
48
+
49
+ # Private: a component is a pure transformer; the engine handle stays internal.
50
+ sig { returns(::ActiveMail::Core) }
51
+ attr_reader :core
52
+
53
+ sig { params(value: T.untyped).returns(String) }
54
+ def escape_attr(value)
55
+ CGI.escapeHTML(value.to_s)
56
+ end
57
+
58
+ # Author attributes are untrusted: "abc".to_i would silently become 0.
59
+ sig { params(value: T.untyped).returns(T.nilable(Integer)) }
60
+ def positive_int(value)
61
+ # The RBI types Integer(exception: false) as non-nilable; it does return nil.
62
+ int = T.let(Integer(value.to_s, exception: false), T.nilable(Integer))
63
+ int if int&.positive?
64
+ end
65
+
66
+ sig { params(node: Nokogiri::XML::Node).returns(String) }
67
+ def pass_through_attributes(node)
68
+ node.attributes.reject { |name, _| IGNORED_ON_PASSTHROUGH.include?(name.downcase) }.map do |name, value|
69
+ %(#{name}="#{escape_attr(value)}" )
70
+ end.join
71
+ end
72
+
73
+ # Author style merged after layout (a duplicated style attribute would be
74
+ # dropped by parsers); author wins on overlapping properties.
75
+ sig { params(node: Nokogiri::XML::Node, layout: String).returns(String) }
76
+ def style_attribute(node, layout = '')
77
+ user = escape_attr(node.attr('style').to_s.strip)
78
+ user = "#{user};" unless user.empty? || user.end_with?(';')
79
+ value = "#{layout}#{user}"
80
+ value.empty? ? '' : %( style="#{value}")
81
+ end
82
+
83
+ sig { params(node: Nokogiri::XML::Node, klass: String).returns(T::Boolean) }
84
+ def class?(node, klass)
85
+ !((node.attr('class') || '') =~ /(^|\s)#{Regexp.escape(klass)}($|\s)/).nil?
86
+ end
87
+
88
+ sig { params(node: Nokogiri::XML::Node, extra_classes: T.nilable(String)).returns(String) }
89
+ def combine_classes(node, extra_classes)
90
+ existing = node['class'].to_s.split
91
+ to_add = extra_classes.to_s.split
92
+ (existing + to_add).uniq.join(' ')
93
+ end
94
+
95
+ sig { params(node: Nokogiri::XML::Node, extra_classes: T.nilable(String)).returns(String) }
96
+ def combine_attributes(node, extra_classes = nil)
97
+ classes = combine_classes(node, extra_classes)
98
+ # Escape like the other attribute paths: a literal " in the class would
99
+ # otherwise reopen the attribute.
100
+ [pass_through_attributes(node), %(class="#{escape_attr(classes)}")].join
101
+ end
102
+
103
+ sig { params(node: Nokogiri::XML::Node).returns(String) }
104
+ def target_attribute(node)
105
+ node.attributes['target'] ? %( target="#{escape_attr(node.attributes['target'])}") : ''
106
+ end
107
+
108
+ sig { returns(Integer) }
109
+ def column_count
110
+ core.column_count
111
+ end
112
+
113
+ sig { returns(Integer) }
114
+ def container_width
115
+ core.container_width
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,19 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class BlockGrid < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
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>)
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 Components
8
+ class Button < Base
9
+ extend T::Sig
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
+ classes = combine_classes(node, 'button')
18
+ expander = expand ? '<td class="expander"></td>' : ''
19
+ [
20
+ %(<table class="#{classes}" #{TABLE_RESET}><tbody><tr><td>),
21
+ %(<table #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>),
22
+ %(</td>#{expander}</tr></tbody></table>)
23
+ ].join
24
+ end
25
+
26
+ private
27
+
28
+ sig { params(node: Nokogiri::XML::Node, inner: String, expand: T::Boolean).returns(String) }
29
+ def anchor(node, inner, expand)
30
+ target = target_attribute(node)
31
+ extra = expand ? ' align="center" class="float-center"' : ''
32
+ # Padding on the <a> makes the whole button a clickable target.
33
+ link_style = 'display:inline-block;text-decoration:none;padding:12px 24px;'
34
+ attrs = %(#{pass_through_attributes(node)}href="#{escape_attr(node.attr('href'))}"#{target}#{extra})
35
+ %(<a #{attrs}#{style_attribute(node, link_style)}>#{inner}</a>)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -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
+ class Callout < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
13
+ classes = combine_classes(node, 'callout-inner')
14
+ attributes = pass_through_attributes(node)
15
+ [
16
+ %(<table #{attributes}class="callout" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>),
17
+ %(<th class="#{classes}">#{inner}</th><th class="expander"></th>),
18
+ '</tr></tbody></table>'
19
+ ].join
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class Center < Base
9
+ extend T::Sig
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
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,72 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class Columns < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
13
+ small, large = column_sizes(node)
14
+ width_px = ghost_width(large)
15
+
16
+ [
17
+ %(<!--[if mso | IE]><td width="#{width_px}" valign="top"><![endif]-->),
18
+ column_markup(node, inner, column_classes(node, small, large), width_px, expander(node, large)),
19
+ '<!--[if mso | IE]></td><![endif]-->'
20
+ ].join
21
+ end
22
+
23
+ private
24
+
25
+ sig { params(node: Nokogiri::XML::Node).returns([Integer, Integer]) }
26
+ def column_sizes(node)
27
+ small = positive_int(node.attr('small'))
28
+ 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]
31
+ end
32
+
33
+ sig { params(node: Nokogiri::XML::Node, small: Integer, large: Integer).returns(String) }
34
+ def column_classes(node, small, large)
35
+ classes = combine_classes(node, "small-#{small} large-#{large} columns")
36
+ classes += ' first' unless node.previous_element
37
+ classes += ' last' unless node.next_element
38
+ classes
39
+ end
40
+
41
+ sig { params(node: Nokogiri::XML::Node, large: Integer).returns(String) }
42
+ 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?
45
+
46
+ %(<th class="expander"></th>)
47
+ end
48
+
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
54
+ end
55
+
56
+ sig { params(node: Nokogiri::XML::Node, inner: String, classes: String, width_px: Integer, expander: String).returns(String) }
57
+ def column_markup(node, inner, classes, width_px, expander)
58
+ # display:inline-block + max-width gives natural stacking on small screens
59
+ # without a media query; MSO ghost cells restore the grid in Outlook Word.
60
+ style = "display:inline-block;vertical-align:top;width:100%;max-width:#{width_px}px;"
61
+ # Neutralize the client default th rendering (bold, centered).
62
+ content_style = 'font-weight:normal;text-align:left;'
63
+ [
64
+ %(<#{::ActiveMail::Core::INTERIM_TH_TAG} class="#{classes}"#{style_attribute(node, style)} #{pass_through_attributes(node)}>),
65
+ %(<table #{TABLE_RESET} style="width:100%;"><tbody><tr>),
66
+ %(<th style="#{content_style}">#{inner}</th>#{expander}),
67
+ %(</tr></tbody></table></#{::ActiveMail::Core::INTERIM_TH_TAG}>)
68
+ ].join
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,25 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class Container < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
13
+ attributes = combine_attributes(node, 'container')
14
+ width = container_width
15
+ # Outlook Word ignores max-width; the MSO ghost table pins the width.
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;")}>),
19
+ %(<tbody><tr><td>#{inner}</td></tr></tbody></table>),
20
+ '<!--[if mso | IE]></td></tr></table><![endif]-->'
21
+ ].join
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,36 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ # Colors read from tokens at transform time (runtime config), not load-time constants.
9
+ class Cta < Base
10
+ extend T::Sig
11
+
12
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
13
+ def transform(node, inner)
14
+ # A CTA without a link is an authoring bug — surface it at render time.
15
+ raise ArgumentError, '<cta> requires an href attribute' if node.attr('href').to_s.strip.empty?
16
+
17
+ background = ActiveMail.tokens.color!(class?(node, 'secondary') ? :secondary : :primary)
18
+ classes = combine_classes(node, 'cta')
19
+ anchor = %(<a href="#{escape_attr(node.attr('href'))}"#{target_attribute(node)} style="#{link_style(background)}">#{inner}</a>)
20
+ [
21
+ %(<table class="#{classes}" #{TABLE_RESET}><tbody><tr><td>),
22
+ %(<table #{TABLE_RESET}><tbody><tr><td style="background:#{background};border-radius:4px;">),
23
+ "#{anchor}</td></tr></tbody></table></td></tr></tbody></table>"
24
+ ].join
25
+ end
26
+
27
+ private
28
+
29
+ sig { params(background: String).returns(String) }
30
+ def link_style(background)
31
+ 'display:inline-block;text-decoration:none;padding:12px 24px;' \
32
+ "background:#{background};color:#{ActiveMail.tokens.color!(:button_text)};font-weight:bold;border-radius:4px;"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,19 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class HLine < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
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>)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ # Colors read from tokens at transform time (runtime config), not load-time constants.
9
+ class InfoBox < Base
10
+ extend T::Sig
11
+
12
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
13
+ def transform(node, inner)
14
+ classes = combine_classes(node, 'info-box')
15
+ [
16
+ %(<table class="#{classes}" #{TABLE_RESET} style="width:100%;"><tbody><tr>),
17
+ %(<td style="#{cell_style}">#{inner}</td>),
18
+ '</tr></tbody></table>'
19
+ ].join
20
+ end
21
+
22
+ private
23
+
24
+ sig { returns(String) }
25
+ def cell_style
26
+ tokens = ActiveMail.tokens
27
+ "background-color:#{tokens.color!(:background)};border-left:5px solid #{tokens.color!(:border)};" \
28
+ "color:#{tokens.color!(:text)};padding:#{tokens.spacing!(:md)};"
29
+ end
30
+ end
31
+ end
32
+ end