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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -2
- data/README.md +105 -47
- data/UPGRADING.md +112 -0
- data/app/assets/stylesheets/activemail/_components.scss +33 -42
- data/app/assets/stylesheets/activemail/_dark.scss +31 -20
- data/app/assets/stylesheets/activemail/_grid.scss +2 -0
- data/app/assets/stylesheets/activemail/_settings.scss +22 -10
- data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
- data/app/assets/stylesheets/activemail/activemail.scss +6 -5
- data/app/helpers/active_mail/styles_helper.rb +14 -15
- data/app/views/layouts/activemail/mailer.html.inky-erb +0 -5
- data/lib/activemail/asset_pipeline/base.rb +47 -0
- data/lib/activemail/asset_pipeline/null.rb +19 -0
- data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
- data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
- data/lib/activemail/asset_pipeline.rb +35 -0
- data/lib/activemail/components/base.rb +35 -39
- data/lib/activemail/components/block_grid.rb +11 -4
- data/lib/activemail/components/button.rb +5 -28
- data/lib/activemail/components/button_renderer.rb +65 -0
- data/lib/activemail/components/callout.rb +4 -5
- data/lib/activemail/components/center.rb +5 -7
- data/lib/activemail/components/columns.rb +26 -7
- data/lib/activemail/components/container.rb +11 -4
- data/lib/activemail/components/cta.rb +9 -33
- data/lib/activemail/components/h_line.rb +3 -5
- data/lib/activemail/components/info_box.rb +6 -12
- data/lib/activemail/components/menu.rb +3 -4
- data/lib/activemail/components/menu_item.rb +10 -3
- data/lib/activemail/components/row.rb +3 -4
- data/lib/activemail/components/spacer.rb +16 -12
- data/lib/activemail/components/table_component.rb +23 -0
- data/lib/activemail/components/wrapper.rb +10 -4
- data/lib/activemail/configuration.rb +43 -57
- data/lib/activemail/errors.rb +15 -0
- data/lib/activemail/inliner/base.rb +2 -10
- data/lib/activemail/inliner/null.rb +0 -5
- data/lib/activemail/inliner/roadie.rb +1 -1
- data/lib/activemail/inliner.rb +33 -0
- data/lib/activemail/parse_error_reporter.rb +5 -4
- data/lib/activemail/pipeline.rb +26 -0
- data/lib/activemail/quality/builtin_rules.rb +154 -0
- data/lib/activemail/quality/configuration.rb +83 -3
- data/lib/activemail/quality/guard.rb +45 -87
- data/lib/activemail/quality/minitest.rb +41 -25
- data/lib/activemail/quality/output_dir.rb +52 -0
- data/lib/activemail/quality/preview_renderer.rb +46 -33
- data/lib/activemail/quality/render_all.rb +96 -40
- data/lib/activemail/quality/rspec.rb +4 -8
- data/lib/activemail/quality/rule.rb +47 -0
- data/lib/activemail/rails/engine.rb +13 -13
- data/lib/activemail/rails/rendering.rb +51 -0
- data/lib/activemail/rails/template_handler.rb +3 -1
- data/lib/activemail/tokens/button_style.rb +1 -2
- data/lib/activemail/tokens/defaults.rb +77 -0
- data/lib/activemail/tokens/scss_serializer.rb +4 -4
- data/lib/activemail/tokens.rb +19 -58
- data/lib/activemail/version.rb +1 -1
- data/lib/activemail.rb +89 -61
- data/lib/generators/activemail/templates/component.rb.tt +2 -3
- data/lib/generators/activemail/templates/initializer.rb +0 -3
- data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +0 -2
- data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
- data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
- data/lib/tasks/activemail.rake +4 -5
- metadata +38 -7
- data/lib/activemail/components/inky.rb +0 -18
- data/lib/activemail/inliner/interceptor.rb +0 -51
- 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/
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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::
|
|
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")
|
|
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
|
|
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,
|
|
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
|
data/lib/activemail/tokens.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
|
data/lib/activemail/version.rb
CHANGED
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/
|
|
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
|
-
|
|
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(
|
|
64
|
-
attr_reader :
|
|
77
|
+
sig { returns(ActiveMail::Configuration) }
|
|
78
|
+
attr_reader :config
|
|
65
79
|
|
|
66
|
-
sig
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
@error_reporter.call(html.errors)
|
|
81
106
|
transform_doc(html)
|
|
82
|
-
|
|
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(' ', ' ')
|
|
87
|
-
re_inject_raws(string, raws)
|
|
107
|
+
re_inject_raws(postprocess(html.to_html), marker, raws)
|
|
88
108
|
end
|
|
89
109
|
|
|
90
|
-
|
|
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 {
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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" ? ' ' : '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
|
|
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::
|
|
7
|
+
class <%= class_name %> < ActiveMail::Components::TableComponent
|
|
8
8
|
def transform(node, inner)
|
|
9
|
-
|
|
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
|