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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +23 -0
- data/README.md +375 -0
- data/app/assets/stylesheets/active_mail/_active_mail_tokens.scss.erb +3 -0
- data/app/assets/stylesheets/active_mail/_components.scss +58 -0
- data/app/assets/stylesheets/active_mail/_dark.scss +123 -0
- data/app/assets/stylesheets/active_mail/_grid.scss +49 -0
- data/app/assets/stylesheets/active_mail/_settings.scss +25 -0
- data/app/assets/stylesheets/active_mail/_utilities.scss +47 -0
- data/app/assets/stylesheets/active_mail/active_mail.scss +44 -0
- data/app/helpers/active_mail/styles_helper.rb +29 -0
- data/app/views/layouts/active_mail/_footer.html.inky-erb +10 -0
- data/app/views/layouts/active_mail/_head.html.inky-erb +6 -0
- data/app/views/layouts/active_mail/mailer.html.inky-erb +35 -0
- data/lib/active_mail/components/base.rb +119 -0
- data/lib/active_mail/components/block_grid.rb +19 -0
- data/lib/active_mail/components/button.rb +39 -0
- data/lib/active_mail/components/callout.rb +23 -0
- data/lib/active_mail/components/center.rb +24 -0
- data/lib/active_mail/components/columns.rb +72 -0
- data/lib/active_mail/components/container.rb +25 -0
- data/lib/active_mail/components/cta.rb +36 -0
- data/lib/active_mail/components/h_line.rb +19 -0
- data/lib/active_mail/components/info_box.rb +32 -0
- data/lib/active_mail/components/inky.rb +18 -0
- data/lib/active_mail/components/menu.rb +22 -0
- data/lib/active_mail/components/menu_item.rb +21 -0
- data/lib/active_mail/components/row.rb +18 -0
- data/lib/active_mail/components/spacer.rb +45 -0
- data/lib/active_mail/components/wrapper.rb +21 -0
- data/lib/active_mail/configuration.rb +229 -0
- data/lib/active_mail/inliner/base.rb +29 -0
- data/lib/active_mail/inliner/interceptor.rb +51 -0
- data/lib/active_mail/inliner/null.rb +23 -0
- data/lib/active_mail/inliner/premailer.rb +22 -0
- data/lib/active_mail/inliner/roadie.rb +30 -0
- data/lib/active_mail/libxml.rb +8 -0
- data/lib/active_mail/parse_error_reporter.rb +47 -0
- data/lib/active_mail/quality/configuration.rb +56 -0
- data/lib/active_mail/quality/guard.rb +131 -0
- data/lib/active_mail/quality/minitest.rb +64 -0
- data/lib/active_mail/quality/preview_renderer.rb +70 -0
- data/lib/active_mail/quality/render_all.rb +90 -0
- data/lib/active_mail/quality/rspec.rb +65 -0
- data/lib/active_mail/quality.rb +38 -0
- data/lib/active_mail/rails/compiled_stylesheet.rb +62 -0
- data/lib/active_mail/rails/engine.rb +36 -0
- data/lib/active_mail/rails/template_handler.rb +62 -0
- data/lib/active_mail/tokens.rb +139 -0
- data/lib/active_mail/version.rb +6 -0
- data/lib/active_mail.rb +161 -0
- data/lib/activemail.rb +5 -0
- data/lib/generators/active_mail/component_generator.rb +31 -0
- data/lib/generators/active_mail/install_generator.rb +59 -0
- data/lib/generators/active_mail/styles_generator.rb +30 -0
- data/lib/generators/active_mail/templates/component.rb.tt +13 -0
- data/lib/generators/active_mail/templates/initializer.rb +32 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-erb +30 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-haml +17 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-slim +16 -0
- data/lib/generators/active_mail/views_generator.rb +16 -0
- data/lib/tasks/active_mail.rake +36 -0
- 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,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> </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
|