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
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'pathname'
|
|
5
6
|
|
|
6
7
|
require_relative '../quality'
|
|
8
|
+
require_relative 'output_dir'
|
|
7
9
|
|
|
8
10
|
module ActiveMail
|
|
9
11
|
module Quality
|
|
@@ -12,6 +14,7 @@ module ActiveMail
|
|
|
12
14
|
extend T::Sig
|
|
13
15
|
|
|
14
16
|
class Result < T::Struct
|
|
17
|
+
const :output_root, Pathname
|
|
15
18
|
const :discovered, Integer
|
|
16
19
|
const :rendered, Integer
|
|
17
20
|
const :render_failures, T::Hash[String, String]
|
|
@@ -19,72 +22,125 @@ module ActiveMail
|
|
|
19
22
|
const :broken_required, T::Array[String]
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
MARKER = '.activemail_previews'
|
|
26
|
+
EXTENSIONS = T.let({ html: 'html', text: 'txt' }.freeze, T::Hash[Symbol, String])
|
|
27
|
+
|
|
28
|
+
sig { returns(Pathname) }
|
|
29
|
+
attr_reader :output_root
|
|
30
|
+
|
|
31
|
+
sig { params(root: T.any(String, Pathname), config: Configuration).void }
|
|
32
|
+
def initialize(root:, config: ActiveMail::Quality.config)
|
|
25
33
|
@config = T.let(config, Configuration)
|
|
34
|
+
@output_root = T.let(OutputDir.resolve(config.output_dir, root), Pathname)
|
|
35
|
+
@render_failures = T.let({}, T::Hash[String, String])
|
|
36
|
+
@guard_failures = T.let({}, T::Hash[String, T::Array[Guard::Violation]])
|
|
37
|
+
@failed_keys = T.let(Set.new, T::Set[String])
|
|
38
|
+
@rendered = T.let(0, Integer)
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
sig { returns(Result) }
|
|
29
42
|
def call
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
render_failures = {}
|
|
34
|
-
guard_failures = {}
|
|
43
|
+
@config.check_exemptions!(@config.guard)
|
|
44
|
+
clean_previous_output
|
|
35
45
|
previews = PreviewRenderer.all
|
|
36
|
-
|
|
46
|
+
PreviewRenderer.cases(@config, previews).each { |preview, email, locale| render_locale(preview, email, locale) }
|
|
37
47
|
|
|
38
48
|
Result.new(
|
|
39
|
-
discovered: previews.size, rendered: rendered, render_failures: render_failures,
|
|
40
|
-
guard_failures: guard_failures, broken_required: broken_required(previews
|
|
49
|
+
output_root: @output_root, discovered: previews.size, rendered: @rendered, render_failures: @render_failures,
|
|
50
|
+
guard_failures: @guard_failures, broken_required: broken_required(previews)
|
|
41
51
|
)
|
|
42
52
|
end
|
|
43
53
|
|
|
44
54
|
private
|
|
45
55
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
sig { void }
|
|
57
|
+
def clean_previous_output
|
|
58
|
+
ensure_owned!
|
|
59
|
+
@output_root.glob('**/*.{html,txt}').each { |path| path.delete if path.file? }
|
|
60
|
+
prune_empty_directories
|
|
61
|
+
FileUtils.mkdir_p(@output_root)
|
|
62
|
+
FileUtils.touch(@output_root.join(MARKER))
|
|
53
63
|
end
|
|
54
|
-
|
|
64
|
+
|
|
65
|
+
# The marker proves a previous run owns the directory: without it, an existing
|
|
66
|
+
# non-empty output_dir (public/, app/...) is never touched.
|
|
67
|
+
sig { void }
|
|
68
|
+
def ensure_owned!
|
|
69
|
+
return if !@output_root.exist? || @output_root.join(MARKER).exist? || @output_root.empty?
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, "output_dir #{@output_root} is not empty and was not created by activemail (no #{MARKER}); use a dedicated directory"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { void }
|
|
75
|
+
def prune_empty_directories
|
|
76
|
+
@output_root.glob('**/*/').sort_by { |dir| -dir.to_s.size }.each { |dir| dir.rmdir if dir.empty? }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A required preview that fails, OR is never discovered at all (typo / deleted), is broken.
|
|
80
|
+
sig { params(previews: T::Array[[T.untyped, String]]).returns(T::Array[String]) }
|
|
81
|
+
def broken_required(previews)
|
|
55
82
|
required = @config.required_previews
|
|
56
83
|
discovered = previews.map { |preview, email| PreviewRenderer.key(preview, email) }
|
|
57
|
-
(
|
|
84
|
+
(@failed_keys.to_a & required) | (required - discovered)
|
|
58
85
|
end
|
|
59
86
|
|
|
60
|
-
sig
|
|
61
|
-
|
|
87
|
+
sig { params(preview: T.untyped, email: String, locale: Symbol).void }
|
|
88
|
+
def render_locale(preview, email, locale)
|
|
89
|
+
key = PreviewRenderer.key(preview, email)
|
|
90
|
+
label = locale == I18n.default_locale ? key : "#{key} [#{locale}]"
|
|
91
|
+
violations = render_one(preview, email, locale, label)
|
|
92
|
+
record(key, label, violations)
|
|
62
93
|
end
|
|
63
|
-
def render_all(previews, render_failures, guard_failures)
|
|
64
|
-
previews.count do |preview, email|
|
|
65
|
-
key = PreviewRenderer.key(preview, email)
|
|
66
|
-
path = render_one(preview, email, render_failures, key)
|
|
67
|
-
next false unless path
|
|
68
94
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
95
|
+
sig { params(key: String, label: String, violations: T.nilable(T::Array[Guard::Violation])).void }
|
|
96
|
+
def record(key, label, violations)
|
|
97
|
+
return @failed_keys << key if violations.nil?
|
|
98
|
+
|
|
99
|
+
@rendered += 1
|
|
100
|
+
return if violations.empty?
|
|
74
101
|
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
@failed_keys << key
|
|
103
|
+
@guard_failures[label] = violations
|
|
77
104
|
end
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
|
|
106
|
+
sig { params(preview: T.untyped, email: String, locale: Symbol, label: String).returns(T.nilable(T::Array[Guard::Violation])) }
|
|
107
|
+
def render_one(preview, email, locale, label)
|
|
108
|
+
PreviewRenderer.within(preview, email, locale, @config.around_each) do |mail|
|
|
109
|
+
write(preview, email, locale, mail) unless PreviewRenderer.declined?(mail)
|
|
110
|
+
key = PreviewRenderer.key(preview, email)
|
|
111
|
+
@config.guard.violations(mail, allow_declined: @config.allow_declined?(key), skip: @config.exempt_rules_for(key))
|
|
112
|
+
end
|
|
80
113
|
rescue SystemCallError
|
|
81
114
|
raise # disk/permission failures are not template bugs — let them abort the run
|
|
82
115
|
rescue StandardError => e
|
|
83
|
-
|
|
84
|
-
cause = root ? "\n caused by: #{root.class}: #{root.message}" : ''
|
|
85
|
-
failures[key] = "#{e.class}: #{e.message}\n #{e.backtrace&.first(5)&.join("\n ")}#{cause}"
|
|
116
|
+
@render_failures[label] = describe(e)
|
|
86
117
|
nil
|
|
87
118
|
end
|
|
119
|
+
|
|
120
|
+
sig { params(error: StandardError).returns(String) }
|
|
121
|
+
def describe(error)
|
|
122
|
+
root = error.cause
|
|
123
|
+
cause = root ? "\n caused by: #{root.class}: #{root.message}" : ''
|
|
124
|
+
"#{error.class}: #{error.message}\n #{error.backtrace&.first(5)&.join("\n ")}#{cause}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { params(preview: T.untyped, email: String, locale: Symbol, mail: T.untyped).void }
|
|
128
|
+
def write(preview, email, locale, mail)
|
|
129
|
+
dir = @output_root.join(preview.preview_name)
|
|
130
|
+
FileUtils.mkdir_p(dir)
|
|
131
|
+
# The default locale stays unsuffixed so a diff against an older run still lines up.
|
|
132
|
+
stem = locale == I18n.default_locale ? email : "#{email}.#{locale}"
|
|
133
|
+
contents = { html: PreviewRenderer.html_body(mail), text: PreviewRenderer.text_body(mail) }
|
|
134
|
+
|
|
135
|
+
@config.formats.each do |format|
|
|
136
|
+
File.write(dir.join("#{stem}.#{EXTENSIONS.fetch(format)}"), filtered(contents.fetch(format)))
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
sig { params(content: String).returns(String) }
|
|
141
|
+
def filtered(content)
|
|
142
|
+
@config.output_filters.reduce(content) { |acc, filter| filter.call(acc) }
|
|
143
|
+
end
|
|
88
144
|
end
|
|
89
145
|
end
|
|
90
146
|
end
|
|
@@ -6,20 +6,20 @@ require_relative '../quality'
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Quality
|
|
8
8
|
module Rspec
|
|
9
|
-
# Standalone matcher object — testable without booting RSpec. The
|
|
10
|
-
# be_a_valid_email helper below just returns it (no RSpec::Matchers.define DSL).
|
|
11
9
|
class ValidEmailMatcher
|
|
12
10
|
extend T::Sig
|
|
13
11
|
|
|
14
|
-
sig { params(guard: Guard).void }
|
|
15
|
-
def initialize(guard: ActiveMail::Quality.guard)
|
|
12
|
+
sig { params(guard: Guard, allow_declined: T::Boolean, skip: T::Array[Symbol]).void }
|
|
13
|
+
def initialize(guard: ActiveMail::Quality.guard, allow_declined: false, skip: Guard::NO_SKIP)
|
|
16
14
|
@guard = T.let(guard, Guard)
|
|
15
|
+
@allow_declined = T.let(allow_declined, T::Boolean)
|
|
16
|
+
@skip = T.let(skip, T::Array[Symbol])
|
|
17
17
|
@violations = T.let([], T::Array[Guard::Violation])
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
sig { params(
|
|
21
|
-
def matches?(
|
|
22
|
-
@violations = @guard.violations(
|
|
20
|
+
sig { params(html_or_message: T.untyped).returns(T::Boolean) }
|
|
21
|
+
def matches?(html_or_message)
|
|
22
|
+
@violations = @guard.violations(html_or_message, allow_declined: @allow_declined, skip: @skip)
|
|
23
23
|
@violations.empty?
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -42,7 +42,7 @@ module ActiveMail
|
|
|
42
42
|
|
|
43
43
|
sig { returns(String) }
|
|
44
44
|
def formatted_violations
|
|
45
|
-
@violations.map { |v| " -
|
|
45
|
+
@violations.map { |v| " - #{v}" }.join("\n")
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -50,15 +50,16 @@ module ActiveMail
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
if defined?(RSpec)
|
|
53
|
-
# ValidEmailMatcher already implements the full matcher protocol, so a simple
|
|
54
|
-
# helper returning it is enough — no RSpec::Matchers.define DSL needed.
|
|
55
53
|
module RSpec
|
|
56
54
|
module Matchers
|
|
57
55
|
extend T::Sig
|
|
58
56
|
|
|
59
|
-
sig
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
sig do
|
|
58
|
+
params(guard: ActiveMail::Quality::Guard, allow_declined: T::Boolean, skip: T::Array[Symbol])
|
|
59
|
+
.returns(ActiveMail::Quality::Rspec::ValidEmailMatcher)
|
|
60
|
+
end
|
|
61
|
+
def be_a_valid_email(guard: ActiveMail::Quality.guard, allow_declined: false, skip: ActiveMail::Quality::Guard::NO_SKIP)
|
|
62
|
+
ActiveMail::Quality::Rspec::ValidEmailMatcher.new(guard: guard, allow_declined: allow_declined, skip: skip)
|
|
62
63
|
end
|
|
63
64
|
end
|
|
64
65
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Quality
|
|
8
|
+
# :html rules receive (Nokogiri document, html), :message rules the mail; a check
|
|
9
|
+
# returns nil/false to pass, else one message or an Array of them.
|
|
10
|
+
class Rule
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
LEVELS = T.let(%i[html message].freeze, T::Array[Symbol])
|
|
14
|
+
|
|
15
|
+
sig { returns(Symbol) }
|
|
16
|
+
attr_reader :name
|
|
17
|
+
|
|
18
|
+
sig { returns(Symbol) }
|
|
19
|
+
attr_reader :level
|
|
20
|
+
|
|
21
|
+
sig { params(name: Symbol, level: Symbol, check: Proc).void }
|
|
22
|
+
def initialize(name, level, check)
|
|
23
|
+
raise ArgumentError, "rule level must be one of #{LEVELS.inspect}, got #{level.inspect}" unless LEVELS.include?(level)
|
|
24
|
+
|
|
25
|
+
@name = name
|
|
26
|
+
@level = level
|
|
27
|
+
@check = check
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { params(name: Symbol, check: Proc).returns(Rule) }
|
|
31
|
+
def self.html(name, &check)
|
|
32
|
+
new(name, :html, check)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { params(name: Symbol, check: Proc).returns(Rule) }
|
|
36
|
+
def self.message(name, &check)
|
|
37
|
+
new(name, :message, check)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(args: T::Array[T.untyped]).returns(T::Array[String]) }
|
|
41
|
+
def messages(args)
|
|
42
|
+
result = T.unsafe(@check).call(*args)
|
|
43
|
+
result ? Array(result).map(&:to_s) : []
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'guard'
|
|
7
|
+
|
|
8
|
+
module ActiveMail
|
|
9
|
+
module Quality
|
|
10
|
+
# Rules a preview is exempt from, keyed by preview name (every email of it) or by
|
|
11
|
+
# preview key ("preview_name#email"); values are rule names, built-in or custom.
|
|
12
|
+
module RuleExemptions
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
sig { returns(T::Hash[String, T::Array[Symbol]]) }
|
|
16
|
+
def rule_exemptions
|
|
17
|
+
exemptions.dup.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(value: T.untyped).void }
|
|
21
|
+
def rule_exemptions=(value)
|
|
22
|
+
Kernel.raise TypeError, "rule_exemptions must be a Hash, got #{value.class}" unless value.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
exemptions = value.to_h { |key, rules| [key.to_s.freeze, Kernel.Array(rules).map(&:to_sym).freeze] }
|
|
25
|
+
@exemptions = T.let(exemptions, T.nilable(T::Hash[String, T::Array[Symbol]]))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(key: String).returns(T::Array[Symbol]) }
|
|
29
|
+
def exempt_rules_for(key)
|
|
30
|
+
by_key = exemptions.fetch(key, Guard::NO_SKIP)
|
|
31
|
+
by_name = exemptions.fetch(key.split('#', 2).first.to_s, Guard::NO_SKIP)
|
|
32
|
+
by_key.empty? ? by_name : by_key | by_name
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Called before any preview renders: a bad name would otherwise surface as a render failure, or a skip.
|
|
36
|
+
sig { params(guard: Guard).void }
|
|
37
|
+
def check_exemptions!(guard)
|
|
38
|
+
exemptions.each_value { |rules| guard.check_skippable!(rules) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
sig { returns(T::Hash[String, T::Array[Symbol]]) }
|
|
44
|
+
def exemptions
|
|
45
|
+
@exemptions ||= T.let({}, T.nilable(T::Hash[String, T::Array[Symbol]]))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -2,28 +2,28 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'rails/engine'
|
|
5
|
-
require 'activemail/
|
|
5
|
+
require 'activemail/asset_pipeline'
|
|
6
|
+
require 'activemail/rails/rendering'
|
|
6
7
|
|
|
7
8
|
module ActiveMail
|
|
8
9
|
module Rails
|
|
9
10
|
class Engine < ::Rails::Engine
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# Sprockets only compiles whitelisted assets; the framework entry must be
|
|
13
|
-
# reachable as `stylesheet_link_tag "activemail/activemail"` from a host.
|
|
11
|
+
# Sprockets only compiles whitelisted assets (a no-op under Propshaft).
|
|
14
12
|
initializer 'activemail.assets' do |app|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
ActiveMail::AssetPipeline.for(app).register_precompile(%w[activemail/activemail.css])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer 'activemail.annotations' do |app|
|
|
17
|
+
ActiveSupport.on_load(:action_view) do
|
|
18
|
+
extensions = ActionView::Template.template_handler_extensions.grep(/\Ainky-/)
|
|
19
|
+
app.config.annotations.register_extensions(*extensions) { |annotation| /<!--\s*(#{annotation}):?\s*(.*) -->/ }
|
|
20
|
+
end
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
initializer 'activemail.action_mailer' do
|
|
21
24
|
ActiveSupport.on_load(:action_mailer) do
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# :null) at delivery time — a boot-time check would precede host config.
|
|
25
|
-
register_interceptor ActiveMail::Inliner::Interceptor
|
|
26
|
-
# activemail_inline_styles must be available to mailer layouts/views.
|
|
25
|
+
prepend ActiveMail::Rails::Rendering::MailerExtension
|
|
26
|
+
|
|
27
27
|
helper ActiveMail::StylesHelper
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'active_support/isolated_execution_state'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Rails
|
|
8
|
+
# Handlers only mark the scope; the pipeline runs once at its exit, so a
|
|
9
|
+
# layout wrapping a view never re-transpiles it.
|
|
10
|
+
module Rendering
|
|
11
|
+
STATE_KEY = :activemail_render_scope
|
|
12
|
+
|
|
13
|
+
class Scope
|
|
14
|
+
attr_accessor :inky_html
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def scoped
|
|
19
|
+
return yield if current
|
|
20
|
+
|
|
21
|
+
scope = Scope.new
|
|
22
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = scope
|
|
23
|
+
body = yield
|
|
24
|
+
scope.inky_html ? ActiveMail::Pipeline.call(body.to_s).html_safe : body
|
|
25
|
+
ensure
|
|
26
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = nil if scope
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def process(output, format)
|
|
30
|
+
scope = current
|
|
31
|
+
return ActiveMail.configuration.core.transpile(output) unless scope
|
|
32
|
+
|
|
33
|
+
scope.inky_html = true if format.nil? || format == :html
|
|
34
|
+
output
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def current
|
|
40
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module MailerExtension
|
|
45
|
+
def render_to_body(options = {})
|
|
46
|
+
ActiveMail::Rails::Rendering.scoped { super }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
require 'sorbet-runtime'
|
|
5
5
|
|
|
6
|
+
require_relative 'rendering'
|
|
7
|
+
|
|
6
8
|
module ActiveMail
|
|
7
9
|
module Rails
|
|
8
10
|
class TemplateHandler
|
|
@@ -37,7 +39,7 @@ module ActiveMail
|
|
|
37
39
|
else
|
|
38
40
|
engine_handler.call(template)
|
|
39
41
|
end
|
|
40
|
-
"ActiveMail::
|
|
42
|
+
"ActiveMail::Rails::Rendering.process(begin; #{compiled_source};end, #{template.format.inspect})"
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
module Composer
|
|
@@ -14,12 +14,11 @@ module ActiveMail
|
|
|
14
14
|
const :radius, String
|
|
15
15
|
const :border, T.nilable(String)
|
|
16
16
|
|
|
17
|
-
# color falls back to :button_text; border is opt-in (a "<variant>_border" token).
|
|
18
17
|
sig { params(tokens: ActiveMail::Tokens, variant: T.any(String, Symbol)).returns(ButtonStyle) }
|
|
19
18
|
def self.from(tokens, variant)
|
|
20
19
|
new(
|
|
21
20
|
background: tokens.color!(variant),
|
|
22
|
-
color: tokens.color("#{variant}_text")
|
|
21
|
+
color: tokens.color!("#{variant}_text"),
|
|
23
22
|
radius: tokens.radius!(:button),
|
|
24
23
|
border: tokens.color("#{variant}_border")
|
|
25
24
|
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
class Tokens
|
|
8
|
+
TokenMap = T.type_alias { T::Hash[Symbol, String] }
|
|
9
|
+
|
|
10
|
+
DEFAULT_COLORS = T.let(
|
|
11
|
+
{
|
|
12
|
+
primary: '#2a9d8f',
|
|
13
|
+
secondary: '#264653',
|
|
14
|
+
text: '#1a1a1a',
|
|
15
|
+
background: '#ffffff',
|
|
16
|
+
muted: '#6b7280',
|
|
17
|
+
border: '#e5e7eb',
|
|
18
|
+
button_text: '#ffffff'
|
|
19
|
+
}.freeze,
|
|
20
|
+
TokenMap
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
|
|
24
|
+
|
|
25
|
+
DEFAULT_FONTS = T.let(
|
|
26
|
+
{
|
|
27
|
+
body: SYSTEM_FONT_STACK,
|
|
28
|
+
heading: SYSTEM_FONT_STACK
|
|
29
|
+
}.freeze,
|
|
30
|
+
TokenMap
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
DEFAULT_SPACINGS = T.let(
|
|
34
|
+
{
|
|
35
|
+
xs: '4px',
|
|
36
|
+
sm: '8px',
|
|
37
|
+
md: '16px',
|
|
38
|
+
lg: '24px',
|
|
39
|
+
xl: '40px',
|
|
40
|
+
container_gutter: '16px'
|
|
41
|
+
}.freeze,
|
|
42
|
+
TokenMap
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
DEFAULT_RADII = T.let(
|
|
46
|
+
{
|
|
47
|
+
button: '4px',
|
|
48
|
+
box: '4px'
|
|
49
|
+
}.freeze,
|
|
50
|
+
TokenMap
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Optional colors resolve to another token (nil: unset) so components and the
|
|
54
|
+
# SCSS bridge read the same value whether or not the host configured them.
|
|
55
|
+
DERIVED_COLORS = T.let(
|
|
56
|
+
{
|
|
57
|
+
primary_text: :button_text,
|
|
58
|
+
primary_border: nil,
|
|
59
|
+
secondary_text: :button_text,
|
|
60
|
+
secondary_border: nil,
|
|
61
|
+
info_box_background: :background,
|
|
62
|
+
info_box_border: :border,
|
|
63
|
+
info_box_text: :text
|
|
64
|
+
}.freeze,
|
|
65
|
+
T::Hash[Symbol, T.nilable(Symbol)]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
GROUPS = T.let(
|
|
69
|
+
{
|
|
70
|
+
color: DEFAULT_COLORS,
|
|
71
|
+
font: DEFAULT_FONTS,
|
|
72
|
+
spacing: DEFAULT_SPACINGS,
|
|
73
|
+
radius: DEFAULT_RADII
|
|
74
|
+
}.freeze,
|
|
75
|
+
T::Hash[Symbol, TokenMap]
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -5,15 +5,15 @@ require 'sorbet-runtime'
|
|
|
5
5
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
class Tokens
|
|
8
|
-
# !default lets a host
|
|
9
|
-
# verbatim (trusted, app-controlled input) — not escaped.
|
|
8
|
+
# !default lets a host configure overrides upstream. Values are emitted
|
|
9
|
+
# verbatim (trusted, app-controlled input) — not escaped; nil becomes Sass null.
|
|
10
10
|
module ScssSerializer
|
|
11
11
|
extend T::Sig
|
|
12
12
|
|
|
13
|
-
sig { params(stores: T::Hash[Symbol,
|
|
13
|
+
sig { params(stores: T::Hash[Symbol, T::Hash[Symbol, T.nilable(String)]]).returns(String) }
|
|
14
14
|
def self.call(stores)
|
|
15
15
|
lines = stores.flat_map do |group, store|
|
|
16
|
-
store.map { |name, value| "$am-#{group}-#{name.to_s.tr('_', '-')}: #{value} !default;" }
|
|
16
|
+
store.map { |name, value| "$am-#{group}-#{name.to_s.tr('_', '-')}: #{value || 'null'} !default;" }
|
|
17
17
|
end
|
|
18
18
|
"#{lines.join("\n")}\n"
|
|
19
19
|
end
|