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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -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 +17 -4
  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
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'sorbet-runtime'
5
+ require_relative 'tokens/defaults'
5
6
  require_relative 'tokens/button_style'
6
7
  require_relative 'tokens/scss_serializer'
7
8
 
@@ -9,61 +10,6 @@ module ActiveMail
9
10
  class Tokens
10
11
  extend T::Sig
11
12
 
12
- TokenMap = T.type_alias { T::Hash[Symbol, String] }
13
-
14
- DEFAULT_COLORS = T.let(
15
- {
16
- primary: '#2a9d8f',
17
- secondary: '#264653',
18
- text: '#1a1a1a',
19
- background: '#ffffff',
20
- muted: '#6b7280',
21
- border: '#e5e7eb',
22
- button_text: '#ffffff'
23
- }.freeze,
24
- TokenMap
25
- )
26
-
27
- SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
28
-
29
- DEFAULT_FONTS = T.let(
30
- {
31
- body: SYSTEM_FONT_STACK,
32
- heading: SYSTEM_FONT_STACK
33
- }.freeze,
34
- TokenMap
35
- )
36
-
37
- DEFAULT_SPACINGS = T.let(
38
- {
39
- xs: '4px',
40
- sm: '8px',
41
- md: '16px',
42
- lg: '24px',
43
- xl: '40px'
44
- }.freeze,
45
- TokenMap
46
- )
47
-
48
- DEFAULT_RADII = T.let(
49
- {
50
- button: '4px',
51
- box: '4px'
52
- }.freeze,
53
- TokenMap
54
- )
55
-
56
- # Open/Closed: adding a group here is the only change needed to wire it everywhere.
57
- GROUPS = T.let(
58
- {
59
- color: DEFAULT_COLORS,
60
- font: DEFAULT_FONTS,
61
- spacing: DEFAULT_SPACINGS,
62
- radius: DEFAULT_RADII
63
- }.freeze,
64
- T::Hash[Symbol, TokenMap]
65
- )
66
-
67
13
  sig { void }
68
14
  def initialize
69
15
  @stores = T.let(GROUPS.transform_values(&:dup), T::Hash[Symbol, TokenMap])
@@ -123,6 +69,13 @@ module ActiveMail
123
69
  end
124
70
  end
125
71
 
72
+ # Every token with derived colors filled in; nil marks a derived token left unset.
73
+ sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.nilable(String)]]) }
74
+ def resolved
75
+ colors = DERIVED_COLORS.keys.to_h { |name| [name, color(name)] }
76
+ @stores.merge(color: store_for(:color).merge(colors))
77
+ end
78
+
126
79
  sig { params(variant: T.any(String, Symbol)).returns(ButtonStyle) }
127
80
  def button_style(variant)
128
81
  ButtonStyle.from(self, variant)
@@ -130,7 +83,7 @@ module ActiveMail
130
83
 
131
84
  sig { returns(String) }
132
85
  def to_scss
133
- ScssSerializer.call(@stores)
86
+ ScssSerializer.call(resolved)
134
87
  end
135
88
 
136
89
  private
@@ -142,14 +95,22 @@ module ActiveMail
142
95
 
143
96
  sig { params(group: Symbol, name: T.any(String, Symbol)).returns(String) }
144
97
  def fetch!(group, name)
145
- store_for(group).fetch(name.to_sym) { raise KeyError, "unknown #{group} token #{name.inspect}" }
98
+ access(group, name, nil) || raise(KeyError, "unknown or unset #{group} token #{name.inspect}")
99
+ end
100
+
101
+ sig { params(group: Symbol, key: Symbol).returns(T.nilable(String)) }
102
+ def derived(group, key)
103
+ return unless group == :color
104
+
105
+ source = DERIVED_COLORS[key]
106
+ source && store_for(:color)[source]
146
107
  end
147
108
 
148
109
  sig { params(group: Symbol, name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
149
110
  def access(group, name, value)
150
111
  store = store_for(group)
151
112
  key = name.to_sym
152
- return store[key] if value.nil?
113
+ return store.fetch(key) { derived(group, key) } if value.nil?
153
114
  # Emitted verbatim into SCSS — reject blanks that would yield broken CSS.
154
115
  raise ArgumentError, "token #{key} value must not be blank" if value.strip.empty?
155
116
 
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module ActiveMail
5
- VERSION = '1.2.3'
5
+ VERSION = '2.0.0'
6
6
  end
data/lib/activemail.rb CHANGED
@@ -2,15 +2,17 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'nokogiri'
5
+ require 'securerandom'
5
6
  require 'sorbet-runtime'
6
7
 
7
8
  require_relative 'activemail/version'
9
+ require_relative 'activemail/errors'
8
10
  require_relative 'activemail/components/base'
11
+ require_relative 'activemail/components/button_renderer'
9
12
  require_relative 'activemail/components/button'
10
13
  require_relative 'activemail/components/row'
11
14
  require_relative 'activemail/components/columns'
12
15
  require_relative 'activemail/components/container'
13
- require_relative 'activemail/components/inky'
14
16
  require_relative 'activemail/components/block_grid'
15
17
  require_relative 'activemail/components/menu'
16
18
  require_relative 'activemail/components/menu_item'
@@ -22,19 +24,32 @@ require_relative 'activemail/components/wrapper'
22
24
  require_relative 'activemail/components/cta'
23
25
  require_relative 'activemail/components/info_box'
24
26
  require_relative 'activemail/configuration'
25
- require_relative 'activemail/inliner/interceptor'
27
+ require_relative 'activemail/pipeline'
26
28
  require_relative 'activemail/parse_error_reporter'
27
29
 
28
30
  module ActiveMail
29
- class ParseError < StandardError; end
30
-
31
31
  class Core
32
32
  extend T::Sig
33
33
 
34
34
  # Nokogiri cannot parse a bare <th> outside a <tr>; components that emit
35
35
  # <th> use this placeholder, swapped back at the end.
36
36
  INTERIM_TH_TAG = 'activemail-interim-th'
37
- INTERIM_TH_TAG_REGEX = T.let(%r{(?<=<|</)#{Regexp.escape(INTERIM_TH_TAG)}}, Regexp)
37
+
38
+ # Comment placeholder handed to components as `inner`; the transformed children
39
+ # are moved there without being re-serialized.
40
+ SLOT_TEXT = 'activemail-slot'
41
+ SLOT = T.let("<!--#{SLOT_TEXT}-->".freeze, String)
42
+
43
+ # The component markup repeats the id of the node it replaces, which libxml2 still has registered.
44
+ LIBXML_ID_REDEFINED_CODE = 513
45
+
46
+ RAW_REGEX = T.let(%r{< *raw *>([\s\S]*?)</ *raw *>}i, Regexp)
47
+
48
+ # One pass over the serialized output; <style>/<script> bodies are matched first so they are left alone.
49
+ POSTPROCESS_REGEX = T.let(
50
+ %r{(?<verbatim><(?<tag>style|script)\b[\s\S]*?</\k<tag>\s*>)|(?<=<|</)#{Regexp.escape(INTERIM_TH_TAG)}(?![\w-])|\u00A0}i,
51
+ Regexp
52
+ )
38
53
 
39
54
  DEFAULT_COMPONENTS = T.let(
40
55
  {
@@ -44,7 +59,6 @@ module ActiveMail
44
59
  'row' => ActiveMail::Components::Row,
45
60
  'columns' => ActiveMail::Components::Columns,
46
61
  'container' => ActiveMail::Components::Container,
47
- 'inky' => ActiveMail::Components::Inky,
48
62
  'block-grid' => ActiveMail::Components::BlockGrid,
49
63
  'menu' => ActiveMail::Components::Menu,
50
64
  'item' => ActiveMail::Components::MenuItem,
@@ -60,85 +74,99 @@ module ActiveMail
60
74
  sig { returns(Integer) }
61
75
  attr_reader :column_count, :container_width
62
76
 
63
- sig { returns(T::Hash[String, ActiveMail::Components::Base]) }
64
- attr_reader :component_instances
77
+ sig { returns(ActiveMail::Configuration) }
78
+ attr_reader :config
65
79
 
66
- sig { params(options: T::Hash[Symbol, T.untyped]).void }
67
- def initialize(options = {})
68
- config = ::ActiveMail.configuration
69
- @component_instances = T.let(build_components(config, options[:components]), T::Hash[String, ActiveMail::Components::Base])
70
- @column_count = T.let(ActiveMail.assert_positive_dimension!(:column_count, options[:column_count] || config.column_count), Integer)
71
- @container_width = T.let(ActiveMail.assert_positive_dimension!(:container_width, options[:container_width] || config.container_width), Integer)
80
+ sig do
81
+ params(
82
+ config: ActiveMail::Configuration,
83
+ components: T.untyped,
84
+ column_count: T.untyped,
85
+ container_width: T.untyped
86
+ ).void
87
+ end
88
+ def initialize(config: ::ActiveMail.configuration, components: nil, column_count: nil, container_width: nil)
89
+ @config = T.let(config, ActiveMail::Configuration)
90
+ @component_instances = T.let(build_components(config, components), T::Hash[String, ActiveMail::Components::Base])
91
+ @column_count = T.let(ActiveMail.positive_integer!(:column_count, column_count || config.column_count), Integer)
92
+ @container_width = T.let(ActiveMail.positive_integer!(:container_width, container_width || config.container_width), Integer)
93
+ @error_reporter = T.let(ParseErrorReporter.new(@component_instances.keys + [INTERIM_TH_TAG], config), ParseErrorReporter)
72
94
  end
73
95
 
74
96
  # Object, not String: ActionView::OutputBuffer is no longer a String since Rails 7.1.
97
+ # Holds no per-call state: the instance is shared across threads.
75
98
  sig { params(html_string: Object).returns(String) }
76
99
  def transpile(html_string)
77
- raws, str = extract_raws(normalize_input(html_string))
100
+ marker = "activemail-raw-#{SecureRandom.hex(8)}-"
101
+ raws = {}
102
+ str = extract_raws(normalize_input(html_string), marker, raws)
78
103
  parse_cmd = ::ActiveMail.full_document?(str) ? :parse : :fragment
79
104
  html = Nokogiri::HTML.public_send(parse_cmd, str)
80
- ParseErrorReporter.new(component_instances.keys).call(html.errors)
105
+ @error_reporter.call(html.errors)
81
106
  transform_doc(html)
82
- string = html.to_html
83
- string = string.gsub(INTERIM_TH_TAG_REGEX, 'th')
84
- # Needle is a literal U+00A0 (Nokogiri decodes the nbsp entity to one); re-encode
85
- # it to the entity for email clients that mishandle raw NBSP bytes.
86
- string = string.gsub(' ', '&nbsp;')
87
- re_inject_raws(string, raws)
107
+ re_inject_raws(postprocess(html.to_html), marker, raws)
88
108
  end
89
109
 
90
- sig { params(elem: Nokogiri::XML::Node).returns(Nokogiri::XML::Node) }
91
- def transform_doc(elem)
92
- if elem.respond_to?(:children)
93
- elem.children.each { |child| transform_doc(child) }
94
- markup = component_factory(elem)
95
- elem.replace(markup) if markup
96
- end
97
- elem
98
- end
110
+ private
99
111
 
100
- sig { params(node: Nokogiri::XML::Node).returns(T.nilable(String)) }
101
- def component_factory(node)
112
+ sig { returns(T::Hash[String, ActiveMail::Components::Base]) }
113
+ attr_reader :component_instances
114
+
115
+ sig { params(node: Nokogiri::XML::Node).void }
116
+ def transform_doc(node)
117
+ node.children.each { |child| transform_doc(child) }
102
118
  component = component_instances[node.name]
103
- return unless component
119
+ replace_with_component(node, component, component.transform(node, SLOT)) if component
120
+ end
121
+
122
+ # Only the component's own markup is parsed; the node's children are moved into
123
+ # its slot, never re-serialized.
124
+ sig { params(node: Nokogiri::XML::Node, component: ActiveMail::Components::Base, markup: String).void }
125
+ def replace_with_component(node, component, markup)
126
+ fragment = Nokogiri::HTML::DocumentFragment.new(node.document, markup, node)
127
+ @error_reporter.call(fragment.errors.reject { |error| error.code == LIBXML_ID_REDEFINED_CODE })
128
+ slots = fragment.xpath('.//comment()').select { |comment| comment.content == SLOT_TEXT }
129
+ ensure_slot_usage!(node, component, slots.size)
130
+ slots.first&.replace(node.children)
131
+ node.replace(fragment)
132
+ end
133
+
134
+ sig { params(node: Nokogiri::XML::Node, component: ActiveMail::Components::Base, count: Integer).void }
135
+ def ensure_slot_usage!(node, component, count)
136
+ return if count == 1 || (count.zero? && node.children.all? { |child| child.text? && child.content.strip.empty? })
104
137
 
105
- # Nokogiri::NodeSet has no #join; map to String first.
106
- inner = node.children.map(&:to_s).join # rubocop:disable Style/MapJoin
107
- component.transform(node, inner)
138
+ raise ActiveMail::Error,
139
+ "<#{node.name}> (#{component.class}) must output `inner` exactly once, found #{count}: its children would be lost or duplicated"
108
140
  end
109
141
 
110
- sig { params(string: String).returns([T::Array[String], String]) }
111
- def extract_raws(string)
112
- raws = []
113
- i = 0
142
+ sig { params(string: String, marker: String, raws: T::Hash[String, String]).returns(String) }
143
+ def extract_raws(string, marker, raws)
114
144
  # Only the tags + content, across lines: surrounding whitespace is left in
115
145
  # place (true pass-through; eating adjacent newlines corrupts <pre>/inline text).
116
- regex = %r{< *raw *>([\s\S]*?)</ *raw *>}i
117
- str = string
118
- while (raw = str.match(regex))
119
- raws[i] = T.must(raw[1])
120
- str = str.sub(regex, "###RAW#{i}###")
121
- i += 1
146
+ string.gsub(RAW_REGEX) do
147
+ placeholder = "#{marker}#{raws.size}~"
148
+ raws[placeholder] = T.must(Regexp.last_match(1))
149
+ placeholder
122
150
  end
123
- [raws, str]
124
151
  end
125
152
 
126
- sig { params(string: String, raws: T::Array[String]).returns(String) }
127
- def re_inject_raws(string, raws)
128
- str = string
129
- raws.each_with_index do |val, i|
130
- # Block form: the 2-arg String#sub would expand \0/\1/\& in val.
131
- str = str.sub("###RAW#{i}###") { val }
153
+ # U+00A0 is what Nokogiri decodes the nbsp entity to; re-encoded for email clients that mishandle raw NBSP bytes.
154
+ sig { params(string: String).returns(String) }
155
+ def postprocess(string)
156
+ string.gsub(POSTPROCESS_REGEX) do |match|
157
+ next match if Regexp.last_match(:verbatim)
158
+
159
+ match == "\u00A0" ? '&nbsp;' : 'th'
132
160
  end
161
+ end
162
+
163
+ sig { params(string: String, marker: String, raws: T::Hash[String, String]).returns(String) }
164
+ def re_inject_raws(string, marker, raws)
165
+ str = raws.empty? ? string : string.gsub(/#{Regexp.escape(marker)}\d+~/) { |token| raws.fetch(token) }
133
166
  str = str.html_safe if str.respond_to?(:html_safe)
134
167
  str
135
168
  end
136
169
 
137
- private
138
-
139
- # Internal transpilation details, not public surface (column_count/container_width stay public).
140
- private :component_instances, :transform_doc, :component_factory, :extract_raws, :re_inject_raws
141
-
142
170
  sig { params(config: ActiveMail::Configuration, overrides: T.untyped).returns(T::Hash[String, ActiveMail::Components::Base]) }
143
171
  def build_components(config, overrides)
144
172
  # Lookup is by node name (String); a Symbol key would never match.
@@ -151,8 +179,8 @@ module ActiveMail
151
179
  def normalize_input(html_string)
152
180
  html_string = html_string.to_s
153
181
  html_string = html_string.dup.force_encoding(Encoding::UTF_8) if html_string.encoding == Encoding::BINARY
154
- html_string = ::ActiveMail.scrub_invalid_bytes(html_string) unless html_string.valid_encoding?
155
- html_string.gsub(/doctype/i, 'DOCTYPE')
182
+ html_string = ::ActiveMail.scrub_invalid_bytes(html_string, config.on_parse_error) unless html_string.valid_encoding?
183
+ html_string
156
184
  end
157
185
  end
158
186
  end
@@ -4,10 +4,9 @@
4
4
  module Components
5
5
  # Register in config/initializers/activemail.rb:
6
6
  # config.register_component "<%= tag_name %>", Components::<%= class_name %>
7
- class <%= class_name %> < ActiveMail::Components::Base
7
+ class <%= class_name %> < ActiveMail::Components::TableComponent
8
8
  def transform(node, inner)
9
- classes = combine_classes(node, "<%= tag_name %>")
10
- %(<table class="#{classes}" #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
9
+ %(<table#{root_attributes(node, classes: "<%= tag_name %>")} #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
11
10
  end
12
11
  end
13
12
  end
@@ -14,9 +14,6 @@ ActiveMail.configure do |config|
14
14
  # ActiveMail::Inliner::Base subclass/instance.
15
15
  # config.inliner = :premailer
16
16
 
17
- # Set false if another inliner (e.g. premailer-rails) already runs on mailers.
18
- # config.register_inline_interceptor = true
19
-
20
17
  # How malformed markup is handled: :warn (default), :ignore, or :raise.
21
18
  # config.on_parse_error = :warn
22
19
 
@@ -16,8 +16,6 @@
16
16
  </noscript>
17
17
  <![endif]-->
18
18
  <%%= activemail_inline_styles %>
19
- <%%# Fallback for when activemail_inline_styles cannot read the compiled asset. %>
20
- <%%= stylesheet_link_tag "activemail/activemail", media: "all" %>
21
19
  </head>
22
20
 
23
21
  <body style="margin:0;padding:0;word-spacing:normal;">
@@ -9,8 +9,6 @@
9
9
  :plain
10
10
  <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
11
11
  = activemail_inline_styles
12
- -# Fallback for when activemail_inline_styles cannot read the compiled asset.
13
- = stylesheet_link_tag "activemail/activemail", media: "all"
14
12
  %body{style: "margin:0;padding:0;word-spacing:normal;"}
15
13
  %div{role: "article", "aria-roledescription" => "email", lang: I18n.locale, style: "width:100%;"}
16
14
  %container
@@ -8,8 +8,6 @@ html lang=I18n.locale xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-
8
8
  meta name="supported-color-schemes" content="light dark"
9
9
  | <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
10
10
  = activemail_inline_styles
11
- / Fallback for when activemail_inline_styles cannot read the compiled asset.
12
- = stylesheet_link_tag "activemail/activemail", media: "all"
13
11
  body style="margin:0;padding:0;word-spacing:normal;"
14
12
  div role="article" aria-roledescription="email" lang=I18n.locale style="width:100%;"
15
13
  container
@@ -20,15 +20,14 @@ namespace :activemail do
20
20
  task render_all: :environment do
21
21
  require 'activemail/quality/render_all'
22
22
 
23
- config = ActiveMail::Quality.config
24
- output_root = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join(config.output_dir) : Pathname(config.output_dir)
25
- result = ActiveMail::Quality::RenderAll.new(output_root: output_root, config: config).call
26
- puts "Rendered #{result.rendered} email(s) into #{output_root}"
23
+ root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root : Pathname.pwd
24
+ result = ActiveMail::Quality::RenderAll.new(root: root, config: ActiveMail::Quality.config).call
25
+ puts "Rendered #{result.rendered} email(s) into #{result.output_root}"
27
26
  # A green run on zero previews would silently verify nothing — make it visible.
28
27
  warn '[activemail] WARNING: no mailer previews were discovered — nothing was checked.' if result.discovered.zero?
29
28
  result.render_failures.each { |key, error| puts " render failed: #{key}: #{error}" }
30
29
  result.guard_failures.each do |key, violations|
31
- puts " guard failed: #{key}", violations.map { |v| " - [#{v.rule}] #{v.message}" }.join("\n")
30
+ puts " guard failed: #{key}", violations.map { |v| " - #{v}" }.join("\n")
32
31
  end
33
32
  abort "\n#{result.broken_required.size} required preview(s) failed: #{result.broken_required.join(', ')}" if result.broken_required.any?
34
33
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activemail
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Advitam
@@ -81,6 +81,7 @@ files:
81
81
  - CHANGELOG.md
82
82
  - LICENSE.txt
83
83
  - README.md
84
+ - UPGRADING.md
84
85
  - app/assets/stylesheets/activemail/_activemail_tokens.scss.erb
85
86
  - app/assets/stylesheets/activemail/_components.scss
86
87
  - app/assets/stylesheets/activemail/_dark.scss
@@ -93,9 +94,15 @@ files:
93
94
  - app/views/layouts/activemail/_head.html.inky-erb
94
95
  - app/views/layouts/activemail/mailer.html.inky-erb
95
96
  - lib/activemail.rb
97
+ - lib/activemail/asset_pipeline.rb
98
+ - lib/activemail/asset_pipeline/base.rb
99
+ - lib/activemail/asset_pipeline/null.rb
100
+ - lib/activemail/asset_pipeline/propshaft.rb
101
+ - lib/activemail/asset_pipeline/sprockets.rb
96
102
  - lib/activemail/components/base.rb
97
103
  - lib/activemail/components/block_grid.rb
98
104
  - lib/activemail/components/button.rb
105
+ - lib/activemail/components/button_renderer.rb
99
106
  - lib/activemail/components/callout.rb
100
107
  - lib/activemail/components/center.rb
101
108
  - lib/activemail/components/columns.rb
@@ -103,32 +110,38 @@ files:
103
110
  - lib/activemail/components/cta.rb
104
111
  - lib/activemail/components/h_line.rb
105
112
  - lib/activemail/components/info_box.rb
106
- - lib/activemail/components/inky.rb
107
113
  - lib/activemail/components/menu.rb
108
114
  - lib/activemail/components/menu_item.rb
109
115
  - lib/activemail/components/row.rb
110
116
  - lib/activemail/components/spacer.rb
117
+ - lib/activemail/components/table_component.rb
111
118
  - lib/activemail/components/wrapper.rb
112
119
  - lib/activemail/configuration.rb
120
+ - lib/activemail/errors.rb
121
+ - lib/activemail/inliner.rb
113
122
  - lib/activemail/inliner/base.rb
114
- - lib/activemail/inliner/interceptor.rb
115
123
  - lib/activemail/inliner/null.rb
116
124
  - lib/activemail/inliner/premailer.rb
117
125
  - lib/activemail/inliner/roadie.rb
118
126
  - lib/activemail/libxml.rb
119
127
  - lib/activemail/parse_error_reporter.rb
128
+ - lib/activemail/pipeline.rb
120
129
  - lib/activemail/quality.rb
130
+ - lib/activemail/quality/builtin_rules.rb
121
131
  - lib/activemail/quality/configuration.rb
122
132
  - lib/activemail/quality/guard.rb
123
133
  - lib/activemail/quality/minitest.rb
134
+ - lib/activemail/quality/output_dir.rb
124
135
  - lib/activemail/quality/preview_renderer.rb
125
136
  - lib/activemail/quality/render_all.rb
126
137
  - lib/activemail/quality/rspec.rb
127
- - lib/activemail/rails/compiled_stylesheet.rb
138
+ - lib/activemail/quality/rule.rb
128
139
  - lib/activemail/rails/engine.rb
140
+ - lib/activemail/rails/rendering.rb
129
141
  - lib/activemail/rails/template_handler.rb
130
142
  - lib/activemail/tokens.rb
131
143
  - lib/activemail/tokens/button_style.rb
144
+ - lib/activemail/tokens/defaults.rb
132
145
  - lib/activemail/tokens/scss_serializer.rb
133
146
  - lib/activemail/version.rb
134
147
  - lib/generators/activemail/component_generator.rb
@@ -1,18 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- require_relative 'base'
5
-
6
- module ActiveMail
7
- module Components
8
- # Renders a bare <tr>, useful inside hand-written tables.
9
- class Inky < Base
10
- extend T::Sig
11
-
12
- sig { override.params(_node: Nokogiri::XML::Node, inner: String).returns(String) }
13
- def transform(_node, inner)
14
- %(<tr>#{inner}</tr>)
15
- end
16
- end
17
- end
18
- end
@@ -1,51 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- require 'sorbet-runtime'
5
-
6
- module ActiveMail
7
- module Inliner
8
- class Interceptor
9
- class << self
10
- extend T::Sig
11
-
12
- sig { params(message: T.untyped).void }
13
- def delivering_email(message)
14
- config = ActiveMail.configuration
15
- # Runtime check: an engine initializer runs before the host's, so a
16
- # boot-time flag read would always see the default.
17
- return unless config.register_inline_interceptor
18
-
19
- inliner = config.resolved_inliner
20
- return if inliner.noop?
21
-
22
- html_parts(message).each do |part|
23
- part.body = inliner.inline(part.body.to_s)
24
- rescue StandardError => e
25
- # Surface which inliner failed; do not swallow (a silently non-inlined
26
- # mail is worse). Ruby sets e as #cause, preserving the original backtrace.
27
- raise ActiveMail::Inliner::Error, "[#{inliner.class}] CSS inlining failed: #{e.message}"
28
- end
29
- end
30
-
31
- private
32
-
33
- # The html part(s) to inline: a single text/html message, or every text/html
34
- # part of a multipart message. Attachments are never inlined.
35
- sig { params(message: T.untyped).returns(T::Array[T.untyped]) }
36
- def html_parts(message)
37
- return message.all_parts.select { |part| html_body_part?(part) } if message.multipart?
38
-
39
- html_body_part?(message) ? [message] : []
40
- end
41
-
42
- sig { params(part: T.untyped).returns(T::Boolean) }
43
- def html_body_part?(part)
44
- return false unless part.respond_to?(:mime_type) && part.mime_type == 'text/html'
45
-
46
- !(part.respond_to?(:attachment?) && part.attachment?)
47
- end
48
- end
49
- end
50
- end
51
- end
@@ -1,62 +0,0 @@
1
- # typed: strict
2
- # frozen_string_literal: true
3
-
4
- require 'sorbet-runtime'
5
-
6
- module ActiveMail
7
- # Reads a compiled CSS asset's bytes (Sprockets or Propshaft, by duck-type) — the
8
- # bytes, not a digest URL, are what the Premailer adapter needs to inline.
9
- module CompiledStylesheet
10
- class << self
11
- extend T::Sig
12
-
13
- # '' (not nil) on a miss: callers embed the result verbatim, and a missing
14
- # asset must degrade to an empty <style>, never interpolate "nil" into HTML.
15
- sig { params(logical_path: String).returns(String) }
16
- def read(logical_path)
17
- sprockets_source(logical_path) || propshaft_source(logical_path) || ''
18
- end
19
-
20
- private
21
-
22
- # Sprockets: config.assets.compile (dev) resolves logical paths in-memory;
23
- # precompiled (prod) resolves through the manifest. #find_asset covers both.
24
- sig { params(logical_path: String).returns(T.nilable(String)) }
25
- def sprockets_source(logical_path)
26
- assets = rails_assets_environment
27
- return unless assets.respond_to?(:find_asset)
28
-
29
- asset = assets.find_asset(logical_path)
30
- asset&.source&.to_s
31
- end
32
-
33
- # Propshaft: the load_path resolves a logical path to a compiled file on disk.
34
- sig { params(logical_path: String).returns(T.nilable(String)) }
35
- def propshaft_source(logical_path)
36
- assets = rails_assets
37
- load_path = assets.respond_to?(:load_path) ? assets.load_path : nil
38
- return unless load_path.respond_to?(:find)
39
-
40
- asset = load_path.find(logical_path)
41
- asset&.path && File.exist?(asset.path) ? File.read(asset.path) : nil
42
- end
43
-
44
- # config.assets.environment is the Sprockets::Environment; absent under Propshaft.
45
- sig { returns(T.untyped) }
46
- def rails_assets_environment
47
- assets = rails_assets
48
- assets.respond_to?(:environment) ? assets.environment : nil
49
- end
50
-
51
- sig { returns(T.untyped) }
52
- def rails_assets
53
- return unless Object.const_defined?(:Rails)
54
-
55
- rails = Object.const_get(:Rails)
56
- app = rails.respond_to?(:application) ? rails.application : nil
57
- config = app&.config
58
- config.respond_to?(:assets) ? config.assets : nil
59
- end
60
- end
61
- end
62
- end