activemail 1.2.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -2
  3. data/README.md +105 -47
  4. data/UPGRADING.md +112 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +31 -20
  7. data/app/assets/stylesheets/activemail/_grid.scss +2 -0
  8. data/app/assets/stylesheets/activemail/_settings.scss +22 -10
  9. data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
  10. data/app/assets/stylesheets/activemail/activemail.scss +6 -5
  11. data/app/helpers/active_mail/styles_helper.rb +14 -15
  12. data/app/views/layouts/activemail/mailer.html.inky-erb +0 -5
  13. data/lib/activemail/asset_pipeline/base.rb +47 -0
  14. data/lib/activemail/asset_pipeline/null.rb +19 -0
  15. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  16. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  17. data/lib/activemail/asset_pipeline.rb +35 -0
  18. data/lib/activemail/components/base.rb +35 -39
  19. data/lib/activemail/components/block_grid.rb +11 -4
  20. data/lib/activemail/components/button.rb +5 -28
  21. data/lib/activemail/components/button_renderer.rb +65 -0
  22. data/lib/activemail/components/callout.rb +4 -5
  23. data/lib/activemail/components/center.rb +5 -7
  24. data/lib/activemail/components/columns.rb +26 -7
  25. data/lib/activemail/components/container.rb +11 -4
  26. data/lib/activemail/components/cta.rb +9 -33
  27. data/lib/activemail/components/h_line.rb +3 -5
  28. data/lib/activemail/components/info_box.rb +6 -12
  29. data/lib/activemail/components/menu.rb +3 -4
  30. data/lib/activemail/components/menu_item.rb +10 -3
  31. data/lib/activemail/components/row.rb +3 -4
  32. data/lib/activemail/components/spacer.rb +16 -12
  33. data/lib/activemail/components/table_component.rb +23 -0
  34. data/lib/activemail/components/wrapper.rb +10 -4
  35. data/lib/activemail/configuration.rb +43 -57
  36. data/lib/activemail/errors.rb +15 -0
  37. data/lib/activemail/inliner/base.rb +2 -10
  38. data/lib/activemail/inliner/null.rb +0 -5
  39. data/lib/activemail/inliner/roadie.rb +1 -1
  40. data/lib/activemail/inliner.rb +33 -0
  41. data/lib/activemail/parse_error_reporter.rb +5 -4
  42. data/lib/activemail/pipeline.rb +26 -0
  43. data/lib/activemail/quality/builtin_rules.rb +154 -0
  44. data/lib/activemail/quality/configuration.rb +83 -3
  45. data/lib/activemail/quality/guard.rb +45 -87
  46. data/lib/activemail/quality/minitest.rb +41 -25
  47. data/lib/activemail/quality/output_dir.rb +52 -0
  48. data/lib/activemail/quality/preview_renderer.rb +46 -33
  49. data/lib/activemail/quality/render_all.rb +96 -40
  50. data/lib/activemail/quality/rspec.rb +4 -8
  51. data/lib/activemail/quality/rule.rb +47 -0
  52. data/lib/activemail/rails/engine.rb +13 -13
  53. data/lib/activemail/rails/rendering.rb +51 -0
  54. data/lib/activemail/rails/template_handler.rb +3 -1
  55. data/lib/activemail/tokens/button_style.rb +1 -2
  56. data/lib/activemail/tokens/defaults.rb +77 -0
  57. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  58. data/lib/activemail/tokens.rb +19 -58
  59. data/lib/activemail/version.rb +1 -1
  60. data/lib/activemail.rb +89 -61
  61. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  62. data/lib/generators/activemail/templates/initializer.rb +0 -3
  63. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +0 -2
  64. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
  65. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
  66. data/lib/tasks/activemail.rake +4 -5
  67. metadata +38 -7
  68. data/lib/activemail/components/inky.rb +0 -18
  69. data/lib/activemail/inliner/interceptor.rb +0 -51
  70. data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
@@ -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
@@ -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.2'
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