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
@@ -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.1.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
 
@@ -1,29 +1,13 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="<%%= I18n.locale %>" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta name="x-apple-disable-message-reformatting">
7
- <meta name="color-scheme" content="light dark">
8
- <meta name="supported-color-schemes" content="light dark">
9
- <!--[if mso]>
10
- <noscript>
11
- <xml>
12
- <o:OfficeDocumentSettings>
13
- <o:PixelsPerInch>96</o:PixelsPerInch>
14
- </o:OfficeDocumentSettings>
15
- </xml>
16
- </noscript>
17
- <![endif]-->
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
- </head>
3
+ <%%= render "layouts/activemail/document_head" %>
22
4
 
23
5
  <body style="margin:0;padding:0;word-spacing:normal;">
24
6
  <div role="article" aria-roledescription="email" lang="<%%= I18n.locale %>" style="width:100%;">
25
7
  <container>
8
+ <%%= render "layouts/activemail/head" %>
26
9
  <%%= yield %>
10
+ <%%= render "layouts/activemail/footer" %>
27
11
  </container>
28
12
  </div>
29
13
  </body>
@@ -1,17 +1,9 @@
1
1
  !!!
2
2
  %html{lang: I18n.locale, xmlns: "http://www.w3.org/1999/xhtml", "xmlns:v" => "urn:schemas-microsoft-com:vml", "xmlns:o" => "urn:schemas-microsoft-com:office:office"}
3
- %head
4
- %meta{charset: "utf-8"}
5
- %meta{name: "viewport", content: "width=device-width, initial-scale=1"}
6
- %meta{name: "x-apple-disable-message-reformatting"}
7
- %meta{name: "color-scheme", content: "light dark"}
8
- %meta{name: "supported-color-schemes", content: "light dark"}
9
- :plain
10
- <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
11
- = activemail_inline_styles
12
- -# Fallback for when activemail_inline_styles cannot read the compiled asset.
13
- = stylesheet_link_tag "activemail/activemail", media: "all"
3
+ = render "layouts/activemail/document_head"
14
4
  %body{style: "margin:0;padding:0;word-spacing:normal;"}
15
5
  %div{role: "article", "aria-roledescription" => "email", lang: I18n.locale, style: "width:100%;"}
16
6
  %container
7
+ = render "layouts/activemail/head"
17
8
  = yield
9
+ = render "layouts/activemail/footer"
@@ -1,16 +1,9 @@
1
1
  doctype html
2
2
  html lang=I18n.locale xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"
3
- head
4
- meta charset="utf-8"
5
- meta name="viewport" content="width=device-width, initial-scale=1"
6
- meta name="x-apple-disable-message-reformatting"
7
- meta name="color-scheme" content="light dark"
8
- meta name="supported-color-schemes" content="light dark"
9
- | <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
10
- = activemail_inline_styles
11
- / Fallback for when activemail_inline_styles cannot read the compiled asset.
12
- = stylesheet_link_tag "activemail/activemail", media: "all"
3
+ = render "layouts/activemail/document_head"
13
4
  body style="margin:0;padding:0;word-spacing:normal;"
14
5
  div role="article" aria-roledescription="email" lang=I18n.locale style="width:100%;"
15
6
  container
7
+ = render "layouts/activemail/head"
16
8
  = yield
9
+ = render "layouts/activemail/footer"
@@ -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.1.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
@@ -89,13 +90,20 @@ files:
89
90
  - app/assets/stylesheets/activemail/_utilities.scss
90
91
  - app/assets/stylesheets/activemail/activemail.scss
91
92
  - app/helpers/active_mail/styles_helper.rb
93
+ - app/views/layouts/activemail/_document_head.html.erb
92
94
  - app/views/layouts/activemail/_footer.html.inky-erb
93
95
  - app/views/layouts/activemail/_head.html.inky-erb
94
96
  - app/views/layouts/activemail/mailer.html.inky-erb
95
97
  - lib/activemail.rb
98
+ - lib/activemail/asset_pipeline.rb
99
+ - lib/activemail/asset_pipeline/base.rb
100
+ - lib/activemail/asset_pipeline/null.rb
101
+ - lib/activemail/asset_pipeline/propshaft.rb
102
+ - lib/activemail/asset_pipeline/sprockets.rb
96
103
  - lib/activemail/components/base.rb
97
104
  - lib/activemail/components/block_grid.rb
98
105
  - lib/activemail/components/button.rb
106
+ - lib/activemail/components/button_renderer.rb
99
107
  - lib/activemail/components/callout.rb
100
108
  - lib/activemail/components/center.rb
101
109
  - lib/activemail/components/columns.rb
@@ -103,32 +111,42 @@ files:
103
111
  - lib/activemail/components/cta.rb
104
112
  - lib/activemail/components/h_line.rb
105
113
  - lib/activemail/components/info_box.rb
106
- - lib/activemail/components/inky.rb
107
114
  - lib/activemail/components/menu.rb
108
115
  - lib/activemail/components/menu_item.rb
109
116
  - lib/activemail/components/row.rb
110
117
  - lib/activemail/components/spacer.rb
118
+ - lib/activemail/components/table_component.rb
111
119
  - lib/activemail/components/wrapper.rb
112
120
  - lib/activemail/configuration.rb
121
+ - lib/activemail/errors.rb
122
+ - lib/activemail/inliner.rb
113
123
  - lib/activemail/inliner/base.rb
114
- - lib/activemail/inliner/interceptor.rb
115
124
  - lib/activemail/inliner/null.rb
116
125
  - lib/activemail/inliner/premailer.rb
126
+ - lib/activemail/inliner/premailer/document.rb
117
127
  - lib/activemail/inliner/roadie.rb
118
128
  - lib/activemail/libxml.rb
119
129
  - lib/activemail/parse_error_reporter.rb
130
+ - lib/activemail/pipeline.rb
120
131
  - lib/activemail/quality.rb
132
+ - lib/activemail/quality/builtin_message_rules.rb
133
+ - lib/activemail/quality/builtin_rules.rb
121
134
  - lib/activemail/quality/configuration.rb
122
135
  - lib/activemail/quality/guard.rb
123
136
  - lib/activemail/quality/minitest.rb
137
+ - lib/activemail/quality/output_dir.rb
138
+ - lib/activemail/quality/output_filter.rb
124
139
  - lib/activemail/quality/preview_renderer.rb
125
140
  - lib/activemail/quality/render_all.rb
126
141
  - lib/activemail/quality/rspec.rb
127
- - lib/activemail/rails/compiled_stylesheet.rb
142
+ - lib/activemail/quality/rule.rb
143
+ - lib/activemail/quality/rule_exemptions.rb
128
144
  - lib/activemail/rails/engine.rb
145
+ - lib/activemail/rails/rendering.rb
129
146
  - lib/activemail/rails/template_handler.rb
130
147
  - lib/activemail/tokens.rb
131
148
  - lib/activemail/tokens/button_style.rb
149
+ - lib/activemail/tokens/defaults.rb
132
150
  - lib/activemail/tokens/scss_serializer.rb
133
151
  - lib/activemail/version.rb
134
152
  - 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