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,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
+ @config.check_exemptions!(@config.guard)
44
+ clean_previous_output
35
45
  previews = PreviewRenderer.all
36
- rendered = render_all(previews, render_failures, guard_failures)
46
+ PreviewRenderer.cases(@config, previews).each { |preview, email, locale| render_locale(preview, email, locale) }
37
47
 
38
48
  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)
49
+ output_root: @output_root, discovered: previews.size, rendered: @rendered, render_failures: @render_failures,
50
+ guard_failures: @guard_failures, broken_required: broken_required(previews)
41
51
  )
42
52
  end
43
53
 
44
54
  private
45
55
 
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])
56
+ sig { void }
57
+ def clean_previous_output
58
+ ensure_owned!
59
+ @output_root.glob('**/*.{html,txt}').each { |path| path.delete if path.file? }
60
+ prune_empty_directories
61
+ FileUtils.mkdir_p(@output_root)
62
+ FileUtils.touch(@output_root.join(MARKER))
53
63
  end
54
- def broken_required(previews, render_failures, guard_failures)
64
+
65
+ # The marker proves a previous run owns the directory: without it, an existing
66
+ # non-empty output_dir (public/, app/...) is never touched.
67
+ sig { void }
68
+ def ensure_owned!
69
+ return if !@output_root.exist? || @output_root.join(MARKER).exist? || @output_root.empty?
70
+
71
+ raise ArgumentError, "output_dir #{@output_root} is not empty and was not created by activemail (no #{MARKER}); use a dedicated directory"
72
+ end
73
+
74
+ sig { void }
75
+ def prune_empty_directories
76
+ @output_root.glob('**/*/').sort_by { |dir| -dir.to_s.size }.each { |dir| dir.rmdir if dir.empty? }
77
+ end
78
+
79
+ # A required preview that fails, OR is never discovered at all (typo / deleted), is broken.
80
+ sig { params(previews: T::Array[[T.untyped, String]]).returns(T::Array[String]) }
81
+ def broken_required(previews)
55
82
  required = @config.required_previews
56
83
  discovered = previews.map { |preview, email| PreviewRenderer.key(preview, email) }
57
- ((render_failures.keys | guard_failures.keys) & required) | (required - discovered)
84
+ (@failed_keys.to_a & required) | (required - discovered)
58
85
  end
59
86
 
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)
87
+ sig { params(preview: T.untyped, email: String, locale: Symbol).void }
88
+ def render_locale(preview, email, locale)
89
+ key = PreviewRenderer.key(preview, email)
90
+ label = locale == I18n.default_locale ? key : "#{key} [#{locale}]"
91
+ violations = render_one(preview, email, locale, label)
92
+ record(key, label, violations)
62
93
  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
94
 
69
- violations = @config.guard.violations(File.read(path))
70
- guard_failures[key] = violations unless violations.empty?
71
- true
72
- end
73
- end
95
+ sig { params(key: String, label: String, violations: T.nilable(T::Array[Guard::Violation])).void }
96
+ def record(key, label, violations)
97
+ return @failed_keys << key if violations.nil?
98
+
99
+ @rendered += 1
100
+ return if violations.empty?
74
101
 
75
- sig do
76
- params(preview: T.untyped, email: String, failures: T::Hash[String, String], key: String).returns(T.nilable(Pathname))
102
+ @failed_keys << key
103
+ @guard_failures[label] = violations
77
104
  end
78
- def render_one(preview, email, failures, key)
79
- PreviewRenderer.render_to_disk(preview, email, @output_root)
105
+
106
+ sig { params(preview: T.untyped, email: String, locale: Symbol, label: String).returns(T.nilable(T::Array[Guard::Violation])) }
107
+ def render_one(preview, email, locale, label)
108
+ PreviewRenderer.within(preview, email, locale, @config.around_each) do |mail|
109
+ write(preview, email, locale, mail) unless PreviewRenderer.declined?(mail)
110
+ key = PreviewRenderer.key(preview, email)
111
+ @config.guard.violations(mail, allow_declined: @config.allow_declined?(key), skip: @config.exempt_rules_for(key))
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,20 +6,20 @@ 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
 
14
- sig { params(guard: Guard).void }
15
- def initialize(guard: ActiveMail::Quality.guard)
12
+ sig { params(guard: Guard, allow_declined: T::Boolean, skip: T::Array[Symbol]).void }
13
+ def initialize(guard: ActiveMail::Quality.guard, allow_declined: false, skip: Guard::NO_SKIP)
16
14
  @guard = T.let(guard, Guard)
15
+ @allow_declined = T.let(allow_declined, T::Boolean)
16
+ @skip = T.let(skip, T::Array[Symbol])
17
17
  @violations = T.let([], T::Array[Guard::Violation])
18
18
  end
19
19
 
20
- sig { params(html: String).returns(T::Boolean) }
21
- def matches?(html)
22
- @violations = @guard.violations(html)
20
+ sig { params(html_or_message: T.untyped).returns(T::Boolean) }
21
+ def matches?(html_or_message)
22
+ @violations = @guard.violations(html_or_message, allow_declined: @allow_declined, skip: @skip)
23
23
  @violations.empty?
24
24
  end
25
25
 
@@ -42,7 +42,7 @@ module ActiveMail
42
42
 
43
43
  sig { returns(String) }
44
44
  def formatted_violations
45
- @violations.map { |v| " - [#{v.rule}] #{v.message}" }.join("\n")
45
+ @violations.map { |v| " - #{v}" }.join("\n")
46
46
  end
47
47
  end
48
48
  end
@@ -50,15 +50,16 @@ module ActiveMail
50
50
  end
51
51
 
52
52
  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
53
  module RSpec
56
54
  module Matchers
57
55
  extend T::Sig
58
56
 
59
- sig { params(guard: ActiveMail::Quality::Guard).returns(ActiveMail::Quality::Rspec::ValidEmailMatcher) }
60
- def be_a_valid_email(guard: ActiveMail::Quality.guard)
61
- ActiveMail::Quality::Rspec::ValidEmailMatcher.new(guard: guard)
57
+ sig do
58
+ params(guard: ActiveMail::Quality::Guard, allow_declined: T::Boolean, skip: T::Array[Symbol])
59
+ .returns(ActiveMail::Quality::Rspec::ValidEmailMatcher)
60
+ end
61
+ def be_a_valid_email(guard: ActiveMail::Quality.guard, allow_declined: false, skip: ActiveMail::Quality::Guard::NO_SKIP)
62
+ ActiveMail::Quality::Rspec::ValidEmailMatcher.new(guard: guard, allow_declined: allow_declined, skip: skip)
62
63
  end
63
64
  end
64
65
  end
@@ -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
@@ -0,0 +1,49 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ require_relative 'guard'
7
+
8
+ module ActiveMail
9
+ module Quality
10
+ # Rules a preview is exempt from, keyed by preview name (every email of it) or by
11
+ # preview key ("preview_name#email"); values are rule names, built-in or custom.
12
+ module RuleExemptions
13
+ extend T::Sig
14
+
15
+ sig { returns(T::Hash[String, T::Array[Symbol]]) }
16
+ def rule_exemptions
17
+ exemptions.dup.freeze
18
+ end
19
+
20
+ sig { params(value: T.untyped).void }
21
+ def rule_exemptions=(value)
22
+ Kernel.raise TypeError, "rule_exemptions must be a Hash, got #{value.class}" unless value.is_a?(Hash)
23
+
24
+ exemptions = value.to_h { |key, rules| [key.to_s.freeze, Kernel.Array(rules).map(&:to_sym).freeze] }
25
+ @exemptions = T.let(exemptions, T.nilable(T::Hash[String, T::Array[Symbol]]))
26
+ end
27
+
28
+ sig { params(key: String).returns(T::Array[Symbol]) }
29
+ def exempt_rules_for(key)
30
+ by_key = exemptions.fetch(key, Guard::NO_SKIP)
31
+ by_name = exemptions.fetch(key.split('#', 2).first.to_s, Guard::NO_SKIP)
32
+ by_key.empty? ? by_name : by_key | by_name
33
+ end
34
+
35
+ # Called before any preview renders: a bad name would otherwise surface as a render failure, or a skip.
36
+ sig { params(guard: Guard).void }
37
+ def check_exemptions!(guard)
38
+ exemptions.each_value { |rules| guard.check_skippable!(rules) }
39
+ end
40
+
41
+ private
42
+
43
+ sig { returns(T::Hash[String, T::Array[Symbol]]) }
44
+ def exemptions
45
+ @exemptions ||= T.let({}, T.nilable(T::Hash[String, T::Array[Symbol]]))
46
+ end
47
+ end
48
+ end
49
+ 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,78 @@
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
+ container_gutter: '16px'
41
+ }.freeze,
42
+ TokenMap
43
+ )
44
+
45
+ DEFAULT_RADII = T.let(
46
+ {
47
+ button: '4px',
48
+ box: '4px'
49
+ }.freeze,
50
+ TokenMap
51
+ )
52
+
53
+ # Optional colors resolve to another token (nil: unset) so components and the
54
+ # SCSS bridge read the same value whether or not the host configured them.
55
+ DERIVED_COLORS = T.let(
56
+ {
57
+ primary_text: :button_text,
58
+ primary_border: nil,
59
+ secondary_text: :button_text,
60
+ secondary_border: nil,
61
+ info_box_background: :background,
62
+ info_box_border: :border,
63
+ info_box_text: :text
64
+ }.freeze,
65
+ T::Hash[Symbol, T.nilable(Symbol)]
66
+ )
67
+
68
+ GROUPS = T.let(
69
+ {
70
+ color: DEFAULT_COLORS,
71
+ font: DEFAULT_FONTS,
72
+ spacing: DEFAULT_SPACINGS,
73
+ radius: DEFAULT_RADII
74
+ }.freeze,
75
+ T::Hash[Symbol, TokenMap]
76
+ )
77
+ end
78
+ 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