activemail 1.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 +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +23 -0
- data/README.md +375 -0
- data/app/assets/stylesheets/active_mail/_active_mail_tokens.scss.erb +3 -0
- data/app/assets/stylesheets/active_mail/_components.scss +58 -0
- data/app/assets/stylesheets/active_mail/_dark.scss +123 -0
- data/app/assets/stylesheets/active_mail/_grid.scss +49 -0
- data/app/assets/stylesheets/active_mail/_settings.scss +25 -0
- data/app/assets/stylesheets/active_mail/_utilities.scss +47 -0
- data/app/assets/stylesheets/active_mail/active_mail.scss +44 -0
- data/app/helpers/active_mail/styles_helper.rb +29 -0
- data/app/views/layouts/active_mail/_footer.html.inky-erb +10 -0
- data/app/views/layouts/active_mail/_head.html.inky-erb +6 -0
- data/app/views/layouts/active_mail/mailer.html.inky-erb +35 -0
- data/lib/active_mail/components/base.rb +119 -0
- data/lib/active_mail/components/block_grid.rb +19 -0
- data/lib/active_mail/components/button.rb +39 -0
- data/lib/active_mail/components/callout.rb +23 -0
- data/lib/active_mail/components/center.rb +24 -0
- data/lib/active_mail/components/columns.rb +72 -0
- data/lib/active_mail/components/container.rb +25 -0
- data/lib/active_mail/components/cta.rb +36 -0
- data/lib/active_mail/components/h_line.rb +19 -0
- data/lib/active_mail/components/info_box.rb +32 -0
- data/lib/active_mail/components/inky.rb +18 -0
- data/lib/active_mail/components/menu.rb +22 -0
- data/lib/active_mail/components/menu_item.rb +21 -0
- data/lib/active_mail/components/row.rb +18 -0
- data/lib/active_mail/components/spacer.rb +45 -0
- data/lib/active_mail/components/wrapper.rb +21 -0
- data/lib/active_mail/configuration.rb +229 -0
- data/lib/active_mail/inliner/base.rb +29 -0
- data/lib/active_mail/inliner/interceptor.rb +51 -0
- data/lib/active_mail/inliner/null.rb +23 -0
- data/lib/active_mail/inliner/premailer.rb +22 -0
- data/lib/active_mail/inliner/roadie.rb +30 -0
- data/lib/active_mail/libxml.rb +8 -0
- data/lib/active_mail/parse_error_reporter.rb +47 -0
- data/lib/active_mail/quality/configuration.rb +56 -0
- data/lib/active_mail/quality/guard.rb +131 -0
- data/lib/active_mail/quality/minitest.rb +64 -0
- data/lib/active_mail/quality/preview_renderer.rb +70 -0
- data/lib/active_mail/quality/render_all.rb +90 -0
- data/lib/active_mail/quality/rspec.rb +65 -0
- data/lib/active_mail/quality.rb +38 -0
- data/lib/active_mail/rails/compiled_stylesheet.rb +62 -0
- data/lib/active_mail/rails/engine.rb +36 -0
- data/lib/active_mail/rails/template_handler.rb +62 -0
- data/lib/active_mail/tokens.rb +139 -0
- data/lib/active_mail/version.rb +6 -0
- data/lib/active_mail.rb +161 -0
- data/lib/activemail.rb +5 -0
- data/lib/generators/active_mail/component_generator.rb +31 -0
- data/lib/generators/active_mail/install_generator.rb +59 -0
- data/lib/generators/active_mail/styles_generator.rb +30 -0
- data/lib/generators/active_mail/templates/component.rb.tt +13 -0
- data/lib/generators/active_mail/templates/initializer.rb +32 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-erb +30 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-haml +17 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-slim +16 -0
- data/lib/generators/active_mail/views_generator.rb +16 -0
- data/lib/tasks/active_mail.rake +36 -0
- metadata +151 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
# Design-tokens registry: the single Ruby source of truth, bridged to SCSS by #to_scss.
|
|
8
|
+
class Tokens
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
TokenMap = T.type_alias { T::Hash[Symbol, String] }
|
|
12
|
+
|
|
13
|
+
DEFAULT_COLORS = T.let(
|
|
14
|
+
{
|
|
15
|
+
primary: '#2a9d8f',
|
|
16
|
+
secondary: '#264653',
|
|
17
|
+
text: '#1a1a1a',
|
|
18
|
+
background: '#ffffff',
|
|
19
|
+
muted: '#6b7280',
|
|
20
|
+
border: '#e5e7eb',
|
|
21
|
+
button_text: '#ffffff'
|
|
22
|
+
}.freeze,
|
|
23
|
+
TokenMap
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
|
|
27
|
+
|
|
28
|
+
DEFAULT_FONTS = T.let(
|
|
29
|
+
{
|
|
30
|
+
body: SYSTEM_FONT_STACK,
|
|
31
|
+
heading: SYSTEM_FONT_STACK
|
|
32
|
+
}.freeze,
|
|
33
|
+
TokenMap
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_SPACINGS = T.let(
|
|
37
|
+
{
|
|
38
|
+
xs: '4px',
|
|
39
|
+
sm: '8px',
|
|
40
|
+
md: '16px',
|
|
41
|
+
lg: '24px',
|
|
42
|
+
xl: '40px'
|
|
43
|
+
}.freeze,
|
|
44
|
+
TokenMap
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
sig { void }
|
|
48
|
+
def initialize
|
|
49
|
+
@colors = T.let(DEFAULT_COLORS.dup, TokenMap)
|
|
50
|
+
@fonts = T.let(DEFAULT_FONTS.dup, TokenMap)
|
|
51
|
+
@spacings = T.let(DEFAULT_SPACINGS.dup, TokenMap)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# value given → set, omitted → get. Open registry: any key is accepted (define
|
|
55
|
+
# custom tokens for custom components); color!/font!/spacing! fail loud on read.
|
|
56
|
+
sig { params(name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
|
|
57
|
+
def color(name, value = nil)
|
|
58
|
+
access(@colors, name, value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { params(name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
|
|
62
|
+
def font(name, value = nil)
|
|
63
|
+
access(@fonts, name, value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { params(name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
|
|
67
|
+
def spacing(name, value = nil)
|
|
68
|
+
access(@spacings, name, value)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Strict reads for code that must have the token (e.g. a component's inline
|
|
72
|
+
# color): raise rather than interpolating nil into the CSS.
|
|
73
|
+
sig { params(name: T.any(String, Symbol)).returns(String) }
|
|
74
|
+
def color!(name)
|
|
75
|
+
fetch!(@colors, :color, name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sig { params(name: T.any(String, Symbol)).returns(String) }
|
|
79
|
+
def font!(name)
|
|
80
|
+
fetch!(@fonts, :font, name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
sig { params(name: T.any(String, Symbol)).returns(String) }
|
|
84
|
+
def spacing!(name)
|
|
85
|
+
fetch!(@spacings, :spacing, name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Frozen dup: mutating the returned hash must not bypass the DSL setters.
|
|
89
|
+
sig { returns(TokenMap) }
|
|
90
|
+
def colors
|
|
91
|
+
@colors.dup.freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig { returns(TokenMap) }
|
|
95
|
+
def fonts
|
|
96
|
+
@fonts.dup.freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { returns(TokenMap) }
|
|
100
|
+
def spacings
|
|
101
|
+
@spacings.dup.freeze
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# SCSS bridge: !default lets power-users pre-declare overrides upstream.
|
|
105
|
+
# Values are emitted verbatim (trusted, app-controlled input) — not escaped.
|
|
106
|
+
sig { returns(String) }
|
|
107
|
+
def to_scss
|
|
108
|
+
lines = scss_lines('color', @colors) + scss_lines('font', @fonts) + scss_lines('spacing', @spacings)
|
|
109
|
+
"#{lines.join("\n")}\n"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
sig { params(store: TokenMap, kind: Symbol, name: T.any(String, Symbol)).returns(String) }
|
|
115
|
+
def fetch!(store, kind, name)
|
|
116
|
+
store.fetch(name.to_sym) { raise KeyError, "unknown #{kind} token #{name.inspect}" }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sig { params(store: TokenMap, name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
|
|
120
|
+
def access(store, name, value)
|
|
121
|
+
key = name.to_sym
|
|
122
|
+
return store[key] if value.nil?
|
|
123
|
+
# Emitted verbatim into SCSS by #to_scss — reject blanks that would yield broken CSS.
|
|
124
|
+
raise ArgumentError, "token #{key} value must not be blank" if value.strip.empty?
|
|
125
|
+
|
|
126
|
+
store[key] = value
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
sig { params(group: String, store: TokenMap).returns(T::Array[String]) }
|
|
130
|
+
def scss_lines(group, store)
|
|
131
|
+
store.map { |name, value| "$am-#{group}-#{scss_name(name)}: #{value} !default;" }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
sig { params(name: Symbol).returns(String) }
|
|
135
|
+
def scss_name(name)
|
|
136
|
+
name.to_s.tr('_', '-')
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/active_mail.rb
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
require_relative 'active_mail/version'
|
|
8
|
+
require_relative 'active_mail/components/base'
|
|
9
|
+
require_relative 'active_mail/components/button'
|
|
10
|
+
require_relative 'active_mail/components/row'
|
|
11
|
+
require_relative 'active_mail/components/columns'
|
|
12
|
+
require_relative 'active_mail/components/container'
|
|
13
|
+
require_relative 'active_mail/components/inky'
|
|
14
|
+
require_relative 'active_mail/components/block_grid'
|
|
15
|
+
require_relative 'active_mail/components/menu'
|
|
16
|
+
require_relative 'active_mail/components/menu_item'
|
|
17
|
+
require_relative 'active_mail/components/center'
|
|
18
|
+
require_relative 'active_mail/components/callout'
|
|
19
|
+
require_relative 'active_mail/components/spacer'
|
|
20
|
+
require_relative 'active_mail/components/h_line'
|
|
21
|
+
require_relative 'active_mail/components/wrapper'
|
|
22
|
+
require_relative 'active_mail/components/cta'
|
|
23
|
+
require_relative 'active_mail/components/info_box'
|
|
24
|
+
require_relative 'active_mail/configuration'
|
|
25
|
+
require_relative 'active_mail/inliner/interceptor'
|
|
26
|
+
require_relative 'active_mail/parse_error_reporter'
|
|
27
|
+
|
|
28
|
+
module ActiveMail
|
|
29
|
+
class ParseError < StandardError; end
|
|
30
|
+
|
|
31
|
+
class Core
|
|
32
|
+
extend T::Sig
|
|
33
|
+
|
|
34
|
+
# Nokogiri cannot parse a bare <th> outside a <tr>; components that emit
|
|
35
|
+
# <th> use this placeholder, swapped back at the end.
|
|
36
|
+
INTERIM_TH_TAG = 'active-mail-interim-th'
|
|
37
|
+
INTERIM_TH_TAG_REGEX = T.let(%r{(?<=<|</)#{Regexp.escape(INTERIM_TH_TAG)}}, Regexp)
|
|
38
|
+
|
|
39
|
+
DEFAULT_COMPONENTS = T.let(
|
|
40
|
+
{
|
|
41
|
+
'button' => ActiveMail::Components::Button,
|
|
42
|
+
'row' => ActiveMail::Components::Row,
|
|
43
|
+
'columns' => ActiveMail::Components::Columns,
|
|
44
|
+
'container' => ActiveMail::Components::Container,
|
|
45
|
+
'inky' => ActiveMail::Components::Inky,
|
|
46
|
+
'block-grid' => ActiveMail::Components::BlockGrid,
|
|
47
|
+
'menu' => ActiveMail::Components::Menu,
|
|
48
|
+
'item' => ActiveMail::Components::MenuItem,
|
|
49
|
+
'center' => ActiveMail::Components::Center,
|
|
50
|
+
'callout' => ActiveMail::Components::Callout,
|
|
51
|
+
'spacer' => ActiveMail::Components::Spacer,
|
|
52
|
+
'h-line' => ActiveMail::Components::HLine,
|
|
53
|
+
'wrapper' => ActiveMail::Components::Wrapper
|
|
54
|
+
}.freeze,
|
|
55
|
+
ActiveMail::ComponentMap
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
sig { returns(Integer) }
|
|
59
|
+
attr_reader :column_count, :container_width
|
|
60
|
+
|
|
61
|
+
sig { returns(T::Hash[String, ActiveMail::Components::Base]) }
|
|
62
|
+
attr_reader :component_instances
|
|
63
|
+
|
|
64
|
+
sig { params(options: T::Hash[Symbol, T.untyped]).void }
|
|
65
|
+
def initialize(options = {})
|
|
66
|
+
config = ::ActiveMail.configuration
|
|
67
|
+
@component_instances = T.let(build_components(config, options[:components]), T::Hash[String, ActiveMail::Components::Base])
|
|
68
|
+
@column_count = T.let(ActiveMail.assert_positive_dimension!(:column_count, options[:column_count] || config.column_count), Integer)
|
|
69
|
+
@container_width = T.let(ActiveMail.assert_positive_dimension!(:container_width, options[:container_width] || config.container_width), Integer)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Object, not String: ActionView::OutputBuffer is no longer a String since Rails 7.1.
|
|
73
|
+
sig { params(html_string: Object).returns(String) }
|
|
74
|
+
def release_the_kraken(html_string)
|
|
75
|
+
raws, str = extract_raws(normalize_input(html_string))
|
|
76
|
+
parse_cmd = ::ActiveMail.full_document?(str) ? :parse : :fragment
|
|
77
|
+
html = Nokogiri::HTML.public_send(parse_cmd, str)
|
|
78
|
+
ParseErrorReporter.new(component_instances.keys).call(html.errors)
|
|
79
|
+
transform_doc(html)
|
|
80
|
+
string = html.to_html
|
|
81
|
+
string = string.gsub(INTERIM_TH_TAG_REGEX, 'th')
|
|
82
|
+
# Needle is a literal U+00A0 (Nokogiri decodes the nbsp entity to one); re-encode
|
|
83
|
+
# it to the entity for email clients that mishandle raw NBSP bytes.
|
|
84
|
+
string = string.gsub(' ', ' ')
|
|
85
|
+
re_inject_raws(string, raws)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
sig { params(elem: Nokogiri::XML::Node).returns(Nokogiri::XML::Node) }
|
|
89
|
+
def transform_doc(elem)
|
|
90
|
+
if elem.respond_to?(:children)
|
|
91
|
+
elem.children.each { |child| transform_doc(child) }
|
|
92
|
+
markup = component_factory(elem)
|
|
93
|
+
elem.replace(markup) if markup
|
|
94
|
+
end
|
|
95
|
+
elem
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { params(node: Nokogiri::XML::Node).returns(T.nilable(String)) }
|
|
99
|
+
def component_factory(node)
|
|
100
|
+
component = component_instances[node.name]
|
|
101
|
+
return unless component
|
|
102
|
+
|
|
103
|
+
# Nokogiri::NodeSet has no #join; map to String first.
|
|
104
|
+
inner = node.children.map(&:to_s).join # rubocop:disable Style/MapJoin
|
|
105
|
+
component.transform(node, inner)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { params(string: String).returns([T::Array[String], String]) }
|
|
109
|
+
def extract_raws(string)
|
|
110
|
+
raws = []
|
|
111
|
+
i = 0
|
|
112
|
+
# Only the tags + content, across lines: surrounding whitespace is left in
|
|
113
|
+
# place (true pass-through; eating adjacent newlines corrupts <pre>/inline text).
|
|
114
|
+
regex = %r{< *raw *>([\s\S]*?)</ *raw *>}i
|
|
115
|
+
str = string
|
|
116
|
+
while (raw = str.match(regex))
|
|
117
|
+
raws[i] = T.must(raw[1])
|
|
118
|
+
str = str.sub(regex, "###RAW#{i}###")
|
|
119
|
+
i += 1
|
|
120
|
+
end
|
|
121
|
+
[raws, str]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
sig { params(string: String, raws: T::Array[String]).returns(String) }
|
|
125
|
+
def re_inject_raws(string, raws)
|
|
126
|
+
str = string
|
|
127
|
+
raws.each_with_index do |val, i|
|
|
128
|
+
# Block form: the 2-arg String#sub would expand \0/\1/\& in val.
|
|
129
|
+
str = str.sub("###RAW#{i}###") { val }
|
|
130
|
+
end
|
|
131
|
+
str = str.html_safe if str.respond_to?(:html_safe)
|
|
132
|
+
str
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# Internal transpilation details, not public surface (column_count/container_width stay public).
|
|
138
|
+
private :component_instances, :transform_doc, :component_factory, :extract_raws, :re_inject_raws
|
|
139
|
+
|
|
140
|
+
sig { params(config: ActiveMail::Configuration, overrides: T.untyped).returns(T::Hash[String, ActiveMail::Components::Base]) }
|
|
141
|
+
def build_components(config, overrides)
|
|
142
|
+
# Lookup is by node name (String); a Symbol key would never match.
|
|
143
|
+
overrides = (overrides || {}).transform_keys(&:to_s)
|
|
144
|
+
overrides.each { |tag, klass| ActiveMail::Components.validate_component!(tag, klass) }
|
|
145
|
+
DEFAULT_COMPONENTS.merge(config.components).merge(overrides).transform_values { |klass| klass.new(self) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
sig { params(html_string: Object).returns(String) }
|
|
149
|
+
def normalize_input(html_string)
|
|
150
|
+
html_string = html_string.to_s
|
|
151
|
+
html_string = html_string.dup.force_encoding(Encoding::UTF_8) if html_string.encoding == Encoding::BINARY
|
|
152
|
+
html_string = ::ActiveMail.scrub_invalid_bytes(html_string) unless html_string.valid_encoding?
|
|
153
|
+
html_string.gsub(/doctype/i, 'DOCTYPE')
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if defined?(Rails::Engine)
|
|
159
|
+
require 'active_mail/rails/engine'
|
|
160
|
+
require 'active_mail/rails/template_handler'
|
|
161
|
+
end
|
data/lib/activemail.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/named_base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Generators
|
|
8
|
+
class ComponentGenerator < ::Rails::Generators::NamedBase
|
|
9
|
+
desc 'Scaffold an ActiveMail component class (rails g active_mail:component Cta)'
|
|
10
|
+
source_root File.join(File.dirname(__FILE__), 'templates')
|
|
11
|
+
|
|
12
|
+
def create_component
|
|
13
|
+
template 'component.rb.tt', File.join('app', 'mailers', 'components', "#{file_name}.rb")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show_register_snippet
|
|
17
|
+
say "\nRegister the component in config/initializers/active_mail.rb:", :green
|
|
18
|
+
say %( config.register_component "#{tag_name}", Components::#{class_name})
|
|
19
|
+
say '(top-level Components:: — rename the module if it collides in your app)', :yellow
|
|
20
|
+
say "\nThen use <#{tag_name}>…</#{tag_name}> in your ActiveMail views.\n"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Tag = kebab-cased class name, matching the gem's component naming.
|
|
26
|
+
def tag_name
|
|
27
|
+
file_name.tr('_', '-')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module ActiveMail
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
8
|
+
desc 'Install ActiveMail: initializer and a mailer layout'
|
|
9
|
+
source_root File.join(File.dirname(__FILE__), 'templates')
|
|
10
|
+
argument :layout_name, type: :string, default: 'mailer', banner: 'layout_name'
|
|
11
|
+
|
|
12
|
+
class_option :haml, desc: 'Generate the layout in Haml', type: :boolean
|
|
13
|
+
class_option :slim, desc: 'Generate the layout in Slim', type: :boolean
|
|
14
|
+
|
|
15
|
+
def create_initializer
|
|
16
|
+
template 'initializer.rb', File.join('config', 'initializers', 'active_mail.rb')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A plain mailer.html.erb would win over the generated inky layout; keep it.
|
|
20
|
+
def preserve_original_mailer_layout
|
|
21
|
+
return unless layout_name == 'mailer' && extension == 'erb'
|
|
22
|
+
|
|
23
|
+
original = File.join(layouts_base_dir, 'mailer.html.erb')
|
|
24
|
+
back_up_layout(original) if File.exist?(File.join(destination_root, original))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_mailer_layout
|
|
28
|
+
template "mailer_layout.html.inky-#{extension}",
|
|
29
|
+
File.join(layouts_base_dir, "#{layout_name.underscore}.html.inky-#{extension}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show_readme
|
|
33
|
+
say "\nActiveMail installed.", :green
|
|
34
|
+
say ' • config/initializers/active_mail.rb — configure tokens, inliner, components.'
|
|
35
|
+
say " • app/views/layouts/#{layout_name.underscore}.html.inky-#{extension} — your mailer layout."
|
|
36
|
+
say "\nPoint your mailers at the layout, e.g. `layout \"#{layout_name.underscore}\"`, and"
|
|
37
|
+
say "name views *.html.inky-#{extension} to enable ActiveMail markup."
|
|
38
|
+
say "\nCustomize styling via Ruby tokens in the initializer (config.tokens.color/font/spacing),"
|
|
39
|
+
say 'or run `rails g active_mail:styles` to eject and edit the SCSS partials.'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def back_up_layout(original)
|
|
45
|
+
backup = File.join(layouts_base_dir, "old_mailer_#{Time.now.strftime('%Y%m%d%H%M%S%L')}.html.erb")
|
|
46
|
+
File.rename(File.join(destination_root, original), File.join(destination_root, backup))
|
|
47
|
+
say "Renamed existing #{original} → #{backup} (it would shadow the new ActiveMail layout).", :yellow
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def layouts_base_dir
|
|
51
|
+
File.join('app', 'views', 'layouts')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def extension
|
|
55
|
+
%w[haml slim].find { |ext| options.send(ext) } || 'erb'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module ActiveMail
|
|
6
|
+
module Generators
|
|
7
|
+
class StylesGenerator < ::Rails::Generators::Base
|
|
8
|
+
desc 'Copy ActiveMail framework SCSS partials into the host app for customization'
|
|
9
|
+
source_root File.expand_path('../../../app/assets/stylesheets/active_mail', __dir__)
|
|
10
|
+
|
|
11
|
+
TARGET_DIR = File.join('app', 'assets', 'stylesheets', 'active_mail')
|
|
12
|
+
|
|
13
|
+
# The .scss.erb token bridge is deliberately not ejected: it needs ERB
|
|
14
|
+
# preprocessing, and tokens come from Ruby config (rake active_mail:tokens:export).
|
|
15
|
+
def copy_styles
|
|
16
|
+
Dir.children(self.class.source_root).each do |name|
|
|
17
|
+
next if name.end_with?('.erb')
|
|
18
|
+
|
|
19
|
+
copy_file name, File.join(TARGET_DIR, name)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show_readme
|
|
24
|
+
say "\nEjected the ActiveMail SCSS partials to #{TARGET_DIR}.", :green
|
|
25
|
+
say 'Token values come from Ruby (config.tokens.color/font/spacing). For a static'
|
|
26
|
+
say 'SCSS partial of those values, run `rake active_mail:tokens:export`.'
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Components
|
|
5
|
+
# Register in config/initializers/active_mail.rb:
|
|
6
|
+
# config.register_component "<%= tag_name %>", Components::<%= class_name %>
|
|
7
|
+
class <%= class_name %> < ActiveMail::Components::Base
|
|
8
|
+
def transform(node, inner)
|
|
9
|
+
classes = combine_classes(node, "<%= tag_name %>")
|
|
10
|
+
%(<table class="#{classes}" #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ActiveMail configuration. Works zero-config; uncomment to customize.
|
|
4
|
+
ActiveMail.configure do |config|
|
|
5
|
+
# Template engine ActiveMail composes with for `.inky` views (`.inky-erb`,
|
|
6
|
+
# `.inky-haml`, … are always available regardless of this default).
|
|
7
|
+
# config.template_engine = :erb
|
|
8
|
+
|
|
9
|
+
# Layout geometry.
|
|
10
|
+
# config.column_count = 12
|
|
11
|
+
# config.container_width = 600
|
|
12
|
+
|
|
13
|
+
# CSS inliner: :premailer (default), :roadie, :null, or a custom
|
|
14
|
+
# ActiveMail::Inliner::Base subclass/instance.
|
|
15
|
+
# config.inliner = :premailer
|
|
16
|
+
|
|
17
|
+
# Set false if another inliner (e.g. premailer-rails) already runs on mailers.
|
|
18
|
+
# config.register_inline_interceptor = true
|
|
19
|
+
|
|
20
|
+
# How malformed markup is handled: :warn (default), :ignore, or :raise.
|
|
21
|
+
# config.on_parse_error = :warn
|
|
22
|
+
|
|
23
|
+
# Design tokens — the single source of truth, bridged to SCSS via $am-*.
|
|
24
|
+
# config.tokens.color :primary, "#2a9d8f"
|
|
25
|
+
# config.tokens.color :secondary, "#264653"
|
|
26
|
+
# config.tokens.font :heading, "Georgia, serif"
|
|
27
|
+
# config.tokens.spacing :lg, "32px"
|
|
28
|
+
|
|
29
|
+
# Register components (built-ins like ActiveMail::Components::Cta, or your own
|
|
30
|
+
# Components::* from `rails g active_mail:component`).
|
|
31
|
+
# config.register_component "cta", ActiveMail::Components::Cta
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="<%%= I18n.locale %>" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="x-apple-disable-message-reformatting">
|
|
7
|
+
<meta name="color-scheme" content="light dark">
|
|
8
|
+
<meta name="supported-color-schemes" content="light dark">
|
|
9
|
+
<!--[if mso]>
|
|
10
|
+
<noscript>
|
|
11
|
+
<xml>
|
|
12
|
+
<o:OfficeDocumentSettings>
|
|
13
|
+
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
14
|
+
</o:OfficeDocumentSettings>
|
|
15
|
+
</xml>
|
|
16
|
+
</noscript>
|
|
17
|
+
<![endif]-->
|
|
18
|
+
<%%= active_mail_inline_styles %>
|
|
19
|
+
<%%# Fallback for when active_mail_inline_styles cannot read the compiled asset. %>
|
|
20
|
+
<%%= stylesheet_link_tag "active_mail/active_mail", media: "all" %>
|
|
21
|
+
</head>
|
|
22
|
+
|
|
23
|
+
<body style="margin:0;padding:0;word-spacing:normal;">
|
|
24
|
+
<div role="article" aria-roledescription="email" lang="<%%= I18n.locale %>" style="width:100%;">
|
|
25
|
+
<container>
|
|
26
|
+
<%%= yield %>
|
|
27
|
+
</container>
|
|
28
|
+
</div>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
!!!
|
|
2
|
+
%html{lang: I18n.locale, xmlns: "http://www.w3.org/1999/xhtml", "xmlns:v" => "urn:schemas-microsoft-com:vml", "xmlns:o" => "urn:schemas-microsoft-com:office:office"}
|
|
3
|
+
%head
|
|
4
|
+
%meta{charset: "utf-8"}
|
|
5
|
+
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
|
|
6
|
+
%meta{name: "x-apple-disable-message-reformatting"}
|
|
7
|
+
%meta{name: "color-scheme", content: "light dark"}
|
|
8
|
+
%meta{name: "supported-color-schemes", content: "light dark"}
|
|
9
|
+
:plain
|
|
10
|
+
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
11
|
+
= active_mail_inline_styles
|
|
12
|
+
-# Fallback for when active_mail_inline_styles cannot read the compiled asset.
|
|
13
|
+
= stylesheet_link_tag "active_mail/active_mail", media: "all"
|
|
14
|
+
%body{style: "margin:0;padding:0;word-spacing:normal;"}
|
|
15
|
+
%div{role: "article", "aria-roledescription" => "email", lang: I18n.locale, style: "width:100%;"}
|
|
16
|
+
%container
|
|
17
|
+
= yield
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
doctype html
|
|
2
|
+
html lang=I18n.locale xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"
|
|
3
|
+
head
|
|
4
|
+
meta charset="utf-8"
|
|
5
|
+
meta name="viewport" content="width=device-width, initial-scale=1"
|
|
6
|
+
meta name="x-apple-disable-message-reformatting"
|
|
7
|
+
meta name="color-scheme" content="light dark"
|
|
8
|
+
meta name="supported-color-schemes" content="light dark"
|
|
9
|
+
| <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
10
|
+
= active_mail_inline_styles
|
|
11
|
+
/ Fallback for when active_mail_inline_styles cannot read the compiled asset.
|
|
12
|
+
= stylesheet_link_tag "active_mail/active_mail", media: "all"
|
|
13
|
+
body style="margin:0;padding:0;word-spacing:normal;"
|
|
14
|
+
div role="article" aria-roledescription="email" lang=I18n.locale style="width:100%;"
|
|
15
|
+
container
|
|
16
|
+
= yield
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module ActiveMail
|
|
6
|
+
module Generators
|
|
7
|
+
class ViewsGenerator < ::Rails::Generators::Base
|
|
8
|
+
desc 'Copy ActiveMail default layout views into the host app for customization'
|
|
9
|
+
source_root File.expand_path('../../../app/views/layouts/active_mail', __dir__)
|
|
10
|
+
|
|
11
|
+
def copy_views
|
|
12
|
+
directory '.', File.join('app', 'views', 'layouts', 'active_mail')
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_mail'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
namespace :active_mail do
|
|
7
|
+
namespace :tokens do
|
|
8
|
+
desc 'Export design tokens to a static SCSS partial (for Propshaft apps that cannot preprocess .scss.erb)'
|
|
9
|
+
# :environment so the host initializer's config.tokens overrides are loaded.
|
|
10
|
+
task :export, [:path] => :environment do |_task, args|
|
|
11
|
+
path = args[:path] || 'app/assets/stylesheets/active_mail/_active_mail_tokens.scss'
|
|
12
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
13
|
+
File.write(path, ActiveMail.scss_variables)
|
|
14
|
+
puts "Wrote #{ActiveMail.tokens.colors.size + ActiveMail.tokens.fonts.size + ActiveMail.tokens.spacings.size} tokens to #{path}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
namespace :emails do
|
|
19
|
+
desc 'Render every host mailer preview to disk and run the quality guard on each'
|
|
20
|
+
task render_all: :environment do
|
|
21
|
+
require 'active_mail/quality/render_all'
|
|
22
|
+
|
|
23
|
+
config = ActiveMail::Quality.config
|
|
24
|
+
output_root = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join(config.output_dir) : Pathname(config.output_dir)
|
|
25
|
+
result = ActiveMail::Quality::RenderAll.new(output_root: output_root, config: config).call
|
|
26
|
+
puts "Rendered #{result.rendered} email(s) into #{output_root}"
|
|
27
|
+
# A green run on zero previews would silently verify nothing — make it visible.
|
|
28
|
+
warn '[activemail] WARNING: no mailer previews were discovered — nothing was checked.' if result.discovered.zero?
|
|
29
|
+
result.render_failures.each { |key, error| puts " render failed: #{key}: #{error}" }
|
|
30
|
+
result.guard_failures.each do |key, violations|
|
|
31
|
+
puts " guard failed: #{key}", violations.map { |v| " - [#{v.rule}] #{v.message}" }.join("\n")
|
|
32
|
+
end
|
|
33
|
+
abort "\n#{result.broken_required.size} required preview(s) failed: #{result.broken_required.join(', ')}" if result.broken_required.any?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|