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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -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 +17 -4
- 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
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// Sass only lets `with (...)` configure !default variables of forwarded modules:
|
|
2
|
+
// every configurable variable must live here or in the forwarded token bridge.
|
|
3
|
+
@forward "activemail_tokens";
|
|
4
|
+
@use "sass:color";
|
|
5
|
+
@use "activemail_tokens" as *;
|
|
3
6
|
|
|
4
7
|
// Typography
|
|
5
8
|
$am-font-family: $am-font-body !default;
|
|
@@ -11,19 +14,19 @@ $am-background: $am-color-background !default;
|
|
|
11
14
|
$am-muted: $am-color-muted !default;
|
|
12
15
|
$am-border: $am-color-border !default;
|
|
13
16
|
|
|
14
|
-
// Buttons
|
|
17
|
+
// Buttons — a null border means no border at all.
|
|
15
18
|
$am-button-primary-bg: $am-color-primary !default;
|
|
19
|
+
$am-button-primary-color: $am-color-primary-text !default;
|
|
20
|
+
$am-button-primary-border: $am-color-primary-border !default;
|
|
16
21
|
$am-button-secondary-bg: $am-color-secondary !default;
|
|
17
|
-
$am-button-color: $am-color-
|
|
18
|
-
|
|
19
|
-
$am-button-secondary-color: $am-color-button-text !default;
|
|
20
|
-
$am-button-secondary-border: transparent !default;
|
|
22
|
+
$am-button-secondary-color: $am-color-secondary-text !default;
|
|
23
|
+
$am-button-secondary-border: $am-color-secondary-border !default;
|
|
21
24
|
$am-button-radius: $am-radius-button !default;
|
|
22
25
|
|
|
23
26
|
// Info box
|
|
24
|
-
$am-info-box-
|
|
25
|
-
$am-info-box-border: $am-border !default;
|
|
26
|
-
$am-info-box-text: $am-text !default;
|
|
27
|
+
$am-info-box-background: $am-color-info-box-background !default;
|
|
28
|
+
$am-info-box-border: $am-color-info-box-border !default;
|
|
29
|
+
$am-info-box-text: $am-color-info-box-text !default;
|
|
27
30
|
$am-box-radius: $am-radius-box !default;
|
|
28
31
|
|
|
29
32
|
// Grid — the gem emits no gutters; we provide them via column padding. Width
|
|
@@ -31,3 +34,12 @@ $am-box-radius: $am-radius-box !default;
|
|
|
31
34
|
// aligned with the transpiled markup (ghost tables, column max-width).
|
|
32
35
|
$am-container-width: $am-grid-container-width !default;
|
|
33
36
|
$am-gutter: $am-spacing-sm !default;
|
|
37
|
+
$am-breakpoint: $am-container-width !default;
|
|
38
|
+
|
|
39
|
+
// Dark mode
|
|
40
|
+
$am-dark-bg: color.mix($am-color-text, $am-color-background, 96%) !default;
|
|
41
|
+
$am-dark-surface: color.mix($am-color-text, $am-color-background, 92%) !default;
|
|
42
|
+
$am-dark-panel: color.mix($am-color-text, $am-color-background, 82%) !default;
|
|
43
|
+
$am-dark-text: color.mix($am-color-background, $am-color-text, 88%) !default;
|
|
44
|
+
$am-dark-muted: $am-color-muted !default;
|
|
45
|
+
$am-dark-link: $am-button-primary-bg !default;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@use "settings" as *;
|
|
2
|
+
|
|
1
3
|
// Alignment and visibility utilities.
|
|
2
4
|
|
|
3
5
|
.text-center {
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
overflow: hidden;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
@media only screen and (min-width:
|
|
37
|
+
@media only screen and (min-width: $am-breakpoint) {
|
|
36
38
|
.show-for-large {
|
|
37
39
|
display: block !important;
|
|
38
40
|
font-size: inherit;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// ActiveMail email framework entry point. Critical styles are inlined by the
|
|
2
2
|
// configured CSS inliner (Gmail mobile strips <style>); only media-query and
|
|
3
3
|
// dark-mode enhancements stay in the <head>.
|
|
4
|
-
@
|
|
5
|
-
@
|
|
6
|
-
@
|
|
7
|
-
@
|
|
8
|
-
@
|
|
4
|
+
@forward "settings";
|
|
5
|
+
@use "settings" as *;
|
|
6
|
+
@use "grid";
|
|
7
|
+
@use "components";
|
|
8
|
+
@use "utilities";
|
|
9
|
+
@use "dark";
|
|
9
10
|
|
|
10
11
|
body,
|
|
11
12
|
table,
|
|
@@ -1,29 +1,28 @@
|
|
|
1
1
|
# typed: false
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require 'activemail/asset_pipeline'
|
|
5
|
+
|
|
4
6
|
module ActiveMail
|
|
5
|
-
# Embeds the compiled
|
|
6
|
-
#
|
|
7
|
+
# Embeds the compiled CSS as a <style> block: the inliners take an HTML string
|
|
8
|
+
# and cannot fetch the digest URL a stylesheet_link_tag would emit.
|
|
7
9
|
module StylesHelper
|
|
8
|
-
FRAMEWORK_STYLESHEET = 'activemail/activemail
|
|
9
|
-
|
|
10
|
-
# '' (not raise) when the asset can't be read — degrades to the link fallback,
|
|
11
|
-
# but warns, since the email then ships unstyled.
|
|
12
|
-
def activemail_inline_styles
|
|
13
|
-
css = activemail_compiled_css
|
|
14
|
-
if css.blank?
|
|
15
|
-
ActiveMail.log_warning('[activemail] framework stylesheet could not be read from the asset pipeline; ' \
|
|
16
|
-
'email ships without inlined framework CSS')
|
|
17
|
-
return ''.html_safe
|
|
18
|
-
end
|
|
10
|
+
FRAMEWORK_STYLESHEET = 'activemail/activemail'
|
|
19
11
|
|
|
12
|
+
def activemail_inline_styles(*logical_paths)
|
|
13
|
+
paths = logical_paths.empty? ? [FRAMEWORK_STYLESHEET] : logical_paths
|
|
14
|
+
css = paths.map { |path| activemail_compiled_css(path.to_s) }.join("\n")
|
|
20
15
|
content_tag(:style, css.html_safe, type: 'text/css')
|
|
21
16
|
end
|
|
22
17
|
|
|
23
18
|
private
|
|
24
19
|
|
|
25
|
-
def activemail_compiled_css
|
|
26
|
-
|
|
20
|
+
def activemail_compiled_css(logical_path)
|
|
21
|
+
asset = logical_path.end_with?('.css') ? logical_path : "#{logical_path}.css"
|
|
22
|
+
css = ActiveMail::AssetPipeline.current.read(asset)
|
|
23
|
+
return css if css.present?
|
|
24
|
+
|
|
25
|
+
raise ActiveMail::StylesheetNotFound, "stylesheet #{asset.inspect} is missing or empty in the asset pipeline"
|
|
27
26
|
end
|
|
28
27
|
end
|
|
29
28
|
end
|
|
@@ -15,12 +15,7 @@
|
|
|
15
15
|
</xml>
|
|
16
16
|
</noscript>
|
|
17
17
|
<![endif]-->
|
|
18
|
-
<%# Embedded so the Premailer adapter (HTML-string only) can inline the framework CSS. %>
|
|
19
18
|
<%= activemail_inline_styles %>
|
|
20
|
-
<%# Fallback for when activemail_inline_styles cannot read the compiled asset %>
|
|
21
|
-
<%# (CDN-only host, compile=false without precompile); redundant otherwise, as the %>
|
|
22
|
-
<%# embed's rules are idempotent once inlined. %>
|
|
23
|
-
<%= stylesheet_link_tag "activemail/activemail", media: "all" %>
|
|
24
19
|
</head>
|
|
25
20
|
|
|
26
21
|
<body style="margin:0;padding:0;word-spacing:normal;">
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module AssetPipeline
|
|
8
|
+
class Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend T::Helpers
|
|
11
|
+
|
|
12
|
+
abstract!
|
|
13
|
+
|
|
14
|
+
sig { params(app: T.untyped).void }
|
|
15
|
+
def initialize(app)
|
|
16
|
+
@app = T.let(app, T.untyped)
|
|
17
|
+
@cache = T.let({}, T::Hash[String, String])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(logical_path: String).returns(T.nilable(String)) }
|
|
21
|
+
def read(logical_path)
|
|
22
|
+
return fetch(logical_path) unless cache?
|
|
23
|
+
|
|
24
|
+
cached = @cache[logical_path]
|
|
25
|
+
return cached if cached
|
|
26
|
+
|
|
27
|
+
fetch(logical_path).tap { |css| @cache[logical_path] = css if css }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { params(_paths: T::Array[String]).void }
|
|
31
|
+
def register_precompile(_paths); end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
sig { abstract.params(logical_path: String).returns(T.nilable(String)) }
|
|
36
|
+
def fetch(logical_path); end
|
|
37
|
+
|
|
38
|
+
sig { returns(T.untyped) }
|
|
39
|
+
attr_reader :app
|
|
40
|
+
|
|
41
|
+
sig { returns(T::Boolean) }
|
|
42
|
+
def cache?
|
|
43
|
+
!app.config.enable_reloading
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module AssetPipeline
|
|
8
|
+
class Null < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
sig { override.params(_logical_path: String).returns(T.nilable(String)) }
|
|
14
|
+
def fetch(_logical_path)
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module AssetPipeline
|
|
8
|
+
class Propshaft < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
sig { override.params(logical_path: String).returns(T.nilable(String)) }
|
|
14
|
+
def fetch(logical_path)
|
|
15
|
+
app.assets.load_path.find(logical_path)&.content&.force_encoding(Encoding::UTF_8)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module AssetPipeline
|
|
8
|
+
class Sprockets < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { override.params(paths: T::Array[String]).void }
|
|
12
|
+
def register_precompile(paths)
|
|
13
|
+
app.config.assets.precompile += paths
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# app.assets is the live environment (compile = true); with precompiled
|
|
19
|
+
# assets it is nil and only the manifest can resolve the logical path.
|
|
20
|
+
sig { override.params(logical_path: String).returns(T.nilable(String)) }
|
|
21
|
+
def fetch(logical_path)
|
|
22
|
+
environment = app.assets
|
|
23
|
+
return environment.find_asset(logical_path)&.to_s if environment
|
|
24
|
+
|
|
25
|
+
manifest_source(logical_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(logical_path: String).returns(T.nilable(String)) }
|
|
29
|
+
def manifest_source(logical_path)
|
|
30
|
+
manifest = app.assets_manifest
|
|
31
|
+
compiled = manifest&.assets&.[](logical_path)
|
|
32
|
+
return unless compiled
|
|
33
|
+
|
|
34
|
+
path = File.join(manifest.directory, compiled)
|
|
35
|
+
File.read(path) if File.file?(path)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'asset_pipeline/base'
|
|
7
|
+
require_relative 'asset_pipeline/sprockets'
|
|
8
|
+
require_relative 'asset_pipeline/propshaft'
|
|
9
|
+
require_relative 'asset_pipeline/null'
|
|
10
|
+
|
|
11
|
+
module ActiveMail
|
|
12
|
+
module AssetPipeline
|
|
13
|
+
class << self
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
# Never respond_to?: Sprockets' config.assets is an OrderedOptions that
|
|
17
|
+
# answers it for any key.
|
|
18
|
+
sig { params(app: T.untyped).returns(ActiveMail::AssetPipeline::Base) }
|
|
19
|
+
def for(app)
|
|
20
|
+
if defined?(::Sprockets::Railtie)
|
|
21
|
+
Sprockets.new(app)
|
|
22
|
+
elsif defined?(::Propshaft)
|
|
23
|
+
Propshaft.new(app)
|
|
24
|
+
else
|
|
25
|
+
Null.new(app)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns(ActiveMail::AssetPipeline::Base) }
|
|
30
|
+
def current
|
|
31
|
+
@current ||= T.let(self.for(::Rails.application), T.nilable(ActiveMail::AssetPipeline::Base))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -21,22 +21,20 @@ module ActiveMail
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
#
|
|
24
|
+
# `inner` is an opaque slot: interpolate it exactly once; the engine moves the
|
|
25
|
+
# already transformed children there.
|
|
25
26
|
class Base
|
|
26
27
|
extend T::Sig
|
|
27
28
|
extend T::Helpers
|
|
28
29
|
|
|
29
30
|
abstract!
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
T::Array[String]
|
|
34
|
-
)
|
|
32
|
+
HANDLED_ATTRIBUTES = T.let(%w[class style].freeze, T::Array[String])
|
|
33
|
+
LINK_ATTRIBUTES = T.let(%w[href target rel].freeze, T::Array[String])
|
|
35
34
|
|
|
36
35
|
# Layout tables: presentation role (a11y) and zeroed legacy spacing.
|
|
37
36
|
TABLE_RESET = 'role="presentation" border="0" cellpadding="0" cellspacing="0"'
|
|
38
|
-
|
|
39
|
-
BUTTON_PADDING = 'padding:12px 24px;'
|
|
37
|
+
TABLE_RESET_ATTRIBUTES = T.let(%w[role border cellpadding cellspacing].freeze, T::Array[String])
|
|
40
38
|
|
|
41
39
|
sig { params(core: ::ActiveMail::Core).void }
|
|
42
40
|
def initialize(core)
|
|
@@ -57,19 +55,33 @@ module ActiveMail
|
|
|
57
55
|
CGI.escapeHTML(value.to_s)
|
|
58
56
|
end
|
|
59
57
|
|
|
60
|
-
# Author attributes are untrusted: "abc".to_i would silently become 0.
|
|
61
58
|
sig { params(value: T.untyped).returns(T.nilable(Integer)) }
|
|
62
59
|
def positive_int(value)
|
|
63
|
-
|
|
64
|
-
int = T.let(Integer(value.to_s, exception: false), T.nilable(Integer))
|
|
65
|
-
int if int&.positive?
|
|
60
|
+
ActiveMail.parse_positive_integer(value)
|
|
66
61
|
end
|
|
67
62
|
|
|
68
|
-
sig {
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
sig { overridable.returns(T::Array[String]) }
|
|
64
|
+
def consumed_attributes
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sig do
|
|
69
|
+
params(node: Nokogiri::XML::Node, classes: T.nilable(String), layout_style: String, skip: T::Array[String]).returns(String)
|
|
70
|
+
end
|
|
71
|
+
def root_attributes(node, classes:, layout_style: '', skip: [])
|
|
72
|
+
"#{class_attribute(node, classes)}#{passthrough_attributes(node, skip)}#{style_attribute(node, layout_style)}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
sig { params(node: Nokogiri::XML::Node, classes: T.nilable(String)).returns(String) }
|
|
76
|
+
def class_attribute(node, classes)
|
|
77
|
+
merged = combine_classes(node, classes)
|
|
78
|
+
merged.empty? ? '' : %( class="#{escape_attr(merged)}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
sig { params(node: Nokogiri::XML::Node, skip: T::Array[String]).returns(String) }
|
|
82
|
+
def passthrough_attributes(node, skip = [])
|
|
83
|
+
reserved = HANDLED_ATTRIBUTES + consumed_attributes + skip
|
|
84
|
+
node.attributes.except(*reserved).map { |name, attr| %( #{name}="#{escape_attr(attr.value)}") }.join
|
|
73
85
|
end
|
|
74
86
|
|
|
75
87
|
# Author style merged after layout (a duplicated style attribute would be
|
|
@@ -84,7 +96,7 @@ module ActiveMail
|
|
|
84
96
|
|
|
85
97
|
sig { params(node: Nokogiri::XML::Node, klass: String).returns(T::Boolean) }
|
|
86
98
|
def class?(node, klass)
|
|
87
|
-
|
|
99
|
+
node['class'].to_s.split.include?(klass)
|
|
88
100
|
end
|
|
89
101
|
|
|
90
102
|
sig { params(node: Nokogiri::XML::Node, extra_classes: T.nilable(String)).returns(String) }
|
|
@@ -94,14 +106,6 @@ module ActiveMail
|
|
|
94
106
|
(existing + to_add).uniq.join(' ')
|
|
95
107
|
end
|
|
96
108
|
|
|
97
|
-
sig { params(node: Nokogiri::XML::Node, extra_classes: T.nilable(String)).returns(String) }
|
|
98
|
-
def combine_attributes(node, extra_classes = nil)
|
|
99
|
-
classes = combine_classes(node, extra_classes)
|
|
100
|
-
# Escape like the other attribute paths: a literal " in the class would
|
|
101
|
-
# otherwise reopen the attribute.
|
|
102
|
-
[pass_through_attributes(node), %(class="#{escape_attr(classes)}")].join
|
|
103
|
-
end
|
|
104
|
-
|
|
105
109
|
sig { params(node: Nokogiri::XML::Node).returns(String) }
|
|
106
110
|
def link_attributes(node)
|
|
107
111
|
target = node.attributes['target']&.value
|
|
@@ -112,24 +116,16 @@ module ActiveMail
|
|
|
112
116
|
].join
|
|
113
117
|
end
|
|
114
118
|
|
|
119
|
+
sig { params(node: Nokogiri::XML::Node, inner: String, before: String, after: String).returns(String) }
|
|
120
|
+
def anchor(node, inner, before: '', after: '')
|
|
121
|
+
%(<a#{before} href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)}#{after}>#{inner}</a>)
|
|
122
|
+
end
|
|
123
|
+
|
|
115
124
|
sig { params(node: Nokogiri::XML::Node, target: T.nilable(String)).returns(T.nilable(String)) }
|
|
116
125
|
def resolve_rel(node, target)
|
|
117
126
|
rel = node.attributes['rel']&.value
|
|
118
127
|
rel = nil if rel&.strip&.empty?
|
|
119
|
-
rel || (
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# Outlook-safe nested-table structure kept in one place for <button> and <cta>.
|
|
123
|
-
sig do
|
|
124
|
-
params(outer_classes: String, inner: String, cell_style: String, outer_extra: String).returns(String)
|
|
125
|
-
end
|
|
126
|
-
def bulletproof_button_table(outer_classes:, inner:, cell_style: '', outer_extra: '')
|
|
127
|
-
cell = cell_style.empty? ? '<td>' : %(<td style="#{cell_style}">)
|
|
128
|
-
[
|
|
129
|
-
%(<table class="#{outer_classes}" #{TABLE_RESET}><tbody><tr><td>),
|
|
130
|
-
%(<table #{TABLE_RESET}><tbody><tr>#{cell}#{inner}</td></tr></tbody></table>),
|
|
131
|
-
%(</td>#{outer_extra}</tr></tbody></table>)
|
|
132
|
-
].join
|
|
128
|
+
rel || (core.config.blank_link_rel if target == '_blank')
|
|
133
129
|
end
|
|
134
130
|
|
|
135
131
|
sig { returns(Integer) }
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative 'table_component'
|
|
5
5
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Components
|
|
8
|
-
class BlockGrid <
|
|
8
|
+
class BlockGrid < 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
13
|
up = positive_int(node.attr('up'))
|
|
14
|
-
classes =
|
|
15
|
-
%(<table
|
|
14
|
+
classes = ['block-grid', up && "up-#{up}"].compact.join(' ')
|
|
15
|
+
%(<table#{root_attributes(node, classes: classes, layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
sig { override.returns(T::Array[String]) }
|
|
21
|
+
def consumed_attributes
|
|
22
|
+
[*super, 'up']
|
|
16
23
|
end
|
|
17
24
|
end
|
|
18
25
|
end
|
|
@@ -1,39 +1,16 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative 'button_renderer'
|
|
5
5
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Components
|
|
8
|
-
class Button <
|
|
8
|
+
class Button < ButtonRenderer
|
|
9
9
|
extend T::Sig
|
|
10
10
|
|
|
11
|
-
sig { override.
|
|
12
|
-
def
|
|
13
|
-
|
|
14
|
-
inner = anchor(node, inner, expand) if node.attr('href')
|
|
15
|
-
inner = "<center>#{inner}</center>" if expand
|
|
16
|
-
|
|
17
|
-
expander = expand ? '<td class="expander"></td>' : ''
|
|
18
|
-
# CSS-driven by design: colors come from .button rules, not inline tokens —
|
|
19
|
-
# a distinct robustness model from <cta>, which inlines its palette.
|
|
20
|
-
bulletproof_button_table(
|
|
21
|
-
outer_classes: combine_classes(node, 'button'),
|
|
22
|
-
inner: inner,
|
|
23
|
-
outer_extra: expander
|
|
24
|
-
)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
sig { params(node: Nokogiri::XML::Node, inner: String, expand: T::Boolean).returns(String) }
|
|
30
|
-
def anchor(node, inner, expand)
|
|
31
|
-
links = link_attributes(node)
|
|
32
|
-
extra = expand ? ' align="center" class="float-center"' : ''
|
|
33
|
-
# Padding on the <a> makes the whole button a clickable target.
|
|
34
|
-
link_style = "display:inline-block;text-decoration:none;#{BUTTON_PADDING}"
|
|
35
|
-
attrs = %(#{pass_through_attributes(node)}href="#{escape_attr(node.attr('href'))}"#{links}#{extra})
|
|
36
|
-
%(<a #{attrs}#{style_attribute(node, link_style)}>#{inner}</a>)
|
|
11
|
+
sig { override.returns(String) }
|
|
12
|
+
def root_class
|
|
13
|
+
'button'
|
|
37
14
|
end
|
|
38
15
|
end
|
|
39
16
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Components
|
|
8
|
+
# Bulletproof button shared by <button> and <cta>: styles are inlined from the
|
|
9
|
+
# tokens (read at transform time) so it survives clients that strip <style>.
|
|
10
|
+
class ButtonRenderer < Base
|
|
11
|
+
extend T::Sig
|
|
12
|
+
extend T::Helpers
|
|
13
|
+
|
|
14
|
+
abstract!
|
|
15
|
+
|
|
16
|
+
CONTROL_PADDING = 'padding:12px 24px;'
|
|
17
|
+
|
|
18
|
+
sig { abstract.returns(String) }
|
|
19
|
+
def root_class; end
|
|
20
|
+
|
|
21
|
+
sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
22
|
+
def transform(node, inner)
|
|
23
|
+
style = core.config.tokens.button_style(class?(node, 'secondary') ? :secondary : :primary)
|
|
24
|
+
expand = class?(node, 'expand')
|
|
25
|
+
width = expand ? ' style="width:100%;"' : ''
|
|
26
|
+
[
|
|
27
|
+
%(<table#{class_attribute(node, root_class)} #{TABLE_RESET}#{width}><tbody><tr><td>),
|
|
28
|
+
%(<table #{TABLE_RESET}#{width}><tbody><tr><td style="#{cell_style(style, expand)}">),
|
|
29
|
+
control(node, inner, style, expand),
|
|
30
|
+
'</td></tr></tbody></table></td></tr></tbody></table>'
|
|
31
|
+
].join
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
sig { override.returns(T::Array[String]) }
|
|
37
|
+
def consumed_attributes
|
|
38
|
+
LINK_ATTRIBUTES
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Padding on the control makes the whole button a clickable target.
|
|
42
|
+
sig { params(node: Nokogiri::XML::Node, inner: String, style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
|
|
43
|
+
def control(node, inner, style, expand)
|
|
44
|
+
css = control_style(style, expand)
|
|
45
|
+
extra = passthrough_attributes(node)
|
|
46
|
+
return %(<span#{extra}#{style_attribute(node, css)}>#{inner}</span>) unless node.attr('href')
|
|
47
|
+
|
|
48
|
+
anchor(node, inner, before: extra, after: style_attribute(node, css))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The outline lives on the cell only: repeating it on the control would double the line.
|
|
52
|
+
sig { params(style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
|
|
53
|
+
def cell_style(style, expand)
|
|
54
|
+
border = style.border ? "border:1px solid #{style.border};" : ''
|
|
55
|
+
"background:#{style.background};border-radius:#{style.radius};#{border}#{'text-align:center;' if expand}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
|
|
59
|
+
def control_style(style, expand)
|
|
60
|
+
"display:#{expand ? 'block' : 'inline-block'};text-decoration:none;#{CONTROL_PADDING}" \
|
|
61
|
+
"background:#{style.background};color:#{style.color};font-weight:bold;border-radius:#{style.radius};"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative 'table_component'
|
|
5
5
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Components
|
|
8
|
-
class Callout <
|
|
8
|
+
class Callout < 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
13
|
classes = combine_classes(node, 'callout-inner')
|
|
14
|
-
attributes = pass_through_attributes(node)
|
|
15
14
|
[
|
|
16
|
-
%(<table
|
|
17
|
-
%(<th class="#{classes}">#{inner}</th><th class="expander"></th>),
|
|
15
|
+
%(<table class="callout"#{passthrough_attributes(node)}#{style_attribute(node, 'width:100%;')} #{TABLE_RESET}><tbody><tr>),
|
|
16
|
+
%(<th class="#{escape_attr(classes)}">#{inner}</th><th class="expander"></th>),
|
|
18
17
|
'</tr></tbody></table>'
|
|
19
18
|
].join
|
|
20
19
|
end
|
|
@@ -8,16 +8,14 @@ module ActiveMail
|
|
|
8
8
|
class Center < Base
|
|
9
9
|
extend T::Sig
|
|
10
10
|
|
|
11
|
-
sig { override.params(node: Nokogiri::XML::Node,
|
|
12
|
-
def transform(node,
|
|
13
|
-
elements
|
|
14
|
-
elements.each do |child|
|
|
11
|
+
sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
12
|
+
def transform(node, inner)
|
|
13
|
+
node.elements.each do |child|
|
|
15
14
|
child['align'] = 'center'
|
|
16
15
|
child['class'] = combine_classes(child, 'float-center')
|
|
17
16
|
end
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
node.to_s
|
|
17
|
+
node.css('.menu-item').each { |item| item['class'] = combine_classes(item, 'float-center') }
|
|
18
|
+
%(<center#{root_attributes(node, classes: nil)}>#{inner}</center>)
|
|
21
19
|
end
|
|
22
20
|
end
|
|
23
21
|
end
|