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
@@ -0,0 +1,52 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'pathname'
5
+ require 'sorbet-runtime'
6
+
7
+ module ActiveMail
8
+ module Quality
9
+ # RenderAll deletes inside this directory, so it must not escape the host root.
10
+ module OutputDir
11
+ class << self
12
+ extend T::Sig
13
+
14
+ sig { params(value: String).returns(String) }
15
+ def validate!(value)
16
+ raise ArgumentError, 'output_dir must not be blank' if value.strip.empty?
17
+ raise ArgumentError, "output_dir must be relative, got #{value.inspect}" if Pathname(value).absolute?
18
+
19
+ segments = value.split(%r{[/\\]}).reject(&:empty?)
20
+ raise ArgumentError, "output_dir must not contain '..', got #{value.inspect}" if segments.include?('..')
21
+ raise ArgumentError, "output_dir must not be '.', got #{value.inspect}" if segments.all?('.')
22
+
23
+ value
24
+ end
25
+
26
+ sig { params(value: String, root: T.any(String, Pathname)).returns(Pathname) }
27
+ def resolve(value, root)
28
+ validate!(value)
29
+ base = real(root.to_s)
30
+ resolved = real(File.expand_path(value, base.to_s))
31
+ raise ArgumentError, "output_dir #{value.inspect} must resolve strictly under #{base}" unless resolved.to_s.start_with?("#{base}/")
32
+
33
+ resolved
34
+ end
35
+
36
+ private
37
+
38
+ # Symlinks are resolved on the deepest existing ancestor, so a link cannot smuggle the target out.
39
+ sig { params(path: String).returns(Pathname) }
40
+ def real(path)
41
+ missing = []
42
+ existing = Pathname(File.expand_path(path))
43
+ until existing.exist?
44
+ missing.unshift(existing.basename)
45
+ existing = existing.dirname
46
+ end
47
+ missing.reduce(existing.realpath) { |acc, segment| acc.join(segment) }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,8 +1,7 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'fileutils'
5
- require 'pathname'
4
+ require 'i18n'
6
5
  require 'sorbet-runtime'
7
6
 
8
7
  module ActiveMail
@@ -11,29 +10,29 @@ module ActiveMail
11
10
  class << self
12
11
  extend T::Sig
13
12
 
14
- # preview and message are external/dynamic ActionMailer & Mail objects.
15
- sig { params(preview: T.untyped, email: String).returns(String) }
16
- def render(preview, email)
17
- html_body(preview.call(email))
18
- end
19
-
20
- # The single source for the "preview_name#email" key format (config keys,
21
- # render_all, the Minitest helper).
22
13
  sig { params(preview: T.untyped, email: String).returns(String) }
23
14
  def key(preview, email)
24
15
  "#{preview.preview_name}##{email}"
25
16
  end
26
17
 
27
- sig { params(preview: T.untyped, email: String, output_root: T.any(String, Pathname)).returns(Pathname) }
28
- def render_to_disk(preview, email, output_root)
29
- dir = Pathname(output_root).join(preview.preview_name)
30
- FileUtils.mkdir_p(dir)
31
- path = dir.join("#{email}.html")
32
- File.write(path, render(preview, email))
33
- path
18
+ # The message renders lazily: every read of it must happen inside the block.
19
+ sig do
20
+ params(preview: T.untyped, email: String, locale: Symbol, around_each: T.nilable(Proc), block: T.proc.params(mail: T.untyped).returns(T.untyped))
21
+ .returns(T.untyped)
22
+ end
23
+ def within(preview, email, locale, around_each, &block)
24
+ ran = T.let(false, T::Boolean)
25
+ result = T.let(nil, T.untyped)
26
+ run = lambda do
27
+ ran = true
28
+ result = I18n.with_locale(locale) { block.call(mail(preview.call(email, locale: locale))) }
29
+ end
30
+ around_each ? around_each.call(preview, email, locale, &run) : run.call
31
+ raise ArgumentError, 'around_each must yield' unless ran
32
+
33
+ result
34
34
  end
35
35
 
36
- # [] when ActionMailer previews aren't loaded (host has none / previews disabled).
37
36
  sig { returns(T::Array[[T.untyped, String]]) }
38
37
  def all
39
38
  return [] unless defined?(ActionMailer::Preview)
@@ -43,26 +42,40 @@ module ActiveMail
43
42
  end
44
43
  end
45
44
 
46
- # Returns '' for a plain-text-only message so the Guard surfaces missing
47
- # HTML instead of validating plain text as if it were HTML.
48
- sig { params(message: T.untyped).returns(String) }
49
- def html_body(message)
50
- mail = message.respond_to?(:message) ? message.message : message
51
- body = mail.html_part&.body || single_part_html_body(mail)
52
- return '' unless body
45
+ # '' for a plain-text-only message: plain text must not be validated as HTML.
46
+ sig { params(mail: T.untyped).returns(String) }
47
+ def html_body(mail)
48
+ body_of(mail, mail.html_part, [nil, 'text/html'])
49
+ end
50
+
51
+ sig { params(mail: T.untyped).returns(String) }
52
+ def text_body(mail)
53
+ body_of(mail, mail.text_part, ['text/plain'])
54
+ end
53
55
 
54
- body.respond_to?(:decoded) ? body.decoded : body.to_s
56
+ # NullMail answers every respond_to? and returns nil: it is its own message.
57
+ sig { params(message: T.untyped).returns(T.untyped) }
58
+ def mail(message)
59
+ (message.message if message.respond_to?(:message)) || message
55
60
  end
56
61
 
57
- private
62
+ sig { params(mail: T.untyped).returns(T::Boolean) }
63
+ def declined?(mail)
64
+ mail.class.name.to_s.end_with?('::NullMail')
65
+ end
58
66
 
59
- # A single-part body counts as HTML only when its content-type says so
60
- # (or is unset); a declared text/plain body is not HTML.
61
- sig { params(mail: T.untyped).returns(T.untyped) }
62
- def single_part_html_body(mail)
63
- return if mail.multipart?
67
+ sig { params(config: Configuration, previews: T::Array[[T.untyped, String]]).returns(T::Array[[T.untyped, String, Symbol]]) }
68
+ def cases(config, previews = all)
69
+ previews.flat_map { |preview, email| config.locales_for(preview).map { |locale| [preview, email, locale] } }
70
+ end
71
+
72
+ private
64
73
 
65
- mail.body if mail.mime_type.nil? || mail.mime_type == 'text/html'
74
+ # A single-part body counts only when its content-type says so (unset counts as HTML).
75
+ sig { params(mail: T.untyped, part: T.untyped, mime_types: T::Array[T.nilable(String)]).returns(String) }
76
+ def body_of(mail, part, mime_types)
77
+ body = part&.body || (mail.body if !mail.multipart? && mime_types.include?(mail.mime_type))
78
+ body ? body.decoded : ''
66
79
  end
67
80
  end
68
81
  end
@@ -2,8 +2,10 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'fileutils'
5
+ require 'pathname'
5
6
 
6
7
  require_relative '../quality'
8
+ require_relative 'output_dir'
7
9
 
8
10
  module ActiveMail
9
11
  module Quality
@@ -12,6 +14,7 @@ module ActiveMail
12
14
  extend T::Sig
13
15
 
14
16
  class Result < T::Struct
17
+ const :output_root, Pathname
15
18
  const :discovered, Integer
16
19
  const :rendered, Integer
17
20
  const :render_failures, T::Hash[String, String]
@@ -19,72 +22,125 @@ module ActiveMail
19
22
  const :broken_required, T::Array[String]
20
23
  end
21
24
 
22
- sig { params(output_root: T.any(String, Pathname), config: Configuration).void }
23
- def initialize(output_root:, config: ActiveMail::Quality.config)
24
- @output_root = T.let(Pathname(output_root), Pathname)
25
+ MARKER = '.activemail_previews'
26
+ EXTENSIONS = T.let({ html: 'html', text: 'txt' }.freeze, T::Hash[Symbol, String])
27
+
28
+ sig { returns(Pathname) }
29
+ attr_reader :output_root
30
+
31
+ sig { params(root: T.any(String, Pathname), config: Configuration).void }
32
+ def initialize(root:, config: ActiveMail::Quality.config)
25
33
  @config = T.let(config, Configuration)
34
+ @output_root = T.let(OutputDir.resolve(config.output_dir, root), Pathname)
35
+ @render_failures = T.let({}, T::Hash[String, String])
36
+ @guard_failures = T.let({}, T::Hash[String, T::Array[Guard::Violation]])
37
+ @failed_keys = T.let(Set.new, T::Set[String])
38
+ @rendered = T.let(0, Integer)
26
39
  end
27
40
 
28
41
  sig { returns(Result) }
29
42
  def call
30
- FileUtils.rm_rf(@output_root)
31
- FileUtils.mkdir_p(@output_root)
32
-
33
- render_failures = {}
34
- guard_failures = {}
43
+ clean_previous_output
35
44
  previews = PreviewRenderer.all
36
- rendered = render_all(previews, render_failures, guard_failures)
45
+ PreviewRenderer.cases(@config, previews).each { |preview, email, locale| render_locale(preview, email, locale) }
37
46
 
38
47
  Result.new(
39
- discovered: previews.size, rendered: rendered, render_failures: render_failures,
40
- guard_failures: guard_failures, broken_required: broken_required(previews, render_failures, guard_failures)
48
+ output_root: @output_root, discovered: previews.size, rendered: @rendered, render_failures: @render_failures,
49
+ guard_failures: @guard_failures, broken_required: broken_required(previews)
41
50
  )
42
51
  end
43
52
 
44
53
  private
45
54
 
46
- # A required preview that fails, OR is never discovered at all (typo / deleted), is broken.
47
- sig do
48
- params(
49
- previews: T::Array[[T.untyped, String]],
50
- render_failures: T::Hash[String, String],
51
- guard_failures: T::Hash[String, T::Array[Guard::Violation]]
52
- ).returns(T::Array[String])
55
+ sig { void }
56
+ def clean_previous_output
57
+ ensure_owned!
58
+ @output_root.glob('**/*.{html,txt}').each { |path| path.delete if path.file? }
59
+ prune_empty_directories
60
+ FileUtils.mkdir_p(@output_root)
61
+ FileUtils.touch(@output_root.join(MARKER))
53
62
  end
54
- def broken_required(previews, render_failures, guard_failures)
63
+
64
+ # The marker proves a previous run owns the directory: without it, an existing
65
+ # non-empty output_dir (public/, app/...) is never touched.
66
+ sig { void }
67
+ def ensure_owned!
68
+ return if !@output_root.exist? || @output_root.join(MARKER).exist? || @output_root.empty?
69
+
70
+ raise ArgumentError, "output_dir #{@output_root} is not empty and was not created by activemail (no #{MARKER}); use a dedicated directory"
71
+ end
72
+
73
+ sig { void }
74
+ def prune_empty_directories
75
+ @output_root.glob('**/*/').sort_by { |dir| -dir.to_s.size }.each { |dir| dir.rmdir if dir.empty? }
76
+ end
77
+
78
+ # A required preview that fails, OR is never discovered at all (typo / deleted), is broken.
79
+ sig { params(previews: T::Array[[T.untyped, String]]).returns(T::Array[String]) }
80
+ def broken_required(previews)
55
81
  required = @config.required_previews
56
82
  discovered = previews.map { |preview, email| PreviewRenderer.key(preview, email) }
57
- ((render_failures.keys | guard_failures.keys) & required) | (required - discovered)
83
+ (@failed_keys.to_a & required) | (required - discovered)
58
84
  end
59
85
 
60
- sig do
61
- params(previews: T::Array[[T.untyped, String]], render_failures: T::Hash[String, String], guard_failures: T::Hash[String, T::Array[Guard::Violation]]).returns(Integer)
62
- end
63
- def render_all(previews, render_failures, guard_failures)
64
- previews.count do |preview, email|
65
- key = PreviewRenderer.key(preview, email)
66
- path = render_one(preview, email, render_failures, key)
67
- next false unless path
68
-
69
- violations = @config.guard.violations(File.read(path))
70
- guard_failures[key] = violations unless violations.empty?
71
- true
72
- end
86
+ sig { params(preview: T.untyped, email: String, locale: Symbol).void }
87
+ def render_locale(preview, email, locale)
88
+ key = PreviewRenderer.key(preview, email)
89
+ label = locale == I18n.default_locale ? key : "#{key} [#{locale}]"
90
+ violations = render_one(preview, email, locale, label)
91
+ record(key, label, violations)
73
92
  end
74
93
 
75
- sig do
76
- params(preview: T.untyped, email: String, failures: T::Hash[String, String], key: String).returns(T.nilable(Pathname))
94
+ sig { params(key: String, label: String, violations: T.nilable(T::Array[Guard::Violation])).void }
95
+ def record(key, label, violations)
96
+ return @failed_keys << key if violations.nil?
97
+
98
+ @rendered += 1
99
+ return if violations.empty?
100
+
101
+ @failed_keys << key
102
+ @guard_failures[label] = violations
77
103
  end
78
- def render_one(preview, email, failures, key)
79
- PreviewRenderer.render_to_disk(preview, email, @output_root)
104
+
105
+ sig { params(preview: T.untyped, email: String, locale: Symbol, label: String).returns(T.nilable(T::Array[Guard::Violation])) }
106
+ def render_one(preview, email, locale, label)
107
+ PreviewRenderer.within(preview, email, locale, @config.around_each) do |mail|
108
+ next [] if PreviewRenderer.declined?(mail)
109
+
110
+ write(preview, email, locale, mail)
111
+ @config.guard.violations(mail)
112
+ end
80
113
  rescue SystemCallError
81
114
  raise # disk/permission failures are not template bugs — let them abort the run
82
115
  rescue StandardError => e
83
- root = e.cause
84
- cause = root ? "\n caused by: #{root.class}: #{root.message}" : ''
85
- failures[key] = "#{e.class}: #{e.message}\n #{e.backtrace&.first(5)&.join("\n ")}#{cause}"
116
+ @render_failures[label] = describe(e)
86
117
  nil
87
118
  end
119
+
120
+ sig { params(error: StandardError).returns(String) }
121
+ def describe(error)
122
+ root = error.cause
123
+ cause = root ? "\n caused by: #{root.class}: #{root.message}" : ''
124
+ "#{error.class}: #{error.message}\n #{error.backtrace&.first(5)&.join("\n ")}#{cause}"
125
+ end
126
+
127
+ sig { params(preview: T.untyped, email: String, locale: Symbol, mail: T.untyped).void }
128
+ def write(preview, email, locale, mail)
129
+ dir = @output_root.join(preview.preview_name)
130
+ FileUtils.mkdir_p(dir)
131
+ # The default locale stays unsuffixed so a diff against an older run still lines up.
132
+ stem = locale == I18n.default_locale ? email : "#{email}.#{locale}"
133
+ contents = { html: PreviewRenderer.html_body(mail), text: PreviewRenderer.text_body(mail) }
134
+
135
+ @config.formats.each do |format|
136
+ File.write(dir.join("#{stem}.#{EXTENSIONS.fetch(format)}"), filtered(contents.fetch(format)))
137
+ end
138
+ end
139
+
140
+ sig { params(content: String).returns(String) }
141
+ def filtered(content)
142
+ @config.output_filters.reduce(content) { |acc, filter| filter.call(acc) }
143
+ end
88
144
  end
89
145
  end
90
146
  end
@@ -6,8 +6,6 @@ require_relative '../quality'
6
6
  module ActiveMail
7
7
  module Quality
8
8
  module Rspec
9
- # Standalone matcher object — testable without booting RSpec. The
10
- # be_a_valid_email helper below just returns it (no RSpec::Matchers.define DSL).
11
9
  class ValidEmailMatcher
12
10
  extend T::Sig
13
11
 
@@ -17,9 +15,9 @@ module ActiveMail
17
15
  @violations = T.let([], T::Array[Guard::Violation])
18
16
  end
19
17
 
20
- sig { params(html: String).returns(T::Boolean) }
21
- def matches?(html)
22
- @violations = @guard.violations(html)
18
+ sig { params(html_or_message: T.untyped).returns(T::Boolean) }
19
+ def matches?(html_or_message)
20
+ @violations = @guard.violations(html_or_message)
23
21
  @violations.empty?
24
22
  end
25
23
 
@@ -42,7 +40,7 @@ module ActiveMail
42
40
 
43
41
  sig { returns(String) }
44
42
  def formatted_violations
45
- @violations.map { |v| " - [#{v.rule}] #{v.message}" }.join("\n")
43
+ @violations.map { |v| " - #{v}" }.join("\n")
46
44
  end
47
45
  end
48
46
  end
@@ -50,8 +48,6 @@ module ActiveMail
50
48
  end
51
49
 
52
50
  if defined?(RSpec)
53
- # ValidEmailMatcher already implements the full matcher protocol, so a simple
54
- # helper returning it is enough — no RSpec::Matchers.define DSL needed.
55
51
  module RSpec
56
52
  module Matchers
57
53
  extend T::Sig
@@ -0,0 +1,47 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ActiveMail
7
+ module Quality
8
+ # :html rules receive (Nokogiri document, html), :message rules the mail; a check
9
+ # returns nil/false to pass, else one message or an Array of them.
10
+ class Rule
11
+ extend T::Sig
12
+
13
+ LEVELS = T.let(%i[html message].freeze, T::Array[Symbol])
14
+
15
+ sig { returns(Symbol) }
16
+ attr_reader :name
17
+
18
+ sig { returns(Symbol) }
19
+ attr_reader :level
20
+
21
+ sig { params(name: Symbol, level: Symbol, check: Proc).void }
22
+ def initialize(name, level, check)
23
+ raise ArgumentError, "rule level must be one of #{LEVELS.inspect}, got #{level.inspect}" unless LEVELS.include?(level)
24
+
25
+ @name = name
26
+ @level = level
27
+ @check = check
28
+ end
29
+
30
+ sig { params(name: Symbol, check: Proc).returns(Rule) }
31
+ def self.html(name, &check)
32
+ new(name, :html, check)
33
+ end
34
+
35
+ sig { params(name: Symbol, check: Proc).returns(Rule) }
36
+ def self.message(name, &check)
37
+ new(name, :message, check)
38
+ end
39
+
40
+ sig { params(args: T::Array[T.untyped]).returns(T::Array[String]) }
41
+ def messages(args)
42
+ result = T.unsafe(@check).call(*args)
43
+ result ? Array(result).map(&:to_s) : []
44
+ end
45
+ end
46
+ end
47
+ end
@@ -2,28 +2,28 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'rails/engine'
5
- require 'activemail/rails/compiled_stylesheet'
5
+ require 'activemail/asset_pipeline'
6
+ require 'activemail/rails/rendering'
6
7
 
7
8
  module ActiveMail
8
9
  module Rails
9
10
  class Engine < ::Rails::Engine
10
- config.annotations.register_extensions('activemail') { |annotation| /<!--\s*(#{annotation}):?\s*(.*) -->/ } if config.respond_to?(:annotations)
11
-
12
- # Sprockets only compiles whitelisted assets; the framework entry must be
13
- # reachable as `stylesheet_link_tag "activemail/activemail"` from a host.
11
+ # Sprockets only compiles whitelisted assets (a no-op under Propshaft).
14
12
  initializer 'activemail.assets' do |app|
15
- # Propshaft exposes config.assets but no #precompile (Sprockets-only).
16
- assets = app.config.respond_to?(:assets) ? app.config.assets : nil
17
- assets.precompile += %w[activemail/activemail.css] if assets.respond_to?(:precompile)
13
+ ActiveMail::AssetPipeline.for(app).register_precompile(%w[activemail/activemail.css])
14
+ end
15
+
16
+ initializer 'activemail.annotations' do |app|
17
+ ActiveSupport.on_load(:action_view) do
18
+ extensions = ActionView::Template.template_handler_extensions.grep(/\Ainky-/)
19
+ app.config.annotations.register_extensions(*extensions) { |annotation| /<!--\s*(#{annotation}):?\s*(.*) -->/ }
20
+ end
18
21
  end
19
22
 
20
23
  initializer 'activemail.action_mailer' do
21
24
  ActiveSupport.on_load(:action_mailer) do
22
- require 'activemail/inliner/interceptor'
23
- # The interceptor honors config.register_inline_interceptor (and inliner =
24
- # :null) at delivery time — a boot-time check would precede host config.
25
- register_interceptor ActiveMail::Inliner::Interceptor
26
- # activemail_inline_styles must be available to mailer layouts/views.
25
+ prepend ActiveMail::Rails::Rendering::MailerExtension
26
+
27
27
  helper ActiveMail::StylesHelper
28
28
  end
29
29
  end
@@ -0,0 +1,51 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'active_support/isolated_execution_state'
5
+
6
+ module ActiveMail
7
+ module Rails
8
+ # Handlers only mark the scope; the pipeline runs once at its exit, so a
9
+ # layout wrapping a view never re-transpiles it.
10
+ module Rendering
11
+ STATE_KEY = :activemail_render_scope
12
+
13
+ class Scope
14
+ attr_accessor :inky_html
15
+ end
16
+
17
+ class << self
18
+ def scoped
19
+ return yield if current
20
+
21
+ scope = Scope.new
22
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = scope
23
+ body = yield
24
+ scope.inky_html ? ActiveMail::Pipeline.call(body.to_s).html_safe : body
25
+ ensure
26
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = nil if scope
27
+ end
28
+
29
+ def process(output, format)
30
+ scope = current
31
+ return ActiveMail.configuration.core.transpile(output) unless scope
32
+
33
+ scope.inky_html = true if format.nil? || format == :html
34
+ output
35
+ end
36
+
37
+ private
38
+
39
+ def current
40
+ ActiveSupport::IsolatedExecutionState[STATE_KEY]
41
+ end
42
+ end
43
+
44
+ module MailerExtension
45
+ def render_to_body(options = {})
46
+ ActiveMail::Rails::Rendering.scoped { super }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -3,6 +3,8 @@
3
3
 
4
4
  require 'sorbet-runtime'
5
5
 
6
+ require_relative 'rendering'
7
+
6
8
  module ActiveMail
7
9
  module Rails
8
10
  class TemplateHandler
@@ -37,7 +39,7 @@ module ActiveMail
37
39
  else
38
40
  engine_handler.call(template)
39
41
  end
40
- "ActiveMail::Core.new.transpile(begin; #{compiled_source};end)"
42
+ "ActiveMail::Rails::Rendering.process(begin; #{compiled_source};end, #{template.format.inspect})"
41
43
  end
42
44
 
43
45
  module Composer
@@ -14,12 +14,11 @@ module ActiveMail
14
14
  const :radius, String
15
15
  const :border, T.nilable(String)
16
16
 
17
- # color falls back to :button_text; border is opt-in (a "<variant>_border" token).
18
17
  sig { params(tokens: ActiveMail::Tokens, variant: T.any(String, Symbol)).returns(ButtonStyle) }
19
18
  def self.from(tokens, variant)
20
19
  new(
21
20
  background: tokens.color!(variant),
22
- color: tokens.color("#{variant}_text") || tokens.color!(:button_text),
21
+ color: tokens.color!("#{variant}_text"),
23
22
  radius: tokens.radius!(:button),
24
23
  border: tokens.color("#{variant}_border")
25
24
  )
@@ -0,0 +1,77 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ActiveMail
7
+ class Tokens
8
+ TokenMap = T.type_alias { T::Hash[Symbol, String] }
9
+
10
+ DEFAULT_COLORS = T.let(
11
+ {
12
+ primary: '#2a9d8f',
13
+ secondary: '#264653',
14
+ text: '#1a1a1a',
15
+ background: '#ffffff',
16
+ muted: '#6b7280',
17
+ border: '#e5e7eb',
18
+ button_text: '#ffffff'
19
+ }.freeze,
20
+ TokenMap
21
+ )
22
+
23
+ SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
24
+
25
+ DEFAULT_FONTS = T.let(
26
+ {
27
+ body: SYSTEM_FONT_STACK,
28
+ heading: SYSTEM_FONT_STACK
29
+ }.freeze,
30
+ TokenMap
31
+ )
32
+
33
+ DEFAULT_SPACINGS = T.let(
34
+ {
35
+ xs: '4px',
36
+ sm: '8px',
37
+ md: '16px',
38
+ lg: '24px',
39
+ xl: '40px'
40
+ }.freeze,
41
+ TokenMap
42
+ )
43
+
44
+ DEFAULT_RADII = T.let(
45
+ {
46
+ button: '4px',
47
+ box: '4px'
48
+ }.freeze,
49
+ TokenMap
50
+ )
51
+
52
+ # Optional colors resolve to another token (nil: unset) so components and the
53
+ # SCSS bridge read the same value whether or not the host configured them.
54
+ DERIVED_COLORS = T.let(
55
+ {
56
+ primary_text: :button_text,
57
+ primary_border: nil,
58
+ secondary_text: :button_text,
59
+ secondary_border: nil,
60
+ info_box_background: :background,
61
+ info_box_border: :border,
62
+ info_box_text: :text
63
+ }.freeze,
64
+ T::Hash[Symbol, T.nilable(Symbol)]
65
+ )
66
+
67
+ GROUPS = T.let(
68
+ {
69
+ color: DEFAULT_COLORS,
70
+ font: DEFAULT_FONTS,
71
+ spacing: DEFAULT_SPACINGS,
72
+ radius: DEFAULT_RADII
73
+ }.freeze,
74
+ T::Hash[Symbol, TokenMap]
75
+ )
76
+ end
77
+ end
@@ -5,15 +5,15 @@ require 'sorbet-runtime'
5
5
 
6
6
  module ActiveMail
7
7
  class Tokens
8
- # !default lets a host pre-declare overrides upstream. Values are emitted
9
- # verbatim (trusted, app-controlled input) — not escaped.
8
+ # !default lets a host configure overrides upstream. Values are emitted
9
+ # verbatim (trusted, app-controlled input) — not escaped; nil becomes Sass null.
10
10
  module ScssSerializer
11
11
  extend T::Sig
12
12
 
13
- sig { params(stores: T::Hash[Symbol, TokenMap]).returns(String) }
13
+ sig { params(stores: T::Hash[Symbol, T::Hash[Symbol, T.nilable(String)]]).returns(String) }
14
14
  def self.call(stores)
15
15
  lines = stores.flat_map do |group, store|
16
- store.map { |name, value| "$am-#{group}-#{name.to_s.tr('_', '-')}: #{value} !default;" }
16
+ store.map { |name, value| "$am-#{group}-#{name.to_s.tr('_', '-')}: #{value || 'null'} !default;" }
17
17
  end
18
18
  "#{lines.join("\n")}\n"
19
19
  end