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
@@ -22,26 +22,45 @@ module ActiveMail
22
22
 
23
23
  private
24
24
 
25
+ sig { override.returns(T::Array[String]) }
26
+ def consumed_attributes
27
+ %w[small large]
28
+ end
29
+
25
30
  sig { params(node: Nokogiri::XML::Node).returns([Integer, Integer]) }
26
31
  def column_sizes(node)
27
32
  small = positive_int(node.attr('small'))
28
33
  large = positive_int(node.attr('large'))
29
- siblings = node.parent ? node.parent.elements.size : 1
30
- [small || column_count, large || small || [column_count / siblings, 1].max]
34
+ [small || column_count, large || small || [column_count / column_siblings(node).size, 1].max]
35
+ end
36
+
37
+ # Already transformed columns are interim <th class="columns"> elements; other siblings take no width.
38
+ sig { params(node: Nokogiri::XML::Node).returns(T::Array[Nokogiri::XML::Node]) }
39
+ def column_siblings(node)
40
+ return [node] unless node.parent
41
+
42
+ node.parent.elements.select { |sibling| sibling.name == node.name || column?(sibling) }
43
+ end
44
+
45
+ sig { params(node: Nokogiri::XML::Node).returns(T::Boolean) }
46
+ def column?(node)
47
+ return false unless node.name == ::ActiveMail::Core::INTERIM_TH_TAG
48
+
49
+ class?(node, 'columns')
31
50
  end
32
51
 
33
52
  sig { params(node: Nokogiri::XML::Node, small: Integer, large: Integer).returns(String) }
34
53
  def column_classes(node, small, large)
54
+ siblings = column_siblings(node)
35
55
  classes = combine_classes(node, "small-#{small} large-#{large} columns")
36
- classes += ' first' unless node.previous_element
37
- classes += ' last' unless node.next_element
56
+ classes += ' first' if siblings.first == node
57
+ classes += ' last' if siblings.last == node
38
58
  classes
39
59
  end
40
60
 
41
61
  sig { params(node: Nokogiri::XML::Node, large: Integer).returns(String) }
42
62
  def expander(node, large)
43
- subrows = node.elements.css('.row').to_a.concat(node.elements.css('row').to_a)
44
- return '' unless large == column_count && subrows.empty?
63
+ return '' unless large == column_count && node.at_css('.row').nil?
45
64
 
46
65
  %(<th class="expander"></th>)
47
66
  end
@@ -61,7 +80,7 @@ module ActiveMail
61
80
  # Neutralize the client default th rendering (bold, centered).
62
81
  content_style = 'font-weight:normal;text-align:left;'
63
82
  [
64
- %(<#{::ActiveMail::Core::INTERIM_TH_TAG} class="#{classes}"#{style_attribute(node, style)} #{pass_through_attributes(node)}>),
83
+ %(<#{::ActiveMail::Core::INTERIM_TH_TAG}#{root_attributes(node, classes: classes, layout_style: style)}>),
65
84
  %(<table #{TABLE_RESET} style="width:100%;"><tbody><tr>),
66
85
  %(<th style="#{content_style}">#{inner}</th>#{expander}),
67
86
  %(</tr></tbody></table></#{::ActiveMail::Core::INTERIM_TH_TAG}>)
@@ -1,25 +1,32 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Container < Base
8
+ class Container < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'container')
14
13
  width = container_width
14
+ attributes = root_attributes(node, classes: 'container', layout_style: "width:100%;max-width:#{width}px;margin:0 auto;")
15
15
  # Outlook Word ignores max-width; the MSO ghost table pins the width.
16
16
  [
17
17
  %(<!--[if mso | IE]><table #{TABLE_RESET} align="center" width="#{width}"><tr><td><![endif]-->),
18
- %(<table #{attributes} #{TABLE_RESET} align="center"#{style_attribute(node, "width:100%;max-width:#{width}px;margin:0 auto;")}>),
18
+ %(<table#{attributes} #{TABLE_RESET} align="center">),
19
19
  %(<tbody><tr><td>#{inner}</td></tr></tbody></table>),
20
20
  '<!--[if mso | IE]></td></tr></table><![endif]-->'
21
21
  ].join
22
22
  end
23
+
24
+ private
25
+
26
+ sig { override.returns(T::Array[String]) }
27
+ def consumed_attributes
28
+ [*super, 'align']
29
+ end
23
30
  end
24
31
  end
25
32
  end
@@ -1,48 +1,24 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'button_renderer'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- # Colors read from tokens at transform time (runtime config), not load-time constants.
9
- # Styles are inlined so the button survives clients that strip <style> (Gmail mobile…).
10
- class Cta < Base
8
+ class Cta < ButtonRenderer
11
9
  extend T::Sig
12
10
 
13
- sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
11
+ sig { override.returns(String) }
12
+ def root_class
13
+ 'cta'
14
+ end
15
+
16
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
14
17
  def transform(node, inner)
15
18
  # A CTA without a link is an authoring bug — surface it at render time.
16
19
  raise ArgumentError, '<cta> requires an href attribute' if node.attr('href').to_s.strip.empty?
17
20
 
18
- style = ActiveMail.tokens.button_style(class?(node, 'secondary') ? :secondary : :primary)
19
- anchor = %(<a href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)} ) +
20
- %(style="#{link_style(style)}">#{inner}</a>)
21
- bulletproof_button_table(
22
- outer_classes: combine_classes(node, 'cta'),
23
- inner: anchor,
24
- cell_style: cell_style(style)
25
- )
26
- end
27
-
28
- private
29
-
30
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
31
- def cell_style(style)
32
- "background:#{style.background};border-radius:#{style.radius};#{border_css(style)}"
33
- end
34
-
35
- # No border here: it lives on the cell only — a repeated <a> border doubles the line.
36
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
37
- def link_style(style)
38
- "display:inline-block;text-decoration:none;#{BUTTON_PADDING}" \
39
- "background:#{style.background};color:#{style.color};font-weight:bold;" \
40
- "border-radius:#{style.radius};"
41
- end
42
-
43
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
44
- def border_css(style)
45
- style.border ? "border:1px solid #{style.border};" : ''
21
+ super
46
22
  end
47
23
  end
48
24
  end
@@ -1,18 +1,16 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class HLine < Base
8
+ class HLine < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
12
12
  def transform(node, _inner)
13
- classes = combine_classes(node, 'h-line')
14
- attributes = pass_through_attributes(node)
15
- %(<table #{attributes}class="#{classes}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr><th>&nbsp;</th></tr></tbody></table>)
13
+ %(<table#{root_attributes(node, classes: 'h-line', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr><th>&nbsp;</th></tr></tbody></table>)
16
14
  end
17
15
  end
18
16
  end
@@ -1,19 +1,18 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
8
  # Colors read from tokens at transform time (runtime config), not load-time constants.
9
- class InfoBox < Base
9
+ class InfoBox < TableComponent
10
10
  extend T::Sig
11
11
 
12
12
  sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
13
13
  def transform(node, inner)
14
- classes = combine_classes(node, 'info-box')
15
14
  [
16
- %(<table class="#{classes}" #{TABLE_RESET} style="width:100%;"><tbody><tr>),
15
+ %(<table#{root_attributes(node, classes: 'info-box', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>),
17
16
  %(<td style="#{cell_style}">#{inner}</td>),
18
17
  '</tr></tbody></table>'
19
18
  ].join
@@ -21,16 +20,11 @@ module ActiveMail
21
20
 
22
21
  private
23
22
 
24
- # Box-scoped tokens (fall back to page-level ones) so a host can give the box
25
- # a distinct surface without colliding with :background/:border/:text.
26
23
  sig { returns(String) }
27
24
  def cell_style
28
- tokens = ActiveMail.tokens
29
- background = tokens.color(:info_box_background) || tokens.color!(:background)
30
- border = tokens.color(:info_box_border) || tokens.color!(:border)
31
- text = tokens.color(:info_box_text) || tokens.color!(:text)
32
- "background-color:#{background};border-left:5px solid #{border};border-radius:#{tokens.radius!(:box)};" \
33
- "color:#{text};padding:#{tokens.spacing!(:md)};"
25
+ tokens = core.config.tokens
26
+ "background-color:#{tokens.color!(:info_box_background)};border-left:5px solid #{tokens.color!(:info_box_border)};" \
27
+ "border-radius:#{tokens.radius!(:box)};color:#{tokens.color!(:info_box_text)};padding:#{tokens.spacing!(:md)};"
34
28
  end
35
29
  end
36
30
  end
@@ -1,18 +1,17 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Menu < Base
8
+ class Menu < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'menu')
14
13
  [
15
- %(<table #{attributes} #{TABLE_RESET}#{style_attribute(node)}><tbody><tr><td>),
14
+ %(<table#{root_attributes(node, classes: 'menu')} #{TABLE_RESET}><tbody><tr><td>),
16
15
  %(<table #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>),
17
16
  '</td></tr></tbody></table>'
18
17
  ].join
@@ -10,11 +10,18 @@ module ActiveMail
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'menu-item')
13
+ attributes = root_attributes(node, classes: 'menu-item')
14
14
  # No href → a non-link item, not a broken <a href="">; mirrors <button>.
15
- content = node.attr('href') ? %(<a href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)}>#{inner}</a>) : inner
15
+ content = node.attr('href') ? anchor(node, inner) : inner
16
16
  th = ::ActiveMail::Core::INTERIM_TH_TAG
17
- %(<#{th} #{attributes}#{style_attribute(node)}>#{content}</#{th}>)
17
+ %(<#{th}#{attributes}>#{content}</#{th}>)
18
+ end
19
+
20
+ private
21
+
22
+ sig { override.returns(T::Array[String]) }
23
+ def consumed_attributes
24
+ LINK_ATTRIBUTES
18
25
  end
19
26
  end
20
27
  end
@@ -1,17 +1,16 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Row < Base
8
+ class Row < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'row')
14
- %(<table #{attributes} #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>#{inner}</tr></tbody></table>)
13
+ %(<table#{root_attributes(node, classes: 'row', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>)
15
14
  end
16
15
  end
17
16
  end
@@ -1,41 +1,45 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Spacer < Base
8
+ class Spacer < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
12
12
  def transform(node, _inner)
13
- classes = combine_classes(node, 'spacer')
14
13
  size_sm = node.attr('size-sm')
15
14
  size_lg = node.attr('size-lg')
16
15
 
17
- return build_table(node, classes, nil, size_for(node.attr('size'))) unless size_sm || size_lg
16
+ return build_table(node, 'spacer', size_for(node.attr('size')), skip: []) unless size_sm || size_lg
18
17
 
19
- html = +''
20
- html << build_table(node, classes, 'hide-for-large', size_for(size_sm)) if size_sm
21
- html << build_table(node, classes, 'show-for-large', size_for(size_lg)) if size_lg
22
- html
18
+ tables = []
19
+ tables << ['spacer hide-for-large', size_for(size_sm)] if size_sm
20
+ tables << ['spacer show-for-large', size_for(size_lg)] if size_lg
21
+ tables.each_with_index.map { |(classes, size), index| build_table(node, classes, size, skip: index.zero? ? [] : %w[id]) }.join
23
22
  end
24
23
 
25
24
  private
26
25
 
26
+ sig { override.returns(T::Array[String]) }
27
+ def consumed_attributes
28
+ [*super, 'size', 'size-sm', 'size-lg']
29
+ end
30
+
27
31
  sig { params(value: T.untyped).returns(Integer) }
28
32
  def size_for(value)
29
33
  positive_int(value) || 16
30
34
  end
31
35
 
32
- sig { params(node: Nokogiri::XML::Node, classes: String, extra: T.nilable(String), size: Integer).returns(String) }
33
- def build_table(node, classes, extra, size)
34
- css_class = extra ? "#{classes} #{extra}" : classes
36
+ # A repeated id would be invalid HTML: only the first table carries it.
37
+ sig { params(node: Nokogiri::XML::Node, classes: String, size: Integer, skip: T::Array[String]).returns(String) }
38
+ def build_table(node, classes, size, skip:)
35
39
  # mso-line-height-rule:exactly keeps Outlook from inflating the spacer.
36
40
  style = "font-size:#{size}px;line-height:#{size}px;mso-line-height-rule:exactly;"
37
41
  [
38
- %(<table class="#{css_class}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>),
42
+ %(<table#{root_attributes(node, classes: classes, layout_style: 'width:100%;', skip: skip)} #{TABLE_RESET}><tbody><tr>),
39
43
  %(<td height="#{size}" style="#{style}">&nbsp;</td>),
40
44
  '</tr></tbody></table>'
41
45
  ].join
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ # Root element is a layout table: its reset attributes are fixed, not author-overridable.
9
+ class TableComponent < Base
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ abstract!
14
+
15
+ private
16
+
17
+ sig { override.overridable.returns(T::Array[String]) }
18
+ def consumed_attributes
19
+ TABLE_RESET_ATTRIBUTES
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,21 +1,27 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Wrapper < Base
8
+ class Wrapper < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'wrapper')
14
13
  [
15
- %(<table #{attributes} #{TABLE_RESET} align="center"#{style_attribute(node, 'width:100%;')}><tbody><tr>),
14
+ %(<table#{root_attributes(node, classes: 'wrapper', layout_style: 'width:100%;')} #{TABLE_RESET} align="center"><tbody><tr>),
16
15
  %(<td class="wrapper-inner">#{inner}</td></tr></tbody></table>)
17
16
  ].join
18
17
  end
18
+
19
+ private
20
+
21
+ sig { override.returns(T::Array[String]) }
22
+ def consumed_attributes
23
+ [*super, 'align']
24
+ end
19
25
  end
20
26
  end
21
27
  end
@@ -4,10 +4,7 @@
4
4
  require 'sorbet-runtime'
5
5
 
6
6
  require_relative 'tokens'
7
- require_relative 'inliner/base'
8
- require_relative 'inliner/premailer'
9
- require_relative 'inliner/roadie'
10
- require_relative 'inliner/null'
7
+ require_relative 'inliner'
11
8
 
12
9
  module ActiveMail
13
10
  extend T::Sig
@@ -36,17 +33,17 @@ module ActiveMail
36
33
  block.call(configuration)
37
34
  end
38
35
 
39
- # Parse mode (and the Guard's same decision): a full document carries <html.
36
+ # <html[\s>] so a <htmlfoo> custom tag or "<html" in text never flips the parse mode.
40
37
  sig { params(html: String).returns(T::Boolean) }
41
38
  def self.full_document?(html)
42
- html.match?(/<html/i)
39
+ html.match?(/<html[\s>]/i)
43
40
  end
44
41
 
45
- # The complete SCSS variable bridge — tokens + the container_width layout knob.
46
42
  # Single source for the .scss.erb bridge (Sprockets) and tokens:export (Propshaft).
47
43
  sig { returns(String) }
48
44
  def self.scss_variables
49
- "#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n"
45
+ "#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n" \
46
+ "$am-column-count: #{configuration.column_count} !default;\n"
50
47
  end
51
48
 
52
49
  # Route warnings to the app logger when present (Sentry breadcrumbs, log
@@ -67,9 +64,8 @@ module ActiveMail
67
64
 
68
65
  # Invalid bytes degrade deterministically to U+FFFD, but that silently corrupts
69
66
  # content — surface it per on_parse_error before scrubbing.
70
- sig { params(html_string: String).returns(String) }
71
- def self.scrub_invalid_bytes(html_string)
72
- mode = configuration.on_parse_error
67
+ sig { params(html_string: String, mode: Symbol).returns(String) }
68
+ def self.scrub_invalid_bytes(html_string, mode)
73
69
  message = '[activemail] input had invalid byte sequences; scrubbed to U+FFFD'
74
70
  raise ParseError, message if mode == :raise
75
71
 
@@ -77,20 +73,25 @@ module ActiveMail
77
73
  html_string.scrub
78
74
  end
79
75
 
80
- # Zero or negative dimensions blow up at transform time (Infinity ghost width).
81
- # Reject non-integers rather than silently coercing ("abc"→0, 12.9→12).
76
+ # Rejects non-integers rather than silently coercing ("abc"→0, 12.9→12); zero or
77
+ # negative dimensions blow up at transform time (Infinity ghost width).
82
78
  sig { params(name: Symbol, value: T.untyped).returns(Integer) }
83
- def self.assert_positive_dimension!(name, value)
84
- raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}" unless value.is_a?(Integer) && value.positive?
79
+ def self.positive_integer!(name, value)
80
+ raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
81
+ raise ArgumentError, "#{name} must be positive, got #{value}" unless value.positive?
85
82
 
86
83
  value
87
84
  end
88
85
 
89
- sig { params(name: Symbol, value: T.untyped).returns(Integer) }
90
- def self.positive_integer!(name, value)
91
- raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
86
+ # Author attributes are untrusted strings: base 10 only (Integer() reads "010" as
87
+ # octal and "0x20" as hex), nil when not a positive integer.
88
+ sig { params(value: T.untyped).returns(T.nilable(Integer)) }
89
+ def self.parse_positive_integer(value)
90
+ string = value.to_s.strip
91
+ return unless string.match?(/\A\d+\z/)
92
92
 
93
- assert_positive_dimension!(name, value)
93
+ int = string.to_i
94
+ int if int.positive?
94
95
  end
95
96
 
96
97
  class Configuration
@@ -98,27 +99,9 @@ module ActiveMail
98
99
 
99
100
  ON_PARSE_ERROR_MODES = T.let(%i[ignore warn raise].freeze, T::Array[Symbol])
100
101
 
101
- INLINERS = T.let(
102
- { premailer: ActiveMail::Inliner::Premailer, roadie: ActiveMail::Inliner::Roadie, null: ActiveMail::Inliner::Null }.freeze,
103
- T::Hash[Symbol, T.class_of(ActiveMail::Inliner::Base)]
104
- )
105
-
106
- InlinerSetting = T.type_alias { T.any(Symbol, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base)) }
107
-
108
- sig { returns(InlinerSetting) }
102
+ sig { returns(ActiveMail::Inliner::Setting) }
109
103
  attr_reader :inliner
110
104
 
111
- # Lets a host already running another inliner (e.g. premailer-rails) opt out.
112
- sig { returns(T::Boolean) }
113
- attr_reader :register_inline_interceptor
114
-
115
- sig { params(value: T.untyped).void }
116
- def register_inline_interceptor=(value)
117
- raise TypeError, 'register_inline_interceptor must be true or false' unless [true, false].include?(value)
118
-
119
- @register_inline_interceptor = value
120
- end
121
-
122
105
  sig { returns(Symbol) }
123
106
  attr_reader :template_engine, :on_parse_error
124
107
 
@@ -146,26 +129,27 @@ module ActiveMail
146
129
  @container_width = T.let(600, Integer)
147
130
  @components = T.let({}, ActiveMail::ComponentMap)
148
131
  @on_parse_error = T.let(:warn, Symbol)
149
- @tokens = T.let(nil, T.nilable(ActiveMail::Tokens))
150
- @inliner = T.let(:premailer, InlinerSetting)
151
- @resolved_inliner = T.let(nil, T.nilable(ActiveMail::Inliner::Base))
152
- @register_inline_interceptor = T.let(true, T::Boolean)
132
+ @inliner = T.let(:premailer, ActiveMail::Inliner::Setting)
153
133
  @blank_link_rel = T.let('noopener', T.nilable(String))
154
134
  end
155
135
 
136
+ # Shared: building the component map per render is pure overhead.
137
+ sig { returns(ActiveMail::Core) }
138
+ def core
139
+ @core ||= T.let(ActiveMail::Core.new(config: self), T.nilable(ActiveMail::Core))
140
+ end
141
+
156
142
  # Validates eagerly (like sibling setters): a typo fails at boot, not silently mid-delivery.
157
- sig { params(value: T.any(Symbol, String, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base))).returns(InlinerSetting) }
143
+ sig { params(value: T.any(String, ActiveMail::Inliner::Setting)).returns(ActiveMail::Inliner::Setting) }
158
144
  def inliner=(value)
159
- resolved = value.is_a?(String) ? value.to_sym : value
160
- resolve_inliner(resolved) # raises on an invalid value, eagerly
161
- @resolved_inliner = nil # a new setting invalidates the memoized instance
162
- @inliner = resolved
145
+ setting = value.is_a?(String) ? value.to_sym : value
146
+ @resolved_inliner = T.let(ActiveMail::Inliner.resolve(setting), T.nilable(ActiveMail::Inliner::Base))
147
+ @inliner = setting
163
148
  end
164
149
 
165
- # Memoized so the lifecycle is consistent (one instance reused, not re-built per call).
166
150
  sig { returns(ActiveMail::Inliner::Base) }
167
151
  def resolved_inliner
168
- @resolved_inliner ||= resolve_inliner(@inliner)
152
+ @resolved_inliner ||= T.let(ActiveMail::Inliner.resolve(@inliner), T.nilable(ActiveMail::Inliner::Base))
169
153
  end
170
154
 
171
155
  sig { params(value: T.untyped).returns(Symbol) }
@@ -186,11 +170,15 @@ module ActiveMail
186
170
  sig { params(value: T.untyped).returns(Integer) }
187
171
  def column_count=(value)
188
172
  @column_count = ActiveMail.positive_integer!(:column_count, value)
173
+ invalidate_core!
174
+ @column_count
189
175
  end
190
176
 
191
177
  sig { params(value: T.untyped).returns(Integer) }
192
178
  def container_width=(value)
193
179
  @container_width = ActiveMail.positive_integer!(:container_width, value)
180
+ invalidate_core!
181
+ @container_width
194
182
  end
195
183
 
196
184
  sig { params(value: T.untyped).returns(T.nilable(String)) }
@@ -209,6 +197,8 @@ module ActiveMail
209
197
  normalized = value.to_hash.transform_keys(&:to_s)
210
198
  normalized.each { |tag, klass| ActiveMail::Components.validate_component!(tag, klass) }
211
199
  @components = normalized
200
+ invalidate_core!
201
+ normalized
212
202
  end
213
203
 
214
204
  sig { params(tag: T.any(String, Symbol), component_class: T.class_of(ActiveMail::Components::Base)).void }
@@ -216,18 +206,14 @@ module ActiveMail
216
206
  ActiveMail::Components.validate_component!(tag, component_class)
217
207
 
218
208
  @components = @components.merge(tag.to_s => component_class)
209
+ invalidate_core!
219
210
  end
220
211
 
221
212
  private
222
213
 
223
- # Single source for both eager validation (in the setter) and resolution.
224
- sig { params(value: InlinerSetting).returns(ActiveMail::Inliner::Base) }
225
- def resolve_inliner(value)
226
- return value if value.is_a?(ActiveMail::Inliner::Base)
227
- return value.new if value.is_a?(Class) && value < ActiveMail::Inliner::Base
228
- return T.must(INLINERS[value]).new if value.is_a?(Symbol) && INLINERS.key?(value)
229
-
230
- raise ArgumentError, "unknown inliner #{value.inspect}, expected one of #{INLINERS.keys.inspect}, an Inliner::Base subclass, or an instance"
214
+ sig { void }
215
+ def invalidate_core!
216
+ @core = nil
231
217
  end
232
218
  end
233
219
  end
@@ -0,0 +1,15 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveMail
5
+ class Error < StandardError; end
6
+
7
+ class ParseError < Error; end
8
+
9
+ class StylesheetNotFound < Error; end
10
+
11
+ module Inliner
12
+ # The adapter's original exception is preserved as Exception#cause.
13
+ class Error < ActiveMail::Error; end
14
+ end
15
+ end
@@ -3,12 +3,10 @@
3
3
 
4
4
  require 'sorbet-runtime'
5
5
 
6
+ require_relative '../errors'
7
+
6
8
  module ActiveMail
7
9
  module Inliner
8
- # Raised when an adapter fails; the original (premailer/roadie/nokogiri)
9
- # exception is preserved as Exception#cause with its backtrace intact.
10
- class Error < StandardError; end
11
-
12
10
  # DIP seam: ActiveMail depends on this abstraction, not premailer/roadie.
13
11
  class Base
14
12
  extend T::Sig
@@ -18,12 +16,6 @@ module ActiveMail
18
16
 
19
17
  sig { abstract.params(html: String).returns(String) }
20
18
  def inline(html); end
21
-
22
- # Lets the interceptor skip work without type-checking concrete adapters.
23
- sig { returns(T::Boolean) }
24
- def noop?
25
- false
26
- end
27
19
  end
28
20
  end
29
21
  end
@@ -13,11 +13,6 @@ module ActiveMail
13
13
  def inline(html)
14
14
  html
15
15
  end
16
-
17
- sig { override.returns(T::Boolean) }
18
- def noop?
19
- true
20
- end
21
16
  end
22
17
  end
23
18
  end