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
|
@@ -12,10 +12,11 @@ module ActiveMail
|
|
|
12
12
|
def inline(html)
|
|
13
13
|
# premailer is a hard runtime dependency (the default inliner), so require
|
|
14
14
|
# can't fail — unlike the optional Roadie adapter, which guards LoadError.
|
|
15
|
-
|
|
15
|
+
# Loaded on first use: premailer is slow to load and a :roadie or :null host never needs it.
|
|
16
|
+
require_relative 'premailer/document'
|
|
16
17
|
# warn_level NONE: premailer's un-inlinable-CSS warnings are noise at delivery
|
|
17
18
|
# time; the quality layer is where coverage gaps should surface.
|
|
18
|
-
|
|
19
|
+
Document.new(html, with_html_string: true, warn_level: ::Premailer::Warnings::NONE).to_inline_css
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
end
|
|
@@ -22,7 +22,7 @@ module ActiveMail
|
|
|
22
22
|
require 'roadie'
|
|
23
23
|
rescue LoadError
|
|
24
24
|
# Unify on Inliner::Error (LoadError is not a StandardError, so the
|
|
25
|
-
#
|
|
25
|
+
# Pipeline's rescue would otherwise miss it); the LoadError is #cause.
|
|
26
26
|
raise ActiveMail::Inliner::Error, "ActiveMail::Inliner::Roadie requires the 'roadie' gem. Add it to your Gemfile."
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'inliner/base'
|
|
7
|
+
require_relative 'inliner/premailer'
|
|
8
|
+
require_relative 'inliner/roadie'
|
|
9
|
+
require_relative 'inliner/null'
|
|
10
|
+
|
|
11
|
+
module ActiveMail
|
|
12
|
+
module Inliner
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
ADAPTERS = T.let(
|
|
16
|
+
{ premailer: Premailer, roadie: Roadie, null: Null }.freeze,
|
|
17
|
+
T::Hash[Symbol, T.class_of(Base)]
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
Setting = T.type_alias { T.any(Symbol, Base, T.class_of(Base)) }
|
|
21
|
+
|
|
22
|
+
sig { params(setting: Setting).returns(Base) }
|
|
23
|
+
def self.resolve(setting)
|
|
24
|
+
return setting if setting.is_a?(Base)
|
|
25
|
+
return setting.new if setting.is_a?(Class) && setting < Base
|
|
26
|
+
|
|
27
|
+
adapter = ADAPTERS[setting] if setting.is_a?(Symbol)
|
|
28
|
+
return adapter.new if adapter
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "unknown inliner #{setting.inspect}, expected one of #{ADAPTERS.keys.inspect}, an Inliner::Base subclass, or an instance"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -7,19 +7,20 @@ require_relative 'libxml'
|
|
|
7
7
|
|
|
8
8
|
module ActiveMail
|
|
9
9
|
# Surfaces libxml2 recover-mode repairs that would otherwise silently change
|
|
10
|
-
# the rendered email, honoring
|
|
10
|
+
# the rendered email, honoring the config's on_parse_error.
|
|
11
11
|
class ParseErrorReporter
|
|
12
12
|
extend T::Sig
|
|
13
13
|
|
|
14
|
-
sig { params(known_tags: T::Array[String]).void }
|
|
15
|
-
def initialize(known_tags)
|
|
14
|
+
sig { params(known_tags: T::Array[String], config: ActiveMail::Configuration).void }
|
|
15
|
+
def initialize(known_tags, config)
|
|
16
|
+
@config = config
|
|
16
17
|
# dup.freeze: the caller's array stays decoupled and cannot be mutated under us.
|
|
17
18
|
@known_tags = T.let(known_tags.dup.freeze, T::Array[String])
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
sig { params(errors: T::Array[Nokogiri::XML::SyntaxError]).void }
|
|
21
22
|
def call(errors)
|
|
22
|
-
mode =
|
|
23
|
+
mode = @config.on_parse_error
|
|
23
24
|
return if mode == :ignore
|
|
24
25
|
|
|
25
26
|
relevant = errors.reject { |error| known_tag_error?(error) }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Pipeline
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(html: String, config: ActiveMail::Configuration).returns(String) }
|
|
11
|
+
def self.call(html, config: ActiveMail.configuration)
|
|
12
|
+
inline(config.resolved_inliner, config.core.transpile(html))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
sig { params(inliner: ActiveMail::Inliner::Base, html: String).returns(String) }
|
|
16
|
+
def self.inline(inliner, html)
|
|
17
|
+
inliner.inline(html)
|
|
18
|
+
rescue ActiveMail::Inliner::Error
|
|
19
|
+
raise
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
# A silently non-inlined mail is worse than a failed one.
|
|
22
|
+
raise ActiveMail::Inliner::Error, "[#{inliner.class}] CSS inlining failed: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
private_class_method :inline
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'preview_renderer'
|
|
7
|
+
require_relative 'rule'
|
|
8
|
+
|
|
9
|
+
module ActiveMail
|
|
10
|
+
module Quality
|
|
11
|
+
# The built-in rules that read the Mail message (subject, text part) rather than the HTML.
|
|
12
|
+
class BuiltinMessageRules
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
sig { returns(T::Array[Rule]) }
|
|
16
|
+
def rules
|
|
17
|
+
[
|
|
18
|
+
Rule.message(:translation_missing_text) { |mail| translation_missing_text_message(mail) },
|
|
19
|
+
Rule.message(:html_part_present) { |mail| html_part_present_message(mail) },
|
|
20
|
+
Rule.message(:text_part_present) { |mail| text_part_present_message(mail) },
|
|
21
|
+
Rule.message(:text_part_plain) { |mail| text_part_plain_message(mail) },
|
|
22
|
+
Rule.message(:text_entities) { |mail| text_entities_message(mail) }
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
29
|
+
def text_entities_message(mail)
|
|
30
|
+
found = PreviewRenderer.text_body(mail).scan(BuiltinRules::HTML_ENTITY).uniq
|
|
31
|
+
"text/plain part contains HTML entities: #{found.first(3).join(', ')}" unless found.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
35
|
+
def translation_missing_text_message(mail)
|
|
36
|
+
text = "#{mail.subject}\n#{PreviewRenderer.text_body(mail)}"
|
|
37
|
+
'subject or text part contains a missing i18n translation' if text.match?(BuiltinRules::MISSING_TRANSLATION)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
41
|
+
def html_part_present_message(mail)
|
|
42
|
+
'message has no HTML part' if PreviewRenderer.html_body(mail).empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
46
|
+
def text_part_present_message(mail)
|
|
47
|
+
return if mail.multipart? && !PreviewRenderer.text_body(mail).strip.empty?
|
|
48
|
+
|
|
49
|
+
'message must be multipart with a non-empty text/plain part'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
53
|
+
def text_part_plain_message(mail)
|
|
54
|
+
'text/plain part contains HTML markup' if PreviewRenderer.text_body(mail).match?(BuiltinRules::MARKUP_IN_TEXT)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
require_relative '../libxml'
|
|
8
|
+
require_relative 'builtin_message_rules'
|
|
9
|
+
require_relative 'preview_renderer'
|
|
10
|
+
require_relative 'rule'
|
|
11
|
+
|
|
12
|
+
module ActiveMail
|
|
13
|
+
module Quality
|
|
14
|
+
class BuiltinRules
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
NAMES = T.let(
|
|
18
|
+
%i[
|
|
19
|
+
max_bytes parse_error table_role img_alt lang min_full_doc_bytes translation_missing single_root
|
|
20
|
+
translation_missing_text html_part_present text_part_present text_part_plain
|
|
21
|
+
title anchor_href placeholder unsupported_color text_entities
|
|
22
|
+
].freeze,
|
|
23
|
+
T::Array[Symbol]
|
|
24
|
+
)
|
|
25
|
+
MARKUP_IN_TEXT = %r{<(?:table|div|span|p|br|a)[\s>/]}i
|
|
26
|
+
MISSING_TRANSLATION = /translation[ _]missing/i
|
|
27
|
+
# An unresolved `{{variable}}` of a user-editable template; public so a host can refuse it before sending.
|
|
28
|
+
PLACEHOLDER = /\{\{[^{}]+\}\}/
|
|
29
|
+
# Clients drop or mangle these CSS color forms in a style attribute.
|
|
30
|
+
UNSUPPORTED_COLOR = /rgba?\([^)]*%[^)]*\)|hsla?\(|color-mix\(|\bvar\(/i
|
|
31
|
+
HTML_ENTITY = /&(?:[a-z][a-z0-9]*|#\d+|#x\h+);/i
|
|
32
|
+
STYLE_AND_SCRIPT = %r{<(style|script)\b[\s\S]*?</\1\s*>}i
|
|
33
|
+
ROOTS = T.let({ html: /<html[\s>]/i, body: /<body[\s>]/i }.freeze, T::Hash[Symbol, Regexp])
|
|
34
|
+
|
|
35
|
+
sig { params(max_bytes: Integer, min_full_doc_bytes: Integer).void }
|
|
36
|
+
def initialize(max_bytes:, min_full_doc_bytes:)
|
|
37
|
+
# A non-positive threshold would silently disable (or invert) a check.
|
|
38
|
+
@max_bytes = T.let(::ActiveMail.positive_integer!(:max_bytes, max_bytes), Integer)
|
|
39
|
+
@min_full_doc_bytes = T.let(::ActiveMail.positive_integer!(:min_full_doc_bytes, min_full_doc_bytes), Integer)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(T::Array[Rule]) }
|
|
43
|
+
def rules
|
|
44
|
+
structure_rules + content_rules + BuiltinMessageRules.new.rules
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
sig { returns(T::Array[Rule]) }
|
|
50
|
+
def structure_rules
|
|
51
|
+
[
|
|
52
|
+
Rule.html(:max_bytes) { |_doc, html| size_message(html) },
|
|
53
|
+
# The Core surfaces libxml2 repairs on input; the validation layer must not
|
|
54
|
+
# silently bless malformed *output* (mismatched tags, bad entities) before send.
|
|
55
|
+
Rule.html(:parse_error) { |doc, _html| parse_error_message(doc) },
|
|
56
|
+
Rule.html(:table_role) { |doc, _html| table_role_message(doc) },
|
|
57
|
+
Rule.html(:img_alt) { |doc, _html| img_alt_message(doc) },
|
|
58
|
+
Rule.html(:anchor_href) { |doc, _html| anchor_href_message(doc) },
|
|
59
|
+
Rule.html(:unsupported_color) { |doc, html| unsupported_color_message(doc, html) }
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { returns(T::Array[Rule]) }
|
|
64
|
+
def content_rules
|
|
65
|
+
[
|
|
66
|
+
Rule.html(:translation_missing) { |_doc, html| translation_missing_message(html) },
|
|
67
|
+
Rule.html(:single_root) { |_doc, html| single_root_messages(html) },
|
|
68
|
+
Rule.html(:min_full_doc_bytes) { |_doc, html| min_full_doc_message(html) },
|
|
69
|
+
Rule.html(:lang) { |doc, html| lang_message(doc, html) },
|
|
70
|
+
Rule.html(:title) { |doc, html| title_message(doc, html) },
|
|
71
|
+
Rule.html(:placeholder) { |_doc, html| placeholder_message(html) }
|
|
72
|
+
]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
sig { params(html: String).returns(T.nilable(String)) }
|
|
76
|
+
def size_message(html)
|
|
77
|
+
return if html.bytesize <= @max_bytes
|
|
78
|
+
|
|
79
|
+
"HTML is #{html.bytesize} bytes, exceeds #{@max_bytes} (Gmail clipping)"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
|
|
83
|
+
def parse_error_message(doc)
|
|
84
|
+
errors = doc.errors.reject { |e| e.code == LIBXML_UNKNOWN_TAG_CODE }
|
|
85
|
+
return if errors.empty?
|
|
86
|
+
|
|
87
|
+
"malformed HTML: #{errors.first(3).map { |e| e.message.to_s.strip }.join('; ')}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
|
|
91
|
+
def table_role_message(doc)
|
|
92
|
+
offenders = doc.css('table').count { |table| table['role'] != 'presentation' }
|
|
93
|
+
%(#{offenders} <table> missing role="presentation") unless offenders.zero?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
|
|
97
|
+
def img_alt_message(doc)
|
|
98
|
+
offenders = doc.css('img').count { |img| !img.key?('alt') }
|
|
99
|
+
"#{offenders} <img> missing an alt attribute" unless offenders.zero?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
|
|
103
|
+
def anchor_href_message(doc)
|
|
104
|
+
offenders = doc.css('a').count { |anchor| anchor['href'].to_s.strip.empty? }
|
|
105
|
+
"#{offenders} <a> without a href" unless offenders.zero?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The raw-string pre-check keeps the per-node scan off the common, clean path.
|
|
109
|
+
sig { params(doc: Nokogiri::XML::Document, html: String).returns(T.nilable(String)) }
|
|
110
|
+
def unsupported_color_message(doc, html)
|
|
111
|
+
return unless html.match?(UNSUPPORTED_COLOR)
|
|
112
|
+
|
|
113
|
+
offenders = doc.xpath('//*[@style]').filter_map { |node| node['style'].to_s[UNSUPPORTED_COLOR] }
|
|
114
|
+
return if offenders.empty?
|
|
115
|
+
|
|
116
|
+
"#{offenders.size} inline style(s) use a color clients ignore (rgb(%), hsl, color-mix, var()): #{offenders.uniq.first(3).join(', ')}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sig { params(html: String).returns(T.nilable(String)) }
|
|
120
|
+
def translation_missing_message(html)
|
|
121
|
+
'rendered HTML contains a missing i18n translation' if html.match?(MISSING_TRANSLATION)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Counted on the raw string: libxml2 silently merges duplicate roots.
|
|
125
|
+
sig { params(html: String).returns(T::Array[String]) }
|
|
126
|
+
def single_root_messages(html)
|
|
127
|
+
ROOTS.filter_map do |tag, pattern|
|
|
128
|
+
count = html.scan(pattern).size
|
|
129
|
+
"#{count} <#{tag}> roots, expected at most one" if count > 1
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
sig { params(html: String).returns(T.nilable(String)) }
|
|
134
|
+
def min_full_doc_message(html)
|
|
135
|
+
return unless ::ActiveMail.full_document?(html) && html.bytesize < @min_full_doc_bytes
|
|
136
|
+
|
|
137
|
+
"full document is only #{html.bytesize} bytes (under #{@min_full_doc_bytes})"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
sig { params(doc: Nokogiri::XML::Document, html: String).returns(T.nilable(String)) }
|
|
141
|
+
def lang_message(doc, html)
|
|
142
|
+
return unless ::ActiveMail.full_document?(html)
|
|
143
|
+
|
|
144
|
+
html_tag = doc.at_css('html')
|
|
145
|
+
lang = html_tag && html_tag['lang']
|
|
146
|
+
'<html> must declare a non-blank lang attribute' if lang.nil? || lang.strip.empty?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
sig { params(doc: Nokogiri::XML::Document, html: String).returns(T.nilable(String)) }
|
|
150
|
+
def title_message(doc, html)
|
|
151
|
+
return unless ::ActiveMail.full_document?(html)
|
|
152
|
+
|
|
153
|
+
'<head> must contain a non-blank <title>' if doc.at_css('title')&.text.to_s.strip.empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
sig { params(html: String).returns(T.nilable(String)) }
|
|
157
|
+
def placeholder_message(html)
|
|
158
|
+
return unless html.include?('{{')
|
|
159
|
+
|
|
160
|
+
found = html.gsub(STYLE_AND_SCRIPT, '').scan(PLACEHOLDER).uniq
|
|
161
|
+
"unresolved placeholder(s): #{found.first(3).join(', ')}" unless found.empty?
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require 'i18n'
|
|
4
5
|
require 'sorbet-runtime'
|
|
5
6
|
|
|
6
7
|
require_relative 'guard'
|
|
8
|
+
require_relative 'output_dir'
|
|
9
|
+
require_relative 'output_filter'
|
|
10
|
+
require_relative 'rule_exemptions'
|
|
7
11
|
|
|
8
12
|
module ActiveMail
|
|
9
13
|
module Quality
|
|
10
14
|
class Configuration
|
|
11
15
|
extend T::Sig
|
|
16
|
+
include RuleExemptions
|
|
17
|
+
|
|
18
|
+
FORMATS = T.let(%i[html text].freeze, T::Array[Symbol])
|
|
19
|
+
DEFAULT_LOCALES = T.let(->(_preview) { [I18n.default_locale] }, Proc)
|
|
12
20
|
|
|
13
21
|
sig { returns(Guard) }
|
|
14
22
|
attr_reader :guard
|
|
@@ -25,9 +33,7 @@ module ActiveMail
|
|
|
25
33
|
|
|
26
34
|
sig { params(value: String).void }
|
|
27
35
|
def output_dir=(value)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@output_dir = value
|
|
36
|
+
@output_dir = OutputDir.validate!(value)
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
# Preview keys ("preview_name#email") that MUST render and pass the guard;
|
|
@@ -35,21 +41,110 @@ module ActiveMail
|
|
|
35
41
|
# reported.
|
|
36
42
|
sig { returns(T::Array[String]) }
|
|
37
43
|
def required_previews
|
|
38
|
-
@
|
|
44
|
+
@preview_keys.fetch(:required).dup.freeze
|
|
39
45
|
end
|
|
40
46
|
|
|
41
|
-
sig { params(value: T.untyped).
|
|
47
|
+
sig { params(value: T.untyped).void }
|
|
42
48
|
def required_previews=(value)
|
|
43
|
-
|
|
49
|
+
@preview_keys[:required] = preview_keys(value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Preview keys that may legitimately decline to send (NullMail); any other
|
|
53
|
+
# preview returning one is a violation.
|
|
54
|
+
sig { returns(T::Array[String]) }
|
|
55
|
+
def allow_declined
|
|
56
|
+
@preview_keys.fetch(:allow_declined).dup.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { params(value: T.untyped).void }
|
|
60
|
+
def allow_declined=(value)
|
|
61
|
+
@preview_keys[:allow_declined] = preview_keys(value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { params(key: String).returns(T::Boolean) }
|
|
65
|
+
def allow_declined?(key)
|
|
66
|
+
@preview_keys.fetch(:allow_declined).include?(key)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { returns(T.any(T::Array[Symbol], Proc)) }
|
|
70
|
+
attr_reader :locales
|
|
71
|
+
|
|
72
|
+
sig { params(value: T.untyped).void }
|
|
73
|
+
def locales=(value)
|
|
74
|
+
@locales = value.is_a?(Proc) ? value : normalize_locales(value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { params(preview: T.untyped).returns(T::Array[Symbol]) }
|
|
78
|
+
def locales_for(preview)
|
|
79
|
+
value = @locales
|
|
80
|
+
value.is_a?(Proc) ? normalize_locales(value.call(preview)) : value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
sig { returns(T::Array[Symbol]) }
|
|
84
|
+
def formats
|
|
85
|
+
@formats.dup.freeze
|
|
86
|
+
end
|
|
44
87
|
|
|
45
|
-
|
|
88
|
+
sig { params(value: T.untyped).void }
|
|
89
|
+
def formats=(value)
|
|
90
|
+
raise TypeError, "formats must be an Array, got #{value.class}" unless value.is_a?(Array)
|
|
91
|
+
|
|
92
|
+
formats = value.map(&:to_sym)
|
|
93
|
+
unknown = formats - FORMATS
|
|
94
|
+
raise ArgumentError, "unknown format(s): #{unknown.inspect}, expected a subset of #{FORMATS.inspect}" unless unknown.empty?
|
|
95
|
+
raise ArgumentError, 'formats must not be empty' if formats.empty?
|
|
96
|
+
|
|
97
|
+
@formats = formats.uniq
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ->(preview, email, locale, &block) wrapping each rendering; it must call block.
|
|
101
|
+
sig { returns(T.nilable(Proc)) }
|
|
102
|
+
attr_reader :around_each
|
|
103
|
+
|
|
104
|
+
sig { params(value: T.untyped).void }
|
|
105
|
+
def around_each=(value)
|
|
106
|
+
raise TypeError, "around_each must be a Proc or nil, got #{value.class}" unless value.nil? || value.is_a?(Proc)
|
|
107
|
+
|
|
108
|
+
@around_each = value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Applied to written files only: the Guard always sees the unfiltered content.
|
|
112
|
+
sig { returns(T::Array[Proc]) }
|
|
113
|
+
attr_reader :output_filters
|
|
114
|
+
|
|
115
|
+
sig { params(value: T.untyped).void }
|
|
116
|
+
def output_filters=(value)
|
|
117
|
+
raise TypeError, "output_filters must be an Array, got #{value.class}" unless value.is_a?(Array)
|
|
118
|
+
|
|
119
|
+
@output_filters = value.map { |filter| OutputFilter.coerce(filter) }
|
|
46
120
|
end
|
|
47
121
|
|
|
48
122
|
sig { void }
|
|
49
123
|
def initialize
|
|
50
124
|
@guard = T.let(Guard.new, Guard)
|
|
51
125
|
@output_dir = T.let('tmp/activemail_previews', String)
|
|
52
|
-
@
|
|
126
|
+
@preview_keys = T.let({ required: [], allow_declined: [] }, T::Hash[Symbol, T::Array[String]])
|
|
127
|
+
@locales = T.let(DEFAULT_LOCALES, T.any(T::Array[Symbol], Proc))
|
|
128
|
+
@formats = T.let(FORMATS, T::Array[Symbol])
|
|
129
|
+
@around_each = T.let(nil, T.nilable(Proc))
|
|
130
|
+
@output_filters = T.let([], T::Array[Proc])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
sig { params(value: T.untyped).returns(T::Array[String]) }
|
|
136
|
+
def preview_keys(value)
|
|
137
|
+
raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_a'" unless value.respond_to?(:to_a)
|
|
138
|
+
|
|
139
|
+
value.to_a.map(&:to_s)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
sig { params(value: T.untyped).returns(T::Array[Symbol]) }
|
|
143
|
+
def normalize_locales(value)
|
|
144
|
+
raise TypeError, "locales must be an Array, got #{value.class}" unless value.is_a?(Array)
|
|
145
|
+
raise ArgumentError, 'locales must not be empty' if value.empty?
|
|
146
|
+
|
|
147
|
+
value.map(&:to_sym)
|
|
53
148
|
end
|
|
54
149
|
end
|
|
55
150
|
end
|