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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -2
  3. data/README.md +141 -56
  4. data/UPGRADING.md +185 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +44 -24
  7. data/app/assets/stylesheets/activemail/_grid.scss +40 -14
  8. data/app/assets/stylesheets/activemail/_settings.scss +23 -10
  9. data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
  10. data/app/assets/stylesheets/activemail/activemail.scss +6 -5
  11. data/app/helpers/active_mail/styles_helper.rb +14 -15
  12. data/app/views/layouts/activemail/_document_head.html.erb +18 -0
  13. data/app/views/layouts/activemail/mailer.html.inky-erb +1 -22
  14. data/lib/activemail/asset_pipeline/base.rb +47 -0
  15. data/lib/activemail/asset_pipeline/null.rb +19 -0
  16. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  17. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  18. data/lib/activemail/asset_pipeline.rb +35 -0
  19. data/lib/activemail/components/base.rb +35 -39
  20. data/lib/activemail/components/block_grid.rb +11 -4
  21. data/lib/activemail/components/button.rb +5 -28
  22. data/lib/activemail/components/button_renderer.rb +69 -0
  23. data/lib/activemail/components/callout.rb +4 -5
  24. data/lib/activemail/components/center.rb +10 -10
  25. data/lib/activemail/components/columns.rb +58 -15
  26. data/lib/activemail/components/container.rb +13 -6
  27. data/lib/activemail/components/cta.rb +9 -33
  28. data/lib/activemail/components/h_line.rb +3 -5
  29. data/lib/activemail/components/info_box.rb +6 -12
  30. data/lib/activemail/components/menu.rb +3 -4
  31. data/lib/activemail/components/menu_item.rb +10 -3
  32. data/lib/activemail/components/row.rb +3 -4
  33. data/lib/activemail/components/spacer.rb +16 -12
  34. data/lib/activemail/components/table_component.rb +23 -0
  35. data/lib/activemail/components/wrapper.rb +10 -4
  36. data/lib/activemail/configuration.rb +43 -57
  37. data/lib/activemail/errors.rb +15 -0
  38. data/lib/activemail/inliner/base.rb +2 -10
  39. data/lib/activemail/inliner/null.rb +0 -5
  40. data/lib/activemail/inliner/premailer/document.rb +100 -0
  41. data/lib/activemail/inliner/premailer.rb +3 -2
  42. data/lib/activemail/inliner/roadie.rb +1 -1
  43. data/lib/activemail/inliner.rb +33 -0
  44. data/lib/activemail/parse_error_reporter.rb +5 -4
  45. data/lib/activemail/pipeline.rb +26 -0
  46. data/lib/activemail/quality/builtin_message_rules.rb +58 -0
  47. data/lib/activemail/quality/builtin_rules.rb +165 -0
  48. data/lib/activemail/quality/configuration.rb +103 -8
  49. data/lib/activemail/quality/guard.rb +61 -83
  50. data/lib/activemail/quality/minitest.rb +56 -27
  51. data/lib/activemail/quality/output_dir.rb +52 -0
  52. data/lib/activemail/quality/output_filter.rb +24 -0
  53. data/lib/activemail/quality/preview_renderer.rb +46 -33
  54. data/lib/activemail/quality/render_all.rb +95 -39
  55. data/lib/activemail/quality/rspec.rb +14 -13
  56. data/lib/activemail/quality/rule.rb +47 -0
  57. data/lib/activemail/quality/rule_exemptions.rb +49 -0
  58. data/lib/activemail/rails/engine.rb +13 -13
  59. data/lib/activemail/rails/rendering.rb +51 -0
  60. data/lib/activemail/rails/template_handler.rb +3 -1
  61. data/lib/activemail/tokens/button_style.rb +1 -2
  62. data/lib/activemail/tokens/defaults.rb +78 -0
  63. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  64. data/lib/activemail/tokens.rb +19 -58
  65. data/lib/activemail/version.rb +1 -1
  66. data/lib/activemail.rb +89 -61
  67. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  68. data/lib/generators/activemail/templates/initializer.rb +0 -3
  69. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -19
  70. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -11
  71. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -10
  72. data/lib/tasks/activemail.rake +4 -5
  73. metadata +22 -4
  74. data/lib/activemail/components/inky.rb +0 -18
  75. data/lib/activemail/inliner/interceptor.rb +0 -51
  76. 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
- require 'premailer'
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
- ::Premailer.new(html, with_html_string: true, warn_level: ::Premailer::Warnings::NONE).to_inline_css
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
- # interceptor's rescue would otherwise miss it); the LoadError is #cause.
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 ActiveMail.configuration.on_parse_error.
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 = ::ActiveMail.configuration.on_parse_error
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
- raise ArgumentError, 'output_dir must not be blank' if value.strip.empty?
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
- @required_previews.dup.freeze
44
+ @preview_keys.fetch(:required).dup.freeze
39
45
  end
40
46
 
41
- sig { params(value: T.untyped).returns(T::Array[String]) }
47
+ sig { params(value: T.untyped).void }
42
48
  def required_previews=(value)
43
- raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_a'" unless value.respond_to?(:to_a)
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
- @required_previews = value.to_a.map(&:to_s)
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
- @required_previews = T.let([], T::Array[String])
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