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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -2
- data/README.md +141 -56
- data/UPGRADING.md +185 -0
- data/app/assets/stylesheets/activemail/_components.scss +33 -42
- data/app/assets/stylesheets/activemail/_dark.scss +44 -24
- data/app/assets/stylesheets/activemail/_grid.scss +40 -14
- data/app/assets/stylesheets/activemail/_settings.scss +23 -10
- data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
- data/app/assets/stylesheets/activemail/activemail.scss +6 -5
- data/app/helpers/active_mail/styles_helper.rb +14 -15
- data/app/views/layouts/activemail/_document_head.html.erb +18 -0
- data/app/views/layouts/activemail/mailer.html.inky-erb +1 -22
- data/lib/activemail/asset_pipeline/base.rb +47 -0
- data/lib/activemail/asset_pipeline/null.rb +19 -0
- data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
- data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
- data/lib/activemail/asset_pipeline.rb +35 -0
- data/lib/activemail/components/base.rb +35 -39
- data/lib/activemail/components/block_grid.rb +11 -4
- data/lib/activemail/components/button.rb +5 -28
- data/lib/activemail/components/button_renderer.rb +69 -0
- data/lib/activemail/components/callout.rb +4 -5
- data/lib/activemail/components/center.rb +10 -10
- data/lib/activemail/components/columns.rb +58 -15
- data/lib/activemail/components/container.rb +13 -6
- data/lib/activemail/components/cta.rb +9 -33
- data/lib/activemail/components/h_line.rb +3 -5
- data/lib/activemail/components/info_box.rb +6 -12
- data/lib/activemail/components/menu.rb +3 -4
- data/lib/activemail/components/menu_item.rb +10 -3
- data/lib/activemail/components/row.rb +3 -4
- data/lib/activemail/components/spacer.rb +16 -12
- data/lib/activemail/components/table_component.rb +23 -0
- data/lib/activemail/components/wrapper.rb +10 -4
- data/lib/activemail/configuration.rb +43 -57
- data/lib/activemail/errors.rb +15 -0
- data/lib/activemail/inliner/base.rb +2 -10
- data/lib/activemail/inliner/null.rb +0 -5
- data/lib/activemail/inliner/premailer/document.rb +100 -0
- data/lib/activemail/inliner/premailer.rb +3 -2
- data/lib/activemail/inliner/roadie.rb +1 -1
- data/lib/activemail/inliner.rb +33 -0
- data/lib/activemail/parse_error_reporter.rb +5 -4
- data/lib/activemail/pipeline.rb +26 -0
- data/lib/activemail/quality/builtin_message_rules.rb +58 -0
- data/lib/activemail/quality/builtin_rules.rb +165 -0
- data/lib/activemail/quality/configuration.rb +103 -8
- data/lib/activemail/quality/guard.rb +61 -83
- data/lib/activemail/quality/minitest.rb +56 -27
- data/lib/activemail/quality/output_dir.rb +52 -0
- data/lib/activemail/quality/output_filter.rb +24 -0
- data/lib/activemail/quality/preview_renderer.rb +46 -33
- data/lib/activemail/quality/render_all.rb +95 -39
- data/lib/activemail/quality/rspec.rb +14 -13
- data/lib/activemail/quality/rule.rb +47 -0
- data/lib/activemail/quality/rule_exemptions.rb +49 -0
- data/lib/activemail/rails/engine.rb +13 -13
- data/lib/activemail/rails/rendering.rb +51 -0
- data/lib/activemail/rails/template_handler.rb +3 -1
- data/lib/activemail/tokens/button_style.rb +1 -2
- data/lib/activemail/tokens/defaults.rb +78 -0
- data/lib/activemail/tokens/scss_serializer.rb +4 -4
- data/lib/activemail/tokens.rb +19 -58
- data/lib/activemail/version.rb +1 -1
- data/lib/activemail.rb +89 -61
- data/lib/generators/activemail/templates/component.rb.tt +2 -3
- data/lib/generators/activemail/templates/initializer.rb +0 -3
- data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -19
- data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -11
- data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -10
- data/lib/tasks/activemail.rake +4 -5
- metadata +22 -4
- data/lib/activemail/components/inky.rb +0 -18
- data/lib/activemail/inliner/interceptor.rb +0 -51
- 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
|
-
#
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 {
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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 || (
|
|
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 '
|
|
4
|
+
require_relative 'table_component'
|
|
5
5
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Components
|
|
8
|
-
class BlockGrid <
|
|
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 =
|
|
15
|
-
%(<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 '
|
|
4
|
+
require_relative 'button_renderer'
|
|
5
5
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Components
|
|
8
|
-
class Button <
|
|
8
|
+
class Button < ButtonRenderer
|
|
9
9
|
extend T::Sig
|
|
10
10
|
|
|
11
|
-
sig { override.
|
|
12
|
-
def
|
|
13
|
-
|
|
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 '
|
|
4
|
+
require_relative 'table_component'
|
|
5
5
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Components
|
|
8
|
-
class Callout <
|
|
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
|
|
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,
|
|
12
|
-
def transform(node,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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'
|
|
37
|
-
classes += ' last'
|
|
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
|
-
|
|
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
|
-
#
|
|
50
|
-
#
|
|
51
|
-
sig { params(large: Integer).returns(Integer) }
|
|
52
|
-
def ghost_width(large)
|
|
53
|
-
|
|
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}
|
|
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 '
|
|
4
|
+
require_relative 'table_component'
|
|
5
5
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Components
|
|
8
|
-
class Container <
|
|
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
|
|
18
|
-
%(<table
|
|
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
|
|
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
|