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
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
// Dark mode: prefers-color-scheme (Apple Mail/iOS) + [data-ogsc] (Outlook
|
|
2
2
|
// Android). Colors derived from tokens — pure black/white crush contrast.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
$am-dark-surface: mix($am-color-text, $am-color-background, 92%) !default;
|
|
6
|
-
$am-dark-panel: mix($am-color-text, $am-color-background, 82%) !default;
|
|
7
|
-
$am-dark-text: mix($am-color-background, $am-color-text, 88%) !default;
|
|
8
|
-
$am-dark-muted: $am-color-muted !default;
|
|
9
|
-
$am-dark-link: $am-color-primary !default;
|
|
4
|
+
@use "settings" as *;
|
|
10
5
|
|
|
11
6
|
@mixin am-dark-rules {
|
|
12
7
|
.body,
|
|
@@ -39,25 +34,33 @@ $am-dark-link: $am-color-primary !default;
|
|
|
39
34
|
|
|
40
35
|
.button table td,
|
|
41
36
|
.button a,
|
|
37
|
+
.button span,
|
|
42
38
|
.cta table td,
|
|
43
|
-
.cta a
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
.cta a,
|
|
40
|
+
.cta span {
|
|
41
|
+
background: $am-button-primary-bg !important;
|
|
42
|
+
color: $am-button-primary-color !important;
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
// Restore the secondary fill AND text — the primary rule above
|
|
49
|
-
//
|
|
45
|
+
// Restore the secondary fill AND text — the primary rule above would be unreadable
|
|
46
|
+
// on a light secondary fill.
|
|
50
47
|
.button.secondary table td,
|
|
51
48
|
.button.secondary a,
|
|
49
|
+
.button.secondary span,
|
|
52
50
|
.cta.secondary table td,
|
|
53
|
-
.cta.secondary a
|
|
54
|
-
|
|
51
|
+
.cta.secondary a,
|
|
52
|
+
.cta.secondary span {
|
|
53
|
+
background: $am-button-secondary-bg !important;
|
|
55
54
|
color: $am-button-secondary-color !important;
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
// Panels keep their inline background unless overridden here, which would
|
|
59
58
|
// otherwise leave dark text on a dark background.
|
|
60
|
-
.callout-inner
|
|
59
|
+
.callout-inner {
|
|
60
|
+
background-color: $am-dark-panel !important;
|
|
61
|
+
color: $am-dark-text !important;
|
|
62
|
+
}
|
|
63
|
+
|
|
61
64
|
.info-box,
|
|
62
65
|
.info-box td {
|
|
63
66
|
background-color: $am-dark-panel !important;
|
|
@@ -102,21 +105,29 @@ $am-dark-link: $am-color-primary !default;
|
|
|
102
105
|
|
|
103
106
|
[data-ogsc] .button table td,
|
|
104
107
|
[data-ogsc] .button a,
|
|
108
|
+
[data-ogsc] .button span,
|
|
105
109
|
[data-ogsc] .cta table td,
|
|
106
|
-
[data-ogsc] .cta a
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
[data-ogsc] .cta a,
|
|
111
|
+
[data-ogsc] .cta span {
|
|
112
|
+
background: $am-button-primary-bg !important;
|
|
113
|
+
color: $am-button-primary-color !important;
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
[data-ogsc] .button.secondary table td,
|
|
112
117
|
[data-ogsc] .button.secondary a,
|
|
118
|
+
[data-ogsc] .button.secondary span,
|
|
113
119
|
[data-ogsc] .cta.secondary table td,
|
|
114
|
-
[data-ogsc] .cta.secondary a
|
|
115
|
-
|
|
120
|
+
[data-ogsc] .cta.secondary a,
|
|
121
|
+
[data-ogsc] .cta.secondary span {
|
|
122
|
+
background: $am-button-secondary-bg !important;
|
|
116
123
|
color: $am-button-secondary-color !important;
|
|
117
124
|
}
|
|
118
125
|
|
|
119
|
-
[data-ogsc] .callout-inner
|
|
126
|
+
[data-ogsc] .callout-inner {
|
|
127
|
+
background-color: $am-dark-panel !important;
|
|
128
|
+
color: $am-dark-text !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
[data-ogsc] .info-box,
|
|
121
132
|
[data-ogsc] .info-box td {
|
|
122
133
|
background-color: $am-dark-panel !important;
|
|
@@ -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
|