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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
module ActiveMail
|
|
8
|
+
module Quality
|
|
9
|
+
# RenderAll deletes inside this directory, so it must not escape the host root.
|
|
10
|
+
module OutputDir
|
|
11
|
+
class << self
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(value: String).returns(String) }
|
|
15
|
+
def validate!(value)
|
|
16
|
+
raise ArgumentError, 'output_dir must not be blank' if value.strip.empty?
|
|
17
|
+
raise ArgumentError, "output_dir must be relative, got #{value.inspect}" if Pathname(value).absolute?
|
|
18
|
+
|
|
19
|
+
segments = value.split(%r{[/\\]}).reject(&:empty?)
|
|
20
|
+
raise ArgumentError, "output_dir must not contain '..', got #{value.inspect}" if segments.include?('..')
|
|
21
|
+
raise ArgumentError, "output_dir must not be '.', got #{value.inspect}" if segments.all?('.')
|
|
22
|
+
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { params(value: String, root: T.any(String, Pathname)).returns(Pathname) }
|
|
27
|
+
def resolve(value, root)
|
|
28
|
+
validate!(value)
|
|
29
|
+
base = real(root.to_s)
|
|
30
|
+
resolved = real(File.expand_path(value, base.to_s))
|
|
31
|
+
raise ArgumentError, "output_dir #{value.inspect} must resolve strictly under #{base}" unless resolved.to_s.start_with?("#{base}/")
|
|
32
|
+
|
|
33
|
+
resolved
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Symlinks are resolved on the deepest existing ancestor, so a link cannot smuggle the target out.
|
|
39
|
+
sig { params(path: String).returns(Pathname) }
|
|
40
|
+
def real(path)
|
|
41
|
+
missing = []
|
|
42
|
+
existing = Pathname(File.expand_path(path))
|
|
43
|
+
until existing.exist?
|
|
44
|
+
missing.unshift(existing.basename)
|
|
45
|
+
existing = existing.dirname
|
|
46
|
+
end
|
|
47
|
+
missing.reduce(existing.realpath) { |acc, segment| acc.join(segment) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require '
|
|
5
|
-
require 'pathname'
|
|
4
|
+
require 'i18n'
|
|
6
5
|
require 'sorbet-runtime'
|
|
7
6
|
|
|
8
7
|
module ActiveMail
|
|
@@ -11,29 +10,29 @@ module ActiveMail
|
|
|
11
10
|
class << self
|
|
12
11
|
extend T::Sig
|
|
13
12
|
|
|
14
|
-
# preview and message are external/dynamic ActionMailer & Mail objects.
|
|
15
|
-
sig { params(preview: T.untyped, email: String).returns(String) }
|
|
16
|
-
def render(preview, email)
|
|
17
|
-
html_body(preview.call(email))
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# The single source for the "preview_name#email" key format (config keys,
|
|
21
|
-
# render_all, the Minitest helper).
|
|
22
13
|
sig { params(preview: T.untyped, email: String).returns(String) }
|
|
23
14
|
def key(preview, email)
|
|
24
15
|
"#{preview.preview_name}##{email}"
|
|
25
16
|
end
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
18
|
+
# The message renders lazily: every read of it must happen inside the block.
|
|
19
|
+
sig do
|
|
20
|
+
params(preview: T.untyped, email: String, locale: Symbol, around_each: T.nilable(Proc), block: T.proc.params(mail: T.untyped).returns(T.untyped))
|
|
21
|
+
.returns(T.untyped)
|
|
22
|
+
end
|
|
23
|
+
def within(preview, email, locale, around_each, &block)
|
|
24
|
+
ran = T.let(false, T::Boolean)
|
|
25
|
+
result = T.let(nil, T.untyped)
|
|
26
|
+
run = lambda do
|
|
27
|
+
ran = true
|
|
28
|
+
result = I18n.with_locale(locale) { block.call(mail(preview.call(email, locale: locale))) }
|
|
29
|
+
end
|
|
30
|
+
around_each ? around_each.call(preview, email, locale, &run) : run.call
|
|
31
|
+
raise ArgumentError, 'around_each must yield' unless ran
|
|
32
|
+
|
|
33
|
+
result
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
# [] when ActionMailer previews aren't loaded (host has none / previews disabled).
|
|
37
36
|
sig { returns(T::Array[[T.untyped, String]]) }
|
|
38
37
|
def all
|
|
39
38
|
return [] unless defined?(ActionMailer::Preview)
|
|
@@ -43,26 +42,40 @@ module ActiveMail
|
|
|
43
42
|
end
|
|
44
43
|
end
|
|
45
44
|
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
# '' for a plain-text-only message: plain text must not be validated as HTML.
|
|
46
|
+
sig { params(mail: T.untyped).returns(String) }
|
|
47
|
+
def html_body(mail)
|
|
48
|
+
body_of(mail, mail.html_part, [nil, 'text/html'])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(mail: T.untyped).returns(String) }
|
|
52
|
+
def text_body(mail)
|
|
53
|
+
body_of(mail, mail.text_part, ['text/plain'])
|
|
54
|
+
end
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
# NullMail answers every respond_to? and returns nil: it is its own message.
|
|
57
|
+
sig { params(message: T.untyped).returns(T.untyped) }
|
|
58
|
+
def mail(message)
|
|
59
|
+
(message.message if message.respond_to?(:message)) || message
|
|
55
60
|
end
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
sig { params(mail: T.untyped).returns(T::Boolean) }
|
|
63
|
+
def declined?(mail)
|
|
64
|
+
mail.class.name.to_s.end_with?('::NullMail')
|
|
65
|
+
end
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
sig { params(config: Configuration, previews: T::Array[[T.untyped, String]]).returns(T::Array[[T.untyped, String, Symbol]]) }
|
|
68
|
+
def cases(config, previews = all)
|
|
69
|
+
previews.flat_map { |preview, email| config.locales_for(preview).map { |locale| [preview, email, locale] } }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
64
73
|
|
|
65
|
-
|
|
74
|
+
# A single-part body counts only when its content-type says so (unset counts as HTML).
|
|
75
|
+
sig { params(mail: T.untyped, part: T.untyped, mime_types: T::Array[T.nilable(String)]).returns(String) }
|
|
76
|
+
def body_of(mail, part, mime_types)
|
|
77
|
+
body = part&.body || (mail.body if !mail.multipart? && mime_types.include?(mail.mime_type))
|
|
78
|
+
body ? body.decoded : ''
|
|
66
79
|
end
|
|
67
80
|
end
|
|
68
81
|
end
|
|
@@ -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
|
-
FileUtils.mkdir_p(@output_root)
|
|
32
|
-
|
|
33
|
-
render_failures = {}
|
|
34
|
-
guard_failures = {}
|
|
43
|
+
clean_previous_output
|
|
35
44
|
previews = PreviewRenderer.all
|
|
36
|
-
|
|
45
|
+
PreviewRenderer.cases(@config, previews).each { |preview, email, locale| render_locale(preview, email, locale) }
|
|
37
46
|
|
|
38
47
|
Result.new(
|
|
39
|
-
discovered: previews.size, rendered: rendered, render_failures: render_failures,
|
|
40
|
-
guard_failures: guard_failures, broken_required: broken_required(previews
|
|
48
|
+
output_root: @output_root, discovered: previews.size, rendered: @rendered, render_failures: @render_failures,
|
|
49
|
+
guard_failures: @guard_failures, broken_required: broken_required(previews)
|
|
41
50
|
)
|
|
42
51
|
end
|
|
43
52
|
|
|
44
53
|
private
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
sig { void }
|
|
56
|
+
def clean_previous_output
|
|
57
|
+
ensure_owned!
|
|
58
|
+
@output_root.glob('**/*.{html,txt}').each { |path| path.delete if path.file? }
|
|
59
|
+
prune_empty_directories
|
|
60
|
+
FileUtils.mkdir_p(@output_root)
|
|
61
|
+
FileUtils.touch(@output_root.join(MARKER))
|
|
53
62
|
end
|
|
54
|
-
|
|
63
|
+
|
|
64
|
+
# The marker proves a previous run owns the directory: without it, an existing
|
|
65
|
+
# non-empty output_dir (public/, app/...) is never touched.
|
|
66
|
+
sig { void }
|
|
67
|
+
def ensure_owned!
|
|
68
|
+
return if !@output_root.exist? || @output_root.join(MARKER).exist? || @output_root.empty?
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "output_dir #{@output_root} is not empty and was not created by activemail (no #{MARKER}); use a dedicated directory"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { void }
|
|
74
|
+
def prune_empty_directories
|
|
75
|
+
@output_root.glob('**/*/').sort_by { |dir| -dir.to_s.size }.each { |dir| dir.rmdir if dir.empty? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A required preview that fails, OR is never discovered at all (typo / deleted), is broken.
|
|
79
|
+
sig { params(previews: T::Array[[T.untyped, String]]).returns(T::Array[String]) }
|
|
80
|
+
def broken_required(previews)
|
|
55
81
|
required = @config.required_previews
|
|
56
82
|
discovered = previews.map { |preview, email| PreviewRenderer.key(preview, email) }
|
|
57
|
-
(
|
|
83
|
+
(@failed_keys.to_a & required) | (required - discovered)
|
|
58
84
|
end
|
|
59
85
|
|
|
60
|
-
sig
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
path = render_one(preview, email, render_failures, key)
|
|
67
|
-
next false unless path
|
|
68
|
-
|
|
69
|
-
violations = @config.guard.violations(File.read(path))
|
|
70
|
-
guard_failures[key] = violations unless violations.empty?
|
|
71
|
-
true
|
|
72
|
-
end
|
|
86
|
+
sig { params(preview: T.untyped, email: String, locale: Symbol).void }
|
|
87
|
+
def render_locale(preview, email, locale)
|
|
88
|
+
key = PreviewRenderer.key(preview, email)
|
|
89
|
+
label = locale == I18n.default_locale ? key : "#{key} [#{locale}]"
|
|
90
|
+
violations = render_one(preview, email, locale, label)
|
|
91
|
+
record(key, label, violations)
|
|
73
92
|
end
|
|
74
93
|
|
|
75
|
-
sig
|
|
76
|
-
|
|
94
|
+
sig { params(key: String, label: String, violations: T.nilable(T::Array[Guard::Violation])).void }
|
|
95
|
+
def record(key, label, violations)
|
|
96
|
+
return @failed_keys << key if violations.nil?
|
|
97
|
+
|
|
98
|
+
@rendered += 1
|
|
99
|
+
return if violations.empty?
|
|
100
|
+
|
|
101
|
+
@failed_keys << key
|
|
102
|
+
@guard_failures[label] = violations
|
|
77
103
|
end
|
|
78
|
-
|
|
79
|
-
|
|
104
|
+
|
|
105
|
+
sig { params(preview: T.untyped, email: String, locale: Symbol, label: String).returns(T.nilable(T::Array[Guard::Violation])) }
|
|
106
|
+
def render_one(preview, email, locale, label)
|
|
107
|
+
PreviewRenderer.within(preview, email, locale, @config.around_each) do |mail|
|
|
108
|
+
next [] if PreviewRenderer.declined?(mail)
|
|
109
|
+
|
|
110
|
+
write(preview, email, locale, mail)
|
|
111
|
+
@config.guard.violations(mail)
|
|
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,8 +6,6 @@ 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
|
|
|
@@ -17,9 +15,9 @@ module ActiveMail
|
|
|
17
15
|
@violations = T.let([], T::Array[Guard::Violation])
|
|
18
16
|
end
|
|
19
17
|
|
|
20
|
-
sig { params(
|
|
21
|
-
def matches?(
|
|
22
|
-
@violations = @guard.violations(
|
|
18
|
+
sig { params(html_or_message: T.untyped).returns(T::Boolean) }
|
|
19
|
+
def matches?(html_or_message)
|
|
20
|
+
@violations = @guard.violations(html_or_message)
|
|
23
21
|
@violations.empty?
|
|
24
22
|
end
|
|
25
23
|
|
|
@@ -42,7 +40,7 @@ module ActiveMail
|
|
|
42
40
|
|
|
43
41
|
sig { returns(String) }
|
|
44
42
|
def formatted_violations
|
|
45
|
-
@violations.map { |v| " -
|
|
43
|
+
@violations.map { |v| " - #{v}" }.join("\n")
|
|
46
44
|
end
|
|
47
45
|
end
|
|
48
46
|
end
|
|
@@ -50,8 +48,6 @@ module ActiveMail
|
|
|
50
48
|
end
|
|
51
49
|
|
|
52
50
|
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
51
|
module RSpec
|
|
56
52
|
module Matchers
|
|
57
53
|
extend T::Sig
|
|
@@ -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
|
|
@@ -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,77 @@
|
|
|
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
|
+
}.freeze,
|
|
41
|
+
TokenMap
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
DEFAULT_RADII = T.let(
|
|
45
|
+
{
|
|
46
|
+
button: '4px',
|
|
47
|
+
box: '4px'
|
|
48
|
+
}.freeze,
|
|
49
|
+
TokenMap
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Optional colors resolve to another token (nil: unset) so components and the
|
|
53
|
+
# SCSS bridge read the same value whether or not the host configured them.
|
|
54
|
+
DERIVED_COLORS = T.let(
|
|
55
|
+
{
|
|
56
|
+
primary_text: :button_text,
|
|
57
|
+
primary_border: nil,
|
|
58
|
+
secondary_text: :button_text,
|
|
59
|
+
secondary_border: nil,
|
|
60
|
+
info_box_background: :background,
|
|
61
|
+
info_box_border: :border,
|
|
62
|
+
info_box_text: :text
|
|
63
|
+
}.freeze,
|
|
64
|
+
T::Hash[Symbol, T.nilable(Symbol)]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
GROUPS = T.let(
|
|
68
|
+
{
|
|
69
|
+
color: DEFAULT_COLORS,
|
|
70
|
+
font: DEFAULT_FONTS,
|
|
71
|
+
spacing: DEFAULT_SPACINGS,
|
|
72
|
+
radius: DEFAULT_RADII
|
|
73
|
+
}.freeze,
|
|
74
|
+
T::Hash[Symbol, TokenMap]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
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
|