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
data/lib/activemail/tokens.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'sorbet-runtime'
|
|
5
|
+
require_relative 'tokens/defaults'
|
|
5
6
|
require_relative 'tokens/button_style'
|
|
6
7
|
require_relative 'tokens/scss_serializer'
|
|
7
8
|
|
|
@@ -9,61 +10,6 @@ module ActiveMail
|
|
|
9
10
|
class Tokens
|
|
10
11
|
extend T::Sig
|
|
11
12
|
|
|
12
|
-
TokenMap = T.type_alias { T::Hash[Symbol, String] }
|
|
13
|
-
|
|
14
|
-
DEFAULT_COLORS = T.let(
|
|
15
|
-
{
|
|
16
|
-
primary: '#2a9d8f',
|
|
17
|
-
secondary: '#264653',
|
|
18
|
-
text: '#1a1a1a',
|
|
19
|
-
background: '#ffffff',
|
|
20
|
-
muted: '#6b7280',
|
|
21
|
-
border: '#e5e7eb',
|
|
22
|
-
button_text: '#ffffff'
|
|
23
|
-
}.freeze,
|
|
24
|
-
TokenMap
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
|
|
28
|
-
|
|
29
|
-
DEFAULT_FONTS = T.let(
|
|
30
|
-
{
|
|
31
|
-
body: SYSTEM_FONT_STACK,
|
|
32
|
-
heading: SYSTEM_FONT_STACK
|
|
33
|
-
}.freeze,
|
|
34
|
-
TokenMap
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
DEFAULT_SPACINGS = T.let(
|
|
38
|
-
{
|
|
39
|
-
xs: '4px',
|
|
40
|
-
sm: '8px',
|
|
41
|
-
md: '16px',
|
|
42
|
-
lg: '24px',
|
|
43
|
-
xl: '40px'
|
|
44
|
-
}.freeze,
|
|
45
|
-
TokenMap
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
DEFAULT_RADII = T.let(
|
|
49
|
-
{
|
|
50
|
-
button: '4px',
|
|
51
|
-
box: '4px'
|
|
52
|
-
}.freeze,
|
|
53
|
-
TokenMap
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
# Open/Closed: adding a group here is the only change needed to wire it everywhere.
|
|
57
|
-
GROUPS = T.let(
|
|
58
|
-
{
|
|
59
|
-
color: DEFAULT_COLORS,
|
|
60
|
-
font: DEFAULT_FONTS,
|
|
61
|
-
spacing: DEFAULT_SPACINGS,
|
|
62
|
-
radius: DEFAULT_RADII
|
|
63
|
-
}.freeze,
|
|
64
|
-
T::Hash[Symbol, TokenMap]
|
|
65
|
-
)
|
|
66
|
-
|
|
67
13
|
sig { void }
|
|
68
14
|
def initialize
|
|
69
15
|
@stores = T.let(GROUPS.transform_values(&:dup), T::Hash[Symbol, TokenMap])
|
|
@@ -123,6 +69,13 @@ module ActiveMail
|
|
|
123
69
|
end
|
|
124
70
|
end
|
|
125
71
|
|
|
72
|
+
# Every token with derived colors filled in; nil marks a derived token left unset.
|
|
73
|
+
sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.nilable(String)]]) }
|
|
74
|
+
def resolved
|
|
75
|
+
colors = DERIVED_COLORS.keys.to_h { |name| [name, color(name)] }
|
|
76
|
+
@stores.merge(color: store_for(:color).merge(colors))
|
|
77
|
+
end
|
|
78
|
+
|
|
126
79
|
sig { params(variant: T.any(String, Symbol)).returns(ButtonStyle) }
|
|
127
80
|
def button_style(variant)
|
|
128
81
|
ButtonStyle.from(self, variant)
|
|
@@ -130,7 +83,7 @@ module ActiveMail
|
|
|
130
83
|
|
|
131
84
|
sig { returns(String) }
|
|
132
85
|
def to_scss
|
|
133
|
-
ScssSerializer.call(
|
|
86
|
+
ScssSerializer.call(resolved)
|
|
134
87
|
end
|
|
135
88
|
|
|
136
89
|
private
|
|
@@ -142,14 +95,22 @@ module ActiveMail
|
|
|
142
95
|
|
|
143
96
|
sig { params(group: Symbol, name: T.any(String, Symbol)).returns(String) }
|
|
144
97
|
def fetch!(group, name)
|
|
145
|
-
|
|
98
|
+
access(group, name, nil) || raise(KeyError, "unknown or unset #{group} token #{name.inspect}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sig { params(group: Symbol, key: Symbol).returns(T.nilable(String)) }
|
|
102
|
+
def derived(group, key)
|
|
103
|
+
return unless group == :color
|
|
104
|
+
|
|
105
|
+
source = DERIVED_COLORS[key]
|
|
106
|
+
source && store_for(:color)[source]
|
|
146
107
|
end
|
|
147
108
|
|
|
148
109
|
sig { params(group: Symbol, name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
|
|
149
110
|
def access(group, name, value)
|
|
150
111
|
store = store_for(group)
|
|
151
112
|
key = name.to_sym
|
|
152
|
-
return store
|
|
113
|
+
return store.fetch(key) { derived(group, key) } if value.nil?
|
|
153
114
|
# Emitted verbatim into SCSS — reject blanks that would yield broken CSS.
|
|
154
115
|
raise ArgumentError, "token #{key} value must not be blank" if value.strip.empty?
|
|
155
116
|
|
data/lib/activemail/version.rb
CHANGED
data/lib/activemail.rb
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'nokogiri'
|
|
5
|
+
require 'securerandom'
|
|
5
6
|
require 'sorbet-runtime'
|
|
6
7
|
|
|
7
8
|
require_relative 'activemail/version'
|
|
9
|
+
require_relative 'activemail/errors'
|
|
8
10
|
require_relative 'activemail/components/base'
|
|
11
|
+
require_relative 'activemail/components/button_renderer'
|
|
9
12
|
require_relative 'activemail/components/button'
|
|
10
13
|
require_relative 'activemail/components/row'
|
|
11
14
|
require_relative 'activemail/components/columns'
|
|
12
15
|
require_relative 'activemail/components/container'
|
|
13
|
-
require_relative 'activemail/components/inky'
|
|
14
16
|
require_relative 'activemail/components/block_grid'
|
|
15
17
|
require_relative 'activemail/components/menu'
|
|
16
18
|
require_relative 'activemail/components/menu_item'
|
|
@@ -22,19 +24,32 @@ require_relative 'activemail/components/wrapper'
|
|
|
22
24
|
require_relative 'activemail/components/cta'
|
|
23
25
|
require_relative 'activemail/components/info_box'
|
|
24
26
|
require_relative 'activemail/configuration'
|
|
25
|
-
require_relative 'activemail/
|
|
27
|
+
require_relative 'activemail/pipeline'
|
|
26
28
|
require_relative 'activemail/parse_error_reporter'
|
|
27
29
|
|
|
28
30
|
module ActiveMail
|
|
29
|
-
class ParseError < StandardError; end
|
|
30
|
-
|
|
31
31
|
class Core
|
|
32
32
|
extend T::Sig
|
|
33
33
|
|
|
34
34
|
# Nokogiri cannot parse a bare <th> outside a <tr>; components that emit
|
|
35
35
|
# <th> use this placeholder, swapped back at the end.
|
|
36
36
|
INTERIM_TH_TAG = 'activemail-interim-th'
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
# Comment placeholder handed to components as `inner`; the transformed children
|
|
39
|
+
# are moved there without being re-serialized.
|
|
40
|
+
SLOT_TEXT = 'activemail-slot'
|
|
41
|
+
SLOT = T.let("<!--#{SLOT_TEXT}-->".freeze, String)
|
|
42
|
+
|
|
43
|
+
# The component markup repeats the id of the node it replaces, which libxml2 still has registered.
|
|
44
|
+
LIBXML_ID_REDEFINED_CODE = 513
|
|
45
|
+
|
|
46
|
+
RAW_REGEX = T.let(%r{< *raw *>([\s\S]*?)</ *raw *>}i, Regexp)
|
|
47
|
+
|
|
48
|
+
# One pass over the serialized output; <style>/<script> bodies are matched first so they are left alone.
|
|
49
|
+
POSTPROCESS_REGEX = T.let(
|
|
50
|
+
%r{(?<verbatim><(?<tag>style|script)\b[\s\S]*?</\k<tag>\s*>)|(?<=<|</)#{Regexp.escape(INTERIM_TH_TAG)}(?![\w-])|\u00A0}i,
|
|
51
|
+
Regexp
|
|
52
|
+
)
|
|
38
53
|
|
|
39
54
|
DEFAULT_COMPONENTS = T.let(
|
|
40
55
|
{
|
|
@@ -44,7 +59,6 @@ module ActiveMail
|
|
|
44
59
|
'row' => ActiveMail::Components::Row,
|
|
45
60
|
'columns' => ActiveMail::Components::Columns,
|
|
46
61
|
'container' => ActiveMail::Components::Container,
|
|
47
|
-
'inky' => ActiveMail::Components::Inky,
|
|
48
62
|
'block-grid' => ActiveMail::Components::BlockGrid,
|
|
49
63
|
'menu' => ActiveMail::Components::Menu,
|
|
50
64
|
'item' => ActiveMail::Components::MenuItem,
|
|
@@ -60,85 +74,99 @@ module ActiveMail
|
|
|
60
74
|
sig { returns(Integer) }
|
|
61
75
|
attr_reader :column_count, :container_width
|
|
62
76
|
|
|
63
|
-
sig { returns(
|
|
64
|
-
attr_reader :
|
|
77
|
+
sig { returns(ActiveMail::Configuration) }
|
|
78
|
+
attr_reader :config
|
|
65
79
|
|
|
66
|
-
sig
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
sig do
|
|
81
|
+
params(
|
|
82
|
+
config: ActiveMail::Configuration,
|
|
83
|
+
components: T.untyped,
|
|
84
|
+
column_count: T.untyped,
|
|
85
|
+
container_width: T.untyped
|
|
86
|
+
).void
|
|
87
|
+
end
|
|
88
|
+
def initialize(config: ::ActiveMail.configuration, components: nil, column_count: nil, container_width: nil)
|
|
89
|
+
@config = T.let(config, ActiveMail::Configuration)
|
|
90
|
+
@component_instances = T.let(build_components(config, components), T::Hash[String, ActiveMail::Components::Base])
|
|
91
|
+
@column_count = T.let(ActiveMail.positive_integer!(:column_count, column_count || config.column_count), Integer)
|
|
92
|
+
@container_width = T.let(ActiveMail.positive_integer!(:container_width, container_width || config.container_width), Integer)
|
|
93
|
+
@error_reporter = T.let(ParseErrorReporter.new(@component_instances.keys + [INTERIM_TH_TAG], config), ParseErrorReporter)
|
|
72
94
|
end
|
|
73
95
|
|
|
74
96
|
# Object, not String: ActionView::OutputBuffer is no longer a String since Rails 7.1.
|
|
97
|
+
# Holds no per-call state: the instance is shared across threads.
|
|
75
98
|
sig { params(html_string: Object).returns(String) }
|
|
76
99
|
def transpile(html_string)
|
|
77
|
-
|
|
100
|
+
marker = "activemail-raw-#{SecureRandom.hex(8)}-"
|
|
101
|
+
raws = {}
|
|
102
|
+
str = extract_raws(normalize_input(html_string), marker, raws)
|
|
78
103
|
parse_cmd = ::ActiveMail.full_document?(str) ? :parse : :fragment
|
|
79
104
|
html = Nokogiri::HTML.public_send(parse_cmd, str)
|
|
80
|
-
|
|
105
|
+
@error_reporter.call(html.errors)
|
|
81
106
|
transform_doc(html)
|
|
82
|
-
|
|
83
|
-
string = string.gsub(INTERIM_TH_TAG_REGEX, 'th')
|
|
84
|
-
# Needle is a literal U+00A0 (Nokogiri decodes the nbsp entity to one); re-encode
|
|
85
|
-
# it to the entity for email clients that mishandle raw NBSP bytes.
|
|
86
|
-
string = string.gsub(' ', ' ')
|
|
87
|
-
re_inject_raws(string, raws)
|
|
107
|
+
re_inject_raws(postprocess(html.to_html), marker, raws)
|
|
88
108
|
end
|
|
89
109
|
|
|
90
|
-
|
|
91
|
-
def transform_doc(elem)
|
|
92
|
-
if elem.respond_to?(:children)
|
|
93
|
-
elem.children.each { |child| transform_doc(child) }
|
|
94
|
-
markup = component_factory(elem)
|
|
95
|
-
elem.replace(markup) if markup
|
|
96
|
-
end
|
|
97
|
-
elem
|
|
98
|
-
end
|
|
110
|
+
private
|
|
99
111
|
|
|
100
|
-
sig {
|
|
101
|
-
|
|
112
|
+
sig { returns(T::Hash[String, ActiveMail::Components::Base]) }
|
|
113
|
+
attr_reader :component_instances
|
|
114
|
+
|
|
115
|
+
sig { params(node: Nokogiri::XML::Node).void }
|
|
116
|
+
def transform_doc(node)
|
|
117
|
+
node.children.each { |child| transform_doc(child) }
|
|
102
118
|
component = component_instances[node.name]
|
|
103
|
-
|
|
119
|
+
replace_with_component(node, component, component.transform(node, SLOT)) if component
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Only the component's own markup is parsed; the node's children are moved into
|
|
123
|
+
# its slot, never re-serialized.
|
|
124
|
+
sig { params(node: Nokogiri::XML::Node, component: ActiveMail::Components::Base, markup: String).void }
|
|
125
|
+
def replace_with_component(node, component, markup)
|
|
126
|
+
fragment = Nokogiri::HTML::DocumentFragment.new(node.document, markup, node)
|
|
127
|
+
@error_reporter.call(fragment.errors.reject { |error| error.code == LIBXML_ID_REDEFINED_CODE })
|
|
128
|
+
slots = fragment.xpath('.//comment()').select { |comment| comment.content == SLOT_TEXT }
|
|
129
|
+
ensure_slot_usage!(node, component, slots.size)
|
|
130
|
+
slots.first&.replace(node.children)
|
|
131
|
+
node.replace(fragment)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
sig { params(node: Nokogiri::XML::Node, component: ActiveMail::Components::Base, count: Integer).void }
|
|
135
|
+
def ensure_slot_usage!(node, component, count)
|
|
136
|
+
return if count == 1 || (count.zero? && node.children.all? { |child| child.text? && child.content.strip.empty? })
|
|
104
137
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
component.transform(node, inner)
|
|
138
|
+
raise ActiveMail::Error,
|
|
139
|
+
"<#{node.name}> (#{component.class}) must output `inner` exactly once, found #{count}: its children would be lost or duplicated"
|
|
108
140
|
end
|
|
109
141
|
|
|
110
|
-
sig { params(string: String
|
|
111
|
-
def extract_raws(string)
|
|
112
|
-
raws = []
|
|
113
|
-
i = 0
|
|
142
|
+
sig { params(string: String, marker: String, raws: T::Hash[String, String]).returns(String) }
|
|
143
|
+
def extract_raws(string, marker, raws)
|
|
114
144
|
# Only the tags + content, across lines: surrounding whitespace is left in
|
|
115
145
|
# place (true pass-through; eating adjacent newlines corrupts <pre>/inline text).
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
str = str.sub(regex, "###RAW#{i}###")
|
|
121
|
-
i += 1
|
|
146
|
+
string.gsub(RAW_REGEX) do
|
|
147
|
+
placeholder = "#{marker}#{raws.size}~"
|
|
148
|
+
raws[placeholder] = T.must(Regexp.last_match(1))
|
|
149
|
+
placeholder
|
|
122
150
|
end
|
|
123
|
-
[raws, str]
|
|
124
151
|
end
|
|
125
152
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
153
|
+
# U+00A0 is what Nokogiri decodes the nbsp entity to; re-encoded for email clients that mishandle raw NBSP bytes.
|
|
154
|
+
sig { params(string: String).returns(String) }
|
|
155
|
+
def postprocess(string)
|
|
156
|
+
string.gsub(POSTPROCESS_REGEX) do |match|
|
|
157
|
+
next match if Regexp.last_match(:verbatim)
|
|
158
|
+
|
|
159
|
+
match == "\u00A0" ? ' ' : 'th'
|
|
132
160
|
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
sig { params(string: String, marker: String, raws: T::Hash[String, String]).returns(String) }
|
|
164
|
+
def re_inject_raws(string, marker, raws)
|
|
165
|
+
str = raws.empty? ? string : string.gsub(/#{Regexp.escape(marker)}\d+~/) { |token| raws.fetch(token) }
|
|
133
166
|
str = str.html_safe if str.respond_to?(:html_safe)
|
|
134
167
|
str
|
|
135
168
|
end
|
|
136
169
|
|
|
137
|
-
private
|
|
138
|
-
|
|
139
|
-
# Internal transpilation details, not public surface (column_count/container_width stay public).
|
|
140
|
-
private :component_instances, :transform_doc, :component_factory, :extract_raws, :re_inject_raws
|
|
141
|
-
|
|
142
170
|
sig { params(config: ActiveMail::Configuration, overrides: T.untyped).returns(T::Hash[String, ActiveMail::Components::Base]) }
|
|
143
171
|
def build_components(config, overrides)
|
|
144
172
|
# Lookup is by node name (String); a Symbol key would never match.
|
|
@@ -151,8 +179,8 @@ module ActiveMail
|
|
|
151
179
|
def normalize_input(html_string)
|
|
152
180
|
html_string = html_string.to_s
|
|
153
181
|
html_string = html_string.dup.force_encoding(Encoding::UTF_8) if html_string.encoding == Encoding::BINARY
|
|
154
|
-
html_string = ::ActiveMail.scrub_invalid_bytes(html_string) unless html_string.valid_encoding?
|
|
155
|
-
html_string
|
|
182
|
+
html_string = ::ActiveMail.scrub_invalid_bytes(html_string, config.on_parse_error) unless html_string.valid_encoding?
|
|
183
|
+
html_string
|
|
156
184
|
end
|
|
157
185
|
end
|
|
158
186
|
end
|
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
module Components
|
|
5
5
|
# Register in config/initializers/activemail.rb:
|
|
6
6
|
# config.register_component "<%= tag_name %>", Components::<%= class_name %>
|
|
7
|
-
class <%= class_name %> < ActiveMail::Components::
|
|
7
|
+
class <%= class_name %> < ActiveMail::Components::TableComponent
|
|
8
8
|
def transform(node, inner)
|
|
9
|
-
|
|
10
|
-
%(<table class="#{classes}" #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
|
|
9
|
+
%(<table#{root_attributes(node, classes: "<%= tag_name %>")} #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
|
|
11
10
|
end
|
|
12
11
|
end
|
|
13
12
|
end
|
|
@@ -14,9 +14,6 @@ ActiveMail.configure do |config|
|
|
|
14
14
|
# ActiveMail::Inliner::Base subclass/instance.
|
|
15
15
|
# config.inliner = :premailer
|
|
16
16
|
|
|
17
|
-
# Set false if another inliner (e.g. premailer-rails) already runs on mailers.
|
|
18
|
-
# config.register_inline_interceptor = true
|
|
19
|
-
|
|
20
17
|
# How malformed markup is handled: :warn (default), :ignore, or :raise.
|
|
21
18
|
# config.on_parse_error = :warn
|
|
22
19
|
|
|
@@ -16,8 +16,6 @@
|
|
|
16
16
|
</noscript>
|
|
17
17
|
<![endif]-->
|
|
18
18
|
<%%= activemail_inline_styles %>
|
|
19
|
-
<%%# Fallback for when activemail_inline_styles cannot read the compiled asset. %>
|
|
20
|
-
<%%= stylesheet_link_tag "activemail/activemail", media: "all" %>
|
|
21
19
|
</head>
|
|
22
20
|
|
|
23
21
|
<body style="margin:0;padding:0;word-spacing:normal;">
|
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
:plain
|
|
10
10
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
11
11
|
= activemail_inline_styles
|
|
12
|
-
-# Fallback for when activemail_inline_styles cannot read the compiled asset.
|
|
13
|
-
= stylesheet_link_tag "activemail/activemail", media: "all"
|
|
14
12
|
%body{style: "margin:0;padding:0;word-spacing:normal;"}
|
|
15
13
|
%div{role: "article", "aria-roledescription" => "email", lang: I18n.locale, style: "width:100%;"}
|
|
16
14
|
%container
|
|
@@ -8,8 +8,6 @@ html lang=I18n.locale xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-
|
|
|
8
8
|
meta name="supported-color-schemes" content="light dark"
|
|
9
9
|
| <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
10
10
|
= activemail_inline_styles
|
|
11
|
-
/ Fallback for when activemail_inline_styles cannot read the compiled asset.
|
|
12
|
-
= stylesheet_link_tag "activemail/activemail", media: "all"
|
|
13
11
|
body style="margin:0;padding:0;word-spacing:normal;"
|
|
14
12
|
div role="article" aria-roledescription="email" lang=I18n.locale style="width:100%;"
|
|
15
13
|
container
|
data/lib/tasks/activemail.rake
CHANGED
|
@@ -20,15 +20,14 @@ namespace :activemail do
|
|
|
20
20
|
task render_all: :environment do
|
|
21
21
|
require 'activemail/quality/render_all'
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
puts "Rendered #{result.rendered} email(s) into #{output_root}"
|
|
23
|
+
root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root : Pathname.pwd
|
|
24
|
+
result = ActiveMail::Quality::RenderAll.new(root: root, config: ActiveMail::Quality.config).call
|
|
25
|
+
puts "Rendered #{result.rendered} email(s) into #{result.output_root}"
|
|
27
26
|
# A green run on zero previews would silently verify nothing — make it visible.
|
|
28
27
|
warn '[activemail] WARNING: no mailer previews were discovered — nothing was checked.' if result.discovered.zero?
|
|
29
28
|
result.render_failures.each { |key, error| puts " render failed: #{key}: #{error}" }
|
|
30
29
|
result.guard_failures.each do |key, violations|
|
|
31
|
-
puts " guard failed: #{key}", violations.map { |v| " -
|
|
30
|
+
puts " guard failed: #{key}", violations.map { |v| " - #{v}" }.join("\n")
|
|
32
31
|
end
|
|
33
32
|
abort "\n#{result.broken_required.size} required preview(s) failed: #{result.broken_required.join(', ')}" if result.broken_required.any?
|
|
34
33
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activemail
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Advitam
|
|
@@ -81,6 +81,7 @@ files:
|
|
|
81
81
|
- CHANGELOG.md
|
|
82
82
|
- LICENSE.txt
|
|
83
83
|
- README.md
|
|
84
|
+
- UPGRADING.md
|
|
84
85
|
- app/assets/stylesheets/activemail/_activemail_tokens.scss.erb
|
|
85
86
|
- app/assets/stylesheets/activemail/_components.scss
|
|
86
87
|
- app/assets/stylesheets/activemail/_dark.scss
|
|
@@ -93,9 +94,15 @@ files:
|
|
|
93
94
|
- app/views/layouts/activemail/_head.html.inky-erb
|
|
94
95
|
- app/views/layouts/activemail/mailer.html.inky-erb
|
|
95
96
|
- lib/activemail.rb
|
|
97
|
+
- lib/activemail/asset_pipeline.rb
|
|
98
|
+
- lib/activemail/asset_pipeline/base.rb
|
|
99
|
+
- lib/activemail/asset_pipeline/null.rb
|
|
100
|
+
- lib/activemail/asset_pipeline/propshaft.rb
|
|
101
|
+
- lib/activemail/asset_pipeline/sprockets.rb
|
|
96
102
|
- lib/activemail/components/base.rb
|
|
97
103
|
- lib/activemail/components/block_grid.rb
|
|
98
104
|
- lib/activemail/components/button.rb
|
|
105
|
+
- lib/activemail/components/button_renderer.rb
|
|
99
106
|
- lib/activemail/components/callout.rb
|
|
100
107
|
- lib/activemail/components/center.rb
|
|
101
108
|
- lib/activemail/components/columns.rb
|
|
@@ -103,32 +110,38 @@ files:
|
|
|
103
110
|
- lib/activemail/components/cta.rb
|
|
104
111
|
- lib/activemail/components/h_line.rb
|
|
105
112
|
- lib/activemail/components/info_box.rb
|
|
106
|
-
- lib/activemail/components/inky.rb
|
|
107
113
|
- lib/activemail/components/menu.rb
|
|
108
114
|
- lib/activemail/components/menu_item.rb
|
|
109
115
|
- lib/activemail/components/row.rb
|
|
110
116
|
- lib/activemail/components/spacer.rb
|
|
117
|
+
- lib/activemail/components/table_component.rb
|
|
111
118
|
- lib/activemail/components/wrapper.rb
|
|
112
119
|
- lib/activemail/configuration.rb
|
|
120
|
+
- lib/activemail/errors.rb
|
|
121
|
+
- lib/activemail/inliner.rb
|
|
113
122
|
- lib/activemail/inliner/base.rb
|
|
114
|
-
- lib/activemail/inliner/interceptor.rb
|
|
115
123
|
- lib/activemail/inliner/null.rb
|
|
116
124
|
- lib/activemail/inliner/premailer.rb
|
|
117
125
|
- lib/activemail/inliner/roadie.rb
|
|
118
126
|
- lib/activemail/libxml.rb
|
|
119
127
|
- lib/activemail/parse_error_reporter.rb
|
|
128
|
+
- lib/activemail/pipeline.rb
|
|
120
129
|
- lib/activemail/quality.rb
|
|
130
|
+
- lib/activemail/quality/builtin_rules.rb
|
|
121
131
|
- lib/activemail/quality/configuration.rb
|
|
122
132
|
- lib/activemail/quality/guard.rb
|
|
123
133
|
- lib/activemail/quality/minitest.rb
|
|
134
|
+
- lib/activemail/quality/output_dir.rb
|
|
124
135
|
- lib/activemail/quality/preview_renderer.rb
|
|
125
136
|
- lib/activemail/quality/render_all.rb
|
|
126
137
|
- lib/activemail/quality/rspec.rb
|
|
127
|
-
- lib/activemail/
|
|
138
|
+
- lib/activemail/quality/rule.rb
|
|
128
139
|
- lib/activemail/rails/engine.rb
|
|
140
|
+
- lib/activemail/rails/rendering.rb
|
|
129
141
|
- lib/activemail/rails/template_handler.rb
|
|
130
142
|
- lib/activemail/tokens.rb
|
|
131
143
|
- lib/activemail/tokens/button_style.rb
|
|
144
|
+
- lib/activemail/tokens/defaults.rb
|
|
132
145
|
- lib/activemail/tokens/scss_serializer.rb
|
|
133
146
|
- lib/activemail/version.rb
|
|
134
147
|
- lib/generators/activemail/component_generator.rb
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# typed: strict
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require_relative 'base'
|
|
5
|
-
|
|
6
|
-
module ActiveMail
|
|
7
|
-
module Components
|
|
8
|
-
# Renders a bare <tr>, useful inside hand-written tables.
|
|
9
|
-
class Inky < Base
|
|
10
|
-
extend T::Sig
|
|
11
|
-
|
|
12
|
-
sig { override.params(_node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
13
|
-
def transform(_node, inner)
|
|
14
|
-
%(<tr>#{inner}</tr>)
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# typed: strict
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require 'sorbet-runtime'
|
|
5
|
-
|
|
6
|
-
module ActiveMail
|
|
7
|
-
module Inliner
|
|
8
|
-
class Interceptor
|
|
9
|
-
class << self
|
|
10
|
-
extend T::Sig
|
|
11
|
-
|
|
12
|
-
sig { params(message: T.untyped).void }
|
|
13
|
-
def delivering_email(message)
|
|
14
|
-
config = ActiveMail.configuration
|
|
15
|
-
# Runtime check: an engine initializer runs before the host's, so a
|
|
16
|
-
# boot-time flag read would always see the default.
|
|
17
|
-
return unless config.register_inline_interceptor
|
|
18
|
-
|
|
19
|
-
inliner = config.resolved_inliner
|
|
20
|
-
return if inliner.noop?
|
|
21
|
-
|
|
22
|
-
html_parts(message).each do |part|
|
|
23
|
-
part.body = inliner.inline(part.body.to_s)
|
|
24
|
-
rescue StandardError => e
|
|
25
|
-
# Surface which inliner failed; do not swallow (a silently non-inlined
|
|
26
|
-
# mail is worse). Ruby sets e as #cause, preserving the original backtrace.
|
|
27
|
-
raise ActiveMail::Inliner::Error, "[#{inliner.class}] CSS inlining failed: #{e.message}"
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
# The html part(s) to inline: a single text/html message, or every text/html
|
|
34
|
-
# part of a multipart message. Attachments are never inlined.
|
|
35
|
-
sig { params(message: T.untyped).returns(T::Array[T.untyped]) }
|
|
36
|
-
def html_parts(message)
|
|
37
|
-
return message.all_parts.select { |part| html_body_part?(part) } if message.multipart?
|
|
38
|
-
|
|
39
|
-
html_body_part?(message) ? [message] : []
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
sig { params(part: T.untyped).returns(T::Boolean) }
|
|
43
|
-
def html_body_part?(part)
|
|
44
|
-
return false unless part.respond_to?(:mime_type) && part.mime_type == 'text/html'
|
|
45
|
-
|
|
46
|
-
!(part.respond_to?(:attachment?) && part.attachment?)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
# typed: strict
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require 'sorbet-runtime'
|
|
5
|
-
|
|
6
|
-
module ActiveMail
|
|
7
|
-
# Reads a compiled CSS asset's bytes (Sprockets or Propshaft, by duck-type) — the
|
|
8
|
-
# bytes, not a digest URL, are what the Premailer adapter needs to inline.
|
|
9
|
-
module CompiledStylesheet
|
|
10
|
-
class << self
|
|
11
|
-
extend T::Sig
|
|
12
|
-
|
|
13
|
-
# '' (not nil) on a miss: callers embed the result verbatim, and a missing
|
|
14
|
-
# asset must degrade to an empty <style>, never interpolate "nil" into HTML.
|
|
15
|
-
sig { params(logical_path: String).returns(String) }
|
|
16
|
-
def read(logical_path)
|
|
17
|
-
sprockets_source(logical_path) || propshaft_source(logical_path) || ''
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
# Sprockets: config.assets.compile (dev) resolves logical paths in-memory;
|
|
23
|
-
# precompiled (prod) resolves through the manifest. #find_asset covers both.
|
|
24
|
-
sig { params(logical_path: String).returns(T.nilable(String)) }
|
|
25
|
-
def sprockets_source(logical_path)
|
|
26
|
-
assets = rails_assets_environment
|
|
27
|
-
return unless assets.respond_to?(:find_asset)
|
|
28
|
-
|
|
29
|
-
asset = assets.find_asset(logical_path)
|
|
30
|
-
asset&.source&.to_s
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Propshaft: the load_path resolves a logical path to a compiled file on disk.
|
|
34
|
-
sig { params(logical_path: String).returns(T.nilable(String)) }
|
|
35
|
-
def propshaft_source(logical_path)
|
|
36
|
-
assets = rails_assets
|
|
37
|
-
load_path = assets.respond_to?(:load_path) ? assets.load_path : nil
|
|
38
|
-
return unless load_path.respond_to?(:find)
|
|
39
|
-
|
|
40
|
-
asset = load_path.find(logical_path)
|
|
41
|
-
asset&.path && File.exist?(asset.path) ? File.read(asset.path) : nil
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# config.assets.environment is the Sprockets::Environment; absent under Propshaft.
|
|
45
|
-
sig { returns(T.untyped) }
|
|
46
|
-
def rails_assets_environment
|
|
47
|
-
assets = rails_assets
|
|
48
|
-
assets.respond_to?(:environment) ? assets.environment : nil
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
sig { returns(T.untyped) }
|
|
52
|
-
def rails_assets
|
|
53
|
-
return unless Object.const_defined?(:Rails)
|
|
54
|
-
|
|
55
|
-
rails = Object.const_get(:Rails)
|
|
56
|
-
app = rails.respond_to?(:application) ? rails.application : nil
|
|
57
|
-
config = app&.config
|
|
58
|
-
config.respond_to?(:assets) ? config.assets : nil
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|