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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -2
- data/README.md +105 -47
- data/UPGRADING.md +112 -0
- data/app/assets/stylesheets/activemail/_components.scss +33 -42
- data/app/assets/stylesheets/activemail/_dark.scss +31 -20
- data/app/assets/stylesheets/activemail/_grid.scss +2 -0
- data/app/assets/stylesheets/activemail/_settings.scss +22 -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/mailer.html.inky-erb +0 -5
- 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 +65 -0
- data/lib/activemail/components/callout.rb +4 -5
- data/lib/activemail/components/center.rb +5 -7
- data/lib/activemail/components/columns.rb +26 -7
- data/lib/activemail/components/container.rb +11 -4
- 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/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_rules.rb +154 -0
- data/lib/activemail/quality/configuration.rb +83 -3
- data/lib/activemail/quality/guard.rb +45 -87
- data/lib/activemail/quality/minitest.rb +41 -25
- data/lib/activemail/quality/output_dir.rb +52 -0
- data/lib/activemail/quality/preview_renderer.rb +46 -33
- data/lib/activemail/quality/render_all.rb +96 -40
- data/lib/activemail/quality/rspec.rb +4 -8
- data/lib/activemail/quality/rule.rb +47 -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 +77 -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 +0 -2
- data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
- data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
- data/lib/tasks/activemail.rake +4 -5
- metadata +17 -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
|
@@ -22,26 +22,45 @@ module ActiveMail
|
|
|
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
|
|
@@ -61,7 +80,7 @@ module ActiveMail
|
|
|
61
80
|
# Neutralize the client default th rendering (bold, centered).
|
|
62
81
|
content_style = 'font-weight:normal;text-align:left;'
|
|
63
82
|
[
|
|
64
|
-
%(<#{::ActiveMail::Core::INTERIM_TH_TAG}
|
|
83
|
+
%(<#{::ActiveMail::Core::INTERIM_TH_TAG}#{root_attributes(node, classes: classes, layout_style: style)}>),
|
|
65
84
|
%(<table #{TABLE_RESET} style="width:100%;"><tbody><tr>),
|
|
66
85
|
%(<th style="#{content_style}">#{inner}</th>#{expander}),
|
|
67
86
|
%(</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
17
|
%(<!--[if mso | IE]><table #{TABLE_RESET} align="center" width="#{width}"><tr><td><![endif]-->),
|
|
18
|
-
%(<table
|
|
18
|
+
%(<table#{attributes} #{TABLE_RESET} align="center">),
|
|
19
19
|
%(<tbody><tr><td>#{inner}</td></tr></tbody></table>),
|
|
20
20
|
'<!--[if mso | IE]></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
|
|
@@ -1,48 +1,24 @@
|
|
|
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
|
-
|
|
9
|
-
# Styles are inlined so the button survives clients that strip <style> (Gmail mobile…).
|
|
10
|
-
class Cta < Base
|
|
8
|
+
class Cta < ButtonRenderer
|
|
11
9
|
extend T::Sig
|
|
12
10
|
|
|
13
|
-
sig { override.
|
|
11
|
+
sig { override.returns(String) }
|
|
12
|
+
def root_class
|
|
13
|
+
'cta'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
14
17
|
def transform(node, inner)
|
|
15
18
|
# A CTA without a link is an authoring bug — surface it at render time.
|
|
16
19
|
raise ArgumentError, '<cta> requires an href attribute' if node.attr('href').to_s.strip.empty?
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
anchor = %(<a href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)} ) +
|
|
20
|
-
%(style="#{link_style(style)}">#{inner}</a>)
|
|
21
|
-
bulletproof_button_table(
|
|
22
|
-
outer_classes: combine_classes(node, 'cta'),
|
|
23
|
-
inner: anchor,
|
|
24
|
-
cell_style: cell_style(style)
|
|
25
|
-
)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
|
|
31
|
-
def cell_style(style)
|
|
32
|
-
"background:#{style.background};border-radius:#{style.radius};#{border_css(style)}"
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# No border here: it lives on the cell only — a repeated <a> border doubles the line.
|
|
36
|
-
sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
|
|
37
|
-
def link_style(style)
|
|
38
|
-
"display:inline-block;text-decoration:none;#{BUTTON_PADDING}" \
|
|
39
|
-
"background:#{style.background};color:#{style.color};font-weight:bold;" \
|
|
40
|
-
"border-radius:#{style.radius};"
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
|
|
44
|
-
def border_css(style)
|
|
45
|
-
style.border ? "border:1px solid #{style.border};" : ''
|
|
21
|
+
super
|
|
46
22
|
end
|
|
47
23
|
end
|
|
48
24
|
end
|
|
@@ -1,18 +1,16 @@
|
|
|
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 HLine <
|
|
8
|
+
class HLine < 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
|
-
|
|
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>)
|
|
13
|
+
%(<table#{root_attributes(node, classes: 'h-line', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr><th> </th></tr></tbody></table>)
|
|
16
14
|
end
|
|
17
15
|
end
|
|
18
16
|
end
|
|
@@ -1,19 +1,18 @@
|
|
|
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
8
|
# Colors read from tokens at transform time (runtime config), not load-time constants.
|
|
9
|
-
class InfoBox <
|
|
9
|
+
class InfoBox < TableComponent
|
|
10
10
|
extend T::Sig
|
|
11
11
|
|
|
12
12
|
sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
13
13
|
def transform(node, inner)
|
|
14
|
-
classes = combine_classes(node, 'info-box')
|
|
15
14
|
[
|
|
16
|
-
%(<table
|
|
15
|
+
%(<table#{root_attributes(node, classes: 'info-box', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>),
|
|
17
16
|
%(<td style="#{cell_style}">#{inner}</td>),
|
|
18
17
|
'</tr></tbody></table>'
|
|
19
18
|
].join
|
|
@@ -21,16 +20,11 @@ module ActiveMail
|
|
|
21
20
|
|
|
22
21
|
private
|
|
23
22
|
|
|
24
|
-
# Box-scoped tokens (fall back to page-level ones) so a host can give the box
|
|
25
|
-
# a distinct surface without colliding with :background/:border/:text.
|
|
26
23
|
sig { returns(String) }
|
|
27
24
|
def cell_style
|
|
28
|
-
tokens =
|
|
29
|
-
background
|
|
30
|
-
|
|
31
|
-
text = tokens.color(:info_box_text) || tokens.color!(:text)
|
|
32
|
-
"background-color:#{background};border-left:5px solid #{border};border-radius:#{tokens.radius!(:box)};" \
|
|
33
|
-
"color:#{text};padding:#{tokens.spacing!(:md)};"
|
|
25
|
+
tokens = core.config.tokens
|
|
26
|
+
"background-color:#{tokens.color!(:info_box_background)};border-left:5px solid #{tokens.color!(:info_box_border)};" \
|
|
27
|
+
"border-radius:#{tokens.radius!(:box)};color:#{tokens.color!(:info_box_text)};padding:#{tokens.spacing!(:md)};"
|
|
34
28
|
end
|
|
35
29
|
end
|
|
36
30
|
end
|
|
@@ -1,18 +1,17 @@
|
|
|
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 Menu <
|
|
8
|
+
class Menu < 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, 'menu')
|
|
14
13
|
[
|
|
15
|
-
%(<table
|
|
14
|
+
%(<table#{root_attributes(node, classes: 'menu')} #{TABLE_RESET}><tbody><tr><td>),
|
|
16
15
|
%(<table #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>),
|
|
17
16
|
'</td></tr></tbody></table>'
|
|
18
17
|
].join
|
|
@@ -10,11 +10,18 @@ module ActiveMail
|
|
|
10
10
|
|
|
11
11
|
sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
12
12
|
def transform(node, inner)
|
|
13
|
-
attributes =
|
|
13
|
+
attributes = root_attributes(node, classes: 'menu-item')
|
|
14
14
|
# No href → a non-link item, not a broken <a href="">; mirrors <button>.
|
|
15
|
-
content = node.attr('href') ?
|
|
15
|
+
content = node.attr('href') ? anchor(node, inner) : inner
|
|
16
16
|
th = ::ActiveMail::Core::INTERIM_TH_TAG
|
|
17
|
-
%(<#{th}
|
|
17
|
+
%(<#{th}#{attributes}>#{content}</#{th}>)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
sig { override.returns(T::Array[String]) }
|
|
23
|
+
def consumed_attributes
|
|
24
|
+
LINK_ATTRIBUTES
|
|
18
25
|
end
|
|
19
26
|
end
|
|
20
27
|
end
|
|
@@ -1,17 +1,16 @@
|
|
|
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 Row <
|
|
8
|
+
class Row < 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
|
-
|
|
14
|
-
%(<table #{attributes} #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>#{inner}</tr></tbody></table>)
|
|
13
|
+
%(<table#{root_attributes(node, classes: 'row', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>)
|
|
15
14
|
end
|
|
16
15
|
end
|
|
17
16
|
end
|
|
@@ -1,41 +1,45 @@
|
|
|
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 Spacer <
|
|
8
|
+
class Spacer < 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
|
-
classes = combine_classes(node, 'spacer')
|
|
14
13
|
size_sm = node.attr('size-sm')
|
|
15
14
|
size_lg = node.attr('size-lg')
|
|
16
15
|
|
|
17
|
-
return build_table(node,
|
|
16
|
+
return build_table(node, 'spacer', size_for(node.attr('size')), skip: []) unless size_sm || size_lg
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
tables = []
|
|
19
|
+
tables << ['spacer hide-for-large', size_for(size_sm)] if size_sm
|
|
20
|
+
tables << ['spacer show-for-large', size_for(size_lg)] if size_lg
|
|
21
|
+
tables.each_with_index.map { |(classes, size), index| build_table(node, classes, size, skip: index.zero? ? [] : %w[id]) }.join
|
|
23
22
|
end
|
|
24
23
|
|
|
25
24
|
private
|
|
26
25
|
|
|
26
|
+
sig { override.returns(T::Array[String]) }
|
|
27
|
+
def consumed_attributes
|
|
28
|
+
[*super, 'size', 'size-sm', 'size-lg']
|
|
29
|
+
end
|
|
30
|
+
|
|
27
31
|
sig { params(value: T.untyped).returns(Integer) }
|
|
28
32
|
def size_for(value)
|
|
29
33
|
positive_int(value) || 16
|
|
30
34
|
end
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
# A repeated id would be invalid HTML: only the first table carries it.
|
|
37
|
+
sig { params(node: Nokogiri::XML::Node, classes: String, size: Integer, skip: T::Array[String]).returns(String) }
|
|
38
|
+
def build_table(node, classes, size, skip:)
|
|
35
39
|
# mso-line-height-rule:exactly keeps Outlook from inflating the spacer.
|
|
36
40
|
style = "font-size:#{size}px;line-height:#{size}px;mso-line-height-rule:exactly;"
|
|
37
41
|
[
|
|
38
|
-
%(<table
|
|
42
|
+
%(<table#{root_attributes(node, classes: classes, layout_style: 'width:100%;', skip: skip)} #{TABLE_RESET}><tbody><tr>),
|
|
39
43
|
%(<td height="#{size}" style="#{style}"> </td>),
|
|
40
44
|
'</tr></tbody></table>'
|
|
41
45
|
].join
|
|
@@ -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
|
+
# Root element is a layout table: its reset attributes are fixed, not author-overridable.
|
|
9
|
+
class TableComponent < Base
|
|
10
|
+
extend T::Sig
|
|
11
|
+
extend T::Helpers
|
|
12
|
+
|
|
13
|
+
abstract!
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
sig { override.overridable.returns(T::Array[String]) }
|
|
18
|
+
def consumed_attributes
|
|
19
|
+
TABLE_RESET_ATTRIBUTES
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -1,21 +1,27 @@
|
|
|
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 Wrapper <
|
|
8
|
+
class Wrapper < 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, 'wrapper')
|
|
14
13
|
[
|
|
15
|
-
%(<table
|
|
14
|
+
%(<table#{root_attributes(node, classes: 'wrapper', layout_style: 'width:100%;')} #{TABLE_RESET} align="center"><tbody><tr>),
|
|
16
15
|
%(<td class="wrapper-inner">#{inner}</td></tr></tbody></table>)
|
|
17
16
|
].join
|
|
18
17
|
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
sig { override.returns(T::Array[String]) }
|
|
22
|
+
def consumed_attributes
|
|
23
|
+
[*super, 'align']
|
|
24
|
+
end
|
|
19
25
|
end
|
|
20
26
|
end
|
|
21
27
|
end
|
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
require 'sorbet-runtime'
|
|
5
5
|
|
|
6
6
|
require_relative 'tokens'
|
|
7
|
-
require_relative 'inliner
|
|
8
|
-
require_relative 'inliner/premailer'
|
|
9
|
-
require_relative 'inliner/roadie'
|
|
10
|
-
require_relative 'inliner/null'
|
|
7
|
+
require_relative 'inliner'
|
|
11
8
|
|
|
12
9
|
module ActiveMail
|
|
13
10
|
extend T::Sig
|
|
@@ -36,17 +33,17 @@ module ActiveMail
|
|
|
36
33
|
block.call(configuration)
|
|
37
34
|
end
|
|
38
35
|
|
|
39
|
-
#
|
|
36
|
+
# <html[\s>] so a <htmlfoo> custom tag or "<html" in text never flips the parse mode.
|
|
40
37
|
sig { params(html: String).returns(T::Boolean) }
|
|
41
38
|
def self.full_document?(html)
|
|
42
|
-
html.match?(/<html/i)
|
|
39
|
+
html.match?(/<html[\s>]/i)
|
|
43
40
|
end
|
|
44
41
|
|
|
45
|
-
# The complete SCSS variable bridge — tokens + the container_width layout knob.
|
|
46
42
|
# Single source for the .scss.erb bridge (Sprockets) and tokens:export (Propshaft).
|
|
47
43
|
sig { returns(String) }
|
|
48
44
|
def self.scss_variables
|
|
49
|
-
"#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n"
|
|
45
|
+
"#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n" \
|
|
46
|
+
"$am-column-count: #{configuration.column_count} !default;\n"
|
|
50
47
|
end
|
|
51
48
|
|
|
52
49
|
# Route warnings to the app logger when present (Sentry breadcrumbs, log
|
|
@@ -67,9 +64,8 @@ module ActiveMail
|
|
|
67
64
|
|
|
68
65
|
# Invalid bytes degrade deterministically to U+FFFD, but that silently corrupts
|
|
69
66
|
# content — surface it per on_parse_error before scrubbing.
|
|
70
|
-
sig { params(html_string: String).returns(String) }
|
|
71
|
-
def self.scrub_invalid_bytes(html_string)
|
|
72
|
-
mode = configuration.on_parse_error
|
|
67
|
+
sig { params(html_string: String, mode: Symbol).returns(String) }
|
|
68
|
+
def self.scrub_invalid_bytes(html_string, mode)
|
|
73
69
|
message = '[activemail] input had invalid byte sequences; scrubbed to U+FFFD'
|
|
74
70
|
raise ParseError, message if mode == :raise
|
|
75
71
|
|
|
@@ -77,20 +73,25 @@ module ActiveMail
|
|
|
77
73
|
html_string.scrub
|
|
78
74
|
end
|
|
79
75
|
|
|
80
|
-
#
|
|
81
|
-
#
|
|
76
|
+
# Rejects non-integers rather than silently coercing ("abc"→0, 12.9→12); zero or
|
|
77
|
+
# negative dimensions blow up at transform time (Infinity ghost width).
|
|
82
78
|
sig { params(name: Symbol, value: T.untyped).returns(Integer) }
|
|
83
|
-
def self.
|
|
84
|
-
raise
|
|
79
|
+
def self.positive_integer!(name, value)
|
|
80
|
+
raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
|
|
81
|
+
raise ArgumentError, "#{name} must be positive, got #{value}" unless value.positive?
|
|
85
82
|
|
|
86
83
|
value
|
|
87
84
|
end
|
|
88
85
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
# Author attributes are untrusted strings: base 10 only (Integer() reads "010" as
|
|
87
|
+
# octal and "0x20" as hex), nil when not a positive integer.
|
|
88
|
+
sig { params(value: T.untyped).returns(T.nilable(Integer)) }
|
|
89
|
+
def self.parse_positive_integer(value)
|
|
90
|
+
string = value.to_s.strip
|
|
91
|
+
return unless string.match?(/\A\d+\z/)
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
int = string.to_i
|
|
94
|
+
int if int.positive?
|
|
94
95
|
end
|
|
95
96
|
|
|
96
97
|
class Configuration
|
|
@@ -98,27 +99,9 @@ module ActiveMail
|
|
|
98
99
|
|
|
99
100
|
ON_PARSE_ERROR_MODES = T.let(%i[ignore warn raise].freeze, T::Array[Symbol])
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
{ premailer: ActiveMail::Inliner::Premailer, roadie: ActiveMail::Inliner::Roadie, null: ActiveMail::Inliner::Null }.freeze,
|
|
103
|
-
T::Hash[Symbol, T.class_of(ActiveMail::Inliner::Base)]
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
InlinerSetting = T.type_alias { T.any(Symbol, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base)) }
|
|
107
|
-
|
|
108
|
-
sig { returns(InlinerSetting) }
|
|
102
|
+
sig { returns(ActiveMail::Inliner::Setting) }
|
|
109
103
|
attr_reader :inliner
|
|
110
104
|
|
|
111
|
-
# Lets a host already running another inliner (e.g. premailer-rails) opt out.
|
|
112
|
-
sig { returns(T::Boolean) }
|
|
113
|
-
attr_reader :register_inline_interceptor
|
|
114
|
-
|
|
115
|
-
sig { params(value: T.untyped).void }
|
|
116
|
-
def register_inline_interceptor=(value)
|
|
117
|
-
raise TypeError, 'register_inline_interceptor must be true or false' unless [true, false].include?(value)
|
|
118
|
-
|
|
119
|
-
@register_inline_interceptor = value
|
|
120
|
-
end
|
|
121
|
-
|
|
122
105
|
sig { returns(Symbol) }
|
|
123
106
|
attr_reader :template_engine, :on_parse_error
|
|
124
107
|
|
|
@@ -146,26 +129,27 @@ module ActiveMail
|
|
|
146
129
|
@container_width = T.let(600, Integer)
|
|
147
130
|
@components = T.let({}, ActiveMail::ComponentMap)
|
|
148
131
|
@on_parse_error = T.let(:warn, Symbol)
|
|
149
|
-
@
|
|
150
|
-
@inliner = T.let(:premailer, InlinerSetting)
|
|
151
|
-
@resolved_inliner = T.let(nil, T.nilable(ActiveMail::Inliner::Base))
|
|
152
|
-
@register_inline_interceptor = T.let(true, T::Boolean)
|
|
132
|
+
@inliner = T.let(:premailer, ActiveMail::Inliner::Setting)
|
|
153
133
|
@blank_link_rel = T.let('noopener', T.nilable(String))
|
|
154
134
|
end
|
|
155
135
|
|
|
136
|
+
# Shared: building the component map per render is pure overhead.
|
|
137
|
+
sig { returns(ActiveMail::Core) }
|
|
138
|
+
def core
|
|
139
|
+
@core ||= T.let(ActiveMail::Core.new(config: self), T.nilable(ActiveMail::Core))
|
|
140
|
+
end
|
|
141
|
+
|
|
156
142
|
# Validates eagerly (like sibling setters): a typo fails at boot, not silently mid-delivery.
|
|
157
|
-
sig { params(value: T.any(
|
|
143
|
+
sig { params(value: T.any(String, ActiveMail::Inliner::Setting)).returns(ActiveMail::Inliner::Setting) }
|
|
158
144
|
def inliner=(value)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
@
|
|
162
|
-
@inliner = resolved
|
|
145
|
+
setting = value.is_a?(String) ? value.to_sym : value
|
|
146
|
+
@resolved_inliner = T.let(ActiveMail::Inliner.resolve(setting), T.nilable(ActiveMail::Inliner::Base))
|
|
147
|
+
@inliner = setting
|
|
163
148
|
end
|
|
164
149
|
|
|
165
|
-
# Memoized so the lifecycle is consistent (one instance reused, not re-built per call).
|
|
166
150
|
sig { returns(ActiveMail::Inliner::Base) }
|
|
167
151
|
def resolved_inliner
|
|
168
|
-
@resolved_inliner ||=
|
|
152
|
+
@resolved_inliner ||= T.let(ActiveMail::Inliner.resolve(@inliner), T.nilable(ActiveMail::Inliner::Base))
|
|
169
153
|
end
|
|
170
154
|
|
|
171
155
|
sig { params(value: T.untyped).returns(Symbol) }
|
|
@@ -186,11 +170,15 @@ module ActiveMail
|
|
|
186
170
|
sig { params(value: T.untyped).returns(Integer) }
|
|
187
171
|
def column_count=(value)
|
|
188
172
|
@column_count = ActiveMail.positive_integer!(:column_count, value)
|
|
173
|
+
invalidate_core!
|
|
174
|
+
@column_count
|
|
189
175
|
end
|
|
190
176
|
|
|
191
177
|
sig { params(value: T.untyped).returns(Integer) }
|
|
192
178
|
def container_width=(value)
|
|
193
179
|
@container_width = ActiveMail.positive_integer!(:container_width, value)
|
|
180
|
+
invalidate_core!
|
|
181
|
+
@container_width
|
|
194
182
|
end
|
|
195
183
|
|
|
196
184
|
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
@@ -209,6 +197,8 @@ module ActiveMail
|
|
|
209
197
|
normalized = value.to_hash.transform_keys(&:to_s)
|
|
210
198
|
normalized.each { |tag, klass| ActiveMail::Components.validate_component!(tag, klass) }
|
|
211
199
|
@components = normalized
|
|
200
|
+
invalidate_core!
|
|
201
|
+
normalized
|
|
212
202
|
end
|
|
213
203
|
|
|
214
204
|
sig { params(tag: T.any(String, Symbol), component_class: T.class_of(ActiveMail::Components::Base)).void }
|
|
@@ -216,18 +206,14 @@ module ActiveMail
|
|
|
216
206
|
ActiveMail::Components.validate_component!(tag, component_class)
|
|
217
207
|
|
|
218
208
|
@components = @components.merge(tag.to_s => component_class)
|
|
209
|
+
invalidate_core!
|
|
219
210
|
end
|
|
220
211
|
|
|
221
212
|
private
|
|
222
213
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return value if value.is_a?(ActiveMail::Inliner::Base)
|
|
227
|
-
return value.new if value.is_a?(Class) && value < ActiveMail::Inliner::Base
|
|
228
|
-
return T.must(INLINERS[value]).new if value.is_a?(Symbol) && INLINERS.key?(value)
|
|
229
|
-
|
|
230
|
-
raise ArgumentError, "unknown inliner #{value.inspect}, expected one of #{INLINERS.keys.inspect}, an Inliner::Base subclass, or an instance"
|
|
214
|
+
sig { void }
|
|
215
|
+
def invalidate_core!
|
|
216
|
+
@core = nil
|
|
231
217
|
end
|
|
232
218
|
end
|
|
233
219
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveMail
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
class ParseError < Error; end
|
|
8
|
+
|
|
9
|
+
class StylesheetNotFound < Error; end
|
|
10
|
+
|
|
11
|
+
module Inliner
|
|
12
|
+
# The adapter's original exception is preserved as Exception#cause.
|
|
13
|
+
class Error < ActiveMail::Error; end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
require 'sorbet-runtime'
|
|
5
5
|
|
|
6
|
+
require_relative '../errors'
|
|
7
|
+
|
|
6
8
|
module ActiveMail
|
|
7
9
|
module Inliner
|
|
8
|
-
# Raised when an adapter fails; the original (premailer/roadie/nokogiri)
|
|
9
|
-
# exception is preserved as Exception#cause with its backtrace intact.
|
|
10
|
-
class Error < StandardError; end
|
|
11
|
-
|
|
12
10
|
# DIP seam: ActiveMail depends on this abstraction, not premailer/roadie.
|
|
13
11
|
class Base
|
|
14
12
|
extend T::Sig
|
|
@@ -18,12 +16,6 @@ module ActiveMail
|
|
|
18
16
|
|
|
19
17
|
sig { abstract.params(html: String).returns(String) }
|
|
20
18
|
def inline(html); end
|
|
21
|
-
|
|
22
|
-
# Lets the interceptor skip work without type-checking concrete adapters.
|
|
23
|
-
sig { returns(T::Boolean) }
|
|
24
|
-
def noop?
|
|
25
|
-
false
|
|
26
|
-
end
|
|
27
19
|
end
|
|
28
20
|
end
|
|
29
21
|
end
|