activemail 1.2.2 → 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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -2
  3. data/README.md +105 -47
  4. data/UPGRADING.md +112 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +31 -20
  7. data/app/assets/stylesheets/activemail/_grid.scss +2 -0
  8. data/app/assets/stylesheets/activemail/_settings.scss +22 -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/mailer.html.inky-erb +0 -5
  13. data/lib/activemail/asset_pipeline/base.rb +47 -0
  14. data/lib/activemail/asset_pipeline/null.rb +19 -0
  15. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  16. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  17. data/lib/activemail/asset_pipeline.rb +35 -0
  18. data/lib/activemail/components/base.rb +35 -39
  19. data/lib/activemail/components/block_grid.rb +11 -4
  20. data/lib/activemail/components/button.rb +5 -28
  21. data/lib/activemail/components/button_renderer.rb +65 -0
  22. data/lib/activemail/components/callout.rb +4 -5
  23. data/lib/activemail/components/center.rb +5 -7
  24. data/lib/activemail/components/columns.rb +26 -7
  25. data/lib/activemail/components/container.rb +11 -4
  26. data/lib/activemail/components/cta.rb +9 -33
  27. data/lib/activemail/components/h_line.rb +3 -5
  28. data/lib/activemail/components/info_box.rb +6 -12
  29. data/lib/activemail/components/menu.rb +3 -4
  30. data/lib/activemail/components/menu_item.rb +10 -3
  31. data/lib/activemail/components/row.rb +3 -4
  32. data/lib/activemail/components/spacer.rb +16 -12
  33. data/lib/activemail/components/table_component.rb +23 -0
  34. data/lib/activemail/components/wrapper.rb +10 -4
  35. data/lib/activemail/configuration.rb +43 -57
  36. data/lib/activemail/errors.rb +15 -0
  37. data/lib/activemail/inliner/base.rb +2 -10
  38. data/lib/activemail/inliner/null.rb +0 -5
  39. data/lib/activemail/inliner/roadie.rb +1 -1
  40. data/lib/activemail/inliner.rb +33 -0
  41. data/lib/activemail/parse_error_reporter.rb +5 -4
  42. data/lib/activemail/pipeline.rb +26 -0
  43. data/lib/activemail/quality/builtin_rules.rb +154 -0
  44. data/lib/activemail/quality/configuration.rb +83 -3
  45. data/lib/activemail/quality/guard.rb +45 -87
  46. data/lib/activemail/quality/minitest.rb +41 -25
  47. data/lib/activemail/quality/output_dir.rb +52 -0
  48. data/lib/activemail/quality/preview_renderer.rb +46 -33
  49. data/lib/activemail/quality/render_all.rb +96 -40
  50. data/lib/activemail/quality/rspec.rb +4 -8
  51. data/lib/activemail/quality/rule.rb +47 -0
  52. data/lib/activemail/rails/engine.rb +13 -13
  53. data/lib/activemail/rails/rendering.rb +51 -0
  54. data/lib/activemail/rails/template_handler.rb +3 -1
  55. data/lib/activemail/tokens/button_style.rb +1 -2
  56. data/lib/activemail/tokens/defaults.rb +77 -0
  57. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  58. data/lib/activemail/tokens.rb +19 -58
  59. data/lib/activemail/version.rb +1 -1
  60. data/lib/activemail.rb +89 -61
  61. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  62. data/lib/generators/activemail/templates/initializer.rb +0 -3
  63. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +0 -2
  64. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
  65. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
  66. data/lib/tasks/activemail.rake +4 -5
  67. metadata +38 -7
  68. data/lib/activemail/components/inky.rb +0 -18
  69. data/lib/activemail/inliner/interceptor.rb +0 -51
  70. data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
@@ -4,10 +4,7 @@
4
4
  require 'sorbet-runtime'
5
5
 
6
6
  require_relative 'tokens'
7
- require_relative 'inliner/base'
8
- require_relative 'inliner/premailer'
9
- require_relative 'inliner/roadie'
10
- require_relative 'inliner/null'
7
+ require_relative 'inliner'
11
8
 
12
9
  module ActiveMail
13
10
  extend T::Sig
@@ -36,17 +33,17 @@ module ActiveMail
36
33
  block.call(configuration)
37
34
  end
38
35
 
39
- # Parse mode (and the Guard's same decision): a full document carries <html.
36
+ # <html[\s>] so a <htmlfoo> custom tag or "<html" in text never flips the parse mode.
40
37
  sig { params(html: String).returns(T::Boolean) }
41
38
  def self.full_document?(html)
42
- html.match?(/<html/i)
39
+ html.match?(/<html[\s>]/i)
43
40
  end
44
41
 
45
- # The complete SCSS variable bridge — tokens + the container_width layout knob.
46
42
  # Single source for the .scss.erb bridge (Sprockets) and tokens:export (Propshaft).
47
43
  sig { returns(String) }
48
44
  def self.scss_variables
49
- "#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n"
45
+ "#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n" \
46
+ "$am-column-count: #{configuration.column_count} !default;\n"
50
47
  end
51
48
 
52
49
  # Route warnings to the app logger when present (Sentry breadcrumbs, log
@@ -67,9 +64,8 @@ module ActiveMail
67
64
 
68
65
  # Invalid bytes degrade deterministically to U+FFFD, but that silently corrupts
69
66
  # content — surface it per on_parse_error before scrubbing.
70
- sig { params(html_string: String).returns(String) }
71
- def self.scrub_invalid_bytes(html_string)
72
- mode = configuration.on_parse_error
67
+ sig { params(html_string: String, mode: Symbol).returns(String) }
68
+ def self.scrub_invalid_bytes(html_string, mode)
73
69
  message = '[activemail] input had invalid byte sequences; scrubbed to U+FFFD'
74
70
  raise ParseError, message if mode == :raise
75
71
 
@@ -77,20 +73,25 @@ module ActiveMail
77
73
  html_string.scrub
78
74
  end
79
75
 
80
- # Zero or negative dimensions blow up at transform time (Infinity ghost width).
81
- # Reject non-integers rather than silently coercing ("abc"→0, 12.9→12).
76
+ # Rejects non-integers rather than silently coercing ("abc"→0, 12.9→12); zero or
77
+ # negative dimensions blow up at transform time (Infinity ghost width).
82
78
  sig { params(name: Symbol, value: T.untyped).returns(Integer) }
83
- def self.assert_positive_dimension!(name, value)
84
- raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}" unless value.is_a?(Integer) && value.positive?
79
+ def self.positive_integer!(name, value)
80
+ raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
81
+ raise ArgumentError, "#{name} must be positive, got #{value}" unless value.positive?
85
82
 
86
83
  value
87
84
  end
88
85
 
89
- sig { params(name: Symbol, value: T.untyped).returns(Integer) }
90
- def self.positive_integer!(name, value)
91
- raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
86
+ # Author attributes are untrusted strings: base 10 only (Integer() reads "010" as
87
+ # octal and "0x20" as hex), nil when not a positive integer.
88
+ sig { params(value: T.untyped).returns(T.nilable(Integer)) }
89
+ def self.parse_positive_integer(value)
90
+ string = value.to_s.strip
91
+ return unless string.match?(/\A\d+\z/)
92
92
 
93
- assert_positive_dimension!(name, value)
93
+ int = string.to_i
94
+ int if int.positive?
94
95
  end
95
96
 
96
97
  class Configuration
@@ -98,27 +99,9 @@ module ActiveMail
98
99
 
99
100
  ON_PARSE_ERROR_MODES = T.let(%i[ignore warn raise].freeze, T::Array[Symbol])
100
101
 
101
- INLINERS = T.let(
102
- { premailer: ActiveMail::Inliner::Premailer, roadie: ActiveMail::Inliner::Roadie, null: ActiveMail::Inliner::Null }.freeze,
103
- T::Hash[Symbol, T.class_of(ActiveMail::Inliner::Base)]
104
- )
105
-
106
- InlinerSetting = T.type_alias { T.any(Symbol, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base)) }
107
-
108
- sig { returns(InlinerSetting) }
102
+ sig { returns(ActiveMail::Inliner::Setting) }
109
103
  attr_reader :inliner
110
104
 
111
- # Lets a host already running another inliner (e.g. premailer-rails) opt out.
112
- sig { returns(T::Boolean) }
113
- attr_reader :register_inline_interceptor
114
-
115
- sig { params(value: T.untyped).void }
116
- def register_inline_interceptor=(value)
117
- raise TypeError, 'register_inline_interceptor must be true or false' unless [true, false].include?(value)
118
-
119
- @register_inline_interceptor = value
120
- end
121
-
122
105
  sig { returns(Symbol) }
123
106
  attr_reader :template_engine, :on_parse_error
124
107
 
@@ -146,26 +129,27 @@ module ActiveMail
146
129
  @container_width = T.let(600, Integer)
147
130
  @components = T.let({}, ActiveMail::ComponentMap)
148
131
  @on_parse_error = T.let(:warn, Symbol)
149
- @tokens = T.let(nil, T.nilable(ActiveMail::Tokens))
150
- @inliner = T.let(:premailer, InlinerSetting)
151
- @resolved_inliner = T.let(nil, T.nilable(ActiveMail::Inliner::Base))
152
- @register_inline_interceptor = T.let(true, T::Boolean)
132
+ @inliner = T.let(:premailer, ActiveMail::Inliner::Setting)
153
133
  @blank_link_rel = T.let('noopener', T.nilable(String))
154
134
  end
155
135
 
136
+ # Shared: building the component map per render is pure overhead.
137
+ sig { returns(ActiveMail::Core) }
138
+ def core
139
+ @core ||= T.let(ActiveMail::Core.new(config: self), T.nilable(ActiveMail::Core))
140
+ end
141
+
156
142
  # Validates eagerly (like sibling setters): a typo fails at boot, not silently mid-delivery.
157
- sig { params(value: T.any(Symbol, String, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base))).returns(InlinerSetting) }
143
+ sig { params(value: T.any(String, ActiveMail::Inliner::Setting)).returns(ActiveMail::Inliner::Setting) }
158
144
  def inliner=(value)
159
- resolved = value.is_a?(String) ? value.to_sym : value
160
- resolve_inliner(resolved) # raises on an invalid value, eagerly
161
- @resolved_inliner = nil # a new setting invalidates the memoized instance
162
- @inliner = resolved
145
+ setting = value.is_a?(String) ? value.to_sym : value
146
+ @resolved_inliner = T.let(ActiveMail::Inliner.resolve(setting), T.nilable(ActiveMail::Inliner::Base))
147
+ @inliner = setting
163
148
  end
164
149
 
165
- # Memoized so the lifecycle is consistent (one instance reused, not re-built per call).
166
150
  sig { returns(ActiveMail::Inliner::Base) }
167
151
  def resolved_inliner
168
- @resolved_inliner ||= resolve_inliner(@inliner)
152
+ @resolved_inliner ||= T.let(ActiveMail::Inliner.resolve(@inliner), T.nilable(ActiveMail::Inliner::Base))
169
153
  end
170
154
 
171
155
  sig { params(value: T.untyped).returns(Symbol) }
@@ -186,11 +170,15 @@ module ActiveMail
186
170
  sig { params(value: T.untyped).returns(Integer) }
187
171
  def column_count=(value)
188
172
  @column_count = ActiveMail.positive_integer!(:column_count, value)
173
+ invalidate_core!
174
+ @column_count
189
175
  end
190
176
 
191
177
  sig { params(value: T.untyped).returns(Integer) }
192
178
  def container_width=(value)
193
179
  @container_width = ActiveMail.positive_integer!(:container_width, value)
180
+ invalidate_core!
181
+ @container_width
194
182
  end
195
183
 
196
184
  sig { params(value: T.untyped).returns(T.nilable(String)) }
@@ -209,6 +197,8 @@ module ActiveMail
209
197
  normalized = value.to_hash.transform_keys(&:to_s)
210
198
  normalized.each { |tag, klass| ActiveMail::Components.validate_component!(tag, klass) }
211
199
  @components = normalized
200
+ invalidate_core!
201
+ normalized
212
202
  end
213
203
 
214
204
  sig { params(tag: T.any(String, Symbol), component_class: T.class_of(ActiveMail::Components::Base)).void }
@@ -216,18 +206,14 @@ module ActiveMail
216
206
  ActiveMail::Components.validate_component!(tag, component_class)
217
207
 
218
208
  @components = @components.merge(tag.to_s => component_class)
209
+ invalidate_core!
219
210
  end
220
211
 
221
212
  private
222
213
 
223
- # Single source for both eager validation (in the setter) and resolution.
224
- sig { params(value: InlinerSetting).returns(ActiveMail::Inliner::Base) }
225
- def resolve_inliner(value)
226
- return value if value.is_a?(ActiveMail::Inliner::Base)
227
- return value.new if value.is_a?(Class) && value < ActiveMail::Inliner::Base
228
- return T.must(INLINERS[value]).new if value.is_a?(Symbol) && INLINERS.key?(value)
229
-
230
- raise ArgumentError, "unknown inliner #{value.inspect}, expected one of #{INLINERS.keys.inspect}, an Inliner::Base subclass, or an instance"
214
+ sig { void }
215
+ def invalidate_core!
216
+ @core = nil
231
217
  end
232
218
  end
233
219
  end
@@ -0,0 +1,15 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveMail
5
+ class Error < StandardError; end
6
+
7
+ class ParseError < Error; end
8
+
9
+ class StylesheetNotFound < Error; end
10
+
11
+ module Inliner
12
+ # The adapter's original exception is preserved as Exception#cause.
13
+ class Error < ActiveMail::Error; end
14
+ end
15
+ end
@@ -3,12 +3,10 @@
3
3
 
4
4
  require 'sorbet-runtime'
5
5
 
6
+ require_relative '../errors'
7
+
6
8
  module ActiveMail
7
9
  module Inliner
8
- # Raised when an adapter fails; the original (premailer/roadie/nokogiri)
9
- # exception is preserved as Exception#cause with its backtrace intact.
10
- class Error < StandardError; end
11
-
12
10
  # DIP seam: ActiveMail depends on this abstraction, not premailer/roadie.
13
11
  class Base
14
12
  extend T::Sig
@@ -18,12 +16,6 @@ module ActiveMail
18
16
 
19
17
  sig { abstract.params(html: String).returns(String) }
20
18
  def inline(html); end
21
-
22
- # Lets the interceptor skip work without type-checking concrete adapters.
23
- sig { returns(T::Boolean) }
24
- def noop?
25
- false
26
- end
27
19
  end
28
20
  end
29
21
  end
@@ -13,11 +13,6 @@ module ActiveMail
13
13
  def inline(html)
14
14
  html
15
15
  end
16
-
17
- sig { override.returns(T::Boolean) }
18
- def noop?
19
- true
20
- end
21
16
  end
22
17
  end
23
18
  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,154 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'nokogiri'
5
+ require 'sorbet-runtime'
6
+
7
+ require_relative '../libxml'
8
+ require_relative 'preview_renderer'
9
+ require_relative 'rule'
10
+
11
+ module ActiveMail
12
+ module Quality
13
+ class BuiltinRules
14
+ extend T::Sig
15
+
16
+ NAMES = T.let(
17
+ %i[
18
+ max_bytes parse_error table_role img_alt lang min_full_doc_bytes translation_missing single_root
19
+ translation_missing_text html_part_present text_part_present text_part_plain
20
+ ].freeze,
21
+ T::Array[Symbol]
22
+ )
23
+ MARKUP_IN_TEXT = %r{<(?:table|div|span|p|br|a)[\s>/]}i
24
+ MISSING_TRANSLATION = /translation[ _]missing/i
25
+ ROOTS = T.let({ html: /<html[\s>]/i, body: /<body[\s>]/i }.freeze, T::Hash[Symbol, Regexp])
26
+
27
+ sig { params(max_bytes: Integer, min_full_doc_bytes: Integer).void }
28
+ def initialize(max_bytes:, min_full_doc_bytes:)
29
+ # A non-positive threshold would silently disable (or invert) a check.
30
+ @max_bytes = T.let(::ActiveMail.positive_integer!(:max_bytes, max_bytes), Integer)
31
+ @min_full_doc_bytes = T.let(::ActiveMail.positive_integer!(:min_full_doc_bytes, min_full_doc_bytes), Integer)
32
+ end
33
+
34
+ sig { returns(T::Array[Rule]) }
35
+ def rules
36
+ structure_rules + content_rules + message_rules
37
+ end
38
+
39
+ private
40
+
41
+ sig { returns(T::Array[Rule]) }
42
+ def structure_rules
43
+ [
44
+ Rule.html(:max_bytes) { |_doc, html| size_message(html) },
45
+ # The Core surfaces libxml2 repairs on input; the validation layer must not
46
+ # silently bless malformed *output* (mismatched tags, bad entities) before send.
47
+ Rule.html(:parse_error) { |doc, _html| parse_error_message(doc) },
48
+ Rule.html(:table_role) { |doc, _html| table_role_message(doc) },
49
+ Rule.html(:img_alt) { |doc, _html| img_alt_message(doc) }
50
+ ]
51
+ end
52
+
53
+ sig { returns(T::Array[Rule]) }
54
+ def content_rules
55
+ [
56
+ Rule.html(:translation_missing) { |_doc, html| translation_missing_message(html) },
57
+ Rule.html(:single_root) { |_doc, html| single_root_messages(html) },
58
+ Rule.html(:min_full_doc_bytes) { |_doc, html| min_full_doc_message(html) },
59
+ Rule.html(:lang) { |doc, html| lang_message(doc, html) }
60
+ ]
61
+ end
62
+
63
+ sig { returns(T::Array[Rule]) }
64
+ def message_rules
65
+ [
66
+ Rule.message(:translation_missing_text) { |mail| translation_missing_text_message(mail) },
67
+ Rule.message(:html_part_present) { |mail| html_part_present_message(mail) },
68
+ Rule.message(:text_part_present) { |mail| text_part_present_message(mail) },
69
+ Rule.message(:text_part_plain) { |mail| text_part_plain_message(mail) }
70
+ ]
71
+ end
72
+
73
+ sig { params(html: String).returns(T.nilable(String)) }
74
+ def size_message(html)
75
+ return if html.bytesize <= @max_bytes
76
+
77
+ "HTML is #{html.bytesize} bytes, exceeds #{@max_bytes} (Gmail clipping)"
78
+ end
79
+
80
+ sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
81
+ def parse_error_message(doc)
82
+ errors = doc.errors.reject { |e| e.code == LIBXML_UNKNOWN_TAG_CODE }
83
+ return if errors.empty?
84
+
85
+ "malformed HTML: #{errors.first(3).map { |e| e.message.to_s.strip }.join('; ')}"
86
+ end
87
+
88
+ sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
89
+ def table_role_message(doc)
90
+ offenders = doc.css('table').count { |table| table['role'] != 'presentation' }
91
+ %(#{offenders} <table> missing role="presentation") unless offenders.zero?
92
+ end
93
+
94
+ sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
95
+ def img_alt_message(doc)
96
+ offenders = doc.css('img').count { |img| !img.key?('alt') }
97
+ "#{offenders} <img> missing an alt attribute" unless offenders.zero?
98
+ end
99
+
100
+ sig { params(html: String).returns(T.nilable(String)) }
101
+ def translation_missing_message(html)
102
+ 'rendered HTML contains a missing i18n translation' if html.match?(MISSING_TRANSLATION)
103
+ end
104
+
105
+ # Counted on the raw string: libxml2 silently merges duplicate roots.
106
+ sig { params(html: String).returns(T::Array[String]) }
107
+ def single_root_messages(html)
108
+ ROOTS.filter_map do |tag, pattern|
109
+ count = html.scan(pattern).size
110
+ "#{count} <#{tag}> roots, expected at most one" if count > 1
111
+ end
112
+ end
113
+
114
+ sig { params(html: String).returns(T.nilable(String)) }
115
+ def min_full_doc_message(html)
116
+ return unless ::ActiveMail.full_document?(html) && html.bytesize < @min_full_doc_bytes
117
+
118
+ "full document is only #{html.bytesize} bytes (under #{@min_full_doc_bytes})"
119
+ end
120
+
121
+ sig { params(doc: Nokogiri::XML::Document, html: String).returns(T.nilable(String)) }
122
+ def lang_message(doc, html)
123
+ return unless ::ActiveMail.full_document?(html)
124
+
125
+ html_tag = doc.at_css('html')
126
+ lang = html_tag && html_tag['lang']
127
+ '<html> must declare a non-blank lang attribute' if lang.nil? || lang.strip.empty?
128
+ end
129
+
130
+ sig { params(mail: T.untyped).returns(T.nilable(String)) }
131
+ def translation_missing_text_message(mail)
132
+ text = "#{mail.subject}\n#{PreviewRenderer.text_body(mail)}"
133
+ 'subject or text part contains a missing i18n translation' if text.match?(MISSING_TRANSLATION)
134
+ end
135
+
136
+ sig { params(mail: T.untyped).returns(T.nilable(String)) }
137
+ def html_part_present_message(mail)
138
+ 'message has no HTML part' if PreviewRenderer.html_body(mail).empty?
139
+ end
140
+
141
+ sig { params(mail: T.untyped).returns(T.nilable(String)) }
142
+ def text_part_present_message(mail)
143
+ return if mail.multipart? && !PreviewRenderer.text_body(mail).strip.empty?
144
+
145
+ 'message must be multipart with a non-empty text/plain part'
146
+ end
147
+
148
+ sig { params(mail: T.untyped).returns(T.nilable(String)) }
149
+ def text_part_plain_message(mail)
150
+ 'text/plain part contains HTML markup' if PreviewRenderer.text_body(mail).match?(MARKUP_IN_TEXT)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -1,15 +1,20 @@
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'
7
9
 
8
10
  module ActiveMail
9
11
  module Quality
10
12
  class Configuration
11
13
  extend T::Sig
12
14
 
15
+ FORMATS = T.let(%i[html text].freeze, T::Array[Symbol])
16
+ DEFAULT_LOCALES = T.let(->(_preview) { [I18n.default_locale] }, Proc)
17
+
13
18
  sig { returns(Guard) }
14
19
  attr_reader :guard
15
20
 
@@ -25,9 +30,7 @@ module ActiveMail
25
30
 
26
31
  sig { params(value: String).void }
27
32
  def output_dir=(value)
28
- raise ArgumentError, 'output_dir must not be blank' if value.strip.empty?
29
-
30
- @output_dir = value
33
+ @output_dir = OutputDir.validate!(value)
31
34
  end
32
35
 
33
36
  # Preview keys ("preview_name#email") that MUST render and pass the guard;
@@ -45,11 +48,88 @@ module ActiveMail
45
48
  @required_previews = value.to_a.map(&:to_s)
46
49
  end
47
50
 
51
+ sig { returns(T.any(T::Array[Symbol], Proc)) }
52
+ attr_reader :locales
53
+
54
+ sig { params(value: T.untyped).void }
55
+ def locales=(value)
56
+ @locales = value.is_a?(Proc) ? value : normalize_locales(value)
57
+ end
58
+
59
+ sig { params(preview: T.untyped).returns(T::Array[Symbol]) }
60
+ def locales_for(preview)
61
+ value = @locales
62
+ value.is_a?(Proc) ? normalize_locales(value.call(preview)) : value
63
+ end
64
+
65
+ sig { returns(T::Array[Symbol]) }
66
+ def formats
67
+ @formats.dup.freeze
68
+ end
69
+
70
+ sig { params(value: T.untyped).void }
71
+ def formats=(value)
72
+ raise TypeError, "formats must be an Array, got #{value.class}" unless value.is_a?(Array)
73
+
74
+ formats = value.map(&:to_sym)
75
+ unknown = formats - FORMATS
76
+ raise ArgumentError, "unknown format(s): #{unknown.inspect}, expected a subset of #{FORMATS.inspect}" unless unknown.empty?
77
+ raise ArgumentError, 'formats must not be empty' if formats.empty?
78
+
79
+ @formats = formats.uniq
80
+ end
81
+
82
+ # ->(preview, email, locale, &block) wrapping each rendering; it must call block.
83
+ sig { returns(T.nilable(Proc)) }
84
+ attr_reader :around_each
85
+
86
+ sig { params(value: T.untyped).void }
87
+ def around_each=(value)
88
+ raise TypeError, "around_each must be a Proc or nil, got #{value.class}" unless value.nil? || value.is_a?(Proc)
89
+
90
+ @around_each = value
91
+ end
92
+
93
+ # Applied to written files only: the Guard always sees the unfiltered content.
94
+ sig { returns(T::Array[Proc]) }
95
+ attr_reader :output_filters
96
+
97
+ sig { params(value: T.untyped).void }
98
+ def output_filters=(value)
99
+ raise TypeError, "output_filters must be an Array, got #{value.class}" unless value.is_a?(Array)
100
+
101
+ @output_filters = value.map { |filter| output_filter(filter) }
102
+ end
103
+
48
104
  sig { void }
49
105
  def initialize
50
106
  @guard = T.let(Guard.new, Guard)
51
107
  @output_dir = T.let('tmp/activemail_previews', String)
52
108
  @required_previews = T.let([], T::Array[String])
109
+ @locales = T.let(DEFAULT_LOCALES, T.any(T::Array[Symbol], Proc))
110
+ @formats = T.let(FORMATS, T::Array[Symbol])
111
+ @around_each = T.let(nil, T.nilable(Proc))
112
+ @output_filters = T.let([], T::Array[Proc])
113
+ end
114
+
115
+ private
116
+
117
+ sig { params(value: T.untyped).returns(T::Array[Symbol]) }
118
+ def normalize_locales(value)
119
+ raise TypeError, "locales must be an Array, got #{value.class}" unless value.is_a?(Array)
120
+ raise ArgumentError, 'locales must not be empty' if value.empty?
121
+
122
+ value.map(&:to_sym)
123
+ end
124
+
125
+ sig { params(filter: T.untyped).returns(Proc) }
126
+ def output_filter(filter)
127
+ return filter if filter.is_a?(Proc)
128
+
129
+ pattern, replacement = filter if filter.is_a?(Array) && filter.size == 2
130
+ raise TypeError, "output filter must be a Proc or [Regexp, String], got #{filter.inspect}" unless pattern.is_a?(Regexp) && replacement.is_a?(String)
131
+
132
+ ->(content) { content.gsub(pattern, replacement) }
53
133
  end
54
134
  end
55
135
  end